Upcoming events (#1195)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2022-03-23 16:03:41 +07:00 committed by GitHub
parent 90955638c5
commit 6f7e95e233
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 408 additions and 84 deletions

View File

@ -41,7 +41,8 @@ import workbench from '@anticrm/model-workbench'
import { Applicant, Candidate, Candidates, Vacancy } from '@anticrm/recruit'
import { TOpinion, TReview, TReviewCategory } from './review-model'
import recruit from './plugin'
import { createReviewModel } from './review'
import { createReviewModel, reviewTableConfig, reviewTableOptions } from './review'
import calendar from '@anticrm/model-calendar'
@Model(recruit.class.Vacancy, task.class.SpaceWithStates)
@UX(recruit.string.Vacancy, recruit.icon.Vacancy)
@ -192,7 +193,18 @@ export function createModel (builder: Builder): void {
componentProps: {
labelTasks: recruit.string.Applications,
_class: recruit.class.Applicant
}
},
{
id: 'upcoming',
component: calendar.component.UpcomingEvents,
componentProps: {
_class: recruit.class.Review,
options: reviewTableOptions,
config: reviewTableConfig
},
icon: calendar.icon.Calendar,
label: calendar.string.UpcomingEvents,
position: 'top'
}
]

View File

@ -1,12 +1,38 @@
import { Doc, FindOptions } from '@anticrm/core'
import { FindOptions } from '@anticrm/core'
import { Builder } from '@anticrm/model'
import calendar from '@anticrm/model-calendar'
import contact from '@anticrm/model-contact'
import core from '@anticrm/model-core'
import task from '@anticrm/model-task'
import view from '@anticrm/model-view'
import workbench from '@anticrm/model-workbench'
import { Review } from '@anticrm/recruit'
import { BuildModelKey } from '@anticrm/view'
import recruit from './plugin'
import calendar from '@anticrm/model-calendar'
export const reviewTableOptions: FindOptions<Review> = {
lookup: {
attachedTo: recruit.mixin.Candidate,
participants: contact.class.Employee,
company: contact.class.Organization
}
}
export const reviewTableConfig: (BuildModelKey | string)[] = [
'',
'title',
'$lookup.attachedTo',
// 'verdict',
{ key: '', presenter: recruit.component.OpinionsPresenter, label: recruit.string.Opinions, sortingKey: 'opinions' },
{
key: '$lookup.participants',
presenter: calendar.component.PersonsPresenter,
label: calendar.string.Participants,
sortingKey: '$lookup.participants'
},
'$lookup.company',
{ key: '', presenter: calendar.component.DateTimePresenter, label: calendar.string.Date, sortingKey: 'date' },
'modifiedOn'
]
export function createReviewModel (builder: Builder): void {
builder.mixin(recruit.class.ReviewCategory, core.class.Class, workbench.mixin.SpaceView, {
@ -82,27 +108,20 @@ export function createReviewModel (builder: Builder): void {
}
})
const reviewOptions: FindOptions<Review> = {
lookup: {
attachedTo: recruit.mixin.Candidate,
participants: contact.class.Employee,
company: contact.class.Organization
}
}
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: [
'',
'title',
'$lookup.attachedTo',
'verdict',
{ key: '', presenter: recruit.component.OpinionsPresenter, label: recruit.string.Opinions, sortingKey: 'opinions' },
{ key: '$lookup.participants', presenter: calendar.component.PersonsPresenter, label: calendar.string.Participants, sortingKey: '$lookup.participants' },
'$lookup.company',
'modifiedOn'
]
options: reviewOptions,
config: reviewTableConfig
})
}
@ -111,25 +130,8 @@ function createTableViewlet (builder: Builder): void {
attachTo: recruit.class.Review,
descriptor: view.viewlet.Table,
// 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: [
'',
'title',
'$lookup.attachedTo',
'verdict',
{ key: '', presenter: recruit.component.OpinionsPresenter, label: recruit.string.Opinions, sortingKey: 'opinions' },
{ key: '$lookup.participants', presenter: calendar.component.PersonsPresenter, label: calendar.string.Participants, sortingKey: '$lookup.participants' },
'$lookup.company',
'date',
'dueDate',
'modifiedOn'
]
options: reviewTableOptions,
config: reviewTableConfig
})
builder.mixin(recruit.class.Opinion, core.class.Class, view.mixin.AttributeEditor, {

View File

@ -32,7 +32,7 @@ export type QuerySelector<T> = {
/**
* @public
*/
export type ObjQueryType<T> = T | QuerySelector<T>
export type ObjQueryType<T> = (T extends Array<infer U> ? U | U[] : T) | QuerySelector<T>
/**
* @public
@ -50,23 +50,26 @@ export type DocumentQuery<T extends Doc> = {
* @public
*/
export type ToClassRefT<T extends object, P extends keyof T> = T[P] extends Ref<infer X> | null | undefined ? Ref<Class<X>> | [Ref<Class<X>>, Lookup<X>] : never
/**
* @public
*/
export type ToClassRefTA<T extends object, P extends keyof T> = T[P] extends Array<Ref<infer X>> | null | undefined ? Ref<Class<X>> | [Ref<Class<X>>, Lookup<X>] : never
/**
* @public
*/
export type ToClassRef<T extends object> = {
[P in keyof T]?: ToClassRefT<T, P>
[P in keyof T]?: ToClassRefT<T, P> | ToClassRefTA<T, P>
}
/**
* @public
*/
export type RefKeys<T extends Doc> = Pick<T, KeysByType<T, NullableRef>>
export type NullableRef = Ref<Doc> | Array<Ref<Doc>> | null | undefined
/**
* @public
*/
export type NullableRef = Ref<Doc> | null | undefined
export type RefKeys<T extends Doc> = Pick<T, KeysByType<T, NullableRef>>
/**
* @public

View File

@ -116,7 +116,7 @@ export interface MoveDescriptor<X extends PropertyType> {
* @public
*/
export type ArrayAsElementPosition<T extends object> = {
[P in keyof T]: T[P] extends Arr<infer X> ? X | Position<X> : never
[P in keyof T]-?: T[P] extends Arr<infer X> ? X | Position<X> : never
}
/**

View File

@ -58,10 +58,18 @@ export const test = plugin('test' as Plugin, {
TestMixin: '' as Ref<Mixin<TestMixin>>
},
class: {
TestComment: '' as Ref<Class<AttachedComment>>
TestComment: '' as Ref<Class<AttachedComment>>,
ParticipantsHolder: '' as Ref<Class<ParticipantsHolder>>
}
})
/**
* @public
*/
export interface ParticipantsHolder extends Doc {
participants?: Ref<Doc>[]
}
const DOMAIN_TEST: Domain = 'test' as Domain
/**
@ -90,6 +98,8 @@ export function genMinModel (): TxCUD<Doc>[] {
txes.push(createClass(test.class.TestComment, { label: 'TestComment' as IntlString, extends: core.class.AttachedDoc, kind: ClassifierKind.CLASS, domain: DOMAIN_TEST }))
txes.push(createClass(test.class.ParticipantsHolder, { label: 'ParticipantsHolder' as IntlString, extends: core.class.Doc, kind: ClassifierKind.CLASS, domain: DOMAIN_TEST }))
const u1 = 'User1' as Ref<Account>
const u2 = 'User2' as Ref<Account>
txes.push(

View File

@ -14,9 +14,9 @@
//
import core, { createClient, Doc, generateId, Ref, SortingOrder, Space, Tx, TxCreateDoc, TxOperations, WithLookup } from '@anticrm/core'
import { AttachedComment, test, genMinModel } from './minmodel'
import { LiveQuery } from '..'
import { connect } from './connection'
import { AttachedComment, genMinModel, ParticipantsHolder, test } from './minmodel'
interface Channel extends Space {
x: number
@ -49,12 +49,12 @@ describe('query', () => {
}
}
await new Promise((resolve) => {
const result = await new Promise((resolve) => {
liveQuery.query<Space>(core.class.Space, { private: false }, (result) => {
expect(result).toHaveLength(expectedLength)
resolve(null)
resolve(result)
})
})
expect(result).toHaveLength(expectedLength)
})
it('query should be live', async () => {
@ -743,4 +743,51 @@ describe('query', () => {
// }
// await pp
// })
it('update-array-value', async () => {
const { liveQuery, factory } = await getClient()
const spaces = await liveQuery.findAll(core.class.Space, {})
await factory.createDoc(test.class.ParticipantsHolder, spaces[0]._id, {
participants: ['a' as Ref<Doc>]
})
const a2 = await factory.createDoc(test.class.ParticipantsHolder, spaces[0]._id, {
participants: ['b' as Ref<Doc>]
})
const holderBefore = await liveQuery.findAll(test.class.ParticipantsHolder, { participants: 'a' as Ref<Doc> })
expect(holderBefore.length).toEqual(1)
let attempt = 0
let resolvePpv: (value: Doc[] | PromiseLike<Doc[]>) => void
const resolveP = new Promise<Doc[]>((resolve) => {
resolvePpv = resolve
})
const pp = await new Promise((resolve) => {
liveQuery.query<Space>(
test.class.ParticipantsHolder,
{ participants: 'a' as Ref<Doc> },
(result) => {
if (attempt > 0) {
resolvePpv(result)
} else {
resolve(null)
}
},
{ sort: { private: SortingOrder.Ascending } }
)
})
await pp // We have first value returned
attempt++
await factory.updateDoc<ParticipantsHolder>(test.class.ParticipantsHolder, spaces[0]._id, a2, {
$push: {
participants: 'a' as Ref<Doc>
}
})
const result = await resolveP
expect(result.length).toEqual(2)
})
})

View File

@ -338,9 +338,19 @@ export class LiveQuery extends TxProcessor implements Client {
return false
}
const doc: Doc = {
_id: tx.objectId,
_class: tx.objectClass,
modifiedBy: tx.modifiedBy,
modifiedOn: tx.modifiedOn,
space: tx.objectSpace
}
TxProcessor.updateDoc2Doc(doc, tx)
for (const key in q.query) {
const value = (q.query as any)[key]
const res = findProperty([tx.operations as unknown as Doc], key, value)
const res = findProperty([doc], key, value)
if (res.length === 1) {
return true
}

View File

@ -20,6 +20,11 @@
"ModeWeek": "Week",
"ModeMonth": "Month",
"ModeYear": "Year",
"Today": "Today"
"Today": "Today",
"UpcomingEvents": "Upcoming events",
"TableView": "Table",
"DueMinutes": "{minutes, plural, =0 {less than a minute} =1 {a minute} other {# minutes}}",
"DueHours": "{hours, plural, =0 {less than an hour} =1 {1 hour} other {# hours}}",
"DueDays": "{days, plural, =0 {today} =1 {1 day} other {# days}}"
}
}

View File

@ -20,6 +20,11 @@
"ModeWeek": "Неделя",
"ModeMonth": "Месяц",
"ModeYear": "Год",
"Today": "Сегодня"
"Today": "Сегодня",
"UpcomingEvents": "Предстоящие события",
"TableView": "Таблица",
"DueMinutes": "{minutes, plural, =0 {меньше минуты} =1 {минута} other {# минут}}",
"DueHours": "{hours, plural, =0 {меньше часа} =1 {1 час} other {# часы}}",
"DueDays": "{days, plural, =0 {сегодня} =1 {1 день} other {# дня}}"
}
}

View File

@ -38,7 +38,8 @@
let loading = false
let resultQuery: DocumentQuery<Event>
$: resultQuery = search === '' ? { ...query, space } : { ...query, $search: search, space }
$: spaceOpt = (space ? { space } : {})
$: resultQuery = search === '' ? { ...query, ...spaceOpt } : { ...query, $search: search, ...spaceOpt }
let objects: Event[] = []
@ -73,14 +74,16 @@
}
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)))
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
weekShift: number
}
let shifts: ShiftType = {
@ -134,7 +137,12 @@
return res
}
enum CalendarMode { Day, Week, Month, Year }
enum CalendarMode {
Day,
Week,
Month,
Year
}
let mode: CalendarMode = CalendarMode.Year
@ -156,7 +164,7 @@
}
</script>
<div class='fs-title ml-2 mb-2 flex-row-center'>
<div class="fs-title ml-2 mb-2 flex-row-center">
{label(currentDate(date, shifts), mode)}
</div>
@ -202,29 +210,63 @@
}
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) }}/>
<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>
{#if mode === CalendarMode.Year}
<ScrollBox bothScroll>
<YearCalendar {mondayStart} cellHeight={'2.5rem'} bind:value={value} currentDate={currentDate(date, shifts)}>
<ScrollBox bothScroll>
<YearCalendar {mondayStart} cellHeight={'2.5rem'} bind:value currentDate={currentDate(date, shifts)}>
<svelte:fragment slot="cell" let:date>
<Day events={findEvents(objects, date)} {date} {_class} {baseMenuClass} {options} {config} query={resultQuery} />
<Day
events={findEvents(objects, date)}
{date}
{_class}
{baseMenuClass}
{options}
{config}
query={resultQuery}
/>
</svelte:fragment>
</YearCalendar>
</ScrollBox>
{:else if mode === CalendarMode.Month}
<div class='flex flex-grow'>
<MonthCalendar {mondayStart} cellHeight={'8.5rem'} bind:value={value} currentDate={currentDate(date, shifts)}>
<svelte:fragment slot="cell" let:date={date}>
<Day events={findEvents(objects, date)} {date} size={'huge'} {_class} {baseMenuClass} {options} {config} query={resultQuery}/>
</svelte:fragment>
</MonthCalendar>
</div>
{/if}
{:else if mode === CalendarMode.Month}
<div class="flex flex-grow">
<MonthCalendar {mondayStart} cellHeight={'8.5rem'} bind:value currentDate={currentDate(date, shifts)}>
<svelte:fragment slot="cell" let:date>
<Day
events={findEvents(objects, date)}
{date}
size={'huge'}
{_class}
{baseMenuClass}
{options}
{config}
query={resultQuery}
/>
</svelte:fragment>
</MonthCalendar>
</div>
{/if}

View File

@ -0,0 +1,59 @@
<!--
// 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 { translate } from '@anticrm/platform'
import { DatePresenter } from '@anticrm/ui'
import calendar from '../plugin'
export let value: Event
$: date = value ? new Date(value.date) : undefined
$: dueDate = value ? new Date(value.dueDate ?? value.date) : undefined
$: interval = (value.dueDate ?? value.date) - value.date
const SECOND = 1000
const MINUTE = SECOND * 60
const HOUR = MINUTE * 60
const DAY = HOUR * 24
async function formatDueDate (interval: number): Promise<string> {
let passed = interval
if (interval < 0) passed = 0
if (passed < HOUR) {
return await translate(calendar.string.DueMinutes, { minutes: Math.floor(passed / MINUTE) })
} else if (passed < DAY) {
return await translate(calendar.string.DueHours, { hours: Math.floor(passed / HOUR) })
} else {
return await translate(calendar.string.DueDays, { days: Math.floor(passed / DAY) })
}
}
</script>
<div class="antiSelect">
{#if date}
<DatePresenter value={date} withTime={date.getMinutes() !== 0 && date.getHours() !== 0 && interval < DAY} />
{#if interval > 0}
{#await formatDueDate(interval) then t}
<span class='ml-2 mr-1 whitespace-nowrap'>({t})</span>
{/await}
{/if}
{:else}
No date
{/if}
</div>

View File

@ -0,0 +1,116 @@
<!--
// 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 { EmployeeAccount } from '@anticrm/contact'
import { Class, DocumentQuery, FindOptions, getCurrentAccount, Ref } from '@anticrm/core'
import { Asset, IntlString } from '@anticrm/platform'
import { AnySvelteComponent, Icon, Label, SearchEdit, Tooltip } from '@anticrm/ui'
import { Table } from '@anticrm/view-resources'
import view from '@anticrm/view'
import calendar from '../plugin'
import CalendarView from './CalendarView.svelte'
export let _class: Ref<Class<Event>>
export let query: DocumentQuery<Event> = {}
export let options: FindOptions<Event> | undefined = undefined
export let baseMenuClass: Ref<Class<Event>> | undefined = undefined
export let config: string[]
const currentUser = getCurrentAccount() as EmployeeAccount
let search = ''
let resultQuery: DocumentQuery<Event> = {}
function updateResultQuery (search: string): void {
resultQuery = search === '' ? { ...query } : { ...query, $search: search }
resultQuery.participants = currentUser.employee
}
$: updateResultQuery(search)
interface CalendarViewlet {
component: AnySvelteComponent
props: Record<string, any>
label: IntlString
icon: Asset
}
$: viewlets = [{
component: CalendarView,
icon: calendar.icon.Calendar,
label: calendar.string.Calendar,
props: {
_class,
space: undefined,
query: resultQuery,
options,
baseMenuClass,
config,
search
}
},
{
component: Table,
icon: view.icon.Table,
label: calendar.string.TableView,
props: {
_class,
query: resultQuery,
options,
baseMenuClass,
config,
search
}
}] as CalendarViewlet[]
let selectedViewlet = 0
</script>
<div class="ac-header full">
<div class="ac-header__wrap-title">
<div class="ac-header__icon"><Icon icon={calendar.icon.Calendar} size={'small'} /></div>
<span class="ac-header__title"><Label label={calendar.string.UpcomingEvents} /></span>
</div>
{#if viewlets.length > 1}
<div class="flex">
{#each viewlets as viewlet, i}
<Tooltip label={viewlet.label} direction={'top'}>
<button
class="ac-header__icon-button"
class:selected={selectedViewlet === i}
on:click={() => {
selectedViewlet = i
}}
>
<Icon icon={viewlet.icon} size={'small'} />
</button>
</Tooltip>
{/each}
</div>
{/if}
<SearchEdit
bind:value={search}
on:change={() => {
updateResultQuery(search)
}}
/>
</div>
{#if viewlets[selectedViewlet]}
<svelte:component this={viewlets[selectedViewlet].component} {...(viewlets[selectedViewlet].props)} />
{/if}

View File

@ -17,10 +17,14 @@ import { Resources } from '@anticrm/platform'
import PersonsPresenter from './components/PersonsPresenter.svelte'
import CalendarView from './components/CalendarView.svelte'
import UpcomingEvents from './components/UpcomingEvents.svelte'
import DateTimePresenter from './components/DateTimePresenter.svelte'
export default async (): Promise<Resources> => ({
component: {
PersonsPresenter,
CalendarView
CalendarView,
UpcomingEvents,
DateTimePresenter
}
})

View File

@ -25,6 +25,11 @@ export default mergeIds(calendarId, calendar, {
ModeWeek: '' as IntlString,
ModeMonth: '' as IntlString,
ModeYear: '' as IntlString,
Today: '' as IntlString
Today: '' as IntlString,
UpcomingEvents: '' as IntlString,
TableView: '' as IntlString,
DueMinutes: '' as IntlString,
DueHours: '' as IntlString,
DueDays: '' as IntlString
}
})

View File

@ -69,7 +69,9 @@ const calendarPlugin = plugin(calendarId, {
Calendar: '' as Ref<Doc>
},
component: {
PersonsPresenter: '' as AnyComponent
PersonsPresenter: '' as AnyComponent,
UpcomingEvents: '' as AnyComponent,
DateTimePresenter: '' as AnyComponent
},
string: {
Title: '' as IntlString,

View File

@ -13,10 +13,10 @@
// limitations under the License.
-->
<script lang="ts">
import type { Contact, Organization, Person } from '@anticrm/contact'
import type { Contact, EmployeeAccount, Organization, Person } from '@anticrm/contact'
import contact from '@anticrm/contact'
import { OrganizationSelector } from '@anticrm/contact-resources'
import { Account, Class, Client, Doc, generateId, Ref } from '@anticrm/core'
import { Account, Class, Client, Doc, generateId, getCurrentAccount, Ref } from '@anticrm/core'
import { getResource, OK, Resource, Severity, Status } from '@anticrm/platform'
import { Card, getClient, UserBox } from '@anticrm/presentation'
import type { Candidate, Review } from '@anticrm/recruit'
@ -32,6 +32,8 @@
export let preserveCandidate = false
const currentUser = getCurrentAccount() as EmployeeAccount
let status: Status = OK
let title: string = ''
@ -56,7 +58,8 @@
description,
company,
verdict: '',
title
title,
participants: [currentUser.employee]
}
const dispatch = createEventDispatcher()

View File

@ -16,7 +16,7 @@
import type { Doc, Ref } from '@anticrm/core'
import core from '@anticrm/core'
import { IntlString } from '@anticrm/platform'
import task from '@anticrm/task'
import calendar from '@anticrm/calendar'
import { CircleButton, IconAdd, Label, showPopup } from '@anticrm/ui'
import { Table } from '@anticrm/view-resources'
import recruit from '../../plugin'
@ -50,8 +50,7 @@
label: recruit.string.Opinions,
sortingKey: 'opinions'
},
'date',
'dueDate'
{ key: '', presenter: calendar.component.DateTimePresenter, label: calendar.string.Date, sortingKey: 'date' },
]}
options={{
lookup: {