Calendar view (#1174)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2022-03-18 23:07:42 +07:00 committed by GitHub
parent 4577878d0c
commit 1db5547318
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 654 additions and 15 deletions

View File

@ -25,6 +25,7 @@ import core, { TAttachedDoc } from '@anticrm/model-core'
import { TSpaceWithStates } from '@anticrm/model-task'
import workbench from '@anticrm/model-workbench'
import calendar from './plugin'
import view from '@anticrm/model-view'
export * from '@anticrm/calendar'
@ -35,6 +36,7 @@ export const DOMAIN_CALENDAR = 'calendar' as Domain
export class TCalendar extends TSpaceWithStates implements Calendar {}
@Model(calendar.class.Event, core.class.AttachedDoc, DOMAIN_CALENDAR)
@UX(calendar.string.Event, calendar.icon.Calendar)
export class TEvent extends TAttachedDoc implements Event {
@Prop(TypeString(), calendar.string.Title)
@Index(IndexKind.FullText)
@ -85,6 +87,22 @@ export function createModel (builder: Builder): void {
]
}
}, calendar.app.Calendar)
builder.createDoc(
view.class.ViewletDescriptor,
core.space.Model,
{
label: calendar.string.Calendar,
icon: calendar.icon.Calendar,
component: calendar.component.CalendarView
},
calendar.viewlet.Calendar
)
// Use generic child presenter
builder.mixin(calendar.class.Event, core.class.Class, view.mixin.AttributePresenter, {
presenter: view.component.ObjectPresenter
})
}
export default calendar

View File

@ -15,17 +15,22 @@
import { calendarId } from '@anticrm/calendar'
import calendar from '@anticrm/calendar-resources/src/plugin'
import { Ref } from '@anticrm/core'
import type { IntlString } from '@anticrm/platform'
import { mergeIds } from '@anticrm/platform'
import { AnyComponent } from '@anticrm/ui'
import { ViewletDescriptor } from '@anticrm/view'
export default mergeIds(calendarId, calendar, {
component: {
CreateCalendar: '' as AnyComponent
CreateCalendar: '' as AnyComponent,
CalendarView: '' as AnyComponent
},
string: {
ApplicationLabelCalendar: '' as IntlString
ApplicationLabelCalendar: '' as IntlString,
Event: '' as IntlString
},
space: {
viewlet: {
Calendar: '' as Ref<ViewletDescriptor>
}
})

View File

@ -81,6 +81,20 @@ export function createReviewModel (builder: Builder): void {
archived: false
}
})
builder.createDoc(view.class.Viewlet, core.space.Model, {
attachTo: recruit.class.Review,
descriptor: calendar.viewlet.Calendar,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
options: {
lookup: {
attachedTo: recruit.mixin.Candidate,
participants: contact.class.Employee,
company: contact.class.Organization
}
} as FindOptions<Doc>,
config: []
})
}
function createTableViewlet (builder: Builder): void {

View File

@ -16,6 +16,8 @@
"NotSelected": "Not selected",
"Today": "Today",
"English": "English",
"Russian": "Russian"
"Russian": "Russian",
"CalendarLeft": "<",
"CalendarRight": ">"
}
}

View File

@ -16,6 +16,8 @@
"NotSelected": "Не выбрано",
"Today": "Сегодня",
"English": "Английский",
"Russian": "Русский"
"Russian": "Русский",
"CalendarLeft": "<",
"CalendarRight": ">"
}
}

View File

@ -20,7 +20,7 @@
import Icon from './Icon.svelte'
import { onMount } from 'svelte'
export let label: IntlString
export let label: IntlString | undefined = undefined
export let primary: boolean = false
export let size: 'small' | 'medium' = 'medium'
export let icon: Asset | AnySvelteComponent | undefined = undefined
@ -49,7 +49,9 @@
{#if loading}
<Spinner />
{:else}
<Label {label} />
{#if label}
<Label {label} />
{/if}
{/if}
</button>

View File

@ -0,0 +1,111 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script type="ts">
import { areDatesEqual, day, firstDay, getWeekDayName, isWeekend, weekday } from './internal/DateUtils'
export let mondayStart = true
export let weekFormat: 'narrow' | 'short' | 'long' | undefined = 'short'
export let cellHeight: string | undefined = undefined
export let value: Date = new Date()
export let currentDate: Date = new Date()
export let displayedWeeksCount = 6
$: firstDayOfCurrentMonth = firstDay(currentDate, mondayStart)
function onSelect (date: Date) {
value = date
}
const todayDate = new Date()
</script>
<div class="month-calendar">
<div class="days-of-week-header">
{#each [...Array(7).keys()] as dayOfWeek}
<div class="day-name">{getWeekDayName(day(firstDayOfCurrentMonth, dayOfWeek), weekFormat)}</div>
{/each}
</div>
<div class="days-of-month">
{#each [...Array(displayedWeeksCount).keys()] as weekIndex}
{#each [...Array(7).keys()] as dayOfWeek}
<div style={`grid-column-start: ${dayOfWeek + 1}; grid-row-start: ${weekIndex + 1}`}>
<div style={`display: flex; width: 100%; height: ${cellHeight ? `${cellHeight};` : '100%;'}`}>
<div
class="cell flex-center"
class:weekend={isWeekend(weekday(firstDayOfCurrentMonth, weekIndex, dayOfWeek))}
class:today={areDatesEqual(todayDate, weekday(firstDayOfCurrentMonth, weekIndex, dayOfWeek))}
class:selected={weekday(firstDayOfCurrentMonth, weekIndex, dayOfWeek).getMonth() ===
currentDate.getMonth() && areDatesEqual(value, weekday(firstDayOfCurrentMonth, weekIndex, dayOfWeek))}
class:wrongMonth={weekday(firstDayOfCurrentMonth, weekIndex, dayOfWeek).getMonth() !==
currentDate.getMonth()}
on:click={() => onSelect(weekday(firstDayOfCurrentMonth, weekIndex, dayOfWeek))}
>
{#if !$$slots.cell || weekday(firstDayOfCurrentMonth, weekIndex, dayOfWeek).getMonth() !== currentDate.getMonth()}
{weekday(firstDayOfCurrentMonth, weekIndex, dayOfWeek).getDate()}
{:else}
<slot name="cell" date={weekday(firstDayOfCurrentMonth, weekIndex, dayOfWeek)} />
{/if}
</div>
</div>
</div>
{/each}
{/each}
</div>
</div>
<style lang="scss">
.day-name,
.selected-month-controller {
display: flex;
justify-content: center;
}
.days-of-week-header,
.days-of-month {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.weekend {
background-color: var(--theme-bg-accent-color);
}
.today {
color: #a66600;
}
.selected {
border-radius: 3px;
background-color: var(--primary-button-enabled);
border-color: var(--primary-button-focused-border);
color: var(--primary-button-color);
}
.cell {
height: 100%;
width: 100%;
border-radius: 0.5rem;
border: 1px solid transparent;
}
.cell:hover:not(.wrongMonth) {
border: 1px solid var(--primary-button-focused-border);
background-color: var(--primary-button-enabled);
color: var(--primary-button-color);
}
.wrongMonth {
color: var(--grayscale-grey-03);
}
.month-name {
font-size: 14px;
font-weight: bold;
margin: 0 5px;
color: var(--theme-content-dark-color);
}
</style>

View File

@ -0,0 +1,68 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script type="ts">
import MonthCalendar from './MonthCalendar.svelte'
/**
* If passed, calendars will use monday as first day
*/
export let mondayStart = true
export let value: Date = new Date()
export let currentDate: Date = new Date()
export let cellHeight: string | undefined = undefined
export let minWidth = '18rem'
function getMonthName (date: Date): string {
const locale = new Intl.NumberFormat().resolvedOptions().locale
return new Intl.DateTimeFormat(locale, { month: 'long' }).format(date)
}
function month (date: Date, m: number): Date {
date = new Date(date)
date.setMonth(m)
return date
}
</script>
<div class="year-erp-calendar">
{#each [...Array(12).keys()] as m}
<div class="antiComponentBox mt-2 mb-2 ml-2 mr-2 flex-grow" style={`min-width: ${minWidth};`}>
{getMonthName(month(value, m))}
<MonthCalendar {cellHeight} weekFormat="narrow" bind:value currentDate={month(currentDate, m)} {mondayStart}>
<!----> eslint-disable-next-line no-undef -->
<svelte:fragment slot="cell" let:date={date}>
<slot name="cell" date={date} />
</svelte:fragment>
</MonthCalendar>
</div>
{/each}
</div>
<style lang="scss">
.year-erp-calendar {
display: grid;
grid-template-columns: repeat(4, 1fr);
border-collapse: collapse;
.row {
display: table-row;
}
.th {
display: table-cell;
}
.calendar {
display: table-cell;
padding: 0.3em;
}
}
</style>

View File

@ -0,0 +1,69 @@
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
const DAYS_IN_WEEK = 7
const MILLISECONDS_IN_DAY = 86400000
export function firstDay (date: Date, mondayStart: boolean): Date {
const firstDayOfMonth = new Date(date)
firstDayOfMonth.setDate(1) // First day of month
const result = new Date(firstDayOfMonth)
result.setDate(
result.getDate() - result.getDay() + (mondayStart ? 1 : 0)
)
// Check if we need add one more week
if (result.getTime() > firstDayOfMonth.getTime()) {
result.setDate(result.getDate() - DAYS_IN_WEEK)
}
result.setHours(0)
result.setMinutes(0)
result.setSeconds(0)
result.setMilliseconds(0)
return result
}
export function getWeekDayName (weekDay: Date, weekFormat: 'narrow' | 'short' | 'long' | undefined = 'short'): string {
const locale = new Intl.NumberFormat().resolvedOptions().locale
return new Intl.DateTimeFormat(locale, {
weekday: weekFormat
}).format(weekDay)
}
export function day (firstDay: Date, offset: number): Date {
return new Date(firstDay.getTime() + offset * MILLISECONDS_IN_DAY)
}
export function weekday (firstDay: Date, w: number, d: number): Date {
return day(firstDay, w * DAYS_IN_WEEK + d)
}
export function areDatesEqual (firstDate: Date | undefined, secondDate: Date | undefined): boolean {
if (firstDate === undefined || secondDate === undefined) {
return false
}
return (
firstDate.getFullYear() === secondDate.getFullYear() &&
firstDate.getMonth() === secondDate.getMonth() &&
firstDate.getDate() === secondDate.getDate()
)
}
export function isWeekend (date: Date): boolean {
return date.getDay() === 0 || date.getDay() === 6
}
export function getMonthName (date: Date): string {
const locale = new Intl.NumberFormat().resolvedOptions().locale
return new Intl.DateTimeFormat(locale, { month: 'long' }).format(date)
}

View File

@ -116,3 +116,6 @@ addStringsLoader(uiId, async (lang: string) => {
export { default } from './plugin'
export * from './colors'
export { default as MonthCalendar } from './components/calendar/MonthCalendar.svelte'
export { default as YearCalendar } from './components/calendar/YearCalendar.svelte'

View File

@ -181,11 +181,11 @@
{/if}
{#if isMessageType(m.attribute)}
<div class="strong message emphasized">
<svelte:component this={m.presenter} {value} />
<svelte:component this={m.presenter} {value} attributeType={m.attribute?.type} />
</div>
{:else}
<div class="strong">
<svelte:component this={m.presenter} {value} />
<svelte:component this={m.presenter} {value} attributeType={m.attribute?.type} />
</div>
{/if}
{/if}
@ -206,11 +206,11 @@
</span>
{#if isMessageType(m.attribute)}
<div class="strong message emphasized">
<svelte:component this={m.presenter} {value} />
<svelte:component this={m.presenter} {value} attributeType={m.attribute?.type} />
</div>
{:else}
<div class="strong">
<svelte:component this={m.presenter} {value} />
<svelte:component this={m.presenter} {value} attributeType={m.attribute?.type} />
</div>
{/if}
{/if}

View File

@ -12,6 +12,14 @@
"Title": "Title",
"Location": "Location",
"Company": "Company",
"CreateCalendar": "Create Calendar"
"CreateCalendar": "Create Calendar",
"Calendar": "Calendar",
"Events": "Events",
"Event": "Event",
"ModeDay": "Day",
"ModeWeek": "Week",
"ModeMonth": "Month",
"ModeYear": "Year",
"Today": "Today"
}
}

View File

@ -12,6 +12,14 @@
"Title": "Название",
"Location": "Местоположение",
"Company": "Компания",
"CreateCalendar": "Новый Калеедарь"
"CreateCalendar": "Новый Календарь",
"Calendar": "Календарь",
"Events": "События",
"Event": "Событие",
"ModeDay": "День",
"ModeWeek": "Неделя",
"ModeMonth": "Месяц",
"ModeYear": "Год",
"Today": "Сегодня"
}
}

View File

@ -0,0 +1,228 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { Event } from '@anticrm/calendar'
import { Class, Doc, DocumentQuery, FindOptions, Ref, SortingOrder, Space } from '@anticrm/core'
import { createQuery } from '@anticrm/presentation'
import { Button, IconBack, IconForward, MonthCalendar, ScrollBox, YearCalendar } from '@anticrm/ui'
import calendar from '../plugin'
import Day from './Day.svelte'
export let _class: Ref<Class<Doc>>
export let space: Ref<Space> | undefined = undefined
export let query: DocumentQuery<Event> = {}
export let options: FindOptions<Event> | undefined = undefined
export let baseMenuClass: Ref<Class<Event>> | undefined = undefined
export let config: string[]
export let search: string = ''
const mondayStart = true
// Current selected day
let value: Date = new Date()
const sortKey = 'modifiedOn'
const sortOrder = SortingOrder.Descending
let loading = false
let resultQuery: DocumentQuery<Event>
$: resultQuery = search === '' ? { ...query, space } : { ...query, $search: search, space }
let objects: Event[] = []
const q = createQuery()
async function update (
_class: Ref<Class<Event>>,
query: DocumentQuery<Event>,
sortKey: string,
sortOrder: SortingOrder,
options?: FindOptions<Event>
) {
loading = true
q.query<Event>(
_class,
query,
(result) => {
objects = result
loading = false
},
{ sort: { [sortKey]: sortOrder }, ...options }
)
}
$: update(_class, resultQuery, sortKey, sortOrder, options)
function areDatesLess (firstDate: Date, secondDate: Date): boolean {
return (
firstDate.getFullYear() <= secondDate.getFullYear() &&
firstDate.getMonth() <= secondDate.getMonth() &&
firstDate.getDate() <= secondDate.getDate()
)
}
function findEvents (events: Event[], date: Date): Event[] {
return events.filter((it) => areDatesLess(new Date(it.date), date) && areDatesLess(date, new Date(it.dueDate ?? it.date)))
}
interface ShiftType {
yearShift: number
monthShift: number
dayShift: number
weekShift:number
}
let shifts: ShiftType = {
yearShift: 0,
monthShift: 0,
dayShift: 0,
weekShift: 0
}
let date = new Date()
function inc (val: number): void {
if (val === 0) {
shifts = {
yearShift: 0,
monthShift: 0,
dayShift: 0,
weekShift: 0
}
return
}
switch (mode) {
case CalendarMode.Day: {
shifts = { ...shifts, dayShift: val === 0 ? 0 : shifts.dayShift + val }
break
}
case CalendarMode.Week: {
shifts = { ...shifts, weekShift: val === 0 ? 0 : shifts.weekShift + val }
break
}
case CalendarMode.Month: {
shifts = { ...shifts, monthShift: val === 0 ? 0 : shifts.monthShift + val }
break
}
case CalendarMode.Year: {
shifts = { ...shifts, yearShift: val === 0 ? 0 : shifts.yearShift + val }
break
}
}
}
function getMonthName (date: Date): string {
const locale = new Intl.NumberFormat().resolvedOptions().locale
return new Intl.DateTimeFormat(locale, {
month: 'long'
}).format(date)
}
function currentDate (date: Date, shifts: ShiftType): Date {
const res = new Date(date)
res.setMonth(date.getMonth() + shifts.monthShift)
res.setFullYear(date.getFullYear() + shifts.yearShift)
res.setDate(date.getDate() + shifts.dayShift + shifts.weekShift * 7)
return res
}
enum CalendarMode { Day, Week, Month, Year }
let mode: CalendarMode = CalendarMode.Year
function label (date: Date, mode: CalendarMode): string {
switch (mode) {
case CalendarMode.Day: {
return `${date.getDate()} ${getMonthName(date)} ${date.getFullYear()}`
}
case CalendarMode.Week: {
return `${getMonthName(date)} ${date.getFullYear()}`
}
case CalendarMode.Month: {
return `${getMonthName(date)} ${date.getFullYear()}`
}
case CalendarMode.Year: {
return `${date.getFullYear()}`
}
}
}
</script>
<div class='fs-title ml-2 mb-2 flex-row-center'>
{label(currentDate(date, shifts), mode)}
</div>
<div class="flex gap-2 mb-4">
<!-- <Button
size={'small'}
label={calendar.string.ModeDay}
on:click={() => {
mode = CalendarMode.Day
}}
/>
<Button
size={'small'}
label={calendar.string.ModeWeek}
on:click={() => {
mode = CalendarMode.Week
}}
/> -->
<Button
size={'small'}
label={calendar.string.ModeMonth}
on:click={() => {
date = value
shifts = {
dayShift: 0,
monthShift: 0,
weekShift: 0,
yearShift: 0
}
mode = CalendarMode.Month
}}
/>
<Button
size={'small'}
label={calendar.string.ModeYear}
on:click={() => {
date = value
shifts = {
dayShift: 0,
monthShift: 0,
weekShift: 0,
yearShift: 0
}
mode = CalendarMode.Year
}}
/>
<div class="flex ml-4 gap-1">
<Button icon={IconBack} size={'small'} on:click={() => { inc(-1) } }/>
<Button size={'small'} label={calendar.string.Today} on:click={() => { inc(0) }}/>
<Button icon={IconForward} size={'small'} on:click={() => { inc(1) }}/>
</div>
</div>
<ScrollBox bothScroll>
{#if mode === CalendarMode.Year}
<YearCalendar {mondayStart} cellHeight={'2.5rem'} bind:value={value} currentDate={currentDate(date, shifts)}>
<svelte:fragment slot="cell" let:date>
<Day events={findEvents(objects, date)} {date} />
</svelte:fragment>
</YearCalendar>
{:else if mode === CalendarMode.Month}
<MonthCalendar {mondayStart} cellHeight={'5rem'} bind:value={value} currentDate={currentDate(date, shifts)}>
<svelte:fragment slot="cell" let:date={date}>
<Day events={findEvents(objects, date)} {date} size={'huge'}/>
</svelte:fragment>
</MonthCalendar>
{/if}
</ScrollBox>

View File

@ -0,0 +1,54 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { Event } from '@anticrm/calendar'
import { Tooltip } from '@anticrm/ui'
import calendar from '../plugin'
import EventsPopup from './EventsPopup.svelte'
export let events: Event[]
export let date: Date
export let size: 'small' | 'huge' = 'small'
</script>
{#if events.length > 0}
<Tooltip label={calendar.string.Events} component={EventsPopup} props={{ value: events }}>
<div class="cell" class:huge={size === 'huge'}>
{date.getDate()}
<div class="marker" />
</div>
</Tooltip>
{:else}
{date.getDate()}
{/if}
<style lang="scss">
.marker {
// position: relative;
top: -0.25rem;
width: 0.25rem;
height: 0.25rem;
border-radius: 50%;
background-color: var(--highlight-red);
}
.huge {
width: 5rem;
}
.cell {
display: flex;
flex-grow: 1;
justify-content: center;
}
</style>

View File

@ -0,0 +1,38 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import type { Event } from '@anticrm/calendar'
import core from '@anticrm/core'
import { Table } from '@anticrm/view-resources'
import calendar from '../plugin'
export let value: Event[]
</script>
<Table
_class={calendar.class.Event}
config={['', 'title', '$lookup.space.name', 'date', 'dueDate', 'modifiedOn']}
options={
{
lookup: {
space: core.class.Space
}
}
}
query={ { _id: { $in: value.map(it => it._id) } } }
loadingProps={{ length: value.length ?? 0 }}
/>

View File

@ -16,9 +16,11 @@
import { Resources } from '@anticrm/platform'
import PersonsPresenter from './components/PersonsPresenter.svelte'
import CalendarView from './components/CalendarView.svelte'
export default async (): Promise<Resources> => ({
component: {
PersonsPresenter
PersonsPresenter,
CalendarView
}
})

View File

@ -14,11 +14,17 @@
//
import calendar, { calendarId } from '@anticrm/calendar'
import { mergeIds } from '@anticrm/platform'
import { IntlString, mergeIds } from '@anticrm/platform'
export default mergeIds(calendarId, calendar, {
component: {
},
string: {
Events: '' as IntlString,
ModeDay: '' as IntlString,
ModeWeek: '' as IntlString,
ModeMonth: '' as IntlString,
ModeYear: '' as IntlString,
Today: '' as IntlString
}
})

View File

@ -6,6 +6,7 @@
"declaration": true,
"outDir": "./lib",
"strict": true,
"noImplicitAny": false,
"esModuleInterop": true,
"lib": [
"esnext",