From 3373e3af0b13c6d23ce025f2875a995d8fd74689 Mon Sep 17 00:00:00 2001 From: Andrey Sobolev Date: Tue, 28 Mar 2023 13:03:08 +0700 Subject: [PATCH 01/11] TSK-943: Rework status support Signed-off-by: Andrey Sobolev --- models/core/src/index.ts | 6 +- models/core/src/status.ts | 52 +++++ models/tracker/src/index.ts | 80 +++---- models/tracker/src/migration.ts | 55 +++-- models/view/src/index.ts | 12 + models/view/src/plugin.ts | 4 +- packages/core/src/component.ts | 22 +- packages/core/src/index.ts | 1 + packages/core/src/lang/en.json | 4 +- packages/core/src/lang/ru.json | 4 +- packages/core/src/status.ts | 84 +++++++ packages/model/src/dsl.ts | 5 +- packages/presentation/src/pipeline.ts | 210 ++++++++++++++++++ packages/presentation/src/status.ts | 150 +++++++++++++ packages/presentation/src/utils.ts | 27 ++- plugins/tracker-assets/lang/en.json | 4 +- plugins/tracker-assets/lang/ru.json | 2 + .../SetParentIssueActionPopup.svelte | 4 +- .../src/components/icons/StatusIcon.svelte | 4 +- .../issues/IssueStatusActivity.svelte | 2 +- .../components/issues/IssueStatusIcon.svelte | 16 +- .../src/components/issues/KanbanView.svelte | 31 +-- .../src/components/issues/StatusEditor.svelte | 4 +- .../components/issues/StatusPresenter.svelte | 6 +- .../issues/StatusRefPresenter.svelte | 9 +- .../issues/edit/SubIssuesSelector.svelte | 3 +- .../related/RelatedIssueSelector.svelte | 3 +- .../components/projects/ChangeIdentity.svelte | 41 ++++ .../components/projects/CreateProject.svelte | 63 ++++-- .../components/sprints/IssueStatistics.svelte | 2 +- .../src/components/workflow/Statuses.svelte | 42 ++-- plugins/tracker-resources/src/index.ts | 36 +-- plugins/tracker-resources/src/plugin.ts | 1 + plugins/tracker-resources/src/utils.ts | 56 ++--- plugins/tracker/src/index.ts | 50 ++--- .../src/components/filter/ObjectFilter.svelte | 32 +-- .../src/components/list/ListCategories.svelte | 16 +- .../components/status/StatusPresenter.svelte | 37 +++ .../status/StatusRefPresenter.svelte | 30 +++ plugins/view-resources/src/index.ts | 48 ++-- plugins/view-resources/src/plugin.ts | 5 +- plugins/view-resources/src/utils.ts | 62 +++++- 42 files changed, 1010 insertions(+), 315 deletions(-) create mode 100644 models/core/src/status.ts create mode 100644 packages/core/src/status.ts create mode 100644 packages/presentation/src/pipeline.ts create mode 100644 packages/presentation/src/status.ts create mode 100644 plugins/tracker-resources/src/components/projects/ChangeIdentity.svelte create mode 100644 plugins/view-resources/src/components/status/StatusPresenter.svelte create mode 100644 plugins/view-resources/src/components/status/StatusRefPresenter.svelte diff --git a/models/core/src/index.ts b/models/core/src/index.ts index 1e2f9b8a73..64ff9b648b 100644 --- a/models/core/src/index.ts +++ b/models/core/src/index.ts @@ -60,6 +60,7 @@ import { TVersion } from './core' import { TAccount, TSpace } from './security' +import { TStatus, TStatusCategory } from './status' import { TUserStatus } from './transient' import { TTx, TTxApplyIf, TTxCollectionCUD, TTxCreateDoc, TTxCUD, TTxMixin, TTxRemoveDoc, TTxUpdateDoc } from './tx' @@ -67,6 +68,7 @@ export * from './core' export { coreOperation } from './migration' export * from './security' export * from './tx' +export * from './status' export { core as default } export function createModel (builder: Builder): void { @@ -114,7 +116,9 @@ export function createModel (builder: Builder): void { TFullTextSearchContext, TConfiguration, TConfigurationElement, - TIndexConfiguration + TIndexConfiguration, + TStatus, + TStatusCategory ) builder.createDoc( diff --git a/models/core/src/status.ts b/models/core/src/status.ts new file mode 100644 index 0000000000..2c680c9187 --- /dev/null +++ b/models/core/src/status.ts @@ -0,0 +1,52 @@ +// +// 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 { Attribute, Domain, DOMAIN_MODEL, Ref, Status, StatusCategory } from '@hcengineering/core' +import { Model, Prop, TypeRef, UX } from '@hcengineering/model' +import { Asset, IntlString } from '@hcengineering/platform' +import core from './component' +import { TDoc } from './core' + +export const DOMAIN_STATUS = 'status' as Domain + +// S T A T U S + +@Model(core.class.Status, core.class.Doc, DOMAIN_STATUS) +@UX(core.string.Status) +export class TStatus extends TDoc implements Status { + // We attach to attribute, so we could distinguish between + ofAttribute!: Ref> + + @Prop(TypeRef(core.class.StatusCategory), core.string.StatusCategory) + category!: Ref + + name!: string + color!: number + description!: string + rank!: string +} + +@Model(core.class.StatusCategory, core.class.Doc, DOMAIN_MODEL) +@UX(core.string.StatusCategory) +export class TStatusCategory extends TDoc implements StatusCategory { + // We attach to attribute, so we could distinguish between + ofAttribute!: Ref> + + icon!: Asset + label!: IntlString + color!: number + defaultStatusName!: string + order!: number +} diff --git a/models/tracker/src/index.ts b/models/tracker/src/index.ts index f5f569a25f..ae71573ca7 100644 --- a/models/tracker/src/index.ts +++ b/models/tracker/src/index.ts @@ -47,30 +47,29 @@ import { } from '@hcengineering/model' import attachment from '@hcengineering/model-attachment' import chunter from '@hcengineering/model-chunter' -import core, { DOMAIN_SPACE, TAttachedDoc, TDoc, TSpace, TType } from '@hcengineering/model-core' +import core, { DOMAIN_SPACE, TAttachedDoc, TDoc, TSpace, TStatus, TType } from '@hcengineering/model-core' import view, { actionTemplates, classPresenter, createAction } from '@hcengineering/model-view' import workbench, { createNavigateAction } from '@hcengineering/model-workbench' import notification from '@hcengineering/notification' -import { Asset, IntlString } from '@hcengineering/platform' +import { IntlString } from '@hcengineering/platform' import setting from '@hcengineering/setting' import tags, { TagElement } from '@hcengineering/tags' import task from '@hcengineering/task' import { + Component, + ComponentStatus, Issue, IssueChildInfo, IssueParentInfo, IssuePriority, IssueStatus, - IssueStatusCategory, IssueTemplate, IssueTemplateChild, - Component, - ComponentStatus, + Project, Scrum, ScrumRecord, Sprint, SprintStatus, - Project, TimeReportDayType, TimeSpendReport, trackerId @@ -89,34 +88,8 @@ export const DOMAIN_TRACKER = 'tracker' as Domain /** * @public */ -@Model(tracker.class.IssueStatus, core.class.AttachedDoc, DOMAIN_TRACKER) -export class TIssueStatus extends TAttachedDoc implements IssueStatus { - @Index(IndexKind.Indexed) - name!: string - - description?: string - color?: number - - @Prop(TypeRef(tracker.class.IssueStatusCategory), tracker.string.StatusCategory) - @Index(IndexKind.Indexed) - category!: Ref - - @Prop(TypeString(), tracker.string.Rank) - @Hidden() - rank!: string -} - -/** - * @public - */ -@Model(tracker.class.IssueStatusCategory, core.class.Doc, DOMAIN_MODEL) -export class TIssueStatusCategory extends TDoc implements IssueStatusCategory { - label!: IntlString - icon!: Asset - color!: number - defaultStatusName!: string - order!: number -} +@Model(tracker.class.IssueStatus, core.class.Status) +export class TIssueStatus extends TStatus implements IssueStatus {} /** * @public @@ -207,7 +180,7 @@ export class TIssue extends TAttachedDoc implements Issue { @Index(IndexKind.FullText) description!: Markup - @Prop(TypeRef(tracker.class.IssueStatus), tracker.string.Status) + @Prop(TypeRef(tracker.class.IssueStatus), tracker.string.Status, { _id: tracker.attribute.IssueStatus }) @Index(IndexKind.Indexed) status!: Ref @@ -498,7 +471,6 @@ export function createModel (builder: Builder): void { TIssue, TIssueTemplate, TIssueStatus, - TIssueStatusCategory, TTypeIssuePriority, TTypeComponentStatus, TSprint, @@ -773,9 +745,10 @@ export function createModel (builder: Builder): void { ) builder.createDoc( - tracker.class.IssueStatusCategory, + core.class.StatusCategory, core.space.Model, { + ofAttribute: tracker.attribute.IssueStatus, label: tracker.string.CategoryBacklog, icon: tracker.icon.CategoryBacklog, color: 12, @@ -786,9 +759,10 @@ export function createModel (builder: Builder): void { ) builder.createDoc( - tracker.class.IssueStatusCategory, + core.class.StatusCategory, core.space.Model, { + ofAttribute: tracker.attribute.IssueStatus, label: tracker.string.CategoryUnstarted, icon: tracker.icon.CategoryUnstarted, color: 13, @@ -799,9 +773,10 @@ export function createModel (builder: Builder): void { ) builder.createDoc( - tracker.class.IssueStatusCategory, + core.class.StatusCategory, core.space.Model, { + ofAttribute: tracker.attribute.IssueStatus, label: tracker.string.CategoryStarted, icon: tracker.icon.CategoryStarted, color: 14, @@ -812,9 +787,10 @@ export function createModel (builder: Builder): void { ) builder.createDoc( - tracker.class.IssueStatusCategory, + core.class.StatusCategory, core.space.Model, { + ofAttribute: tracker.attribute.IssueStatus, label: tracker.string.CategoryCompleted, icon: tracker.icon.CategoryCompleted, color: 15, @@ -825,9 +801,10 @@ export function createModel (builder: Builder): void { ) builder.createDoc( - tracker.class.IssueStatusCategory, + core.class.StatusCategory, core.space.Model, { + ofAttribute: tracker.attribute.IssueStatus, label: tracker.string.CategoryCanceled, icon: tracker.icon.CategoryCanceled, color: 16, @@ -870,6 +847,14 @@ export function createModel (builder: Builder): void { presenters: [tracker.component.IssueStatistics] }) + builder.mixin(tracker.class.IssueStatus, core.class.Class, view.mixin.ObjectPresenter, { + presenter: tracker.component.StatusPresenter + }) + + builder.mixin(tracker.class.IssueStatus, core.class.Class, view.mixin.AttributePresenter, { + presenter: tracker.component.StatusRefPresenter + }) + builder.mixin(tracker.class.IssueStatus, core.class.Class, view.mixin.SortFuncs, { func: tracker.function.IssueStatusSort }) @@ -894,14 +879,6 @@ export function createModel (builder: Builder): void { component: view.component.ValueFilter }) - builder.mixin(tracker.class.IssueStatus, core.class.Class, view.mixin.ObjectPresenter, { - presenter: tracker.component.StatusPresenter - }) - - builder.mixin(tracker.class.IssueStatus, core.class.Class, view.mixin.AttributePresenter, { - presenter: tracker.component.StatusRefPresenter - }) - builder.mixin(tracker.class.TypeIssuePriority, core.class.Class, view.mixin.AttributePresenter, { presenter: tracker.component.PriorityRefPresenter }) @@ -1342,7 +1319,7 @@ export function createModel (builder: Builder): void { const statusOptions: FindOptions = { lookup: { - category: tracker.class.IssueStatusCategory + category: core.class.StatusCategory }, sort: { rank: SortingOrder.Ascending } } @@ -1377,6 +1354,9 @@ export function createModel (builder: Builder): void { attribute: 'status', _class: tracker.class.IssueStatus, placeholder: tracker.string.SetStatus, + query: { + ofAttribute: tracker.attribute.IssueStatus + }, fillQuery: { space: 'space' }, diff --git a/models/tracker/src/migration.ts b/models/tracker/src/migration.ts index 0f4b5fad33..8c155d7f9f 100644 --- a/models/tracker/src/migration.ts +++ b/models/tracker/src/migration.ts @@ -21,6 +21,7 @@ import core, { generateId, Ref, SortingOrder, + StatusCategory, TxCollectionCUD, TxCreateDoc, TxOperations, @@ -28,14 +29,13 @@ import core, { TxUpdateDoc } from '@hcengineering/core' import { createOrUpdate, MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@hcengineering/model' -import { DOMAIN_SPACE } from '@hcengineering/model-core' +import { DOMAIN_SPACE, DOMAIN_STATUS } from '@hcengineering/model-core' import tags from '@hcengineering/tags' import { calcRank, genRanks, Issue, IssueStatus, - IssueStatusCategory, IssueTemplate, IssueTemplateChild, Project, @@ -55,9 +55,9 @@ enum DeprecatedIssueStatus { interface CreateProjectIssueStatusesArgs { tx: TxOperations projectId: Ref - categories: IssueStatusCategory[] + categories: StatusCategory[] defaultStatusId?: Ref - defaultCategoryId?: Ref + defaultCategoryId?: Ref } const categoryByDeprecatedIssueStatus = { @@ -81,15 +81,14 @@ async function createProjectIssueStatuses ({ const { _id: category, defaultStatusName } = statusCategory const rank = issueStatusRanks[i] - await tx.addCollection( - tracker.class.IssueStatus, - attachedTo, - attachedTo, - tracker.class.Project, - 'issueStatuses', - { name: defaultStatusName, category, rank }, - category === defaultCategoryId ? defaultStatusId : undefined - ) + if (defaultStatusName !== undefined) { + await tx.createDoc( + tracker.class.IssueStatus, + attachedTo, + { ofAttribute: tracker.attribute.IssueStatus, name: defaultStatusName, category, rank }, + category === defaultCategoryId ? defaultStatusId : undefined + ) + } } } @@ -105,11 +104,7 @@ async function createDefaultProject (tx: TxOperations): Promise { // Create new if not deleted by customers. if (current === undefined && currentDeleted === undefined) { const defaultStatusId: Ref = generateId() - const categories = await tx.findAll( - tracker.class.IssueStatusCategory, - {}, - { sort: { order: SortingOrder.Ascending } } - ) + const categories = await tx.findAll(core.class.StatusCategory, {}, { sort: { order: SortingOrder.Ascending } }) await tx.createDoc( tracker.class.Project, @@ -137,7 +132,7 @@ async function fixProjectIssueStatusesOrder (tx: TxOperations, project: Project) const statuses = await tx.findAll( tracker.class.IssueStatus, { attachedTo: project._id }, - { lookup: { category: tracker.class.IssueStatusCategory } } + { lookup: { category: core.class.StatusCategory } } ) statuses.sort((a, b) => (a.$lookup?.category?.order ?? 0) - (b.$lookup?.category?.order ?? 0)) const issueStatusRanks = genRanks(statuses.length) @@ -170,11 +165,7 @@ async function upgradeProjectIssueStatuses (tx: TxOperations): Promise { const projects = await tx.findAll(tracker.class.Project, { issueStatuses: undefined }) if (projects.length > 0) { - const categories = await tx.findAll( - tracker.class.IssueStatusCategory, - {}, - { sort: { order: SortingOrder.Ascending } } - ) + const categories = await tx.findAll(core.class.StatusCategory, {}, { sort: { order: SortingOrder.Ascending } }) for (const project of projects) { const defaultStatusId: Ref = generateId() @@ -754,6 +745,22 @@ export const trackerOperation: MigrateOperation = { await fillRank(client) await renameProject(client) await setCreate(client) + + // Move all status objects into status domain + await client.move( + DOMAIN_TRACKER, + { + _class: tracker.class.IssueStatus + }, + DOMAIN_STATUS + ) + await client.update( + DOMAIN_STATUS, + { _class: tracker.class.IssueStatus, ofAttribute: { $exists: false } }, + { + ofAttribute: tracker.attribute.IssueStatus + } + ) }, async upgrade (client: MigrationUpgradeClient): Promise { const tx = new TxOperations(client, core.account.System) diff --git a/models/view/src/index.ts b/models/view/src/index.ts index a4e90d3ae5..0bb80e5f73 100644 --- a/models/view/src/index.ts +++ b/models/view/src/index.ts @@ -774,6 +774,18 @@ export function createModel (builder: Builder): void { }, view.action.Open ) + + builder.mixin(core.class.Status, core.class.Class, view.mixin.SortFuncs, { + func: view.function.StatusSort + }) + + builder.mixin(core.class.Status, core.class.Class, view.mixin.ObjectPresenter, { + presenter: view.component.StatusPresenter + }) + + builder.mixin(core.class.Status, core.class.Class, view.mixin.AttributePresenter, { + presenter: view.component.StatusRefPresenter + }) } export default view diff --git a/models/view/src/plugin.ts b/models/view/src/plugin.ts index b9553ff0be..b2d0f3da9d 100644 --- a/models/view/src/plugin.ts +++ b/models/view/src/plugin.ts @@ -67,7 +67,9 @@ export default mergeIds(viewId, view, { ListView: '' as AnyComponent, IndexedDocumentPreview: '' as AnyComponent, SpaceRefPresenter: '' as AnyComponent, - EnumPresenter: '' as AnyComponent + EnumPresenter: '' as AnyComponent, + StatusPresenter: '' as AnyComponent, + StatusRefPresenter: '' as AnyComponent }, string: { Table: '' as IntlString, diff --git a/packages/core/src/component.ts b/packages/core/src/component.ts index c9d7862631..0872e215c6 100644 --- a/packages/core/src/component.ts +++ b/packages/core/src/component.ts @@ -23,13 +23,17 @@ import type { BlobData, Class, Collection, + Configuration, + ConfigurationElement, Doc, DocIndexState, - FullTextSearchContext, Enum, EnumOf, FullTextData, + FullTextSearchContext, Hyperlink, + IndexingConfiguration, + IndexStageState, Interface, Obj, PluginConfiguration, @@ -39,12 +43,9 @@ import type { Space, Timestamp, Type, - UserStatus, - Configuration, - ConfigurationElement, - IndexStageState, - IndexingConfiguration + UserStatus } from './classes' +import { Status, StatusCategory } from './status' import type { Tx, TxApplyIf, @@ -111,7 +112,10 @@ export default plugin(coreId, { DocIndexState: '' as Ref>, IndexStageState: '' as Ref>, - Configuration: '' as Ref> + Configuration: '' as Ref>, + + Status: '' as Ref>, + StatusCategory: '' as Ref> }, mixin: { FullTextSearchContext: '' as Ref>, @@ -161,6 +165,8 @@ export default plugin(coreId, { Private: '' as IntlString, Object: '' as IntlString, System: '' as IntlString, - CreatedBy: '' as IntlString + CreatedBy: '' as IntlString, + Status: '' as IntlString, + StatusCategory: '' as IntlString } }) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4bdbf1a828..a4bd3b1100 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -31,6 +31,7 @@ export * from './storage' export * from './tx' export * from './utils' export * from './backup' +export * from './status' addStringsLoader(coreId, async (lang: string) => { return await import(`./lang/${lang}.json`) diff --git a/packages/core/src/lang/en.json b/packages/core/src/lang/en.json index 9917a32f4f..81e8ebdf52 100644 --- a/packages/core/src/lang/en.json +++ b/packages/core/src/lang/en.json @@ -28,6 +28,8 @@ "Hyperlink": "URL", "Object": "Object", "System": "System", - "CreatedBy": "Reporter" + "CreatedBy": "Reporter", + "Status": "Status", + "StatusCategory": "Status category" } } diff --git a/packages/core/src/lang/ru.json b/packages/core/src/lang/ru.json index 5c7f1d1bca..bf581f1246 100644 --- a/packages/core/src/lang/ru.json +++ b/packages/core/src/lang/ru.json @@ -28,6 +28,8 @@ "Hyperlink": "URL", "Object": "Объект", "System": "Система", - "CreatedBy": "Автор" + "CreatedBy": "Автор", + "Status": "Статус", + "StatusCategory": "Категория статуса" } } diff --git a/packages/core/src/status.ts b/packages/core/src/status.ts new file mode 100644 index 0000000000..90977490ec --- /dev/null +++ b/packages/core/src/status.ts @@ -0,0 +1,84 @@ +// +// 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 { Asset, IntlString } from '@hcengineering/platform' +import { Attribute, Doc, Ref } from './classes' +import { WithLookup } from './storage' +import { IdMap, toIdMap } from './utils' + +/** + * @public + */ +export interface StatusCategory extends Doc { + ofAttribute: Ref> + icon: Asset + label: IntlString + color: number + defaultStatusName?: string + order: number // category order +} + +/** + * @public + * + * Status is attached to attribute, and if user attribute will be removed, all status values will be remove as well. + */ +export interface Status extends Doc { + // We attach to attribute, so we could distinguish between + ofAttribute: Ref> + + // Optional category. + category?: Ref + + // Status with case insensitivity name match will be assumed same. + name: string + + // Optional color + color?: number + // Optional description + description?: string + // Lexorank rank for ordering. + rank: string +} + +/** + * @public + */ +export interface StatusValue { + name: string + color?: number + value: Ref[] // Real status items per category. +} + +/** + * @public + * + * Allow to query for status keys/values. + */ +export class StatusManager { + byId: IdMap> + + constructor (readonly statuses: WithLookup[]) { + this.byId = toIdMap(statuses) + } + + get (ref: Ref): WithLookup | undefined { + return this.byId.get(ref) + } + + filter (predicate: (value: WithLookup) => boolean): WithLookup[] { + return this.statuses.filter(predicate) + } +} diff --git a/packages/model/src/dsl.ts b/packages/model/src/dsl.ts index 1ba152717a..164da47184 100644 --- a/packages/model/src/dsl.ts +++ b/packages/model/src/dsl.ts @@ -131,7 +131,7 @@ export function Prop (type: Type, label: IntlString, extra: Partia modifiedBy: core.account.System, modifiedOn: Date.now(), objectSpace: core.space.Model, - objectId: propertyKey as Ref>, + objectId: extra._id ?? (propertyKey as Ref>), objectClass: core.class.Attribute, attributes: { ...extra, @@ -240,7 +240,8 @@ function generateIds (objectId: Ref, txes: TxCreateDoc { const withId = { ...tx, - objectId: `${objectId}_${tx.objectId}` + // Do not override custom attribute id if specified + objectId: tx.objectId !== tx.attributes.name ? tx.objectId : `${objectId}_${tx.objectId}` } withId.attributes.attributeOf = objectId as Ref> return withId diff --git a/packages/presentation/src/pipeline.ts b/packages/presentation/src/pipeline.ts new file mode 100644 index 0000000000..881072b771 --- /dev/null +++ b/packages/presentation/src/pipeline.ts @@ -0,0 +1,210 @@ +import { + Class, + Client, + Doc, + DocumentQuery, + FindOptions, + FindResult, + Hierarchy, + ModelDb, + Ref, + Tx, + TxResult, + WithLookup +} from '@hcengineering/core' + +/** + * @public + */ +export interface PresentationMiddleware { + next?: PresentationMiddleware + + tx: (tx: Tx) => Promise + + findAll: ( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ) => Promise> + + findOne: ( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ) => Promise | undefined> + + subscribe: ( + _class: Ref>, + query: DocumentQuery, + options: FindOptions | undefined, + refresh: () => void + ) => () => void + + initialize: () => Promise +} + +/** + * @public + */ +export type PresentationMiddlewareCreator = (client: Client, next?: PresentationMiddleware) => PresentationMiddleware + +/** + * @public + */ +export interface PresentationPipeline extends Client, Exclude { + close: () => Promise +} + +/** + * @public + */ +export class PresentationPipelineImpl implements PresentationPipeline { + private head: PresentationMiddleware | undefined + + private constructor (readonly client: Client) {} + + getHierarchy (): Hierarchy { + return this.client.getHierarchy() + } + + getModel (): ModelDb { + return this.client.getModel() + } + + static create (client: Client, constructors: PresentationMiddlewareCreator[]): PresentationPipeline { + const pipeline = new PresentationPipelineImpl(client) + pipeline.head = pipeline.buildChain(constructors) + return pipeline + } + + async initialize (): Promise { + let h = this.head + while (h !== undefined) { + await h.initialize() + h = h.next + } + } + + private buildChain (constructors: PresentationMiddlewareCreator[]): PresentationMiddleware | undefined { + let current: PresentationMiddleware | undefined + for (let index = constructors.length - 1; index >= 0; index--) { + const element = constructors[index] + current = element(this.client, current) + } + return current + } + + async findAll( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + return this.head !== undefined + ? await this.head.findAll(_class, query, options) + : await this.client.findAll(_class, query, options) + } + + async findOne( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise | undefined> { + return this.head !== undefined + ? await this.head.findOne(_class, query, options) + : await this.client.findOne(_class, query, options) + } + + subscribe( + _class: Ref>, + query: DocumentQuery, + options: FindOptions | undefined, + refresh: () => void + ): () => void { + return this.head !== undefined ? this.head.subscribe(_class, query, options, refresh) : () => {} + } + + async tx (tx: Tx): Promise { + if (this.head === undefined) { + return await this.client.tx(tx) + } else { + return await this.head.tx(tx) + } + } + + async close (): Promise { + await this.client.close() + } +} + +/** + * @public + */ +export abstract class BasePresentationMiddleware { + constructor (protected readonly client: Client, readonly next?: PresentationMiddleware) {} + + async findAll( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + return await this.provideFindAll(_class, query, options) + } + + async findOne( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise | undefined> { + return await this.provideFindOne(_class, query, options) + } + + subscribe( + _class: Ref>, + query: DocumentQuery, + options: FindOptions | undefined, + refresh: () => void + ): () => void { + return this.provideSubscribe(_class, query, options, refresh) + } + + protected async provideTx (tx: Tx): Promise { + if (this.next !== undefined) { + return await this.next.tx(tx) + } + return await this.client.tx(tx) + } + + protected async provideFindAll( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise> { + if (this.next !== undefined) { + return await this.next.findAll(_class, query, options) + } + return await this.client.findAll(_class, query, options) + } + + protected async provideFindOne( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions + ): Promise | undefined> { + if (this.next !== undefined) { + return await this.next.findOne(_class, query, options) + } + return await this.client.findOne(_class, query, options) + } + + protected provideSubscribe( + _class: Ref>, + query: DocumentQuery, + options: FindOptions | undefined, + refresh: () => void + ): () => void { + if (this.next !== undefined) { + return this.next.subscribe(_class, query, options, refresh) + } + return () => {} + } +} diff --git a/packages/presentation/src/status.ts b/packages/presentation/src/status.ts new file mode 100644 index 0000000000..e2fc794d17 --- /dev/null +++ b/packages/presentation/src/status.ts @@ -0,0 +1,150 @@ +// +// 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, + Class, + Client, + Doc, + DocumentQuery, + FindOptions, + FindResult, + generateId, + Ref, + RefTo, + SortingOrder, + StatusManager, + Tx, + TxResult +} from '@hcengineering/core' +import { writable } from 'svelte/store' +import { BasePresentationMiddleware, PresentationMiddleware } from './pipeline' +import { createQuery, LiveQuery } from './utils' + +let statusQuery: LiveQuery +// Issue status live query +export const statusStore = writable(new StatusManager([])) + +interface StatusSubscriber { + attributes: Array> + + _class: Ref> + query: DocumentQuery + options?: FindOptions + + refresh: () => void +} + +/** + * @public + */ +export class StatusMiddleware extends BasePresentationMiddleware implements PresentationMiddleware { + initState: Promise | undefined + mgr: StatusManager | undefined + + subscribers: Map = new Map() + private constructor (client: Client, next?: PresentationMiddleware) { + super(client, next) + } + + async initialize (): Promise { + this.initState = new Promise((resolve) => { + if (statusQuery === undefined) { + statusQuery = createQuery(true) + statusQuery.query( + core.class.Status, + {}, + (res) => { + this.mgr = new StatusManager(res) + statusStore.set(this.mgr) + + this.refreshSubscribers() + + resolve() + }, + { + lookup: { + category: core.class.StatusCategory + }, + sort: { + rank: SortingOrder.Ascending + } + } + ) + } + }) + } + + 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() + } + } + + subscribe( + _class: Ref>, + query: DocumentQuery, + options: FindOptions | undefined, + refresh: () => void + ): () => void { + const ret = this.provideSubscribe(_class, query, options, refresh) + const h = this.client.getHierarchy() + + const id = generateId() + const s: StatusSubscriber = { + _class, + query, + refresh, + options, + attributes: [] + } + for (const [k] of Object.entries(query)) { + try { + const attr = h.findAttribute(_class, k) + if (attr?.type._class === core.class.RefTo && h.isDerived((attr.type as RefTo).to, core.class.Status)) { + // Ok we have status field for query. + s.attributes.push(attr._id) + } + } catch (err: any) { + console.error(err) + } + } + if (s.attributes.length > 0) { + this.subscribers.set(id, s) + return () => { + ret() + this.subscribers.delete(id) + } + } + return ret + } + + static create (client: Client, next?: PresentationMiddleware): StatusMiddleware { + return new StatusMiddleware(client, next) + } + + async findAll( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions | undefined + ): Promise> { + return await this.provideFindAll(_class, query, options) + } + + async tx (tx: Tx): Promise { + return await this.provideTx(tx) + } +} diff --git a/packages/presentation/src/utils.ts b/packages/presentation/src/utils.ts index 8ddd47d416..40d01af309 100644 --- a/packages/presentation/src/utils.ts +++ b/packages/presentation/src/utils.ts @@ -43,10 +43,14 @@ import view, { AttributeEditor } from '@hcengineering/view' import { deepEqual } from 'fast-equals' 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 +let pipeline: PresentationPipeline const txListeners: Array<(tx: Tx) => void> = [] @@ -68,7 +72,7 @@ export function removeTxListener (l: (tx: Tx) => void): void { } class UIClient extends TxOperations implements Client { - constructor (client: Client, private readonly liveQuery: LQ) { + constructor (client: Client, private readonly liveQuery: Client) { super(client, getCurrentAccount()._id) } @@ -89,7 +93,7 @@ class UIClient extends TxOperations implements Client { } override async tx (tx: Tx): Promise { - return await super.tx(tx) + return await this.client.tx(tx) } } @@ -107,9 +111,14 @@ export function setClient (_client: Client): void { if (liveQuery !== undefined) { void liveQuery.close() } + pipeline = PresentationPipelineImpl.create(_client, [StatusMiddleware.create]) + const needRefresh = liveQuery !== undefined - liveQuery = new LQ(_client) - client = new UIClient(_client, liveQuery) + liveQuery = new LQ(pipeline) + client = new UIClient(pipeline, liveQuery) + + void pipeline.initialize() + _client.notify = (tx: Tx) => { liveQuery.tx(tx).catch((err) => console.log(err)) @@ -142,8 +151,8 @@ export class LiveQuery { private oldCallback: ((result: FindResult) => void) | undefined unsubscribe = () => {} - constructor (dontDestroy: boolean = false) { - if (!dontDestroy) { + constructor (noDestroy: boolean = false) { + if (!noDestroy) { onDestroy(() => { this.unsubscribe() }) @@ -177,14 +186,20 @@ export class LiveQuery { this.oldQuery = query const unsub = liveQuery.query(_class, query, callback, options) + const removeQuery = pipeline.subscribe(_class, query, options, () => { + // Refresh query if pipeline decide it is required. + refreshClient() + }) this.unsubscribe = () => { unsub() + removeQuery() this.oldCallback = undefined this.oldClass = undefined this.oldOptions = undefined this.oldQuery = undefined this.unsubscribe = () => {} } + return true } diff --git a/plugins/tracker-assets/lang/en.json b/plugins/tracker-assets/lang/en.json index dfb8b88acb..07304eadd6 100644 --- a/plugins/tracker-assets/lang/en.json +++ b/plugins/tracker-assets/lang/en.json @@ -42,7 +42,9 @@ "CreateProject": "Create project", "NewProject": "New project", "ProjectTitlePlaceholder": "Project title", - "ProjectIdentifierPlaceholder": "Project ID", + "Identifier": "Project Identifier", + "IdentifierExists": "Project identifier already exists", + "ProjectIdentifierPlaceholder": "Project Identifier", "ChooseIcon": "Choose icon", "AddIssue": "Add Issue", "NewIssue": "New issue", diff --git a/plugins/tracker-assets/lang/ru.json b/plugins/tracker-assets/lang/ru.json index 814574ab8a..9ecacb60d8 100644 --- a/plugins/tracker-assets/lang/ru.json +++ b/plugins/tracker-assets/lang/ru.json @@ -42,6 +42,8 @@ "CreateProject": "Создать проект", "NewProject": "Новый проект", "ProjectTitlePlaceholder": "Название проекта", + "Identifier": "Идентификатор проекта", + "IdentifierExists": "Идентификатор уже существует проекта", "ProjectIdentifierPlaceholder": "Идентификатор проекта", "ChooseIcon": "Выбрать иконку", "AddIssue": "Добавить задачу", diff --git a/plugins/tracker-resources/src/components/SetParentIssueActionPopup.svelte b/plugins/tracker-resources/src/components/SetParentIssueActionPopup.svelte index 63b4330c2c..befddc8f35 100644 --- a/plugins/tracker-resources/src/components/SetParentIssueActionPopup.svelte +++ b/plugins/tracker-resources/src/components/SetParentIssueActionPopup.svelte @@ -13,7 +13,7 @@ // limitations under the License. --> {#if value} - {@const icon = $statusStore.byId.get(value._id)?.$lookup?.category?.icon}
- {#if !inline && icon} + {#if !inline} {/if} - + {value.name}
diff --git a/plugins/tracker-resources/src/components/issues/StatusRefPresenter.svelte b/plugins/tracker-resources/src/components/issues/StatusRefPresenter.svelte index 2f98d49d8f..316d5b2c1d 100644 --- a/plugins/tracker-resources/src/components/issues/StatusRefPresenter.svelte +++ b/plugins/tracker-resources/src/components/issues/StatusRefPresenter.svelte @@ -13,15 +13,14 @@ // limitations under the License. --> {#if value} - + {/if} diff --git a/plugins/tracker-resources/src/components/issues/edit/SubIssuesSelector.svelte b/plugins/tracker-resources/src/components/issues/edit/SubIssuesSelector.svelte index c04d377dee..1516b55273 100644 --- a/plugins/tracker-resources/src/components/issues/edit/SubIssuesSelector.svelte +++ b/plugins/tracker-resources/src/components/issues/edit/SubIssuesSelector.svelte @@ -29,7 +29,8 @@ } from '@hcengineering/ui' import { getIssueId } from '../../../issues' import tracker from '../../../plugin' - import { statusStore, subIssueListProvider } from '../../../utils' + import { subIssueListProvider } from '../../../utils' + import { statusStore } from '@hcengineering/presentation' export let value: WithLookup export let currentProject: Project | undefined = undefined diff --git a/plugins/tracker-resources/src/components/issues/related/RelatedIssueSelector.svelte b/plugins/tracker-resources/src/components/issues/related/RelatedIssueSelector.svelte index d8d2e48f7e..8ca12c3071 100644 --- a/plugins/tracker-resources/src/components/issues/related/RelatedIssueSelector.svelte +++ b/plugins/tracker-resources/src/components/issues/related/RelatedIssueSelector.svelte @@ -29,7 +29,8 @@ } from '@hcengineering/ui' import { getIssueId } from '../../../issues' import tracker from '../../../plugin' - import { statusStore, subIssueListProvider } from '../../../utils' + import { subIssueListProvider } from '../../../utils' + import { statusStore } from '@hcengineering/presentation' export let object: WithLookup | undefined export let value: WithLookup | undefined diff --git a/plugins/tracker-resources/src/components/projects/ChangeIdentity.svelte b/plugins/tracker-resources/src/components/projects/ChangeIdentity.svelte new file mode 100644 index 0000000000..604b90ad09 --- /dev/null +++ b/plugins/tracker-resources/src/components/projects/ChangeIdentity.svelte @@ -0,0 +1,41 @@ + + + { + dispatch('close') + }} +> +
+
+ +
+
+
diff --git a/plugins/tracker-resources/src/components/projects/CreateProject.svelte b/plugins/tracker-resources/src/components/projects/CreateProject.svelte index 44846e28f5..9e381a7366 100644 --- a/plugins/tracker-resources/src/components/projects/CreateProject.svelte +++ b/plugins/tracker-resources/src/components/projects/CreateProject.svelte @@ -14,17 +14,17 @@ --> - +
+ + {#if !isNew} +
dispatch('changeContent')}> diff --git a/plugins/view-resources/src/components/list/ListCategories.svelte b/plugins/view-resources/src/components/list/ListCategories.svelte index 9576b70ed3..b46b28e50f 100644 --- a/plugins/view-resources/src/components/list/ListCategories.svelte +++ b/plugins/view-resources/src/components/list/ListCategories.svelte @@ -13,7 +13,7 @@ // limitations under the License. --> {#each categories as category, i (category)} - {@const items = groupedDocs[category] ?? []} + {@const items = getCategoryValues(groupedDocs, category)} + + +{#if value} +
+ {#if icon && typeof icon === 'string'} + + {:else if icon !== undefined && typeof icon !== 'string'} + + {/if} + + {value.name} + +
+{/if} diff --git a/plugins/view-resources/src/components/status/StatusRefPresenter.svelte b/plugins/view-resources/src/components/status/StatusRefPresenter.svelte new file mode 100644 index 0000000000..ad97338804 --- /dev/null +++ b/plugins/view-resources/src/components/status/StatusRefPresenter.svelte @@ -0,0 +1,30 @@ + + + +{#if value} + +{/if} diff --git a/plugins/view-resources/src/index.ts b/plugins/view-resources/src/index.ts index 01e846894c..a8cd9ed3d9 100644 --- a/plugins/view-resources/src/index.ts +++ b/plugins/view-resources/src/index.ts @@ -27,9 +27,12 @@ import ColorsPopup from './components/ColorsPopup.svelte' import DateEditor from './components/DateEditor.svelte' import DatePresenter from './components/DatePresenter.svelte' import DocAttributeBar from './components/DocAttributeBar.svelte' +import DocNavLink from './components/DocNavLink.svelte' import EditBoxPopup from './components/EditBoxPopup.svelte' import EditDoc from './components/EditDoc.svelte' +import EnumArrayEditor from './components/EnumArrayEditor.svelte' import EnumEditor from './components/EnumEditor.svelte' +import EnumPresenter from './components/EnumPresenter.svelte' import FilterBar from './components/filter/FilterBar.svelte' import FilterTypePopup from './components/filter/FilterTypePopup.svelte' import ObjectFilter from './components/filter/ObjectFilter.svelte' @@ -49,11 +52,14 @@ import MarkupEditor from './components/MarkupEditor.svelte' import MarkupEditorPopup from './components/MarkupEditorPopup.svelte' import MarkupPresenter from './components/MarkupPresenter.svelte' import Menu from './components/Menu.svelte' +import TreeItem from './components/navigator/TreeItem.svelte' +import TreeNode from './components/navigator/TreeNode.svelte' import NumberEditor from './components/NumberEditor.svelte' import NumberPresenter from './components/NumberPresenter.svelte' import ObjectPresenter from './components/ObjectPresenter.svelte' import RolePresenter from './components/RolePresenter.svelte' import SpacePresenter from './components/SpacePresenter.svelte' +import SpaceRefPresenter from './components/SpaceRefPresenter.svelte' import StringEditor from './components/StringEditor.svelte' import StringPresenter from './components/StringPresenter.svelte' import Table from './components/Table.svelte' @@ -62,12 +68,8 @@ import TimestampPresenter from './components/TimestampPresenter.svelte' import UpDownNavigator from './components/UpDownNavigator.svelte' import ValueSelector from './components/ValueSelector.svelte' import ViewletSettingButton from './components/ViewletSettingButton.svelte' -import SpaceRefPresenter from './components/SpaceRefPresenter.svelte' -import EnumArrayEditor from './components/EnumArrayEditor.svelte' -import EnumPresenter from './components/EnumPresenter.svelte' -import TreeNode from './components/navigator/TreeNode.svelte' -import TreeItem from './components/navigator/TreeItem.svelte' -import DocNavLink from './components/DocNavLink.svelte' +import StatusPresenter from './components/status/StatusPresenter.svelte' +import StatusRefPresenter from './components/status/StatusRefPresenter.svelte' import { afterResult, @@ -82,11 +84,7 @@ import { import { IndexedDocumentPreview } from '@hcengineering/presentation' import { showEmptyGroups } from './viewOptions' - -function PositionElementAlignment (e?: Event): PopupAlignment | undefined { - return getEventPopupPositionElement(e) -} - +import { statusSort } from './utils' export { getActions, invokeAction } from './actions' export { default as ActionContext } from './components/ActionContext.svelte' export { default as ActionHandler } from './components/ActionHandler.svelte' @@ -95,30 +93,33 @@ export { default as FixedColumn } from './components/FixedColumn.svelte' export { default as SourcePresenter } from './components/inference/SourcePresenter.svelte' export { default as LinkPresenter } from './components/LinkPresenter.svelte' export { default as List } from './components/list/List.svelte' +export { default as MarkupPresenter } from './components/MarkupPresenter.svelte' +export { default as MarkupPreviewPopup } from './components/MarkupPreviewPopup.svelte' export { default as ContextMenu } from './components/Menu.svelte' export { default as ObjectBox } from './components/ObjectBox.svelte' -export { default as ObjectSearchBox } from './components/ObjectSearchBox.svelte' export { default as ObjectPresenter } from './components/ObjectPresenter.svelte' +export { default as ObjectSearchBox } from './components/ObjectSearchBox.svelte' +export { default as StatusPresenter } from './components/status/StatusPresenter.svelte' +export { default as StatusRefPresenter } from './components/status/StatusRefPresenter.svelte' export { default as TableBrowser } from './components/TableBrowser.svelte' export { default as ValueSelector } from './components/ValueSelector.svelte' -export { default as MarkupPreviewPopup } from './components/MarkupPreviewPopup.svelte' -export { default as MarkupPresenter } from './components/MarkupPresenter.svelte' export * from './context' export * from './filter' export * from './selection' +export * from './utils' export { buildModel, getActiveViewletId, + getAdditionalHeader, + getCategories, getCollectionCounter, getFiltredKeys, getObjectPresenter, getObjectPreview, + groupBy, isCollectionAttr, LoadingProps, - setActiveViewletId, - getAdditionalHeader, - groupBy, - getCategories + setActiveViewletId } from './utils' export * from './viewOptions' export { @@ -152,6 +153,10 @@ export { EditBoxPopup } +function PositionElementAlignment (e?: Event): PopupAlignment | undefined { + return getEventPopupPositionElement(e) +} + export default async (): Promise => ({ actionImpl, component: { @@ -194,7 +199,9 @@ export default async (): Promise => ({ IndexedDocumentPreview, SpaceRefPresenter, EnumArrayEditor, - EnumPresenter + EnumPresenter, + StatusPresenter, + StatusRefPresenter }, popup: { PositionElementAlignment @@ -208,6 +215,7 @@ export default async (): Promise => ({ FilterAfterResult: afterResult, FilterNestedMatchResult: nestedMatchResult, FilterNestedDontMatchResult: nestedDontMatchResult, - ShowEmptyGroups: showEmptyGroups + ShowEmptyGroups: showEmptyGroups, + StatusSort: statusSort } }) diff --git a/plugins/view-resources/src/plugin.ts b/plugins/view-resources/src/plugin.ts index 63b1b8d1f4..08b08ce4fd 100644 --- a/plugins/view-resources/src/plugin.ts +++ b/plugins/view-resources/src/plugin.ts @@ -16,7 +16,7 @@ import { IntlString, mergeIds } from '@hcengineering/platform' import { AnyComponent } from '@hcengineering/ui' -import view, { viewId } from '@hcengineering/view' +import view, { SortFunc, viewId } from '@hcengineering/view' export default mergeIds(viewId, view, { component: { @@ -64,5 +64,8 @@ export default mergeIds(viewId, view, { Shown: '' as IntlString, ShowEmptyGroups: '' as IntlString, Total: '' as IntlString + }, + function: { + StatusSort: '' as SortFunc } }) diff --git a/plugins/view-resources/src/utils.ts b/plugins/view-resources/src/utils.ts index 02545fa23a..641f23d2b3 100644 --- a/plugins/view-resources/src/utils.ts +++ b/plugins/view-resources/src/utils.ts @@ -27,13 +27,15 @@ import core, { Ref, RefTo, ReverseLookup, + StatusValue, ReverseLookups, Space, + Status, TxOperations } from '@hcengineering/core' import type { IntlString } from '@hcengineering/platform' import { getResource } from '@hcengineering/platform' -import { AttributeCategory, getAttributePresenterClass, KeyedAttribute } from '@hcengineering/presentation' +import { AttributeCategory, createQuery, getAttributePresenterClass, KeyedAttribute } from '@hcengineering/presentation' import { AnyComponent, ErrorPresenter, @@ -521,7 +523,32 @@ export async function getCategories ( if (key === noCategory) return [undefined] const existingCategories = Array.from(new Set(docs.map((x: any) => x[key] ?? undefined))) - return await sortCategories(client, _class, existingCategories, key, viewletDescriptorId) + const result = await sortCategories(client, _class, existingCategories, key, viewletDescriptorId) + + const h = client.getHierarchy() + // group categories + const attr = h.getAttribute(_class, key) + if (attr.type._class === core.class.RefTo && h.isDerived((attr.type as RefTo).to, core.class.Status)) { + const statuses = await client.findAll(core.class.Status, { _id: { $in: result as unknown as Array> } }) + // We have status attribute and need perform group. + const newResult: StatusValue[] = [] + const unique = [...new Set(statuses.map((v) => v.name))] + unique.forEach((label, i) => { + let count = 0 + statuses.forEach((state) => { + if (state.name === label) { + if (count === 0) { + newResult[i] = { name: state.name, value: [state._id], color: state.color } + } else { + newResult[i] = { name: state.name, value: [...newResult[i].value, state._id], color: state.color } + } + count++ + } + }) + }) + return newResult.map((it) => (it.value.length === 1 ? it.value[0] : it)) + } + return result } export async function sortCategories ( @@ -666,3 +693,34 @@ export async function getObjectLinkFragment ( loc.fragment = getPanelURI(component, object._id, Hierarchy.mixinOrClass(object), 'content') return loc } + +export async function statusSort ( + value: Array>, + viewletDescriptorId?: Ref +): Promise>> { + return await new Promise((resolve) => { + // TODO: How we track category updates. + const query = createQuery(true) + query.query( + core.class.Status, + { _id: { $in: value } }, + (res) => { + res.sort((a, b) => { + const res = (a.$lookup?.category?.order ?? 0) - (b.$lookup?.category?.order ?? 0) + if (res === 0) { + return a.rank.localeCompare(b.rank) + } + return res + }) + + resolve(res.map((p) => p._id)) + query.unsubscribe() + }, + { + sort: { + rank: 1 + } + } + ) + }) +} From 84e8b35a3eb05d33ccc9689408dcaecee54c3c65 Mon Sep 17 00:00:00 2001 From: Andrey Sobolev Date: Wed, 29 Mar 2023 20:09:07 +0700 Subject: [PATCH 02/11] Finish with list view and start Fix Kanban view Signed-off-by: Andrey Sobolev --- packages/core/src/status.ts | 11 +- packages/kanban/src/components/Kanban.svelte | 165 +++++++----------- packages/kanban/src/types.ts | 2 +- packages/presentation/src/status.ts | 79 ++++++++- .../src/components/ScheduleView.svelte | 4 +- .../src/components/issues/KanbanView.svelte | 62 ++++--- .../issues/StatusRefPresenter.svelte | 2 +- .../scrums/ScrumRecordObjects.svelte | 1 + plugins/tracker-resources/src/utils.ts | 116 +++++------- .../src/components/filter/ObjectFilter.svelte | 44 ++--- .../src/components/list/List.svelte | 7 +- .../src/components/list/ListCategories.svelte | 24 +-- .../src/components/list/ListCategory.svelte | 97 ++++++---- .../src/components/list/ListHeader.svelte | 4 +- plugins/view-resources/src/utils.ts | 84 +++++---- plugins/view/src/index.ts | 4 +- 16 files changed, 394 insertions(+), 312 deletions(-) diff --git a/packages/core/src/status.ts b/packages/core/src/status.ts index 90977490ec..f7854a3034 100644 --- a/packages/core/src/status.ts +++ b/packages/core/src/status.ts @@ -56,10 +56,8 @@ export interface Status extends Doc { /** * @public */ -export interface StatusValue { - name: string - color?: number - value: Ref[] // Real status items per category. +export class StatusValue { + constructor (readonly name: string, readonly color: number | undefined, readonly values: WithLookup[]) {} } /** @@ -82,3 +80,8 @@ export class StatusManager { return this.statuses.filter(predicate) } } + +/** + * @public + */ +export type CategoryType = number | string | undefined | Ref | StatusValue diff --git a/packages/kanban/src/components/Kanban.svelte b/packages/kanban/src/components/Kanban.svelte index 3741b8a67e..55522d868c 100644 --- a/packages/kanban/src/components/Kanban.svelte +++ b/packages/kanban/src/components/Kanban.svelte @@ -13,81 +13,49 @@ // limitations under the License. --> {#if !states?.length} @@ -262,12 +277,11 @@ { listProvider.update(evt.detail) }} diff --git a/plugins/tracker-resources/src/components/issues/StatusRefPresenter.svelte b/plugins/tracker-resources/src/components/issues/StatusRefPresenter.svelte index 316d5b2c1d..c76a084235 100644 --- a/plugins/tracker-resources/src/components/issues/StatusRefPresenter.svelte +++ b/plugins/tracker-resources/src/components/issues/StatusRefPresenter.svelte @@ -22,5 +22,5 @@ {#if value} - + {/if} diff --git a/plugins/tracker-resources/src/components/scrums/ScrumRecordObjects.svelte b/plugins/tracker-resources/src/components/scrums/ScrumRecordObjects.svelte index 38e06bce43..42360f1788 100644 --- a/plugins/tracker-resources/src/components/scrums/ScrumRecordObjects.svelte +++ b/plugins/tracker-resources/src/components/scrums/ScrumRecordObjects.svelte @@ -14,6 +14,7 @@ -->
(space ? { space } : {})} {elementByIndex} {indexById} {docs} diff --git a/plugins/view-resources/src/components/list/ListCategories.svelte b/plugins/view-resources/src/components/list/ListCategories.svelte index b46b28e50f..e26dee7188 100644 --- a/plugins/view-resources/src/components/list/ListCategories.svelte +++ b/plugins/view-resources/src/components/list/ListCategories.svelte @@ -13,9 +13,9 @@ // limitations under the License. --> diff --git a/plugins/view-resources/src/utils.ts b/plugins/view-resources/src/utils.ts index 641f23d2b3..debcba2665 100644 --- a/plugins/view-resources/src/utils.ts +++ b/plugins/view-resources/src/utils.ts @@ -16,21 +16,22 @@ import core, { AttachedDoc, + CategoryType, Class, Client, Collection, Doc, DocumentUpdate, + getObjectValue, Hierarchy, Lookup, - Obj, - Ref, + Obj, Ref, RefTo, - ReverseLookup, - StatusValue, - ReverseLookups, + ReverseLookup, ReverseLookups, Space, Status, + StatusManager, + StatusValue, TxOperations } from '@hcengineering/core' import type { IntlString } from '@hcengineering/platform' @@ -501,12 +502,23 @@ export type FixedWidthStore = Record export const fixedWidthStore = writable({}) -export function groupBy (docs: T[], key: string): { [key: string]: T[] } { +export function groupBy (docs: T[], key: string, categories?: CategoryType[]): { [key: string | number]: T[] } { return docs.reduce((storage: { [key: string]: T[] }, item: T) => { - const group = (item as any)[key] ?? undefined + let group = getObjectValue(key, item) ?? undefined + + if (categories !== undefined) { + for (const c of categories) { + if (typeof c === 'object') { + const st = c.values.find(it => it._id === group) + if (st !== undefined) { + group = st.name + break + } + } + } + } storage[group] = storage[group] ?? [] - storage[group].push(item) return storage @@ -518,36 +530,44 @@ export async function getCategories ( _class: Ref>, docs: Doc[], key: string, + mgr: StatusManager, viewletDescriptorId?: Ref -): Promise { +): Promise { if (key === noCategory) return [undefined] - const existingCategories = Array.from(new Set(docs.map((x: any) => x[key] ?? undefined))) - - const result = await sortCategories(client, _class, existingCategories, key, viewletDescriptorId) - const h = client.getHierarchy() - // group categories const attr = h.getAttribute(_class, key) - if (attr.type._class === core.class.RefTo && h.isDerived((attr.type as RefTo).to, core.class.Status)) { - const statuses = await client.findAll(core.class.Status, { _id: { $in: result as unknown as Array> } }) - // We have status attribute and need perform group. - const newResult: StatusValue[] = [] - const unique = [...new Set(statuses.map((v) => v.name))] - unique.forEach((label, i) => { - let count = 0 - statuses.forEach((state) => { - if (state.name === label) { - if (count === 0) { - newResult[i] = { name: state.name, value: [state._id], color: state.color } - } else { - newResult[i] = { name: state.name, value: [...newResult[i].value, state._id], color: state.color } - } - count++ + const isStatusField = attr.type._class === core.class.RefTo && h.isDerived((attr.type as RefTo).to, core.class.Status) + + const valueSet = new Set() + const existingCategories = [] + const statusMap = new Map() + + for (const d of docs) { + const v = getObjectValue(key, d) ?? undefined + + if (isStatusField) { + const status = mgr.byId.get(v) + if (status !== undefined) { + let fst = statusMap.get(status.name) + if (fst === undefined) { + const sttt = mgr.statuses + .filter((it) => it.ofAttribute === attr._id && it.name === status.name) + .sort((a, b) => a.rank.localeCompare(b.rank)) + fst = new StatusValue(status.name, status.color, sttt) + statusMap.set(status.name, fst) + existingCategories.push(fst) } - }) - }) - return newResult.map((it) => (it.value.length === 1 ? it.value[0] : it)) + } + } else { + if (!valueSet.has(v)) { + valueSet.add(v) + existingCategories.push(v) + } + } } + + const result = await sortCategories(client, _class, existingCategories, key, viewletDescriptorId) + return result } diff --git a/plugins/view/src/index.ts b/plugins/view/src/index.ts index 0cccc193c9..39647a0bb9 100644 --- a/plugins/view/src/index.ts +++ b/plugins/view/src/index.ts @@ -25,9 +25,11 @@ import type { Mixin, Obj, ObjQueryType, + PrimitiveType, Ref, SortingOrder, Space, + StatusValue, Type, UXObject } from '@hcengineering/core' @@ -236,7 +238,7 @@ export interface ListHeaderExtra extends Class { /** * @public */ -export type SortFunc = Resource<(values: any[], viewletDescriptorId?: Ref) => Promise> +export type SortFunc = Resource<(values: (PrimitiveType | StatusValue)[], viewletDescriptorId?: Ref) => Promise> /** * @public From 413634e3d1155e9e3bd4d438e54d39b405084fb1 Mon Sep 17 00:00:00 2001 From: Andrey Sobolev Date: Thu, 30 Mar 2023 00:55:55 +0700 Subject: [PATCH 03/11] Kanban working for Tracker. Signed-off-by: Andrey Sobolev --- packages/kanban/src/components/Kanban.svelte | 221 ++++++++------ .../kanban/src/components/KanbanRow.svelte | 8 +- packages/presentation/src/status.ts | 1 - .../src/components/issues/KanbanView.svelte | 289 ++++++++---------- .../scrums/ScrumRecordObjects.svelte | 1 - plugins/tracker-resources/src/utils.ts | 121 +------- .../src/components/filter/ObjectFilter.svelte | 2 +- .../src/components/list/List.svelte | 2 +- .../src/components/list/ListCategories.svelte | 18 +- .../src/components/list/ListCategory.svelte | 4 +- plugins/view-resources/src/utils.ts | 47 ++- plugins/view/src/index.ts | 4 +- 12 files changed, 328 insertions(+), 390 deletions(-) diff --git a/packages/kanban/src/components/Kanban.svelte b/packages/kanban/src/components/Kanban.svelte index 55522d868c..d6f222706d 100644 --- a/packages/kanban/src/components/Kanban.svelte +++ b/packages/kanban/src/components/Kanban.svelte @@ -15,47 +15,51 @@ -{#if !states?.length} +{#if categories.length === 0}} {:else} + groupByKey === noCategory ? issues : getGroupByValues(groupByDocs, category)} + {setGroupByValues} + {getUpdateProps} + {groupByDocs} on:content={(evt) => { listProvider.update(evt.detail) }} @@ -296,27 +267,23 @@ on:contextmenu={(evt) => showMenu(evt.detail.evt, evt.detail.objects)} > - {@const status = $statusStore.get(state._id)} +
-
-
- {#if state.icon} - {#if groupBy === 'status' && status} - - {:else} - - {/if} - {/if} - {state.title} - {count} -
+
+ {#if groupByKey === noCategory} + + + {:else if headerComponent} + + {/if}
@@ -326,69 +293,71 @@ {@const issue = toIssue(object)} {@const issueId = object._id} -
{ - showPanel(tracker.component.EditIssue, object._id, object._class, 'content') - }} - > -
-
- - -
-
- {#if groupBy !== 'status'} - - {/if} - - {object.title} - + {#key issueId} +
{ + showPanel(tracker.component.EditIssue, object._id, object._class, 'content') + }} + > +
+
+ + +
+
+ {#if groupByKey !== 'status'} + + {/if} + + {object.title} + +
-
-
- -
- +
+ +
+ +
-
-
- {#if issue && issue.subIssues > 0} - - {/if} - - - -
- { - if (res.detail.full) fullFilled[issueId] = true - }} +
+ {#if issue && issue.subIssues > 0} + + {/if} + + + +
+ { + if (res.detail.full) fullFilled[issueId] = true + }} + /> +
-
+ {/key} {/if} diff --git a/plugins/tracker-resources/src/components/scrums/ScrumRecordObjects.svelte b/plugins/tracker-resources/src/components/scrums/ScrumRecordObjects.svelte index 42360f1788..38e06bce43 100644 --- a/plugins/tracker-resources/src/components/scrums/ScrumRecordObjects.svelte +++ b/plugins/tracker-resources/src/components/scrums/ScrumRecordObjects.svelte @@ -14,7 +14,6 @@ --> {#each categories as category, i (category)} - {@const items = getCategoryValues(groupedDocs, category)} + {@const items = groupByKey === noCategory ? docs : getGroupByValues(groupByDocs, category)} > export let config: (string | BuildModelKey)[] export let viewOptions: ViewOptions - export let newObjectProps: (doc: Doc) => Record|undefined + export let newObjectProps: (doc: Doc) => Record | undefined export let docByIndex: Map export let viewOptionsConfig: ViewOptionModel[] | undefined export let dragItem: { @@ -288,7 +288,7 @@ dragItem = { doc: docObject, revert: () => { - const d = items.find(it => it._id === docObject._id) + const d = items.find((it) => it._id === docObject._id) if (d === undefined) { items.splice(i, 0, docObject) items = items diff --git a/plugins/view-resources/src/utils.ts b/plugins/view-resources/src/utils.ts index debcba2665..7f6408bb78 100644 --- a/plugins/view-resources/src/utils.ts +++ b/plugins/view-resources/src/utils.ts @@ -25,9 +25,11 @@ import core, { getObjectValue, Hierarchy, Lookup, - Obj, Ref, + Obj, + Ref, RefTo, - ReverseLookup, ReverseLookups, + ReverseLookup, + ReverseLookups, Space, Status, StatusManager, @@ -502,14 +504,18 @@ export type FixedWidthStore = Record export const fixedWidthStore = writable({}) -export function groupBy (docs: T[], key: string, categories?: CategoryType[]): { [key: string | number]: T[] } { +export function groupBy ( + docs: T[], + key: string, + categories?: CategoryType[] +): { [key: string | number]: T[] } { return docs.reduce((storage: { [key: string]: T[] }, item: T) => { let group = getObjectValue(key, item) ?? undefined if (categories !== undefined) { for (const c of categories) { if (typeof c === 'object') { - const st = c.values.find(it => it._id === group) + const st = c.values.find((it) => it._id === group) if (st !== undefined) { group = st.name break @@ -525,6 +531,36 @@ export function groupBy (docs: T[], key: string, categories?: Cat }, {}) } +/** + * @public + */ +export function getGroupByValues ( + groupByDocs: Record, + category: CategoryType +): T[] { + if (typeof category === 'object') { + return groupByDocs[category.name] ?? [] + } else if (category !== undefined) { + return groupByDocs[category] ?? [] + } + return [] +} + +/** + * @public + */ +export function setGroupByValues ( + groupByDocs: Record, + category: CategoryType, + docs: Doc[] +): void { + if (typeof category === 'object') { + groupByDocs[category.name] = docs + } else if (category !== undefined) { + groupByDocs[category] = docs + } +} + export async function getCategories ( client: TxOperations, _class: Ref>, @@ -536,7 +572,8 @@ export async function getCategories ( if (key === noCategory) return [undefined] const h = client.getHierarchy() const attr = h.getAttribute(_class, key) - const isStatusField = attr.type._class === core.class.RefTo && h.isDerived((attr.type as RefTo).to, core.class.Status) + const isStatusField = + attr.type._class === core.class.RefTo && h.isDerived((attr.type as RefTo).to, core.class.Status) const valueSet = new Set() const existingCategories = [] diff --git a/plugins/view/src/index.ts b/plugins/view/src/index.ts index 39647a0bb9..0bf781776d 100644 --- a/plugins/view/src/index.ts +++ b/plugins/view/src/index.ts @@ -238,7 +238,9 @@ export interface ListHeaderExtra extends Class { /** * @public */ -export type SortFunc = Resource<(values: (PrimitiveType | StatusValue)[], viewletDescriptorId?: Ref) => Promise> +export type SortFunc = Resource< +(values: (PrimitiveType | StatusValue)[], viewletDescriptorId?: Ref) => Promise +> /** * @public From d93bd738e40f962a410debc96a498cbeb67f3308 Mon Sep 17 00:00:00 2001 From: Andrey Sobolev Date: Thu, 30 Mar 2023 12:07:29 +0700 Subject: [PATCH 04/11] Fix all statuses Signed-off-by: Andrey Sobolev --- models/tracker/src/index.ts | 4 --- models/view/src/index.ts | 7 ++-- .../kanban/src/components/KanbanRow.svelte | 24 ++++++++++++- .../src/components/issues/KanbanView.svelte | 20 ++++------- plugins/tracker-resources/src/index.ts | 2 -- plugins/tracker-resources/src/plugin.ts | 19 +++------- plugins/tracker-resources/src/utils.ts | 8 ----- .../src/components/list/ListCategories.svelte | 7 +--- plugins/view-resources/src/utils.ts | 34 +++++++++++++----- plugins/view-resources/src/viewOptions.ts | 21 +++++++---- plugins/view/src/index.ts | 36 ++++++++++++------- 11 files changed, 103 insertions(+), 79 deletions(-) diff --git a/models/tracker/src/index.ts b/models/tracker/src/index.ts index ae71573ca7..ad6a92392e 100644 --- a/models/tracker/src/index.ts +++ b/models/tracker/src/index.ts @@ -924,10 +924,6 @@ export function createModel (builder: Builder): void { fields: ['assignee'] }) - builder.mixin(tracker.class.IssueStatus, core.class.Class, view.mixin.AllValuesFunc, { - func: tracker.function.GetAllStatuses - }) - builder.mixin(tracker.class.TypeIssuePriority, core.class.Class, view.mixin.AllValuesFunc, { func: tracker.function.GetAllPriority }) diff --git a/models/view/src/index.ts b/models/view/src/index.ts index 0bb80e5f73..3657028b34 100644 --- a/models/view/src/index.ts +++ b/models/view/src/index.ts @@ -64,7 +64,8 @@ import type { ViewOptions, AllValuesFunc, LinkProvider, - ObjectPanel + ObjectPanel, + AllValuesFuncGetter } from '@hcengineering/view' import view from './plugin' @@ -220,9 +221,7 @@ export class TSortFuncs extends TClass implements ClassSortFuncs { @Mixin(view.mixin.AllValuesFunc, core.class.Class) export class TAllValuesFunc extends TClass implements AllValuesFunc { - func!: Resource< - (space: Ref | undefined, onUpdate: () => void, queryId: Ref) => Promise - > + func!: Resource } @Model(view.class.ViewletPreference, preference.class.Preference) diff --git a/packages/kanban/src/components/KanbanRow.svelte b/packages/kanban/src/components/KanbanRow.svelte index fa6dd83e4b..b776f576ac 100644 --- a/packages/kanban/src/components/KanbanRow.svelte +++ b/packages/kanban/src/components/KanbanRow.svelte @@ -14,6 +14,7 @@ --> -{#each stateObjects as object, i} +{#each limitedObjects as object, i (object._id)} {@const dragged = isDragging && object._id === dragCard?._id}
{/each} +{#if stateObjects.length > limitedObjects.length} +
+
+ + {limitedObjects.length}/{stateObjects.length} + +
+
+{/if} diff --git a/plugins/task-resources/src/components/state/DoneStatePresenter.svelte b/plugins/task-resources/src/components/state/DoneStatePresenter.svelte index 5a57f259bd..3304098e6d 100644 --- a/plugins/task-resources/src/components/state/DoneStatePresenter.svelte +++ b/plugins/task-resources/src/components/state/DoneStatePresenter.svelte @@ -32,7 +32,7 @@
{#if showTitle} - {value.title} + {value.name} {/if}
{/if} diff --git a/plugins/task-resources/src/components/state/DoneStateRefPresenter.svelte b/plugins/task-resources/src/components/state/DoneStateRefPresenter.svelte index 45ae413e9e..32b261da32 100644 --- a/plugins/task-resources/src/components/state/DoneStateRefPresenter.svelte +++ b/plugins/task-resources/src/components/state/DoneStateRefPresenter.svelte @@ -14,20 +14,16 @@ // limitations under the License. --> -{#if state} +{#if value} + {@const state = $statusStore.get(typeof value === 'string' ? value : value.values[0]._id)} {/if} diff --git a/plugins/task-resources/src/components/state/DoneStatesPopup.svelte b/plugins/task-resources/src/components/state/DoneStatesPopup.svelte index cea6a3e334..ad28fe1dc8 100644 --- a/plugins/task-resources/src/components/state/DoneStatesPopup.svelte +++ b/plugins/task-resources/src/components/state/DoneStatesPopup.svelte @@ -59,7 +59,7 @@
- {state.title} + {state.name} {/each}
-
+{/if} diff --git a/plugins/task-assets/lang/en.json b/plugins/task-assets/lang/en.json index d661b0d446..f303c4b762 100644 --- a/plugins/task-assets/lang/en.json +++ b/plugins/task-assets/lang/en.json @@ -33,7 +33,6 @@ "Kanban": "Kanban", "ApplicationLabelTask": "Tasks", "Projects": "Projects", - "CreateProject": "New Project", "ProjectNamePlaceholder": "Project name", "TaskNamePlaceholder": "The boring task", "TodoDescriptionPlaceholder": "todo...", diff --git a/plugins/task-assets/lang/ru.json b/plugins/task-assets/lang/ru.json index b27783d076..50991ac0c2 100644 --- a/plugins/task-assets/lang/ru.json +++ b/plugins/task-assets/lang/ru.json @@ -33,7 +33,6 @@ "Kanban": "Канбан", "ApplicationLabelTask": "Задачи", "Projects": "Проекты", - "CreateProject": "Новый проект", "ProjectNamePlaceholder": "Название проекта", "TaskNamePlaceholder": "Задача", "TodoDescriptionPlaceholder": "todo...", diff --git a/plugins/task-resources/src/components/CreateProject.svelte b/plugins/task-resources/src/components/CreateProject.svelte deleted file mode 100644 index 1379f3a187..0000000000 --- a/plugins/task-resources/src/components/CreateProject.svelte +++ /dev/null @@ -1,75 +0,0 @@ - - - - 0} - on:close={() => { - dispatch('close') - }} -> - - - - - - diff --git a/plugins/task-resources/src/components/Dashboard.svelte b/plugins/task-resources/src/components/Dashboard.svelte index 260f797c4f..f2ee3f6a49 100644 --- a/plugins/task-resources/src/components/Dashboard.svelte +++ b/plugins/task-resources/src/components/Dashboard.svelte @@ -106,7 +106,7 @@ p._id, { _id: p._id, - label: p.title, + label: p.name, values: [ { color: 10, value: 0 }, { color: 0, value: 0 }, diff --git a/plugins/task-resources/src/components/EditIssue.svelte b/plugins/task-resources/src/components/EditIssue.svelte deleted file mode 100644 index 49eec382c7..0000000000 --- a/plugins/task-resources/src/components/EditIssue.svelte +++ /dev/null @@ -1,78 +0,0 @@ - - - -{#if object !== undefined} - - change('name', object.name)} - /> - -
- -
-
-{/if} - - diff --git a/plugins/task-resources/src/components/KanbanCard.svelte b/plugins/task-resources/src/components/KanbanCard.svelte deleted file mode 100644 index 214c33858e..0000000000 --- a/plugins/task-resources/src/components/KanbanCard.svelte +++ /dev/null @@ -1,78 +0,0 @@ - - - -
-
-
- - {#if todoItems.length > 0} - - ({doneTasks?.length}/{todoItems.length}) - - {/if} -
-
-
- -
- -
-
-
{object.name}
- -
-
- {#if (object.attachments ?? 0) > 0} -
- -
- {/if} - {#if (object.comments ?? 0) > 0} -
- -
- {/if} -
- -
-
diff --git a/plugins/task-resources/src/components/TaskItem.svelte b/plugins/task-resources/src/components/TaskItem.svelte deleted file mode 100644 index d20b118aa8..0000000000 --- a/plugins/task-resources/src/components/TaskItem.svelte +++ /dev/null @@ -1,48 +0,0 @@ - - - -
- -
- {#if shortLabel}{shortLabel}-{/if}{value.number} -
- {#if name} -
{name}
- {/if} -
- - diff --git a/plugins/task-resources/src/components/TaskPresenter.svelte b/plugins/task-resources/src/components/TaskPresenter.svelte index 234619503c..02620df8e0 100644 --- a/plugins/task-resources/src/components/TaskPresenter.svelte +++ b/plugins/task-resources/src/components/TaskPresenter.svelte @@ -15,12 +15,12 @@ --> {#each categories as category, i (category)} - {@const items = groupByKey === noCategory ? docs : getGroupByValues(groupByDocs, category)} + {@const items = groupByKey === noCategory || category === undefined ? docs : getGroupByValues(groupByDocs, category)} { () => { try { if (clientSet) { - refreshClient() + void refreshClient() } } catch (err) { console.error(err) diff --git a/server/mongo/src/storage.ts b/server/mongo/src/storage.ts index be831712a8..23e89d86fa 100644 --- a/server/mongo/src/storage.ts +++ b/server/mongo/src/storage.ts @@ -38,6 +38,8 @@ import core, { ReverseLookups, SortingOrder, SortingQuery, + SortingRules, + SortQuerySelector, StorageIterator, toFindResult, Tx, @@ -342,31 +344,26 @@ abstract class MongoAdapterBase implements DbAdapter { const sort = {} as any for (const _key in options.sort) { const key = this.translateKey(_key, clazz) - const enumOf = await this.isEnumSortKey(clazz, _key) - if (enumOf === undefined) { - sort[key] = options.sort[_key] === SortingOrder.Ascending ? 1 : -1 + + if (typeof options.sort[_key] === 'object') { + const rules = options.sort[_key] as SortingRules + fillCustomSort(rules, key, pipeline, sort, options, _key) } else { - const branches = enumOf.enumValues.map((value, index) => { - return { case: { $eq: [`$${key}`, value] }, then: index } - }) - pipeline.push({ - $addFields: { - [`sort_${key}`]: { - $switch: { - branches, - default: enumOf.enumValues.length - } - } - } - }) - sort[`sort_${key}`] = options.sort[_key] === SortingOrder.Ascending ? 1 : -1 + // Sort enum if no special sorting is defined. + const enumOf = this.getEnumById(clazz, _key) + if (enumOf !== undefined) { + fillEnumSort(enumOf, key, pipeline, sort, options, _key) + } else { + // Ordinary sort field. + sort[key] = options.sort[_key] === SortingOrder.Ascending ? 1 : -1 + } } } pipeline.push({ $sort: sort }) } } - private async lookup( + private async findWithPipeline( clazz: Ref>, query: DocumentQuery, options: FindOptions @@ -415,7 +412,6 @@ abstract class MongoAdapterBase implements DbAdapter { const result = res.results as WithLookup[] const total = res.totalCount?.shift()?.count for (const row of result) { - row.$lookup = {} await this.fillLookupValue(clazz, options.lookup, row) this.clearExtraLookups(row) } @@ -470,26 +466,27 @@ abstract class MongoAdapterBase implements DbAdapter { return key } - private async isEnumSortKey(_class: Ref>, key: string): Promise { + private getEnumById(_class: Ref>, key: string): Enum | undefined { const attr = this.hierarchy.findAttribute(_class, key) if (attr !== undefined) { if (attr.type._class === core.class.EnumOf) { const ref = (attr.type as EnumOf).of - const res = await this.modelDb.findAll(core.class.Enum, { _id: ref }) - return res[0] + return this.modelDb.getObject(ref) } } + return undefined } private isEnumSort(_class: Ref>, options?: FindOptions): boolean { if (options?.sort === undefined) return false - for (const key in options.sort) { - const attr = this.hierarchy.findAttribute(_class, key) - if (attr !== undefined) { - if (attr.type._class === core.class.EnumOf) { - return true - } - } + return Object.keys(options.sort).some( + (key) => this.hierarchy.findAttribute(_class, key)?.type?._class === core.class.EnumOf + ) + } + + private isRulesSort(options?: FindOptions): boolean { + if (options?.sort !== undefined) { + return Object.values(options.sort).some((it) => typeof it === 'object') } return false } @@ -500,10 +497,8 @@ abstract class MongoAdapterBase implements DbAdapter { options?: FindOptions ): Promise> { // TODO: rework this - if (options !== null && options !== undefined) { - if (options.lookup !== undefined || this.isEnumSort(_class, options)) { - return await this.lookup(_class, query, options) - } + if (options != null && (options?.lookup != null || this.isEnumSort(_class, options) || this.isRulesSort(options))) { + return await this.findWithPipeline(_class, query, options) } const domain = this.hierarchy.getDomain(_class) const coll = this.db.collection(domain) @@ -995,6 +990,71 @@ class MongoTxAdapter extends MongoAdapterBase implements TxAdapter { } } +function fillEnumSort ( + enumOf: Enum, + key: string, + pipeline: any[], + sort: any, + options: FindOptions, + _key: string +): void { + const branches = enumOf.enumValues.map((value, index) => { + return { case: { $eq: [`$${key}`, value] }, then: index } + }) + pipeline.push({ + $addFields: { + [`sort_${key}`]: { + $switch: { + branches, + default: enumOf.enumValues.length + } + } + } + }) + if (options.sort === undefined) { + options.sort = {} + } + sort[`sort_${key}`] = options.sort[_key] === SortingOrder.Ascending ? 1 : -1 +} +function fillCustomSort ( + rules: SortingRules, + key: string, + pipeline: any[], + sort: any, + options: FindOptions, + _key: string +): void { + const branches = rules.cases.map((selector) => { + if (typeof selector.query === 'object') { + const q = selector.query as SortQuerySelector + if (q.$in !== undefined) { + return { case: { $in: { [key]: q.$in } }, then: selector.index } + } + if (q.$nin !== undefined) { + return { case: { $nin: { [key]: q.$in } }, then: selector.index } + } + if (q.$ne !== undefined) { + return { case: { $ne: [`$${key}`, q.$ne] }, then: selector.index } + } + } + return { case: { $eq: [`$${key}`, selector.query] }, then: selector.index } + }) + pipeline.push({ + $addFields: { + [`sort_${key}`]: { + $switch: { + branches, + default: rules.default ?? branches.length + } + } + } + }) + if (options.sort === undefined) { + options.sort = {} + } + sort[`sort_${key}`] = rules.order === SortingOrder.Ascending ? 1 : -1 +} + function translateLikeQuery (pattern: string): { $regex: string, $options: string } { return { $regex: `^${pattern diff --git a/tests/sanity/tests/contacts.spec.ts b/tests/sanity/tests/contacts.spec.ts index 963cecd1d4..de5b84c34e 100644 --- a/tests/sanity/tests/contacts.spec.ts +++ b/tests/sanity/tests/contacts.spec.ts @@ -55,6 +55,8 @@ test.describe('contact tests', () => { await expect(page.locator('text=M. Marina')).toBeVisible() expect(await page.locator('.antiTable-body__row').count()).toBeGreaterThan(5) + await page.waitForTimeout(1000) + const searchBox = page.locator('[placeholder="Search"]') await searchBox.fill('Marina') await searchBox.press('Enter') diff --git a/tests/sanity/tests/playwright.config.ts b/tests/sanity/tests/playwright.config.ts index b84b743aff..26ccd0585b 100644 --- a/tests/sanity/tests/playwright.config.ts +++ b/tests/sanity/tests/playwright.config.ts @@ -14,6 +14,9 @@ const config: PlaywrightTestConfig = { }, retries: 1, timeout: 60000, - maxFailures: 5 + maxFailures: 5, + expect: { + timeout: 15000 + } } export default config diff --git a/tests/sanity/tests/recruit.spec.ts b/tests/sanity/tests/recruit.spec.ts index 373a906fec..87d69b4ceb 100644 --- a/tests/sanity/tests/recruit.spec.ts +++ b/tests/sanity/tests/recruit.spec.ts @@ -85,6 +85,7 @@ test.describe('recruit tests', () => { await page.click('[id = "space.selector"]') + await page.waitForTimeout(1000) await page.fill('[placeholder="Search..."]', vacancyId) await page.click(`button:has-text("${vacancyId}")`) diff --git a/tests/sanity/tests/tracker.layout.spec.ts b/tests/sanity/tests/tracker.layout.spec.ts index 15b83a7b28..f1c847030c 100644 --- a/tests/sanity/tests/tracker.layout.spec.ts +++ b/tests/sanity/tests/tracker.layout.spec.ts @@ -20,14 +20,19 @@ test.use({ const getIssueName = (postfix: string = generateId(5)): string => `issue-${postfix}` -async function createIssues (page: Page, components?: string[], sprints?: string[]): Promise { +async function createIssues ( + prefix: string, + page: Page, + components?: string[], + sprints?: string[] +): Promise { const issuesProps = [] for (let index = 0; index < 5; index++) { const shiftedIndex = 4 - index const name = sprints !== undefined - ? getIssueName(`layout-${shiftedIndex}-${sprints[index % sprints.length]}`) - : getIssueName(`layout-${shiftedIndex}`) + ? getIssueName(`${prefix}-layout-${shiftedIndex}-${sprints[index % sprints.length]}`) + : getIssueName(`${prefix}-layout-${shiftedIndex}`) const issueProps = { name, status: DEFAULT_STATUSES[shiftedIndex], @@ -70,20 +75,21 @@ async function createSprints (page: Page): Promise { return sprints } -async function initIssues (page: Page): Promise { +async function initIssues (prefix: string, page: Page): Promise { const components = await createComponents(page) const sprints = await createSprints(page) - const issuesProps = await createIssues(page, components, sprints) + const issuesProps = await createIssues(prefix, page, components, sprints) await page.click('text="Issues"') return issuesProps } test.describe('tracker layout tests', () => { + const id = generateId(4) test.beforeEach(async ({ page }) => { test.setTimeout(60000) await navigate(page) - issuesProps = await initIssues(page) + issuesProps = await initIssues(id, page) }) let issuesProps: IssueProps[] = [] @@ -166,6 +172,11 @@ test.describe('tracker layout tests', () => { await page.click(ViewletSelectors.Board) await setViewGroup(page, 'No grouping') await setViewOrder(page, order) + + await page.waitForTimeout(1000) + const searchBox = page.locator('[placeholder="Search"]') + await searchBox.fill(id) + await searchBox.press('Enter') await expect(locator).toContainText(orderedIssueNames) }) } diff --git a/tests/sanity/tests/tracker.spec.ts b/tests/sanity/tests/tracker.spec.ts index 08e4bc0179..fe3752d11f 100644 --- a/tests/sanity/tests/tracker.spec.ts +++ b/tests/sanity/tests/tracker.spec.ts @@ -1,17 +1,17 @@ -import { test, expect } from '@playwright/test' +import { expect, test } from '@playwright/test' import { + DEFAULT_STATUSES, + DEFAULT_USER, + ViewletSelectors, checkIssue, createIssue, createLabel, createSubissue, - DEFAULT_STATUSES, - DEFAULT_USER, fillIssueForm, navigate, - openIssue, - ViewletSelectors + openIssue } from './tracker.utils' -import { generateId, PlatformSetting } from './utils' +import { PlatformSetting, generateId } from './utils' test.use({ storageState: PlatformSetting }) @@ -32,18 +32,16 @@ test('create-issue-and-sub-issue', async ({ page }) => { await createIssue(page, props) await page.click('text="Issues"') - // Click [placeholder="Search"] + await page.waitForTimeout(1000) await page.locator('[placeholder="Search"]').click() - // Fill [placeholder="Search"] await page.locator('[placeholder="Search"]').fill(props.name) - // Press Enter await page.locator('[placeholder="Search"]').press('Enter') await openIssue(page, props.name) await checkIssue(page, props) props.name = `sub${props.name}` await createSubissue(page, props) - await page.click(`span:has-text("${props.name}")`) + await page.click(`span[title=${props.name}]`) await checkIssue(page, props) }) @@ -169,11 +167,10 @@ test('report-time-from-main-view', async ({ page }) => { // await page.click('.close-button > .button') - // Click [placeholder="Search"] + // We need to fait for indexer to complete indexing. + await page.waitForTimeout(1000) await page.locator('[placeholder="Search"]').click() - // Fill [placeholder="Search"] await page.locator('[placeholder="Search"]').fill(name) - // Press Enter await page.locator('[placeholder="Search"]').press('Enter') await page.waitForSelector(`text="${name}"`) @@ -312,11 +309,9 @@ test('sub-issue-draft', async ({ page }) => { await createIssue(page, props) await page.click('text="Issues"') - // Click [placeholder="Search"] + await page.waitForTimeout(1000) await page.locator('[placeholder="Search"]').click() - // Fill [placeholder="Search"] await page.locator('[placeholder="Search"]').fill(props.name) - // Press Enter await page.locator('[placeholder="Search"]').press('Enter') await openIssue(page, props.name) From 2f50737177f3b2de6a7bc9f2a63cde4f53acf11f Mon Sep 17 00:00:00 2001 From: Andrey Sobolev Date: Tue, 4 Apr 2023 01:44:33 +0700 Subject: [PATCH 08/11] Fix concurrency and full text refresh event Signed-off-by: Andrey Sobolev --- packages/core/src/component.ts | 4 +- packages/core/src/tx.ts | 26 ++++- packages/presentation/src/utils.ts | 9 +- packages/query/src/index.ts | 97 ++++++++++++++----- plugins/client-resources/src/connection.ts | 9 +- .../src/components/list/ListCategories.svelte | 2 +- server/core/src/indexer/indexer.ts | 18 +++- server/core/src/storage.ts | 22 ++++- 8 files changed, 148 insertions(+), 39 deletions(-) diff --git a/packages/core/src/component.ts b/packages/core/src/component.ts index 0872e215c6..d9fd444239 100644 --- a/packages/core/src/component.ts +++ b/packages/core/src/component.ts @@ -55,7 +55,8 @@ import type { TxMixin, TxModelUpgrade, TxRemoveDoc, - TxUpdateDoc + TxUpdateDoc, + TxWorkspaceEvent } from './tx' /** @@ -79,6 +80,7 @@ export default plugin(coreId, { Attribute: '' as Ref>, Tx: '' as Ref>, TxModelUpgrade: '' as Ref>, + TxWorkspaceEvent: '' as Ref>, TxApplyIf: '' as Ref>, TxCUD: '' as Ref>>, TxCreateDoc: '' as Ref>>, diff --git a/packages/core/src/tx.ts b/packages/core/src/tx.ts index 86d8e25fab..e7fa57f180 100644 --- a/packages/core/src/tx.ts +++ b/packages/core/src/tx.ts @@ -13,6 +13,7 @@ // limitations under the License. // +import justClone from 'just-clone' import type { KeysByType } from 'simplytyped' import type { Account, @@ -34,7 +35,6 @@ import { _getOperator } from './operator' import { _toDoc } from './proxy' import type { DocumentQuery, TxResult } from './storage' import { generateId } from './utils' -import justClone from 'just-clone' /** * @public @@ -43,10 +43,34 @@ export interface Tx extends Doc { objectSpace: Ref // space where transaction will operate } +/** + * @public + */ +export enum WorkspaceEvent { + UpgradeScheduled, + Upgrade, + IndexingUpdate +} + /** * Event to be send by server during model upgrade procedure. * @public */ +export interface TxWorkspaceEvent extends Tx { + event: WorkspaceEvent + params: any +} + +/** + * @public + */ +export interface IndexingUpdateEvent { + _class: Ref>[] +} + +/** + * @public + */ export interface TxModelUpgrade extends Tx {} /** diff --git a/packages/presentation/src/utils.ts b/packages/presentation/src/utils.ts index bbbe2b0a16..dee4001bf8 100644 --- a/packages/presentation/src/utils.ts +++ b/packages/presentation/src/utils.ts @@ -186,16 +186,17 @@ export class LiveQuery { callback: (result: FindResult) => void, options: FindOptions | undefined ): Promise { + const piplineQuery = await pipeline.subscribe(_class, query, options, () => { + // Refresh query if pipeline decide it is required. + this.refreshClient() + }) + this.unsubscribe() this.oldCallback = callback this.oldClass = _class this.oldOptions = options this.oldQuery = query - const piplineQuery = await pipeline.subscribe(_class, query, options, () => { - // Refresh query if pipeline decide it is required. - this.refreshClient() - }) const unsub = liveQuery.query(_class, piplineQuery.query ?? query, callback, piplineQuery.options ?? options) this.unsubscribe = () => { unsub() diff --git a/packages/query/src/index.ts b/packages/query/src/index.ts index 9afe9c002b..ddfe68ab0e 100644 --- a/packages/query/src/index.ts +++ b/packages/query/src/index.ts @@ -24,8 +24,10 @@ import core, { FindOptions, findProperty, FindResult, + generateId, getObjectValue, Hierarchy, + IndexingUpdateEvent, Lookup, LookupData, matchQuery, @@ -43,7 +45,9 @@ import core, { TxRemoveDoc, TxResult, TxUpdateDoc, - WithLookup + TxWorkspaceEvent, + WithLookup, + WorkspaceEvent } from '@hcengineering/core' import { deepEqual } from 'fast-equals' @@ -57,7 +61,7 @@ interface Query { result: Doc[] | Promise options?: FindOptions total: number - callbacks: Callback[] + callbacks: Map } /** @@ -138,12 +142,10 @@ export class LiveQuery extends TxProcessor implements Client { options?: FindOptions ): Query { const callback: () => void = () => {} - const q = this.createQuery(_class, query, callback, options) - const index = q.callbacks.indexOf(callback as (result: Doc[]) => void) - if (index !== -1) { - q.callbacks.splice(index, 1) - } - if (q.callbacks.length === 0) { + const callbackId = generateId() + const q = this.createQuery(_class, query, { callback, callbackId }, options) + q.callbacks.delete(callbackId) + if (q.callbacks.size === 0) { this.queue.push(q) } return q @@ -213,7 +215,7 @@ export class LiveQuery extends TxProcessor implements Client { } private removeFromQueue (q: Query): boolean { - if (q.callbacks.length === 0) { + if (q.callbacks.size === 0) { const queueIndex = this.queue.indexOf(q) if (queueIndex !== -1) { this.queue.splice(queueIndex, 1) @@ -223,8 +225,14 @@ export class LiveQuery extends TxProcessor implements Client { return false } - private pushCallback (q: Query, callback: (result: Doc[]) => void): void { - q.callbacks.push(callback) + private pushCallback ( + q: Query, + callback: { + callback: (result: Doc[]) => void + callbackId: string + } + ): void { + q.callbacks.set(callback.callbackId, callback.callback) setTimeout(async () => { if (q !== undefined) { await this.callback(q) @@ -235,7 +243,10 @@ export class LiveQuery extends TxProcessor implements Client { private getQuery( _class: Ref>, query: DocumentQuery, - callback: (result: Doc[]) => void, + callback: { + callback: (result: Doc[]) => void + callbackId: string + }, options?: FindOptions ): Query | undefined { const current = this.findQuery(_class, query, options) @@ -250,7 +261,7 @@ export class LiveQuery extends TxProcessor implements Client { private createQuery( _class: Ref>, query: DocumentQuery, - callback: (result: FindResult) => void, + callback: { callback: (result: FindResult) => void, callbackId: string }, options?: FindOptions ): Query { const queries = this.queries.get(_class) ?? [] @@ -261,8 +272,9 @@ export class LiveQuery extends TxProcessor implements Client { result, total: 0, options: options as FindOptions, - callbacks: [callback as (result: Doc[]) => void] + callbacks: new Map() } + q.callbacks.set(callback.callbackId, callback.callback as unknown as Callback) queries.push(q) result .then(async (result) => { @@ -303,16 +315,14 @@ export class LiveQuery extends TxProcessor implements Client { modifiedOn: 1 } } + const callbackId = generateId() const q = - this.getQuery(_class, query, callback as (result: Doc[]) => void, options) ?? - this.createQuery(_class, query, callback, options) + this.getQuery(_class, query, { callback: callback as (result: Doc[]) => void, callbackId }, options) ?? + this.createQuery(_class, query, { callback, callbackId }, options) return () => { - const index = q.callbacks.indexOf(callback as (result: Doc[]) => void) - if (index !== -1) { - q.callbacks.splice(index, 1) - } - if (q.callbacks.length === 0) { + q.callbacks.delete(callbackId) + if (q.callbacks.size === 0) { this.queue.push(q) } } @@ -329,12 +339,16 @@ export class LiveQuery extends TxProcessor implements Client { return true } else { const pos = q.result.findIndex((p) => p._id === _id) - q.result.splice(pos, 1) - q.total-- + if (pos !== -1) { + q.result.splice(pos, 1) + q.total-- + } } } else { const pos = q.result.findIndex((p) => p._id === _id) - q.result[pos] = match + if (pos !== -1) { + q.result[pos] = match + } } return false } @@ -772,7 +786,7 @@ export class LiveQuery extends TxProcessor implements Client { q.result = await q.result } const result = q.result - q.callbacks.forEach((callback) => { + Array.from(q.callbacks.values()).forEach((callback) => { callback(toFindResult(this.clone(result), q.total)) }) } @@ -938,9 +952,42 @@ export class LiveQuery extends TxProcessor implements Client { } async tx (tx: Tx): Promise { + if (tx._class === core.class.TxWorkspaceEvent) { + await this.checkUpdateFulltextQueries(tx) + return {} + } return await super.tx(tx) } + private async checkUpdateFulltextQueries (tx: Tx): Promise { + const evt = tx as TxWorkspaceEvent + if (evt.event === WorkspaceEvent.IndexingUpdate) { + const indexingParam = evt.params as IndexingUpdateEvent + for (const q of [...this.queue]) { + if (indexingParam._class.includes(q._class) && q.query.$search !== undefined) { + if (!(await this.removeFromQueue(q))) { + try { + await this.refresh(q) + } catch (err) { + console.error(err) + } + } + } + } + for (const v of this.queries.values()) { + for (const q of v) { + if (indexingParam._class.includes(q._class) && q.query.$search !== undefined) { + try { + await this.refresh(q) + } catch (err) { + console.error(err) + } + } + } + } + } + } + private async __updateLookup (q: Query, updatedDoc: WithLookup, ops: any): Promise { for (const key in ops) { if (!key.startsWith('$')) { diff --git a/plugins/client-resources/src/connection.ts b/plugins/client-resources/src/connection.ts index ddd11fa0d2..f665bb16de 100644 --- a/plugins/client-resources/src/connection.ts +++ b/plugins/client-resources/src/connection.ts @@ -29,7 +29,9 @@ import core, { Tx, TxApplyIf, TxHandler, - TxResult + TxResult, + TxWorkspaceEvent, + WorkspaceEvent } from '@hcengineering/core' import { getMetadata, @@ -167,7 +169,10 @@ class Connection implements ClientConnection { } } else { const tx = resp.result as Tx - if (tx?._class === core.class.TxModelUpgrade) { + if ( + (tx?._class === core.class.TxWorkspaceEvent && (tx as TxWorkspaceEvent).event === WorkspaceEvent.Upgrade) || + tx?._class === core.class.TxModelUpgrade + ) { console.log('Processing upgrade') websocket.send( serialize({ diff --git a/plugins/view-resources/src/components/list/ListCategories.svelte b/plugins/view-resources/src/components/list/ListCategories.svelte index 6c9125bf46..f0a3576a2d 100644 --- a/plugins/view-resources/src/components/list/ListCategories.svelte +++ b/plugins/view-resources/src/components/list/ListCategories.svelte @@ -122,7 +122,7 @@ const dispatch = createEventDispatcher() -{#each categories as category, i (category)} +{#each categories as category, i (typeof category === 'object' ? category.name : category)} {@const items = groupByKey === noCategory || category === undefined ? docs : getGroupByValues(groupByDocs, category)} >[]) => void ) { this.readyStages = stages.map((it) => it.stageId) this.readyStages.sort() @@ -261,18 +262,24 @@ export class FullTextIndexPipeline implements FullTextPipeline { return } await this.initStates() + const classes = new Set>>() while (!this.cancelling) { await this.initializeStages() await this.processRemove() console.log('Indexing:', this.indexId, this.workspace) - await rateLimitter.exec(() => this.processIndex()) + const _classes = await rateLimitter.exec(() => this.processIndex()) + _classes.forEach((it) => classes.add(it)) if (this.toIndex.size === 0 || this.stageChanged === 0) { if (this.toIndex.size === 0) { console.log(`${this.workspace.name} Indexing complete`, this.indexId) } if (!this.cancelling) { + // We need to send index update event + this.broadcastUpdate(Array.from(classes.values())) + classes.clear() + await new Promise((resolve) => { this.triggerIndexing = () => { resolve(null) @@ -291,14 +298,15 @@ export class FullTextIndexPipeline implements FullTextPipeline { console.log('Exit indexer', this.indexId, this.workspace) } - private async processIndex (): Promise { + private async processIndex (): Promise>[]> { let idx = 0 + const _classUpdate = new Set>>() for (const st of this.stages) { idx++ while (true) { try { if (this.cancelling) { - return + return Array.from(_classUpdate.values()) } if (!st.enabled) { break @@ -347,6 +355,7 @@ export class FullTextIndexPipeline implements FullTextPipeline { // Do Indexing this.currentStage = st await st.collect(toIndex, this) + toIndex.forEach((it) => _classUpdate.add(it.objectClass)) // go with next stages if they accept it @@ -374,6 +383,7 @@ export class FullTextIndexPipeline implements FullTextPipeline { } } } + return Array.from(_classUpdate.values()) } private async processRemove (): Promise { diff --git a/server/core/src/storage.ts b/server/core/src/storage.ts index 6eacd6770a..105a92ee31 100644 --- a/server/core/src/storage.ts +++ b/server/core/src/storage.ts @@ -29,7 +29,9 @@ import core, { DOMAIN_TX, FindOptions, FindResult, + generateId, Hierarchy, + IndexingUpdateEvent, MeasureContext, Mixin, ModelDb, @@ -45,6 +47,8 @@ import core, { TxRemoveDoc, TxResult, TxUpdateDoc, + TxWorkspaceEvent, + WorkspaceEvent, WorkspaceId } from '@hcengineering/core' import { MinioService } from '@hcengineering/minio' @@ -818,7 +822,23 @@ export async function createServerStorage ( hierarchy, conf.workspace, metrics.newChild('fulltext', {}), - modelDb + modelDb, + (classes: Ref>[]) => { + const evt: IndexingUpdateEvent = { + _class: classes + } + const tx: TxWorkspaceEvent = { + _class: core.class.TxWorkspaceEvent, + _id: generateId(), + event: WorkspaceEvent.IndexingUpdate, + modifiedBy: core.account.System, + modifiedOn: Date.now(), + objectSpace: core.space.DerivedTx, + space: core.space.DerivedTx, + params: evt + } + options.broadcast?.([tx]) + } ) return new FullTextIndex( hierarchy, From d0fadd384a8e3cf4bf35cf0314b41c85a5a9ad67 Mon Sep 17 00:00:00 2001 From: Andrey Sobolev Date: Tue, 4 Apr 2023 10:18:46 +0700 Subject: [PATCH 09/11] Add workspace event send threshold and fix live query update Signed-off-by: Andrey Sobolev --- packages/presentation/src/utils.ts | 7 +++++++ server/core/src/indexer/indexer.ts | 13 +++++++++---- server/core/src/storage.ts | 1 + 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/presentation/src/utils.ts b/packages/presentation/src/utils.ts index dee4001bf8..1db4a12837 100644 --- a/packages/presentation/src/utils.ts +++ b/packages/presentation/src/utils.ts @@ -152,6 +152,7 @@ export class LiveQuery { private oldQuery: DocumentQuery | undefined private oldOptions: FindOptions | undefined private oldCallback: ((result: FindResult) => void) | undefined + private reqId = 0 unsubscribe = () => {} clientRecreated = false @@ -186,10 +187,16 @@ export class LiveQuery { callback: (result: FindResult) => void, options: FindOptions | undefined ): Promise { + const id = ++this.reqId const piplineQuery = await pipeline.subscribe(_class, query, options, () => { // Refresh query if pipeline decide it is required. this.refreshClient() }) + if (id !== this.reqId) { + // If we have one more request after this one, no need to do something. + piplineQuery.unsubscribe() + return + } this.unsubscribe() this.oldCallback = callback diff --git a/server/core/src/indexer/indexer.ts b/server/core/src/indexer/indexer.ts index 608e129cc3..c906be2487 100644 --- a/server/core/src/indexer/indexer.ts +++ b/server/core/src/indexer/indexer.ts @@ -252,6 +252,9 @@ export class FullTextIndexPipeline implements FullTextPipeline { } } + broadcastClasses = new Set>>() + updateBroadcast: any = undefined + async doIndexing (): Promise { // Check model is upgraded to support indexer. @@ -262,14 +265,13 @@ export class FullTextIndexPipeline implements FullTextPipeline { return } await this.initStates() - const classes = new Set>>() while (!this.cancelling) { await this.initializeStages() await this.processRemove() console.log('Indexing:', this.indexId, this.workspace) const _classes = await rateLimitter.exec(() => this.processIndex()) - _classes.forEach((it) => classes.add(it)) + _classes.forEach((it) => this.broadcastClasses.add(it)) if (this.toIndex.size === 0 || this.stageChanged === 0) { if (this.toIndex.size === 0) { @@ -277,8 +279,11 @@ export class FullTextIndexPipeline implements FullTextPipeline { } if (!this.cancelling) { // We need to send index update event - this.broadcastUpdate(Array.from(classes.values())) - classes.clear() + clearTimeout(this.updateBroadcast) + this.updateBroadcast = setTimeout(() => { + this.broadcastUpdate(Array.from(this.broadcastClasses.values())) + this.broadcastClasses.clear() + }, 5000) await new Promise((resolve) => { this.triggerIndexing = () => { diff --git a/server/core/src/storage.ts b/server/core/src/storage.ts index 105a92ee31..6ce9c79029 100644 --- a/server/core/src/storage.ts +++ b/server/core/src/storage.ts @@ -816,6 +816,7 @@ export async function createServerStorage ( throw new Error('No storage adapter') } const stages = conf.fulltextAdapter.stages(fulltextAdapter, storage, storageAdapter, contentAdapter) + const indexer = new FullTextIndexPipeline( defaultAdapter, stages, From edc8cbf09574666b5023cb80020ba140d15b5e18 Mon Sep 17 00:00:00 2001 From: Andrey Sobolev Date: Tue, 4 Apr 2023 11:20:45 +0700 Subject: [PATCH 10/11] Fix UI Tests Signed-off-by: Andrey Sobolev --- tests/sanity/tests/contacts.spec.ts | 2 -- tests/sanity/tests/recruit.spec.ts | 1 - tests/sanity/tests/tracker.layout.spec.ts | 6 ++++-- tests/sanity/tests/tracker.spec.ts | 5 +---- tests/sanity/tests/tracker.utils.ts | 4 +++- 5 files changed, 8 insertions(+), 10 deletions(-) diff --git a/tests/sanity/tests/contacts.spec.ts b/tests/sanity/tests/contacts.spec.ts index de5b84c34e..963cecd1d4 100644 --- a/tests/sanity/tests/contacts.spec.ts +++ b/tests/sanity/tests/contacts.spec.ts @@ -55,8 +55,6 @@ test.describe('contact tests', () => { await expect(page.locator('text=M. Marina')).toBeVisible() expect(await page.locator('.antiTable-body__row').count()).toBeGreaterThan(5) - await page.waitForTimeout(1000) - const searchBox = page.locator('[placeholder="Search"]') await searchBox.fill('Marina') await searchBox.press('Enter') diff --git a/tests/sanity/tests/recruit.spec.ts b/tests/sanity/tests/recruit.spec.ts index 87d69b4ceb..373a906fec 100644 --- a/tests/sanity/tests/recruit.spec.ts +++ b/tests/sanity/tests/recruit.spec.ts @@ -85,7 +85,6 @@ test.describe('recruit tests', () => { await page.click('[id = "space.selector"]') - await page.waitForTimeout(1000) await page.fill('[placeholder="Search..."]', vacancyId) await page.click(`button:has-text("${vacancyId}")`) diff --git a/tests/sanity/tests/tracker.layout.spec.ts b/tests/sanity/tests/tracker.layout.spec.ts index f1c847030c..ef8f3d2abd 100644 --- a/tests/sanity/tests/tracker.layout.spec.ts +++ b/tests/sanity/tests/tracker.layout.spec.ts @@ -173,11 +173,13 @@ test.describe('tracker layout tests', () => { await setViewGroup(page, 'No grouping') await setViewOrder(page, order) - await page.waitForTimeout(1000) const searchBox = page.locator('[placeholder="Search"]') await searchBox.fill(id) await searchBox.press('Enter') - await expect(locator).toContainText(orderedIssueNames) + + await expect(locator).toContainText(orderedIssueNames, { + timeout: 15000 + }) }) } }) diff --git a/tests/sanity/tests/tracker.spec.ts b/tests/sanity/tests/tracker.spec.ts index fe3752d11f..63cea6e5a8 100644 --- a/tests/sanity/tests/tracker.spec.ts +++ b/tests/sanity/tests/tracker.spec.ts @@ -32,7 +32,6 @@ test('create-issue-and-sub-issue', async ({ page }) => { await createIssue(page, props) await page.click('text="Issues"') - await page.waitForTimeout(1000) await page.locator('[placeholder="Search"]').click() await page.locator('[placeholder="Search"]').fill(props.name) await page.locator('[placeholder="Search"]').press('Enter') @@ -168,12 +167,11 @@ test('report-time-from-main-view', async ({ page }) => { // await page.click('.close-button > .button') // We need to fait for indexer to complete indexing. - await page.waitForTimeout(1000) await page.locator('[placeholder="Search"]').click() await page.locator('[placeholder="Search"]').fill(name) await page.locator('[placeholder="Search"]').press('Enter') - await page.waitForSelector(`text="${name}"`) + await page.waitForSelector(`text="${name}"`, { timeout: 15000 }) let count = 0 for (let j = 0; j < 5; j++) { @@ -309,7 +307,6 @@ test('sub-issue-draft', async ({ page }) => { await createIssue(page, props) await page.click('text="Issues"') - await page.waitForTimeout(1000) await page.locator('[placeholder="Search"]').click() await page.locator('[placeholder="Search"]').fill(props.name) await page.locator('[placeholder="Search"]').press('Enter') diff --git a/tests/sanity/tests/tracker.utils.ts b/tests/sanity/tests/tracker.utils.ts index 6bfa7188d9..b3ede3c813 100644 --- a/tests/sanity/tests/tracker.utils.ts +++ b/tests/sanity/tests/tracker.utils.ts @@ -168,5 +168,7 @@ export async function checkIssueFromList (page: Page, issueName: string): Promis } export async function openIssue (page: Page, name: string): Promise { - await page.click(`.antiList__row:has-text("${name}") .issuePresenterRoot`) + await page.click(`.antiList__row:has-text("${name}") .issuePresenterRoot`, { + timeout: 15000 + }) } From ca517b7b5af6f31a5e443252a94f0588e5947364 Mon Sep 17 00:00:00 2001 From: Andrey Sobolev Date: Tue, 4 Apr 2023 12:18:14 +0700 Subject: [PATCH 11/11] Trim and ignore case Signed-off-by: Andrey Sobolev --- packages/presentation/src/status.ts | 7 +++++-- plugins/view-resources/src/utils.ts | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/presentation/src/status.ts b/packages/presentation/src/status.ts index 73e02cc582..d5bc20c981 100644 --- a/packages/presentation/src/status.ts +++ b/packages/presentation/src/status.ts @@ -227,7 +227,10 @@ export class StatusMiddleware extends BasePresentationMiddleware implements Pres const s = mgr.byId.get(sid) if (s !== undefined) { const statuses = mgr.statuses.filter( - (it) => it.ofAttribute === attr._id && it.name === s.name && it._id !== s._id + (it) => + it.ofAttribute === attr._id && + it.name.toLowerCase().trim() === s.name.toLowerCase().trim() && + it._id !== s._id ) if (statuses !== undefined) { target.push(...statuses.map((it) => it._id)) @@ -270,7 +273,7 @@ export class StatusMiddleware extends BasePresentationMiddleware implements Pres ret = (a.$lookup?.category?.order ?? 0) - (b.$lookup?.category?.order ?? 0) } if (ret === 0) { - if (a.name === b.name) { + if (a.name.toLowerCase().trim() === b.name.toLowerCase().trim()) { return 0 } ret = a.rank.localeCompare(b.rank) diff --git a/plugins/view-resources/src/utils.ts b/plugins/view-resources/src/utils.ts index 7d91fbde5b..924bbe9d02 100644 --- a/plugins/view-resources/src/utils.ts +++ b/plugins/view-resources/src/utils.ts @@ -614,18 +614,18 @@ export async function groupByStatusCategories ( for (const v of categories) { const status = mgr.byId.get(v) if (status !== undefined) { - let fst = statusMap.get(status.name) + let fst = statusMap.get(status.name.toLowerCase().trim()) if (fst === undefined) { const statuses = mgr.statuses .filter( (it) => it.ofAttribute === status.ofAttribute && - it.name === status.name && + it.name.toLowerCase().trim() === status.name.toLowerCase().trim() && (categories.includes(it._id) || it.space === status.space) ) .sort((a, b) => a.rank.localeCompare(b.rank)) fst = new StatusValue(status.name, status.color, statuses) - statusMap.set(status.name, fst) + statusMap.set(status.name.toLowerCase().trim(), fst) existingCategories.push(fst) } }