Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov
2026-06-03 03:52:47 +05:00
committed by GitHub
parent a6778992f7
commit be5d41cecc
34 changed files with 3071 additions and 192 deletions
+14 -3
View File
@@ -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"
+37 -47
View File
@@ -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
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+9 -2
View File
@@ -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<string, SlotModel>
@Prop(TypeRecord(), process.string.Bindings)
bindings?: Record<string, string>
context!: Record<ContextId, ProcessContext>
}
+5 -1
View File
@@ -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}",
+5 -1
View File
@@ -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}",
+4
View File
@@ -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",
+5 -1
View File
@@ -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}",
+5 -1
View File
@@ -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}",
+5 -1
View File
@@ -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}",
+5 -1
View File
@@ -176,7 +176,11 @@
"TextFromSelect": "テキストから選択",
"SelectFromText": "選択からテキスト",
"AskSubclass": "サブクラスを尋ねる",
"TextFromIdentifier": "テキストから識別子"
"TextFromIdentifier": "テキストから識別子",
"ExportWithSlots": "スロット付きでエクスポート",
"ExportWithoutSlots": "スロットなしでエクスポート",
"RequiredSlots": "必須スロット",
"Bindings": "バインディング"
},
"error": {
"MethodNotFound": "メソッドが見つかりません: {methodId}",
+5 -1
View File
@@ -177,7 +177,11 @@
"TextFromSelect": "선택에서 텍스트로",
"SelectFromText": "텍스트에서 선택으로",
"AskSubclass": "하위 클래스 선택",
"TextFromIdentifier": "식별자에서 텍스트로"
"TextFromIdentifier": "식별자에서 텍스트로",
"ExportWithSlots": "슬롯 포함 내보내기",
"ExportWithoutSlots": "슬롯 없이 내보내기",
"RequiredSlots": "필수 슬롯",
"Bindings": "바인딩"
},
"error": {
"MethodNotFound": "메서드를 찾을 수 없습니다: {methodId}",
+5 -1
View File
@@ -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}",
+5 -1
View File
@@ -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}",
+4
View File
@@ -151,6 +151,10 @@
"UnlockSection": "Разблокировать секцию",
"CancelToDo": "Отменить задачу",
"Export": "Экспорт",
"ExportWithSlots": "Экспортировать со слотами",
"ExportWithoutSlots": "Экспортировать без слотов",
"RequiredSlots": "Обязательные слоты",
"Bindings": "Связки",
"Import": "Импорт",
"CancelProcess": "Отменить процесс",
"SyncWithField": "Синхронизировать с полем",
+5 -1
View File
@@ -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}",
+5 -1
View File
@@ -177,7 +177,11 @@
"TextFromSelect": "选择到文本",
"SelectFromText": "文本到选择",
"AskSubclass": "询问子类",
"TextFromIdentifier": "文本从标识符"
"TextFromIdentifier": "文本从标识符",
"ExportWithSlots": "导出包含槽",
"ExportWithoutSlots": "导出不含槽",
"RequiredSlots": "必需槽",
"Bindings": "绑定"
},
"error": {
"MethodNotFound": "找不到方法:{methodId}",
File diff suppressed because it is too large Load Diff
@@ -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<Process>()
const stateId = generateId<State>()
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<void> {
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
})
})
@@ -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<Class<Obj>>, attributes: Data<Class<Obj>>): TxCreateDoc<Doc> {
return txFactory.createTxCreateDoc(core.class.Class, core.space.Model, attributes, _class)
}
export function createDoc<T extends Doc> (_class: Ref<Class<T>>, attributes: Data<T>, id?: Ref<T>): TxCreateDoc<Doc> {
return txFactory.createTxCreateDoc(_class, core.space.Model, attributes, id)
}
export function genMinModel (): Array<TxCUD<Doc>> {
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
}
@@ -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()
@@ -0,0 +1,188 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import core, { AnyAttribute, Class, Doc, Ref } from '@hcengineering/core'
import { getEmbeddedLabel, IntlString } from '@hcengineering/platform'
import presentation, { Card, getClient } from '@hcengineering/presentation'
import { Process } from '@hcengineering/process'
import { Button, eventToHTMLElement, Label, SelectPopup, showPopup } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import processPlugin from '../../plugin'
import { isTypeEqual } from '../../utils'
export let process: Process
const client = getClient()
const hierarchy = client.getHierarchy()
const model = client.getModel()
const dispatch = createEventDispatcher()
$: allAttrs = hierarchy.getAllAttributes(process.masterTag as any, core.class.Doc)
$: allAssociations = model.findAllSync(core.class.Association, {})
$: allProcesses = model.findAllSync(processPlugin.class.Process, {})
function isClassLike (obj: Doc | undefined): boolean {
if (!obj || !obj._class) return false
return hierarchy.isDerived(obj._class, core.class.Class)
}
function resolveMemberOf (memberOf: string | undefined): Ref<Class<Doc>> | undefined {
if (memberOf === undefined) return process.masterTag as any
let targetId: string | undefined
if (memberOf.startsWith('__SLOT_')) {
const parentSlotId = memberOf.replace(/^__SLOT_(.+)__$/, '$1')
targetId = process.bindings?.[parentSlotId]
if (!targetId) {
const parentObj = model.findObject(parentSlotId as any)
if (isClassLike(parentObj)) targetId = parentSlotId
}
} else {
targetId = memberOf
}
const obj = targetId ? model.findObject(targetId as any) : undefined
if (isClassLike(obj)) return targetId as Ref<Class<Doc>>
return undefined
}
async function setBinding (slotId: string, e: MouseEvent): Promise<void> {
const slot = process.requiredSlots?.[slotId]
if (slot === undefined) return
const memberOfTag = resolveMemberOf(slot.memberOf)
if (memberOfTag === undefined) return
let possible: Array<{ id: string, label?: IntlString, name?: string }> = []
if (slot.slotKind === 'association') {
const aAssociations = allAssociations
.filter((assoc) => {
return (
hierarchy.isDerived(process.masterTag as any, assoc.classA) &&
hierarchy.isDerived(memberOfTag, assoc.classB)
)
})
.map((a) => ({ id: a._id, name: a.nameA }))
const bAssociations = allAssociations
.filter((assoc) => {
return (
hierarchy.isDerived(process.masterTag as any, assoc.classB) &&
hierarchy.isDerived(memberOfTag, assoc.classA)
)
})
.map((a) => ({ id: a._id, name: a.nameB }))
possible = aAssociations.concat(bAssociations)
} else if (slot.slotKind === 'process') {
possible = allProcesses
.filter((proc) => hierarchy.isDerived(memberOfTag, proc.masterTag))
.map((p) => ({ id: p._id, name: p.name }))
} else if (slot.slotKind === 'class') {
let descendants: string[] = []
try {
descendants = hierarchy.getDescendants(core.class.Obj)
} catch (e) {}
possible = descendants
.map((id) => model.findObject(id as any))
.filter((c) => c && !(c as any).hidden && isClassLike(c))
.map((c: any) => ({ id: c._id, label: c.label, name: c.name }))
} else {
// Default: attribute matching
const attributes = hierarchy.getAllAttributes(memberOfTag, core.class.Doc)
possible = Array.from(attributes.values())
.filter((attr) => !(attr.hidden ?? false) && isTypeEqual(slot as any, attr.type))
.map((a) => ({ id: a.name, label: a.label, name: a.name }))
}
showPopup(
SelectPopup,
{
value: possible.map((p) => ({ id: p.id, label: p.label, text: p.name }))
},
eventToHTMLElement(e),
async (res) => {
if (res != null) {
const bindings = { ...(process.bindings ?? {}) }
bindings[slotId] = res as string
await client.update(process, { bindings })
}
}
)
}
function getBindingLabel (
allAttrs: Map<string, AnyAttribute>,
bindings: Record<string, string> | undefined,
id: string
): any {
if (bindings?.[id] === undefined) return presentation.string.NotSelected
const value = bindings[id]
// Search in resolved memberOf tag's attributes
const slot = process.requiredSlots?.[id]
const memberOfTag = slot?.memberOf !== undefined ? resolveMemberOf(slot.memberOf) : (process.masterTag as any)
const searchTag = memberOfTag ?? process.masterTag
try {
const attrs = hierarchy.getAllAttributes(searchTag, core.class.Doc)
const attr = Array.from(attrs.values()).find((a) => a.name === value)
if (attr !== undefined) return attr.label
} catch (e) {}
// Also check masterTag if different
if (searchTag !== process.masterTag) {
const attr = Array.from(allAttrs.values()).find((a) => a.name === value)
if (attr !== undefined) return attr.label
}
const assoc = allAssociations.find((a) => a._id === value)
if (assoc !== undefined) return getEmbeddedLabel(assoc.nameA)
const proc = allProcesses.find((p) => p._id === value)
if (proc !== undefined) return getEmbeddedLabel(proc.name)
const cls = model.findObject(value as any)
if (isClassLike(cls)) return (cls as Class<Doc>).label
return value
}
</script>
<Card
label={processPlugin.string.Bindings}
canSave={true}
width="small"
okLabel={presentation.string.Save}
okAction={() => {
dispatch('close')
}}
on:close
>
<div class="flex-column flex-gap-4">
{#each Object.entries(process.requiredSlots ?? {}) as [id, slot]}
<div class="flex-column flex-gap-1">
<Label label={slot.label ?? getEmbeddedLabel(slot.name ?? '')} />
<Button
label={getBindingLabel(allAttrs, process.bindings, id)}
disabled={slot.memberOf !== undefined && resolveMemberOf(slot.memberOf) === undefined}
kind="secondary"
width="100%"
on:click={(e) => setBinding(id, e)}
/>
</div>
{/each}
{#if Object.keys(process.requiredSlots ?? {}).length === 0}
<div class="opacity-40 text-center py-4">No slots defined for this process</div>
{/if}
</div>
</Card>
@@ -0,0 +1,241 @@
<!--
// 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.
-->
<script lang="ts">
import core, { Class, Doc, Ref } from '@hcengineering/core'
import presentation, { Card, getClient } from '@hcengineering/presentation'
import { Button, eventToHTMLElement, Label, SelectPopup, showPopup } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import type { SlotModel } from '@hcengineering/process'
import processPlugin from '../../plugin'
import { isTypeEqual } from '../../utils'
export let requiredSlots: Record<string, SlotModel>
export let masterTag: Ref<Class<any>>
const client = getClient()
const hierarchy = client.getHierarchy()
const model = client.getModel()
const dispatch = createEventDispatcher()
let bindings: Record<string, string> = {}
$: allAttrs = hierarchy.getAllAttributes(masterTag, core.class.Doc)
$: allAssociations = model.findAllSync(core.class.Association, {})
$: allProcesses = model.findAllSync(processPlugin.class.Process, {})
function isClassLike (obj: Doc | undefined): boolean {
if (!obj || !obj._class) return false
return obj._class === core.class.Class || hierarchy.isDerived(obj._class, core.class.Class)
}
function resolveMemberOf (
memberOf: string | undefined,
currentBindings: Record<string, string>
): Ref<Class<Doc>> | undefined {
if (memberOf === undefined) return masterTag
let targetId: string | undefined
if (memberOf.startsWith('__SLOT_')) {
const parentSlotId = memberOf.replace(/^__SLOT_(.+)__$/, '$1')
targetId = currentBindings[parentSlotId]
if (!targetId) {
const parentObj = model.findObject(parentSlotId as any)
if (isClassLike(parentObj)) targetId = parentSlotId
}
} else {
targetId = memberOf
}
const obj = targetId ? model.findObject(targetId as any) : undefined
if (isClassLike(obj)) return targetId as Ref<Class<Doc>>
return undefined
}
// Reactive map: recalculated every time `bindings` changes.
// Each slot gets its resolved memberOf tag and whether it's currently enabled.
$: resolvedSlots = (() => {
const result: Record<string, { resolved: Ref<Class<Doc>> | undefined, enabled: boolean }> = {}
for (const [id, slot] of Object.entries(requiredSlots)) {
const resolved = resolveMemberOf(slot.memberOf, bindings)
const enabled = slot.memberOf === undefined || resolved !== undefined
result[id] = { resolved, enabled }
}
return result
})()
function setBinding (slotId: string, e: MouseEvent): void {
const slot = requiredSlots[slotId] as any
const memberOfTag = resolvedSlots[slotId]?.resolved
if (!memberOfTag) return
let possible: Array<{ id: string, label?: any, text?: string }> = []
if (slot.slotKind === 'association' || slot._class === core.class.Association) {
possible = allAssociations
.filter((assoc) => {
const isA =
hierarchy.isDerived(masterTag, assoc.classA) &&
(memberOfTag ? hierarchy.isDerived(memberOfTag, assoc.classB) : false)
const isB =
hierarchy.isDerived(masterTag, assoc.classB) &&
(memberOfTag ? hierarchy.isDerived(memberOfTag, assoc.classA) : false)
return isA || isB
})
.map((a) => ({
id: a._id,
text: hierarchy.isDerived(a.classA, masterTag) ? a.nameA + ' -> ' + a.nameB : a.nameB + ' -> ' + a.nameA
}))
} else if (slot.slotKind === 'process' || slot._class === processPlugin.class.Process) {
possible = allProcesses
.filter((proc) => !memberOfTag || hierarchy.isDerived(memberOfTag, proc.masterTag))
.map((p) => ({
id: p._id,
label: (p as any).label,
text: p.name ?? p._id
}))
} else if (slot.slotKind === 'class' || isClassLike({ _class: slot._class } as any)) {
let descendants: string[] = []
try {
descendants = hierarchy.getDescendants(core.class.Obj)
} catch (e) {}
const expectedClass = slot._class || core.class.Class
possible = descendants
.map((id) => model.findObject(id as any))
.filter((c) => {
if (!c || (c as any).hidden) return false
const cClass = c._class
return cClass === expectedClass || hierarchy.isDerived(cClass, expectedClass)
})
.map((c: any) => ({
id: c._id,
label: c.label,
text: c.name ?? c._id
}))
} else {
const attributes = memberOfTag ? hierarchy.getAllAttributes(memberOfTag, core.class.Doc) : []
possible = Array.from(attributes.values())
.filter((attr) => !attr.hidden && isTypeEqual(slot, attr.type))
.map((a) => ({
id: a.name,
label: a.label,
text: a.name
}))
}
showPopup(
SelectPopup,
{
value: possible.map((p) => ({ id: p.id, label: p.label, text: p.label ? undefined : p.text }))
},
eventToHTMLElement(e),
(res) => {
if (res != null) {
// Clear dependent bindings when a parent slot changes
const nextBindings: Record<string, string | undefined> = { ...bindings, [slotId]: res as string }
for (const [depId, depSlot] of Object.entries(requiredSlots)) {
if (depSlot.memberOf === `__SLOT_${slotId}__`) {
nextBindings[depId] = undefined
}
}
const filteredBindings: Record<string, string> = {}
for (const [k, v] of Object.entries(nextBindings)) {
if (v !== undefined) {
filteredBindings[k] = v
}
}
bindings = filteredBindings
}
}
)
}
function onSave (): void {
dispatch('close', bindings)
}
$: allBound = Object.keys(requiredSlots).every((id) => bindings[id] !== undefined)
function getBindingLabel (currentBindings: Record<string, string>, id: string): any {
const value = currentBindings[id]
if (value === undefined) return presentation.string.NotSelected
// Search in the resolved memberOf tag's attributes first, then fallback to masterTag
const slot = requiredSlots[id]
const memberOfTag = slot?.memberOf !== undefined ? resolveMemberOf(slot.memberOf, currentBindings) : masterTag
const searchTag = memberOfTag ?? masterTag
try {
const attrs = hierarchy.getAllAttributes(searchTag, core.class.Doc)
const attr = Array.from(attrs.values()).find((a) => a.name === value)
if (attr) return attr.label ?? attr.name
} catch (e) {
// Tag may not be in hierarchy yet
}
// Also search masterTag if different
if (searchTag !== masterTag) {
try {
const masterAttrs = hierarchy.getAllAttributes(masterTag, core.class.Doc)
const attr = Array.from(masterAttrs.values()).find((a) => a.name === value)
if (attr !== undefined) return attr.label ?? attr.name
} catch (e) {}
}
const assoc = allAssociations.find((a) => a._id === value)
if (assoc !== undefined) {
return hierarchy.isDerived(assoc.classA, masterTag)
? assoc.nameA + ' -> ' + assoc.nameB
: assoc.nameB + ' -> ' + assoc.nameA
}
const proc = allProcesses.find((p) => p._id === value)
if (proc !== undefined) return proc.name
const cls = model.findObject(value as any)
if (isClassLike(cls)) return (cls as any).label ?? (cls as any).name
return value
}
</script>
<Card
label={processPlugin.string.RequiredSlots}
canSave={allBound}
width="small"
okLabel={processPlugin.string.Import}
okAction={onSave}
on:close
>
<div class="flex-column flex-gap-4">
{#each Object.entries(requiredSlots) as [id, slot]}
<div class="flex-column flex-gap-1">
{#if slot.label}
<Label label={slot.label} />
{:else}
{slot.name}
{/if}
<Button
label={getBindingLabel(bindings, id)}
disabled={!resolvedSlots[id]?.enabled}
kind="secondary"
width="100%"
on:click={(e) => {
setBinding(id, e)
}}
/>
</div>
{/each}
</div>
</Card>
@@ -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)
@@ -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<string | number>): 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}
/>
<div class="flex-row-center flex-gap-2">
{#if value.requiredSlots && Object.keys(value.requiredSlots).length > 0}
<ButtonIcon
icon={IconLink}
tooltip={{ label: process.string.Bindings, direction: 'bottom' }}
size="small"
kind="secondary"
on:click={handleBindings}
/>
{/if}
<ButtonIcon icon={IconSettings} size="small" kind="secondary" on:click={handleSettings} />
<ButtonIcon
<ButtonMenu
icon={IconDownload}
tooltip={{ label: process.string.Export, direction: 'bottom' }}
size="small"
kind="secondary"
on:click={handleExport}
items={exportItems}
noSelection
on:selected={onExportSelected}
/>
<ButtonIcon
icon={IconDetails}
@@ -54,7 +54,12 @@
let keys = Object.keys(params)
$: allAttrs = getKeys(process.masterTag)
$: possibleAttrs = allAttrs.filter((attr) => !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]
+789 -76
View File
@@ -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<MasterTag>): {
// ─── Types ───────────────────────────────────────────────────────────────────
interface ExportResult {
docs: Doc[]
required: Array<Ref<Class<Doc>>>
} {
const docs: Doc[] = []
const required: Array<Ref<Class<Doc>>> = []
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<Ref<Class<Doc>>>
} {
const docs: Doc[] = [proc]
const required: Array<Ref<Class<Doc>>> = []
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<T extends Doc> (
doc: T
): Omit<T, '_id' | '_class' | 'space' | 'modifiedBy' | 'modifiedOn' | 'createdBy' | 'createdOn'> {
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<any>, 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<any>, docs: Doc[], required: Array<Ref<Class<Doc>>>, 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<any>, docs: Doc[], required: Array<Ref<Class<Do
}
}
if (type._class === core.class.ArrOf) {
const of = (type as ArrOf<Doc>).of
processType(of, docs, required, m, h)
processType((type as ArrOf<Doc>).of, docs, required, m, h)
}
}
/** Recursively scans action/trigger params for type dependencies. */
function processParams (
params: Record<string, any> | undefined,
masterTag: Ref<MasterTag>,
@@ -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<MasterTag>, json: string): Promise<void> {
/** Scans DSL expressions for attribute and object references. */
function scanDSL (
dsl: string,
masterTag: Ref<MasterTag>,
docs: Doc[],
required: Array<Ref<Class<Doc>>>,
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<MasterTag>): ExportResult {
const docs: Doc[] = []
const required: Array<Ref<Class<Doc>>> = []
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<Ref<Class<Doc>>> = []
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<string, SlotModel> = {}
const bindings: Record<string, string> = {}
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<string, SlotModel> | 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<MasterTag>,
json: string,
bindings?: Record<string, string>
): Promise<void> {
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<MasterTag>, json: string): P
}
}
function stripData<T extends Doc> (
doc: T
): Omit<T, '_id' | '_class' | 'space' | 'modifiedBy' | 'modifiedOn' | 'createdBy' | 'createdOn'> {
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<string, string> {
const idMap: Record<string, string> = {}
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<MasterTag>, bindings?: Record<string, string>): Doc[] {
const idMap: Record<string, string> = {
__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<string> {
const ids = new Set<string>()
// 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<string, Ref<MasterTag>>,
masterTag: Ref<MasterTag>
): Ref<MasterTag> {
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>,
masterTag: Ref<MasterTag>,
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<any> | 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<string, SlotModel>,
bindings: Record<string, string>,
m: ModelDb,
h: Hierarchy
): void {
const masterTag = proc.masterTag
const attrToSlot: Record<string, string> = {}
const contextClasses: Record<string, Ref<MasterTag>> = {}
// 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<string, any> | undefined,
activeTag: Ref<MasterTag>,
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<Doc>).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>,
masterTag: Ref<MasterTag>,
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>,
masterTag: Ref<MasterTag>,
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<any> | 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>,
masterTag: Ref<MasterTag>,
memberOfRef: string | undefined,
direction: string | undefined,
slots: Record<string, SlotModel>,
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<MasterTag>,
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<any>, memberOfRef))
} else if (classId === normalizeId(core.class.Class) || h.isDerived(obj._class, core.class.Class)) {
getOrAddSlot(id, classToSlot(obj as any as Class<any>, memberOfRef))
} else {
getOrAddSlot(id, unknownToSlot(id, memberOfRef))
}
}
+5 -1
View File
@@ -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<Permission>,
+11 -6
View File
@@ -63,13 +63,18 @@ import { showPopup } from '@hcengineering/ui'
import { type AttributeCategory } from '@hcengineering/view'
import process from './plugin'
export function isTypeEqual (toCheck: Type<any> | undefined, attr: Type<any>): boolean {
const skip = ['label', 'icon', 'hidden', 'readonly']
export function isTypeEqual (toCheck: any | undefined, attr: Type<any>): 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<any>] !== attr[key as keyof Type<any>]) 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<Doc>).to === (attr as RefTo<Doc>).to
}
if (check._class === core.class.ArrOf) {
return isTypeEqual((check as ArrOf<Doc>).of, (attr as ArrOf<Doc>).of)
}
if (check._class === core.class.EnumOf) {
return check.of === (attr as any).of
}
return true
}
+15
View File
@@ -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<Class<Doc>>
label?: IntlString
name?: string
memberOf?: string
}
export interface AttributeSlotModel extends SlotModel {
slotKind: 'attribute'
type: Type<any>
}
// Process model dscription
export interface Process extends Doc {
masterTag: Ref<MasterTag | Tag>
@@ -34,6 +47,8 @@ export interface Process extends Doc {
automationOnly?: boolean
context: Record<ContextId, ProcessContext>
resultType?: Type<any>
requiredSlots?: Record<string, SlotModel>
bindings?: Record<string, string>
}
export interface ProcessContext {
@@ -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<string, any> | 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<string, any> = {}
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<string, any>
): 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<Doc>
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<string, any> = {}
const prevValue: Record<string, any> = {}
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<ApproveRequest>[] = []
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<ApproveRequest>()
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<ProcessToDo>()
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<string, any> = {}
for (const key in attrs) {
resolvedAttrs[resolveAttributeId(_process, key)] = (attrs as any)[key]
}
const masterTag = _class as Ref<MasterTag>
const _id = generateId<Card>()
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
+23 -6
View File
@@ -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<T extends string> (_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<any> {
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<Doc>).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<any> {
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) {
+2 -2
View File
@@ -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",