From be5d41cecc1073b42e03311f79004ee51b9a7b76 Mon Sep 17 00:00:00 2001 From: Denis Bykhov Date: Wed, 3 Jun 2026 03:52:47 +0500 Subject: [PATCH] Slots (#10897) Signed-off-by: Denis Bykhov --- .vscode/settings.json | 19 +- common/config/rush/pnpm-lock.yaml | 84 +- desktop/package.json | 6 +- dev/prod/package.json | 6 +- models/process/src/index.ts | 11 +- plugins/process-assets/lang/cs.json | 8 +- plugins/process-assets/lang/de.json | 6 +- plugins/process-assets/lang/en.json | 6 +- plugins/process-assets/lang/es.json | 6 +- plugins/process-assets/lang/fr.json | 6 +- plugins/process-assets/lang/it.json | 6 +- plugins/process-assets/lang/ja.json | 6 +- plugins/process-assets/lang/ko.json | 6 +- plugins/process-assets/lang/pt-br.json | 6 +- plugins/process-assets/lang/pt.json | 6 +- plugins/process-assets/lang/ru.json | 4 + plugins/process-assets/lang/tr.json | 6 +- plugins/process-assets/lang/zh.json | 6 +- .../src/__tests__/detectSlotsRefined.test.ts | 1141 +++++++++++++++++ .../src/__tests__/integration.test.ts | 166 +++ .../src/__tests__/minmodel.ts | 229 ++++ .../src/components/ProcessesSection.svelte | 32 +- .../components/settings/BindingsEditor.svelte | 188 +++ .../settings/ImportSlotsPopup.svelte | 241 ++++ .../settings/ProcessAttributeEditor.svelte | 5 +- .../components/settings/ProcessEditor.svelte | 52 +- .../settings/UpdateCardEditor.svelte | 7 +- plugins/process-resources/src/exporter.ts | 865 +++++++++++-- plugins/process-resources/src/plugin.ts | 6 +- plugins/process-resources/src/utils.ts | 17 +- plugins/process/src/index.ts | 15 + .../process-resources/src/functions.ts | 60 +- server-plugins/process-resources/src/utils.ts | 29 +- templates/package/package.json | 6 +- 34 files changed, 3071 insertions(+), 192 deletions(-) create mode 100644 plugins/process-resources/src/__tests__/detectSlotsRefined.test.ts create mode 100644 plugins/process-resources/src/__tests__/integration.test.ts create mode 100644 plugins/process-resources/src/__tests__/minmodel.ts create mode 100644 plugins/process-resources/src/components/settings/BindingsEditor.svelte create mode 100644 plugins/process-resources/src/components/settings/ImportSlotsPopup.svelte diff --git a/.vscode/settings.json b/.vscode/settings.json index bb197af43f..2b631168c1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,14 +1,25 @@ { "eslint.format.enable": true, "eslint.enable": true, - "eslint.probe": ["javascript", "typescript", "html", "markdown", "svelte"], - "eslint.validate": ["javascript", "typescript", "markdown", "svelte"], + "eslint.probe": [ + "javascript", + "typescript", + "html", + "markdown", + "svelte" + ], + "eslint.validate": [ + "javascript", + "typescript", + "markdown", + "svelte" + ], "eslint.lintTask.enable": true, "[typescript]": { "editor.defaultFormatter": "dbaeumer.vscode-eslint" }, "[json]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "vscode.json-language-features" }, "[markdown]": { "editor.defaultFormatter": "esbenp.prettier-vscode" @@ -16,4 +27,4 @@ "[svelte]": { "editor.defaultFormatter": "dbaeumer.vscode-eslint" } -} +} \ No newline at end of file diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 5437a5f45b..d723071d11 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -683,8 +683,8 @@ importers: specifier: ^10.4.14 version: 10.4.21(postcss@8.5.6) browserslist: - specifier: ^4.28.1 - version: 4.28.1 + specifier: ^4.28.2 + version: 4.28.2 compression-webpack-plugin: specifier: ^10.0.0 version: 10.0.0(webpack@5.102.1) @@ -791,8 +791,8 @@ importers: specifier: ^5.9.3 version: 5.9.3 update-browserslist-db: - specifier: ^1.1.3 - version: 1.1.4(browserslist@4.28.1) + specifier: ^1.2.3 + version: 1.2.3(browserslist@4.28.2) webpack: specifier: ^5.97.1 version: 5.102.1(esbuild@0.25.12)(webpack-cli@5.1.4) @@ -1803,8 +1803,8 @@ importers: specifier: ^10.4.14 version: 10.4.21(postcss@8.5.6) browserslist: - specifier: ^4.28.1 - version: 4.28.1 + specifier: ^4.28.2 + version: 4.28.2 compression-webpack-plugin: specifier: ^10.0.0 version: 10.0.0(webpack@5.102.1) @@ -1869,8 +1869,8 @@ importers: specifier: ^5.9.3 version: 5.9.3 update-browserslist-db: - specifier: ^1.1.3 - version: 1.1.4(browserslist@4.28.1) + specifier: ^1.2.3 + version: 1.2.3(browserslist@4.28.2) webpack: specifier: ^5.97.1 version: 5.102.1(esbuild@0.25.12)(webpack-cli@5.1.4) @@ -45706,8 +45706,9 @@ packages: resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} engines: {node: '>=6.0.0'} - baseline-browser-mapping@2.9.11: - resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} + baseline-browser-mapping@2.10.33: + resolution: {integrity: sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==} + engines: {node: '>=6.0.0'} hasBin: true basic-auth@2.0.1: @@ -45801,8 +45802,8 @@ packages: browser-or-node@2.1.1: resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==} - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -45947,8 +45948,8 @@ packages: caniuse-lite@1.0.30001754: resolution: {integrity: sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==} - caniuse-lite@1.0.30001762: - resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==} + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} centra@2.7.0: resolution: {integrity: sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg==} @@ -46966,8 +46967,8 @@ packages: electron-store@8.2.0: resolution: {integrity: sha512-ukLL5Bevdil6oieAOXz3CMy+OgaItMiVBg701MNlG6W5RaC0AHN7rvlqTCmeb6O7jP0Qa1KKYTE0xV0xbhF4Hw==} - electron-to-chromium@1.5.267: - resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + electron-to-chromium@1.5.366: + resolution: {integrity: sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg==} electron-updater@6.6.2: resolution: {integrity: sha512-Cr4GDOkbAUqRHP5/oeOmH/L2Bn6+FQPxVLZtPbcmKZC63a1F3uu5EefYOssgZXG3u/zBlubbJ5PJdITdMVggbw==} @@ -49500,8 +49501,9 @@ packages: resolution: {integrity: sha512-8bp200rELOX72mHv0cfnuDs7LWmd3jr3Y9pxgaysjeSxnUiZxhTA0Pg+SaXkTplIK+r4CfIVAfrla4omNq6UtA==} engines: {node: '>=20'} - node-releases@2.0.27: - resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + node-releases@2.0.47: + resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} + engines: {node: '>=18'} nodemailer@6.10.1: resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==} @@ -51613,12 +51615,6 @@ packages: resolution: {integrity: sha512-ZX6U1J04K1FoSUeoX1OicAhw4d0aro2qo+L8RhJkiGTNtBNkd/Fi1Wxoc9HzcVu6HfOzm0si/N15JjxFmD1z6A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - update-browserslist-db@1.1.4: - resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -52728,7 +52724,7 @@ snapshots: dependencies: '@babel/compat-data': 7.28.5 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 + browserslist: 4.28.2 lru-cache: 5.1.1 semver: 6.3.1 @@ -56930,7 +56926,7 @@ snapshots: autoprefixer@10.4.21(postcss@8.5.6): dependencies: - browserslist: 4.28.1 + browserslist: 4.28.2 caniuse-lite: 1.0.30001754 fraction.js: 4.3.7 normalize-range: 0.1.2 @@ -57044,7 +57040,7 @@ snapshots: base64url@3.0.1: {} - baseline-browser-mapping@2.9.11: {} + baseline-browser-mapping@2.10.33: {} basic-auth@2.0.1: dependencies: @@ -57138,13 +57134,13 @@ snapshots: browser-or-node@2.1.1: {} - browserslist@4.28.1: + browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.9.11 - caniuse-lite: 1.0.30001762 - electron-to-chromium: 1.5.267 - node-releases: 2.0.27 - update-browserslist-db: 1.2.3(browserslist@4.28.1) + baseline-browser-mapping: 2.10.33 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.366 + node-releases: 2.0.47 + update-browserslist-db: 1.2.3(browserslist@4.28.2) bs-logger@0.2.6: dependencies: @@ -57324,7 +57320,7 @@ snapshots: caniuse-lite@1.0.30001754: {} - caniuse-lite@1.0.30001762: {} + caniuse-lite@1.0.30001793: {} centra@2.7.0: dependencies: @@ -58455,7 +58451,7 @@ snapshots: conf: 10.2.0 type-fest: 2.19.0 - electron-to-chromium@1.5.267: {} + electron-to-chromium@1.5.366: {} electron-updater@6.6.2: dependencies: @@ -61610,7 +61606,7 @@ snapshots: camelcase: 6.3.0 semver: 7.7.3 - node-releases@2.0.27: {} + node-releases@2.0.47: {} nodemailer@6.10.1: {} @@ -64045,15 +64041,9 @@ snapshots: escape-string-regexp: 5.0.0 path-exists: 5.0.0 - update-browserslist-db@1.1.4(browserslist@4.28.1): + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: - browserslist: 4.28.1 - escalade: 3.2.0 - picocolors: 1.1.1 - - update-browserslist-db@1.2.3(browserslist@4.28.1): - dependencies: - browserslist: 4.28.1 + browserslist: 4.28.2 escalade: 3.2.0 picocolors: 1.1.1 @@ -64309,7 +64299,7 @@ snapshots: '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.15.0 acorn-import-phases: 1.0.4(acorn@8.15.0) - browserslist: 4.28.1 + browserslist: 4.28.2 chrome-trace-event: 1.0.4 enhanced-resolve: 5.18.3 es-module-lexer: 1.7.0 @@ -64341,7 +64331,7 @@ snapshots: '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.15.0 acorn-import-phases: 1.0.4(acorn@8.15.0) - browserslist: 4.28.1 + browserslist: 4.28.2 chrome-trace-event: 1.0.4 enhanced-resolve: 5.18.3 es-module-lexer: 1.7.0 @@ -64373,7 +64363,7 @@ snapshots: '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.15.0 acorn-import-phases: 1.0.4(acorn@8.15.0) - browserslist: 4.28.1 + browserslist: 4.28.2 chrome-trace-event: 1.0.4 enhanced-resolve: 5.18.3 es-module-lexer: 1.7.0 diff --git a/desktop/package.json b/desktop/package.json index b7395a9ff8..4b0e9113dc 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -47,8 +47,8 @@ "compression-webpack-plugin": "^10.0.0", "html-webpack-plugin": "^5.5.0", "fork-ts-checker-webpack-plugin": "^9.0.2", - "update-browserslist-db": "^1.1.3", - "browserslist": "^4.28.1", + "update-browserslist-db": "^1.2.3", + "browserslist": "^4.28.2", "typescript": "^5.9.3", "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", @@ -295,4 +295,4 @@ "typescript", "svelte" ] -} +} \ No newline at end of file diff --git a/dev/prod/package.json b/dev/prod/package.json index ef504650fa..eb365cf772 100644 --- a/dev/prod/package.json +++ b/dev/prod/package.json @@ -26,7 +26,7 @@ "@hcengineering/platform-rig": "workspace:^0.7.21", "@types/node": "^22.18.1", "autoprefixer": "^10.4.14", - "browserslist": "^4.28.1", + "browserslist": "^4.28.2", "compression-webpack-plugin": "^10.0.0", "cross-env": "~7.0.3", "css-loader": "^5.2.1", @@ -48,7 +48,7 @@ "svgo-loader": "^3.0.0", "ts-loader": "^9.2.5", "typescript": "^5.9.3", - "update-browserslist-db": "^1.1.3", + "update-browserslist-db": "^1.2.3", "webpack": "^5.97.1", "webpack-bundle-analyzer": "^4.10.2", "webpack-cli": "^5.1.4", @@ -297,4 +297,4 @@ "readable-stream": "^4.7.0", "svelte": "^4.2.20" } -} +} \ No newline at end of file diff --git a/models/process/src/index.ts b/models/process/src/index.ts index 562b75eeb1..ade16ff60b 100644 --- a/models/process/src/index.ts +++ b/models/process/src/index.ts @@ -34,8 +34,8 @@ import { TypeAny, TypeBoolean, TypeIntlString, - TypeRecord, TypeRank, + TypeRecord, TypeRef, TypeString, UX @@ -46,7 +46,7 @@ import { TToDo } from '@hcengineering/model-time' import view, { createAction } from '@hcengineering/model-view' import workbench from '@hcengineering/model-workbench' import notification, { type NotificationGroup } from '@hcengineering/notification' -import { type Asset, getEmbeddedLabel, type IntlString, type Resource } from '@hcengineering/platform' +import { type Asset, type IntlString, type Resource, getEmbeddedLabel } from '@hcengineering/platform' import { type ApproveRequest, type CheckFunc, @@ -66,6 +66,7 @@ import { type ProcessCustomEvent, type ProcessFunction, type ProcessToDo, + type SlotModel, type State, type Step, type Transition, @@ -108,6 +109,12 @@ export class TProcess extends TDoc implements Process { @Prop(TypeBoolean(), process.string.AutomationOnly) automationOnly: boolean | undefined + @Prop(TypeRecord(), process.string.RequiredSlots) + requiredSlots?: Record + + @Prop(TypeRecord(), process.string.Bindings) + bindings?: Record + context!: Record } diff --git a/plugins/process-assets/lang/cs.json b/plugins/process-assets/lang/cs.json index 3eb87585db..07ec6bf5f3 100644 --- a/plugins/process-assets/lang/cs.json +++ b/plugins/process-assets/lang/cs.json @@ -172,7 +172,11 @@ "TextFromSelect": "Text z výběru", "SelectFromText": "Výběr z textu", "AskSubclass": "Zeptat se na podtřídu", - "TextFromIdentifier": "Text z identifikátoru" + "TextFromIdentifier": "Text z identifikátoru", + "ExportWithSlots": "Exportovat se sloty", + "ExportWithoutSlots": "Exportovat bez slotů", + "RequiredSlots": "Požadované sloty", + "Bindings": "Vazby" }, "error": { "MethodNotFound": "Metoda nenalezena: {methodId}", @@ -191,4 +195,4 @@ "TooDeepTransitionRecursion": "Příliš hluboká rekurze přechodů", "ToDoAlreadyCompleted": "Akční položka již byla dokončena" } -} +} \ No newline at end of file diff --git a/plugins/process-assets/lang/de.json b/plugins/process-assets/lang/de.json index 98ceeff370..c7b1404c3f 100644 --- a/plugins/process-assets/lang/de.json +++ b/plugins/process-assets/lang/de.json @@ -172,7 +172,11 @@ "TextFromSelect": "Text aus Auswahl", "SelectFromText": "Auswahl aus Text", "AskSubclass": "Nach Unterklasse fragen", - "TextFromIdentifier": "Text aus Identifikator" + "TextFromIdentifier": "Text aus Identifikator", + "ExportWithSlots": "Slot-Export", + "ExportWithoutSlots": "Slot-freier Export", + "RequiredSlots": "Erforderliche Slots", + "Bindings": "Bindungen" }, "error": { "MethodNotFound": "Methode nicht gefunden: {methodId}", diff --git a/plugins/process-assets/lang/en.json b/plugins/process-assets/lang/en.json index 0077c6acef..52fbfa7f09 100644 --- a/plugins/process-assets/lang/en.json +++ b/plugins/process-assets/lang/en.json @@ -151,6 +151,10 @@ "UnlockSection": "Unlock section", "CancelToDo": "Cancel Action item", "Export": "Export", + "ExportWithSlots": "Export with slots", + "ExportWithoutSlots": "Export without slots", + "RequiredSlots": "Required slots", + "Bindings": "Bindings", "Import": "Import", "CancelProcess": "Cancel process", "SyncWithField": "Sync with field", @@ -196,4 +200,4 @@ "TooDeepTransitionRecursion": "Too deep transition recursion", "ToDoAlreadyCompleted": "Action item already completed" } -} +} \ No newline at end of file diff --git a/plugins/process-assets/lang/es.json b/plugins/process-assets/lang/es.json index 92f84e0701..686f4125e4 100644 --- a/plugins/process-assets/lang/es.json +++ b/plugins/process-assets/lang/es.json @@ -177,7 +177,11 @@ "TextFromSelect": "Texto desde selección", "SelectFromText": "Selección desde texto", "AskSubclass": "Preguntar por subclase", - "TextFromIdentifier": "Texto desde identificador" + "TextFromIdentifier": "Texto desde identificador", + "ExportWithSlots": "Exportar con slots", + "ExportWithoutSlots": "Exportar sin slots", + "RequiredSlots": "Slots requeridos", + "Bindings": "Bindings" }, "error": { "MethodNotFound": "Método no encontrado: {methodId}", diff --git a/plugins/process-assets/lang/fr.json b/plugins/process-assets/lang/fr.json index d13f10ee75..3dfca17bcb 100644 --- a/plugins/process-assets/lang/fr.json +++ b/plugins/process-assets/lang/fr.json @@ -177,7 +177,11 @@ "TextFromSelect": "Texte depuis sélection", "SelectFromText": "Sélection depuis texte", "AskSubclass": "Demander la sous-classe", - "TextFromIdentifier": "Texte depuis identifiant" + "TextFromIdentifier": "Texte depuis identifiant", + "ExportWithSlots": "Exporter avec slots", + "ExportWithoutSlots": "Exporter sans slots", + "RequiredSlots": "Slots requis", + "Bindings": "Bindings" }, "error": { "MethodNotFound": "Méthode introuvable : {methodId}", diff --git a/plugins/process-assets/lang/it.json b/plugins/process-assets/lang/it.json index ed2f7e3401..a05b08321e 100644 --- a/plugins/process-assets/lang/it.json +++ b/plugins/process-assets/lang/it.json @@ -177,7 +177,11 @@ "TextFromSelect": "Testo da selezione", "SelectFromText": "Selezione da testo", "AskSubclass": "Chiedi sottoclasse", - "TextFromIdentifier": "Testo da identificatore" + "TextFromIdentifier": "Testo da identificatore", + "ExportWithSlots": "Esporta con slot", + "ExportWithoutSlots": "Esporta senza slot", + "RequiredSlots": "Slot richiesti", + "Bindings": "Binding" }, "error": { "MethodNotFound": "Metodo non trovato: {methodId}", diff --git a/plugins/process-assets/lang/ja.json b/plugins/process-assets/lang/ja.json index deed7948c1..da2e957b93 100644 --- a/plugins/process-assets/lang/ja.json +++ b/plugins/process-assets/lang/ja.json @@ -176,7 +176,11 @@ "TextFromSelect": "テキストから選択", "SelectFromText": "選択からテキスト", "AskSubclass": "サブクラスを尋ねる", - "TextFromIdentifier": "テキストから識別子" + "TextFromIdentifier": "テキストから識別子", + "ExportWithSlots": "スロット付きでエクスポート", + "ExportWithoutSlots": "スロットなしでエクスポート", + "RequiredSlots": "必須スロット", + "Bindings": "バインディング" }, "error": { "MethodNotFound": "メソッドが見つかりません: {methodId}", diff --git a/plugins/process-assets/lang/ko.json b/plugins/process-assets/lang/ko.json index 140acf1aeb..06a521d6a5 100644 --- a/plugins/process-assets/lang/ko.json +++ b/plugins/process-assets/lang/ko.json @@ -177,7 +177,11 @@ "TextFromSelect": "선택에서 텍스트로", "SelectFromText": "텍스트에서 선택으로", "AskSubclass": "하위 클래스 선택", - "TextFromIdentifier": "식별자에서 텍스트로" + "TextFromIdentifier": "식별자에서 텍스트로", + "ExportWithSlots": "슬롯 포함 내보내기", + "ExportWithoutSlots": "슬롯 없이 내보내기", + "RequiredSlots": "필수 슬롯", + "Bindings": "바인딩" }, "error": { "MethodNotFound": "메서드를 찾을 수 없습니다: {methodId}", diff --git a/plugins/process-assets/lang/pt-br.json b/plugins/process-assets/lang/pt-br.json index 5ee9a3286a..337d703e96 100644 --- a/plugins/process-assets/lang/pt-br.json +++ b/plugins/process-assets/lang/pt-br.json @@ -165,7 +165,11 @@ "TextFromSelect": "Texto de seleção", "SelectFromText": "Seleção de texto", "AskSubclass": "Perguntar subclasse", - "TextFromIdentifier": "Texto de identificador" + "TextFromIdentifier": "Texto de identificador", + "ExportWithSlots": "Exportar com slots", + "ExportWithoutSlots": "Exportar sem slots", + "RequiredSlots": "Slots necessários", + "Bindings": "Vinculações" }, "error": { "MethodNotFound": "Método não encontrado: {methodId}", diff --git a/plugins/process-assets/lang/pt.json b/plugins/process-assets/lang/pt.json index 152f18a4a9..acc487f87d 100644 --- a/plugins/process-assets/lang/pt.json +++ b/plugins/process-assets/lang/pt.json @@ -177,7 +177,11 @@ "TextFromSelect": "Texto de seleção", "SelectFromText": "Seleção de texto", "AskSubclass": "Perguntar subclasse", - "TextFromIdentifier": "Texto de identificador" + "TextFromIdentifier": "Texto de identificador", + "ExportWithSlots": "Exportar com slots", + "ExportWithoutSlots": "Exportar sem slots", + "RequiredSlots": "Slots necessários", + "Bindings": "Vinculações" }, "error": { "MethodNotFound": "Método não encontrado: {methodId}", diff --git a/plugins/process-assets/lang/ru.json b/plugins/process-assets/lang/ru.json index 88972a2007..f72ddc3ed4 100644 --- a/plugins/process-assets/lang/ru.json +++ b/plugins/process-assets/lang/ru.json @@ -151,6 +151,10 @@ "UnlockSection": "Разблокировать секцию", "CancelToDo": "Отменить задачу", "Export": "Экспорт", + "ExportWithSlots": "Экспортировать со слотами", + "ExportWithoutSlots": "Экспортировать без слотов", + "RequiredSlots": "Обязательные слоты", + "Bindings": "Связки", "Import": "Импорт", "CancelProcess": "Отменить процесс", "SyncWithField": "Синхронизировать с полем", diff --git a/plugins/process-assets/lang/tr.json b/plugins/process-assets/lang/tr.json index 4dcc0c1f69..7ec970a7e0 100644 --- a/plugins/process-assets/lang/tr.json +++ b/plugins/process-assets/lang/tr.json @@ -172,7 +172,11 @@ "TextFromSelect": "Seçimden metin", "SelectFromText": "Metinden seçim", "AskSubclass": "Alt sınıfı sor", - "TextFromIdentifier": "Tanımlayıcıdan metne" + "TextFromIdentifier": "Tanımlayıcıdan metne", + "ExportWithSlots": "Slotlu dışa aktar", + "ExportWithoutSlots": "Slotsuz dışa aktar", + "RequiredSlots": "Gerekli slotlar", + "Bindings": "Bağlamalar" }, "error": { "MethodNotFound": "Metod bulunamadı: {methodId}", diff --git a/plugins/process-assets/lang/zh.json b/plugins/process-assets/lang/zh.json index 8afa4d0d9d..2f88e9a3da 100644 --- a/plugins/process-assets/lang/zh.json +++ b/plugins/process-assets/lang/zh.json @@ -177,7 +177,11 @@ "TextFromSelect": "选择到文本", "SelectFromText": "文本到选择", "AskSubclass": "询问子类", - "TextFromIdentifier": "文本从标识符" + "TextFromIdentifier": "文本从标识符", + "ExportWithSlots": "导出包含槽", + "ExportWithoutSlots": "导出不含槽", + "RequiredSlots": "必需槽", + "Bindings": "绑定" }, "error": { "MethodNotFound": "找不到方法:{methodId}", diff --git a/plugins/process-resources/src/__tests__/detectSlotsRefined.test.ts b/plugins/process-resources/src/__tests__/detectSlotsRefined.test.ts new file mode 100644 index 0000000000..1a3e62c98e --- /dev/null +++ b/plugins/process-resources/src/__tests__/detectSlotsRefined.test.ts @@ -0,0 +1,1141 @@ +/* eslint-disable no-template-curly-in-string, @typescript-eslint/no-non-null-assertion */ +import cardPlugin from '@hcengineering/card' +import core, { generateId, Hierarchy, ModelDb } from '@hcengineering/core' +import { getEmbeddedLabel } from '@hcengineering/platform' +import process, { type Process, type State, type Transition } from '@hcengineering/process' +import { detectSlots, normalizeIds } from '../exporter' +import { createDoc, genMinModel } from './minmodel' + +jest.mock('@hcengineering/presentation', () => ({ + getClient: jest.fn() +})) + +describe('detectSlots refined', () => { + let h: Hierarchy + let m: ModelDb + const processId = generateId() + const stateId = generateId() + const masterTag = cardPlugin.class.Card + + beforeEach(async () => { + h = new Hierarchy() + m = new ModelDb(h) + const txes = genMinModel() + const _processTx = createDoc( + process.class.Process, + { + masterTag, + name: 'Main process', + description: 'La-la-la', + context: {} + }, + processId + ) + const stateTx = createDoc( + process.class.State, + { + process: processId, + title: 'State 1', + rank: '0' + }, + stateId + ) + + for (const tx of txes) { + h.tx(tx) + } + + h.tx(_processTx) + h.tx(stateTx) + for (const tx of txes) { + await m.tx(tx) + } + await m.tx(_processTx) + await m.tx(stateTx) + }) + + async function addAttribute (id: string, label: string): Promise { + const attrTx = createDoc( + core.class.Attribute, + { + name: id, + attributeOf: masterTag, + type: { _class: core.class.TypeString, label: core.string.String }, + label: getEmbeddedLabel(label) + }, + id as any + ) + h.tx(attrTx) + await m.tx(attrTx) + } + + test('Test 1: Update attribute with fixed value', async () => { + await addAttribute('attr1', 'Attribute 1') + + const transitions: Transition[] = [ + { + triggerParams: {}, + actions: [ + { + _id: generateId(), + _class: 'process:Action' as any, + methodId: process.method.UpdateCard, + params: { + attr1: 'fixed value' + } + } as any + ], + rank: '', + process: processId, + from: null, + to: stateId, + trigger: process.trigger.OnCardUpdate, + _id: generateId(), + space: core.space.Model, + modifiedOn: 0, + modifiedBy: core.account.System, + _class: process.class.Transition + } as any + ] + + const slots: Record = {} + const bindings: Record = {} + + detectSlots({ masterTag } as any, transitions, slots, bindings, m, h) + + // attr1 should be a slot because it is a key in params and NOT a required param of UpdateCard (requiredParams []) + expect(bindings).toEqual({ + attr1: 'attr1' + }) + expect(Object.keys(slots)).toEqual(['attr1']) + }) + + test('Test 2: Update attribute with value from another attribute', async () => { + await addAttribute('attr1', 'Attribute 1') + await addAttribute('attr2', 'Attribute 2') + + const transitions: Transition[] = [ + { + triggerParams: {}, + actions: [ + { + _id: generateId(), + _class: 'process:Action' as any, + methodId: process.method.UpdateCard, + params: { + attr1: '${@attr2}' + } + } as any + ], + rank: '', + process: processId, + from: null, + to: stateId, + trigger: process.trigger.OnCardUpdate, + _id: generateId(), + space: core.space.Model, + modifiedOn: 0, + modifiedBy: core.account.System, + _class: process.class.Transition + } as any + ] + + const slots: Record = {} + const bindings: Record = {} + + detectSlots({ masterTag } as any, transitions, slots, bindings, m, h) + + // Both attr1 and attr2 should be slots + expect(bindings).toEqual({ + attr1: 'attr1', + attr2: 'attr2' + }) + expect(Object.keys(slots).sort()).toEqual(['attr1', 'attr2']) + }) + + test('Test 3: Required params should NOT be slots in keys', async () => { + await addAttribute('title', 'Title') + + const transitions: Transition[] = [ + { + triggerParams: {}, + actions: [ + { + _id: generateId(), + _class: 'process:Action' as any, + methodId: process.method.CreateToDo, + params: { + title: 'My task' + } + } as any + ], + rank: '', + process: processId, + from: null, + to: stateId, + trigger: process.trigger.OnCardUpdate, + _id: generateId(), + space: core.space.Model, + modifiedOn: 0, + modifiedBy: core.account.System, + _class: process.class.Transition + } as any + ] + + const slots: Record = {} + const bindings: Record = {} + + detectSlots({ masterTag } as any, transitions, slots, bindings, m, h) + + // title is a required param for CreateToDo, so it should NOT be a slot even if it matches an attribute on masterTag + expect(bindings).toEqual({}) + expect(slots).toEqual({}) + }) + + test('Test 4: Required param key with DSL value referencing attributes', async () => { + await addAttribute('title', 'Title') + await addAttribute('attr2', 'Attribute 2') + + const transitions: Transition[] = [ + { + triggerParams: {}, + actions: [ + { + _id: generateId(), + _class: 'process:Action' as any, + methodId: process.method.CreateToDo, + params: { + title: '${@title} - ${@attr2}' + } + } as any + ], + rank: '', + process: processId, + from: null, + to: stateId, + trigger: process.trigger.OnCardUpdate, + _id: generateId(), + space: core.space.Model, + modifiedOn: 0, + modifiedBy: core.account.System, + _class: process.class.Transition + } as any + ] + + const slots: Record = {} + const bindings: Record = {} + + detectSlots({ masterTag } as any, transitions, slots, bindings, m, h) + + // 'title' as a KEY should be excluded (required param). + // 'title' and 'attr2' inside DSL should BE slots. + expect(bindings).toEqual({ + title: 'title', + attr2: 'attr2' + }) + expect(Object.keys(slots).sort()).toEqual(['attr2', 'title']) + }) + + test('Test 5: RunSubProcess with required _id parameter containing DSL', async () => { + await addAttribute('_id', 'ID') + await addAttribute('subProcessId', 'Sub Process ID') + + const transitions: Transition[] = [ + { + triggerParams: {}, + actions: [ + { + _id: generateId(), + _class: 'process:Action' as any, + methodId: process.method.RunSubProcess, + params: { + _id: '${@subProcessId}' + } + } as any + ], + rank: '', + process: processId, + from: null, + to: stateId, + trigger: process.trigger.OnCardUpdate, + _id: generateId(), + space: core.space.Model, + modifiedOn: 0, + modifiedBy: core.account.System, + _class: process.class.Transition + } as any + ] + + const slots: Record = {} + const bindings: Record = {} + + detectSlots({ masterTag } as any, transitions, slots, bindings, m, h) + + // _id as a key should NOT be a slot. + // subProcessId inside DSL SHOULD be a slot. + expect(bindings).toEqual({ + subProcessId: 'subProcessId' + }) + expect(Object.keys(slots)).toEqual(['subProcessId']) + expect(slots.subProcessId._class).toBe('core:class:TypeString') + }) + + test('Test 6: Multiple actions with results and cross-references', async () => { + await addAttribute('title', 'Title') + await addAttribute('relationAttr', 'Relation Attribute') + await addAttribute('processIdAttr', 'Process ID Attribute') + + const transitions: Transition[] = [ + { + triggerParams: {}, + actions: [ + { + _id: 'action1', + _class: 'process:Action' as any, + methodId: process.method.CreateCard, + params: { + title: '${@title}', + _class: 'core:class:Card' + } + }, + { + _id: 'action2', + _class: 'process:Action' as any, + methodId: process.method.AddRelation, + params: { + association: 'assoc', + direction: 'both' as any, + _id: '${@relationAttr}', + from: '${context:action1}' // Reference to card from action1 + } + }, + { + _id: 'action3', + _class: 'process:Action' as any, + methodId: process.method.RunSubProcess, + params: { + _id: '${@processIdAttr}', + card: '${context:action1}' // Reference to card from action1 + } + } + ] as any[], + rank: '', + process: processId, + from: null, + to: stateId, + trigger: process.trigger.OnCardUpdate, + _id: generateId(), + space: core.space.Model, + modifiedOn: 0, + modifiedBy: core.account.System, + _class: process.class.Transition + } as any + ] + + const slots: Record = {} + const bindings: Record = {} + + detectSlots({ masterTag } as any, transitions, slots, bindings, m, h) + + // Expected Slots: + // 1. 'title' (from DSL in CreateCard params.title) + // 2. 'relationAttr' (from DSL in AddRelation params._id) + // 3. 'processIdAttr' (from DSL in RunSubProcess params._id) + // IMPORTANT: '${context:action1}' should NOT create slots because it's not starting with '${@' + expect(bindings).toEqual({ + title: 'title', + relationAttr: 'relationAttr', + processIdAttr: 'processIdAttr' + }) + expect(Object.keys(slots).sort()).toEqual(['processIdAttr', 'relationAttr', 'title']) + }) + + test('Test 7: Complex process example (Action 0 logic)', async () => { + // custom69bc3ed74e7b0466f9490f5e is the attribute used in the user example + const attrId = 'custom69bc3ed74e7b0466f9490f5e' + await addAttribute(attrId, 'Action User') + await addAttribute('field', 'Internal Field') // Just in case it matches + + const transitions: Transition[] = [ + { + triggerParams: {}, + actions: [ + { + _id: '__ACTION_0__', + _class: 'process:Action' as any, + methodId: process.method.CreateToDo, + params: { + field: attrId, // 'field' is NOT in requiredParams of CreateToDo + title: 'Create action ', // 'title' IS in requiredParams + user: `\${@${attrId}}`, // 'user' IS in requiredParams, but it has DSL + withRollback: false + } + } + ] as any[], + rank: '0|hzzzzz:', + process: processId, + from: null, + to: stateId, + trigger: 'process:trigger:OnExecutionStart' as any, + _id: '__TRANSITION_0__', + space: core.space.Model, + modifiedOn: 0, + modifiedBy: core.account.System, + _class: process.class.Transition + } as any + ] + + const slots: Record = {} + const bindings: Record = {} + + detectSlots({ masterTag } as any, transitions, slots, bindings, m, h) + + // Expected Slots: + // 1. 'custom6 ... ' (from DSL in params.user) - gets generic 'slot2' name because it starts with 'custom' + // 2. 'field' (from key 'field' - not required) - gets 'field' name + expect(bindings).toEqual({ + field: 'field', + slot2: attrId + }) + expect(Object.keys(slots).sort()).toEqual(['field', 'slot2']) + }) + + test('Test 8: Add Actions handler (RunSubProcess with fixed IDs)', async () => { + const attrId = 'custom69bc3ed74e7b0466f9490f5e' + const fixedSubProcessId = '69c1c99b7ec0668a177ad5c1' + await addAttribute(attrId, 'Action User') + + const transitions: Transition[] = [ + { + _id: '__TRANSITION_0__', + trigger: 'process:trigger:OnExecutionStart' as any, + triggerParams: {}, + actions: [ + { + _id: '__ACTION_0__', + _class: 'process:Action' as any, + methodId: process.method.RunSubProcess, + params: { + _id: fixedSubProcessId // Required param, fixed value. NO slot. + } + } + ] as any[], + process: processId, + from: null, + to: stateId, + _class: process.class.Transition + } as any, + { + _id: '__TRANSITION_1__', + trigger: process.trigger.OnSubProcessesDone, + triggerParams: {}, + actions: [ + { + _id: '__ACTION_1__', + _class: 'process:Action' as any, + methodId: process.method.RunSubProcess, + params: { + _id: fixedSubProcessId // Required param, fixed value. NO slot. + } + }, + { + _id: '__ACTION_2__', + _class: 'process:Action' as any, + methodId: process.method.CreateToDo, + params: { + field: attrId, // 'field' NOT required, but if no 'field' attribute exists, NO slot. + title: 'stop add actions', + user: `\${@${attrId}}`, // 'user' required, but has DSL. YES slot. + withRollback: false + } + } + ] as any[], + process: processId, + from: stateId, + to: stateId, + _class: process.class.Transition + } as any + ] + + const slots: Record = {} + const bindings: Record = {} + + detectSlots({ masterTag } as any, transitions, slots, bindings, m, h) + + // Expected Slots: + // Only 'custom69bc...' should be a slot (from action 2 user DSL). + // 'field' is NOT a slot here because we didn't addAttribute('field'). + expect(bindings).toEqual({ + slot1: attrId + }) + expect(Object.keys(slots)).toEqual(['slot1']) + }) + + test('Test 9: Value-based and Advanced DSL Detection for required params', async () => { + const attr1Id = '69beeeba92ba480730625f97' + const attr2Id = '69c1ca4f7ec0668a177ad6d3' + + await addAttribute(attr1Id, 'Target Class') + await addAttribute(attr2Id, 'Source Attribute') + + const transitions: Transition[] = [ + { + triggerParams: {}, + actions: [ + { + _id: '__ACTION_1__', + _class: 'process:Action' as any, + methodId: process.method.CreateCard, + params: { + _class: attr1Id, // Required key, but VALUE is an attribute ID. SHOULD be a slot. + title: `\${$userRequest(${attr2Id},title,card:class:Card)}` // Required key, but DSL has attribute ID. SHOULD be a slot. + } + } + ] as any[], + rank: '0|i00007:', + process: processId, + from: stateId, + to: stateId, + trigger: 'process:trigger:OnSubProcessesDone' as any, + _id: '__TRANSITION_1__', + space: core.space.Model, + modifiedOn: 0, + modifiedBy: core.account.System, + _class: process.class.Transition + } as any + ] + + const slots: Record = {} + const bindings: Record = {} + + detectSlots({ masterTag } as any, transitions, slots, bindings, m, h) + + // Expected Slots: + // Both attr1Id and attr2Id should be detected. + // They get their IDs as slot names because they are passed as 'name' to addAttribute. + expect(bindings).toEqual({ + [attr1Id]: attr1Id, + [attr2Id]: attr2Id + }) + expect(Object.keys(slots).sort()).toEqual([attr1Id, attr2Id].sort()) + }) + + test('Test 10: Detection of custom classes (not attributes)', async () => { + const classId = '69beca1892ba480730625735' + // Create a Class doc (not an Attribute) + const classDoc = { _id: classId, _class: 'core:class:Class', name: 'CustomClass' } + m.findObject = jest.fn().mockImplementation((id) => { + if (id === classId) return classDoc + return undefined + }) + h.findAttribute = jest.fn().mockReturnValue(undefined) // It's a class, not an attribute + + const transitions: Transition[] = [ + { + triggerParams: {}, + actions: [ + { + _id: '__ACTION_1__', + _class: 'process:Action' as any, + methodId: process.method.CreateCard, + params: { + _class: classId + } + } + ] as any[], + rank: '0|i00007:', + process: processId, + from: stateId, + to: stateId, + trigger: 'process:trigger:OnSubProcessesDone' as any, + _id: '__TRANSITION_1__', + space: core.space.Model, + modifiedOn: 0, + modifiedBy: core.account.System, + _class: process.class.Transition + } as any + ] + + const slots: Record = {} + const bindings: Record = {} + + detectSlots({ masterTag } as any, transitions, slots, bindings, m, h) + + // Expected Slots: + // Slot should be detected for the Class ID because it starts with 69 and is found by findObject + expect(bindings).toEqual({ + [classId]: classId + }) + expect(Object.keys(slots)).toEqual([classId]) + }) + + test('Test 11: Slot Metadata Corruption Check (String Spreading)', async () => { + const attrId = '69becaf392ba48073062581f' + // Create an attribute where type is a string reference, not an object + const attrDoc = { + _id: attrId, + _class: 'core:class:Attribute', + name: 'rootCause', + type: 'N:N', // This caused the "hell" when spread + label: 'Root Cause' + } + m.findObject = jest.fn().mockImplementation((id) => { + if (id === attrId) return attrDoc + return undefined + }) + h.findAttribute = jest.fn().mockImplementation((tag, id) => { + if (id === attrId) return attrDoc + return undefined + }) + + const transitions: Transition[] = [ + { + triggerParams: {}, + actions: [ + { + _id: '__ACTION_1__', + _class: 'process:Action' as any, + methodId: process.method.AddRelation, + params: { + association: attrId + } + } + ] as any[], + rank: '0|i00007:', + process: processId, + from: stateId, + to: stateId, + trigger: 'process:trigger:OnSubProcessesDone' as any, + _id: '__TRANSITION_1__', + space: core.space.Model, + modifiedOn: 0, + modifiedBy: core.account.System, + _class: process.class.Transition + } as any + ] + + const slots: Record = {} + const bindings: Record = {} + + detectSlots({ masterTag } as any, transitions, slots, bindings, m, h) + + // Expected Slots: + // Slot 'rootCause' should be created. + // The 'type' property in slot metadata should NOT be a spread object like {0: "N", ...} + expect(bindings).toEqual({ + rootCause: attrId + }) + expect(slots.rootCause).toEqual({ + _class: 'N:N', + label: 'Root Cause', + slotKind: 'attribute', + memberOf: undefined + }) + // Ensure no corrupted keys in the slot object + expect(slots.rootCause['0']).toBeUndefined() + }) + + test('Test 12: System Attribute Normalization (Hex ID Replacement)', async () => { + const attrId = '69c1c342fe49e50bc1ca7fcf' + const attrDoc = { _id: attrId, name: 'title', _class: 'core:class:Attribute', label: 'Name' } + m.findObject = jest.fn().mockImplementation((id) => (id === attrId ? attrDoc : undefined)) + h.findAttribute = jest.fn().mockImplementation((tag, id) => (id === attrId ? attrDoc : undefined)) + + const transitions: Transition[] = [ + { + triggerParams: {}, + actions: [ + { + _id: '__ACTION_1__', + _class: 'process:Action' as any, + methodId: process.method.UpdateCard, + params: { [attrId]: 'New Title' } + } + ] as any, + process: processId, + from: stateId, + to: stateId, + trigger: 'process:trigger:OnCardUpdate' as any, + _id: '__TRANSITION_1__', + _class: process.class.Transition + } as any + ] + + const slots: Record = {} + const bindings: Record = {} + detectSlots({ masterTag } as any, transitions, slots, bindings, m, h) + + // normalizeIds is now imported + const procDoc = { + _id: processId, + _class: 'process:class:Process', + masterTag, + bindings + } + const docs = [procDoc, ...transitions] + const normalized = normalizeIds(docs as any[]) + const normalizedProc = normalized.find((d: any) => d._id === '__PROCESS__') + + // Bindings key should remain 'title' if we use the refined normalization + expect((normalizedProc as any).bindings.title).toBeDefined() + expect((normalizedProc as any).bindings.__SLOT_title__).toBeUndefined() + + // Param key should be replaced + const normalizedTransition = normalized.find((d: any) => d._id === '__TRANSITION_0__') + expect((normalizedTransition as any).actions[0].params).toEqual({ + __SLOT_title__: 'New Title' + }) + }) + + test('Test 13: Custom Attribute in DSL (30-char ID)', async () => { + const attrId = 'custom68f7bb1d9cc1dda7cedf7a49' + const attrDoc = { _id: attrId, name: attrId, _class: 'core:class:Attribute', label: 'Custom' } + m.findObject = jest.fn().mockImplementation((id) => (id === attrId ? attrDoc : undefined)) + h.findAttribute = jest.fn().mockImplementation((tag, id) => (id === attrId ? attrDoc : undefined)) + + const transitions: Transition[] = [ + { + triggerParams: {}, + actions: [ + { + _id: '__ACTION_1__', + _class: 'process:Action' as any, + methodId: process.method.UpdateCard, + params: { title: `\${$userRequest(${attrId})}` } + } + ] as any, + process: processId, + _id: '__TRANSITION_1__', + _class: process.class.Transition + } as any + ] + + const slots: Record = {} + const bindings: Record = {} + detectSlots({ masterTag } as any, transitions, slots, bindings, m, h) + + // Should detect the custom ID inside DSL and create a slot + expect(bindings).toEqual({ + slot1: attrId + }) + expect(slots.slot1).toBeDefined() + }) + + test('Test 14: Class Slot Metadata (Capture extends/kind)', async () => { + const classId = '69beeeba92ba480730625f97' + const classDoc = { + _id: classId, + _class: 'core:class:Class', + name: 'MyClass', + label: 'My Class', + extends: 'core:class:Doc', + kind: 'CLASS' + } + m.findObject = jest.fn().mockImplementation((id) => (id === classId ? classDoc : undefined)) + h.findAttribute = jest.fn().mockImplementation((tag, id) => (id === classId ? classDoc : undefined)) + + const transitions: Transition[] = [ + { + triggerParams: {}, + actions: [ + { + _id: '__ACTION_1__', + _class: 'process:Action' as any, + methodId: 'process:method:CreateCard', + params: { _class: classId } + } + ] as any, + process: processId, + _id: '__TRANSITION_1__', + _class: process.class.Transition + } as any + ] + + const slots: Record = {} + const bindings: Record = {} + detectSlots({ masterTag } as any, transitions, slots, bindings, m, h) + + expect(slots['My Class']).toEqual({ + _class: 'core:class:Class', + label: 'My Class', + slotKind: 'class', + memberOf: undefined + }) + }) + + test('Test 15: Attribute Label Precedence (Preserve Custom Label)', async () => { + const attrId = 'attempts' + const attrDoc = { + _id: attrId, + name: 'attempts', + _class: 'core:class:Attribute', + label: 'Attempts Count', + type: { _class: 'core:class:TypeNumber', label: 'core:string:Number' } + } + m.findObject = jest.fn().mockImplementation((id) => (id === attrId ? attrDoc : undefined)) + h.findAttribute = jest.fn().mockImplementation((tag, id) => (id === attrId ? attrDoc : undefined)) + + const transitions: Transition[] = [ + { + triggerParams: {}, + actions: [ + { + _id: '__ACTION_1__', + _class: 'process:Action' as any, + methodId: process.method.UpdateCard, + params: { [attrId]: 1 } + } + ] as any, + process: processId, + _id: '__TRANSITION_1__', + _class: process.class.Transition + } as any + ] + + const slots: Record = {} + const bindings: Record = {} + detectSlots({ masterTag } as any, transitions, slots, bindings, m, h) + + // Label should be 'Attempts Count', NOT 'core:string:Number' + expect(slots.attempts.label).toBe('Attempts Count') + }) + + test('Test 16: Custom ID as Param Key (Fallback Slotification)', async () => { + // ID that is NOT in the hierarchy + const attrId = 'custom68f7bb1d9cc1dda7cedf7a49' + h.findAttribute = jest.fn().mockReturnValue(undefined) + + const transitions: Transition[] = [ + { + triggerParams: {}, + actions: [ + { + _id: '__ACTION_1__', + _class: 'process:Action' as any, + methodId: process.method.UpdateCard, + params: { [attrId]: 'value' } + } + ] as any, + process: processId, + _id: '__TRANSITION_1__', + _class: process.class.Transition + } as any + ] + + const slots: Record = {} + const bindings: Record = {} + detectSlots({ masterTag } as any, transitions, slots, bindings, m, h) + + // Should slotify even if not found in hierarchy, since it looks like a custom ID + expect(bindings).toEqual({ + slot1: attrId + }) + expect(slots.slot1).toBeDefined() + }) + + test('Test 17: Custom ID in DSL Value (30-char ID)', async () => { + const attrId = 'custom68f75bafbfb5af6eea5c0eaa' + m.findObject = jest + .fn() + .mockImplementation((id) => (id === attrId ? { _id: attrId, label: 'Custom Value' } : undefined)) + + const transitions: Transition[] = [ + { + triggerParams: {}, + actions: [ + { + _id: '__ACTION_1__', + _class: 'process:Action' as any, + methodId: process.method.UpdateCard, + params: { someField: `\${$context(__CONTEXT_0__, ${attrId})}` } + } + ] as any, + process: processId, + _id: '__TRANSITION_1__', + _class: process.class.Transition + } as any + ] + + const slots: Record = {} + const bindings: Record = {} + detectSlots({ masterTag } as any, transitions, slots, bindings, m, h) + + // Should detect the 30-char custom ID inside DSL + expect(bindings).toEqual({ + slot1: attrId + }) + }) + + test('Test 18: Attribute with String Type (Capture primitive _class)', async () => { + const attrId = 'primitiveField' + const attrDoc = { + _id: attrId, + name: 'primitiveField', + _class: 'core:class:Attribute', + label: 'Text', + type: 'core:class:TypeString' // String type instead of object + } + m.findObject = jest.fn().mockReturnValue(attrDoc) + h.findAttribute = jest.fn().mockReturnValue(attrDoc) + + const transitions: Transition[] = [ + { + triggerParams: {}, + actions: [ + { + _id: 'A1', + _class: 'process:Action' as any, + methodId: process.method.UpdateCard, + params: { [attrId]: 'Hello' } + } + ] as any, + process: processId, + _id: 'T1', + _class: process.class.Transition + } as any + ] + + const slots: Record = {} + const bindings: Record = {} + detectSlots({ masterTag } as any, transitions, slots, bindings, m, h) + + // Slot metadata should have _class: 'core:class:TypeString' + expect(slots.primitiveField._class).toBe('core:class:TypeString') + }) + + test('Test 19: Class Slot (Capture _class and metadata)', async () => { + const classId = '69beeeba92ba480730625f97' + const classDoc = { + _id: classId, + _class: 'core:class:Class', + name: 'MyClass', + label: 'My Class', + extends: 'core:class:Doc' + } + m.findObject = jest.fn().mockReturnValue(classDoc) + h.findAttribute = jest.fn().mockReturnValue(classDoc) + + const transitions: Transition[] = [ + { + triggerParams: {}, + actions: [ + { + _id: 'A1', + _class: 'process:Action' as any, + methodId: 'process:method:CreateCard', + params: { _class: classId } + } + ] as any, + process: processId, + _id: 'T1', + _class: process.class.Transition + } as any + ] + + const slots: Record = {} + const bindings: Record = {} + detectSlots({ masterTag } as any, transitions, slots, bindings, m, h) + + expect(slots['My Class']._class).toBe('core:class:Class') + expect(slots['My Class'].label).toBe('My Class') + }) + + test('Test 20: Multi-Context Attribute Detection (Alternative Class Context)', async () => { + // Setup Class A (Main) and Class B (Target) + const masterTag = 'ClassA' + const classBId = 'ClassB' + const attrBId = 'attrB' + + const classADoc = { _id: masterTag, _class: 'core:class:Class', name: 'ClassA', label: 'Main Class' } + const classBDoc = { _id: classBId, _class: 'core:class:Class', name: 'ClassB', label: 'Other Class' } + const attrBDoc = { + _id: attrBId, + _class: 'core:class:Attribute', + name: 'attrB', + label: 'Target Attribute', + type: 'core:class:TypeString' + } + + m.findObject = jest.fn().mockImplementation((id) => { + if (id === masterTag) return classADoc + if (id === classBId) return classBDoc + if (id === attrBId) return attrBDoc + if (id === 'process:method:CreateCard') return { _id: 'process:method:CreateCard', requiredParams: ['_class'] } + return undefined + }) + + // Hierarchy helper needs to find attrB in ClassB + h.findAttribute = jest.fn().mockImplementation((tag, attrId) => { + if (tag === classBId && (attrId === 'attrB' || attrId === attrBId)) return attrBDoc + return undefined + }) + + const transitions: Transition[] = [ + { + triggerParams: {}, + actions: [ + { + _id: 'A1', + _class: 'process:Action' as any, + methodId: 'process:method:CreateCard', + params: { + _class: classBId, + [attrBId]: 'some value' + } + } + ] as any, + process: processId, + _id: 'T1', + _class: process.class.Transition + } as any + ] + + const slots: Record = {} + const bindings: Record = {} + detectSlots({ masterTag } as any, transitions, slots, bindings, m, h) + + // attrB should be detected and slotified with its correct label even though it is NOT in masterTag (ClassA) + expect(bindings.attrB).toBe(attrBId) + expect(slots.attrB.label).toBe('Target Attribute') + expect(slots.attrB._class).toBe('core:class:TypeString') + }) + + test('Test 21: memberOf Slot Metadata (Dependency tracking)', async () => { + const mainClassId = '69c1c342fe49e50bc1ca7fcf' + const subClassId = '69beeeba92ba480730625f97' + const attrBId = 'custom68f7bb1d9cc1dda7cedf7a49' + + const classADoc = { _id: mainClassId, _class: 'core:class:Class', name: 'ClassA', label: 'Main' } + const classBDoc = { _id: subClassId, _class: 'core:class:Class', name: 'ClassB', label: 'Dependent' } + const attrBDoc = { + _id: attrBId, + _class: 'core:class:Attribute', + name: 'attrB', + label: 'Sub Attr', + type: 'core:class:TypeString' + } + + m.findObject = jest.fn().mockImplementation((id) => { + if (id === mainClassId) return classADoc + if (id === subClassId) return classBDoc + if (id === attrBId) return attrBDoc + if (id === 'process:method:CreateCard') return { _id: 'process:method:CreateCard', requiredParams: ['_class'] } + return undefined + }) + + h.findAttribute = jest.fn().mockImplementation((tag, attrId) => { + if (tag === subClassId && attrId === attrBId) return attrBDoc + return undefined + }) + + const transitions: Transition[] = [ + { + triggerParams: {}, + actions: [ + { + _id: 'A1', + _class: 'process:Action' as any, + methodId: 'process:method:CreateCard', + params: { + _class: subClassId, // This becomes slot1 (because ClassB name is 'Dependent' which might be safe, but wait... B usually gets slot1 if it's the first) + [attrBId]: 'val' + } + } + ] as any, + process: processId, + _id: 'T1', + _class: process.class.Transition + } as any + ] + + const slots: Record = {} + const bindings: Record = {} + detectSlots({ masterTag: mainClassId } as any, transitions, slots, bindings, m, h) + + expect(bindings.attrB).toBe(attrBId) + + const classBSlot = 'Dependent' + const attrBSlot = 'attrB' + + expect(slots[classBSlot]).toBeDefined() + expect(slots[attrBSlot]).toBeDefined() + + // attrB should have memberOf pointing to ClassB + expect(slots[attrBSlot].memberOf).toBe(`__SLOT_${classBSlot}__`) + }) + + test('Test 22: Association Direction-Aware Naming', async () => { + const assocId = '69c1c342fe49e50bc1ca7fcf' // 24 chars + const assocDoc = { + _id: assocId, + _class: 'core:class:Association', + nameA: 'Related From', + nameB: 'Detailed Relations', + label: 'Default Label' + } + + m.findObject = jest.fn().mockReturnValue(assocDoc) + + const transitions = [ + { + _id: 'tr1', + actions: [ + { + methodId: 'process:method:AddRelation', + params: { association: assocId, direction: 'B' } + } + ] + } + ] + + const slots: Record = {} + const bindings: Record = {} + const proc = { masterTag: 'ClassA' } as any + detectSlots(proc, transitions as any[], slots, bindings, m, h) + + const assocSlot = Object.keys(slots).find((k) => slots[k]._class === 'core:class:Association') + expect(assocSlot).toBeDefined() + expect(slots[assocSlot!].name).toBe('Detailed Relations') + }) + + test('Test 23: Sub-Process memberOf linking via context resolution', async () => { + const classBId = '69beeeba92ba480730625f97' // 24 chars + const subProcId = '69c1c342fe49e50bc1ca7fcf' // 24 chars + const classBDoc = { _id: classBId, _class: 'core:class:Class', name: 'ClassB' } + const subProcDoc = { _id: subProcId, _class: 'process:class:Process', name: 'SubProcess' } + + m.findObject = jest.fn().mockImplementation((id) => { + if (id === classBId) return classBDoc + if (id === subProcId) return subProcDoc + return null + }) + + const transitions = [ + { + _id: 'tr1', + actions: [ + { + _id: 'act1', + methodId: 'process:method:CreateCard', + params: { _class: classBId }, + results: [{ _id: '__CONTEXT_B__', type: { _class: 'core:class:RefTo', to: classBId } }] + }, + { + _id: 'act2', + methodId: 'process:method:RunSubProcess', + params: { _id: subProcId, card: '${$context("__CONTEXT_B__")}' } + } + ] + } + ] + + const slots: Record = {} + const bindings: Record = {} + const proc = { masterTag: 'ClassA' } as any + detectSlots(proc, transitions as any[], slots, bindings, m, h) + + const subProcSlot = slots.SubProcess + expect(subProcSlot).toBeDefined() + expect(subProcSlot.memberOf).toBeDefined() // It should refer to slot created for ClassB + }) +}) diff --git a/plugins/process-resources/src/__tests__/integration.test.ts b/plugins/process-resources/src/__tests__/integration.test.ts new file mode 100644 index 0000000000..7b390fc603 --- /dev/null +++ b/plugins/process-resources/src/__tests__/integration.test.ts @@ -0,0 +1,166 @@ +/* eslint-disable no-template-curly-in-string, @typescript-eslint/no-non-null-assertion */ +import core, { generateId, Hierarchy, ModelDb } from '@hcengineering/core' +import { exportProcess, importProcess } from '../exporter' +import { createDoc, genMinModel } from './minmodel' +import cardPlugin from '@hcengineering/card' +import { getEmbeddedLabel } from '@hcengineering/platform' +import process, { type Process, type State } from '@hcengineering/process' +import { getClient } from '@hcengineering/presentation' + +jest.mock('@hcengineering/presentation', () => ({ + getClient: jest.fn() +})) + +describe('Process Export/Import Integration', () => { + let h: Hierarchy + let m: ModelDb + const processId = generateId() + const stateId = generateId() + const masterTag = cardPlugin.class.Card + + beforeEach(async () => { + h = new Hierarchy() + m = new ModelDb(h) + const txes = genMinModel() + + // Mock getClient to return our test model/hierarchy + ;(getClient as jest.Mock).mockReturnValue({ + getModel: () => m, + getHierarchy: () => h, + apply: () => ({ + createDoc: jest.fn().mockResolvedValue(undefined), + commit: jest.fn().mockResolvedValue(undefined) + }) + }) + + for (const tx of txes) { + h.tx(tx) + } + for (const tx of txes) { + await m.tx(tx) + } + + const _processTx = createDoc( + process.class.Process, + { + masterTag, + name: 'Integration Process', + context: { + __CONTEXT_0__: { + _class: process.class.ProcessToDo, + _id: '__CONTEXT_0__', + name: 'Task 1' + } + } as any, + description: '' + }, + processId + ) + const stateTx = createDoc( + process.class.State, + { + process: processId, + title: 'State 1', + rank: '0' + }, + stateId + ) + + h.tx(_processTx) + h.tx(stateTx) + await m.tx(_processTx) + await m.tx(stateTx) + }) + + async function addAttribute (id: string, name: string, label: string): Promise { + const attrTx = createDoc( + core.class.Attribute, + { + name, + attributeOf: masterTag, + type: { _class: core.class.TypeString, label: core.string.String }, + label: getEmbeddedLabel(label) + }, + id as any + ) + h.tx(attrTx) + await m.tx(attrTx) + } + + test('DSL string with slot and context should survive export/import cycle', async () => { + const attrId = '69c1c342fe49e50bc1ca7fcf' + await addAttribute(attrId, 'sourceAttr', 'Source Attribute') + + const proc = m.findObject(processId) as Process + const transition: any = { + _id: generateId(), + _class: process.class.Transition, + process: processId, + from: null, + to: stateId, + trigger: process.trigger.OnExecutionStart, + triggerParams: {}, + actions: [ + { + _id: generateId(), + _class: 'process:Action', + methodId: process.method.CreateCard, + params: { + title: `\${$userRequest(${attrId},title,card:class:Card)}`, + contextRef: '${$context(__CONTEXT_0__)}' + } + } + ], + rank: '0' + } + + m.findAllSync = jest.fn().mockImplementation((_class, filter) => { + if (_class === process.class.Process) return [proc] + if (_class === process.class.State) return [m.findObject(stateId)] + if (_class === process.class.Transition) return [transition] + return [] + }) + + // 1. Export + const { docs } = exportProcess(proc) + const json = JSON.stringify(docs, null, 2) + + // Verify normalization + expect(json).toContain('__SLOT_') + expect(json).toContain('__CONTEXT_') + expect(json).not.toContain(attrId) // Should be replaced by slot placeholder + + // 2. Import + const newMasterTag = generateId() + const bindings = { sourceAttr: attrId } // Correct binding for the slot name + + const mockApply = { + createDoc: jest.fn().mockResolvedValue(undefined), + commit: jest.fn().mockResolvedValue(undefined) + } + ;(getClient as jest.Mock).mockReturnValue({ + getModel: () => m, + getHierarchy: () => h, + apply: () => mockApply + }) + + await importProcess(newMasterTag as any, json, bindings) + + // Check if importProcess called createDoc with the correctly restored DSL + const createDocCalls = mockApply.createDoc.mock.calls + const transitionCall = createDocCalls.find((call) => call[0] === process.class.Transition) + + expect(transitionCall).toBeDefined() + const importedParams = transitionCall![2].actions[0].params + + // CRITICAL: Check if DSL restored the REAL attribute ID + expect(importedParams.title).toContain(attrId) + expect(importedParams.title).not.toContain('__SLOT_') + + // Check if context was restored (it gets a NEW ID, not the placeholder) + expect(importedParams.contextRef).toContain('${$context(') + expect(importedParams.contextRef).not.toContain('__CONTEXT_') + const restoredCtxId = importedParams.contextRef.match(/\$\{?\$context\(([^)]+)\)\}/)![1] + expect(restoredCtxId).toHaveLength(24) // Should be a real generated ID + }) +}) diff --git a/plugins/process-resources/src/__tests__/minmodel.ts b/plugins/process-resources/src/__tests__/minmodel.ts new file mode 100644 index 0000000000..64e23f4bfe --- /dev/null +++ b/plugins/process-resources/src/__tests__/minmodel.ts @@ -0,0 +1,229 @@ +// +// Copyright © 2026 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, { + ClassifierKind, + DOMAIN_MODEL, + DOMAIN_TX, + TxFactory, + type Class, + type Data, + type Doc, + type Obj, + type Ref, + type TxCUD, + type TxCreateDoc +} from '@hcengineering/core' +import type { IntlString } from '@hcengineering/platform' +import processPlugin from '../plugin' +import card from '@hcengineering/card' + +export const txFactory = new TxFactory(core.account.System) + +export function createClass (_class: Ref>, attributes: Data>): TxCreateDoc { + return txFactory.createTxCreateDoc(core.class.Class, core.space.Model, attributes, _class) +} + +export function createDoc (_class: Ref>, attributes: Data, id?: Ref): TxCreateDoc { + return txFactory.createTxCreateDoc(_class, core.space.Model, attributes, id) +} + +export function genMinModel (): Array> { + const txes = [] + // Fill Tx'es with basic model classes. + txes.push(createClass(core.class.Obj, { label: 'Obj' as IntlString, kind: ClassifierKind.CLASS })) + txes.push( + createClass(core.class.Doc, { label: 'Doc' as IntlString, extends: core.class.Obj, kind: ClassifierKind.CLASS }) + ) + txes.push( + createClass(core.class.AttachedDoc, { + label: 'AttachedDoc' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.MIXIN + }) + ) + txes.push( + createClass(core.class.Class, { + label: 'Class' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_MODEL + }) + ) + txes.push( + createClass(core.class.Space, { + label: 'Space' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_MODEL + }) + ) + + txes.push( + createClass(core.class.Tx, { + label: 'Tx' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_TX + }) + ) + txes.push( + createClass(core.class.TxCUD, { + label: 'TxCUD' as IntlString, + extends: core.class.Tx, + kind: ClassifierKind.CLASS, + domain: DOMAIN_TX + }) + ) + txes.push( + createClass(core.class.TxCreateDoc, { + label: 'TxCreateDoc' as IntlString, + extends: core.class.TxCUD, + kind: ClassifierKind.CLASS + }) + ) + + // Add Card and Attribute related classes + txes.push( + createClass(card.class.Card, { label: 'Card' as IntlString, extends: core.class.Doc, kind: ClassifierKind.CLASS }) + ) + txes.push( + createClass(core.class.Attribute, { + label: 'Attribute' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS + }) + ) + + // Add Process related classes + txes.push( + createClass(processPlugin.class.Process, { + label: 'Process' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS + }) + ) + txes.push( + createClass(processPlugin.class.State, { + label: 'State' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS + }) + ) + txes.push( + createClass(processPlugin.class.Transition, { + label: 'Transition' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS + }) + ) + txes.push( + createClass(processPlugin.class.Method, { + label: 'Method' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS + }) + ) + txes.push( + createClass(processPlugin.class.Trigger, { + label: 'Trigger' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS + }) + ) + + // Add real methods from actions.ts + txes.push( + createDoc(processPlugin.class.Method, { requiredParams: ['_id'] } as any, processPlugin.method.RunSubProcess) + ) + txes.push( + createDoc(processPlugin.class.Method, { requiredParams: ['user'] } as any, processPlugin.method.RequestApproval) + ) + txes.push( + createDoc(processPlugin.class.Method, { requiredParams: ['title', 'user'] } as any, processPlugin.method.CreateToDo) + ) + txes.push(createDoc(processPlugin.class.Method, { requiredParams: [] } as any, processPlugin.method.UpdateCard)) + txes.push( + createDoc( + processPlugin.class.Method, + { requiredParams: ['title', '_class'] } as any, + processPlugin.method.CreateCard + ) + ) + txes.push( + createDoc( + processPlugin.class.Method, + { requiredParams: ['association', 'direction', '_id'] } as any, + processPlugin.method.AddRelation + ) + ) + txes.push(createDoc(processPlugin.class.Method, { requiredParams: ['_id'] } as any, processPlugin.method.AddTag)) + txes.push(createDoc(processPlugin.class.Method, { requiredParams: ['_id'] } as any, processPlugin.method.CancelToDo)) + txes.push( + createDoc(processPlugin.class.Method, { requiredParams: ['_id'] } as any, processPlugin.method.CancelSubProcess) + ) + txes.push(createDoc(processPlugin.class.Method, { requiredParams: [] } as any, processPlugin.method.LockCard)) + txes.push(createDoc(processPlugin.class.Method, { requiredParams: ['_id'] } as any, processPlugin.method.LockSection)) + txes.push(createDoc(processPlugin.class.Method, { requiredParams: [] } as any, processPlugin.method.UnlockCard)) + txes.push( + createDoc(processPlugin.class.Method, { requiredParams: ['_id'] } as any, processPlugin.method.UnlockSection) + ) + txes.push(createDoc(processPlugin.class.Method, { requiredParams: ['value'] } as any, processPlugin.method.LockField)) + txes.push( + createDoc(processPlugin.class.Method, { requiredParams: ['value'] } as any, processPlugin.method.UnlockField) + ) + + // Add real triggers from triggers.ts + txes.push( + createDoc( + processPlugin.class.Trigger, + { requiredParams: ['_id'] } as any, + processPlugin.trigger.OnApproveRequestApproved + ) + ) + txes.push( + createDoc( + processPlugin.class.Trigger, + { requiredParams: ['_id'] } as any, + processPlugin.trigger.OnApproveRequestRejected + ) + ) + txes.push( + createDoc(processPlugin.class.Trigger, { requiredParams: ['_id'] } as any, processPlugin.trigger.OnToDoClose) + ) + txes.push( + createDoc(processPlugin.class.Trigger, { requiredParams: [] } as any, processPlugin.trigger.OnExecutionStart) + ) + txes.push( + createDoc(processPlugin.class.Trigger, { requiredParams: ['_id'] } as any, processPlugin.trigger.OnToDoRemove) + ) + txes.push(createDoc(processPlugin.class.Trigger, { requiredParams: [] } as any, processPlugin.trigger.OnCardUpdate)) + txes.push( + createDoc(processPlugin.class.Trigger, { requiredParams: [] } as any, processPlugin.trigger.WhenFieldChanges) + ) + txes.push( + createDoc(processPlugin.class.Trigger, { requiredParams: [] } as any, processPlugin.trigger.OnSubProcessesDone) + ) + txes.push( + createDoc( + processPlugin.class.Trigger, + { requiredParams: ['process'] } as any, + processPlugin.trigger.OnSubProcessMatch + ) + ) + txes.push(createDoc(processPlugin.class.Trigger, { requiredParams: [] } as any, processPlugin.trigger.OnTime)) + + return txes +} diff --git a/plugins/process-resources/src/components/ProcessesSection.svelte b/plugins/process-resources/src/components/ProcessesSection.svelte index d83bb92064..ec2ede6192 100644 --- a/plugins/process-resources/src/components/ProcessesSection.svelte +++ b/plugins/process-resources/src/components/ProcessesSection.svelte @@ -18,10 +18,20 @@ import { translate } from '@hcengineering/platform' import { createQuery, getClient } from '@hcengineering/presentation' import { Process, State } from '@hcengineering/process' - import { ButtonIcon, getCurrentLocation, Icon, IconAdd, IconFile, IconOpen, Label, navigate } from '@hcengineering/ui' - import process from '../plugin' import { makeRank } from '@hcengineering/rank' - import { importProcess } from '../exporter' + import { + ButtonIcon, + getCurrentLocation, + Icon, + IconAdd, + IconFile, + Label, + navigate, + showPopup + } from '@hcengineering/ui' + import { getRequiredSlots, importProcess } from '../exporter' + import process from '../plugin' + import ImportSlotsPopup from './settings/ImportSlotsPopup.svelte' export let masterTag: MasterTag @@ -86,7 +96,21 @@ const file = (evt.target as HTMLInputElement).files?.[0] if (file != null) { const text = await file.text() - await importProcess(masterTag._id, text) + const slots = getRequiredSlots(text) + if (slots && Object.keys(slots).length > 0) { + showPopup( + ImportSlotsPopup, + { requiredSlots: slots, masterTag: masterTag._id }, + undefined, + async (bindings) => { + if (bindings) { + await importProcess(masterTag._id, text, bindings) + } + } + ) + } else { + await importProcess(masterTag._id, text) + } } } input.click() diff --git a/plugins/process-resources/src/components/settings/BindingsEditor.svelte b/plugins/process-resources/src/components/settings/BindingsEditor.svelte new file mode 100644 index 0000000000..a960a89941 --- /dev/null +++ b/plugins/process-resources/src/components/settings/BindingsEditor.svelte @@ -0,0 +1,188 @@ + + + + { + dispatch('close') + }} + on:close +> +
+ {#each Object.entries(process.requiredSlots ?? {}) as [id, slot]} +
+
+ {/each} + {#if Object.keys(process.requiredSlots ?? {}).length === 0} +
No slots defined for this process
+ {/if} +
+
diff --git a/plugins/process-resources/src/components/settings/ImportSlotsPopup.svelte b/plugins/process-resources/src/components/settings/ImportSlotsPopup.svelte new file mode 100644 index 0000000000..f35022de91 --- /dev/null +++ b/plugins/process-resources/src/components/settings/ImportSlotsPopup.svelte @@ -0,0 +1,241 @@ + + + + +
+ {#each Object.entries(requiredSlots) as [id, slot]} +
+ {#if slot.label} +
+ {/each} +
+
diff --git a/plugins/process-resources/src/components/settings/ProcessAttributeEditor.svelte b/plugins/process-resources/src/components/settings/ProcessAttributeEditor.svelte index 351dcf4a99..7ef5bb0ebf 100644 --- a/plugins/process-resources/src/components/settings/ProcessAttributeEditor.svelte +++ b/plugins/process-resources/src/components/settings/ProcessAttributeEditor.svelte @@ -46,7 +46,10 @@ $: value = object[objectKey ?? key] - $: attribute = hierarchy.getAttribute(_class, key) + $: slot = process.requiredSlots?.[key] as any + $: attribute = slot + ? ({ name: key, label: slot.label, _id: key, type: slot } as any) + : hierarchy.getAttribute(_class, key) $: presenterClass = getAttributePresenterClass(hierarchy, attribute.type) $: context = getContext(client, process, presenterClass.attrClass, presenterClass.category, attribute._id, true) diff --git a/plugins/process-resources/src/components/settings/ProcessEditor.svelte b/plugins/process-resources/src/components/settings/ProcessEditor.svelte index 735ec3928c..3716151c9c 100644 --- a/plugins/process-resources/src/components/settings/ProcessEditor.svelte +++ b/plugins/process-resources/src/components/settings/ProcessEditor.svelte @@ -19,7 +19,9 @@ import { clearSettingsStore, settingsStore } from '@hcengineering/setting-resources' import { ButtonIcon, + ButtonMenu, defineSeparators, + DropdownIntlItem, EditBox, getCurrentLocation, IconDelete, @@ -28,13 +30,15 @@ navigate, Scroller, secondNavSeparators, - showPopup + showPopup, + IconLink } from '@hcengineering/ui' import { exportProcess } from '../../exporter' import view from '@hcengineering/view' import { createEventDispatcher } from 'svelte' import process from '../../plugin' import ContextEditor from './ContextEditor.svelte' + import BindingsEditor from './BindingsEditor.svelte' import Navigator from './Navigator.svelte' import ProcesssSetting from './ProcesssSetting.svelte' import StatesInlineEditor from './StatesInlineEditor.svelte' @@ -137,10 +141,37 @@ showPopup(ProcesssSetting, { value }) } - function handleExport (): void { + function handleBindings (): void { + showPopup(BindingsEditor, { process: value }) + } + + const EXPORT_WITH_SLOTS = 'with-slots' + const EXPORT_WITHOUT_SLOTS = 'without-slots' + + let exportItems: DropdownIntlItem[] + $: exportItems = [ + { + id: EXPORT_WITH_SLOTS, + label: process.string.ExportWithSlots + }, + { + id: EXPORT_WITHOUT_SLOTS, + label: process.string.ExportWithoutSlots + } + ] + + function onExportSelected (event: CustomEvent): void { + if (event.detail === EXPORT_WITH_SLOTS) { + handleExport(true) + } else if (event.detail === EXPORT_WITHOUT_SLOTS) { + handleExport(false) + } + } + + function handleExport (withSlots: boolean): void { if (value === undefined) return const str = JSON.stringify( - exportProcess(value).docs.map((doc) => { + exportProcess(value, withSlots).docs.map((doc) => { const { modifiedBy, modifiedOn, createdBy, createdOn, ...rest } = doc return rest }) @@ -171,13 +202,24 @@ placeholder={process.string.Untitled} />
+ {#if value.requiredSlots && Object.keys(value.requiredSlots).length > 0} + + {/if} - !keys.includes(attr.name)) + $: slots = Object.entries(process.requiredSlots ?? {}).map(([id, s]) => ({ + name: id, + label: (s as any).label, + isSlot: true + })) + $: possibleAttrs = [...allAttrs, ...slots].filter((attr) => !keys.includes(attr.name)) function addKey (key: string): void { keys = [...keys, key] diff --git a/plugins/process-resources/src/exporter.ts b/plugins/process-resources/src/exporter.ts index 42d1bdfea3..c91fbbfacd 100644 --- a/plugins/process-resources/src/exporter.ts +++ b/plugins/process-resources/src/exporter.ts @@ -1,84 +1,110 @@ -import { type MasterTag } from '@hcengineering/card' -import card from '@hcengineering/card' +import card, { type MasterTag } from '@hcengineering/card' import core, { - type Doc, - type Ref, - type Class, - type EnumOf, - type RefTo, + type AnyAttribute, type ArrOf, - type ModelDb, + type Association, + type Attribute, + type Class, + type Doc, + type EnumOf, + generateId, type Hierarchy, + type ModelDb, + type Ref, + type RefTo, type Type } from '@hcengineering/core' import { getClient } from '@hcengineering/presentation' +import { type AttributeSlotModel, type Process, type SlotModel, type Transition } from '@hcengineering/process' import { deepEqual } from 'fast-equals' -import { type Process, type Transition } from '@hcengineering/process' import processPlugin from './plugin' -export function exportProcesses (_id: Ref): { +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface ExportResult { docs: Doc[] required: Array>> -} { - const docs: Doc[] = [] - const required: Array>> = [] - const client = getClient() - const m = client.getModel() - const processes = m.findAllSync(processPlugin.class.Process, { masterTag: _id }) - for (const proc of processes) { - const res = exportProcess(proc) - docs.push(...res.docs) - for (const req of res.required) { - if (!required.includes(req)) required.push(req) - } - } - return { docs, required } } -export function exportProcess (proc: Doc): { - docs: Doc[] - required: Array>> -} { - const docs: Doc[] = [proc] - const required: Array>> = [] - const client = getClient() - const m = client.getModel() - const h = client.getHierarchy() +export type { AttributeSlotModel, SlotModel } - docs.push(...m.findAllSync(processPlugin.class.State, { process: proc._id as any })) - const transitions = m.findAllSync(processPlugin.class.Transition, { process: proc._id as any }) as Transition[] - docs.push(...transitions) +type DetailedSlotModel = AttributeSlotModel | SlotModel - const processDoc = proc as Process +// ─── Helpers ───────────────────────────────────────────────────────────────── - for (const tr of transitions) { - processParams(tr.triggerParams, processDoc.masterTag, docs, required, m, h) - for (const action of tr.actions) { - processParams(action.params, processDoc.masterTag, docs, required, m, h) - if (action.results !== undefined) { - for (const res of action.results) { - processType(res.type, docs, required, m, h) - } - } - } - } +/** Normalizes a dot-separated platform ID to colon-separated form. */ +const normalizeId = (id: string | undefined): string | undefined => id?.replace(/\./g, ':') - if (processDoc.context !== undefined) { - for (const key in processDoc.context) { - const ctx = processDoc.context[key as any] - if (ctx.type !== undefined) { - processType(ctx.type, docs, required, m, h) - } - } - } - - if (processDoc.resultType !== undefined) { - processType(processDoc.resultType, docs, required, m, h) - } - - return { docs, required } +/** Checks if a normalized ID belongs to a system namespace (core or process). */ +function isSystemId (id: string): boolean { + return id.startsWith('core:class:') || id.startsWith('process:class:') } +function stripData ( + doc: T +): Omit { + const { _id, _class, space, modifiedBy, modifiedOn, createdBy, createdOn, ...rest } = doc + return rest +} + +// ─── Slot Factories ────────────────────────────────────────────────────────── + +function attributeToSlot (attr: AnyAttribute, memberOf?: string): AttributeSlotModel { + return { + slotKind: 'attribute', + _class: attr._class, + label: attr.label, + name: attr.name, + memberOf, + type: attr.type + } +} + +function classToSlot (cls: Class, memberOf?: string): SlotModel { + return { + slotKind: 'class', + _class: cls._class, + label: cls.label, + memberOf + } +} + +function associationToSlot (assoc: Association, direction: string | undefined, memberOf?: string): SlotModel { + let name = (assoc as any).name ?? assoc.nameA + if (direction === 'B') name = assoc.nameB + else if (direction === 'A') name = assoc.nameA + return { + slotKind: 'association', + _class: assoc._class, + label: (assoc as any).label, + name, + memberOf + } +} + +function processToSlot (proc: Process, memberOf?: string): SlotModel { + return { + slotKind: 'process', + _class: proc._class, + label: (proc as any).label, + name: proc.name, + memberOf + } +} + +function unknownToSlot (id: string, memberOf?: string): SlotModel { + return { + slotKind: 'unknown', + _class: core.class.Obj as any, + label: id as any, + name: id, + memberOf + } +} + +// ─── Type & Param Processing ───────────────────────────────────────────────── + +/** Collects type-level dependencies: enum docs and required Card-derived classes. */ function processType (type: Type, docs: Doc[], required: Array>>, m: ModelDb, h: Hierarchy): void { if (type._class === core.class.EnumOf) { const enumRef = (type as EnumOf).of @@ -94,11 +120,11 @@ function processType (type: Type, docs: Doc[], required: Array).of - processType(of, docs, required, m, h) + processType((type as ArrOf).of, docs, required, m, h) } } +/** Recursively scans action/trigger params for type dependencies. */ function processParams ( params: Record | undefined, masterTag: Ref, @@ -122,27 +148,180 @@ function processParams ( } continue } - const attr = h.findAttribute(masterTag, key) + const attr = h.findAttribute(masterTag as any, key) if (attr !== undefined) { processType(attr.type, docs, required, m, h) } + // Also scan values for DSL + const val = params[key] + if (typeof val === 'string') { + scanDSL(val, masterTag, docs, required, m, h) + } } } -export async function importProcess (masterTag: Ref, json: string): Promise { +/** Scans DSL expressions for attribute and object references. */ +function scanDSL ( + dsl: string, + masterTag: Ref, + docs: Doc[], + required: Array>>, + model: ModelDb, + h: Hierarchy +): void { + // Detect @attr references + for (const m of dsl.matchAll(/\$\{@([a-zA-Z0-9_]+)/g)) { + const attr = h.findAttribute(masterTag as any, m[1]) + if (attr !== undefined) processType(attr.type, docs, required, model, h) + } + // Detect inline 24-char hex IDs + for (const match of dsl.matchAll(/[0-9a-fA-F]{24}/g)) { + const obj = h.findAttribute(masterTag as any, match[0]) ?? model.findObject(match[0] as any) + if ((obj as any)?.type !== undefined) { + processType((obj as any).type, docs, required, model, h) + } + } +} + +// ─── Export API ─────────────────────────────────────────────────────────────── + +/** Exports all processes associated with a MasterTag. */ +export function exportProcesses (_id: Ref): ExportResult { + const docs: Doc[] = [] + const required: Array>> = [] + const client = getClient() + const m = client.getModel() + const processes = m.findAllSync(processPlugin.class.Process, { masterTag: _id }) + for (const proc of processes) { + const res = exportProcess(proc, false) + docs.push(...res.docs) + for (const req of res.required) { + if (!required.includes(req)) required.push(req) + } + } + return { docs, required } +} + +/** + * Exports a single process into a portable template. + * + * Collects the process doc, its states, transitions, and all type dependencies. + * Detects environment-specific references and replaces them with named slots. + * Returns normalized docs where all IDs are replaced with placeholders. + */ +export function exportProcess (proc: Process, withSlots: boolean = true): ExportResult { + const docs: Doc[] = [proc] + const required: Array>> = [] + const client = getClient() + const m = client.getModel() + const h = client.getHierarchy() + + // Collect states and transitions + docs.push(...m.findAllSync(processPlugin.class.State, { process: proc._id })) + const transitions = m.findAllSync(processPlugin.class.Transition, { process: proc._id }) + docs.push(...transitions) + + // Process type dependencies from transitions + for (const tr of transitions) { + processParams(tr.triggerParams, proc.masterTag, docs, required, m, h) + for (const action of tr.actions) { + processParams(action.params, proc.masterTag, docs, required, m, h) + if (action.results !== undefined) { + for (const res of action.results) { + processType(res.type, docs, required, m, h) + } + } + } + } + + // Process type dependencies from context + if (proc.context !== undefined) { + for (const key in proc.context) { + const ctx = proc.context[key as any] + if (ctx.type !== undefined) { + processType(ctx.type, docs, required, m, h) + } + } + } + + if (proc.resultType !== undefined) { + processType(proc.resultType, docs, required, m, h) + } + + if (withSlots) { + // Detect slots and bindings + const requiredSlots: Record = {} + const bindings: Record = {} + detectSlots(proc, transitions, requiredSlots, bindings, m, h) + + // Attach slots/bindings to the process doc for serialization + const clonedProc = { ...proc, requiredSlots, bindings } + const docIndex = docs.findIndex((d) => d._id === proc._id) + if (docIndex !== -1) docs[docIndex] = clonedProc + + // Process type dependencies from detected slots + for (const slotId in requiredSlots) { + const slot = requiredSlots[slotId] + if (slot.slotKind === 'attribute' && (slot as AttributeSlotModel).type != null) { + processType((slot as AttributeSlotModel).type, docs, required, m, h) + } else if (slot.slotKind === 'class' || slot.slotKind === 'process') { + if (h.isDerived(slot._class, card.class.Card) && !required.includes(slot._class)) { + required.push(slot._class) + } + } + } + } else { + const { requiredSlots, bindings, ...restProc } = proc as any + const docIndex = docs.findIndex((d) => d._id === proc._id) + if (docIndex !== -1) docs[docIndex] = restProc + } + + return { docs: normalizeIds(docs), required } +} + +// ─── Import API ────────────────────────────────────────────────────────────── + +/** + * Extracts slot requirements from an exported process JSON. + * Returns the slot definitions if the process has any, for use in binding UI. + */ +export function getRequiredSlots (json: string): Record | undefined { try { - const rawData = JSON.parse(json) as Doc[] - const data = Array.from(new Map(rawData.map((d) => [d._id, d])).values()) + const data = JSON.parse(json) as Doc[] + const proc = data.find((d: any) => d._class === processPlugin.class.Process) + return (proc as any)?.requiredSlots + } catch (e) { + return undefined + } +} + +/** + * Imports a process template into the current environment. + * + * Denormalizes placeholder IDs back to real IDs, applies slot bindings, + * and creates all documents (process, states, transitions) in the model. + */ +export async function importProcess ( + masterTag: Ref, + json: string, + bindings?: Record +): Promise { + try { + const data = JSON.parse(json) as Doc[] if (data.length === 0) return + const denormalizedData = denormalizeIds(data, masterTag, bindings ?? {}) const client = getClient() const m = client.getModel() const apply = client.apply('Import process') - for (const elem of data) { + for (const elem of denormalizedData) { if (elem._class === processPlugin.class.Process) { ;(elem as any).masterTag = masterTag + if (bindings !== undefined) { + ;(elem as any).bindings = bindings + } + // Strip template metadata — slots will be re-detected for the new environment + delete (elem as any).requiredSlots } - } - for (const elem of data) { const existing = m.findObject(elem._id) if (existing !== undefined) { const newData = stripData(elem) @@ -160,9 +339,543 @@ export async function importProcess (masterTag: Ref, json: string): P } } -function stripData ( - doc: T -): Omit { - const { _id, _class, space, modifiedBy, modifiedOn, createdBy, createdOn, ...rest } = doc - return rest +// ─── ID Normalization ──────────────────────────────────────────────────────── + +/** + * Replaces all environment-specific IDs in exported docs with deterministic placeholders. + * + * Uses `__PROCESS__`, `__MASTER_TAG__`, `__STATE_N__`, `__TRANSITION_N__`, + * `__ACTION_N__`, `__CONTEXT_N__`, and `__SLOT_name__` patterns. + */ +export function normalizeIds (docs: Doc[]): Doc[] { + const idMap = buildNormalizationMap(docs) + + return docs.map((doc) => { + let json = JSON.stringify(doc) + // Normalize dot-separated class/method IDs to colon-separated + json = json.replace(/"([a-z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+)"/g, (_, p1) => `"${p1.replace(/\./g, ':')}"`) + + // Replace IDs sorted by length (longest first to avoid partial matches) + const sortedIds = Object.keys(idMap).sort((a, b) => b.length - a.length) + for (const id of sortedIds) { + json = replaceIdInJson(json, id, idMap[id]) + } + return JSON.parse(json) + }) +} + +/** Builds the mapping from real IDs to normalized placeholders. */ +function buildNormalizationMap (docs: Doc[]): Record { + const idMap: Record = {} + let stateCount = 0 + let transitionCount = 0 + let contextCount = 0 + let actionCount = 0 + + for (const doc of docs) { + if (doc._class === processPlugin.class.Process) { + idMap[doc._id] = '__PROCESS__' + const proc = doc as any + if (proc.masterTag !== undefined) idMap[proc.masterTag] = '__MASTER_TAG__' + if (proc.context !== undefined) { + for (const contextId in proc.context) { + idMap[contextId] = `__CONTEXT_${contextCount++}__` + } + } + if (proc.bindings !== undefined) { + for (const [slotId, attrId] of Object.entries(proc.bindings)) { + if (typeof attrId === 'string' && attrId.length > 0) { + idMap[attrId] = `__SLOT_${slotId}__` + } + } + } + } else if (doc._class === processPlugin.class.State) { + idMap[doc._id] = `__STATE_${stateCount++}__` + } else if (doc._class === processPlugin.class.Transition) { + idMap[doc._id] = `__TRANSITION_${transitionCount++}__` + const tr = doc as Transition + for (const action of tr.actions) { + idMap[action._id] = `__ACTION_${actionCount++}__` + if (action.context !== undefined && action.context !== null) { + if (idMap[action.context._id] === undefined) { + idMap[action.context._id] = `__CONTEXT_${contextCount++}__` + } + } + if (action.results !== undefined) { + for (const res of action.results) { + idMap[res._id] = `__CONTEXT_${contextCount++}__` + } + } + } + } + } + return idMap +} + +/** + * Replaces an ID within a JSON string using an appropriate strategy: + * - 24/30-char IDs: global split/join (safe for hex/custom IDs) + * - Shorter IDs: surgical regex to avoid replacing JSON keys + */ +function replaceIdInJson (json: string, id: string, replacement: string): string { + if (id.length === 24 || id.length === 30) { + return json.split(id).join(replacement) + } + const escapedId = id.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') + const regex = new RegExp( + `(?<=[@(\\s,])\\b${escapedId}\\b|\\b${escapedId}\\b(?=[)\\s,])|(?<=:\\s?")${escapedId}(?=")`, + 'g' + ) + return json.replace(regex, replacement) +} + +/** + * Replaces placeholder IDs with real IDs for the target environment. + * Generates fresh IDs for states, transitions, actions, and contexts. + * Bindings map slot placeholders to their actual bound entity IDs. + */ +export function denormalizeIds (docs: Doc[], masterTag: Ref, bindings?: Record): Doc[] { + const idMap: Record = { + __PROCESS__: generateId(), + __MASTER_TAG__: masterTag as string + } + + // Map slot placeholders to bound IDs + if (bindings !== undefined) { + for (const [slotId, attrId] of Object.entries(bindings)) { + idMap[`__SLOT_${slotId}__`] = attrId + } + } + + // Discover all remaining placeholders and assign fresh IDs + const findPlaceholders = (val: any): void => { + if (typeof val === 'string') { + for (const m of val.matchAll(/(__[a-zA-Z0-9_]+__)/g)) { + if (idMap[m[1]] === undefined) { + idMap[m[1]] = generateId() + } + } + } else if (Array.isArray(val)) { + val.forEach(findPlaceholders) + } else if (typeof val === 'object' && val !== null) { + for (const k of Object.keys(val)) { + findPlaceholders(k) + findPlaceholders(val[k]) + } + } + } + docs.forEach(findPlaceholders) + + // Replace all placeholders (longest first) + const sortedPlaceholders = Object.keys(idMap).sort((a, b) => b.length - a.length) + return docs.map((doc) => { + let json = JSON.stringify(doc) + for (const placeholder of sortedPlaceholders) { + json = json.split(placeholder).join(idMap[placeholder]) + } + const denormalized = JSON.parse(json) + if (denormalized._class === processPlugin.class.Process) { + denormalized.masterTag = masterTag + } + return denormalized + }) +} + +// ─── Slot Detection ────────────────────────────────────────────────────────── + +/** + * System parameters for each method that should NOT be treated as user-bound slots. + * Keyed by normalized method ID. + */ +function getMethodSystemParams (methodId: string): string[] { + const normalized = normalizeId(methodId) + if (normalized === normalizeId(processPlugin.method.RunSubProcess)) return ['_id', 'card', 'context'] + if (normalized === normalizeId(processPlugin.method.AddRelation)) return ['_id', 'card', 'direction'] + if (normalized === normalizeId(processPlugin.method.AddTag)) return ['_id', 'card'] + if (normalized === normalizeId(processPlugin.method.CreateToDo)) return ['state', 'title', 'user', 'withRollback'] + if (normalized === normalizeId(processPlugin.method.CreateCard)) return ['_class', 'title'] + return [] +} + +/** Extracts all potential entity IDs from a DSL string value. */ +function extractPotentialIds (val: string): Set { + const ids = new Set() + // DSL attribute references: ${@attrName ...} + for (const m of val.matchAll(/\$\{@([a-zA-Z0-9_]+)/g)) ids.add(m[1]) + // Document IDs: 24-char hex or 30-char custom + for (const m of val.matchAll(/\b(?:custom)?[0-9a-zA-Z]{24,30}\b/g)) ids.add(m[0]) + // Already-normalized slot references + if (val.startsWith('__SLOT_')) ids.add(val) + // Fallback: entire value if it looks like a simple identifier + if (val.length > 0 && !val.includes(' ') && !val.includes('$')) ids.add(val) + return ids +} + +/** + * Determines the active MasterTag context for an action step. + * Uses explicit context, method-specific logic, or falls back to the process MasterTag. + */ +function resolveActionContext ( + step: any, + contextClasses: Record>, + masterTag: Ref +): Ref { + const methodId = normalizeId(step.methodId) + + // Explicit context + if (step.context?._class !== undefined) return step.context._class + + // CreateCard provides the target class directly + if (methodId === normalizeId(processPlugin.method.CreateCard) && step.params?._class !== undefined) { + return step.params._class + } + + // AddRelation/RunSubProcess derive context from $context() references + if (methodId === normalizeId(processPlugin.method.AddRelation) && step.params?._id !== undefined) { + const ctxId = step.params._id.match(/\$context\("?([a-zA-Z0-9_]+)/)?.[1] + if (ctxId !== undefined && contextClasses[ctxId] !== undefined) return contextClasses[ctxId] + } + + if (methodId === normalizeId(processPlugin.method.RunSubProcess) && step.params?.card !== undefined) { + const ctxId = step.params.card.match(/\$context\("?([a-zA-Z0-9_]+)/)?.[1] + if (ctxId !== undefined && contextClasses[ctxId] !== undefined) return contextClasses[ctxId] + } + + return masterTag +} + +/** + * Resolves the memberOf slot reference for a process or association whose + * target class differs from the process's masterTag. + * + * If the target class is foreign, creates a parent class slot and returns + * its `__SLOT_name__` reference for use as `memberOf`. + */ +function resolveParentSlot ( + targetTag: Ref, + masterTag: Ref, + memberOfRef: string | undefined, + getOrAddSlot: (id: string, model: DetailedSlotModel) => string, + m: ModelDb +): string | undefined { + if (targetTag === undefined || targetTag === '' || normalizeId(targetTag) === normalizeId(masterTag)) { + return memberOfRef + } + const targetDoc = m.findObject(targetTag) as Class | undefined + const parentSlotId = getOrAddSlot( + targetTag, + targetDoc !== undefined ? classToSlot(targetDoc) : unknownToSlot(targetTag) + ) + return `__SLOT_${parentSlotId}__` +} + +/** + * Scans all transitions to detect environment-specific references + * (attributes, associations, sub-processes) and registers them as named slots. + * + * Populates `slots` with slot metadata and `bindings` with the mapping + * from slot names to actual entity IDs. + */ +export function detectSlots ( + proc: Process, + transitions: Transition[], + slots: Record, + bindings: Record, + m: ModelDb, + h: Hierarchy +): void { + const masterTag = proc.masterTag + const attrToSlot: Record = {} + const contextClasses: Record> = {} + + // Initialize context classes from process context + if (proc.context !== undefined) { + for (const [id, ctx] of Object.entries(proc.context)) { + if (ctx.type?._class === core.class.RefTo) { + contextClasses[id] = (ctx.type as any).to + } + } + } + + // Initialize reverse mapping from existing bindings + for (const [slotId, attrId] of Object.entries(bindings)) { + attrToSlot[attrId] = slotId + } + + // ── Slot Registration ──────────────────────── + + /** Registers or updates a slot. Returns the slot name. */ + const getOrAddSlot = (id: string, model: DetailedSlotModel): string => { + let slotName = attrToSlot[id] + + // Generate a new slot name if this ID hasn't been seen + if (slotName === undefined) { + slotName = + model.name ?? (model.label !== undefined && typeof model.label === 'string' ? model.label : undefined) ?? id + if (slotName.startsWith('custom') || Object.values(attrToSlot).includes(slotName)) { + slotName = `slot${Object.keys(attrToSlot).length + 1}` + } + attrToSlot[id] = slotName + + const normalizedId = normalizeId(id) ?? '' + if (!id.startsWith('__SLOT_') && !isSystemId(normalizedId)) { + bindings[slotName] = id + } + } + + // Don't overwrite concrete metadata with unknown + const existingSlot = slots[slotName] + if (existingSlot !== undefined && model.slotKind === 'unknown') return slotName + + const meta: SlotModel = existingSlot ?? { + slotKind: model.slotKind, + _class: model._class + } + + // Upgrade unknown → concrete + if (meta.slotKind === 'unknown' || meta.slotKind === undefined) { + meta.slotKind = model.slotKind + } + + // Merge type-specific metadata + if (model.slotKind === 'attribute') { + mergeAttributeSlotMeta(meta, model as AttributeSlotModel) + } else { + mergeNonAttributeSlotMeta(meta, model) + } + + // Set memberOf unless it would be self-referential or undefined + const selfRef = `__SLOT_${slotName}__` + if (model.memberOf !== undefined && model.memberOf !== selfRef) { + meta.memberOf = model.memberOf + } + + slots[slotName] = meta + return slotName + } + + // ── Param Scanning ─────────────────────────── + + /** Scans action/trigger params to find slottable references. */ + const scanParams = ( + params: Record | undefined, + activeTag: Ref, + requiredParams: string[] = [] + ): void => { + if (params === undefined) return + + // Ensure the active tag itself is registered as a slot if non-system & non-master + const nTag = normalizeId(activeTag) ?? '' + if (activeTag !== masterTag && attrToSlot[activeTag] === undefined && !isSystemId(nTag)) { + const tagDoc = m.findObject(activeTag) as any + if (tagDoc !== undefined) { + if (normalizeId(tagDoc._class) === normalizeId(core.class.Attribute)) { + getOrAddSlot(activeTag, attributeToSlot(tagDoc)) + } else { + getOrAddSlot(activeTag, classToSlot(tagDoc)) + } + } else { + getOrAddSlot(activeTag, unknownToSlot(activeTag)) + } + } + + const memberOf = activeTag !== masterTag ? attrToSlot[activeTag] : undefined + const memberOfRef = memberOf !== undefined && memberOf !== '' ? `__SLOT_${memberOf}__` : undefined + + for (const key in params) { + const val = params[key] + // Scan keys as potential attribute references + scanParamKey(key, val, activeTag, masterTag, memberOfRef, requiredParams, h, getOrAddSlot, m) + + // Scan values for entity references + if (typeof val === 'string') { + scanParamValue(val, activeTag, masterTag, memberOfRef, params.direction, slots, getOrAddSlot, m, h) + } else if (typeof val === 'object' && val !== null) { + scanParams(val, activeTag) + } + } + } + + // ── Main Loop ──────────────────────────────── + + for (const tr of transitions) { + const triggerDoc = m.findObject(tr.trigger) as any + const triggerRequired = triggerDoc?.requiredParams ?? [] + scanParams(tr.triggerParams, masterTag, triggerRequired) + + for (const step of tr.actions) { + const methodDoc = m.findObject(step.methodId) + const methodRequired = methodDoc?.requiredParams ?? [] + + // Register result contexts for subsequent action resolution + if (step.results !== undefined) { + for (const res of step.results) { + if (res.type?._class === core.class.RefTo) { + contextClasses[res._id] = (res.type as RefTo).to + } + } + } + + const activeTag = resolveActionContext(step, contextClasses, masterTag) + if (step.context?._id != null) { + contextClasses[step.context._id] = activeTag + } + + const sysParams = getMethodSystemParams(step.methodId) + scanParams(step.params, activeTag, [...methodRequired, ...sysParams]) + } + } +} + +// ─── Slot Detection Helpers ────────────────────────────────────────────────── + +/** Merges attribute-specific metadata into a slot. */ +function mergeAttributeSlotMeta (meta: SlotModel, model: AttributeSlotModel): void { + if (typeof model.type === 'object' && model.type !== null) { + ;(meta as any).type = model.type + meta._class = model.type._class + } else if (typeof model.type === 'string') { + meta._class = model.type as any + } + if (model.label !== undefined) meta.label = model.label +} + +/** Merges non-attribute (process/association/class) metadata into a slot. */ +function mergeNonAttributeSlotMeta (meta: SlotModel, model: DetailedSlotModel): void { + const currentClass = normalizeId(meta._class) + const isStrongType = + currentClass === normalizeId(processPlugin.class.Process) || currentClass === normalizeId(core.class.Association) + + if (!isStrongType || model.slotKind === 'process' || model.slotKind === 'association') { + meta._class = model._class + if (model.label !== undefined) meta.label = model.label + } + if ( + model.name !== undefined && + model.name !== '' && + (model.slotKind === 'process' || model.slotKind === 'association') + ) { + meta.name = model.name + } +} + +/** Checks if a value is contextual/dynamic (e.g. contains DSL, is an ID, or nested object). */ +function isContextualValue ( + val: any, + activeTag: Ref, + masterTag: Ref, + h: Hierarchy, + m: ModelDb +): boolean { + if (typeof val === 'string') { + if (val.includes('${')) return true + if (val.startsWith('__SLOT_')) return true + const potentialIds = extractPotentialIds(val) + for (const id of potentialIds) { + const obj = h.findAttribute(activeTag, id) ?? h.findAttribute(masterTag, id) ?? m.findObject(id as any) + if (obj !== undefined) return true + } + } + if (typeof val === 'object' && val !== null) { + return true + } + return false +} + +/** Checks if a param key represents a slottable attribute. */ +function scanParamKey ( + key: string, + val: any, + activeTag: Ref, + masterTag: Ref, + memberOfRef: string | undefined, + requiredParams: string[], + h: Hierarchy, + getOrAddSlot: (id: string, model: DetailedSlotModel) => string, + m: ModelDb +): void { + if (key.startsWith('$') || key === '_id' || key === '_class' || requiredParams.includes(key)) return + + if (typeof val === 'string' && val.includes('$userRequest')) { + return + } + + const keyAttr = (h.findAttribute(activeTag, key) ?? h.findAttribute(masterTag, key)) as Attribute | undefined + if (keyAttr !== undefined) { + // For system attributes, only slotify if the value is contextual/dynamic + const parentClassId = normalizeId(keyAttr.attributeOf) ?? '' + if (isSystemId(parentClassId) && !isContextualValue(val, activeTag, masterTag, h, m)) { + return + } + getOrAddSlot(key, attributeToSlot(keyAttr, memberOfRef)) + } else if (key.length >= 24) { + getOrAddSlot(key, unknownToSlot(key, memberOfRef)) + } +} + +/** Scans a string param value for entity references and registers them as slots. */ +function scanParamValue ( + val: string, + activeTag: Ref, + masterTag: Ref, + memberOfRef: string | undefined, + direction: string | undefined, + slots: Record, + getOrAddSlot: (id: string, model: DetailedSlotModel) => string, + m: ModelDb, + h: Hierarchy +): void { + const potentialIds = extractPotentialIds(val) + + for (const id of potentialIds) { + const obj = h.findAttribute(activeTag, id) ?? h.findAttribute(masterTag, id) ?? m.findObject(id as any) + + if (obj === undefined) { + // Handle already-normalized slot references + if (id.startsWith('__SLOT_')) { + const slotName = id.replace(/^__SLOT_(.+)__$/, '$1') + const existing = slots[slotName] + if (existing !== undefined) { + const slotModel: SlotModel = { ...existing, name: slotName, memberOf: memberOfRef } + getOrAddSlot(id, slotModel) + } + } + continue + } + + classifyAndRegisterSlot(id, obj, direction, memberOfRef, masterTag, getOrAddSlot, m, h) + } +} + +/** + * Classifies a resolved object by its type and registers it as the appropriate slot kind. + */ +function classifyAndRegisterSlot ( + id: string, + obj: Doc, + direction: string | undefined, + memberOfRef: string | undefined, + masterTag: Ref, + getOrAddSlot: (id: string, model: DetailedSlotModel) => string, + m: ModelDb, + h: Hierarchy +): void { + const classId = normalizeId(obj._class) + + if (classId === normalizeId(processPlugin.class.Process)) { + const subProc = obj as any as Process + const procMemberOf = resolveParentSlot(subProc.masterTag, masterTag, memberOfRef, getOrAddSlot, m) + getOrAddSlot(id, processToSlot(subProc, procMemberOf)) + } else if (classId === normalizeId(core.class.Association)) { + const assoc = obj as any as Association + const targetClass = direction === 'A' ? assoc.classA : assoc.classB + const assocMemberOf = resolveParentSlot(targetClass, masterTag, memberOfRef, getOrAddSlot, m) + getOrAddSlot(id, associationToSlot(assoc, direction, assocMemberOf)) + } else if (classId === normalizeId(core.class.Attribute)) { + getOrAddSlot(id, attributeToSlot(obj as Attribute, memberOfRef)) + } else if (classId === normalizeId(core.class.Class) || h.isDerived(obj._class, core.class.Class)) { + getOrAddSlot(id, classToSlot(obj as any as Class, memberOfRef)) + } else { + getOrAddSlot(id, unknownToSlot(id, memberOfRef)) + } } diff --git a/plugins/process-resources/src/plugin.ts b/plugins/process-resources/src/plugin.ts index de5dcd33bc..2660e72719 100644 --- a/plugins/process-resources/src/plugin.ts +++ b/plugins/process-resources/src/plugin.ts @@ -266,6 +266,8 @@ export default mergeIds(processId, process, { LockField: '' as IntlString, UnlockField: '' as IntlString, Export: '' as IntlString, + ExportWithSlots: '' as IntlString, + ExportWithoutSlots: '' as IntlString, Import: '' as IntlString, TextFromIdentifier: '' as IntlString, TextFromNumber: '' as IntlString, @@ -283,7 +285,9 @@ export default mergeIds(processId, process, { DateDifference: '' as IntlString, TextFromSelect: '' as IntlString, SelectFromText: '' as IntlString, - AskSubclass: '' as IntlString + AskSubclass: '' as IntlString, + RequiredSlots: '' as IntlString, + Bindings: '' as IntlString }, permission: { RunProcess: '' as Ref, diff --git a/plugins/process-resources/src/utils.ts b/plugins/process-resources/src/utils.ts index 61b539d1d3..cdf3d0d9ac 100644 --- a/plugins/process-resources/src/utils.ts +++ b/plugins/process-resources/src/utils.ts @@ -63,13 +63,18 @@ import { showPopup } from '@hcengineering/ui' import { type AttributeCategory } from '@hcengineering/view' import process from './plugin' -export function isTypeEqual (toCheck: Type | undefined, attr: Type): boolean { - const skip = ['label', 'icon', 'hidden', 'readonly'] +export function isTypeEqual (toCheck: any | undefined, attr: Type): boolean { if (toCheck === undefined) return true - if (Object.keys(attr).length !== Object.keys(toCheck).length) return true - for (const key of Object.keys(attr)) { - if (skip.includes(key)) continue - if (toCheck[key as keyof Type] !== attr[key as keyof Type]) return false + const check = toCheck.type !== undefined ? toCheck.type : toCheck + if (check._class !== attr._class) return false + if (check._class === core.class.RefTo) { + return (check as RefTo).to === (attr as RefTo).to + } + if (check._class === core.class.ArrOf) { + return isTypeEqual((check as ArrOf).of, (attr as ArrOf).of) + } + if (check._class === core.class.EnumOf) { + return check.of === (attr as any).of } return true } diff --git a/plugins/process/src/index.ts b/plugins/process/src/index.ts index c1147efca7..370a96b17b 100644 --- a/plugins/process/src/index.ts +++ b/plugins/process/src/index.ts @@ -24,6 +24,19 @@ import { SelectedExecutionContext } from './types' */ export const processId = 'process' as Plugin +export interface SlotModel { + slotKind: 'attribute' | 'class' | 'association' | 'process' | 'unknown' + _class: Ref> + label?: IntlString + name?: string + memberOf?: string +} + +export interface AttributeSlotModel extends SlotModel { + slotKind: 'attribute' + type: Type +} + // Process model dscription export interface Process extends Doc { masterTag: Ref @@ -34,6 +47,8 @@ export interface Process extends Doc { automationOnly?: boolean context: Record resultType?: Type + requiredSlots?: Record + bindings?: Record } export interface ProcessContext { diff --git a/server-plugins/process-resources/src/functions.ts b/server-plugins/process-resources/src/functions.ts index 0797c99114..de57b5e647 100644 --- a/server-plugins/process-resources/src/functions.ts +++ b/server-plugins/process-resources/src/functions.ts @@ -50,6 +50,7 @@ import process, { import { ExecuteResult, ProcessControl, SuccessExecutionContext } from '@hcengineering/server-process' import { isEmptyMarkup } from '@hcengineering/text-core' import time, { ToDoPriority } from '@hcengineering/time' +import { resolveAttributeId } from './utils' function checkResult (execution: Execution, results: Record | undefined): boolean { if (results === undefined) return true @@ -148,16 +149,23 @@ export function MatchCardCheck ( if (card === undefined) return false const process = control.client.getModel().findObject(execution.process) if (process === undefined) return false - const markup = getMarkupParams(process, params, control) + const h = control.client.getHierarchy() if (h.isMixin(process.masterTag)) { card = h.as(card, process.masterTag) } + + const resolvedParams: Record = {} + for (const key in params) { + resolvedParams[resolveAttributeId(process, key)] = params[key] + } + const markup = getMarkupParams(process, resolvedParams, control) + for (const key of Object.keys(markup)) { if (isEmptyMarkup(card[key])) return false } - const res = matchQuery([card], params, process.masterTag, control.client.getHierarchy(), true) + const res = matchQuery([card], resolvedParams, process.masterTag, control.client.getHierarchy(), true) return res.length > 0 } @@ -189,23 +197,25 @@ export function FieldChangedCheck ( context: Record ): boolean { if (context.card === undefined) return false - const process = control.client.getModel().findObject(execution.process) - if (process === undefined) return false + const _process = control.client.getModel().findObject(execution.process) + if (_process === undefined) return false if (context.operations === undefined) return false const operations = context.operations as DocumentUpdate const target = Object.keys(params)[0] - if (!TxProcessor.hasUpdate(operations, target)) return false - const markup = getMarkupParams(process, params, control) const h = control.client.getHierarchy() let card = context.card - if (h.isMixin(process.masterTag)) { - card = h.as(card, process.masterTag) + if (h.isMixin(_process.masterTag)) { + card = h.as(card, _process.masterTag) } + const realTarget = resolveAttributeId(_process, target) + if (!TxProcessor.hasUpdate(operations, realTarget)) return false + const resolvedParams = { [realTarget]: params[target] } + const markup = getMarkupParams(_process, resolvedParams, control) for (const key of Object.keys(markup)) { if (isEmptyMarkup(card[key])) return false } - const res = matchQuery([card], params, process.masterTag, control.client.getHierarchy(), true) + const res = matchQuery([card], resolvedParams, _process.masterTag, control.client.getHierarchy(), true) return res.length > 0 } @@ -292,13 +302,14 @@ export async function UpdateCard ( const update: Record = {} const prevValue: Record = {} for (const key in params) { - const prevKey = checkMixinKey(key, _process.masterTag, hierarchy) - prevValue[key] = getObjectValue(prevKey, target) - const attr = hierarchy.findAttribute(_process.masterTag, key) + const realKey = resolveAttributeId(_process, key) + const prevKey = checkMixinKey(realKey, _process.masterTag, hierarchy) + prevValue[realKey] = getObjectValue(prevKey, target) + const attr = hierarchy.findAttribute(_process.masterTag, realKey) if (attr === undefined) { - update[key] = (params as any)[key] + update[realKey] = (params as any)[key] } else { - update[key] = respectAttributeType(attr.type, (params as any)[key]) + update[realKey] = respectAttributeType(attr.type, (params as any)[key]) } } @@ -492,6 +503,10 @@ export async function RequestApproval ( const group = generateId() const res: TxCreateDoc[] = [] const rollback: Tx[] = [] + const _process = control.client.getModel().findObject(execution.process) + if (_process === undefined) { + throw processError(process.error.RequiredParamsNotProvided, { params: 'user' }) + } for (const user of Array.isArray(params.user) ? params.user : [params.user]) { const id = generateId() const tx = control.client.txFactory.createTxCreateDoc( @@ -516,7 +531,7 @@ export async function RequestApproval ( results, group, actionType: params.actionType, - field: (params as any).field + field: resolveAttributeId(_process, (params as any).field) }, id ) @@ -728,6 +743,9 @@ export async function CreateToDo ( const res: Tx[] = [] const rollback: Tx[] = [] const id = generateId() + const _process = control.client.getModel().findObject(execution.process) + if (_process === undefined) return { txes: [], rollback: [], context: null } + const field = resolveAttributeId(_process, (params as any).field) const tx = control.client.txFactory.createTxCreateDoc( process.class.ProcessToDo, time.space.ToDos, @@ -747,7 +765,7 @@ export async function CreateToDo ( rank: '', withRollback: params.withRollback ?? false, results, - field: (params as any).field + field }, id ) @@ -832,13 +850,21 @@ export async function CreateCard ( throw processError(process.error.RequiredParamsNotProvided, { params: key }) } } + const _process = control.client.getModel().findObject(execution.process) + if (_process === undefined) { + throw processError(process.error.RequiredParamsNotProvided, {}) + } + const resolvedAttrs: Record = {} + for (const key in attrs) { + resolvedAttrs[resolveAttributeId(_process, key)] = (attrs as any)[key] + } const masterTag = _class as Ref const _id = generateId() const newContent = content !== undefined && !isEmpty(content) ? await getContent(control, content, _id, masterTag) : content const data = { title, - ...attrs + ...resolvedAttrs } as any if (newContent !== undefined) { data.content = newContent diff --git a/server-plugins/process-resources/src/utils.ts b/server-plugins/process-resources/src/utils.ts index bee58d3d97..138d34d475 100644 --- a/server-plugins/process-resources/src/utils.ts +++ b/server-plugins/process-resources/src/utils.ts @@ -18,6 +18,7 @@ import { getEmbeddedLabel, getResource } from '@hcengineering/platform' import process, { Execution, parseContext, + Process, ProcessError, processError, SelectedConst, @@ -67,11 +68,19 @@ export async function getContextValue (value: any, control: ProcessControl, exec } } +export function resolveAttributeId (_process: Process, key: T): T { + if (_process.bindings?.[key] !== undefined) { + return _process.bindings[key] as T + } + return key +} + function getValue (control: ProcessControl, execution: Execution, rawKey: string, card: Doc): any { const hierarchy = control.client.getHierarchy() const _process = control.client.getModel().findObject(execution.process) if (_process === undefined) throw processError(process.error.ObjectNotFound, { _id: execution.process }) - const key = checkMixinKey(rawKey, _process.masterTag, hierarchy) + const realKey = resolveAttributeId(_process, rawKey) + const key = checkMixinKey(realKey, _process.masterTag, hierarchy) return getObjectValue(key, card) } @@ -119,8 +128,11 @@ async function fillValue ( async function getNestedValue (control: ProcessControl, execution: Execution, context: SelectedNested): Promise { const card = control.cache.get(execution.card) if (card === undefined) throw processError(process.error.ObjectNotFound, { _id: execution.card }, {}, true) - const attr = control.client.getHierarchy().findAttribute(card._class, context.path) - if (attr === undefined) throw processError(process.error.AttributeNotExists, { key: context.path }) + const _process = control.client.getModel().findObject(execution.process) + if (_process === undefined) throw processError(process.error.ObjectNotFound, { _id: execution.process }) + const realPath = resolveAttributeId(_process, context.path) + const attr = control.client.getHierarchy().findAttribute(card._class, realPath) + if (attr === undefined) throw processError(process.error.AttributeNotExists, { key: realPath }) const nestedValue = getValue(control, execution, context.path, card) if (nestedValue === undefined) throw processError(process.error.EmptyAttributeContextValue, {}, { attr: attr.label }) const parentType = attr.type._class === core.class.ArrOf ? (attr.type as ArrOf).of : attr.type @@ -130,7 +142,8 @@ async function getNestedValue (control: ProcessControl, execution: Execution, co _id: { $in: refs } }) if (target.length === 0) throw processError(process.error.RelatedObjectNotFound, {}, { attr: attr.label }) - const nested = control.client.getHierarchy().findAttribute(targetClass, context.key) + const realKey = resolveAttributeId(_process, context.key) + const nested = control.client.getHierarchy().findAttribute(targetClass, realKey) if (context.sourceFunction !== undefined) { const transform = control.client.getModel().findObject(context.sourceFunction.func) if (transform === undefined) { @@ -172,7 +185,10 @@ async function getRelationValue ( execution: Execution, context: SelectedRelation ): Promise { - const assoc = control.client.getModel().findObject(context.association) + const _process = control.client.getModel().findObject(execution.process) + if (_process === undefined) throw processError(process.error.ObjectNotFound, { _id: execution.process }) + const realAssociation = resolveAttributeId(_process, context.association) + const assoc = control.client.getModel().findObject(realAssociation) if (assoc === undefined) throw processError(process.error.RelationNotExists, {}) const targetClass = context.direction === 'A' ? assoc.classA : assoc.classB const q = context.direction === 'A' ? { docB: execution.card } : { docA: execution.card } @@ -188,7 +204,8 @@ async function getRelationValue ( }) const target = await control.client.findAll(targetClass, { _id: { $in: ids } }) if (target.length === 0) throw processError(process.error.RelatedObjectNotFound, { attr: context.name }) - const attr = context.key !== '' ? control.client.getHierarchy().findAttribute(targetClass, context.key) : undefined + const realKey = resolveAttributeId(_process, context.key) + const attr = realKey !== '' ? control.client.getHierarchy().findAttribute(targetClass, realKey) : undefined if (context.sourceFunction !== undefined) { const transform = control.client.getModel().findObject(context.sourceFunction.func) if (transform === undefined) { diff --git a/templates/package/package.json b/templates/package/package.json index a40bcf04e9..8a251a1a23 100644 --- a/templates/package/package.json +++ b/templates/package/package.json @@ -26,8 +26,8 @@ "compression-webpack-plugin": "^10.0.0", "html-webpack-plugin": "^5.5.0", "fork-ts-checker-webpack-plugin": "^9.0.2", - "update-browserslist-db": "^1.1.3", - "browserslist": "^4.28.1", + "update-browserslist-db": "^1.2.3", + "browserslist": "^4.28.2", "esbuild": "^0.25.10", "esbuild-loader": "^4.3.0", "typescript": "^5.9.3", @@ -41,4 +41,4 @@ "webpack": "*" }, "#replaces": [] -} +} \ No newline at end of file