From e09bd25a8774880b8a1c7a7caeace37260627753 Mon Sep 17 00:00:00 2001 From: Andrey Sobolev Date: Tue, 24 Oct 2023 15:53:33 +0700 Subject: [PATCH] UBER-937: Extensibility changes (#3874) Signed-off-by: Andrey Sobolev --- README.md | 2 +- dev/docker-compose.yaml | 3 +- dev/tool/src/index.ts | 19 +- models/contact/src/migration.ts | 223 +++++++++--------- models/presentation/src/index.ts | 42 +++- models/recruit/src/migration.ts | 10 +- models/tracker/src/actions.ts | 23 +- models/tracker/src/index.ts | 2 - models/tracker/src/types.ts | 16 +- models/tracker/src/viewlets.ts | 3 +- packages/core/src/hierarchy.ts | 20 ++ packages/core/src/memdb.ts | 32 ++- packages/kanban/src/components/Kanban.svelte | 2 - .../kanban/src/components/KanbanRow.svelte | 51 +--- .../ComponentExtensions.svelte | 6 +- .../extensions/DocCreateExtComponent.svelte | 23 ++ .../src/components/extensions/manager.ts | 56 +++++ packages/presentation/src/index.ts | 5 +- packages/presentation/src/pipeline.ts | 53 ++++- packages/presentation/src/plugin.ts | 6 +- packages/presentation/src/rules.ts | 108 +++++++++ packages/presentation/src/types.ts | 105 ++++++++- packages/presentation/src/utils.ts | 4 +- packages/ui/src/components/Component.svelte | 2 + packages/ui/src/components/Loading.svelte | 4 +- packages/ui/src/components/SelectPopup.svelte | 32 +-- packages/ui/src/types.ts | 21 ++ plugins/contact-resources/src/index.ts | 2 + .../src/components/CreateIssue.svelte | 88 +++---- .../components/ComponentBrowser.svelte | 9 +- .../components/ComponentEditor.svelte | 148 +++++++++--- .../components/ComponentPresenter.svelte | 58 +++-- .../{ => components}/ComponentSelector.svelte | 101 ++++---- .../components/components/NewComponent.svelte | 12 +- .../components/issues/AssigneeEditor.svelte | 153 ++++++++---- .../components/issues/IssuePresenter.svelte | 63 +++-- .../milestones/MilestoneSelector.svelte | 4 +- .../templates/CreateIssueTemplate.svelte | 2 +- plugins/tracker-resources/src/index.ts | 2 +- plugins/tracker/src/index.ts | 32 --- plugins/view-resources/src/actionImpl.ts | 5 +- plugins/view-resources/src/actions.ts | 34 ++- .../src/components/HyperlinkEditor.svelte | 11 +- .../components/HyperlinkEditorPopup.svelte | 17 +- .../src/components/ObjectBox.svelte | 8 +- .../src/components/list/List.svelte | 1 + .../src/components/list/ListCategory.svelte | 25 +- .../src/components/list/ListHeader.svelte | 60 +++-- plugins/view-resources/src/utils.ts | 7 +- plugins/view/src/index.ts | 16 +- server/account/src/index.ts | 28 ++- server/backup/src/index.ts | 8 +- server/mongo/src/storage.ts | 4 +- server/tool/src/index.ts | 10 +- server/tool/src/upgrade.ts | 28 ++- tests/docker-compose.yaml | 3 +- 56 files changed, 1206 insertions(+), 606 deletions(-) rename packages/presentation/src/components/{ => extensions}/ComponentExtensions.svelte (79%) create mode 100644 packages/presentation/src/components/extensions/DocCreateExtComponent.svelte create mode 100644 packages/presentation/src/components/extensions/manager.ts create mode 100644 packages/presentation/src/rules.ts rename plugins/tracker-resources/src/components/{ => components}/ComponentSelector.svelte (57%) diff --git a/README.md b/README.md index 3eb480a175..7cabc17f24 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ It may also be necessary to upgrade the running database. ```bash cd ./dev/tool -rushx upgrade +rushx upgrade -f ``` In cases where the project fails to build for any logical reason, try the following steps: diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index b37a3acbd2..0b2d1afbca 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -124,7 +124,8 @@ services: - SERVER_SECRET=secret - ELASTIC_URL=http://elastic:9200 - MONGO_URL=mongodb://mongodb:27017 - - METRICS_CONSOLE=true + - METRICS_CONSOLE=false + - METRICS_FILE=metrics.txt - MINIO_ENDPOINT=minio - MINIO_ACCESS_KEY=minioadmin - MINIO_SECRET_KEY=minioadmin diff --git a/dev/tool/src/index.ts b/dev/tool/src/index.ts index 94690f9f0e..a68f627d29 100644 --- a/dev/tool/src/index.ts +++ b/dev/tool/src/index.ts @@ -50,7 +50,7 @@ import { diffWorkspace } from './workspace' import { Data, getWorkspaceId, RateLimitter, Tx, Version } from '@hcengineering/core' import { MinioService } from '@hcengineering/minio' -import { MigrateOperation } from '@hcengineering/model' +import { consoleModelLogger, MigrateOperation } from '@hcengineering/model' import { openAIConfigDefaults } from '@hcengineering/openai' import path from 'path' import { benchmark } from './benchmark' @@ -237,8 +237,13 @@ export function devTool ( .option('-p|--parallel ', 'Parallel upgrade', '0') .option('-l|--logs ', 'Default logs folder', './logs') .option('-r|--retry ', 'Number of apply retries', '0') + .option( + '-c|--console', + 'Display all information into console(default will create logs folder with {workspace}.log files', + false + ) .option('-f|--force [force]', 'Force update', false) - .action(async (cmd: { parallel: string, logs: string, retry: string, force: boolean }) => { + .action(async (cmd: { parallel: string, logs: string, retry: string, force: boolean, console: boolean }) => { const { mongodbUri, version, txes, migrateOperations } = prepareTools() return await withDatabase(mongodbUri, async (db) => { const workspaces = await listWorkspaces(db, productId) @@ -246,8 +251,10 @@ export function devTool ( async function _upgradeWorkspace (ws: WorkspaceInfoOnly): Promise { const t = Date.now() - const logger = new FileModelLogger(path.join(cmd.logs, `${ws.workspace}.log`)) - console.log('---UPGRADING----', ws.workspace, logger.file) + const logger = cmd.console + ? consoleModelLogger + : new FileModelLogger(path.join(cmd.logs, `${ws.workspace}.log`)) + console.log('---UPGRADING----', ws.workspace, !cmd.console ? (logger as FileModelLogger).file : '') try { await upgradeWorkspace(version, txes, migrateOperations, productId, db, ws.workspace, logger, cmd.force) console.log('---UPGRADING-DONE----', ws.workspace, Date.now() - t) @@ -256,7 +263,9 @@ export function devTool ( logger.log('error', JSON.stringify(err)) console.log('---UPGRADING-FAILED----', ws.workspace, Date.now() - t) } finally { - logger.close() + if (!cmd.console) { + ;(logger as FileModelLogger).close() + } } } if (cmd.parallel !== '0') { diff --git a/models/contact/src/migration.ts b/models/contact/src/migration.ts index 6c70844488..aa9e50e03f 100644 --- a/models/contact/src/migration.ts +++ b/models/contact/src/migration.ts @@ -1,11 +1,17 @@ // import { Class, DOMAIN_TX, Doc, Domain, Ref, TxOperations } from '@hcengineering/core' -import { MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@hcengineering/model' +import { + MigrateOperation, + MigrationClient, + MigrationUpgradeClient, + ModelLogger, + tryMigrate +} from '@hcengineering/model' import { DOMAIN_COMMENT } from '@hcengineering/model-chunter' import core from '@hcengineering/model-core' import { DOMAIN_VIEW } from '@hcengineering/model-view' -import contact, { DOMAIN_CONTACT } from './index' +import contact, { DOMAIN_CONTACT, contactId } from './index' async function createSpace (tx: TxOperations): Promise { const current = await tx.findOne(core.class.Space, { @@ -76,118 +82,125 @@ async function createEmployeeEmail (client: TxOperations): Promise { } export const contactOperation: MigrateOperation = { - async migrate (client: MigrationClient): Promise { - await client.update( - DOMAIN_TX, + async migrate (client: MigrationClient, logger: ModelLogger): Promise { + await tryMigrate(client, contactId, [ { - objectClass: 'contact:class:EmployeeAccount' - }, - { - $rename: { 'attributes.employee': 'attributes.person' }, - $set: { objectClass: contact.class.PersonAccount } - } - ) + state: 'employees', + func: async (client) => { + await client.update( + DOMAIN_TX, + { + objectClass: 'contact:class:EmployeeAccount' + }, + { + $rename: { 'attributes.employee': 'attributes.person' }, + $set: { objectClass: contact.class.PersonAccount } + } + ) - await client.update( - DOMAIN_TX, - { - objectClass: 'contact:class:Employee' - }, - { - $set: { objectClass: contact.mixin.Employee } - } - ) + await client.update( + DOMAIN_TX, + { + objectClass: 'contact:class:Employee' + }, + { + $set: { objectClass: contact.mixin.Employee } + } + ) - await client.update( - DOMAIN_TX, - { - 'tx.attributes.backlinkClass': 'contact:class:Employee' - }, - { - $set: { 'tx.attributes.backlinkClass': contact.mixin.Employee } - } - ) + await client.update( + DOMAIN_TX, + { + 'tx.attributes.backlinkClass': 'contact:class:Employee' + }, + { + $set: { 'tx.attributes.backlinkClass': contact.mixin.Employee } + } + ) - await client.update( - DOMAIN_TX, - { - 'tx.attributes.backlinkClass': 'contact:class:Employee' - }, - { - $set: { 'tx.attributes.backlinkClass': contact.mixin.Employee } - } - ) + await client.update( + DOMAIN_TX, + { + 'tx.attributes.backlinkClass': 'contact:class:Employee' + }, + { + $set: { 'tx.attributes.backlinkClass': contact.mixin.Employee } + } + ) - await client.update( - DOMAIN_TX, - { - objectClass: core.class.Attribute, - 'attributes.type.to': 'contact:class:Employee' - }, - { - $set: { 'attributes.type.to': contact.mixin.Employee } - } - ) - await client.update( - DOMAIN_TX, - { - objectClass: core.class.Attribute, - 'operations.type.to': 'contact:class:Employee' - }, - { - $set: { 'operations.type.to': contact.mixin.Employee } - } - ) + await client.update( + DOMAIN_TX, + { + objectClass: core.class.Attribute, + 'attributes.type.to': 'contact:class:Employee' + }, + { + $set: { 'attributes.type.to': contact.mixin.Employee } + } + ) + await client.update( + DOMAIN_TX, + { + objectClass: core.class.Attribute, + 'operations.type.to': 'contact:class:Employee' + }, + { + $set: { 'operations.type.to': contact.mixin.Employee } + } + ) - await client.update( - DOMAIN_TX, - { - 'attributes.extends': 'contact:class:Employee' - }, - { - $set: { 'attributes.extends': contact.mixin.Employee } - } - ) + await client.update( + DOMAIN_TX, + { + 'attributes.extends': 'contact:class:Employee' + }, + { + $set: { 'attributes.extends': contact.mixin.Employee } + } + ) - for (const d of client.hierarchy.domains()) { - await client.update( - d, - { attachedToClass: 'contact:class:Employee' }, - { $set: { attachedToClass: contact.mixin.Employee } } - ) - } - await client.update( - DOMAIN_COMMENT, - { backlinkClass: 'contact:class:Employee' }, - { $set: { backlinkClass: contact.mixin.Employee } } - ) - await client.update( - 'tags' as Domain, - { targetClass: 'contact:class:Employee' }, - { $set: { targetClass: contact.mixin.Employee } } - ) - await client.update( - DOMAIN_VIEW, - { filterClass: 'contact:class:Employee' }, - { $set: { filterClass: contact.mixin.Employee } } - ) - await client.update( - DOMAIN_CONTACT, - { - _class: 'contact:class:Employee' as Ref> - }, - { - $rename: { - active: `${contact.mixin.Employee as string}.active`, - statuses: `${contact.mixin.Employee as string}.statuses`, - displayName: `${contact.mixin.Employee as string}.displayName`, - position: `${contact.mixin.Employee as string}.position` - }, - $set: { - _class: contact.class.Person + for (const d of client.hierarchy.domains()) { + await client.update( + d, + { attachedToClass: 'contact:class:Employee' }, + { $set: { attachedToClass: contact.mixin.Employee } } + ) + } + await client.update( + DOMAIN_COMMENT, + { backlinkClass: 'contact:class:Employee' }, + { $set: { backlinkClass: contact.mixin.Employee } } + ) + await client.update( + 'tags' as Domain, + { targetClass: 'contact:class:Employee' }, + { $set: { targetClass: contact.mixin.Employee } } + ) + await client.update( + DOMAIN_VIEW, + { filterClass: 'contact:class:Employee' }, + { $set: { filterClass: contact.mixin.Employee } } + ) + await client.update( + DOMAIN_CONTACT, + { + _class: 'contact:class:Employee' as Ref> + }, + { + $rename: { + active: `${contact.mixin.Employee as string}.active`, + statuses: `${contact.mixin.Employee as string}.statuses`, + displayName: `${contact.mixin.Employee as string}.displayName`, + position: `${contact.mixin.Employee as string}.position` + }, + $set: { + _class: contact.class.Person + } + } + ) } } - ) + ]) }, async upgrade (client: MigrationUpgradeClient): Promise { const tx = new TxOperations(client, core.account.System) diff --git a/models/presentation/src/index.ts b/models/presentation/src/index.ts index a71371e1bf..31bc88b9fd 100644 --- a/models/presentation/src/index.ts +++ b/models/presentation/src/index.ts @@ -13,23 +13,28 @@ // limitations under the License. // -import { DOMAIN_MODEL } from '@hcengineering/core' -import { Builder, Model } from '@hcengineering/model' +import { Class, DOMAIN_MODEL, Doc, Ref } from '@hcengineering/core' +import { Builder, Model, Prop, TypeRef } from '@hcengineering/model' import core, { TDoc } from '@hcengineering/model-core' import { Asset, IntlString, Resource } from '@hcengineering/platform' // Import types to prevent .svelte components to being exposed to type typescript. +import { PresentationMiddlewareCreator, PresentationMiddlewareFactory } from '@hcengineering/presentation' import { ComponentPointExtension, + CreateExtensionKind, + DocAttributeRule, + DocRules, + DocCreateExtension, + DocCreateFunction, ObjectSearchCategory, ObjectSearchFactory } from '@hcengineering/presentation/src/types' -import presentation from './plugin' -import { PresentationMiddlewareCreator, PresentationMiddlewareFactory } from '@hcengineering/presentation' import { AnyComponent, ComponentExtensionId } from '@hcengineering/ui' +import presentation from './plugin' export { presentationId } from '@hcengineering/presentation/src/plugin' export { default } from './plugin' -export { ObjectSearchCategory, ObjectSearchFactory } +export { CreateExtensionKind, DocCreateExtension, DocCreateFunction, ObjectSearchCategory, ObjectSearchFactory } @Model(presentation.class.ObjectSearchCategory, core.class.Doc, DOMAIN_MODEL) export class TObjectSearchCategory extends TDoc implements ObjectSearchCategory { @@ -53,6 +58,29 @@ export class TComponentPointExtension extends TDoc implements ComponentPointExte order!: number } -export function createModel (builder: Builder): void { - builder.createModel(TObjectSearchCategory, TPresentationMiddlewareFactory, TComponentPointExtension) +@Model(presentation.class.DocCreateExtension, core.class.Doc, DOMAIN_MODEL) +export class TDocCreateExtension extends TDoc implements DocCreateExtension { + @Prop(TypeRef(core.class.Class), core.string.Class) + ofClass!: Ref> + + components!: Record + apply!: Resource +} + +@Model(presentation.class.DocRules, core.class.Doc, DOMAIN_MODEL) +export class TDocRules extends TDoc implements DocRules { + @Prop(TypeRef(core.class.Class), core.string.Class) + ofClass!: Ref> + + fieldRules!: DocAttributeRule[] +} + +export function createModel (builder: Builder): void { + builder.createModel( + TObjectSearchCategory, + TPresentationMiddlewareFactory, + TComponentPointExtension, + TDocCreateExtension, + TDocRules + ) } diff --git a/models/recruit/src/migration.ts b/models/recruit/src/migration.ts index 724a71bb45..4416644444 100644 --- a/models/recruit/src/migration.ts +++ b/models/recruit/src/migration.ts @@ -35,7 +35,15 @@ export const recruitOperation: MigrateOperation = { async upgrade (client: MigrationUpgradeClient): Promise { const tx = new TxOperations(client, core.account.System) await createDefaults(tx) - await fixTemplateSpace(tx) + + await tryUpgrade(client, recruitId, [ + { + state: 'fix-template-space', + func: async (client) => { + await fixTemplateSpace(tx) + } + } + ]) await tryUpgrade(client, recruitId, [ { diff --git a/models/tracker/src/actions.ts b/models/tracker/src/actions.ts index 73d62c9f38..fa9f27d0a6 100644 --- a/models/tracker/src/actions.ts +++ b/models/tracker/src/actions.ts @@ -419,13 +419,15 @@ export function createActions (builder: Builder, issuesId: string, componentsId: createAction( builder, { - action: view.actionImpl.ValueSelector, - actionPopup: view.component.ValueSelector, + action: view.actionImpl.AttributeSelector, + actionPopup: tracker.component.AssigneeEditor, actionProps: { attribute: 'assignee', - _class: contact.mixin.Employee, - query: {}, - placeholder: tracker.string.AssignTo + isAction: true, + valueKey: 'object' + // _class: contact.mixin.Employee, + // query: {}, + // placeholder: tracker.string.AssignTo }, label: tracker.string.Assignee, icon: contact.icon.Person, @@ -445,16 +447,11 @@ export function createActions (builder: Builder, issuesId: string, componentsId: createAction( builder, { - action: view.actionImpl.ValueSelector, - actionPopup: view.component.ValueSelector, + action: view.actionImpl.AttributeSelector, + actionPopup: tracker.component.ComponentEditor, actionProps: { attribute: 'component', - _class: tracker.class.Component, - query: {}, - fillQuery: { space: 'space' }, - docMatches: ['space'], - searchField: 'label', - placeholder: tracker.string.Component + isAction: true }, label: tracker.string.Component, icon: tracker.icon.Component, diff --git a/models/tracker/src/index.ts b/models/tracker/src/index.ts index f4d732656e..41b01eb4b8 100644 --- a/models/tracker/src/index.ts +++ b/models/tracker/src/index.ts @@ -36,7 +36,6 @@ import { TIssueTemplate, TMilestone, TProject, - TProjectIssueTargetOptions, TRelatedIssueTarget, TTimeSpendReport, TTypeEstimation, @@ -431,7 +430,6 @@ export function createModel (builder: Builder): void { TTypeMilestoneStatus, TTimeSpendReport, TTypeReportedTime, - TProjectIssueTargetOptions, TRelatedIssueTarget, TTypeEstimation, TTypeRemainingTime diff --git a/models/tracker/src/types.ts b/models/tracker/src/types.ts index d0626fb764..f2e6077fe7 100644 --- a/models/tracker/src/types.ts +++ b/models/tracker/src/types.ts @@ -30,7 +30,6 @@ import { Collection, Hidden, Index, - Mixin, Model, Prop, ReadOnly, @@ -43,9 +42,9 @@ import { } from '@hcengineering/model' import attachment from '@hcengineering/model-attachment' import chunter from '@hcengineering/model-chunter' -import core, { TAttachedDoc, TClass, TDoc, TStatus, TType } from '@hcengineering/model-core' +import core, { TAttachedDoc, TDoc, TStatus, TType } from '@hcengineering/model-core' import task, { TSpaceWithStates, TTask } from '@hcengineering/model-task' -import { IntlString, Resource } from '@hcengineering/platform' +import { IntlString } from '@hcengineering/platform' import tags, { TagElement } from '@hcengineering/tags' import { DoneState } from '@hcengineering/task' import { @@ -57,18 +56,15 @@ import { IssueStatus, IssueTemplate, IssueTemplateChild, - IssueUpdateFunction, Milestone, MilestoneStatus, Project, - ProjectIssueTargetOptions, RelatedClassRule, RelatedIssueTarget, RelatedSpaceRule, TimeReportDayType, TimeSpendReport } from '@hcengineering/tracker' -import { AnyComponent } from '@hcengineering/ui' import tracker from './plugin' export const DOMAIN_TRACKER = 'tracker' as Domain @@ -357,14 +353,6 @@ export class TComponent extends TDoc implements Component { declare space: Ref } -@Mixin(tracker.mixin.ProjectIssueTargetOptions, core.class.Class) -export class TProjectIssueTargetOptions extends TClass implements ProjectIssueTargetOptions { - headerComponent!: AnyComponent - bodyComponent!: AnyComponent - footerComponent!: AnyComponent - - update!: Resource -} /** * @public */ diff --git a/models/tracker/src/viewlets.ts b/models/tracker/src/viewlets.ts index 5e0393431f..a588ff8c4c 100644 --- a/models/tracker/src/viewlets.ts +++ b/models/tracker/src/viewlets.ts @@ -483,7 +483,8 @@ export function defineViewlets (builder: Builder): void { { key: '', presenter: tracker.component.ComponentPresenter, - props: { kind: 'list' } + props: { kind: 'list' }, + displayProps: { key: 'component', fixed: 'left' } }, { key: '', displayProps: { grow: true } }, { diff --git a/packages/core/src/hierarchy.ts b/packages/core/src/hierarchy.ts index cefa99203c..4063ddc7b9 100644 --- a/packages/core/src/hierarchy.ts +++ b/packages/core/src/hierarchy.ts @@ -110,6 +110,26 @@ export class Hierarchy { } } + findMixinMixins(doc: Doc, mixin: Ref>): M[] { + const _doc = _toDoc(doc) + const result: M[] = [] + const resultSet = new Set() + // Find all potential mixins of doc + for (const [k, v] of Object.entries(_doc)) { + if (typeof v === 'object' && this.classifiers.has(k as Ref)) { + const clazz = this.getClass(k as Ref) + if (this.hasMixin(clazz, mixin)) { + const cc = this.as(clazz, mixin) as any as M + if (cc !== undefined && !resultSet.has(cc._id)) { + result.push(cc) + resultSet.add(cc._id) + } + } + } + } + return result + } + isMixin (_class: Ref>): boolean { const data = this.classifiers.get(_class) return data !== undefined && this._isMixin(data) diff --git a/packages/core/src/memdb.ts b/packages/core/src/memdb.ts index 572c87361f..ea0188f935 100644 --- a/packages/core/src/memdb.ts +++ b/packages/core/src/memdb.ts @@ -14,11 +14,11 @@ // import { PlatformError, Severity, Status } from '@hcengineering/platform' -import { getObjectValue, Lookup, ReverseLookups } from '.' +import { Lookup, ReverseLookups, getObjectValue } from '.' import type { Class, Doc, Ref } from './classes' import core from './component' import { Hierarchy } from './hierarchy' -import { matchQuery, resultSort, checkMixinKey } from './query' +import { checkMixinKey, matchQuery, resultSort } from './query' import type { DocumentQuery, FindOptions, FindResult, LookupData, Storage, TxResult, WithLookup } from './storage' import type { Tx, TxCreateDoc, TxMixin, TxRemoveDoc, TxUpdateDoc } from './tx' import { TxProcessor } from './tx' @@ -175,6 +175,34 @@ export abstract class MemDb extends TxProcessor implements Storage { return toFindResult(res, total) } + /** + * Only in model find without lookups and sorting. + */ + findAllSync(_class: Ref>, query: DocumentQuery, options?: FindOptions): FindResult { + let result: WithLookup[] + const baseClass = this.hierarchy.getBaseClass(_class) + if ( + Object.prototype.hasOwnProperty.call(query, '_id') && + (typeof query._id === 'string' || query._id?.$in !== undefined || query._id === undefined || query._id === null) + ) { + result = this.getByIdQuery(query, baseClass) + } else { + result = this.getObjectsByClass(baseClass) + } + + result = matchQuery(result, query, _class, this.hierarchy, true) + + if (baseClass !== _class) { + // We need to filter instances without mixin was set + result = result.filter((r) => (r as any)[_class] !== undefined) + } + const total = result.length + result = result.slice(0, options?.limit) + const tresult = this.hierarchy.clone(result) as WithLookup[] + const res = tresult.map((it) => this.hierarchy.updateLookupMixin(_class, it, options)) + return toFindResult(res, total) + } + addDoc (doc: Doc): void { this.hierarchy.getAncestors(doc._class).forEach((_class) => { const arr = this.getObjectsByClass(_class) diff --git a/packages/kanban/src/components/Kanban.svelte b/packages/kanban/src/components/Kanban.svelte index 5855d6a2b1..a6e2d8453e 100644 --- a/packages/kanban/src/components/Kanban.svelte +++ b/packages/kanban/src/components/Kanban.svelte @@ -325,8 +325,6 @@ export let selection: number | undefined = undefined export let checkedSet: Set> export let state: CategoryType - export let index: number export let cardDragOver: (evt: CardDragEvent, object: Item) => void export let cardDrop: (evt: CardDragEvent, object: Item) => void @@ -56,23 +53,7 @@ let limit = 50 let limitedObjects: DocWithRank[] = [] - let loading = false - let loadingTimeout: any | undefined = undefined - - function update (stateObjects: Item[], limit: number | undefined, index: number): void { - clearTimeout(loadingTimeout) - if (limitedObjects.length > 0 || index === 0) { - limitedObjects = stateObjects.slice(0, limit) - } else { - loading = true - loadingTimeout = setTimeout(() => { - limitedObjects = stateObjects.slice(0, limit) - loading = false - }, index) - } - } - - $: update(stateObjects, limit, index) + $: limitedObjects = stateObjects.slice(0, limit) {#each limitedObjects as object, i (object._id)} @@ -110,21 +91,17 @@ {/each} {#if stateObjects.length > limitedObjects.length}
- {#if loading} - - {:else} -
- {limitedObjects.length} / {stateObjects.length} -
- {/if} +
+ {limitedObjects.length} / {stateObjects.length} +
{/if} @@ -133,11 +110,7 @@ background-color: var(--theme-kanban-card-bg-color); border: 1px solid var(--theme-kanban-card-border); border-radius: 0.25rem; - // transition: box-shadow .15s ease-in-out; - // &:hover { - // background-color: var(--highlight-hover); - // } &.checked { background-color: var(--highlight-select); box-shadow: 0 0 1px 1px var(--highlight-select-border); diff --git a/packages/presentation/src/components/ComponentExtensions.svelte b/packages/presentation/src/components/extensions/ComponentExtensions.svelte similarity index 79% rename from packages/presentation/src/components/ComponentExtensions.svelte rename to packages/presentation/src/components/extensions/ComponentExtensions.svelte index 90986ae5d5..b643a363f7 100644 --- a/packages/presentation/src/components/ComponentExtensions.svelte +++ b/packages/presentation/src/components/extensions/ComponentExtensions.svelte @@ -1,8 +1,8 @@ + +{#each filteredExtensions as extension} + {@const state = manager.getState(extension._id)} + {@const component = extension.components[kind]} + {#if component} + + {/if} +{/each} diff --git a/packages/presentation/src/components/extensions/manager.ts b/packages/presentation/src/components/extensions/manager.ts new file mode 100644 index 0000000000..134129b879 --- /dev/null +++ b/packages/presentation/src/components/extensions/manager.ts @@ -0,0 +1,56 @@ +import { Class, Doc, DocData, Ref, SortingOrder, Space, TxOperations } from '@hcengineering/core' +import { getResource } from '@hcengineering/platform' +import { onDestroy } from 'svelte' +import { Writable, writable } from 'svelte/store' +import { LiveQuery } from '../..' +import presentation from '../../plugin' +import { DocCreateExtension } from '../../types' +import { createQuery } from '../../utils' + +export class DocCreateExtensionManager { + query: LiveQuery + _extensions: DocCreateExtension[] = [] + extensions: Writable = writable([]) + states: Map, Writable> = new Map() + + static create (_class: Ref>): DocCreateExtensionManager { + const mgr = new DocCreateExtensionManager(_class) + onDestroy(() => { + mgr.close() + }) + return mgr + } + + getState (ref: Ref): Writable { + let state = this.states.get(ref) + if (state === undefined) { + state = writable({}) + this.states.set(ref, state) + } + return state + } + + private constructor (readonly _class: Ref>) { + this.query = createQuery() + this.query.query( + presentation.class.DocCreateExtension, + { ofClass: _class }, + (res) => { + this._extensions = res + this.extensions.set(res) + }, + { sort: { ofClass: SortingOrder.Ascending } } + ) + } + + async commit (ops: TxOperations, docId: Ref, space: Ref, data: DocData): Promise { + for (const e of this._extensions) { + const applyOp = await getResource(e.apply) + await applyOp?.(ops, docId, space, data, this.getState(e._id)) + } + } + + close (): void { + this.query.unsubscribe() + } +} diff --git a/packages/presentation/src/index.ts b/packages/presentation/src/index.ts index a0d68ffad1..e734f22b87 100644 --- a/packages/presentation/src/index.ts +++ b/packages/presentation/src/index.ts @@ -41,7 +41,8 @@ export { default as NavLink } from './components/NavLink.svelte' export { default as IconForward } from './components/icons/Forward.svelte' export { default as Breadcrumbs } from './components/breadcrumbs/Breadcrumbs.svelte' export { default as BreadcrumbsElement } from './components/breadcrumbs/BreadcrumbsElement.svelte' -export { default as ComponentExtensions } from './components/ComponentExtensions.svelte' +export { default as ComponentExtensions } from './components/extensions/ComponentExtensions.svelte' +export { default as DocCreateExtComponent } from './components/extensions/DocCreateExtComponent.svelte' export { default } from './plugin' export * from './types' export * from './utils' @@ -50,3 +51,5 @@ export { presentationId } export * from './configuration' export * from './context' export * from './pipeline' +export * from './components/extensions/manager' +export * from './rules' diff --git a/packages/presentation/src/pipeline.ts b/packages/presentation/src/pipeline.ts index 6b2e9e1018..d83e81a943 100644 --- a/packages/presentation/src/pipeline.ts +++ b/packages/presentation/src/pipeline.ts @@ -10,7 +10,8 @@ import { Ref, Tx, TxResult, - WithLookup + WithLookup, + toFindResult } from '@hcengineering/core' import { Resource } from '@hcengineering/platform' @@ -240,3 +241,53 @@ export abstract class BasePresentationMiddleware { export interface PresentationMiddlewareFactory extends Doc { createPresentationMiddleware: Resource } + +/** + * @public + */ +export class OptimizeQueryMiddleware extends BasePresentationMiddleware implements PresentationMiddleware { + private constructor (client: Client, next?: PresentationMiddleware) { + super(client, next) + } + + static create (client: Client, next?: PresentationMiddleware): OptimizeQueryMiddleware { + return new OptimizeQueryMiddleware(client, next) + } + + async notifyTx (tx: Tx): Promise { + await this.provideNotifyTx(tx) + } + + async close (): Promise { + return await this.provideClose() + } + + async tx (tx: Tx): Promise { + return await this.provideTx(tx) + } + + async subscribe( + _class: Ref>, + query: DocumentQuery, + options: FindOptions | undefined, + refresh: () => void + ): Promise<{ + unsubscribe: () => void + query?: DocumentQuery + options?: FindOptions + }> { + return await this.provideSubscribe(_class, query, options, refresh) + } + + async findAll( + _class: Ref>, + query: DocumentQuery, + options?: FindOptions | undefined + ): Promise> { + if (_class == null || typeof query !== 'object' || ('_class' in query && query._class == null)) { + console.error('_class must be specified in query', query) + return toFindResult([], 0) + } + return await this.provideFindAll(_class, query, options) + } +} diff --git a/packages/presentation/src/plugin.ts b/packages/presentation/src/plugin.ts index 83d34f1c10..7ab3db0a27 100644 --- a/packages/presentation/src/plugin.ts +++ b/packages/presentation/src/plugin.ts @@ -18,7 +18,7 @@ import { Class, Ref } from '@hcengineering/core' import type { Asset, IntlString, Metadata, Plugin } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform' import { PresentationMiddlewareFactory } from './pipeline' -import { ComponentPointExtension, ObjectSearchCategory } from './types' +import { ComponentPointExtension, DocRules, DocCreateExtension, ObjectSearchCategory } from './types' /** * @public @@ -29,7 +29,9 @@ export default plugin(presentationId, { class: { ObjectSearchCategory: '' as Ref>, PresentationMiddlewareFactory: '' as Ref>, - ComponentPointExtension: '' as Ref> + ComponentPointExtension: '' as Ref>, + DocCreateExtension: '' as Ref>, + DocRules: '' as Ref> }, string: { Create: '' as IntlString, diff --git a/packages/presentation/src/rules.ts b/packages/presentation/src/rules.ts new file mode 100644 index 0000000000..aec0fcc42f --- /dev/null +++ b/packages/presentation/src/rules.ts @@ -0,0 +1,108 @@ +import { Class, Doc, DocumentQuery, Ref, Space, matchQuery } from '@hcengineering/core' +import { getClient } from '.' +import presentation from './plugin' + +export interface RuleApplyResult { + fieldQuery: DocumentQuery + disableUnset: boolean + disableEdit: boolean +} + +export const emptyRuleApplyResult: RuleApplyResult = { + fieldQuery: {}, + disableUnset: false, + disableEdit: false +} +/** + * @public + */ +export function getDocRules (documents: Doc | Doc[], field: string): RuleApplyResult | undefined { + const docs = Array.isArray(documents) ? documents : [documents] + if (docs.length === 0) { + return emptyRuleApplyResult as RuleApplyResult + } + const c = getClient() + const h = c.getHierarchy() + + const _class = docs[0]._class + for (const d of docs) { + if (d._class !== _class) { + // If we have different classes, we should return undefined. + return undefined + } + } + + const rulesSet = c.getModel().findAllSync(presentation.class.DocRules, { ofClass: { $in: h.getAncestors(_class) } }) + let fieldQuery: DocumentQuery = {} + let disableUnset = false + let disableEdit = false + for (const rules of rulesSet) { + if (h.isDerived(_class, rules.ofClass)) { + // Check individual rules and form a result query + for (const r of rules.fieldRules) { + if (r.field === field) { + const _docs = docs.map((doc) => + r.mixin !== undefined && h.hasMixin(doc, r.mixin) ? h.as(doc, r.mixin) : doc + ) + if (matchQuery(_docs, r.query, r.mixin ?? rules.ofClass, h).length === _docs.length) { + // We have rule match. + if (r.disableUnset === true) { + disableUnset = true + } + if (r.disableEdit === true) { + disableEdit = true + } + if (r.fieldQuery != null) { + fieldQuery = { ...fieldQuery, ...r.fieldQuery } + } + + for (const [sourceK, targetK] of Object.entries(r.fieldQueryFill ?? {})) { + const v = (_docs[0] as any)[sourceK] + for (const d of _docs) { + const newV = (d as any)[sourceK] + if (newV !== v && r.allowConflict === false) { + // Value conflict, we could not choose one. + return undefined + } + ;(fieldQuery as any)[targetK] = newV + } + } + } + } + } + } + } + + return { + fieldQuery, + disableUnset, + disableEdit + } +} + +/** + * @public + */ +export function isCreateAllowed (_class: Ref>, space: Space): boolean { + const c = getClient() + const h = c.getHierarchy() + + const rules = c.getModel().findAllSync(presentation.class.DocRules, { ofClass: _class }) + for (const r of rules) { + if (r.createRule !== undefined) { + if (r.createRule.mixin !== undefined) { + if (h.hasMixin(space, r.createRule.mixin)) { + const _mixin = h.as(space, r.createRule.mixin) + if (matchQuery([_mixin], r.createRule.disallowQuery, r.createRule.mixin ?? space._class, h).length === 1) { + return false + } + } + } else { + if (matchQuery([space], r.createRule.disallowQuery, r.createRule.mixin ?? space._class, h).length === 1) { + return false + } + } + } + } + return true +} diff --git a/packages/presentation/src/types.ts b/packages/presentation/src/types.ts index 850051c180..bc1f608ea2 100644 --- a/packages/presentation/src/types.ts +++ b/packages/presentation/src/types.ts @@ -1,4 +1,15 @@ -import { Client, Doc, RelatedDocument } from '@hcengineering/core' +import { + Class, + Client, + Doc, + DocData, + DocumentQuery, + Mixin, + Ref, + RelatedDocument, + Space, + TxOperations +} from '@hcengineering/core' import { Asset, IntlString, Resource } from '@hcengineering/platform' import { AnyComponent, AnySvelteComponent, ComponentExtensionId } from '@hcengineering/ui' @@ -48,21 +59,93 @@ export interface ObjectSearchCategory extends Doc { query: Resource } +export interface ComponentExt { + component: AnyComponent + props?: Record + order?: number // Positioning of elements, into groups. +} + /** * @public * * An component extension to various places of platform. */ -export interface ComponentPointExtension extends Doc { +export interface ComponentPointExtension extends Doc, ComponentExt { // Extension point we should extend. extension: ComponentExtensionId - - // Component to be instantiated with at least following properties: - // size: 'tiny' | 'small' | 'medium' | 'large' - component: AnyComponent - - // Extra properties to be passed to the component - props?: Record - - order?: number // Positioning of elements, into groups. +} + +/** + * @public + */ +export type DocCreateFunction = ( + client: TxOperations, + id: Ref, + space: Ref, + document: DocData, + + extraData: Record +) => Promise + +/** + * @public + */ +export type CreateExtensionKind = 'header' | 'title' | 'body' | 'footer' | 'pool' | 'buttons' + +/** + * @public + * + * Customization for document creation + * + * Allow to customize create document/move issue dialogs, in case of selecting project of special kind. + */ +export interface DocCreateExtension extends Doc { + ofClass: Ref> + + components: Partial> + apply: Resource +} + +export interface DocAttributeRule { + // A field name + field: string + + // If document is matched, rule will be used. + query: DocumentQuery + + // If specified, will check for mixin to exists and cast to it + mixin?: Ref> + + // If specified, should be applied to field value queries, if field is reference to some document. + fieldQuery?: DocumentQuery + // If specified will fill document properties to fieldQuery + fieldQueryFill?: Record + + // If specified, should disable unset of field value. + disableUnset?: boolean + + // If specified should disable edit of this field value. + disableEdit?: boolean + + // In case of conflict values for multiple documents, will not be applied + // Or will continue processing + allowConflict?: boolean +} + +/** + * A configurable rule's for some type of document + */ +export interface DocRules extends Doc { + // Could be mixin, will be applied if mixin will be set for document. + ofClass: Ref> + + // attribute modification rules + fieldRules: DocAttributeRule[] + + // Check if document create is allowed for project based on query. + createRule?: { + // If query matched, document create is disallowed. + disallowQuery: DocumentQuery + mixin?: Ref> + } } diff --git a/packages/presentation/src/utils.ts b/packages/presentation/src/utils.ts index af40774c01..66f35a6ea9 100644 --- a/packages/presentation/src/utils.ts +++ b/packages/presentation/src/utils.ts @@ -43,7 +43,7 @@ import view, { AttributeEditor } from '@hcengineering/view' import { deepEqual } from 'fast-equals' import { onDestroy } from 'svelte' import { KeyedAttribute } from '..' -import { PresentationPipeline, PresentationPipelineImpl } from './pipeline' +import { OptimizeQueryMiddleware, PresentationPipeline, PresentationPipelineImpl } from './pipeline' import plugin from './plugin' let liveQuery: LQ @@ -115,7 +115,7 @@ export async function setClient (_client: Client): Promise { const factories = await _client.findAll(plugin.class.PresentationMiddlewareFactory, {}) const promises = factories.map(async (it) => await getResource(it.createPresentationMiddleware)) const creators = await Promise.all(promises) - pipeline = PresentationPipelineImpl.create(_client, creators) + pipeline = PresentationPipelineImpl.create(_client, [OptimizeQueryMiddleware.create, ...creators]) const needRefresh = liveQuery !== undefined liveQuery = new LQ(pipeline) diff --git a/packages/ui/src/components/Component.svelte b/packages/ui/src/components/Component.svelte index b90432d201..4904c90978 100644 --- a/packages/ui/src/components/Component.svelte +++ b/packages/ui/src/components/Component.svelte @@ -63,6 +63,7 @@ on:delete on:action on:valid + on:validate > @@ -79,6 +80,7 @@ on:delete on:action on:valid + on:validate /> {/if} diff --git a/packages/ui/src/components/Loading.svelte b/packages/ui/src/components/Loading.svelte index 50620b562d..c11325986e 100644 --- a/packages/ui/src/components/Loading.svelte +++ b/packages/ui/src/components/Loading.svelte @@ -16,9 +16,11 @@ @@ -629,15 +632,7 @@ docProps={{ disabled: true, noUnderline: true }} focusIndex={20000} /> - {#if targetSettings?.headerComponent && currentProject} - { - targetSettingOptions = evt.detail - }} - /> - {/if} +
@@ -660,6 +655,7 @@ }} /> {/if} +
@@ -720,15 +716,7 @@ bind:subIssues={object.subIssues} /> {/if} - {#if targetSettings?.bodyComponent && currentProject} - { - targetSettingOptions = evt.detail - }} - /> - {/if} +
@@ -839,15 +826,7 @@ on:click={object.parentIssue ? clearParentIssue : setParentIssue} />
- {#if targetSettings?.poolComponent && currentProject} - { - targetSettingOptions = evt.detail - }} - /> - {/if} + {#if attachments.size > 0} @@ -874,14 +853,9 @@ descriptionBox.handleAttach() }} /> - {#if targetSettings?.footerComponent && currentProject} - { - targetSettingOptions = evt.detail - }} - /> - {/if} + + + + diff --git a/plugins/tracker-resources/src/components/components/ComponentBrowser.svelte b/plugins/tracker-resources/src/components/components/ComponentBrowser.svelte index 79ef157a53..327af46216 100644 --- a/plugins/tracker-resources/src/components/components/ComponentBrowser.svelte +++ b/plugins/tracker-resources/src/components/components/ComponentBrowser.svelte @@ -27,9 +27,10 @@ } from '@hcengineering/view-resources' import { onDestroy } from 'svelte' import tracker from '../../plugin' - import { ComponentsFilterMode, componentsTitleMap } from '../../utils' + import { ComponentsFilterMode, activeProjects, componentsTitleMap } from '../../utils' import ComponentsContent from './ComponentsContent.svelte' import NewComponent from './NewComponent.svelte' + import { isCreateAllowed } from '@hcengineering/presentation' export let label: IntlString export let query: DocumentQuery = {} @@ -39,6 +40,8 @@ const space = typeof query.space === 'string' ? query.space : tracker.project.DefaultProject + $: project = $activeProjects.get(space) + let viewlet: WithLookup | undefined let viewlets: WithLookup[] | undefined let viewletKey = makeViewletKey() @@ -87,7 +90,9 @@
-
diff --git a/plugins/tracker-resources/src/components/components/ComponentEditor.svelte b/plugins/tracker-resources/src/components/components/ComponentEditor.svelte index 0ba3ac7fce..173f072d55 100644 --- a/plugins/tracker-resources/src/components/components/ComponentEditor.svelte +++ b/plugins/tracker-resources/src/components/components/ComponentEditor.svelte @@ -13,29 +13,30 @@ // limitations under the License. --> -{#if (value.component && value.component !== $activeComponent && groupBy !== 'component') || shouldShowPlaceholder} +{#if kind === 'list'} + {#if !Array.isArray(value) && value.component} +
+ +
+ {/if} +{:else}
- + {#if (!Array.isArray(value) && value.component && value.component !== $activeComponent && groupBy !== 'component') || shouldShowPlaceholder} +
+ +
+ {/if}
{/if} diff --git a/plugins/tracker-resources/src/components/components/ComponentPresenter.svelte b/plugins/tracker-resources/src/components/components/ComponentPresenter.svelte index 1cac03fa9a..a3d5206617 100644 --- a/plugins/tracker-resources/src/components/components/ComponentPresenter.svelte +++ b/plugins/tracker-resources/src/components/components/ComponentPresenter.svelte @@ -14,12 +14,13 @@ --> - - {#if inline} - @{label} - {:else} - - {#if shouldShowAvatar} -
- +
+ + {#if inline} + @{label} + {:else} + +
+ {#if shouldShowAvatar} +
+ +
+ {/if} + + {label} +
- {/if} - - {label} -
+ {/if} +
+ + {#if presenters.length > 0} +
+ {#each presenters as mixinPresenter} + { + if (evt.detail.icon !== undefined) { + icon = evt.detail.icon + } + }} + /> + {/each} +
{/if} - +
diff --git a/plugins/tracker-resources/src/components/ComponentSelector.svelte b/plugins/tracker-resources/src/components/components/ComponentSelector.svelte similarity index 57% rename from plugins/tracker-resources/src/components/ComponentSelector.svelte rename to plugins/tracker-resources/src/components/components/ComponentSelector.svelte index b8756f3377..f3fe42965f 100644 --- a/plugins/tracker-resources/src/components/ComponentSelector.svelte +++ b/plugins/tracker-resources/src/components/components/ComponentSelector.svelte @@ -13,17 +13,20 @@ // limitations under the License. --> -{#if onlyIcon || componentText === undefined} - diff --git a/plugins/tracker-resources/src/components/components/NewComponent.svelte b/plugins/tracker-resources/src/components/components/NewComponent.svelte index b26af8f4af..f78c8b9cf6 100644 --- a/plugins/tracker-resources/src/components/components/NewComponent.svelte +++ b/plugins/tracker-resources/src/components/components/NewComponent.svelte @@ -13,15 +13,15 @@ // limitations under the License. --> @@ -54,7 +56,7 @@ -{#if object} - handleAssigneeChanged(detail)} - /> +{#if _object} + {#if isAction} + { + const result = evt.detail + if (result === null) { + handleAssigneeChanged(null) + } else if (result !== undefined && result._id !== value) { + value = result._id + handleAssigneeChanged(result._id) + } + }} + /> + {:else} + handleAssigneeChanged(detail)} + /> + {/if} {/if} diff --git a/plugins/tracker-resources/src/components/issues/IssuePresenter.svelte b/plugins/tracker-resources/src/components/issues/IssuePresenter.svelte index 09a48abdfd..99ab01cac2 100644 --- a/plugins/tracker-resources/src/components/issues/IssuePresenter.svelte +++ b/plugins/tracker-resources/src/components/issues/IssuePresenter.svelte @@ -15,8 +15,10 @@ {#if value} - - {#if inline} - @{title} - {:else} - - {#if shouldShowAvatar} -
- -
- {/if} - - {title} - +
+ + {#if inline} + @{title} + {:else} + + {#if shouldShowAvatar} +
+ +
+ {/if} + + {title} + +
- + {/if} +
+ {#if presenters.length > 0} +
+ {#each presenters as mixinPresenter} + {mixinPresenter.presenter} + + {/each} +
{/if} - +
{/if}