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
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
}