From 7f03c1800742bb2f08adff865ace13e87af03547 Mon Sep 17 00:00:00 2001 From: Andrey Sobolev <haiodo@users.noreply.github.com> Date: Mon, 27 Nov 2023 22:49:53 +0700 Subject: [PATCH] UBERF-4413: Kanban with huge data sets (#4076) Signed-off-by: Andrey Sobolev <haiodo@gmail.com> --- packages/kanban/src/components/Kanban.svelte | 47 +++- .../kanban/src/components/KanbanRow.svelte | 118 +++++--- .../src/components/KanbanView.svelte | 38 ++- .../src/components/kanban/KanbanView.svelte | 251 +++++++++--------- plugins/task-resources/src/index.ts | 1 + plugins/task-resources/src/utils.ts | 84 ++++++ .../src/components/issues/KanbanView.svelte | 208 ++++++++------- plugins/view-resources/src/plugin.ts | 1 - plugins/view-resources/src/utils.ts | 56 ++++ plugins/view/src/index.ts | 3 +- 10 files changed, 513 insertions(+), 294 deletions(-) create mode 100644 plugins/task-resources/src/utils.ts diff --git a/packages/kanban/src/components/Kanban.svelte b/packages/kanban/src/components/Kanban.svelte index 256f668427..6a19f658c1 100644 --- a/packages/kanban/src/components/Kanban.svelte +++ b/packages/kanban/src/components/Kanban.svelte @@ -13,16 +13,33 @@ // limitations under the License. --> <script lang="ts"> - import { CategoryType, Doc, DocumentUpdate, Ref } from '@hcengineering/core' + import { + CategoryType, + Class, + Doc, + DocumentQuery, + DocumentUpdate, + FindOptions, + RateLimitter, + Ref, + Space + } from '@hcengineering/core' import { getClient } from '@hcengineering/presentation' import { ScrollBox, Scroller } from '@hcengineering/ui' import { createEventDispatcher } from 'svelte' - import { CardDragEvent, Item } from '../types' + import { CardDragEvent, DocWithRank, Item } from '../types' import { calcRank } from '../utils' import KanbanRow from './KanbanRow.svelte' export let categories: CategoryType[] = [] - export let objects: Item[] = [] + + export let _class: Ref<Class<DocWithRank>> + export let space: Ref<Space> | undefined = undefined + export let query: DocumentQuery<DocWithRank> = {} + export let options: FindOptions<DocWithRank> | undefined = undefined + export let objects: DocWithRank[] = [] + export let groupByKey: any + export let groupByDocs: Record<string | number, Item[]> export let getGroupByValues: (groupByDocs: Record<string | number, Item[]>, category: CategoryType) => Item[] export let setGroupByValues: ( @@ -40,7 +57,9 @@ const dispatch = createEventDispatcher() - async function move (state: CategoryType) { + const limiter = new RateLimitter(() => ({ rate: 10 })) + + async function move (state: CategoryType): Promise<void> { if (dragCard === undefined) { return } @@ -48,13 +67,18 @@ const canDrop = !dragCardAvailableCategories || dragCardAvailableCategories.includes(state) if (!canDrop) { + dragCard = undefined + dragCardAvailableCategories = undefined return } let updates = getUpdateProps(dragCard, state) if (updates === undefined) { + console.log('no update props') panelDragLeave(undefined, dragCardState) + dragCard = undefined + dragCardAvailableCategories = undefined return } @@ -101,6 +125,7 @@ } const updates = getUpdateProps(dragCard, state) + console.log('UPD', updates) if (updates === undefined) { return } @@ -185,7 +210,7 @@ } isDragging = false } - async function onDragStart (object: Item, state: CategoryType) { + async function onDragStart (object: Item, state: CategoryType): Promise<void> { dragCardInitialState = state dragCardState = state dragCardInitialRank = object.rank @@ -306,7 +331,7 @@ $: checkedSet = new Set<Ref<Doc>>(checked.map((it) => it._id)) - export function check (docs: Doc[], value: boolean) { + export function check (docs: Doc[], value: boolean): void { dispatch('check', { docs, value }) } const showMenu = async (evt: MouseEvent, object: Item): Promise<void> => { @@ -333,8 +358,9 @@ panelDragOver(event, state) }} on:drop={() => { - move(state) - isDragging = false + void move(state).then(() => { + isDragging = false + }) }} > {#if $$slots.header !== undefined} @@ -354,6 +380,7 @@ {selection} {checkedSet} {state} + {limiter} cardDragOver={(evt, obj) => { cardDragOver(evt, obj, state) }} @@ -362,6 +389,10 @@ }} {onDragStart} {showMenu} + {_class} + {query} + {options} + {groupByKey} > <svelte:fragment slot="card" let:object let:dragged> <slot name="card" {object} {dragged} /> diff --git a/packages/kanban/src/components/KanbanRow.svelte b/packages/kanban/src/components/KanbanRow.svelte index 2636d8152b..70d0febf5d 100644 --- a/packages/kanban/src/components/KanbanRow.svelte +++ b/packages/kanban/src/components/KanbanRow.svelte @@ -13,11 +13,23 @@ // limitations under the License. --> <script lang="ts"> - import { CategoryType, Doc, Ref } from '@hcengineering/core' + import { + CategoryType, + Class, + Doc, + DocumentQuery, + FindOptions, + IdMap, + RateLimitter, + Ref, + Space, + toIdMap + } from '@hcengineering/core' import ui, { Button, IconMoreH, Lazy, mouseAttractor } from '@hcengineering/ui' import { createEventDispatcher } from 'svelte' import { slide } from 'svelte/transition' import { CardDragEvent, DocWithRank, Item } from '../types' + import { createQuery } from '@hcengineering/presentation' export let stateObjects: Item[] export let isDragging: boolean @@ -27,6 +39,13 @@ export let checkedSet: Set<Ref<Doc>> export let state: CategoryType + export let _class: Ref<Class<DocWithRank>> + export let space: Ref<Space> | undefined = undefined + export let query: DocumentQuery<DocWithRank> = {} + export let options: FindOptions<DocWithRank> | undefined = undefined + export let groupByKey: any + export let limiter: RateLimitter + export let cardDragOver: (evt: CardDragEvent, object: Item) => void export let cardDrop: (evt: CardDragEvent, object: Item) => void export let onDragStart: (object: Item, state: CategoryType) => void @@ -52,56 +71,73 @@ let limit = 50 - let limitedObjects: DocWithRank[] = [] - $: limitedObjects = stateObjects.slice(0, limit) + let limitedObjects: IdMap<DocWithRank> = new Map() + + const docQuery = createQuery() + + $: groupQuery = { ...query, [groupByKey]: typeof state === 'object' ? { $in: state.values } : state } + + $: void limiter.add(async () => { + docQuery.query( + _class, + groupQuery, + (res) => { + limitedObjects = toIdMap(res) + }, + { ...options, limit } + ) + }) </script> -{#each limitedObjects as object, i (object._id)} - {@const dragged = isDragging && object._id === dragCard?._id} - <!-- svelte-ignore a11y-no-static-element-interactions --> - <div - bind:this={stateRefs[i]} - transition:slideD|local={{ isDragging }} - class="p-1 flex-no-shrink border-radius-1 clear-mins" - on:dragover|preventDefault={(evt) => { - cardDragOver(evt, object) - }} - on:drop|preventDefault={(evt) => { - cardDrop(evt, object) - }} - > +{#each stateObjects as objectRef, i (objectRef._id)} + {@const dragged = isDragging && objectRef._id === dragCard?._id} + {@const object = limitedObjects.get(objectRef._id) ?? (objectRef._id === dragCard?._id ? dragCard : undefined)} + {#if object !== undefined} + <!-- svelte-ignore a11y-no-static-element-interactions --> <div - class="card-container" - class:selection={selection !== undefined ? objects[selection]?._id === object._id : false} - class:checked={checkedSet.has(object._id)} - on:mouseover={mouseAttractor(() => dispatch('obj-focus', object))} - on:mouseenter={mouseAttractor(() => dispatch('obj-focus', object))} - on:focus={() => {}} - on:contextmenu={(evt) => { - showMenu(evt, object) + bind:this={stateRefs[i]} + transition:slideD|local={{ isDragging }} + class="p-1 flex-no-shrink border-radius-1 clear-mins" + on:dragover|preventDefault={(evt) => { + cardDragOver(evt, object) }} - draggable={true} - class:draggable={true} - on:dragstart - on:dragend - class:dragged - on:dragstart={() => { - onDragStart(object, state) - }} - on:dragend={() => { - isDragging = false + on:drop|preventDefault={(evt) => { + cardDrop(evt, object) }} > - <Lazy> - <slot name="card" object={toAny(object)} {dragged} /> - </Lazy> + <div + class="card-container" + class:selection={selection !== undefined ? objects[selection]?._id === object._id : false} + class:checked={checkedSet.has(object._id)} + on:mouseover={mouseAttractor(() => dispatch('obj-focus', object))} + on:mouseenter={mouseAttractor(() => dispatch('obj-focus', object))} + on:focus={() => {}} + on:contextmenu={(evt) => { + showMenu(evt, object) + }} + draggable={true} + class:draggable={true} + on:dragstart + on:dragend + class:dragged + on:dragstart={() => { + onDragStart(object, state) + }} + on:dragend={() => { + isDragging = false + }} + > + <Lazy> + <slot name="card" object={toAny(object)} {dragged} /> + </Lazy> + </div> </div> - </div> + {/if} {/each} -{#if stateObjects.length > limitedObjects.length} +{#if stateObjects.length > limitedObjects.size + (isDragging ? 1 : 0)} <div class="p-1 flex-no-shrink clear-mins"> <div class="card-container flex-between p-4"> - <span class="caption-color">{limitedObjects.length}</span> / {stateObjects.length} + <span class="caption-color">{limitedObjects.size}</span> / {stateObjects.length} <Button size={'small'} icon={IconMoreH} diff --git a/plugins/board-resources/src/components/KanbanView.svelte b/plugins/board-resources/src/components/KanbanView.svelte index 696c1fffc1..b04fc3653b 100644 --- a/plugins/board-resources/src/components/KanbanView.svelte +++ b/plugins/board-resources/src/components/KanbanView.svelte @@ -15,7 +15,7 @@ --> <script lang="ts"> import board, { Card } from '@hcengineering/board' - import core, { + import { CategoryType, Class, Doc, @@ -23,23 +23,25 @@ DocumentUpdate, FindOptions, Ref, - SortingOrder, Status, WithLookup } from '@hcengineering/core' import { Kanban as KanbanUI } from '@hcengineering/kanban' import { ActionContext, createQuery } from '@hcengineering/presentation' import type { DocWithRank, Project } from '@hcengineering/task' - import task from '@hcengineering/task' + import task, { getStates } from '@hcengineering/task' import { getEventPositionElement, showPopup } from '@hcengineering/ui' + import { typeStore } from '@hcengineering/task-resources' import { ContextMenu, ListSelectionProvider, SelectDirection, focusStore, + getCategoryQueryNoLookup, getGroupByValues, groupBy, - setGroupByValues + setGroupByValues, + statusStore } from '@hcengineering/view-resources' import { onMount } from 'svelte' import KanbanCard from './KanbanCard.svelte' @@ -59,21 +61,8 @@ _space = result[0] }) - const statesQuery = createQuery() - $: if (_space !== undefined) { - statesQuery.query( - core.class.Status, - { space: _space.type }, - (result) => { - states = result - }, - { - sort: { - rank: SortingOrder.Ascending - } - } - ) - } + $: states = getStates(_space, $typeStore, $statusStore.byId) + function castObject (object: any): WithLookup<Card> { return object as WithLookup<Card> } @@ -97,6 +86,8 @@ showPopup(ContextMenu, { object }, getEventPositionElement(ev)) } + let resultQuery: DocumentQuery<DocWithRank> + $: resultQuery = { ...query, isArchived: { $nin: [true] }, space } const cardQuery = createQuery() @@ -104,7 +95,7 @@ $: cardQuery.query<DocWithRank>( _class, - resultQuery, + getCategoryQueryNoLookup(resultQuery), (result) => { cards = result }, @@ -122,7 +113,7 @@ return undefined } return { - state: groupValue, + status: groupValue, space: doc.space } as any } @@ -133,6 +124,7 @@ mode: 'browser' }} /> +{states.length} <KanbanUI bind:this={kanbanUI} objects={cards} @@ -144,6 +136,10 @@ }} {groupByDocs} {getUpdateProps} + {_class} + query={resultQuery} + {options} + groupByKey={'status'} checked={$selection ?? []} on:check={(evt) => { listProvider.updateSelection(evt.detail.docs, evt.detail.value) diff --git a/plugins/task-resources/src/components/kanban/KanbanView.svelte b/plugins/task-resources/src/components/kanban/KanbanView.svelte index 89725bc01c..1bdafc6bec 100644 --- a/plugins/task-resources/src/components/kanban/KanbanView.svelte +++ b/plugins/task-resources/src/components/kanban/KanbanView.svelte @@ -22,13 +22,15 @@ DocumentUpdate, FindOptions, generateId, + Lookup, mergeQueries, Ref } from '@hcengineering/core' - import { Item, Kanban as KanbanUI } from '@hcengineering/kanban' + import { DocWithRank, Item, Kanban as KanbanUI } from '@hcengineering/kanban' import { getResource } from '@hcengineering/platform' import { ActionContext, createQuery, getClient } from '@hcengineering/presentation' - import { Project, Task, TaskGrouping, TaskOrdering } from '@hcengineering/task' + import tags from '@hcengineering/tags' + import { Project, Task, TaskOrdering } from '@hcengineering/task' import { ColorDefinition, defaultBackground, @@ -37,19 +39,12 @@ showPopup, themeStore } from '@hcengineering/ui' - import { - AttributeModel, - BuildModelKey, - CategoryOption, - Viewlet, - ViewOptionModel, - ViewOptions, - ViewQueryOption - } from '@hcengineering/view' + import view, { AttributeModel, BuildModelKey, Viewlet, ViewOptionModel, ViewOptions } from '@hcengineering/view' import { focusStore, - getCategories, - getCategorySpaces, + getCategoryQueryNoLookup, + getCategoryQueryNoLookupOptions, + getCategoryQueryProjection, getGroupByValues, getPresenter, groupBy, @@ -59,9 +54,9 @@ SelectDirection, setGroupByValues } from '@hcengineering/view-resources' - import view from '@hcengineering/view-resources/src/plugin' import { onMount } from 'svelte' import task from '../../plugin' + import { getTaskKanbanResultQuery, updateTaskKanbanCategories } from '../../utils' import KanbanDragDone from './KanbanDragDone.svelte' export let _class: Ref<Class<Task>> @@ -72,42 +67,36 @@ export let viewOptions: ViewOptions export let viewlet: Viewlet export let config: (string | BuildModelKey)[] + export let options: FindOptions<DocWithRank> | undefined = undefined - export let options: FindOptions<Task> | undefined - - $: groupByKey = (viewOptions.groupBy[0] ?? noCategory) as TaskGrouping + $: groupByKey = viewOptions.groupBy[0] ?? noCategory $: orderBy = viewOptions.orderBy - $: sort = { [orderBy[0]]: orderBy[1] } - - $: dontUpdateRank = orderBy[0] !== TaskOrdering.Manual let accentColors = new Map<string, ColorDefinition>() - const setAccentColor = (n: number, ev: CustomEvent<ColorDefinition>) => { + const setAccentColor = (n: number, ev: CustomEvent<ColorDefinition>): void => { accentColors.set(`${n}${$themeStore.dark}${groupByKey}`, ev.detail) accentColors = accentColors } + $: dontUpdateRank = orderBy[0] !== TaskOrdering.Manual + let resultQuery: DocumentQuery<any> = { ...query } - $: getResultQuery(query, viewOptionsConfig, viewOptions).then((p) => (resultQuery = mergeQueries(p, query))) - const client = getClient() - const hierarchy = client.getHierarchy() - async function getResultQuery ( - query: DocumentQuery<Task>, - viewOptions: ViewOptionModel[] | undefined, - viewOptionsStore: ViewOptions - ): Promise<DocumentQuery<Task>> { - if (viewOptions === undefined) return query - let result = hierarchy.clone(query) - for (const viewOption of viewOptions) { - if (viewOption.actionTarget !== 'query') continue - const queryOption = viewOption as ViewQueryOption - const f = await getResource(queryOption.action) - result = f(viewOptionsStore[queryOption.key] ?? queryOption.defaultValue, query) + $: void getTaskKanbanResultQuery(client.getHierarchy(), query, viewOptionsConfig, viewOptions).then((p) => { + resultQuery = mergeQueries(p, query) + }) + + $: queryNoLookup = getCategoryQueryNoLookup(resultQuery) + const lookup: Lookup<Task> = { + ...(options?.lookup ?? {}), + space: task.class.Project, + status: core.class.Status, + _id: { + labels: tags.class.TagReference } - return result } + $: resultOptions = { ...options, lookup, ...(orderBy !== undefined ? { sort: { [orderBy[0]]: orderBy[1] } } : {}) } let kanbanUI: KanbanUI const listProvider = new ListSelectionProvider((offset: 1 | -1 | 0, of?: Doc, dir?: SelectDirection) => { @@ -123,79 +112,96 @@ ev.preventDefault() showPopup(Menu, { object: items, baseMenuClass }, getEventPositionElement(ev)) } - const issuesQuery = createQuery() - let tasks: Task[] = [] + // Category information only + let tasks: DocWithRank[] = [] $: groupByDocs = groupBy(tasks, groupByKey, categories) - $: issuesQuery.query<Task>( - _class, - resultQuery, - (result) => { - tasks = result - }, - { - ...options, - lookup: { - ...options?.lookup, - space: task.class.Project, - status: core.class.Status - }, - sort: { - ...options?.sort, - ...sort - } + let fastDocs: DocWithRank[] = [] + let slowDocs: DocWithRank[] = [] + + const docsQuery = createQuery() + const docsQuerySlow = createQuery() + + let fastQueryIds = new Set<Ref<DocWithRank>>() + + let categoryQueryOptions: Partial<FindOptions<DocWithRank>> + $: categoryQueryOptions = { + ...getCategoryQueryNoLookupOptions(resultOptions), + projection: { + ...resultOptions.projection, + _id: 1, + _class: 1, + rank: 1, + ...getCategoryQueryProjection(client.getHierarchy(), _class, queryNoLookup, viewOptions.groupBy) } + } + + $: docsQuery.query( + _class, + queryNoLookup, + (res) => { + fastDocs = res + fastQueryIds = new Set(res.map((it) => it._id)) + }, + { ...categoryQueryOptions, limit: 1000 } ) + $: docsQuerySlow.query( + _class, + queryNoLookup, + (res) => { + slowDocs = res + }, + categoryQueryOptions + ) + + $: tasks = [...fastDocs, ...slowDocs.filter((it) => !fastQueryIds.has(it._id))] + $: listProvider.update(tasks) let categories: CategoryType[] = [] const queryId = generateId() - $: updateCategories(_class, space, tasks, groupByKey, viewOptions, viewOptionsConfig) - - function update () { - updateCategories(_class, space, tasks, groupByKey, viewOptions, viewOptionsConfig) + function update (): void { + void updateTaskKanbanCategories( + client, + viewlet, + _class, + space, + tasks, + groupByKey, + viewOptions, + viewOptionsConfig, + update, + queryId + ).then((res) => { + categories = res + }) } - async function updateCategories ( - _class: Ref<Class<Doc>>, - space: Ref<Project> | undefined, - docs: Doc[], - groupByKey: string, - viewOptions: ViewOptions, - viewOptionsModel: ViewOptionModel[] | undefined - ) { - categories = await getCategories(client, _class, space, docs, groupByKey, viewlet.descriptor) - for (const viewOption of viewOptionsModel ?? []) { - if (viewOption.actionTarget !== 'category') continue - const categoryFunc = viewOption as CategoryOption - if (viewOptions[viewOption.key] ?? viewOption.defaultValue) { - const categoryAction = await getResource(categoryFunc.action) - - let spaces = getCategorySpaces(categories) - if (spaces.length === 0) { - const set = new Set(docs.map((p) => p.space)) - spaces = Array.from(set) - } - - const query = spaces.length > 0 ? { space: { $in: Array.from(spaces.values()) } } : space ? { space } : {} - - const res = await categoryAction(_class, query, space, groupByKey, update, queryId, viewlet.descriptor) - if (res !== undefined) { - categories = res - break - } - } - } - } + $: void updateTaskKanbanCategories( + client, + viewlet, + _class, + space, + tasks, + groupByKey, + viewOptions, + viewOptionsConfig, + update, + queryId + ).then((res) => { + categories = res + }) function getHeader (_class: Ref<Class<Doc>>, groupByKey: string): void { if (groupByKey === noCategory) { headerComponent = undefined } else { - getPresenter(client, _class, { key: groupByKey }, { key: groupByKey }).then((p) => (headerComponent = p)) + void getPresenter(client, _class, { key: groupByKey }, { key: groupByKey }).then((p) => { + headerComponent = p + }) } } @@ -208,9 +214,6 @@ if (groupValue === undefined) { return undefined } - if ((doc as any)[groupByKey] === groupValue && viewOptions.orderBy[0] !== 'rank') { - return - } return { [groupByKey]: groupValue, space: doc.space @@ -234,12 +237,16 @@ bind:this={kanbanUI} {categories} {dontUpdateRank} + {_class} + query={resultQuery} + options={resultOptions} objects={tasks} getGroupByValues={(groupByDocs, category) => groupByKey === noCategory ? tasks : getGroupByValues(groupByDocs, category)} {setGroupByValues} {getUpdateProps} {groupByDocs} + {groupByKey} on:obj-focus={(evt) => { listProvider.updateFocus(evt.detail) }} @@ -253,32 +260,34 @@ <svelte:fragment slot="header" let:state let:count let:index> {@const color = accentColors.get(`${index}${$themeStore.dark}${groupByKey}`)} {@const headerBGColor = color?.background ?? defaultBackground($themeStore.dark)} - - <div style:background={headerBGColor} class="header flex-row-center"> - <span - class="clear-mins fs-bold overflow-label pointer-events-none" - style:color={color?.title ?? 'var(--theme-caption-color)'} - > - {#if groupByKey === noCategory} - <Label label={view.string.NoGrouping} /> - {:else if headerComponent} - <svelte:component - this={headerComponent.presenter} - value={state} - {space} - size={'small'} - kind={'list-header'} - colorInherit={!$themeStore.dark} - accent - on:accent-color={(ev) => { - setAccentColor(index, ev) - }} - /> - {/if} - </span> - <span class="counter ml-1"> - {count} - </span> + <div style:background={headerBGColor} class="header flex-between"> + <div class="flex-row-center gap-1"> + <span + class="clear-mins fs-bold overflow-label pointer-events-none" + style:color={color?.title ?? 'var(--theme-caption-color)'} + > + {#if groupByKey === noCategory} + <Label label={view.string.NoGrouping} /> + {:else if headerComponent} + <svelte:component + this={headerComponent.presenter} + value={state} + {space} + size={'small'} + kind={'list-header'} + display={'kanban'} + colorInherit={!$themeStore.dark} + accent + on:accent-color={(ev) => { + setAccentColor(index, ev) + }} + /> + {/if} + </span> + <span class="counter ml-1"> + {count} + </span> + </div> </div> </svelte:fragment> <svelte:fragment slot="card" let:object let:dragged> diff --git a/plugins/task-resources/src/index.ts b/plugins/task-resources/src/index.ts index 55958f0c94..8809f58693 100644 --- a/plugins/task-resources/src/index.ts +++ b/plugins/task-resources/src/index.ts @@ -58,6 +58,7 @@ import Todos from './components/todos/Todos.svelte' export { default as AssigneePresenter } from './components/AssigneePresenter.svelte' export { StateRefPresenter, StatePresenter, TypeStatesPopup } +export * from './utils' async function editStatuses (object: Project, ev: Event): Promise<void> { const client = getClient() diff --git a/plugins/task-resources/src/utils.ts b/plugins/task-resources/src/utils.ts new file mode 100644 index 0000000000..894d075e88 --- /dev/null +++ b/plugins/task-resources/src/utils.ts @@ -0,0 +1,84 @@ +import { + type CategoryType, + type Class, + type Doc, + type DocumentQuery, + type Hierarchy, + type Ref, + type Space, + type TxOperations +} from '@hcengineering/core' +import { getResource } from '@hcengineering/platform' +import { type Task } from '@hcengineering/task' +import { + type CategoryOption, + type ViewOptionModel, + type ViewOptions, + type ViewQueryOption, + type Viewlet +} from '@hcengineering/view' +import { getCategories, getCategorySpaces } from '@hcengineering/view-resources' + +/** + * @public + */ +export async function updateTaskKanbanCategories ( + client: TxOperations, + viewlet: Viewlet, + _class: Ref<Class<Doc>>, + space: Ref<Space> | undefined, + docs: Doc[], + groupByKey: string, + viewOptions: ViewOptions, + viewOptionsModel: ViewOptionModel[] | undefined, + update: () => void, + queryId: Ref<Doc> +): Promise<CategoryType[]> { + let categories = await getCategories(client, _class, space, docs, groupByKey, viewlet.descriptor) + for (const viewOption of viewOptionsModel ?? []) { + if (viewOption.actionTarget !== 'category') continue + const categoryFunc = viewOption as CategoryOption + if ((viewOptions[viewOption.key] as boolean) ?? viewOption.defaultValue) { + const categoryAction = await getResource(categoryFunc.action) + + const spaces = getCategorySpaces(categories) + if (space !== undefined) { + spaces.push(space) + } + const res = await categoryAction( + _class, + spaces.length > 0 ? { space: { $in: Array.from(spaces.values()) } } : {}, + space, + groupByKey, + update, + queryId, + viewlet.descriptor + ) + if (res !== undefined) { + categories = res + break + } + } + } + return categories +} + +/** + * @public + */ +export async function getTaskKanbanResultQuery ( + hierarchy: Hierarchy, + query: DocumentQuery<Task>, + viewOptions: ViewOptionModel[] | undefined, + viewOptionsStore: ViewOptions +): Promise<DocumentQuery<Task>> { + if (viewOptions === undefined) return query + let result = hierarchy.clone(query) + for (const viewOption of viewOptions) { + if (viewOption.actionTarget !== 'query') continue + const queryOption = viewOption as ViewQueryOption + const f = await getResource(queryOption.action) + result = f(viewOptionsStore[queryOption.key] ?? queryOption.defaultValue, query) + } + return result +} diff --git a/plugins/tracker-resources/src/components/issues/KanbanView.svelte b/plugins/tracker-resources/src/components/issues/KanbanView.svelte index add858e091..da4f696121 100644 --- a/plugins/tracker-resources/src/components/issues/KanbanView.svelte +++ b/plugins/tracker-resources/src/components/issues/KanbanView.svelte @@ -21,16 +21,19 @@ Doc, DocumentQuery, DocumentUpdate, + FindOptions, generateId, Lookup, + mergeQueries, Ref, WithLookup } from '@hcengineering/core' - import { Item, Kanban } from '@hcengineering/kanban' + import { Item, Kanban as KanbanUI } from '@hcengineering/kanban' import notification from '@hcengineering/notification' - import { getResource } from '@hcengineering/platform' import { ActionContext, createQuery, getClient } from '@hcengineering/presentation' import tags from '@hcengineering/tags' + import { DocWithRank, getStates } from '@hcengineering/task' + import { getTaskKanbanResultQuery, typeStore, updateTaskKanbanCategories } from '@hcengineering/task-resources' import { Issue, IssuesGrouping, IssuesOrdering, Project } from '@hcengineering/tracker' import { Button, @@ -44,20 +47,13 @@ showPopup, themeStore } from '@hcengineering/ui' - import { - AttributeModel, - BuildModelKey, - CategoryOption, - Viewlet, - ViewOptionModel, - ViewOptions, - ViewQueryOption - } from '@hcengineering/view' + import view, { AttributeModel, BuildModelKey, Viewlet, ViewOptionModel, ViewOptions } from '@hcengineering/view' import { enabledConfig, focusStore, - getCategories, - getCategorySpaces, + getCategoryQueryNoLookup, + getCategoryQueryNoLookupOptions, + getCategoryQueryProjection, getGroupByValues, getPresenter, groupBy, @@ -69,7 +65,6 @@ setGroupByValues, statusStore } from '@hcengineering/view-resources' - import view from '@hcengineering/view-resources/src/plugin' import { onMount } from 'svelte' import tracker from '../../plugin' import { activeProjects } from '../../utils' @@ -83,9 +78,8 @@ import PriorityEditor from './PriorityEditor.svelte' import StatusEditor from './StatusEditor.svelte' import EstimationEditor from './timereport/EstimationEditor.svelte' - import { getStates } from '@hcengineering/task' - import { typeStore } from '@hcengineering/task-resources' + const _class = tracker.class.Issue export let space: Ref<Project> | undefined = undefined export let baseMenuClass: Ref<Class<Doc>> | undefined = undefined export let query: DocumentQuery<Issue> = {} @@ -93,11 +87,10 @@ export let viewOptions: ViewOptions export let viewlet: Viewlet export let config: (string | BuildModelKey)[] + export let options: FindOptions<DocWithRank> | undefined = undefined - $: currentSpace = space || tracker.project.DefaultProject $: groupByKey = (viewOptions.groupBy[0] ?? noCategory) as IssuesGrouping $: orderBy = viewOptions.orderBy - $: sort = { [orderBy[0]]: orderBy[1] } let accentColors = new Map<string, ColorDefinition>() const setAccentColor = (n: number, ev: CustomEvent<ColorDefinition>) => { @@ -107,36 +100,25 @@ $: dontUpdateRank = orderBy[0] !== IssuesOrdering.Manual + $: currentSpace = space ?? tracker.project.DefaultProject let currentProject: Project | undefined $: currentProject = $activeProjects.get(currentSpace) - let resultQuery: DocumentQuery<any> = query - $: getResultQuery(query, viewOptionsConfig, viewOptions).then((p) => (resultQuery = p)) - + let resultQuery: DocumentQuery<any> = { ...query } const client = getClient() - const hierarchy = client.getHierarchy() - async function getResultQuery ( - query: DocumentQuery<Issue>, - viewOptions: ViewOptionModel[] | undefined, - viewOptionsStore: ViewOptions - ): Promise<DocumentQuery<Issue>> { - if (viewOptions === undefined) return query - let result = hierarchy.clone(query) - for (const viewOption of viewOptions) { - if (viewOption.actionTarget !== 'query') continue - const queryOption = viewOption as ViewQueryOption - const f = await getResource(queryOption.action) - result = f(viewOptionsStore[queryOption.key] ?? queryOption.defaultValue, query) - } - return result - } + $: void getTaskKanbanResultQuery(client.getHierarchy(), query, viewOptionsConfig, viewOptions).then((p) => { + resultQuery = mergeQueries(p, query) + }) + + $: queryNoLookup = getCategoryQueryNoLookup(resultQuery) function toIssue (object: any): WithLookup<Issue> { return object as WithLookup<Issue> } const lookup: Lookup<Issue> = { + ...(options?.lookup ?? {}), space: tracker.class.Project, status: tracker.class.IssueStatus, component: tracker.class.Component, @@ -147,7 +129,9 @@ } } - let kanbanUI: Kanban + $: resultOptions = { ...options, lookup, ...(orderBy !== undefined ? { sort: { [orderBy[0]]: orderBy[1] } } : {}) } + + let kanbanUI: KanbanUI const listProvider = new ListSelectionProvider((offset: 1 | -1 | 0, of?: Doc, dir?: SelectDirection) => { kanbanUI?.select(offset, of, dir) }) @@ -161,69 +145,88 @@ ev.preventDefault() showPopup(Menu, { object: items, baseMenuClass }, getEventPositionElement(ev)) } - const issuesQuery = createQuery() - let issues: Issue[] = [] + // Category information only + let tasks: DocWithRank[] = [] - $: groupByDocs = groupBy(issues, groupByKey, categories) + $: groupByDocs = groupBy(tasks, groupByKey, categories) - $: issuesQuery.query( - tracker.class.Issue, - resultQuery, - (result) => { - issues = result - }, - { - lookup, - sort + let fastDocs: DocWithRank[] = [] + let slowDocs: DocWithRank[] = [] + + const docsQuery = createQuery() + const docsQuerySlow = createQuery() + + let fastQueryIds = new Set<Ref<DocWithRank>>() + + let categoryQueryOptions: Partial<FindOptions<DocWithRank>> + $: categoryQueryOptions = { + ...getCategoryQueryNoLookupOptions(resultOptions), + projection: { + ...resultOptions.projection, + _id: 1, + _class: 1, + rank: 1, + ...getCategoryQueryProjection(client.getHierarchy(), _class, queryNoLookup, viewOptions.groupBy) } + } + + $: docsQuery.query( + _class, + queryNoLookup, + (res) => { + fastDocs = res + fastQueryIds = new Set(res.map((it) => it._id)) + }, + { ...categoryQueryOptions, limit: 1000 } + ) + $: docsQuerySlow.query( + _class, + queryNoLookup, + (res) => { + slowDocs = res + }, + categoryQueryOptions ) - $: listProvider.update(issues) + $: tasks = [...fastDocs, ...slowDocs.filter((it) => !fastQueryIds.has(it._id))] + + $: listProvider.update(tasks) let categories: CategoryType[] = [] const queryId = generateId() - $: updateCategories(tracker.class.Issue, issues, groupByKey, viewOptions, viewOptionsConfig) - - function update () { - updateCategories(tracker.class.Issue, issues, groupByKey, viewOptions, viewOptionsConfig) + function update (): void { + void updateTaskKanbanCategories( + client, + viewlet, + _class, + space, + tasks, + groupByKey, + viewOptions, + viewOptionsConfig, + update, + queryId + ).then((res) => { + categories = res + }) } - async function updateCategories ( - _class: Ref<Class<Doc>>, - docs: Doc[], - groupByKey: string, - viewOptions: ViewOptions, - viewOptionsModel: ViewOptionModel[] | undefined - ) { - categories = await getCategories(client, _class, space, docs, groupByKey, viewlet.descriptor) - for (const viewOption of viewOptionsModel ?? []) { - if (viewOption.actionTarget !== 'category') continue - const categoryFunc = viewOption as CategoryOption - if (viewOptions[viewOption.key] ?? viewOption.defaultValue) { - const categoryAction = await getResource(categoryFunc.action) - - const spaces = getCategorySpaces(categories) - if (space !== undefined) { - spaces.push(space) - } - const res = await categoryAction( - _class, - spaces.length > 0 ? { space: { $in: Array.from(spaces.values()) } } : {}, - space, - groupByKey, - update, - queryId, - viewlet.descriptor - ) - if (res !== undefined) { - categories = res - break - } - } - } - } + $: void updateTaskKanbanCategories( + client, + viewlet, + _class, + space, + tasks, + groupByKey, + viewOptions, + viewOptionsConfig, + update, + queryId + ).then((res) => { + categories = res + }) const fullFilled: Record<string, boolean> = {} @@ -231,12 +234,14 @@ if (groupByKey === noCategory) { headerComponent = undefined } else { - getPresenter(client, _class, { key: groupByKey }, { key: groupByKey }).then((p) => (headerComponent = p)) + void getPresenter(client, _class, { key: groupByKey }, { key: groupByKey }).then((p) => { + headerComponent = p + }) } } let headerComponent: AttributeModel | undefined - $: getHeader(tracker.class.Issue, groupByKey) + $: getHeader(_class, groupByKey) const getUpdateProps = (doc: Doc, category: CategoryType): DocumentUpdate<Item> | undefined => { const groupValue = @@ -244,9 +249,6 @@ if (groupValue === undefined) { return undefined } - if ((doc as any)[groupByKey] === groupValue && viewOptions.orderBy[0] !== 'rank') { - return - } return { [groupByKey]: groupValue, space: doc.space @@ -273,7 +275,7 @@ if ([IssuesGrouping.Component, IssuesGrouping.Milestone].includes(groupByKey)) { const availableCategories = [] - const clazz = hierarchy.getAttribute(tracker.class.Issue, groupByKey) + const clazz = client.getHierarchy().getAttribute(tracker.class.Issue, groupByKey) for (const category of categories) { if (!category || (issue as any)[groupByKey] === category) { @@ -312,16 +314,20 @@ /> <!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-no-static-element-interactions --> - <Kanban + <KanbanUI bind:this={kanbanUI} {categories} {dontUpdateRank} - objects={issues} + {_class} + query={resultQuery} + options={resultOptions} + objects={tasks} getGroupByValues={(groupByDocs, category) => - groupByKey === noCategory ? issues : getGroupByValues(groupByDocs, category)} + groupByKey === noCategory ? tasks : getGroupByValues(groupByDocs, category)} {setGroupByValues} {getUpdateProps} {groupByDocs} + {groupByKey} on:obj-focus={(evt) => { listProvider.updateFocus(evt.detail) }} @@ -360,7 +366,7 @@ /> {/if} </span> - <span class="counter"> + <span class="counter ml-1"> {count} </span> </div> @@ -386,7 +392,7 @@ <div class="tracker-card" on:click={() => { - openDoc(hierarchy, issue) + void openDoc(client.getHierarchy(), issue) }} > <div class="card-header flex-between"> @@ -482,7 +488,7 @@ </div> {/key} </svelte:fragment> - </Kanban> + </KanbanUI> {/if} <style lang="scss"> diff --git a/plugins/view-resources/src/plugin.ts b/plugins/view-resources/src/plugin.ts index a57936a6e4..358923c5df 100644 --- a/plugins/view-resources/src/plugin.ts +++ b/plugins/view-resources/src/plugin.ts @@ -69,7 +69,6 @@ export default mergeIds(viewId, view, { DontMatchCriteria: '' as IntlString, MarkupEditor: '' as IntlString, Select: '' as IntlString, - NoGrouping: '' as IntlString, Grouping: '' as IntlString, Ordering: '' as IntlString, Manual: '' as IntlString, diff --git a/plugins/view-resources/src/utils.ts b/plugins/view-resources/src/utils.ts index a8b9c981e0..e4cc172cad 100644 --- a/plugins/view-resources/src/utils.ts +++ b/plugins/view-resources/src/utils.ts @@ -16,6 +16,7 @@ import core, { AccountRole, + type FindOptions, Hierarchy, getCurrentAccount, getObjectValue, @@ -26,6 +27,7 @@ import core, { type Client, type Collection, type Doc, + type DocumentQuery, type DocumentUpdate, type Lookup, type Obj, @@ -923,3 +925,57 @@ export async function getSpacePresenter ( return await getResource(value.presenter) } } + +/** + * @public + */ +export function getCategoryQueryProjection ( + hierarchy: Hierarchy, + _class: Ref<Class<Doc>>, + query: DocumentQuery<Doc>, + fields: string[] +): Record<string, number> { + const res: Record<string, number> = {} + for (const f of fields) { + /* + Mongo projection doesn't support properties fields which + start from $. Such field here is $search. The least we could do + is to filter all properties which start from $. + */ + if (!f.startsWith('$')) { + res[f] = 1 + } + } + for (const f of Object.keys(query)) { + if (!f.startsWith('$')) { + res[f] = 1 + } + } + if (hierarchy.isDerived(_class, core.class.AttachedDoc)) { + res.attachedTo = 1 + res.attachedToClass = 1 + res.collection = 1 + } + return res +} + +/** + * @public + */ +export function getCategoryQueryNoLookup<T extends Doc = Doc> (query: DocumentQuery<T>): DocumentQuery<T> { + const newQuery: DocumentQuery<T> = {} + for (const [k, v] of Object.entries(query)) { + if (!k.startsWith('$lookup.')) { + ;(newQuery as any)[k] = v + } + } + return newQuery +} + +/** + * @public + */ +export function getCategoryQueryNoLookupOptions<T extends Doc> (options: FindOptions<T>): FindOptions<T> { + const { lookup, ...resultOptions } = options + return resultOptions +} diff --git a/plugins/view/src/index.ts b/plugins/view/src/index.ts index e01bd1db07..dbfc23d4a2 100644 --- a/plugins/view/src/index.ts +++ b/plugins/view/src/index.ts @@ -862,7 +862,8 @@ const view = plugin(viewId, { Or: '' as IntlString, Subscribed: '' as IntlString, HyperlinkPlaceholder: '' as IntlString, - CopyToClipboard: '' as IntlString + CopyToClipboard: '' as IntlString, + NoGrouping: '' as IntlString }, icon: { Table: '' as Asset,