UBERF-4413: Kanban with huge data sets (#4076)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2023-11-27 22:49:53 +07:00 committed by GitHub
parent fb4cdfba5e
commit 7f03c18007
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 513 additions and 294 deletions

View File

@ -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} />

View File

@ -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}

View File

@ -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)

View File

@ -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>

View File

@ -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()

View File

@ -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
}

View File

@ -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">

View File

@ -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,

View File

@ -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
}

View File

@ -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,