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 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, { builder.mixin(contact.mixin.Employee, core.class.Class, view.mixin.ObjectEditor, {
editor: contact.component.EditEmployee, editor: contact.component.EditEmployee,
pinned: true pinned: true

View File

@ -16,7 +16,7 @@
import { contactId } from '@hcengineering/contact' import { contactId } from '@hcengineering/contact'
import contact from '@hcengineering/contact-resources/src/plugin' 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 ObjectSearchCategory, type ObjectSearchFactory } from '@hcengineering/model-presentation'
import { type NotificationGroup } from '@hcengineering/notification' import { type NotificationGroup } from '@hcengineering/notification'
import { type IntlString, mergeIds, type Resource } from '@hcengineering/platform' import { type IntlString, mergeIds, type Resource } from '@hcengineering/platform'
@ -139,6 +139,8 @@ export default mergeIds(contactId, contact, {
GetContactLastName: '' as Resource<TemplateFieldFunc>, GetContactLastName: '' as Resource<TemplateFieldFunc>,
ContactTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>, ContactTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>,
ChannelTitleProvider: '' 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) @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 { export class TAccount extends TDoc implements Account {
email!: string email!: string
role!: AccountRole role!: AccountRole

View File

@ -94,7 +94,13 @@ function defineSortAndGrouping (builder: Builder): void {
}) })
builder.mixin(tracker.class.Component, core.class.Class, view.mixin.Aggregation, { 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, { 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 DocUpdateMessageViewlet } from '@hcengineering/activity'
import { type ChatMessageViewlet } from '@hcengineering/chunter' 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 ObjectSearchCategory, type ObjectSearchFactory } from '@hcengineering/model-presentation'
import { type NotificationGroup, type NotificationType } from '@hcengineering/notification' import { type NotificationGroup, type NotificationType } from '@hcengineering/notification'
import { mergeIds, type IntlString, type Resource } from '@hcengineering/platform' import { mergeIds, type IntlString, type Resource } from '@hcengineering/platform'
@ -119,5 +119,10 @@ export default mergeIds(trackerId, tracker, {
Started: '' as Ref<StatusCategory>, Started: '' as Ref<StatusCategory>,
Completed: '' as Ref<StatusCategory>, Completed: '' as Ref<StatusCategory>,
Canceled: '' 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, DOMAIN_MODEL,
type Data, type Data,
type Doc, type Doc,
type DocManager,
type DocumentQuery, type DocumentQuery,
type Domain, type Domain,
type Ref, type Ref,
@ -274,6 +275,8 @@ export class TGroupping extends TClass implements Groupping {
@Mixin(view.mixin.Aggregation, core.class.Class) @Mixin(view.mixin.Aggregation, core.class.Class)
export class TAggregation extends TClass implements Aggregation { export class TAggregation extends TClass implements Aggregation {
createAggregationManager!: CreateAggregationManagerFunc createAggregationManager!: CreateAggregationManagerFunc
setStoreFunc!: Resource<(manager: DocManager<any>) => void>
filterFunc!: Resource<(doc: Doc, target: Doc) => boolean>
} }
@Mixin(view.mixin.ObjectIcon, core.class.Class) @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 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 * @public
*/ */
export class DocManager { export class DocManager<T extends Doc> implements IDocManager<T> {
protected readonly byId: IdMap<Doc> protected readonly byId: IdMap<T>
constructor (protected readonly docs: Doc[]) { constructor (protected readonly docs: T[]) {
this.byId = toIdMap(docs) this.byId = toIdMap(docs)
} }
get (ref: Ref<Doc>): Doc | undefined { get (ref: Ref<T>): T | undefined {
return this.byId.get(ref) return this.byId.get(ref)
} }
getDocs (): Doc[] { getDocs (): T[] {
return this.docs return this.docs
} }
getIdMap (): IdMap<Doc> { getIdMap (): IdMap<T> {
return this.byId return this.byId
} }
filter (predicate: (value: Doc) => boolean): Doc[] { filter (predicate: (value: T) => boolean): T[] {
return this.docs.filter(predicate) return this.docs.filter(predicate)
} }
} }

View File

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

View File

@ -15,12 +15,13 @@
--> -->
<script lang="ts"> <script lang="ts">
import { PersonAccount } from '@hcengineering/contact' import { PersonAccount } from '@hcengineering/contact'
import { Ref } from '@hcengineering/core' import { AggregateValue, Ref } from '@hcengineering/core'
import { IconSize } from '@hcengineering/ui' import { IconSize } from '@hcengineering/ui'
import { personAccountByIdStore } from '../utils' import { personAccountByIdStore } from '../utils'
import PersonAccountPresenter from './PersonAccountPresenter.svelte' 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 avatarSize: IconSize = 'x-small'
export let shouldShowAvatar: boolean = true export let shouldShowAvatar: boolean = true
export let shouldShowName: boolean = true export let shouldShowName: boolean = true
@ -30,7 +31,8 @@
export let noUnderline: boolean = false export let noUnderline: boolean = false
export let compact = 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> </script>
{#if account} {#if account}

View File

@ -15,14 +15,16 @@
// //
import { import {
type Channel,
type AvatarInfo,
type Contact,
getGravatarUrl, getGravatarUrl,
getName, getName,
type AvatarInfo, type Person,
type Channel, type PersonAccount
type Contact,
type Person
} from '@hcengineering/contact' } from '@hcengineering/contact'
import { import {
DocManager,
type Class, type Class,
type Client, type Client,
type Data, type Data,
@ -120,8 +122,9 @@ import NameChangedActivityMessage from './components/activity/NameChangedActivit
import IconAddMember from './components/icons/AddMember.svelte' import IconAddMember from './components/icons/AddMember.svelte'
import ExpandRightDouble from './components/icons/ExpandRightDouble.svelte' import ExpandRightDouble from './components/icons/ExpandRightDouble.svelte'
import IconMembers from './components/icons/Members.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 contact from './plugin'
import { import {
channelIdentifierProvider, channelIdentifierProvider,
@ -140,6 +143,7 @@ import {
getCurrentEmployeeName, getCurrentEmployeeName,
getCurrentEmployeePosition, getCurrentEmployeePosition,
getPersonTooltip, getPersonTooltip,
grouppingPersonManager,
resolveLocation resolveLocation
} from './utils' } 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 { export interface PersonLabelTooltip {
personLabel?: IntlString personLabel?: IntlString
placeholderLabel?: IntlString placeholderLabel?: IntlString
@ -431,9 +445,16 @@ export default async (): Promise<Resources> => ({
ContactTitleProvider: contactTitleProvider, ContactTitleProvider: contactTitleProvider,
PersonTooltipProvider: getPersonTooltip, PersonTooltipProvider: getPersonTooltip,
ChannelTitleProvider: channelTitleProvider, ChannelTitleProvider: channelTitleProvider,
ChannelIdentifierProvider: channelIdentifierProvider ChannelIdentifierProvider: channelIdentifierProvider,
SetPersonStore: setStore,
PersonFilterFunction: filterPerson
}, },
resolver: { resolver: {
Location: resolveLocation 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 Client, type Doc } from '@hcengineering/core'
import { type IntlString, mergeIds, type Resource } from '@hcengineering/platform' import { type IntlString, mergeIds, type Resource } from '@hcengineering/platform'
import { type LabelAndProps, type Location } from '@hcengineering/ui' 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, { export default mergeIds(contactId, contact, {
string: { string: {
@ -86,5 +91,9 @@ export default mergeIds(contactId, contact, {
FilterChannelHasMessagesResult: '' as FilterFunction, FilterChannelHasMessagesResult: '' as FilterFunction,
FilterChannelHasNewMessagesResult: '' as FilterFunction, FilterChannelHasNewMessagesResult: '' as FilterFunction,
PersonTooltipProvider: '' as Resource<(client: Client, doc?: Doc | null) => Promise<LabelAndProps | undefined>> 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 Timestamp,
type TxOperations, type TxOperations,
type UserStatus, type UserStatus,
type WithLookup type WithLookup,
AggregateValue,
type Space,
type Hierarchy,
type DocumentQuery,
AggregateValueData,
matchQuery
} from '@hcengineering/core' } from '@hcengineering/core'
import notification, { type DocNotifyContext, type InboxNotification } from '@hcengineering/notification' import notification, { type DocNotifyContext, type InboxNotification } from '@hcengineering/notification'
import { getEmbeddedLabel, getResource, translate } from '@hcengineering/platform' import { getEmbeddedLabel, getResource, translate } from '@hcengineering/platform'
@ -55,11 +61,12 @@ import {
type ResolvedLocation, type ResolvedLocation,
type TabItem type TabItem
} from '@hcengineering/ui' } 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 { FilterQuery, accessDeniedStore } from '@hcengineering/view-resources'
import { derived, get, writable } from 'svelte/store' import { derived, get, writable } from 'svelte/store'
import contact from './plugin' import contact from './plugin'
import { personStore } from '.'
export function formatDate (dueDateMs: Timestamp): string { export function formatDate (dueDateMs: Timestamp): string {
return new Date(dueDateMs).toLocaleString('default', { return new Date(dueDateMs).toLocaleString('default', {
@ -431,3 +438,109 @@ export async function channelTitleProvider (client: Client, ref: Ref<Channel>, d
return channel.value 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 { import {
AggregateValue, AggregateValue,
AggregateValueData, AggregateValueData,
type AnyAttribute,
type Class, type Class,
type Client,
type Doc, type Doc,
DocManager,
type DocumentQuery, type DocumentQuery,
type Hierarchy, type Hierarchy,
type Ref, type Ref,
SortingOrder,
type Space, type Space,
type Tx,
type WithLookup, type WithLookup,
matchQuery matchQuery
} from '@hcengineering/core' } from '@hcengineering/core'
import { LiveQuery } from '@hcengineering/query' import { type Component } from '@hcengineering/tracker'
import tracker, { type Component, ComponentManager } from '@hcengineering/tracker' import { type GrouppingManager } from '@hcengineering/view'
import { type AggregationManager, type GrouppingManager } from '@hcengineering/view'
import { get, writable } from 'svelte/store' import { get, writable } from 'svelte/store'
export const componentStore = writable<ComponentManager>(new ComponentManager([])) export const componentStore = writable<DocManager<Component>>(new DocManager([]))
/**
* @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)
}
}
/** /**
* @public * @public
@ -136,6 +53,7 @@ export function groupByComponentCategories (categories: any[]): AggregateValue[]
const usedSpaces = new Set<Ref<Space>>() const usedSpaces = new Set<Ref<Space>>()
const componentsList: Array<WithLookup<Component>> = [] const componentsList: Array<WithLookup<Component>> = []
// console.log('mgr docs', mgr.getDocs())
for (const v of categories) { for (const v of categories) {
const component = mgr.getIdMap().get(v) const component = mgr.getIdMap().get(v)
if (component !== undefined) { if (component !== undefined) {

View File

@ -30,12 +30,13 @@ import core, {
type Ref, type Ref,
type RelatedDocument, type RelatedDocument,
type TxOperations, type TxOperations,
type DocManager,
AccountRole AccountRole
} from '@hcengineering/core' } from '@hcengineering/core'
import chunter, { type ChatMessage } from '@hcengineering/chunter' import chunter, { type ChatMessage } from '@hcengineering/chunter'
import { type Status, translate, type Resources } from '@hcengineering/platform' import { type Status, translate, type Resources } from '@hcengineering/platform'
import { getClient, MessageBox, type ObjectSearchResult } from '@hcengineering/presentation' 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 { getCurrentLocation, navigate, showPopup, themeStore } from '@hcengineering/ui'
import ComponentEditor from './components/components/ComponentEditor.svelte' import ComponentEditor from './components/components/ComponentEditor.svelte'
import ComponentFilterValuePresenter from './components/components/ComponentFilterValuePresenter.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 IssueTemplatePresenter from './components/templates/IssueTemplatePresenter.svelte'
import IssueTemplates from './components/templates/IssueTemplates.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 MoveAndDeleteMilestonePopup from './components/milestones/MoveAndDeleteMilestonePopup.svelte'
import EditIssueTemplate from './components/templates/EditIssueTemplate.svelte' import EditIssueTemplate from './components/templates/EditIssueTemplate.svelte'
import TemplateEstimationEditor from './components/templates/EstimationEditor.svelte' import TemplateEstimationEditor from './components/templates/EstimationEditor.svelte'
@ -143,7 +144,7 @@ import {
subIssueQuery subIssueQuery
} from './utils' } from './utils'
import { ComponentAggregationManager, grouppingComponentManager } from './component' import { componentStore, grouppingComponentManager } from './component'
import PriorityIcon from './components/activity/PriorityIcon.svelte' import PriorityIcon from './components/activity/PriorityIcon.svelte'
import StatusIcon from './components/activity/StatusIcon.svelte' import StatusIcon from './components/activity/StatusIcon.svelte'
import DeleteComponentPresenter from './components/components/DeleteComponentPresenter.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> => ({ export default async (): Promise<Resources> => ({
activity: { activity: {
PriorityIcon, PriorityIcon,
@ -710,7 +719,9 @@ export default async (): Promise<Resources> => ({
GetVisibleFilters: getVisibleFilters, GetVisibleFilters: getVisibleFilters,
IssueChatTitleProvider: getIssueChatTitle, IssueChatTitleProvider: getIssueChatTitle,
IsProjectJoined: async (project: Project) => project.members.includes(getCurrentAccount()._id), IsProjectJoined: async (project: Project) => project.members.includes(getCurrentAccount()._id),
GetIssueStatusCategories: getIssueStatusCategories GetIssueStatusCategories: getIssueStatusCategories,
SetComponentStore: setStore,
ComponentFilterFunction: filterComponents
}, },
actionImpl: { actionImpl: {
Move: move, Move: move,
@ -726,7 +737,7 @@ export default async (): Promise<Resources> => ({
}, },
aggregation: { aggregation: {
// eslint-disable-next-line @typescript-eslint/unbound-method // eslint-disable-next-line @typescript-eslint/unbound-method
CreateComponentAggregationManager: ComponentAggregationManager.create, CreateComponentAggregationManager: AggregationManager.create,
GrouppingComponentManager: grouppingComponentManager GrouppingComponentManager: grouppingComponentManager
} }
}) })

View File

@ -21,8 +21,6 @@ import {
CollectionSize, CollectionSize,
Data, Data,
Doc, Doc,
DocManager,
IdMap,
Markup, Markup,
Mixin, Mixin,
Ref, Ref,
@ -30,8 +28,7 @@ import {
Space, Space,
Status, Status,
Timestamp, Timestamp,
Type, Type
WithLookup
} from '@hcengineering/core' } from '@hcengineering/core'
import { Asset, IntlString, Plugin, Resource, plugin } from '@hcengineering/platform' import { Asset, IntlString, Plugin, Resource, plugin } from '@hcengineering/platform'
import { Preference } from '@hcengineering/preference' import { Preference } from '@hcengineering/preference'
@ -351,29 +348,6 @@ export interface Component extends Doc {
attachments?: number 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 * @public
*/ */

View File

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

View File

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

View File

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

View File

@ -49,7 +49,12 @@ import core, {
type TxOperations, type TxOperations,
type TxUpdateDoc, type TxUpdateDoc,
type TypeAny, type TypeAny,
type TypedSpace type TypedSpace,
type WithLookup,
type AnyAttribute,
DocManager,
SortingOrder,
type Tx
} from '@hcengineering/core' } from '@hcengineering/core'
import { type Restrictions } from '@hcengineering/guest' import { type Restrictions } from '@hcengineering/guest'
import type { Asset, IntlString } from '@hcengineering/platform' import type { Asset, IntlString } from '@hcengineering/platform'
@ -63,6 +68,7 @@ import {
isAdminUser, isAdminUser,
type KeyedAttribute type KeyedAttribute
} from '@hcengineering/presentation' } from '@hcengineering/presentation'
import { LiveQuery } from '@hcengineering/query'
import { type CollaborationUser } from '@hcengineering/text-editor' import { type CollaborationUser } from '@hcengineering/text-editor'
import { import {
ErrorPresenter, ErrorPresenter,
@ -79,6 +85,7 @@ import {
type Location type Location
} from '@hcengineering/ui' } from '@hcengineering/ui'
import view, { import view, {
type IAggregationManager,
AttributeCategoryOrder, AttributeCategoryOrder,
type AttributeCategory, type AttributeCategory,
type AttributeModel, type AttributeModel,
@ -105,6 +112,102 @@ export interface LoadingProps {
length: number 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 * @public
*/ */

View File

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