[UBER-197] Aggregate components properly (#3265)

Signed-off-by: Ruslan Bayandinov <wazsone@ya.ru>
This commit is contained in:
Ruslan Bayandinov 2023-06-06 17:06:32 +07:00 committed by GitHub
parent 00df8de669
commit 20da8625d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 1263 additions and 598 deletions

View File

@ -19743,7 +19743,7 @@ packages:
dev: false
file:projects/model-view.tgz_typescript@4.8.4:
resolution: {integrity: sha512-MXdgL834gD1r+h6dZaXCpGCSiHrO2TxGXDHfF951mTThYcbz2X5P950/XcADZgvC4riAXQoJRVCjn1iR1paanA==, tarball: file:projects/model-view.tgz}
resolution: {integrity: sha512-nswV/jP/DzqP9yt1TnthbVdziRUYEW3rs/mqG3VTSIwO0Z0Sy0hGbFDr21n8YjxBf756QK3qAItBlJwRq4N3Tg==, tarball: file:projects/model-view.tgz}
id: file:projects/model-view.tgz
name: '@rush-temp/model-view'
version: 0.0.0
@ -22070,7 +22070,7 @@ packages:
dev: false
file:projects/tracker-resources.tgz_a1d864769aaf53d09b76fe134ab55e60:
resolution: {integrity: sha512-jMfAS0PwGvid0QCtAiVe3bDOLx4PnRdtmz23urZXRSf+s9mtZNpKIGPNDic8wragI9GjAbjJ85bHt8fZaxsSlw==, tarball: file:projects/tracker-resources.tgz}
resolution: {integrity: sha512-aahQ2kYGfdS+98z6xeLjeFffJYcfU106RpeslK2u9g9zaAPmdZa6L72ZRFiEDkW1y6n2d3TpDasCCshMw2lEHg==, tarball: file:projects/tracker-resources.tgz}
id: file:projects/tracker-resources.tgz
name: '@rush-temp/tracker-resources'
version: 0.0.0
@ -22210,7 +22210,7 @@ packages:
dev: false
file:projects/view-resources.tgz_a1d864769aaf53d09b76fe134ab55e60:
resolution: {integrity: sha512-jeJXZ80aZypm/6JrOsW+XJ6z8mzAUf7CJpVfPP8qTYIV2FsTqYBY4aHK0roPkaUVEAd/Ox9oAwKDksoR8EgB/w==, tarball: file:projects/view-resources.tgz}
resolution: {integrity: sha512-Taw9Dsr8rIkwsDJBqW4FddqW78UyIqHptCBk+J2c4nJlYnEUYKzbJaszb0N/fOkTN43I72SoxOQlyygIH85sKw==, tarball: file:projects/view-resources.tgz}
id: file:projects/view-resources.tgz
name: '@rush-temp/view-resources'
version: 0.0.0
@ -22245,7 +22245,7 @@ packages:
dev: false
file:projects/view.tgz:
resolution: {integrity: sha512-BKoOzFnUY/wlXgnnf7GsXZi9v3rZao+9EUVJznob9Dd0PK919rBLq/3XtXduTsX9I5XL63X6aThlzp6BpUyCNQ==, tarball: file:projects/view.tgz}
resolution: {integrity: sha512-Q5nEEe6n/qr7hJMMUeD2WSC2/MDdStM4h1maedLCV4OB6t2x0HTmNxm7b8mjROeuTV9lsxUezlMuq2xnBbF7ug==, tarball: file:projects/view.tgz}
name: '@rush-temp/view'
version: 0.0.0
dependencies:
@ -22259,6 +22259,7 @@ packages:
eslint-plugin-n: 15.5.1_eslint@8.27.0
eslint-plugin-promise: 6.1.1_eslint@8.27.0
prettier: 2.8.8
svelte: 3.55.1
typescript: 4.8.4
transitivePeerDependencies:
- supports-color

View File

@ -16,10 +16,11 @@
import { DOMAIN_MODEL } from '@hcengineering/core'
import { Builder, Model } from '@hcengineering/model'
import core, { TDoc } from '@hcengineering/model-core'
import type { Asset, IntlString, Resource } from '@hcengineering/platform'
import { Asset, IntlString, Resource } from '@hcengineering/platform'
// Import types to prevent .svelte components to being exposed to type typescript.
import { ObjectSearchCategory, ObjectSearchFactory } from '@hcengineering/presentation/src/types'
import presentation from './plugin'
import { PresentationMiddlewareCreator, PresentationMiddlewareFactory } from '@hcengineering/presentation'
export { presentationId } from '@hcengineering/presentation/src/plugin'
export { default } from './plugin'
@ -34,6 +35,11 @@ export class TObjectSearchCategory extends TDoc implements ObjectSearchCategory
query!: Resource<ObjectSearchFactory>
}
export function createModel (builder: Builder): void {
builder.createModel(TObjectSearchCategory)
@Model(presentation.class.PresentationMiddlewareFactory, core.class.Doc, DOMAIN_MODEL)
export class TPresentationMiddlewareFactory extends TDoc implements PresentationMiddlewareFactory {
createPresentationMiddleware!: Resource<PresentationMiddlewareCreator>
}
export function createModel (builder: Builder): void {
builder.createModel(TObjectSearchCategory, TPresentationMiddlewareFactory)
}

View File

@ -14,8 +14,7 @@
//
import activity from '@hcengineering/activity'
import type { Employee, EmployeeAccount } from '@hcengineering/contact'
import contact from '@hcengineering/contact'
import contact, { Employee, EmployeeAccount } from '@hcengineering/contact'
import {
DOMAIN_MODEL,
DateRangeMode,
@ -1011,6 +1010,18 @@ export function createModel (builder: Builder): void {
inlineEditor: tracker.component.ComponentSelector
})
builder.mixin(tracker.class.Component, core.class.Class, view.mixin.AttributePresenter, {
presenter: tracker.component.ComponentRefPresenter
})
builder.mixin(tracker.class.Component, core.class.Class, view.mixin.Aggregation, {
createAggregationManager: tracker.aggregation.CreateComponentAggregationManager
})
builder.mixin(tracker.class.Component, core.class.Class, view.mixin.Groupping, {
grouppingManager: tracker.aggregation.GrouppingComponentManager
})
builder.mixin(tracker.class.Milestone, core.class.Class, view.mixin.ObjectPresenter, {
presenter: tracker.component.MilestonePresenter
})

View File

@ -34,6 +34,7 @@
"@hcengineering/model-core": "^0.6.0",
"@hcengineering/preference": "^0.6.6",
"@hcengineering/model-preference": "^0.6.0",
"@hcengineering/model-presentation": "^0.6.0"
"@hcengineering/model-presentation": "^0.6.0",
"@hcengineering/presentation": "^0.6.2"
}
}

View File

@ -13,14 +13,24 @@
// limitations under the License.
//
import type { Account, Class, Client, Data, Doc, DocumentQuery, Domain, Ref, Space } from '@hcengineering/core'
import { DOMAIN_MODEL } from '@hcengineering/core'
import Core, {
DOMAIN_MODEL,
Account,
Class,
Client,
Data,
Doc,
DocumentQuery,
Domain,
Ref,
Space
} from '@hcengineering/core'
import { Builder, Mixin, Model } from '@hcengineering/model'
import core, { TClass, TDoc } from '@hcengineering/model-core'
import preference, { TPreference } from '@hcengineering/model-preference'
import type { Asset, IntlString, Resource, Status } from '@hcengineering/platform'
import type { AnyComponent, Location } from '@hcengineering/ui'
import type {
import { Asset, IntlString, Resource, Status } from '@hcengineering/platform'
import { AnyComponent, Location } from '@hcengineering/ui'
import {
Action,
ActionCategory,
ActivityAttributePresenter,
@ -68,8 +78,13 @@ import type {
ViewOptionsModel,
Viewlet,
ViewletDescriptor,
ViewletPreference
ViewletPreference,
Aggregation,
CreateAggregationManagerFunc,
GrouppingManagerResource,
Groupping
} from '@hcengineering/view'
import presentation from '@hcengineering/model-presentation'
import view from './plugin'
export { viewId } from '@hcengineering/view'
@ -260,6 +275,16 @@ export class TAllValuesFunc extends TClass implements AllValuesFunc {
func!: GetAllValuesFunc
}
@Mixin(view.mixin.Groupping, core.class.Class)
export class TGroupping extends TClass implements Groupping {
grouppingManager!: GrouppingManagerResource
}
@Mixin(view.mixin.Aggregation, core.class.Class)
export class TAggregation extends TClass implements Aggregation {
createAggregationManager!: CreateAggregationManagerFunc
}
@Model(view.class.ViewletPreference, preference.class.Preference)
export class TViewletPreference extends TPreference implements ViewletPreference {
attachedTo!: Ref<Viewlet>
@ -408,7 +433,9 @@ export function createModel (builder: Builder): void {
TArrayEditor,
TInlineAttributEditor,
TFilteredView,
TAllValuesFunc
TAllValuesFunc,
TAggregation,
TGroupping
)
classPresenter(
@ -517,6 +544,15 @@ export function createModel (builder: Builder): void {
view.viewlet.List
)
builder.createDoc(
presentation.class.PresentationMiddlewareFactory,
core.space.Model,
{
createPresentationMiddleware: view.function.CreateDocMiddleware
},
view.pipeline.PresentationMiddleware
)
createAction(
builder,
{
@ -990,6 +1026,14 @@ export function createModel (builder: Builder): void {
builder.mixin(core.class.Status, core.class.Class, view.mixin.AttributePresenter, {
presenter: view.component.StatusRefPresenter
})
builder.mixin(Core.class.Status, core.class.Class, view.mixin.Aggregation, {
createAggregationManager: view.aggregation.CreateStatusAggregationManager
})
builder.mixin(Core.class.Status, core.class.Class, view.mixin.Groupping, {
grouppingManager: view.aggregation.GrouppingStatusManager
})
}
export default view

View File

@ -13,9 +13,11 @@
// limitations under the License.
//
import { Ref } from '@hcengineering/core'
import { IntlString, mergeIds } from '@hcengineering/platform'
import type { AnyComponent } from '@hcengineering/ui'
import { AnyComponent } from '@hcengineering/ui'
import { FilterFunction, ViewAction, ViewCategoryAction, viewId } from '@hcengineering/view'
import { PresentationMiddlewareFactory } from '@hcengineering/presentation'
import view from '@hcengineering/view-resources/src/plugin'
export default mergeIds(viewId, view, {
@ -118,5 +120,8 @@ export default mergeIds(viewId, view, {
FilterDateNotSpecified: '' as FilterFunction,
FilterDateCustom: '' as FilterFunction,
ShowEmptyGroups: '' as ViewCategoryAction
},
pipeline: {
PresentationMiddleware: '' as Ref<PresentationMiddlewareFactory>
}
})

View File

@ -15,8 +15,8 @@
import { Asset, IntlString } from '@hcengineering/platform'
import { Attribute, Doc, Domain, Ref } from './classes'
import { AggregateValue, AggregateValueData, DocManager, IdMap } from './utils'
import { WithLookup } from './storage'
import { IdMap, toIdMap } from './utils'
/**
* @public
@ -60,8 +60,14 @@ export interface Status extends Doc {
/**
* @public
*/
export class StatusValue {
constructor (readonly name: string, readonly color: number | undefined, readonly values: WithLookup<Status>[]) {}
export class StatusValue extends AggregateValue {
constructor (
readonly name: string | undefined,
readonly color: number | undefined,
readonly values: AggregateValueData[]
) {
super(name, values)
}
}
/**
@ -69,23 +75,20 @@ export class StatusValue {
*
* Allow to query for status keys/values.
*/
export class StatusManager {
byId: IdMap<WithLookup<Status>>
constructor (readonly statuses: WithLookup<Status>[]) {
this.byId = toIdMap(statuses)
export class StatusManager extends DocManager {
get (ref: Ref<WithLookup<Status>>): WithLookup<Status> | undefined {
return this.getIdMap().get(ref) as WithLookup<Status>
}
get (ref: Ref<Status>): WithLookup<Status> | undefined {
return this.byId.get(ref)
getDocs (): Array<WithLookup<Status>> {
return this.docs as Status[]
}
filter (predicate: (value: WithLookup<Status>) => boolean): WithLookup<Status>[] {
return this.statuses.filter(predicate)
getIdMap (): IdMap<WithLookup<Status>> {
return this.byId as IdMap<WithLookup<Status>>
}
filter (predicate: (value: Status) => boolean): Status[] {
return this.getDocs().filter(predicate)
}
}
/**
* @public
*/
export type CategoryType = number | string | undefined | Ref<Doc> | StatusValue

View File

@ -13,7 +13,7 @@
// limitations under the License.
//
import { Account, AnyAttribute, Class, Doc, DocData, DocIndexState, IndexKind, Obj, Ref } from './classes'
import { Account, AnyAttribute, Class, Doc, DocData, DocIndexState, IndexKind, Obj, Ref, Space } from './classes'
import core from './component'
import { Hierarchy } from './hierarchy'
import { FindResult } from './storage'
@ -224,3 +224,59 @@ export function fillDefaults<T extends Doc> (
}
return object
}
/**
* @public
*/
export class AggregateValueData {
constructor (
readonly name: string,
readonly _id: Ref<Doc>,
readonly space: Ref<Space>,
readonly rank?: string,
readonly category?: Ref<Doc>
) {}
getRank (): string {
return this.rank ?? ''
}
}
/**
* @public
*/
export class AggregateValue {
constructor (readonly name: string | undefined, readonly values: AggregateValueData[]) {}
}
/**
* @public
*/
export type CategoryType = number | string | undefined | Ref<Doc> | AggregateValue
/**
* @public
*/
export class DocManager {
protected readonly byId: IdMap<Doc>
constructor (protected readonly docs: Doc[]) {
this.byId = toIdMap(docs)
}
get (ref: Ref<Doc>): Doc | undefined {
return this.byId.get(ref)
}
getDocs (): Doc[] {
return this.docs
}
getIdMap (): IdMap<Doc> {
return this.byId
}
filter (predicate: (value: Doc) => boolean): Doc[] {
return this.docs.filter(predicate)
}
}

View File

@ -17,8 +17,6 @@ import core, { PluginConfiguration, SortingOrder } from '@hcengineering/core'
import { Plugin, Resource, getResourcePlugin } from '@hcengineering/platform'
import { writable } from 'svelte/store'
import { createQuery } from '.'
import { statusStore } from './status'
export { statusStore }
/**
* @public

View File

@ -49,6 +49,7 @@ export * from './drafts'
export { presentationId }
export * from './configuration'
export * from './context'
export * from './pipeline'
addStringsLoader(presentationId, async (lang: string) => {
return await import(`../lang/${lang}.json`)

View File

@ -12,6 +12,7 @@ import {
TxResult,
WithLookup
} from '@hcengineering/core'
import { Resource } from '@hcengineering/platform'
/**
* @public
@ -232,3 +233,10 @@ export abstract class BasePresentationMiddleware {
return { unsubscribe: () => {} }
}
}
/**
* @public
*/
export interface PresentationMiddlewareFactory extends Doc {
createPresentationMiddleware: Resource<PresentationMiddlewareCreator>
}

View File

@ -18,6 +18,7 @@ import { Class, Ref } from '@hcengineering/core'
import type { Asset, IntlString, Metadata, Plugin } from '@hcengineering/platform'
import { plugin } from '@hcengineering/platform'
import { ObjectSearchCategory } from './types'
import { PresentationMiddlewareFactory } from './pipeline'
/**
* @public
@ -26,7 +27,8 @@ export const presentationId = 'presentation' as Plugin
export default plugin(presentationId, {
class: {
ObjectSearchCategory: '' as Ref<Class<ObjectSearchCategory>>
ObjectSearchCategory: '' as Ref<Class<ObjectSearchCategory>>,
PresentationMiddlewareFactory: '' as Ref<Class<PresentationMiddlewareFactory>>
},
string: {
Create: '' as IntlString,

View File

@ -1,316 +0,0 @@
//
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import core, {
AnyAttribute,
Attribute,
Class,
Client,
Doc,
DocumentQuery,
FindOptions,
FindResult,
generateId,
Hierarchy,
Ref,
RefTo,
SortingOrder,
SortingRules,
Status,
StatusManager,
Tx,
TxResult
} from '@hcengineering/core'
import { LiveQuery } from '@hcengineering/query'
import { writable } from 'svelte/store'
import { BasePresentationMiddleware, PresentationMiddleware } from './pipeline'
// Issue status live query
export const statusStore = writable<StatusManager>(new StatusManager([]))
interface StatusSubscriber<T extends Doc = Doc> {
attributes: Array<Ref<AnyAttribute>>
_class: Ref<Class<T>>
query: DocumentQuery<T>
options?: FindOptions<T>
refresh: () => void
}
/**
* @public
*/
export class StatusMiddleware extends BasePresentationMiddleware implements PresentationMiddleware {
mgr: StatusManager | Promise<StatusManager> | undefined
status: Status[] | undefined
statusQuery: (() => void) | undefined
lq: LiveQuery
subscribers: Map<string, StatusSubscriber> = new Map()
private constructor (client: Client, next?: PresentationMiddleware) {
super(client, next)
this.lq = new LiveQuery(client)
}
async notifyTx (tx: Tx): Promise<void> {
await this.lq.tx(tx)
await this.provideNotifyTx(tx)
}
async close (): Promise<void> {
this.statusQuery?.()
return await this.provideClose()
}
async getManager (): Promise<StatusManager> {
if (this.mgr !== undefined) {
if (this.mgr instanceof Promise) {
this.mgr = await this.mgr
}
return this.mgr
}
this.mgr = new Promise<StatusManager>((resolve) => {
this.statusQuery = this.lq.query(
core.class.Status,
{},
(res) => {
const first = this.status === undefined
this.status = res
this.mgr = new StatusManager(res)
statusStore.set(this.mgr)
if (!first) {
this.refreshSubscribers()
}
resolve(this.mgr)
},
{
lookup: {
category: core.class.StatusCategory
},
sort: {
rank: SortingOrder.Ascending
}
}
)
})
return await this.mgr
}
private refreshSubscribers (): void {
for (const s of this.subscribers.values()) {
// TODO: Do something more smart and track if used status field is changed.
s.refresh()
}
}
async subscribe<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options: FindOptions<T> | undefined,
refresh: () => void
): Promise<{
unsubscribe: () => void
query?: DocumentQuery<T>
options?: FindOptions<T>
}> {
const ret = await this.provideSubscribe(_class, query, options, refresh)
const h = this.client.getHierarchy()
const id = generateId()
const s: StatusSubscriber<T> = {
_class,
query,
refresh,
options,
attributes: []
}
const statusFields: Array<Attribute<Status>> = []
const allAttrs = h.getAllAttributes(_class)
const updatedQuery: DocumentQuery<T> = { ...(ret.query ?? query) }
const finalOptions = { ...(ret.options ?? options ?? {}) }
await this.updateQueryOptions<T>(allAttrs, h, statusFields, updatedQuery, finalOptions)
if (statusFields.length > 0) {
this.subscribers.set(id, s)
return {
unsubscribe: () => {
ret.unsubscribe()
this.subscribers.delete(id)
},
query: updatedQuery,
options: finalOptions
}
}
return { unsubscribe: (await ret).unsubscribe }
}
static create (client: Client, next?: PresentationMiddleware): StatusMiddleware {
return new StatusMiddleware(client, next)
}
async findAll<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T> | undefined
): Promise<FindResult<T>> {
const statusFields: Array<Attribute<Status>> = []
const h = this.client.getHierarchy()
const allAttrs = h.getAllAttributes(_class)
const finalOptions = options ?? {}
await this.updateQueryOptions<T>(allAttrs, h, statusFields, query, finalOptions)
const result = await this.provideFindAll(_class, query, finalOptions)
// We need to add $
if (statusFields.length > 0) {
// We need to update $lookup for status fields and provide $status group fields.
for (const attr of statusFields) {
for (const r of result) {
const resultDoc = Hierarchy.toDoc(r)
if (resultDoc.$lookup === undefined) {
resultDoc.$lookup = {}
}
// TODO: Check for mixin?
const stateValue = (r as any)[attr.name]
const status = (await this.getManager()).byId.get(stateValue)
if (status !== undefined) {
;(resultDoc.$lookup as any)[attr.name] = status
}
}
}
}
return result
}
private categorizeStatus (mgr: StatusManager, attr: AnyAttribute, target: Array<Ref<Status>>): Array<Ref<Status>> {
for (const sid of [...target]) {
const s = mgr.byId.get(sid)
if (s !== undefined) {
const statuses = mgr.statuses.filter(
(it) =>
it.ofAttribute === attr._id &&
it.name.toLowerCase().trim() === s.name.toLowerCase().trim() &&
it._id !== s._id
)
target.push(...statuses.map((it) => it._id))
}
}
return target.filter((it, idx, arr) => arr.indexOf(it) === idx)
}
private async updateQueryOptions<T extends Doc>(
allAttrs: Map<string, AnyAttribute>,
h: Hierarchy,
statusFields: Array<Attribute<Status>>,
query: DocumentQuery<T>,
finalOptions: FindOptions<T>
): Promise<void> {
for (const attr of allAttrs.values()) {
try {
if (attr.type._class === core.class.RefTo && h.isDerived((attr.type as RefTo<Doc>).to, core.class.Status)) {
const mgr = await this.getManager()
let target: Array<Ref<Status>> = []
let targetNin: Array<Ref<Status>> = []
statusFields.push(attr)
const v = (query as any)[attr.name]
if (v != null) {
// Only add filter if we have filer inside.
if (typeof v === 'string') {
target.push(v as Ref<Status>)
} else {
if (v.$in !== undefined) {
target.push(...v.$in)
} else if (v.$nin !== undefined) {
targetNin.push(...v.$nin)
} else if (v.$ne !== undefined) {
targetNin.push(v.$ne)
}
}
// Find all similar name statues for same attribute name.
target = this.categorizeStatus(mgr, attr, target)
targetNin = this.categorizeStatus(mgr, attr, targetNin)
if (target.length > 0 || targetNin.length > 0) {
;(query as any)[attr.name] = {}
if (target.length > 0) {
;(query as any)[attr.name].$in = target
}
if (targetNin.length > 0) {
;(query as any)[attr.name].$nin = targetNin
}
}
}
if (finalOptions.lookup !== undefined) {
// Remove lookups by status field
if ((finalOptions.lookup as any)[attr.name] !== undefined) {
const { [attr.name]: _, ...newLookup } = finalOptions.lookup as any
finalOptions.lookup = newLookup
}
}
// Update sorting if defined.
this.updateCustomSorting<T>(finalOptions, attr, mgr)
}
} catch (err: any) {
console.error(err)
}
}
}
private updateCustomSorting<T extends Doc>(
finalOptions: FindOptions<T>,
attr: AnyAttribute,
mgr: StatusManager
): void {
const attrSort = finalOptions.sort?.[attr.name]
if (attrSort !== undefined && typeof attrSort !== 'object') {
// Fill custom sorting.
const statuses = mgr.statuses.filter((it) => it.ofAttribute === attr._id)
statuses.sort((a, b) => {
let ret = 0
if (a.category !== undefined && b.category !== undefined) {
ret = (a.$lookup?.category?.order ?? 0) - (b.$lookup?.category?.order ?? 0)
}
if (ret === 0) {
if (a.name.toLowerCase().trim() === b.name.toLowerCase().trim()) {
return 0
}
ret = a.rank.localeCompare(b.rank)
}
return ret
})
if (finalOptions.sort === undefined) {
finalOptions.sort = {}
}
const rules: SortingRules<any> = {
order: attrSort,
cases: statuses.map((it, idx) => ({ query: it._id, index: idx })),
default: statuses.length + 1
}
;(finalOptions.sort as any)[attr.name] = rules
}
}
async tx (tx: Tx): Promise<TxResult> {
return await this.provideTx(tx)
}
}

View File

@ -45,8 +45,6 @@ import { onDestroy } from 'svelte'
import { KeyedAttribute } from '..'
import { PresentationPipeline, PresentationPipelineImpl } from './pipeline'
import plugin from './plugin'
import { StatusMiddleware, statusStore } from './status'
export { statusStore }
let liveQuery: LQ
let client: TxOperations
@ -114,7 +112,10 @@ export async function setClient (_client: Client): Promise<void> {
if (pipeline !== undefined) {
await pipeline.close()
}
pipeline = PresentationPipelineImpl.create(_client, [StatusMiddleware.create])
const factories = await _client.findAll(plugin.class.PresentationMiddlewareFactory, {})
const promises = factories.map(async (it) => await getResource(it.createPresentationMiddleware))
const creators = await Promise.all(promises)
pipeline = PresentationPipelineImpl.create(_client, creators)
const needRefresh = liveQuery !== undefined
liveQuery = new LQ(pipeline)

View File

@ -26,7 +26,7 @@
} from '@hcengineering/core'
import { Item, Kanban as KanbanUI } from '@hcengineering/kanban'
import { getResource } from '@hcengineering/platform'
import { createQuery, getClient, statusStore, ActionContext } from '@hcengineering/presentation'
import { createQuery, getClient, ActionContext } from '@hcengineering/presentation'
import { Kanban, SpaceWithStates, Task, TaskGrouping, TaskOrdering } from '@hcengineering/task'
import {
ColorDefinition,
@ -173,7 +173,7 @@
viewOptions: ViewOptions,
viewOptionsModel: ViewOptionModel[] | undefined
) {
categories = await getCategories(client, _class, docs, groupByKey, $statusStore, viewlet.descriptor)
categories = await getCategories(client, _class, docs, groupByKey, viewlet.descriptor)
for (const viewOption of viewOptionsModel ?? []) {
if (viewOption.actionTarget !== 'category') continue
const categoryFunc = viewOption as CategoryOption
@ -191,7 +191,6 @@
groupByKey,
update,
queryId,
$statusStore,
viewlet.descriptor
)
if (res !== undefined) {

View File

@ -14,8 +14,8 @@
// limitations under the License.
-->
<script lang="ts">
import { Ref, StatusValue } from '@hcengineering/core'
import { statusStore } from '@hcengineering/presentation'
import { Ref, Status, StatusValue } from '@hcengineering/core'
import { statusStore } from '@hcengineering/view-resources'
import type { DoneState } from '@hcengineering/task'
import DoneStatePresenter from './DoneStatePresenter.svelte'
import DoneStateEditor from './DoneStateEditor.svelte'
@ -23,10 +23,11 @@
export let value: Ref<DoneState> | StatusValue
export let showTitle: boolean = true
export let onChange: ((value: Ref<DoneState>) => void) | undefined = undefined
$: state = $statusStore.get(typeof value === 'string' ? value : (value?.values?.[0]?._id as Ref<Status>))
</script>
{#if value}
{@const state = $statusStore.get(typeof value === 'string' ? value : value.values[0]._id)}
{#if onChange !== undefined && state !== undefined}
<DoneStateEditor value={state._id} space={state.space} {onChange} kind="link" size="medium" />
{:else}

View File

@ -27,8 +27,6 @@
export let value: State | undefined
export let shouldShowAvatar = true
export let inline: boolean = false
export let colorInherit: boolean = false
export let accent: boolean = false
export let disabled: boolean = false
export let oneLine: boolean = false

View File

@ -14,25 +14,23 @@
// limitations under the License.
-->
<script lang="ts">
import { Ref, StatusValue } from '@hcengineering/core'
import { statusStore } from '@hcengineering/presentation'
import { Ref, Status, StatusValue } from '@hcengineering/core'
import type { ButtonSize } from '@hcengineering/ui'
import { State } from '@hcengineering/task'
import { statusStore } from '@hcengineering/view-resources'
import StateEditor from './StateEditor.svelte'
import StatePresenter from './StatePresenter.svelte'
export let value: Ref<State> | StatusValue
export let onChange: ((value: Ref<State>) => void) | undefined = undefined
export let colorInherit: boolean = false
export let accent: boolean = false
export let size: ButtonSize = 'medium'
$: state = $statusStore.get(typeof value === 'string' ? value : (value?.values?.[0]?._id as Ref<Status>))
</script>
{#if value}
{@const state = $statusStore.get(typeof value === 'string' ? value : value.values?.[0]?._id)}
{#if onChange !== undefined && state !== undefined}
<StateEditor value={state._id} space={state.space} {onChange} kind="link" {size} />
{:else}
<StatePresenter value={state} {colorInherit} {accent} on:accent-color />
<StatePresenter value={state} on:accent-color />
{/if}
{/if}

View File

@ -15,9 +15,10 @@
-->
<script lang="ts">
import { Ref } from '@hcengineering/core'
import { BreadcrumbsElement, statusStore } from '@hcengineering/presentation'
import { BreadcrumbsElement } from '@hcengineering/presentation'
import task, { SpaceWithStates, State } from '@hcengineering/task'
import { ScrollerBar, getColorNumberByText, getPlatformColor, themeStore } from '@hcengineering/ui'
import { statusStore } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import type { StatesBarPosition } from '../..'

View File

@ -60,6 +60,7 @@
"@hcengineering/chunter-resources": "^0.6.0",
"@hcengineering/workbench-resources": "^0.6.1",
"@hcengineering/activity-resources": "^0.6.1",
"@hcengineering/activity": "^0.6.0"
"@hcengineering/activity": "^0.6.0",
"@hcengineering/query": "^0.6.5"
}
}

View File

@ -0,0 +1,238 @@
//
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import {
AggregateValue,
AggregateValueData,
AnyAttribute,
Attribute,
Class,
Client,
Doc,
DocumentQuery,
Hierarchy,
Ref,
SortingOrder,
Space,
Tx,
WithLookup,
matchQuery
} from '@hcengineering/core'
import { LiveQuery } from '@hcengineering/query'
import tracker, { Component, ComponentManager } from '@hcengineering/tracker'
import { AggregationManager, 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)
}
async updateLookup (resultDoc: WithLookup<Doc>, attr: Attribute<Doc>): Promise<void> {
const value = (resultDoc as any)[attr.name]
const doc = (await this.getManager()).getIdMap().get(value)
if (doc !== undefined) {
;(resultDoc.$lookup as any)[attr.name] = doc
}
}
}
/**
* @public
*/
export const grouppingComponentManager: GrouppingManager = {
groupByCategories: groupByComponentCategories,
groupValues: groupComponentValues,
groupValuesWithEmpty: groupComponentValuesWithEmpty,
hasValue: hasComponentValue
}
/**
* @public
*/
export function groupByComponentCategories (categories: any[]): AggregateValue[] {
const mgr = get(componentStore)
const existingCategories: AggregateValue[] = [new AggregateValue(undefined, [])]
const componentMap = new Map<string, AggregateValue>()
const usedSpaces = new Set<Ref<Space>>()
const componentsList: Array<WithLookup<Component>> = []
for (const v of categories) {
const component = mgr.getIdMap().get(v)
if (component !== undefined) {
componentsList.push(component)
usedSpaces.add(component.space)
}
}
for (const component of componentsList) {
if (component !== undefined) {
let fst = componentMap.get(component.label.toLowerCase().trim())
if (fst === undefined) {
const components = mgr
.getDocs()
.filter(
(it) =>
it.label.toLowerCase().trim() === component.label.toLowerCase().trim() &&
(categories.includes(it._id) || usedSpaces.has(it.space))
)
.sort((a, b) => a.label.localeCompare(b.label))
.map((it) => new AggregateValueData(it.label, it._id, it.space))
fst = new AggregateValue(component.label, components)
componentMap.set(component.label.toLowerCase().trim(), fst)
existingCategories.push(fst)
}
}
}
return existingCategories
}
/**
* @public
*/
export function groupComponentValues (val: Doc[], targets: Set<any>): Doc[] {
const values = val
const result: Doc[] = []
const unique = [...new Set(val.map((c) => (c as Component).label.trim().toLocaleLowerCase()))]
unique.forEach((label, i) => {
let exists = false
values.forEach((c) => {
if ((c as Component).label.trim().toLocaleLowerCase() === label) {
if (!exists) {
result[i] = c
exists = targets.has(c?._id)
}
}
})
})
return result
}
/**
* @public
*/
export function hasComponentValue (value: Doc | undefined | null, values: any[]): boolean {
const mgr = get(componentStore)
const componentSet = new Set(
mgr
.filter((it) => it.label.trim().toLocaleLowerCase() === (value as Component)?.label?.trim()?.toLocaleLowerCase())
.map((it) => it._id)
)
return values.some((it) => componentSet.has(it))
}
/**
* @public
*/
export function groupComponentValuesWithEmpty (
hierarchy: Hierarchy,
_class: Ref<Class<Doc>>,
key: string,
query: DocumentQuery<Doc> | undefined
): Array<Ref<Doc>> {
const mgr = get(componentStore)
// We do not need extensions for all status categories.
let componentsList = 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
}
componentsList = matchQuery<Doc>(componentsList, resQuery, _class, hierarchy) as unknown as Array<
WithLookup<Component>
>
}
return componentsList.map((it) => it._id)
}

View File

@ -14,14 +14,14 @@
-->
<script lang="ts">
import { TxUpdateDoc } from '@hcengineering/core'
import { statusStore } from '@hcengineering/presentation'
import { Issue } from '@hcengineering/tracker'
import { statusStore } from '@hcengineering/view-resources'
import IssueStatusIcon from '../issues/IssueStatusIcon.svelte'
export let tx: TxUpdateDoc<Issue>
$: value = tx.operations.status
$: status = value && $statusStore.byId.get(value)
$: status = value && $statusStore.getIdMap().get(value)
</script>
<div class="icon">

View File

@ -19,8 +19,9 @@
import tracker from '../../plugin'
import view from '@hcengineering/view'
import { DocNavLink } from '@hcengineering/view-resources'
import { translate } from '@hcengineering/platform'
export let value: WithLookup<Component>
export let value: WithLookup<Component> | undefined
export let shouldShowAvatar = true
export let onClick: (() => void) | undefined = undefined
export let disabled = false
@ -28,24 +29,32 @@
export let accent: boolean = false
export let noUnderline = false
export let kind: 'list' | undefined = undefined
let label: string
$: if (value !== undefined) {
label = value.label
} else {
translate(tracker.string.NoComponent, {})
.then((r) => {
label = r
})
.catch((err) => {
console.error(err)
})
}
$: disabled = disabled || value === undefined
</script>
{#if value}
<DocNavLink object={value} {onClick} {disabled} {noUnderline} {inline} {accent} component={view.component.EditDoc}>
<span class="flex-presenter" class:inline-presenter={inline} class:list={kind === 'list'}>
{#if !inline && shouldShowAvatar}
<div class="icon" use:tooltip={{ label: tracker.string.Component }}>
<Icon icon={tracker.icon.Component} size={'small'} />
</div>
{/if}
<span
title={value.label}
class="label nowrap"
class:no-underline={disabled || noUnderline}
class:fs-bold={accent}
>
{value.label}
</span>
<DocNavLink object={value} {onClick} {disabled} {noUnderline} {inline} {accent} component={view.component.EditDoc}>
<span class="flex-presenter" class:inline-presenter={inline} class:list={kind === 'list'}>
{#if !inline && shouldShowAvatar}
<div class="icon" use:tooltip={{ label: tracker.string.Component }}>
<Icon icon={tracker.icon.Component} size={'small'} />
</div>
{/if}
<span title={label} class="label nowrap" class:no-underline={disabled || noUnderline} class:fs-bold={accent}>
{label}
</span>
</DocNavLink>
{/if}
</span>
</DocNavLink>

View File

@ -0,0 +1,30 @@
<!--
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { AggregateValue, Ref } from '@hcengineering/core'
import ComponentPresenter from './ComponentPresenter.svelte'
import { Component } from '@hcengineering/tracker'
import { componentStore } from '../../component'
export let value: Ref<Component> | AggregateValue | undefined
export let kind: 'list' | undefined = undefined
$: componentValue = $componentStore.get(
typeof value === 'string' ? value : (value?.values?.[0]?._id as Ref<Component>)
)
</script>
<ComponentPresenter value={componentValue} {kind} on:accent-color />

View File

@ -3,8 +3,8 @@
import { createQuery } from '@hcengineering/presentation'
import { Issue, IssueStatus } from '@hcengineering/tracker'
import { Label, ticker, Row } from '@hcengineering/ui'
import { statusStore } from '@hcengineering/view-resources'
import tracker from '../../plugin'
import { statusStore } from '@hcengineering/presentation'
import Duration from './Duration.svelte'
import StatusPresenter from './StatusPresenter.svelte'
@ -84,7 +84,7 @@
displaySt = result
}
$: updateStatus(txes, $statusStore.byId, $ticker)
$: updateStatus(txes, $statusStore.getIdMap(), $ticker)
</script>
<Row>

View File

@ -14,9 +14,10 @@
-->
<script lang="ts">
import core, { StatusCategory, WithLookup } from '@hcengineering/core'
import { getClient, statusStore } from '@hcengineering/presentation'
import { getClient } from '@hcengineering/presentation'
import { IssueStatus } from '@hcengineering/tracker'
import { IconSize } from '@hcengineering/ui'
import { statusStore } from '@hcengineering/view-resources'
import tracker from '../../plugin'
import StatusIcon from '../icons/StatusIcon.svelte'

View File

@ -29,7 +29,7 @@
import { Item, Kanban } from '@hcengineering/kanban'
import notification from '@hcengineering/notification'
import { getResource } from '@hcengineering/platform'
import { createQuery, getClient, statusStore, ActionContext } from '@hcengineering/presentation'
import { createQuery, getClient, ActionContext } from '@hcengineering/presentation'
import tags from '@hcengineering/tags'
import { Issue, IssuesGrouping, IssuesOrdering, Project } from '@hcengineering/tracker'
import {
@ -197,7 +197,7 @@
viewOptions: ViewOptions,
viewOptionsModel: ViewOptionModel[] | undefined
) {
categories = await getCategories(client, _class, docs, groupByKey, $statusStore, viewlet.descriptor)
categories = await getCategories(client, _class, docs, groupByKey, viewlet.descriptor)
for (const viewOption of viewOptionsModel ?? []) {
if (viewOption.actionTarget !== 'category') continue
const categoryFunc = viewOption as CategoryOption
@ -214,7 +214,6 @@
groupByKey,
update,
queryId,
$statusStore,
viewlet.descriptor
)
if (res !== undefined) {

View File

@ -15,10 +15,11 @@
-->
<script lang="ts">
import { DocumentUpdate, Ref } from '@hcengineering/core'
import { SpaceSelect, createQuery, getClient, statusStore } from '@hcengineering/presentation'
import { SpaceSelect, createQuery, getClient } from '@hcengineering/presentation'
import { Component, Issue, Project } from '@hcengineering/tracker'
import ui, { Button, Label, Spinner } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { statusStore } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import tracker from '../../plugin'
import { collectIssues, findTargetStatus, moveIssueToSpace } from '../../utils'

View File

@ -14,10 +14,19 @@
-->
<script lang="ts">
import { AttachedData, Ref, StatusManager, WithLookup } from '@hcengineering/core'
import { getClient, statusStore } from '@hcengineering/presentation'
import { getClient } from '@hcengineering/presentation'
import { Issue, IssueDraft, IssueStatus, Project } from '@hcengineering/tracker'
import type { ButtonKind, ButtonSize, IconSize } from '@hcengineering/ui'
import { Button, SelectPopup, TooltipAlignment, eventToHTMLElement, showPopup } from '@hcengineering/ui'
import {
ButtonKind,
ButtonSize,
IconSize,
Button,
SelectPopup,
TooltipAlignment,
eventToHTMLElement,
showPopup
} from '@hcengineering/ui'
import { statusStore } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import tracker from '../../plugin'
import IssueStatusIcon from './IssueStatusIcon.svelte'

View File

@ -15,8 +15,9 @@
<script lang="ts">
import { IssueStatus } from '@hcengineering/tracker'
import IssueStatusIcon from './IssueStatusIcon.svelte'
import { createQuery, statusStore } from '@hcengineering/presentation'
import { createQuery } from '@hcengineering/presentation'
import core, { IdMap, Ref, StatusCategory, toIdMap } from '@hcengineering/core'
import { statusStore } from '@hcengineering/view-resources'
export let value: Ref<IssueStatus>[]
@ -41,7 +42,7 @@
})
}
$: statuses = sort(value.map((p) => $statusStore.byId.get(p)) as IssueStatus[], categories)
$: statuses = sort(value.map((p) => $statusStore.getIdMap().get(p)) as IssueStatus[], categories)
</script>
<div class="flex-presenter flex-gap-1-5">

View File

@ -14,7 +14,7 @@
-->
<script lang="ts">
import { Ref, Status, StatusValue } from '@hcengineering/core'
import { statusStore } from '@hcengineering/presentation'
import { statusStore } from '@hcengineering/view-resources'
import StatusPresenter from './StatusPresenter.svelte'
export let value: Ref<Status> | StatusValue | undefined
@ -22,15 +22,10 @@
export let kind: 'list-header' | undefined = undefined
export let colorInherit: boolean = false
export let accent: boolean = false
$: statusValue = $statusStore.get(typeof value === 'string' ? value : (value?.values?.[0]?._id as Ref<Status>))
</script>
{#if value}
<StatusPresenter
value={$statusStore.get(typeof value === 'string' ? value : value.values?.[0]?._id)}
{size}
{kind}
{colorInherit}
{accent}
on:accent-color
/>
<StatusPresenter value={statusValue} {size} {kind} {colorInherit} {accent} on:accent-color />
{/if}

View File

@ -14,7 +14,7 @@
-->
<script lang="ts">
import core, { SortingOrder, WithLookup } from '@hcengineering/core'
import { createQuery, statusStore } from '@hcengineering/presentation'
import { createQuery } from '@hcengineering/presentation'
import { Issue, IssueStatus } from '@hcengineering/tracker'
import {
Icon,
@ -29,7 +29,7 @@
themeStore,
tooltip
} from '@hcengineering/ui'
import { ListSelectionProvider } from '@hcengineering/view-resources'
import { ListSelectionProvider, statusStore } from '@hcengineering/view-resources'
import { getIssueId, issueLinkFragmentProvider } from '../../../issues'
import tracker from '../../../plugin'
import IssueStatusIcon from '../IssueStatusIcon.svelte'
@ -118,7 +118,7 @@
subIssuesQeury.unsubscribe()
}
$: parentStatus = parentIssue ? $statusStore.byId.get(parentIssue.status) : undefined
$: parentStatus = parentIssue ? $statusStore.getIdMap().get(parentIssue.status) : undefined
</script>
{#if parentIssue}

View File

@ -14,7 +14,7 @@
-->
<script lang="ts">
import { Ref, SortingOrder, WithLookup } from '@hcengineering/core'
import { createQuery, statusStore } from '@hcengineering/presentation'
import { createQuery } from '@hcengineering/presentation'
import { Issue, Project } from '@hcengineering/tracker'
import {
Button,
@ -26,6 +26,7 @@
showPanel,
showPopup
} from '@hcengineering/ui'
import { statusStore } from '@hcengineering/view-resources'
import { getIssueId } from '../../../issues'
import tracker from '../../../plugin'
import { subIssueListProvider } from '../../../utils'
@ -77,7 +78,8 @@
}
$: if (subIssues) {
const doneStatuses = $statusStore.statuses
const doneStatuses = $statusStore
.getDocs()
.filter(
(s) =>
s.category === tracker.issueStatusCategory.Completed || s.category === tracker.issueStatusCategory.Canceled

View File

@ -14,9 +14,10 @@
-->
<script lang="ts">
import { Ref, Status } from '@hcengineering/core'
import { getClient, statusStore } from '@hcengineering/presentation'
import { getClient } from '@hcengineering/presentation'
import { Issue, Project } from '@hcengineering/tracker'
import { Button, Label } from '@hcengineering/ui'
import { statusStore } from '@hcengineering/view-resources'
import tracker from '../../../plugin'
import { findTargetStatus } from '../../../utils'
import StatusRefPresenter from '../StatusRefPresenter.svelte'

View File

@ -14,9 +14,9 @@
-->
<script lang="ts">
import { DocumentUpdate, Ref } from '@hcengineering/core'
import { statusStore } from '@hcengineering/presentation'
import { Issue, Project } from '@hcengineering/tracker'
import { Label } from '@hcengineering/ui'
import { statusStore } from '@hcengineering/view-resources'
import tracker from '../../../plugin'
import { findTargetStatus, issueToAttachedData } from '../../../utils'
import StatusEditor from '../StatusEditor.svelte'

View File

@ -14,7 +14,7 @@
-->
<script lang="ts">
import { Issue, Project } from '@hcengineering/tracker'
import { statusStore } from '@hcengineering/presentation'
import { statusStore } from '@hcengineering/view-resources'
import IssueStatusIcon from '../IssueStatusIcon.svelte'
import { getIssueId } from '../../../issues'
@ -23,7 +23,7 @@
export let issue: Issue
export let size: 'small' | 'medium' | 'large' = 'small'
$: status = $statusStore.byId.get(issue.status)
$: status = $statusStore.getIdMap().get(issue.status)
$: huge = size === 'medium' || size === 'large'
$: text = project ? `${getIssueId(project, issue)} ${issue.title}` : issue.title
</script>

View File

@ -26,9 +26,9 @@
showPanel,
showPopup
} from '@hcengineering/ui'
import { statusStore } from '@hcengineering/view-resources'
import tracker from '../../../plugin'
import { subIssueListProvider } from '../../../utils'
import { statusStore } from '@hcengineering/presentation'
import RelatedIssuePresenter from './RelatedIssuePresenter.svelte'
export let object: WithLookup<Doc & { related: number }> | undefined
@ -69,7 +69,8 @@
}
$: if (subIssues) {
const doneStatuses = $statusStore.statuses
const doneStatuses = $statusStore
.getDocs()
.filter((s) => s.category === tracker.issueStatusCategory.Completed)
.map((p) => p._id)
countComplete = subIssues.filter((si) => doneStatuses.includes(si.status)).length

View File

@ -17,7 +17,7 @@
import { Issue } from '@hcengineering/tracker'
import { floorFractionDigits, Label } from '@hcengineering/ui'
import tracker from '../../plugin'
import { statusStore } from '@hcengineering/presentation'
import { statusStore } from '@hcengineering/view-resources'
import EstimationProgressCircle from '../issues/timereport/EstimationProgressCircle.svelte'
import TimePresenter from '../issues/timereport/TimePresenter.svelte'
import { FixedColumn } from '@hcengineering/view-resources'
@ -30,13 +30,13 @@
$: noParents = docs?.filter((it) => !ids.has(it.attachedTo as Ref<Issue>))
$: rootNoBacklogIssues = noParents?.filter(
(it) => $statusStore.byId.get(it.status)?.category !== tracker.issueStatusCategory.Backlog
(it) => $statusStore.getIdMap().get(it.status)?.category !== tracker.issueStatusCategory.Backlog
)
$: totalEstimation = floorFractionDigits(
(rootNoBacklogIssues ?? [{ estimation: 0, childInfo: [] } as unknown as Issue])
.map((it) => {
const cat = $statusStore.byId.get(it.status)?.category
const cat = $statusStore.getIdMap().get(it.status)?.category
let retEst = it.estimation
if (it.childInfo?.length > 0) {

View File

@ -2,11 +2,11 @@
import { Ref } from '@hcengineering/core'
import { Issue, IssueStatus, Project } from '@hcengineering/tracker'
import { Button, Label, SelectPopup, eventToHTMLElement, showPopup } from '@hcengineering/ui'
import presentation, { getClient, statusStore } from '@hcengineering/presentation'
import presentation, { getClient } from '@hcengineering/presentation'
import tracker from '../../plugin'
import { createEventDispatcher } from 'svelte'
import IssueStatusIcon from '../issues/IssueStatusIcon.svelte'
import { StatusPresenter } from '@hcengineering/view-resources'
import { StatusPresenter, statusStore } from '@hcengineering/view-resources'
export let projectId: Ref<Project>
export let issues: Issue[]
@ -17,10 +17,10 @@
const client = getClient()
let newStatus: IssueStatus =
$statusStore.statuses.find(
(s) => s._id !== status._id && s.category === status.category && s.space === projectId
) ??
$statusStore.statuses.find((s) => s._id !== status._id && s.space === projectId) ??
$statusStore
.getDocs()
.find((s) => s._id !== status._id && s.category === status.category && s.space === projectId) ??
$statusStore.getDocs().find((s) => s._id !== status._id && s.space === projectId) ??
status
async function remove () {
@ -54,7 +54,7 @@
SelectPopup,
{ value: statusesInfo, placeholder: tracker.string.SetStatus, searchable: true },
eventToHTMLElement(event),
(val) => (newStatus = $statusStore.byId.get(val) ?? newStatus)
(val) => (newStatus = $statusStore.getIdMap().get(val) ?? newStatus)
)
}
</script>

View File

@ -28,13 +28,13 @@
Scroller,
showPopup
} from '@hcengineering/ui'
import { statusStore } from '@hcengineering/presentation'
import { createEventDispatcher } from 'svelte'
import { flip } from 'svelte/animate'
import tracker from '../../plugin'
import StatusEditor from './StatusEditor.svelte'
import StatusPresenter from './StatusPresenter.svelte'
import RemoveStatus from './RemoveStatus.svelte'
import { statusStore } from '@hcengineering/view-resources'
export let projectId: Ref<Project>
export let projectClass: Ref<Class<Project>>
@ -88,7 +88,7 @@
async function editStatus () {
if (statusCategories && editingStatus?.name && editingStatus?.category && '_id' in editingStatus) {
const statusId = '_id' in editingStatus ? editingStatus._id : undefined
const status = statusId && $statusStore.byId.get(statusId)
const status = statusId && $statusStore.getIdMap().get(statusId)
if (!status) {
return
@ -238,7 +238,7 @@
$: projectQuery.query(projectClass, { _id: projectId }, (result) => ([project] = result), { limit: 1 })
$: updateStatusCategories()
$: projectStatuses = $statusStore.statuses.filter((status) => status.space === projectId)
$: projectStatuses = $statusStore.getDocs().filter((status) => status.space === projectId)
</script>
<Panel isHeader={false} isAside={false} on:fullsize on:close={() => dispatch('close')}>

View File

@ -29,6 +29,7 @@ import { Issue, Project, Scrum, ScrumRecord, Milestone } from '@hcengineering/tr
import { showPopup } from '@hcengineering/ui'
import ComponentEditor from './components/components/ComponentEditor.svelte'
import ComponentPresenter from './components/components/ComponentPresenter.svelte'
import ComponentRefPresenter from './components/components/ComponentRefPresenter.svelte'
import Components from './components/components/Components.svelte'
import ComponentTitlePresenter from './components/components/ComponentTitlePresenter.svelte'
import EditComponent from './components/components/EditComponent.svelte'
@ -132,6 +133,7 @@ import ProjectSpacePresenter from './components/projects/ProjectSpacePresenter.s
import IssueStatistics from './components/milestones/IssueStatistics.svelte'
import MilestoneRefPresenter from './components/milestones/MilestoneRefPresenter.svelte'
import MilestoneFilter from './components/milestones/MilestoneFilter.svelte'
import { ComponentAggregationManager, grouppingComponentManager } from './component'
export { default as SubIssueList } from './components/issues/edit/SubIssueList.svelte'
@ -385,6 +387,7 @@ export default async (): Promise<Resources> => ({
Components,
IssuePresenter,
ComponentPresenter,
ComponentRefPresenter,
ComponentTitlePresenter,
TitlePresenter,
ModificationDatePresenter,
@ -473,5 +476,9 @@ export default async (): Promise<Resources> => ({
},
resolver: {
Location: resolveLocation
},
aggregation: {
CreateComponentAggregationManager: ComponentAggregationManager.create,
GrouppingComponentManager: grouppingComponentManager
}
})

View File

@ -17,7 +17,15 @@ import type { Asset, IntlString, Metadata, Resource } from '@hcengineering/platf
import { mergeIds } from '@hcengineering/platform'
import { IssueDraft } from '@hcengineering/tracker'
import { AnyComponent, Location } from '@hcengineering/ui'
import { GetAllValuesFunc, SortFunc, Viewlet, ViewletDescriptor, ViewQueryAction } from '@hcengineering/view'
import {
CreateAggregationManagerFunc,
GetAllValuesFunc,
GrouppingManagerResource,
SortFunc,
Viewlet,
ViewletDescriptor,
ViewQueryAction
} from '@hcengineering/view'
import tracker, { trackerId } from '../../tracker/lib'
export default mergeIds(trackerId, tracker, {
@ -314,6 +322,7 @@ export default mergeIds(trackerId, tracker, {
IssuePresenter: '' as AnyComponent,
ComponentTitlePresenter: '' as AnyComponent,
ComponentPresenter: '' as AnyComponent,
ComponentRefPresenter: '' as AnyComponent,
TitlePresenter: '' as AnyComponent,
ModificationDatePresenter: '' as AnyComponent,
PriorityPresenter: '' as AnyComponent,
@ -382,5 +391,9 @@ export default mergeIds(trackerId, tracker, {
GetAllPriority: '' as GetAllValuesFunc,
GetAllComponents: '' as GetAllValuesFunc,
GetAllMilestones: '' as GetAllValuesFunc
},
aggregation: {
CreateComponentAggregationManager: '' as CreateAggregationManagerFunc,
GrouppingComponentManager: '' as GrouppingManagerResource
}
})

View File

@ -61,7 +61,7 @@ import {
import { ViewletDescriptor } from '@hcengineering/view'
import { CategoryQuery, groupBy, ListSelectionProvider, SelectDirection } from '@hcengineering/view-resources'
import tracker from './plugin'
import { defaultMilestoneStatuses, defaultPriorities } from './types'
import { defaultPriorities, defaultMilestoneStatuses } from './types'
export * from './types'
@ -296,7 +296,7 @@ export async function issueStatusSort (
listIssueKanbanStatusOrder.indexOf(a.values[0].category as Ref<StatusCategory>) -
listIssueKanbanStatusOrder.indexOf(b.values[0].category as Ref<StatusCategory>)
if (res === 0) {
return a.values[0].rank.localeCompare(b.values[0].rank)
return a.values[0].getRank().localeCompare(b.values[0].getRank())
}
return res
})
@ -306,7 +306,7 @@ export async function issueStatusSort (
listIssueStatusOrder.indexOf(a.values[0].category as Ref<StatusCategory>) -
listIssueStatusOrder.indexOf(b.values[0].category as Ref<StatusCategory>)
if (res === 0) {
return a.values[0].rank.localeCompare(b.values[0].rank)
return a.values[0].getRank().localeCompare(b.values[0].getRank())
}
return res
})

View File

@ -14,11 +14,13 @@
//
import { Employee, EmployeeAccount } from '@hcengineering/contact'
import type {
import {
AttachedDoc,
Attribute,
Class,
Doc,
DocManager,
IdMap,
Markup,
Ref,
RelatedDocument,
@ -26,7 +28,8 @@ import type {
Status,
StatusCategory,
Timestamp,
Type
Type,
WithLookup
} from '@hcengineering/core'
import type { Asset, IntlString, Plugin, Resource } from '@hcengineering/platform'
import { plugin } from '@hcengineering/platform'
@ -310,6 +313,29 @@ 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

@ -46,6 +46,7 @@
"@hcengineering/presentation": "^0.6.2",
"@hcengineering/setting": "^0.6.7",
"@hcengineering/text-editor": "^0.6.0",
"@hcengineering/query": "^0.6.5",
"fast-equals": "^2.0.3"
}
}

View File

@ -25,7 +25,7 @@ import core, {
Ref
} from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import type { Action, ViewAction, ViewActionInput, ViewContextType } from '@hcengineering/view'
import { Action, ViewAction, ViewActionInput, ViewContextType } from '@hcengineering/view'
import view from './plugin'
import { FocusSelection } from './selection'

View File

@ -19,7 +19,7 @@
import view from '../plugin'
import { getObjectLinkFragment } from '../utils'
export let object: Doc
export let object: Doc | undefined
export let disabled = false
export let onClick: ((event: MouseEvent) => void) | undefined = undefined
export let noUnderline = false

View File

@ -13,9 +13,9 @@
// limitations under the License.
-->
<script lang="ts">
import core, { Doc, FindResult, getObjectValue, Ref, RefTo, SortingOrder, Space, Status } from '@hcengineering/core'
import { translate } from '@hcengineering/platform'
import presentation, { getClient, statusStore } from '@hcengineering/presentation'
import core, { Doc, FindResult, getObjectValue, Ref, RefTo, SortingOrder, Space } from '@hcengineering/core'
import { getResource, translate } from '@hcengineering/platform'
import presentation, { getClient } from '@hcengineering/presentation'
import ui, {
addNotification,
deviceOptionsStore,
@ -27,7 +27,7 @@
Loading,
resizeObserver
} from '@hcengineering/ui'
import { Filter } from '@hcengineering/view'
import { Filter, GrouppingManager } from '@hcengineering/view'
import { createEventDispatcher } from 'svelte'
import { FILTER_DEBOUNCE_MS, sortFilterValues } from '../../filter'
import view from '../../plugin'
@ -49,32 +49,18 @@
let values: (Doc | undefined | null)[] = []
let objectsPromise: Promise<FindResult<Doc>> | undefined
let grouppingManager: GrouppingManager | undefined
const targets = new Set<any>()
$: targetClass = (filter.key.attribute.type as RefTo<Doc>).to
$: clazz = hierarchy.getClass(targetClass)
$: isStatus = client.getHierarchy().isDerived(targetClass, core.class.Status) ?? false
$: mixin = hierarchy.classHierarchyMixin(targetClass, view.mixin.Groupping)
$: if (mixin?.grouppingManager !== undefined) {
getResource(mixin.grouppingManager).then((mgr) => (grouppingManager = mgr))
}
let filterUpdateTimeout: number | undefined
const groupValues = (val: Status[]): Doc[] => {
const statuses = val
const result: Doc[] = []
const unique = [...new Set(val.map((v) => v.name.trim().toLocaleLowerCase()))]
unique.forEach((label, i) => {
let exists = false
statuses.forEach((state) => {
if (state.name.trim().toLocaleLowerCase() === label) {
if (!exists) {
result[i] = state
exists = targets.has(state?._id)
}
}
})
})
return result
}
async function getValues (search: string): Promise<void> {
if (objectsPromise) {
await objectsPromise
@ -108,8 +94,8 @@
const options = clazz.sortingKey !== undefined ? { sort: { [clazz.sortingKey]: SortingOrder.Ascending } } : {}
objectsPromise = client.findAll(targetClass, resultQuery, options)
values = await objectsPromise
if (isStatus) {
values = groupValues(values as Status[])
if (grouppingManager !== undefined) {
values = grouppingManager.groupValues(values as Doc[], targets)
}
if (targets.has(undefined)) {
values.unshift(undefined)
@ -130,13 +116,8 @@
}
function isSelected (value: Doc | undefined | null, values: any[]): boolean {
if (isStatus) {
const statusSet = new Set(
$statusStore
.filter((it) => it.name.trim().toLocaleLowerCase() === (value as Status)?.name?.trim()?.toLocaleLowerCase())
.map((it) => it._id)
)
return values.some((it) => statusSet.has(it))
if (grouppingManager !== undefined) {
return grouppingManager.hasValue(value, values)
}
return values.includes(value?._id ?? value)
}

View File

@ -15,7 +15,7 @@
<script lang="ts">
import { CategoryType, Class, Doc, DocumentQuery, generateId, Lookup, Ref, Space } from '@hcengineering/core'
import { getResource, IntlString } from '@hcengineering/platform'
import { getClient, statusStore } from '@hcengineering/presentation'
import { getClient } from '@hcengineering/presentation'
import { AnyComponent, AnySvelteComponent } from '@hcengineering/ui'
import { AttributeModel, BuildModelKey, CategoryOption, ViewOptionModel, ViewOptions } from '@hcengineering/view'
import { createEventDispatcher, onDestroy, SvelteComponentTyped } from 'svelte'
@ -84,14 +84,14 @@
viewOptions: ViewOptions,
viewOptionsModel: ViewOptionModel[] | undefined
) {
categories = await getCategories(client, _class, docs, groupByKey, $statusStore)
categories = await getCategories(client, _class, docs, groupByKey)
if (level === 0) {
for (const viewOption of viewOptionsModel ?? []) {
if (viewOption.actionTarget !== 'category') continue
const categoryFunc = viewOption as CategoryOption
if (viewOptions[viewOption.key] ?? viewOption.defaultValue) {
const f = await getResource(categoryFunc.action)
const res = hierarchy.clone(await f(_class, query, groupByKey, update, queryId, $statusStore))
const res = hierarchy.clone(await f(_class, query, groupByKey, update, queryId))
if (res !== undefined) {
categories = concatCategories(res, categories)
return

View File

@ -13,7 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { Class, Doc, DocumentUpdate, Lookup, PrimitiveType, Ref, Space, StatusValue } from '@hcengineering/core'
import { AggregateValue, Class, Doc, DocumentUpdate, Lookup, PrimitiveType, Ref, Space } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { DocWithRank, calcRank } from '@hcengineering/task'
@ -34,7 +34,7 @@
import ListHeader from './ListHeader.svelte'
import ListItem from './ListItem.svelte'
export let category: PrimitiveType | StatusValue
export let category: PrimitiveType | AggregateValue
export let headerComponent: AttributeModel | undefined
export let singleCat: boolean
export let oneCat: boolean

View File

@ -13,7 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { Doc, Ref, Space } from '@hcengineering/core'
import { AggregateValue, Doc, PrimitiveType, Ref, Space } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import ui, {
ActionIcon,
@ -40,7 +40,7 @@
import { noCategory } from '../../viewOptions'
export let groupByKey: string
export let category: any
export let category: PrimitiveType | AggregateValue
export let headerComponent: AttributeModel | undefined
export let space: Ref<Space> | undefined
export let limited: number

View File

@ -16,15 +16,17 @@
import { Ref, Status, StatusValue } from '@hcengineering/core'
import { Asset } from '@hcengineering/platform'
import { AnySvelteComponent } from '@hcengineering/ui'
import { statusStore } from '@hcengineering/presentation'
import StatusPresenter from './StatusPresenter.svelte'
import { statusStore } from '../../status'
export let value: Ref<Status> | StatusValue | undefined
export let size: 'small' | 'medium' = 'medium'
export let icon: Asset | AnySvelteComponent | undefined = undefined
$: statusValue = $statusStore.get(typeof value === 'string' ? value : (value?.values?.[0]?._id as Ref<Status>))
</script>
{#if value}
<StatusPresenter value={$statusStore.get(typeof value === 'string' ? value : value.values?.[0]._id)} {size} {icon} />
<StatusPresenter value={statusValue} {size} {icon} />
{/if}

View File

@ -104,6 +104,8 @@ import {
import { IndexedDocumentPreview } from '@hcengineering/presentation'
import { statusSort } from './utils'
import { showEmptyGroups } from './viewOptions'
import { AggregationMiddleware } from './middleware'
import { grouppingStatusManager, StatusAggregationManager } from './status'
export { getActions, invokeAction } from './actions'
export { default as ActionHandler } from './components/ActionHandler.svelte'
export { default as AddSavedView } from './components/filter/AddSavedView.svelte'
@ -129,6 +131,8 @@ export { default as ParentsNavigator } from './components/ParentsNavigator.svelt
export * from './filter'
export * from './selection'
export * from './utils'
export * from './status'
export * from './middleware'
export {
buildModel,
getActiveViewletId,
@ -257,6 +261,11 @@ export default async (): Promise<Resources> => ({
FilterDateMonth: dateMonth,
FilterDateNextMonth: dateNextMonth,
FilterDateNotSpecified: dateNotSpecified,
FilterDateCustom: dateCustom
FilterDateCustom: dateCustom,
CreateDocMiddleware: AggregationMiddleware.create
},
aggregation: {
CreateStatusAggregationManager: StatusAggregationManager.create,
GrouppingStatusManager: grouppingStatusManager
}
})

View File

@ -0,0 +1,234 @@
import core, {
Doc,
Ref,
AnyAttribute,
Class,
DocumentQuery,
FindOptions,
Client,
Tx,
TxResult,
FindResult,
Attribute,
Hierarchy,
RefTo,
generateId
} from '@hcengineering/core'
import { BasePresentationMiddleware, PresentationMiddleware } from '@hcengineering/presentation'
import view, { AggregationManager } from '@hcengineering/view'
import { getResource } from '@hcengineering/platform'
/**
* @public
*/
export interface DocSubScriber<T extends Doc = Doc> {
attributes: Array<Ref<AnyAttribute>>
_class: Ref<Class<T>>
query: DocumentQuery<T>
options?: FindOptions<T>
refresh: () => void
}
/**
* @public
*/
export class AggregationMiddleware extends BasePresentationMiddleware implements PresentationMiddleware {
mgrs: Map<Ref<Class<Doc>>, AggregationManager> = new Map<Ref<Class<Doc>>, AggregationManager>()
docs: Doc[] | undefined
subscribers: Map<string, DocSubScriber> = new Map()
private constructor (client: Client, next?: PresentationMiddleware) {
super(client, next)
}
static create (client: Client, next?: PresentationMiddleware): AggregationMiddleware {
return new AggregationMiddleware(client, next)
}
async notifyTx (tx: Tx): Promise<void> {
const promises: Array<Promise<void>> = []
for (const [, value] of this.mgrs) {
promises.push(value.notifyTx(tx))
}
await Promise.all(promises)
await this.provideNotifyTx(tx)
}
async close (): Promise<void> {
this.mgrs.forEach((mgr) => mgr.close())
return await this.provideClose()
}
async tx (tx: Tx): Promise<TxResult> {
return await this.provideTx(tx)
}
private refreshSubscribers (): void {
for (const s of this.subscribers.values()) {
// TODO: Do something more smart and track if used component field is changed.
s.refresh()
}
}
async subscribe<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options: FindOptions<T> | undefined,
refresh: () => void
): Promise<{
unsubscribe: () => void
query?: DocumentQuery<T>
options?: FindOptions<T>
}> {
const ret = await this.provideSubscribe(_class, query, options, refresh)
const h = this.client.getHierarchy()
const id = generateId()
const s: DocSubScriber<T> = {
_class,
query,
refresh,
options,
attributes: []
}
const statusFields: Array<Attribute<Doc>> = []
const allAttrs = h.getAllAttributes(_class)
const updatedQuery: DocumentQuery<T> = { ...(ret.query ?? query) }
const finalOptions = { ...(ret.options ?? options ?? {}) }
await this.updateQueryOptions<T>(allAttrs, h, statusFields, updatedQuery, finalOptions)
if (statusFields.length > 0) {
this.subscribers.set(id, s)
return {
unsubscribe: () => {
ret.unsubscribe()
this.subscribers.delete(id)
},
query: updatedQuery,
options: finalOptions
}
}
return { unsubscribe: (await ret).unsubscribe }
}
private async getAggregationManager (_class: Ref<Class<Doc>>): Promise<AggregationManager | 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) {
const f = await getResource(mixin.createAggregationManager)
mgr = f(this.client, this.refreshSubscribers)
this.mgrs.set(_class, mgr)
}
}
return mgr
}
async findAll<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T> | undefined
): Promise<FindResult<T>> {
const docFields: Array<Attribute<Doc>> = []
const h = this.client.getHierarchy()
const allAttrs = h.getAllAttributes(_class)
const finalOptions = options ?? {}
await this.updateQueryOptions<T>(allAttrs, h, docFields, query, finalOptions)
const result = await this.provideFindAll(_class, query, finalOptions)
// We need to add $
if (docFields.length > 0) {
// We need to update $lookup for doc fields and provide $doc group fields.
for (const attr of docFields) {
for (const r of result) {
const resultDoc = Hierarchy.toDoc(r)
if (resultDoc.$lookup === undefined) {
resultDoc.$lookup = {}
}
const mgr = await this.getAggregationManager((attr.type as RefTo<Doc>).to)
if (mgr !== undefined) {
await mgr.updateLookup(resultDoc, attr)
}
}
}
}
return result
}
private async updateQueryOptions<T extends Doc>(
allAttrs: Map<string, AnyAttribute>,
h: Hierarchy,
docFields: Array<Attribute<Doc>>,
query: DocumentQuery<T>,
finalOptions: FindOptions<T>
): Promise<void> {
for (const attr of allAttrs.values()) {
try {
if (attr.type._class !== core.class.RefTo) {
continue
}
const mgr = await this.getAggregationManager((attr.type as RefTo<Doc>).to)
if (mgr === undefined) {
continue
}
if (h.isDerived((attr.type as RefTo<Doc>).to, mgr.getAttrClass())) {
let target: Array<Ref<Doc>> = []
let targetNin: Array<Ref<Doc>> = []
docFields.push(attr)
const v = (query as any)[attr.name]
if (v != null) {
// Only add filter if we have filer inside.
if (typeof v === 'string') {
target.push(v as Ref<Doc>)
} else {
if (v.$in !== undefined) {
target.push(...v.$in)
} else if (v.$nin !== undefined) {
targetNin.push(...v.$nin)
} else if (v.$ne !== undefined) {
targetNin.push(v.$ne)
}
}
// Find all similar name statues for same attribute name.
target = await mgr.categorize(target, attr)
targetNin = await mgr.categorize(targetNin, attr)
if (target.length > 0 || targetNin.length > 0) {
;(query as any)[attr.name] = {}
if (target.length > 0) {
;(query as any)[attr.name].$in = target
}
if (targetNin.length > 0) {
;(query as any)[attr.name].$nin = targetNin
}
}
}
if (finalOptions.lookup !== undefined) {
// Remove lookups by status field
if ((finalOptions.lookup as any)[attr.name] !== undefined) {
const { [attr.name]: _, ...newLookup } = finalOptions.lookup as any
finalOptions.lookup = newLookup
}
}
// Update sorting if defined.
if (mgr.updateSorting !== undefined) {
await mgr.updateSorting(finalOptions, attr)
}
}
} catch (err: any) {
console.error(err)
}
}
}
}

View File

@ -14,9 +14,10 @@
// limitations under the License.
//
import { IntlString, mergeIds } from '@hcengineering/platform'
import { IntlString, Resource, mergeIds } from '@hcengineering/platform'
import { PresentationMiddlewareCreator } from '@hcengineering/presentation'
import { AnyComponent } from '@hcengineering/ui'
import view, { SortFunc, viewId } from '@hcengineering/view'
import view, { CreateAggregationManagerFunc, GrouppingManagerResource, SortFunc, viewId } from '@hcengineering/view'
export default mergeIds(viewId, view, {
component: {
@ -94,6 +95,11 @@ export default mergeIds(viewId, view, {
Show: '' as IntlString
},
function: {
StatusSort: '' as SortFunc
StatusSort: '' as SortFunc,
CreateDocMiddleware: '' as Resource<PresentationMiddlewareCreator>
},
aggregation: {
CreateStatusAggregationManager: '' as CreateAggregationManagerFunc,
GrouppingStatusManager: '' as GrouppingManagerResource
}
})

View File

@ -0,0 +1,283 @@
//
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import core, {
AggregateValue,
AggregateValueData,
AnyAttribute,
Attribute,
Class,
Client,
Doc,
DocumentQuery,
FindOptions,
Hierarchy,
Ref,
SortingOrder,
SortingRules,
Space,
Status,
StatusManager,
StatusValue,
Tx,
WithLookup,
matchQuery
} from '@hcengineering/core'
import { LiveQuery } from '@hcengineering/query'
import { AggregationManager, GrouppingManager } from '@hcengineering/view'
import { get, writable } from 'svelte/store'
// Issue status live query
export const statusStore = writable<StatusManager>(new StatusManager([]))
/**
* @public
*/
export class StatusAggregationManager implements AggregationManager {
docs: Doc[] | undefined
mgr: StatusManager | Promise<StatusManager> | 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): StatusAggregationManager {
return new StatusAggregationManager(client, lqCallback)
}
private async getManager (): Promise<StatusManager> {
if (this.mgr !== undefined) {
if (this.mgr instanceof Promise) {
this.mgr = await this.mgr
}
return this.mgr
}
this.mgr = new Promise<StatusManager>((resolve) => {
this.query = this.lq.query(
core.class.Status,
{},
(res) => {
const first = this.docs === undefined
this.docs = res
this.mgr = new StatusManager(res)
statusStore.set(this.mgr)
if (!first) {
this.lqCallback()
}
resolve(this.mgr)
},
{
lookup: {
category: core.class.StatusCategory
},
sort: {
rank: 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 core.class.Status
}
async categorize (target: Array<Ref<Doc>>, attr: AnyAttribute): Promise<Array<Ref<Doc>>> {
const mgr = await this.getManager()
for (const sid of [...target]) {
const s = mgr.getIdMap().get(sid as Ref<Status>) as WithLookup<Status>
if (s !== undefined) {
let statuses = mgr.getDocs()
statuses = statuses.filter(
(it) =>
it.ofAttribute === attr._id &&
it.name.toLowerCase().trim() === s.name.toLowerCase().trim() &&
it._id !== s._id
)
target.push(...statuses.map((it) => it._id))
}
}
return target.filter((it, idx, arr) => arr.indexOf(it) === idx)
}
async updateLookup (resultDoc: WithLookup<Doc>, attr: Attribute<Doc>): Promise<void> {
const value = (resultDoc as any)[attr.name]
const doc = (await this.getManager()).getIdMap().get(value)
if (doc !== undefined) {
;(resultDoc.$lookup as any)[attr.name] = doc
}
}
async updateSorting<T extends Doc>(finalOptions: FindOptions<T>, attr: AnyAttribute): Promise<void> {
const attrSort = finalOptions.sort?.[attr.name]
if (attrSort !== undefined && typeof attrSort !== 'object') {
// Fill custom sorting.
let statuses = (await this.getManager()).getDocs()
statuses = statuses.filter((it) => it.ofAttribute === attr._id)
statuses.sort((a, b) => {
let ret = 0
if (a.category !== undefined && b.category !== undefined) {
ret = (a.$lookup?.category?.order ?? 0) - (b.$lookup?.category?.order ?? 0)
}
if (ret === 0) {
if (a.name.toLowerCase().trim() === b.name.toLowerCase().trim()) {
return 0
}
ret = a.rank.localeCompare(b.rank)
}
return ret
})
if (finalOptions.sort === undefined) {
finalOptions.sort = {}
}
const rules: SortingRules<any> = {
order: attrSort,
cases: statuses.map((it, idx) => ({ query: it._id, index: idx })),
default: statuses.length + 1
}
;(finalOptions.sort as any)[attr.name] = rules
}
}
}
/**
* @public
*/
export const grouppingStatusManager: GrouppingManager = {
groupByCategories: groupByStatusCategories,
groupValues: groupStatusValues,
groupValuesWithEmpty: groupStatusValuesWithEmpty,
hasValue: hasStatusValue
}
/**
* @public
*/
export function groupByStatusCategories (categories: any[]): AggregateValue[] {
const mgr = get(statusStore)
const existingCategories: AggregateValue[] = []
const statusMap = new Map<string, AggregateValue>()
const usedSpaces = new Set<Ref<Space>>()
const statusesList: Array<WithLookup<Status>> = []
for (const v of categories) {
const status = mgr.getIdMap().get(v)
if (status !== undefined) {
statusesList.push(status)
usedSpaces.add(status.space)
}
}
for (const status of statusesList) {
if (status !== undefined) {
let fst = statusMap.get(status.name.toLowerCase().trim())
if (fst === undefined) {
const statuses = mgr
.getDocs()
.filter(
(it) =>
it.ofAttribute === status.ofAttribute &&
it.name.toLowerCase().trim() === status.name.toLowerCase().trim() &&
(categories.includes(it._id) || usedSpaces.has(it.space))
)
.sort((a, b) => a.rank.localeCompare(b.rank))
.map((it) => new AggregateValueData(it.name, it._id, it.space, it.rank, it.category))
fst = new StatusValue(status.name, status.color, statuses)
statusMap.set(status.name.toLowerCase().trim(), fst)
existingCategories.push(fst)
}
}
}
return existingCategories
}
/**
* @public
*/
export function groupStatusValues (val: Doc[], targets: Set<any>): Doc[] {
const values = val
const result: Doc[] = []
const unique = [...new Set(val.map((v) => (v as Status).name.trim().toLocaleLowerCase()))]
unique.forEach((label, i) => {
let exists = false
values.forEach((value) => {
if ((value as Status).name.trim().toLocaleLowerCase() === label) {
if (!exists) {
result[i] = value
exists = targets.has(value?._id)
}
}
})
})
return result
}
/**
* @public
*/
export function hasStatusValue (value: Doc | undefined | null, values: any[]): boolean {
const mgr = get(statusStore)
const statusSet = new Set(
mgr
.filter((it) => it.name.trim().toLocaleLowerCase() === (value as Status)?.name?.trim()?.toLocaleLowerCase())
.map((it) => it._id)
)
return values.some((it) => statusSet.has(it))
}
/**
* @public
*/
export function groupStatusValuesWithEmpty (
hierarchy: Hierarchy,
_class: Ref<Class<Doc>>,
key: string,
query: DocumentQuery<Doc> | undefined
): Array<Ref<Doc>> {
const mgr = get(statusStore)
const attr = hierarchy.getAttribute(_class, key)
// We do not need extensions for all status categories.
let statusList = mgr.filter((it) => {
return it.ofAttribute === attr._id
})
if (query !== undefined) {
const { [key]: st, space } = query
const resQuery: DocumentQuery<Doc> = {}
if (space !== undefined) {
resQuery.space = space
}
if (st !== undefined) {
resQuery._id = st
}
statusList = matchQuery<Doc>(statusList, resQuery, _class, hierarchy) as unknown as Array<WithLookup<Status>>
}
return statusList.map((it) => it._id)
}

View File

@ -17,6 +17,7 @@
import core, {
AccountRole,
AttachedDoc,
AggregateValue,
CategoryType,
Class,
Client,
@ -34,10 +35,7 @@ import core, {
ReverseLookups,
Space,
Status,
StatusManager,
StatusValue,
TxOperations,
WithLookup
TxOperations
} from '@hcengineering/core'
import type { IntlString } from '@hcengineering/platform'
import { getResource } from '@hcengineering/platform'
@ -59,6 +57,7 @@ import {
} from '@hcengineering/ui'
import type { BuildModelOptions, Viewlet, ViewletDescriptor } from '@hcengineering/view'
import view, { AttributeModel, BuildModelKey } from '@hcengineering/view'
import { get, writable } from 'svelte/store'
import plugin from './plugin'
import { noCategory } from './viewOptions'
@ -597,7 +596,7 @@ export function groupBy<T extends Doc> (docs: T[], key: string, categories?: Cat
*/
export function getGroupByValues<T extends Doc> (groupByDocs: Record<any, T[]>, category: CategoryType): T[] {
if (typeof category === 'object') {
return groupByDocs[category.name] ?? []
return groupByDocs[category.name as any] ?? []
} else {
return groupByDocs[category as any] ?? []
}
@ -612,7 +611,7 @@ export function setGroupByValues (
docs: Doc[]
): void {
if (typeof category === 'object') {
groupByDocs[category.name] = docs
groupByDocs[category.name as any] = docs
} else if (category !== undefined) {
groupByDocs[category] = docs
}
@ -626,8 +625,7 @@ export async function groupByCategory (
client: TxOperations,
_class: Ref<Class<Doc>>,
key: string,
categories: any[],
mgr: StatusManager,
categories: CategoryType[],
viewletDescriptorId?: Ref<ViewletDescriptor>
): Promise<CategoryType[]> {
const h = client.getHierarchy()
@ -636,13 +634,12 @@ export async function groupByCategory (
if (key === noCategory) return [undefined]
const attrClass = getAttributePresenterClass(h, attr).attrClass
const isStatusField = h.isDerived(attrClass, core.class.Status)
const mixin = h.classHierarchyMixin(attrClass, view.mixin.Groupping)
let existingCategories: any[] = []
if (isStatusField) {
existingCategories = await groupByStatusCategories(h, attrClass, categories, mgr, viewletDescriptorId)
if (mixin?.grouppingManager !== undefined) {
const grouppingManager = await getResource(mixin.grouppingManager)
existingCategories = grouppingManager.groupByCategories(categories)
} else {
const valueSet = new Set<any>()
for (const v of categories) {
@ -655,56 +652,11 @@ export async function groupByCategory (
return await sortCategories(h, attrClass, existingCategories, viewletDescriptorId)
}
/**
* @public
*/
export async function groupByStatusCategories (
hierarchy: Hierarchy,
attrClass: Ref<Class<Doc>>,
categories: any[],
mgr: StatusManager,
viewletDescriptorId?: Ref<ViewletDescriptor>
): Promise<StatusValue[]> {
const existingCategories: StatusValue[] = []
const statusMap = new Map<string, StatusValue>()
const usedSpaces = new Set<Ref<Space>>()
const statusesList: Array<WithLookup<Status>> = []
for (const v of categories) {
const status = mgr.byId.get(v)
if (status !== undefined) {
statusesList.push(status)
usedSpaces.add(status.space)
}
}
for (const status of statusesList) {
if (status !== undefined) {
let fst = statusMap.get(status.name.toLowerCase().trim())
if (fst === undefined) {
const statuses = mgr.statuses
.filter(
(it) =>
it.ofAttribute === status.ofAttribute &&
it.name.toLowerCase().trim() === status.name.toLowerCase().trim() &&
(categories.includes(it._id) || usedSpaces.has(it.space))
)
.sort((a, b) => a.rank.localeCompare(b.rank))
fst = new StatusValue(status.name, status.color, statuses)
statusMap.set(status.name.toLowerCase().trim(), fst)
existingCategories.push(fst)
}
}
}
return await sortCategories(hierarchy, attrClass, existingCategories, viewletDescriptorId)
}
export async function getCategories (
client: TxOperations,
_class: Ref<Class<Doc>>,
docs: Doc[],
key: string,
mgr: StatusManager,
viewletDescriptorId?: Ref<ViewletDescriptor>
): Promise<CategoryType[]> {
if (key === noCategory) return [undefined]
@ -714,7 +666,6 @@ export async function getCategories (
_class,
key,
docs.map((it) => getObjectValue(key, it) ?? undefined),
mgr,
viewletDescriptorId
)
}
@ -724,7 +675,7 @@ export async function getCategories (
*/
export function getCategorySpaces (categories: CategoryType[]): Array<Ref<Space>> {
return Array.from(
(categories.filter((it) => typeof it === 'object') as StatusValue[]).reduce<Set<Ref<Space>>>((arr, val) => {
(categories.filter((it) => typeof it === 'object') as AggregateValue[]).reduce<Set<Ref<Space>>>((arr, val) => {
val.values.forEach((it) => arr.add(it.space))
return arr
}, new Set())
@ -733,12 +684,12 @@ export function getCategorySpaces (categories: CategoryType[]): Array<Ref<Space>
export function concatCategories (arr1: CategoryType[], arr2: CategoryType[]): CategoryType[] {
const uniqueValues: Set<string | number | undefined> = new Set()
const uniqueObjects: Map<string | number, StatusValue> = new Map()
const uniqueObjects: Map<string | number, AggregateValue> = new Map()
for (const item of arr1) {
if (typeof item === 'object') {
const id = item.name
uniqueObjects.set(id, item)
uniqueObjects.set(id as any, item)
} else {
uniqueValues.add(item)
}
@ -747,8 +698,8 @@ export function concatCategories (arr1: CategoryType[], arr2: CategoryType[]): C
for (const item of arr2) {
if (typeof item === 'object') {
const id = item.name
if (!uniqueObjects.has(id)) {
uniqueObjects.set(id, item)
if (!uniqueObjects.has(id as any)) {
uniqueObjects.set(id as any, item)
}
} else {
uniqueValues.add(item)

View File

@ -1,19 +1,10 @@
import core, {
Class,
Doc,
DocumentQuery,
Ref,
SortingOrder,
Status,
StatusManager,
WithLookup,
matchQuery
} from '@hcengineering/core'
import { Class, Doc, DocumentQuery, Ref, SortingOrder } from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import { LiveQuery, createQuery, getAttributePresenterClass, getClient } from '@hcengineering/presentation'
import { locationToUrl, getCurrentResolvedLocation } from '@hcengineering/ui'
import {
DropdownViewOption,
Groupping,
ToggleViewOption,
ViewOptionModel,
ViewOptions,
@ -118,7 +109,6 @@ export async function showEmptyGroups (
key: string,
onUpdate: () => void,
queryId: Ref<Doc>,
mgr: StatusManager,
viewletDescriptorId?: Ref<ViewletDescriptor>
): Promise<any[] | undefined> {
const client = getClient()
@ -129,32 +119,27 @@ export async function showEmptyGroups (
const { attrClass } = getAttributePresenterClass(hierarchy, attr)
const attributeClass = hierarchy.getClass(attrClass)
if (hierarchy.isDerived(attrClass, core.class.Status)) {
// We do not need extensions for all status categories.
let statusList = mgr.filter((it) => {
return it.ofAttribute === attr._id
})
if (query !== undefined) {
const { [key]: st, space } = query
const resQuery: DocumentQuery<Status> = {}
if (space !== undefined) {
resQuery.space = space
}
if (st !== undefined) {
resQuery._id = st
}
statusList = matchQuery<Status>(statusList, resQuery, _class, hierarchy) as unknown as Array<WithLookup<Status>>
let groupMixin: Groupping | undefined
if (hierarchy.hasMixin(attributeClass, view.mixin.Groupping)) {
groupMixin = hierarchy.as(attributeClass, view.mixin.Groupping)
} else {
const _attributeClass = hierarchy.classHierarchyMixin(attrClass, view.mixin.Groupping)
if (_attributeClass !== undefined) {
groupMixin = hierarchy.as(_attributeClass, view.mixin.Groupping)
}
const statuses = statusList.map((it) => it._id)
return await groupByCategory(client, _class, key, statuses, mgr, viewletDescriptorId)
}
if (groupMixin?.grouppingManager !== undefined) {
const grouppingManager = await getResource(groupMixin.grouppingManager)
const docs = grouppingManager.groupValuesWithEmpty(hierarchy, _class, key, query)
return await groupByCategory(client, _class, key, docs, viewletDescriptorId)
}
const mixin = hierarchy.as(attributeClass, view.mixin.AllValuesFunc)
if (mixin.func !== undefined) {
const f = await getResource(mixin.func)
const allValuesMixin = hierarchy.as(attributeClass, view.mixin.AllValuesFunc)
if (allValuesMixin.func !== undefined) {
const f = await getResource(allValuesMixin.func)
const res = await f(query, onUpdate, queryId)
if (res !== undefined) {
return await groupByCategory(client, _class, key, res, mgr, viewletDescriptorId)
return await groupByCategory(client, _class, key, res, viewletDescriptorId)
}
}
}

View File

@ -14,15 +14,18 @@
// limitations under the License.
//
import type {
import {
Account,
AggregateValue,
AnyAttribute,
Attribute,
CategoryType,
Class,
Client,
Doc,
DocumentQuery,
FindOptions,
Hierarchy,
Lookup,
Mixin,
Obj,
@ -31,14 +34,15 @@ import type {
Ref,
SortingOrder,
Space,
StatusManager,
StatusValue,
Tx,
Type,
UXObject
UXObject,
WithLookup
} from '@hcengineering/core'
import { Asset, IntlString, Plugin, Resource, Status, plugin } from '@hcengineering/platform'
import type { Preference } from '@hcengineering/preference'
import type {
import { Preference } from '@hcengineering/preference'
import {
AnyComponent,
AnySvelteComponent,
Location,
@ -308,6 +312,62 @@ export interface AllValuesFunc extends Class<Doc> {
func: GetAllValuesFunc
}
/**
* @public
*/
export interface GrouppingManager {
groupByCategories: (categories: any[]) => AggregateValue[]
groupValues: (val: Doc[], targets: Set<any>) => Doc[]
groupValuesWithEmpty: (
hierarchy: Hierarchy,
_class: Ref<Class<Doc>>,
key: string,
query: DocumentQuery<Doc> | undefined
) => Array<Ref<Doc>>
hasValue: (value: Doc | undefined | null, values: any[]) => boolean
}
/**
* @public
*/
export type GrouppingManagerResource = Resource<GrouppingManager>
/**
* @public
*/
export interface Groupping extends Class<Doc> {
grouppingManager: GrouppingManagerResource
}
/**
* @public
*/
export interface AggregationManager {
close: () => void
notifyTx: (tx: Tx) => Promise<void>
updateLookup: (resultDoc: WithLookup<Doc>, attr: Attribute<Doc>) => 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>
}
/**
* @public
*/
export type AggregationManagerResource = Resource<AggregationManager>
/**
* @public
*/
export type CreateAggregationManagerFunc = Resource<(client: Client, lqCallback: () => void) => AggregationManager>
/**
* @public
*/
export interface Aggregation extends Class<Doc> {
createAggregationManager: CreateAggregationManagerFunc
}
/**
* @public
*/
@ -578,7 +638,6 @@ export type ViewCategoryActionFunc = (
key: string,
onUpdate: () => void,
queryId: Ref<Doc>,
mgr: StatusManager,
viewletDescriptorId?: Ref<ViewletDescriptor>
) => Promise<CategoryType[] | undefined>
/**
@ -690,7 +749,9 @@ const view = plugin(viewId, {
ObjectPanel: '' as Ref<Mixin<ObjectPanel>>,
LinkProvider: '' as Ref<Mixin<LinkProvider>>,
SpacePresenter: '' as Ref<Mixin<SpacePresenter>>,
AttributeFilterPresenter: '' as Ref<Mixin<AttributeFilterPresenter>>
AttributeFilterPresenter: '' as Ref<Mixin<AttributeFilterPresenter>>,
Aggregation: '' as Ref<Mixin<Aggregation>>,
Groupping: '' as Ref<Mixin<Groupping>>
},
class: {
ViewletPreference: '' as Ref<Class<ViewletPreference>>,