UBERF-5564: rework groupping and support PersonAccount (#5525)

Signed-off-by: Vyacheslav Tumanov <me@slavatumanov.me>
This commit is contained in:
Vyacheslav Tumanov 2024-06-25 10:03:01 +05:00 committed by GitHub
parent 4496530fed
commit 7f2a8779c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 515 additions and 280 deletions

View File

@ -507,6 +507,16 @@ export function createModel (builder: Builder): void {
pinned: true
})
builder.mixin(core.class.Account, core.class.Class, view.mixin.Aggregation, {
createAggregationManager: contact.aggregation.CreatePersonAggregationManager,
setStoreFunc: contact.function.SetPersonStore,
filterFunc: contact.function.PersonFilterFunction
})
builder.mixin(core.class.Account, core.class.Class, view.mixin.Groupping, {
grouppingManager: contact.aggregation.GrouppingPersonManager
})
builder.mixin(contact.mixin.Employee, core.class.Class, view.mixin.ObjectEditor, {
editor: contact.component.EditEmployee,
pinned: true

View File

@ -16,7 +16,7 @@
import { contactId } from '@hcengineering/contact'
import contact from '@hcengineering/contact-resources/src/plugin'
import type { Client, Doc, Ref } from '@hcengineering/core'
import type { Client, Doc, DocManager, Ref } from '@hcengineering/core'
import { type ObjectSearchCategory, type ObjectSearchFactory } from '@hcengineering/model-presentation'
import { type NotificationGroup } from '@hcengineering/notification'
import { type IntlString, mergeIds, type Resource } from '@hcengineering/platform'
@ -139,6 +139,8 @@ export default mergeIds(contactId, contact, {
GetContactLastName: '' as Resource<TemplateFieldFunc>,
ContactTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>,
ChannelTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>,
ChannelIdentifierProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>
ChannelIdentifierProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>,
SetPersonStore: '' as Resource<(manager: DocManager<any>) => void>,
PersonFilterFunction: '' as Resource<(doc: Doc, target: Doc) => boolean>
}
})

View File

@ -166,7 +166,7 @@ export class TSpacesTypeData extends TSpace implements RolesAssignment {
}
@Model(core.class.Account, core.class.Doc, DOMAIN_MODEL)
@UX(core.string.Account)
@UX(core.string.Account, undefined, undefined, 'name')
export class TAccount extends TDoc implements Account {
email!: string
role!: AccountRole

View File

@ -94,7 +94,13 @@ function defineSortAndGrouping (builder: Builder): void {
})
builder.mixin(tracker.class.Component, core.class.Class, view.mixin.Aggregation, {
createAggregationManager: tracker.aggregation.CreateComponentAggregationManager
createAggregationManager: tracker.aggregation.CreateComponentAggregationManager,
setStoreFunc: tracker.function.SetComponentStore,
filterFunc: tracker.function.ComponentFilterFunction
})
builder.mixin(tracker.class.Component, core.class.Class, view.mixin.Groupping, {
grouppingManager: tracker.aggregation.GrouppingComponentManager
})
builder.mixin(tracker.class.TypeIssuePriority, core.class.Class, view.mixin.AllValuesFunc, {

View File

@ -15,7 +15,7 @@
//
import { type DocUpdateMessageViewlet } from '@hcengineering/activity'
import { type ChatMessageViewlet } from '@hcengineering/chunter'
import { type StatusCategory, type Doc, type Ref } from '@hcengineering/core'
import { type StatusCategory, type Doc, type Ref, type DocManager } from '@hcengineering/core'
import { type ObjectSearchCategory, type ObjectSearchFactory } from '@hcengineering/model-presentation'
import { type NotificationGroup, type NotificationType } from '@hcengineering/notification'
import { mergeIds, type IntlString, type Resource } from '@hcengineering/platform'
@ -119,5 +119,10 @@ export default mergeIds(trackerId, tracker, {
Started: '' as Ref<StatusCategory>,
Completed: '' as Ref<StatusCategory>,
Canceled: '' as Ref<StatusCategory>
},
function: {
SetComponentStore: '' as Resource<(manager: DocManager<any>) => void>,
ComponentFilterFunction: '' as Resource<(doc: Doc, target: Doc) => boolean>
}
})

View File

@ -20,6 +20,7 @@ import {
DOMAIN_MODEL,
type Data,
type Doc,
type DocManager,
type DocumentQuery,
type Domain,
type Ref,
@ -274,6 +275,8 @@ export class TGroupping extends TClass implements Groupping {
@Mixin(view.mixin.Aggregation, core.class.Class)
export class TAggregation extends TClass implements Aggregation {
createAggregationManager!: CreateAggregationManagerFunc
setStoreFunc!: Resource<(manager: DocManager<any>) => void>
filterFunc!: Resource<(doc: Doc, target: Doc) => boolean>
}
@Mixin(view.mixin.ObjectIcon, core.class.Class)

View File

@ -315,29 +315,36 @@ export class AggregateValue {
*/
export type CategoryType = number | string | undefined | Ref<Doc> | AggregateValue
export interface IDocManager<T extends Doc> {
get: (ref: Ref<T>) => T | undefined
getDocs: () => T[]
getIdMap: () => IdMap<T>
filter: (predicate: (value: T) => boolean) => T[]
}
/**
* @public
*/
export class DocManager {
protected readonly byId: IdMap<Doc>
export class DocManager<T extends Doc> implements IDocManager<T> {
protected readonly byId: IdMap<T>
constructor (protected readonly docs: Doc[]) {
constructor (protected readonly docs: T[]) {
this.byId = toIdMap(docs)
}
get (ref: Ref<Doc>): Doc | undefined {
get (ref: Ref<T>): T | undefined {
return this.byId.get(ref)
}
getDocs (): Doc[] {
getDocs (): T[] {
return this.docs
}
getIdMap (): IdMap<Doc> {
getIdMap (): IdMap<T> {
return this.byId
}
filter (predicate: (value: Doc) => boolean): Doc[] {
filter (predicate: (value: T) => boolean): T[] {
return this.docs.filter(predicate)
}
}

View File

@ -74,8 +74,15 @@
let limitedObjects: IdMap<DocWithRank> = new Map()
const docQuery = createQuery()
$: groupQuery = { ...query, [groupByKey]: typeof state === 'object' ? { $in: state.values } : state }
$: groupQuery = {
...query,
[groupByKey]:
typeof state === 'object'
? state.name !== undefined
? { $in: state.values.flatMap((x) => x._id) }
: undefined
: state
}
$: void limiter.add(async () => {
docQuery.query(

View File

@ -15,12 +15,13 @@
-->
<script lang="ts">
import { PersonAccount } from '@hcengineering/contact'
import { Ref } from '@hcengineering/core'
import { AggregateValue, Ref } from '@hcengineering/core'
import { IconSize } from '@hcengineering/ui'
import { personAccountByIdStore } from '../utils'
import PersonAccountPresenter from './PersonAccountPresenter.svelte'
import { personStore } from '..'
export let value: Ref<PersonAccount>
export let value: Ref<PersonAccount> | AggregateValue
export let avatarSize: IconSize = 'x-small'
export let shouldShowAvatar: boolean = true
export let shouldShowName: boolean = true
@ -30,7 +31,8 @@
export let noUnderline: boolean = false
export let compact = false
$: account = $personAccountByIdStore.get(value)
$: _value = $personStore.get(typeof value === 'string' ? value : (value?.values?.[0]?._id as Ref<PersonAccount>))
$: account = $personAccountByIdStore.get(_value?._id ?? (value as Ref<PersonAccount>))
</script>
{#if account}

View File

@ -15,14 +15,16 @@
//
import {
type Channel,
type AvatarInfo,
type Contact,
getGravatarUrl,
getName,
type AvatarInfo,
type Channel,
type Contact,
type Person
type Person,
type PersonAccount
} from '@hcengineering/contact'
import {
DocManager,
type Class,
type Client,
type Data,
@ -120,8 +122,9 @@ import NameChangedActivityMessage from './components/activity/NameChangedActivit
import IconAddMember from './components/icons/AddMember.svelte'
import ExpandRightDouble from './components/icons/ExpandRightDouble.svelte'
import IconMembers from './components/icons/Members.svelte'
import { AggregationManager } from '@hcengineering/view-resources'
import { get } from 'svelte/store'
import { get, writable } from 'svelte/store'
import contact from './plugin'
import {
channelIdentifierProvider,
@ -140,6 +143,7 @@ import {
getCurrentEmployeeName,
getCurrentEmployeePosition,
getPersonTooltip,
grouppingPersonManager,
resolveLocation
} from './utils'
@ -293,6 +297,16 @@ async function openChannelURL (doc: Channel): Promise<void> {
}
}
function filterPerson (doc: PersonAccount, target: PersonAccount): boolean {
return doc.person === target.person && doc._id !== target._id
}
export const personStore = writable<DocManager<PersonAccount>>(new DocManager([]))
function setStore (manager: DocManager<PersonAccount>): void {
personStore.set(manager)
}
export interface PersonLabelTooltip {
personLabel?: IntlString
placeholderLabel?: IntlString
@ -431,9 +445,16 @@ export default async (): Promise<Resources> => ({
ContactTitleProvider: contactTitleProvider,
PersonTooltipProvider: getPersonTooltip,
ChannelTitleProvider: channelTitleProvider,
ChannelIdentifierProvider: channelIdentifierProvider
ChannelIdentifierProvider: channelIdentifierProvider,
SetPersonStore: setStore,
PersonFilterFunction: filterPerson
},
resolver: {
Location: resolveLocation
},
aggregation: {
// eslint-disable-next-line @typescript-eslint/unbound-method
CreatePersonAggregationManager: AggregationManager.create,
GrouppingPersonManager: grouppingPersonManager
}
})

View File

@ -18,7 +18,12 @@ import contact, { contactId } from '@hcengineering/contact'
import { type Client, type Doc } from '@hcengineering/core'
import { type IntlString, mergeIds, type Resource } from '@hcengineering/platform'
import { type LabelAndProps, type Location } from '@hcengineering/ui'
import { type FilterFunction, type SortFunc } from '@hcengineering/view'
import {
type CreateAggregationManagerFunc,
type GrouppingManagerResource,
type FilterFunction,
type SortFunc
} from '@hcengineering/view'
export default mergeIds(contactId, contact, {
string: {
@ -86,5 +91,9 @@ export default mergeIds(contactId, contact, {
FilterChannelHasMessagesResult: '' as FilterFunction,
FilterChannelHasNewMessagesResult: '' as FilterFunction,
PersonTooltipProvider: '' as Resource<(client: Client, doc?: Doc | null) => Promise<LabelAndProps | undefined>>
},
aggregation: {
CreatePersonAggregationManager: '' as CreateAggregationManagerFunc,
GrouppingPersonManager: '' as GrouppingManagerResource
}
})

View File

@ -41,7 +41,13 @@ import core, {
type Timestamp,
type TxOperations,
type UserStatus,
type WithLookup
type WithLookup,
AggregateValue,
type Space,
type Hierarchy,
type DocumentQuery,
AggregateValueData,
matchQuery
} from '@hcengineering/core'
import notification, { type DocNotifyContext, type InboxNotification } from '@hcengineering/notification'
import { getEmbeddedLabel, getResource, translate } from '@hcengineering/platform'
@ -55,11 +61,12 @@ import {
type ResolvedLocation,
type TabItem
} from '@hcengineering/ui'
import view, { type Filter } from '@hcengineering/view'
import view, { type GrouppingManager, type Filter } from '@hcengineering/view'
import { FilterQuery, accessDeniedStore } from '@hcengineering/view-resources'
import { derived, get, writable } from 'svelte/store'
import contact from './plugin'
import { personStore } from '.'
export function formatDate (dueDateMs: Timestamp): string {
return new Date(dueDateMs).toLocaleString('default', {
@ -431,3 +438,109 @@ export async function channelTitleProvider (client: Client, ref: Ref<Channel>, d
return channel.value
}
/**
* @public
*/
export const grouppingPersonManager: GrouppingManager = {
groupByCategories: groupByPersonAccountCategories,
groupValues: groupPersonAccountValues,
groupValuesWithEmpty: groupPersonAccountValuesWithEmpty,
hasValue: hasPersonAccountValue
}
/**
* @public
*/
export function groupByPersonAccountCategories (categories: any[]): AggregateValue[] {
const mgr = get(personStore)
const existingCategories: AggregateValue[] = [new AggregateValue(undefined, [])]
const personMap = new Map<string, AggregateValue>()
const usedSpaces = new Set<Ref<Space>>()
const personAccountList: Array<WithLookup<PersonAccount>> = []
for (const v of categories) {
const personAccount = mgr.getIdMap().get(v)
if (personAccount !== undefined) {
personAccountList.push(personAccount)
usedSpaces.add(personAccount.space)
}
}
for (const personAccount of personAccountList) {
if (personAccount !== undefined) {
let fst = personMap.get(personAccount.person)
if (fst === undefined) {
const people = mgr
.getDocs()
.filter(
(it) => it.person === personAccount.person && (categories.includes(it._id) || usedSpaces.has(it.space))
)
.sort((a, b) => a.email.localeCompare(b.email))
.map((it) => new AggregateValueData(it.person, it._id, it.space))
fst = new AggregateValue(personAccount.person, people)
personMap.set(personAccount.person, fst)
existingCategories.push(fst)
}
}
}
return existingCategories
}
/**
* @public
*/
export function groupPersonAccountValues (val: Doc[], targets: Set<any>): Doc[] {
const values = val
const result: Doc[] = []
const unique = [...new Set(val.map((c) => (c as PersonAccount).person))]
unique.forEach((label, i) => {
let exists = false
values.forEach((c) => {
if ((c as PersonAccount).person === label) {
if (!exists) {
result[i] = c
exists = targets.has(c?._id)
}
}
})
})
return result
}
/**
* @public
*/
export function hasPersonAccountValue (value: Doc | undefined | null, values: any[]): boolean {
const mgr = get(personStore)
const personSet = new Set(mgr.filter((it) => it.person === (value as PersonAccount)?.person).map((it) => it._id))
return values.some((it) => personSet.has(it))
}
/**
* @public
*/
export function groupPersonAccountValuesWithEmpty (
hierarchy: Hierarchy,
_class: Ref<Class<Doc>>,
key: string,
query: DocumentQuery<Doc> | undefined
): Array<Ref<Doc>> {
const mgr = get(personStore)
let personAccountList = mgr.getDocs()
if (query !== undefined) {
const { [key]: st, space } = query
const resQuery: DocumentQuery<Doc> = {}
if (space !== undefined) {
resQuery.space = space
}
if (st !== undefined) {
resQuery._id = st
}
personAccountList = matchQuery<Doc>(personAccountList, resQuery, _class, hierarchy) as unknown as Array<
WithLookup<PersonAccount>
>
}
return personAccountList.map((it) => it._id)
}

View File

@ -16,104 +16,21 @@
import {
AggregateValue,
AggregateValueData,
type AnyAttribute,
type Class,
type Client,
type Doc,
DocManager,
type DocumentQuery,
type Hierarchy,
type Ref,
SortingOrder,
type Space,
type Tx,
type WithLookup,
matchQuery
} from '@hcengineering/core'
import { LiveQuery } from '@hcengineering/query'
import tracker, { type Component, ComponentManager } from '@hcengineering/tracker'
import { type AggregationManager, type GrouppingManager } from '@hcengineering/view'
import { type Component } from '@hcengineering/tracker'
import { type GrouppingManager } from '@hcengineering/view'
import { get, writable } from 'svelte/store'
export const componentStore = writable<ComponentManager>(new ComponentManager([]))
/**
* @public
*/
export class ComponentAggregationManager implements AggregationManager {
docs: Doc[] | undefined
mgr: ComponentManager | Promise<ComponentManager> | undefined
query: (() => void) | undefined
lq: LiveQuery
lqCallback: () => void
private constructor (client: Client, lqCallback: () => void) {
this.lq = new LiveQuery(client)
this.lqCallback = lqCallback ?? (() => {})
}
static create (client: Client, lqCallback: () => void): ComponentAggregationManager {
return new ComponentAggregationManager(client, lqCallback)
}
private async getManager (): Promise<ComponentManager> {
if (this.mgr !== undefined) {
if (this.mgr instanceof Promise) {
this.mgr = await this.mgr
}
return this.mgr
}
this.mgr = new Promise<ComponentManager>((resolve) => {
this.query = this.lq.query(
tracker.class.Component,
{},
(res) => {
const first = this.docs === undefined
this.docs = res
this.mgr = new ComponentManager(res)
componentStore.set(this.mgr)
if (!first) {
this.lqCallback()
}
resolve(this.mgr)
},
{
sort: {
label: SortingOrder.Ascending
}
}
)
})
return await this.mgr
}
close (): void {
this.query?.()
}
async notifyTx (...tx: Tx[]): Promise<void> {
await this.lq.tx(...tx)
}
getAttrClass (): Ref<Class<Doc>> {
return tracker.class.Component
}
async categorize (target: Array<Ref<Doc>>, attr: AnyAttribute): Promise<Array<Ref<Doc>>> {
const mgr = await this.getManager()
for (const sid of [...target]) {
const c = mgr.getIdMap().get(sid as Ref<Component>) as WithLookup<Component>
if (c !== undefined) {
let components = mgr.getDocs()
components = components.filter(
(it) => it.label.toLowerCase().trim() === c.label.toLowerCase().trim() && it._id !== c._id
)
target.push(...components.map((it) => it._id))
}
}
return target.filter((it, idx, arr) => arr.indexOf(it) === idx)
}
}
export const componentStore = writable<DocManager<Component>>(new DocManager([]))
/**
* @public
@ -136,6 +53,7 @@ export function groupByComponentCategories (categories: any[]): AggregateValue[]
const usedSpaces = new Set<Ref<Space>>()
const componentsList: Array<WithLookup<Component>> = []
// console.log('mgr docs', mgr.getDocs())
for (const v of categories) {
const component = mgr.getIdMap().get(v)
if (component !== undefined) {

View File

@ -30,12 +30,13 @@ import core, {
type Ref,
type RelatedDocument,
type TxOperations,
type DocManager,
AccountRole
} from '@hcengineering/core'
import chunter, { type ChatMessage } from '@hcengineering/chunter'
import { type Status, translate, type Resources } from '@hcengineering/platform'
import { getClient, MessageBox, type ObjectSearchResult } from '@hcengineering/presentation'
import { type Issue, type Milestone, type Project } from '@hcengineering/tracker'
import { type Component, type Issue, type Milestone, type Project } from '@hcengineering/tracker'
import { getCurrentLocation, navigate, showPopup, themeStore } from '@hcengineering/ui'
import ComponentEditor from './components/components/ComponentEditor.svelte'
import ComponentFilterValuePresenter from './components/components/ComponentFilterValuePresenter.svelte'
@ -122,7 +123,7 @@ import ComponentSelector from './components/components/ComponentSelector.svelte'
import IssueTemplatePresenter from './components/templates/IssueTemplatePresenter.svelte'
import IssueTemplates from './components/templates/IssueTemplates.svelte'
import { deleteObject, deleteObjects } from '@hcengineering/view-resources'
import { deleteObject, deleteObjects, AggregationManager } from '@hcengineering/view-resources'
import MoveAndDeleteMilestonePopup from './components/milestones/MoveAndDeleteMilestonePopup.svelte'
import EditIssueTemplate from './components/templates/EditIssueTemplate.svelte'
import TemplateEstimationEditor from './components/templates/EstimationEditor.svelte'
@ -143,7 +144,7 @@ import {
subIssueQuery
} from './utils'
import { ComponentAggregationManager, grouppingComponentManager } from './component'
import { componentStore, grouppingComponentManager } from './component'
import PriorityIcon from './components/activity/PriorityIcon.svelte'
import StatusIcon from './components/activity/StatusIcon.svelte'
import DeleteComponentPresenter from './components/components/DeleteComponentPresenter.svelte'
@ -591,6 +592,14 @@ export async function importTasks (tasks: File, space: Ref<Project>): Promise<vo
}
}
function filterComponents (doc: Component, target: Component): boolean {
return doc.label.toLowerCase().trim() === target.label.toLowerCase().trim() && doc._id !== target._id
}
function setStore (manager: DocManager<Component>): void {
componentStore.set(manager)
}
export default async (): Promise<Resources> => ({
activity: {
PriorityIcon,
@ -710,7 +719,9 @@ export default async (): Promise<Resources> => ({
GetVisibleFilters: getVisibleFilters,
IssueChatTitleProvider: getIssueChatTitle,
IsProjectJoined: async (project: Project) => project.members.includes(getCurrentAccount()._id),
GetIssueStatusCategories: getIssueStatusCategories
GetIssueStatusCategories: getIssueStatusCategories,
SetComponentStore: setStore,
ComponentFilterFunction: filterComponents
},
actionImpl: {
Move: move,
@ -726,7 +737,7 @@ export default async (): Promise<Resources> => ({
},
aggregation: {
// eslint-disable-next-line @typescript-eslint/unbound-method
CreateComponentAggregationManager: ComponentAggregationManager.create,
CreateComponentAggregationManager: AggregationManager.create,
GrouppingComponentManager: grouppingComponentManager
}
})

View File

@ -21,8 +21,6 @@ import {
CollectionSize,
Data,
Doc,
DocManager,
IdMap,
Markup,
Mixin,
Ref,
@ -30,8 +28,7 @@ import {
Space,
Status,
Timestamp,
Type,
WithLookup
Type
} from '@hcengineering/core'
import { Asset, IntlString, Plugin, Resource, plugin } from '@hcengineering/platform'
import { Preference } from '@hcengineering/preference'
@ -351,29 +348,6 @@ export interface Component extends Doc {
attachments?: number
}
/**
* @public
*
* Allow to query for status keys/values.
*/
export class ComponentManager extends DocManager {
get (ref: Ref<WithLookup<Component>>): WithLookup<Component> | undefined {
return this.getIdMap().get(ref) as WithLookup<Component>
}
getDocs (): Array<WithLookup<Component>> {
return this.docs as Component[]
}
getIdMap (): IdMap<WithLookup<Component>> {
return this.byId as IdMap<WithLookup<Component>>
}
filter (predicate: (value: Component) => boolean): Component[] {
return this.getDocs().filter(predicate)
}
}
/**
* @public
*/

View File

@ -97,6 +97,7 @@
queryNoLookup,
(res) => {
fastDocs = res
// console.log('query, res', queryNoLookup, res)
fastQueryIds = new Set(res.map((it) => it._id))
},
{ ...categoryQueryOptions, limit: 1000 }

View File

@ -349,130 +349,149 @@
const listCategory: SvelteComponentTyped[] = []
const listListCategory: ListCategory[] = []
function getGroupByKey (
docKeys: Partial<DocumentQuery<Doc<Space>>>,
category: CategoryType,
resultQuery: DocumentQuery<Doc<Space>>
): Partial<DocumentQuery<Doc>> {
return {
...docKeys,
[groupByKey]:
typeof category === 'object'
? category.name !== undefined
? { $in: category.values.flatMap((x) => x._id) }
: resultQuery[groupByKey]?.$in?.length !== 0
? undefined
: []
: category
}
}
</script>
{#each categories as category, i (typeof category === 'object' ? category.name : category)}
{@const items = groupByKey === noCategory ? docs : getGroupByValues(groupByDocs, category)}
{@const categoryDocKeys = { ...docKeys, [groupByKey]: category }}
<ListCategory
bind:this={listListCategory[i]}
{extraHeaders}
{space}
{selectedObjectIds}
{headerComponent}
{baseMenuClass}
{level}
{viewOptions}
{groupByKey}
{lookup}
{config}
{configurations}
{configurationsVersion}
{itemModels}
{_class}
parentCategories={categories.length}
groupPersistKey={`${groupPersistKey}_${level}_${typeof category === 'object' ? category.name : category}`}
singleCat={level === 0 && categories.length === 1}
oneCat={viewOptions.groupBy.length === 1}
lastCat={i === categories.length - 1}
{category}
itemProj={items}
docKeys={categoryDocKeys}
{newObjectProps}
{createItemDialog}
{createItemDialogProps}
{createItemLabel}
{viewOptionsConfig}
{compactMode}
{resultQuery}
{resultOptions}
{limiter}
{listProvider}
on:check
on:uncheckAll
on:row-focus
on:dragstart={(e) => {
dispatch('dragstart', {
target: e.detail.target,
index: e.detail.index + getInitIndex(categories, i)
})
}}
on:collapsed
{flatHeaders}
{disableHeader}
{props}
{listDiv}
bind:dragItem
>
<svelte:fragment
slot="category"
let:docs
let:_class
let:space
let:lookup
let:baseMenuClass
let:config
let:selectedObjectIds
let:createItemDialog
let:createItemLabel
let:viewOptions
let:newObjectProps
let:flatHeaders
let:props
let:level
let:viewOptionsConfig
let:listDiv
let:dragstart
{@const categoryDocKeys = getGroupByKey(docKeys, category, resultQuery)}
{#if items.length !== 0}
<ListCategory
bind:this={listListCategory[i]}
{extraHeaders}
{space}
{selectedObjectIds}
{headerComponent}
{baseMenuClass}
{level}
{viewOptions}
{groupByKey}
{lookup}
{config}
{configurations}
{configurationsVersion}
{itemModels}
{_class}
parentCategories={categories.length}
groupPersistKey={`${groupPersistKey}_${level}_${typeof category === 'object' ? category.name : category}`}
singleCat={level === 0 && categories.length === 1}
oneCat={viewOptions.groupBy.length === 1}
lastCat={i === categories.length - 1}
{category}
itemProj={items}
docKeys={categoryDocKeys}
{newObjectProps}
{createItemDialog}
{createItemDialogProps}
{createItemLabel}
{viewOptionsConfig}
{compactMode}
{resultQuery}
{resultOptions}
{limiter}
{listProvider}
on:check
on:uncheckAll
on:row-focus
on:dragstart={(e) => {
dispatch('dragstart', {
target: e.detail.target,
index: e.detail.index + getInitIndex(categories, i)
})
}}
on:collapsed
{flatHeaders}
{disableHeader}
{props}
{listDiv}
bind:dragItem
>
<svelte:self
{docs}
bind:this={listCategory[i]}
{_class}
{space}
{lookup}
{baseMenuClass}
{config}
{selectedObjectIds}
{createItemDialog}
{createItemLabel}
{viewOptions}
{newObjectProps}
{flatHeaders}
{props}
{level}
docKeys={categoryDocKeys}
groupPersistKey={`${groupPersistKey}_${level}_${typeof category === 'object' ? category.name : category}`}
{initIndex}
{viewOptionsConfig}
{listDiv}
{resultQuery}
{resultOptions}
{limiter}
{listProvider}
bind:dragItem
on:dragItem
on:check
on:uncheckAll
on:row-focus
on:dragstart={dragstart}
on:select={(evt) => {
select(0, evt.detail)
}}
on:select-next={(evt) => {
if (level !== 0) {
dispatch('select-next', evt.detail)
} else {
select(2, evt.detail)
}
}}
on:select-prev={(evt) => {
if (level !== 0) {
dispatch('select-prev', evt.detail)
} else {
select(-2, evt.detail)
}
}}
/>
</svelte:fragment>
</ListCategory>
<svelte:fragment
slot="category"
let:docs
let:_class
let:space
let:lookup
let:baseMenuClass
let:config
let:selectedObjectIds
let:createItemDialog
let:createItemLabel
let:viewOptions
let:newObjectProps
let:flatHeaders
let:props
let:level
let:viewOptionsConfig
let:listDiv
let:dragstart
>
<svelte:self
{docs}
bind:this={listCategory[i]}
{_class}
{space}
{lookup}
{baseMenuClass}
{config}
{selectedObjectIds}
{createItemDialog}
{createItemLabel}
{viewOptions}
{newObjectProps}
{flatHeaders}
{props}
{level}
docKeys={categoryDocKeys}
groupPersistKey={`${groupPersistKey}_${level}_${typeof category === 'object' ? category.name : category}`}
{initIndex}
{viewOptionsConfig}
{listDiv}
{resultQuery}
{resultOptions}
{limiter}
{listProvider}
bind:dragItem
on:dragItem
on:check
on:uncheckAll
on:row-focus
on:dragstart={dragstart}
on:select={(evt) => {
select(0, evt.detail)
}}
on:select-next={(evt) => {
if (level !== 0) {
dispatch('select-next', evt.detail)
} else {
select(2, evt.detail)
}
}}
on:select-prev={(evt) => {
if (level !== 0) {
dispatch('select-prev', evt.detail)
} else {
select(-2, evt.detail)
}
}}
/>
</svelte:fragment>
</ListCategory>
{/if}
{/each}

View File

@ -20,7 +20,7 @@ import core, {
} from '@hcengineering/core'
import { getResource, translate } from '@hcengineering/platform'
import { BasePresentationMiddleware, type PresentationMiddleware } from '@hcengineering/presentation'
import view, { type AggregationManager } from '@hcengineering/view'
import view, { type IAggregationManager } from '@hcengineering/view'
/**
* @public
@ -39,7 +39,7 @@ export interface DocSubScriber<T extends Doc = Doc> {
* @public
*/
export class AggregationMiddleware extends BasePresentationMiddleware implements PresentationMiddleware {
mgrs: Map<Ref<Class<Doc>>, AggregationManager> = new Map<Ref<Class<Doc>>, AggregationManager>()
mgrs: Map<Ref<Class<Doc>>, IAggregationManager<any>> = new Map<Ref<Class<Doc>>, IAggregationManager<any>>()
docs: Doc[] | undefined
subscribers: Map<string, DocSubScriber> = new Map<string, DocSubScriber>()
@ -121,17 +121,30 @@ export class AggregationMiddleware extends BasePresentationMiddleware implements
return { unsubscribe: ret.unsubscribe }
}
private async getAggregationManager (_class: Ref<Class<Doc>>): Promise<AggregationManager | undefined> {
private async getAggregationManager (_class: Ref<Class<Doc>>): Promise<IAggregationManager<any> | undefined> {
let mgr = this.mgrs.get(_class)
if (mgr === undefined) {
const h = this.client.getHierarchy()
const mixin = h.classHierarchyMixin(_class, view.mixin.Aggregation)
if (mixin?.createAggregationManager !== undefined) {
if (
mixin?.createAggregationManager !== undefined &&
mixin?.setStoreFunc !== undefined &&
mixin?.filterFunc !== undefined &&
mixin?._class !== undefined
) {
const f = await getResource(mixin.createAggregationManager)
mgr = f(this.client, () => {
this.refreshSubscribers()
})
const storeFunc = await getResource(mixin.setStoreFunc)
const filterFunc = await getResource(mixin.filterFunc)
mgr = f(
this.client,
() => {
this.refreshSubscribers()
},
storeFunc,
filterFunc,
_class
)
this.mgrs.set(_class, mgr)
}
}

View File

@ -49,7 +49,12 @@ import core, {
type TxOperations,
type TxUpdateDoc,
type TypeAny,
type TypedSpace
type TypedSpace,
type WithLookup,
type AnyAttribute,
DocManager,
SortingOrder,
type Tx
} from '@hcengineering/core'
import { type Restrictions } from '@hcengineering/guest'
import type { Asset, IntlString } from '@hcengineering/platform'
@ -63,6 +68,7 @@ import {
isAdminUser,
type KeyedAttribute
} from '@hcengineering/presentation'
import { LiveQuery } from '@hcengineering/query'
import { type CollaborationUser } from '@hcengineering/text-editor'
import {
ErrorPresenter,
@ -79,6 +85,7 @@ import {
type Location
} from '@hcengineering/ui'
import view, {
type IAggregationManager,
AttributeCategoryOrder,
type AttributeCategory,
type AttributeModel,
@ -105,6 +112,102 @@ export interface LoadingProps {
length: number
}
/**
* @public
*/
export class AggregationManager<T extends Doc> implements IAggregationManager<T> {
docs: T[] | undefined
mgr: DocManager<T> | Promise<DocManager<T>> | undefined
query: (() => void) | undefined
lq: LiveQuery
lqCallback: () => void
private readonly setStore: (manager: DocManager<T>) => void
private readonly filter: (doc: T, target: T) => boolean
private readonly _class: Ref<Class<T>>
private constructor (
client: Client,
lqCallback: () => void,
setStore: (manager: DocManager<T>) => void,
categorizingFunc: (doc: T, target: T) => boolean,
_class: Ref<Class<T>>
) {
this.lq = new LiveQuery(client)
this.lqCallback = lqCallback ?? (() => {})
this.setStore = setStore
this.filter = categorizingFunc
this._class = _class
void this.getManager()
}
static create<T extends Doc>(
client: Client,
lqCallback: () => void,
setStore: (manager: DocManager<T>) => void,
categorizingFunc: (doc: T, target: T) => boolean,
_class: Ref<Class<T>>
): AggregationManager<T> {
return new AggregationManager<T>(client, lqCallback, setStore, categorizingFunc, _class)
}
private async getManager (): Promise<DocManager<T>> {
if (this.mgr !== undefined) {
if (this.mgr instanceof Promise) {
this.mgr = await this.mgr
}
return this.mgr
}
this.mgr = new Promise<DocManager<T>>((resolve) => {
this.query = this.lq.query(
this._class,
{},
(res) => {
const first = this.docs === undefined
this.docs = res
this.mgr = new DocManager<T>(res as T[])
this.setStore(this.mgr)
if (!first) {
this.lqCallback()
}
resolve(this.mgr)
},
{
sort: {
label: SortingOrder.Ascending
}
}
)
})
return await this.mgr
}
close (): void {
this.query?.()
}
async notifyTx (...tx: Tx[]): Promise<void> {
await this.lq.tx(...tx)
}
getAttrClass (): Ref<Class<T>> {
return this._class
}
async categorize (target: Array<Ref<T>>, attr: AnyAttribute): Promise<Array<Ref<T>>> {
const mgr = await this.getManager()
for (const sid of [...target]) {
const c = mgr.getIdMap().get(sid) as WithLookup<T>
if (c !== undefined) {
let docs = mgr.getDocs()
docs = docs.filter((it: T) => this.filter(it, c))
target.push(...docs.map((it) => it._id))
}
}
return target.filter((it, idx, arr) => arr.indexOf(it) === idx)
}
}
/**
* @public
*/

View File

@ -22,6 +22,7 @@ import {
Class,
Client,
Doc,
DocManager,
DocumentQuery,
FindOptions,
Hierarchy,
@ -373,29 +374,39 @@ export interface Groupping extends Class<Doc> {
/**
* @public
*/
export interface AggregationManager {
export interface IAggregationManager<T extends Doc> {
close: () => void
notifyTx: (...tx: Tx[]) => Promise<void>
categorize: (target: Array<Ref<Doc>>, attr: AnyAttribute) => Promise<Array<Ref<Doc>>>
getAttrClass: () => Ref<Class<Doc>>
updateSorting?: (finalOptions: FindOptions<Doc>, attr: AnyAttribute) => Promise<void>
categorize: (target: Array<Ref<T>>, attr: AnyAttribute) => Promise<Array<Ref<T>>>
getAttrClass: () => Ref<Class<T>>
updateSorting?: (finalOptions: FindOptions<T>, attr: AnyAttribute) => Promise<void>
}
/**
* @public
*/
export type AggregationManagerResource = Resource<AggregationManager>
export type AggregationManagerResource = Resource<IAggregationManager<any>>
/**
* @public
*/
export type CreateAggregationManagerFunc = Resource<(client: Client, lqCallback: () => void) => AggregationManager>
export type CreateAggregationManagerFunc = Resource<
(
client: Client,
lqCallback: () => void,
setStore: (manager: DocManager<any>) => void,
categorizingFunc: (doc: any, target: any) => boolean,
_class: Ref<Class<any>>
) => IAggregationManager<any>
>
/**
* @public
*/
export interface Aggregation extends Class<Doc> {
createAggregationManager: CreateAggregationManagerFunc
setStoreFunc: Resource<(manager: DocManager<any>) => void>
filterFunc: Resource<(doc: Doc, target: Doc) => boolean>
}
/**