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,