mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-10 11:47:42 +02:00
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]
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user