Add subprocess match trigger (#10101)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov
2025-10-16 12:17:51 +07:00
committed by GitHub
parent c3d15734eb
commit db3bd4d39e
28 changed files with 422 additions and 30 deletions
+16
View File
@@ -94,6 +94,22 @@ export function defineTriggers (builder: Builder): void {
process.trigger.OnSubProcessesDone
)
builder.createDoc(
process.class.Trigger,
core.space.Model,
{
label: process.string.WhenSubProcessMatches,
icon: process.icon.WaitSubprocesses,
editor: process.component.SubProcessMatchEditor,
presenter: process.component.SubProcessMatchPresenter,
checkFunction: process.triggerCheck.SubProcessMatchCheck,
requiredParams: ['process'],
init: false,
auto: true
},
process.trigger.OnSubProcessMatch
)
builder.createDoc(
process.class.Trigger,
core.space.Model,
+5
View File
@@ -77,6 +77,11 @@ export function createModel (builder: Builder): void {
serverCheckFunc: serverProcess.func.CheckSubProcessesDone
})
builder.mixin(process.trigger.OnSubProcessMatch, process.class.Trigger, serverProcess.mixin.TriggerImpl, {
preventRollback: true,
serverCheckFunc: serverProcess.func.CheckSubProcessMatch
})
builder.mixin(process.method.RunSubProcess, process.class.Method, serverProcess.mixin.MethodImpl, {
func: serverProcess.func.RunSubProcess
})
+4 -3
View File
@@ -742,6 +742,10 @@ export function findAttributeEditorByAttribute (client: Client, attribute: AnyAt
const hierarchy = client.getHierarchy()
if (attribute === undefined) return
if (attribute.editor != null) {
return attribute.editor
}
if (attribute.type._class === core.class.TypeAny) {
const _type: TypeAny = attribute.type as TypeAny<AnyComponent>
return _type.editor ?? _type.presenter
@@ -769,9 +773,6 @@ export function findAttributeEditorByAttribute (client: Client, attribute: AnyAt
}
}
if (attribute.editor != null) {
return attribute.editor
}
const editorMixin = hierarchy.classHierarchyMixin(presenterClass.attrClass, mixin)
if (editorMixin?.inlineEditor === undefined) {
+1
View File
@@ -51,6 +51,7 @@
"EnterValue": "Zadat hodnotu",
"OnToDoDone": "Při dokončení akční položky",
"OnSubProcessesDone": "Při dokončení podprocesů",
"WhenSubProcessMatches": "Když podproces odpovídá podmínce",
"Result": "Výsledek",
"NoResultRequired": "Není vyžadován žádný výsledek",
"RequestResult": "Požádat o výsledek",
+1
View File
@@ -51,6 +51,7 @@
"EnterValue": "Wert eingeben",
"OnToDoDone": "Beim Erledigen von Action Item",
"OnSubProcessesDone": "Beim Abschluss von Unterprozessen",
"WhenSubProcessMatches": "Wenn Unterprozess übereinstimmt",
"Result": "Ergebnis",
"NoResultRequired": "Kein Ergebnis erforderlich",
"RequestResult": "Ergebnis anfordern",
+1
View File
@@ -51,6 +51,7 @@
"EnterValue": "Enter value",
"OnToDoDone": "When action item done",
"OnSubProcessesDone": "On sub processes done",
"WhenSubProcessMatches": "When subprocess matches",
"Result": "Result",
"RequestResult": "Request result",
"NoResultRequired": "No result required",
+1
View File
@@ -55,6 +55,7 @@
"EnterValue": "Ingrese un valor",
"OnToDoDone": "Al completar la acción",
"OnSubProcessesDone": "Al finalizar los subprocesos",
"WhenSubProcessMatches": "Cuando el subproceso coincide",
"Result": "Resultado",
"RequestResult": "Solicitar resultado",
"NoResultRequired": "No se requiere resultado",
+1
View File
@@ -55,6 +55,7 @@
"EnterValue": "Saisir une valeur",
"OnToDoDone": "Lors de l'achèvement de l'action",
"OnSubProcessesDone": "Au fin des sous-processus",
"WhenSubProcessMatches": "Lorsque le sous-processus correspond",
"Result": "Résultat",
"RequestResult": "Demander le résultat",
"NoResultRequired": "Aucun résultat requis",
+1
View File
@@ -55,6 +55,7 @@
"EnterValue": "Inserisci valore",
"OnToDoDone": "Quando l'azione è completata",
"OnSubProcessesDone": "Alla fine dei sottoprocessi",
"WhenSubProcessMatches": "Quando il sottoprocesso corrisponde",
"Result": "Risultato",
"RequestResult": "Richiedi risultato",
"NoResultRequired": "Nessun risultato richiesto",
+1
View File
@@ -55,6 +55,7 @@
"EnterValue": "値を入力",
"OnToDoDone": "アクション完了時",
"OnSubProcessesDone": "サブプロセス完了時",
"WhenSubProcessMatches": "サブプロセスが条件に一致したとき",
"RequestResult": "結果をリクエスト",
"NoResultRequired": "結果は不要",
"From": "から",
+1
View File
@@ -55,6 +55,7 @@
"EnterValue": "Insira valor",
"OnToDoDone": "Ao completar a ação",
"OnSubProcessesDone": "Ao concluir processos sub",
"WhenSubProcessMatches": "Quando o subprocesso corresponde",
"Result": "Resultado",
"RequestResult": "Solicitar resultado",
"NoResultRequired": "Nenhum resultado requerido",
+1
View File
@@ -55,6 +55,7 @@
"EnterValue": "Введите значение",
"OnToDoDone": "При завершении задачи",
"OnSubProcessesDone": "При завершении подпроцессов",
"WhenSubProcessMatches": "Когда подпроцесс соответствует условию",
"Result": "Результат",
"NoResultRequired": "Результат не требуется",
"RequestResult": "Запросить результат",
+1
View File
@@ -51,6 +51,7 @@
"EnterValue": "Değer gir",
"OnToDoDone": "Eylem öğesi tamamlandığında",
"OnSubProcessesDone": "Alt süreçler tamamlandığında",
"WhenSubProcessMatches": "Alt süreç eşleştiğinde",
"Result": "Sonuç",
"RequestResult": "Sonuç talep et",
"NoResultRequired": "Sonuç gerekmez",
+1
View File
@@ -55,6 +55,7 @@
"EnterValue": "输入值",
"OnToDoDone": "任务完成时",
"OnSubProcessesDone": "子进程完成时",
"WhenSubProcessMatches": "当子进程匹配条件时",
"Result": "结果",
"RequestResult": "请求结果",
"NoResultRequired": "无需结果",
@@ -16,10 +16,11 @@
<script lang="ts">
import { AnyAttribute } from '@hcengineering/core'
import { Context, Process } from '@hcengineering/process'
import { Component, DropdownLabelsIntl } from '@hcengineering/ui'
import { Component } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import { buildResult, Mode, ModeId, Modes, parseValue } from '../../query'
import BaseCriteriaEditor from './BaseCriteriaEditor.svelte'
import ModeSelector from './ModeSelector.svelte'
export let readonly: boolean
export let value: any
@@ -32,7 +33,6 @@
let [val, selectedMode] = parseValue(modesValues, value)
$: mode = selectedMode.id
const dispatch = createEventDispatcher()
$: [val, selectedMode] = parseValue(modesValues, value)
@@ -42,27 +42,18 @@
value = result
dispatch('change', result)
}
function changeMode (e: CustomEvent): void {
const editorChanged = e.detail
if (editorChanged) {
val = undefined
}
changeResult(val)
}
</script>
<div class="flex-row-center flex-gap-4">
<DropdownLabelsIntl
items={modesValues}
selected={mode}
disabled={readonly}
minW0={false}
kind={'no-border'}
width={'100%'}
on:selected={(e) => {
mode = e.detail
const prevEditor = (selectedMode.editor ?? selectedMode.withoutEditor) ? null : undefined
selectedMode = modesValues.find((m) => m.id === mode) ?? modesValues[0]
const newEditor = (selectedMode.editor ?? selectedMode.withoutEditor) ? null : undefined
if (prevEditor !== newEditor) {
val = undefined
}
changeResult(val)
}}
/>
<ModeSelector bind:selectedMode modes={modesValues} {readonly} on:change={changeMode} />
{#if selectedMode.editor}
<div class="w-full">
<Component
@@ -0,0 +1,44 @@
<!--
// 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 { DropdownLabelsIntl } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import { Mode } from '../../query'
export let selectedMode: Mode
export let readonly: boolean
export let modes: Mode[] = []
$: mode = selectedMode.id
const dispatch = createEventDispatcher()
</script>
<DropdownLabelsIntl
items={modes}
selected={mode}
disabled={readonly}
minW0={false}
kind={'no-border'}
width={'100%'}
on:selected={(e) => {
mode = e.detail
const prevEditor = (selectedMode.editor ?? selectedMode.withoutEditor) ? null : undefined
selectedMode = modes.find((m) => m.id === mode) ?? modes[0]
const newEditor = (selectedMode.editor ?? selectedMode.withoutEditor) ? null : undefined
dispatch('change', prevEditor !== newEditor)
}}
/>
@@ -0,0 +1,73 @@
<!--
// 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 { Ref, SortingOrder } from '@hcengineering/core'
import { Process, State } from '@hcengineering/process'
import { Dropdown, DropdownLabels } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import plugin from '../../plugin'
import { buildResult, Modes, parseValue } from '../../query'
import ModeSelector from './ModeSelector.svelte'
import { ObjectBox } from '@hcengineering/view-resources'
import { createQuery } from '@hcengineering/presentation'
export let readonly: boolean
export let target: Ref<Process> | undefined
export let value: any
const dispatch = createEventDispatcher()
const modes = [Modes.ArrayAll, Modes.ArrayAny, Modes.ArrayNotIncludes]
let [val, selectedMode] = parseValue(modes, value)
$: [val, selectedMode] = parseValue(modes, value)
function changeMode (): void {
value = buildResult(selectedMode, val)
dispatch('change', value)
}
function change (e: CustomEvent): void {
val = e.detail ?? []
value = buildResult(selectedMode, val)
dispatch('change', value)
}
const query = createQuery()
let states: State[] = []
$: query.query(
plugin.class.State,
{ process: target },
(items) => {
states = items
},
{ sort: { rank: SortingOrder.Ascending } }
)
$: selected = states.filter((state) => val?.includes(state._id))?.map((p) => p._id)
</script>
<div class="flex-row-center flex-gap-4">
<ModeSelector bind:selectedMode {modes} {readonly} on:change={changeMode} />
<DropdownLabels
useFlexGrow
width={'100%'}
multiselect={true}
items={states.map((state) => ({ id: state._id, label: state.title }))}
{selected}
on:selected={change}
/>
</div>
@@ -0,0 +1,106 @@
<!--
// 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 { Ref } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { Process, State } from '@hcengineering/process'
import { DropdownLabels, Label } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import plugin from '../../plugin'
import { ModeId, Modes } from '../../query'
import { getContext } from '../../utils'
import BaseCriteria from '../criterias/BaseCriteria.svelte'
import SubProcessStateCriteria from '../criterias/SubProcessStateCriteria.svelte'
export let readonly: boolean
export let process: Process
export let params: Record<string, any>
const dispatch = createEventDispatcher()
const client = getClient()
$: possibleProcesses = getPossibleProcesses(process)
function getPossibleProcesses (proc: Process): Ref<Process>[] {
const res = new Set<Ref<Process>>()
const transitions = client.getModel().findAllSync(plugin.class.Transition, { process: proc._id })
for (const transition of transitions) {
for (const action of transition.actions ?? []) {
if (action.methodId === plugin.method.RunSubProcess) {
if (action.params._id) {
res.add(action.params._id as Ref<Process>)
}
}
}
}
return [...res]
}
$: processes = client.getModel().findAllSync(plugin.class.Process, { _id: { $in: possibleProcesses } })
$: items = processes.map((it) => ({
id: it._id,
label: it.name
}))
let selectedProcessId: Ref<Process> | undefined = params.process
$: selectedProcessId = params.process
$: selected = selectedProcessId !== undefined ? items.find((it) => it.id === selectedProcessId)?.id : undefined
function changeProcess (e: CustomEvent<any>): void {
if (readonly || e.detail == null) return
selectedProcessId = e.detail
params = {
process: selectedProcessId
}
dispatch('change', { params })
}
function changeStateCriteria (e: CustomEvent<any>): void {
if (readonly || e.detail == null) return
params = {
...params,
currentState: e.detail
}
dispatch('change', { params })
}
const modes: ModeId[] = ['ArrayAll', 'ArrayAny', 'ArrayNotIncludes']
</script>
<div class="editor-grid">
<Label label={plugin.string.Process} />
<DropdownLabels
disabled={readonly}
autoSelect={false}
enableSearch={false}
width={'100%'}
{items}
{selected}
placeholder={plugin.string.Process}
on:selected={changeProcess}
/>
{#if selectedProcessId !== undefined}
<Label label={plugin.string.Step} />
<SubProcessStateCriteria
{readonly}
target={selectedProcessId}
value={params.currentState}
on:change={changeStateCriteria}
/>
{/if}
</div>
@@ -0,0 +1,52 @@
<!--
// 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 { getClient } from '@hcengineering/presentation'
import { Process, State } from '@hcengineering/process'
import { Label } from '@hcengineering/ui'
import plugin from '../../plugin'
export let params: Record<string, any>
const client = getClient()
let selectedProcess: Process | undefined
$: if (params.process) {
selectedProcess = client.getModel().findAllSync(plugin.class.Process, { _id: params.process })[0]
} else {
selectedProcess = undefined
}
let states: State[] = []
$: if (params.process) {
states = client.getModel().findAllSync(plugin.class.State, { process: params.process })
} else {
states = []
}
</script>
{#if selectedProcess}
: <Label label={plugin.string.Process} />
<span class="overflow-label">{selectedProcess.name}</span>
{/if}
<style lang="scss">
.overflow-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+8 -2
View File
@@ -67,6 +67,8 @@ import TimeEditor from './components/settings/TimeEditor.svelte'
import TimePresenter from './components/settings/TimePresenter.svelte'
import ToDoSettingPresenter from './components/settings/ToDoPresenter.svelte'
import TransitionRefPresenter from './components/settings/TransitionRefPresenter.svelte'
import SubProcessMatchEditor from './components/settings/SubProcessMatchEditor.svelte'
import SubProcessMatchPresenter from './components/settings/SubProcessMatchPresenter.svelte'
import AppendEditor from './components/transformEditors/AppendEditor.svelte'
import CutEditor from './components/transformEditors/CutEditor.svelte'
import ReplaceEditor from './components/transformEditors/ReplaceEditor.svelte'
@@ -81,7 +83,8 @@ import {
todoTranstionCheck,
matchCardCheck,
subProcessesDoneCheck,
fieldChangesCheck
fieldChangesCheck,
subProcessMatchCheck
} from './utils'
import FieldChangesEditor from './components/settings/FieldChangesEditor.svelte'
@@ -138,7 +141,9 @@ export default async (): Promise<Resources> => ({
AddTagPresenter,
ExecutionMyToDos,
FieldChangesEditor,
FunctionSubmenu
FunctionSubmenu,
SubProcessMatchEditor,
SubProcessMatchPresenter
},
criteriaEditor: {
BaseCriteria,
@@ -164,6 +169,7 @@ export default async (): Promise<Resources> => ({
MatchCheck: matchCardCheck,
FieldChangedCheck: fieldChangesCheck,
SubProcessesDoneCheck: subProcessesDoneCheck,
SubProcessMatchCheck: subProcessMatchCheck,
ToDo: todoTranstionCheck,
Time: timeTransitionCheck
},
+3
View File
@@ -71,6 +71,8 @@ export default mergeIds(processId, process, {
TimePresenter: '' as AnyComponent,
AddTagEditor: '' as AnyComponent,
AddTagPresenter: '' as AnyComponent,
SubProcessMatchEditor: '' as AnyComponent,
SubProcessMatchPresenter: '' as AnyComponent,
FunctionSubmenu: '' as AnyComponent
},
criteriaEditor: {
@@ -166,6 +168,7 @@ export default mergeIds(processId, process, {
EnterValue: '' as IntlString,
OnToDoDone: '' as IntlString,
OnSubProcessesDone: '' as IntlString,
WhenSubProcessMatches: '' as IntlString,
WaitUntil: '' as IntlString,
WhenCardMatches: '' as IntlString,
WhenFieldChanges: '' as IntlString,
+8 -4
View File
@@ -165,19 +165,23 @@ function hasSameKeys (target: any, value: any): boolean {
}
return true
} else {
return typeof target !== 'object' && typeof value !== 'object'
return typeof target !== 'object' && isNotObject(value)
}
}
function isNotObject (value: any): boolean {
return typeof value !== 'object' || value === null || Array.isArray(value)
}
function isModeMatch (mode: Mode, value: any): boolean {
if (typeof mode.query !== 'object') return typeof value !== 'object' || Array.isArray(value)
if (typeof mode.query === 'object' && (typeof value !== 'object' || Array.isArray(value))) return false
if (typeof mode.query !== 'object') return isNotObject(value)
if (typeof mode.query === 'object' && isNotObject(value)) return false
return hasSameKeys(mode.query, value)
}
function getValue (_value: any): any {
let value = _value
while (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 1) {
while (!isNotObject(value) && Object.keys(value).length === 1) {
value = value[Object.keys(value)[0]]
}
return value
+36
View File
@@ -656,6 +656,42 @@ export async function subProcessesDoneCheck (
return res === undefined
}
export async function subProcessMatchCheck (
client: Client,
execution: Execution,
params: Record<string, any>,
context: Record<string, any>
): Promise<boolean> {
const { process: _process, ...otherCritera } = params
if (_process === undefined) return false
if (Object.keys(otherCritera).length === 0) return true
const subProcesses = await client.findAll(process.class.Execution, {
parentId: execution._id,
process: params.process
})
if (subProcesses.length === 0) return false
const [predicate, value] = Object.entries(otherCritera)[0]
const res = matchQuery(
subProcesses,
{ currentState: { $in: value } },
process.class.Execution,
client.getHierarchy(),
true
)
if (predicate === '$all') {
return res.length === subProcesses.length
} else if (predicate === '$any') {
return res.length > 0
} else if (predicate === '$nin') {
return res.length === 0
}
return false
}
export function getCriteriaEditor (
of: Ref<Class<Doc>>,
category: AttributeCategory
+2
View File
@@ -221,6 +221,7 @@ export default plugin(processId, {
OnCardUpdate: '' as Ref<Trigger>, // in fact WhenCardMatches, should migrate in future
WhenFieldChanges: '' as Ref<Trigger>,
OnSubProcessesDone: '' as Ref<Trigger>,
OnSubProcessMatch: '' as Ref<Trigger>,
OnToDoClose: '' as Ref<Trigger>,
OnToDoRemove: '' as Ref<Trigger>,
OnExecutionStart: '' as Ref<Trigger>,
@@ -232,6 +233,7 @@ export default plugin(processId, {
MatchCheck: '' as Resource<CheckFunc>,
FieldChangedCheck: '' as Resource<CheckFunc>,
SubProcessesDoneCheck: '' as Resource<CheckFunc>,
SubProcessMatchCheck: '' as Resource<CheckFunc>,
Time: '' as Resource<CheckFunc>
},
string: {
@@ -94,6 +94,42 @@ export async function CheckSubProcessesDone (control: ProcessControl, execution:
return res === undefined
}
export async function CheckSubProcessMatch (
control: ProcessControl,
execution: Execution,
params: Record<string, any>,
context: Record<string, any>
): Promise<boolean> {
const { process: targetProcess, currentState } = params
if (targetProcess === undefined || currentState === undefined) return false
const subExecutions = await control.client.findAll(process.class.Execution, {
parentId: execution._id,
process: targetProcess
})
if (subExecutions.length === 0) return false
const [predicate, value] = Object.entries(currentState)[0]
const res = matchQuery(
subExecutions,
{ currentState: { $in: value as any } },
process.class.Execution,
control.client.getHierarchy(),
true
)
if (predicate === '$all') {
return res.length === subExecutions.length
} else if (predicate === '$any') {
return res.length > 0
} else if (predicate === '$nin') {
return res.length === 0
}
return false
}
export function MatchCardCheck (
control: ProcessControl,
execution: Execution,
@@ -93,6 +93,7 @@ import {
CheckToDoCancelled,
MatchCardCheck,
CheckSubProcessesDone,
CheckSubProcessMatch,
CheckTime,
FieldChangedCheck
} from './functions'
@@ -398,6 +399,7 @@ export default async () => ({
FieldChangedCheck,
MatchCardCheck,
CheckSubProcessesDone,
CheckSubProcessMatch,
CheckTime
},
transform: {
+1
View File
@@ -59,6 +59,7 @@ export default plugin(serverProcessId, {
MatchCardCheck: '' as Resource<CheckFunc>,
FieldChangedCheck: '' as Resource<CheckFunc>,
CheckSubProcessesDone: '' as Resource<CheckFunc>,
CheckSubProcessMatch: '' as Resource<CheckFunc>,
CheckTime: '' as Resource<CheckFunc>
},
transform: {
+4 -1
View File
@@ -700,7 +700,10 @@ async function checkParent (execution: Execution, control: ProcessControl): Prom
{
from: parent.currentState,
process: parent.process,
trigger: process.trigger.OnSubProcessesDone
trigger:
filtered.length === 0
? { $in: [process.trigger.OnSubProcessesDone, process.trigger.OnSubProcessMatch] }
: process.trigger.OnSubProcessMatch
},
{
sort: { rank: SortingOrder.Ascending }