feat: Implement CancelSubProcess action, fix process execution flow. (#10530)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov
2026-02-20 10:23:03 +07:00
committed by GitHub
parent f446e036ee
commit bb6d4c74e9
28 changed files with 202 additions and 41 deletions
+14
View File
@@ -134,6 +134,20 @@ export function defineMethods (builder: Builder): void {
process.method.CancelToDo
)
builder.createDoc(
process.class.Method,
core.space.Model,
{
label: process.string.CancelProcess,
editor: process.component.CancelSubProcessEditor,
presenter: process.component.SubProcessPresenter,
objectClass: process.class.Process,
requiredParams: ['_id'],
createdContext: null
},
process.method.CancelSubProcess
)
builder.createDoc(
process.class.Method,
core.space.Model,
+4
View File
@@ -107,6 +107,10 @@ export function createModel (builder: Builder): void {
func: serverProcess.func.RunSubProcess
})
builder.mixin(process.method.CancelSubProcess, process.class.Method, serverProcess.mixin.MethodImpl, {
func: serverProcess.func.CancelSubProcess
})
builder.mixin(process.method.CreateToDo, process.class.Method, serverProcess.mixin.MethodImpl, {
func: serverProcess.func.CreateToDo
})
@@ -19,12 +19,16 @@
import card from '../plugin'
import MasterTagAttributes from './MasterTagAttributes.svelte'
import TagAttributes from './TagAttributes.svelte'
import { getClient } from '@hcengineering/presentation'
export let value: Card
export let readonly: boolean = false
export let mixins: Array<Mixin<Doc>> = []
export let ignoreKeys: string[]
const client = getClient()
const h = client.getHierarchy()
let width: number = 0
let columns = 1
@@ -57,7 +61,7 @@
<div class="masterTag">
<MasterTagAttributes
bind:this={masterTagAttributes}
readonly={readonly || value.readonlySections?.includes(value._class)}
readonly={readonly || h.getAncestors(value._class).some((p) => value.readonlySections?.includes(p))}
{value}
{ignoreKeys}
fourRows={columns === 2}
@@ -22,6 +22,7 @@
import { CardSectionAction } from '../../types'
import { permissionsStore } from '@hcengineering/contact-resources'
import { canChangeDoc } from '@hcengineering/view-resources'
import { getClient } from '@hcengineering/presentation'
export let readonly: boolean = false
export let doc: Card
@@ -45,6 +46,9 @@
dispatch('action', { id: 'toc', toc: [] })
})
const client = getClient()
const h = client.getHierarchy()
$: updatePermissionForbidden = doc && !canChangeDoc(doc?._class, doc?.space, $permissionsStore)
</script>
@@ -52,7 +56,9 @@
<div class="content" class:hidden>
<Content
{doc}
readonly={readonly || updatePermissionForbidden || doc.readonlySections?.includes(doc._class)}
readonly={readonly ||
updatePermissionForbidden ||
h.getAncestors(doc._class).some((p) => doc.readonlySections?.includes(p))}
content={contentDiv}
showToc={false}
on:loaded
+2 -1
View File
@@ -143,7 +143,8 @@
"UnlockSection": "Odemknout sekci",
"CancelToDo": "Zrušit akční položku",
"Export": "Exportovat",
"Import": "Importovat"
"Import": "Importovat",
"CancelProcess": "Zrušit proces"
},
"error": {
"MethodNotFound": "Metoda nenalezena: {methodId}",
+2 -1
View File
@@ -143,7 +143,8 @@
"UnlockSection": "Abschnitt entsperren",
"CancelToDo": "Action Item abbrechen",
"Export": "Exportieren",
"Import": "Importieren"
"Import": "Importieren",
"CancelProcess": "Prozess abbrechen"
},
"error": {
"MethodNotFound": "Methode nicht gefunden: {methodId}",
+2 -1
View File
@@ -148,7 +148,8 @@
"UnlockSection": "Unlock section",
"CancelToDo": "Cancel Action item",
"Export": "Export",
"Import": "Import"
"Import": "Import",
"CancelProcess": "Cancel process"
},
"error": {
"MethodNotFound": "Method not found: {methodId}",
+2 -1
View File
@@ -148,7 +148,8 @@
"UnlockSection": "Desbloquear sección",
"CancelToDo": "Cancelar acción",
"Export": "Exportar",
"Import": "Importar"
"Import": "Importar",
"CancelProcess": "Cancelar proceso"
},
"error": {
"MethodNotFound": "Método no encontrado: {methodId}",
+2 -1
View File
@@ -148,7 +148,8 @@
"UnlockSection": "Déverrouiller la section",
"CancelToDo": "Annuler l'action",
"Export": "Exporter",
"Import": "Importer"
"Import": "Importer",
"CancelProcess": "Annuler le processus"
},
"error": {
"MethodNotFound": "Méthode introuvable : {methodId}",
+2 -1
View File
@@ -148,7 +148,8 @@
"UnlockSection": "Sblocca sezione",
"CancelToDo": "Annulla azione",
"Export": "Esporta",
"Import": "Importa"
"Import": "Importa",
"CancelProcess": "Annulla processo"
},
"error": {
"MethodNotFound": "Metodo non trovato: {methodId}",
+2 -1
View File
@@ -147,7 +147,8 @@
"UnlockSection": "セクションのロックを解除",
"CancelToDo": "アクションをキャンセル",
"Export": "エクスポート",
"Import": "インポート"
"Import": "インポート",
"CancelProcess": "プロセスをキャンセル"
},
"error": {
"MethodNotFound": "メソッドが見つかりません: {methodId}",
+2 -1
View File
@@ -136,7 +136,8 @@
"UnlockSection": "Desbloquear seção",
"CancelToDo": "Cancelar ação",
"Export": "Exportar",
"Import": "Importar"
"Import": "Importar",
"CancelProcess": "Cancelar processo"
},
"error": {
"MethodNotFound": "Método não encontrado: {methodId}",
+2 -1
View File
@@ -148,7 +148,8 @@
"UnlockSection": "Desbloquear seção",
"CancelToDo": "Cancelar ação",
"Export": "Exportar",
"Import": "Importar"
"Import": "Importar",
"CancelProcess": "Cancelar processo"
},
"error": {
"MethodNotFound": "Método não encontrado: {methodId}",
+2 -1
View File
@@ -148,7 +148,8 @@
"UnlockSection": "Разблокировать секцию",
"CancelToDo": "Отменить задачу",
"Export": "Экспорт",
"Import": "Импорт"
"Import": "Импорт",
"CancelProcess": "Отменить процесс"
},
"error": {
"MethodNotFound": "Метод не найден: {methodId}",
+2 -1
View File
@@ -145,7 +145,8 @@
"UnlockSection": "Bölümün kilidini aç",
"CancelToDo": "Eylem öğesini iptal et",
"Export": "Dışa aktar",
"Import": "İçe aktar"
"Import": "İçe aktar",
"CancelProcess": "Süreci iptal et"
},
"error": {
"MethodNotFound": "Metod bulunamadı: {methodId}",
+2 -1
View File
@@ -148,7 +148,8 @@
"UnlockSection": "解锁部分",
"CancelToDo": "取消任务",
"Export": "导出",
"Import": "导入"
"Import": "导入",
"CancelProcess": "取消过程"
},
"error": {
"MethodNotFound": "找不到方法:{methodId}",
@@ -16,7 +16,7 @@
import { getCurrentEmployee } from '@hcengineering/contact'
import { getEmbeddedLabel } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation'
import { ApproveRequest, Execution, ProcessToDo } from '@hcengineering/process'
import { ApproveRequest, Execution, ExecutionStatus, ProcessToDo } from '@hcengineering/process'
import { Button } from '@hcengineering/ui'
import plugin from '../plugin'
import ApproveRequestButtons from './ApproveRequestButtons.svelte'
@@ -30,17 +30,22 @@
const emp = getCurrentEmployee()
const query = createQuery()
query.query(
plugin.class.ProcessToDo,
{
execution: value._id,
user: emp,
doneOn: null
},
(res) => {
todos = res
}
)
$: if (value.status === ExecutionStatus.Active) {
query.query(
plugin.class.ProcessToDo,
{
execution: value._id,
user: emp,
doneOn: null
},
(res) => {
todos = res
}
)
} else {
query.unsubscribe()
todos = []
}
async function checkTodo (todo: ProcessToDo) {
await client.update(todo, {
@@ -44,7 +44,7 @@
process.class.Execution,
{
card: card._id,
status: { $ne: ExecutionStatus.Cancelled }
status: ExecutionStatus.Active
},
(res) => {
docs = res
@@ -0,0 +1,72 @@
<!--
// 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 card from '@hcengineering/card'
import { Ref } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { Process, Step } from '@hcengineering/process'
import { DropdownLabels, DropdownTextItem, Label } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import plugin from '../../plugin'
export let process: Process
export let step: Step<Process>
const dispatch = createEventDispatcher()
const client = getClient()
const hierarchy = client.getHierarchy()
const params = step.params
let _id = params._id as Ref<Process>
function change (e: CustomEvent): void {
if (e.detail !== undefined) {
_id = e.detail
params._id = _id
step.params = params
dispatch('change', step)
}
}
let processes: Process[] = []
let items: DropdownTextItem[] = []
$: items = processes.map((it) => ({
id: it._id,
label: it.name
}))
$: ancestors = hierarchy.getAncestors(process.masterTag).filter((it) => hierarchy.isDerived(it, card.class.Card))
$: selected = _id !== undefined ? items.find((it) => it.id === _id)?.id : undefined
$: processes = client
.getModel()
.findAllSync(plugin.class.Process, { masterTag: { $in: ancestors }, _id: { $ne: process._id } })
</script>
<div class="editor-grid">
<Label label={plugin.string.Process} />
<DropdownLabels
autoSelect={false}
enableSearch={false}
width={'100%'}
{items}
{selected}
placeholder={plugin.string.Process}
on:selected={change}
/>
</div>
@@ -41,5 +41,5 @@
<div class="editor-grid">
<Label label={plugin.string.ToDo} />
<ToDoContextSelector readonly={false} {process} value={_id} on:change={change} />
<ToDoContextSelector readonly={false} skipRollback {process} value={_id} on:change={change} />
</div>
@@ -31,7 +31,7 @@
function change (e: CustomEvent<string>): void {
if (readonly || e.detail == null) return
params._id = e.detail
params.result = undefined
params.result = {}
dispatch('change', { params })
}
@@ -46,6 +46,12 @@
<Label label={plugin.string.ToDo} />
<ToDoContextSelector {readonly} {skipRollback} {process} value={params._id} on:change={change} />
{#if !skipRollback}
<ResultCriteriaEditor {readonly} {process} result={params.result} value={params._id} on:change={changeResult} />
<ResultCriteriaEditor
{readonly}
{process}
result={params.result ?? {}}
value={params._id}
on:change={changeResult}
/>
{/if}
</div>
+2
View File
@@ -37,6 +37,7 @@ import RequestsExtension from './components/RequestsExtension.svelte'
import RunProcessCardPopup from './components/RunProcessCardPopup.svelte'
import RunProcessPopup from './components/RunProcessPopup.svelte'
import ActionsPresenter from './components/settings/ActionsPresenter.svelte'
import CancelSubProcessEditor from './components/settings/CancelSubProcessEditor.svelte'
import CancelToDoEditor from './components/settings/CancelToDoEditor.svelte'
import FunctionSubmenu from './components/settings/FunctionSubmenu.svelte'
import ProcessEditor from './components/settings/ProcessEditor.svelte'
@@ -173,6 +174,7 @@ export default async (): Promise<Resources> => ({
LockSectionEditor,
UnLockSectionPresenter,
CancelToDoEditor,
CancelSubProcessEditor,
ToDoValuePresenter
},
criteriaEditor: {
+2
View File
@@ -30,6 +30,7 @@ export default mergeIds(processId, process, {
ProcessEditor: '' as AnyComponent,
ProcessesSettingSection: '' as AnyComponent,
SubProcessEditor: '' as AnyComponent,
CancelSubProcessEditor: '' as AnyComponent,
ApproveRequestEditor: '' as AnyComponent,
ApproveRequestPresenter: '' as AnyComponent,
ApproveRequestTriggerEditor: '' as AnyComponent,
@@ -121,6 +122,7 @@ export default mergeIds(processId, process, {
DeleteState: '' as IntlString,
DeleteStateConfirm: '' as IntlString,
RunProcess: '' as IntlString,
CancelProcess: '' as IntlString,
Processes: '' as IntlString,
Untitled: '' as IntlString,
States: '' as IntlString,
+1
View File
@@ -237,6 +237,7 @@ export default plugin(processId, {
},
method: {
RunSubProcess: '' as Ref<Method<Process>>,
CancelSubProcess: '' as Ref<Method<Process>>,
CreateAction: '' as Ref<Method<EventButton>>,
CancellAction: '' as Ref<Method<EventButton>>,
CreateToDo: '' as Ref<Method<ProcessToDo>>,
@@ -126,7 +126,7 @@ export async function CheckSubProcessMatch (
)
if (predicate === '$all') {
return res.length === subExecutions.length
} else if (predicate === '$any') {
} else if (predicate === '$in') {
return res.length > 0
} else if (predicate === '$nin') {
return res.length === 0
@@ -337,6 +337,38 @@ export async function AddTag (
}
}
export async function CancelSubProcess (
params: MethodParams<Execution>,
execution: Execution,
control: ProcessControl
): Promise<ExecuteResult> {
const processId = params._id as Ref<Process>
if (processId === undefined) throw processError(process.error.RequiredParamsNotProvided, { params: '_id' })
const target = control.client.getModel().findObject(processId)
if (target === undefined) throw processError(process.error.ObjectNotFound, { _id: processId })
const res: Tx[] = []
const rollback: Tx[] = []
const executions = await control.client.findAll(process.class.Execution, {
card: execution.card,
process: processId,
status: ExecutionStatus.Active
})
for (const exec of executions) {
res.push(
control.client.txFactory.createTxUpdateDoc(process.class.Execution, execution.space, exec._id, {
status: ExecutionStatus.Cancelled
})
)
rollback.push(
control.client.txFactory.createTxUpdateDoc(process.class.Execution, execution.space, exec._id, {
status: ExecutionStatus.Active
})
)
}
return { txes: res, rollback, context: null }
}
export async function RunSubProcess (
params: MethodParams<Execution>,
execution: Execution,
@@ -611,7 +643,7 @@ export async function CancelToDo (
): Promise<ExecuteResult> {
if (params._id === undefined) throw processError(process.error.RequiredParamsNotProvided, { params: '_id' })
const todo = await control.client.findOne(process.class.ProcessToDo, { _id: params._id as any })
if (todo === undefined) throw processError(process.error.ObjectNotFound, { _id: params._id })
if (todo === undefined) return { txes: [], rollback: [], context: null }
if (todo.doneOn !== null) throw processError(process.error.ToDoAlreadyCompleted, { _id: params._id })
const res: Tx[] = [control.client.txFactory.createTxRemoveDoc(todo._class, todo.space, todo._id)]
const rollback: Tx[] = [
@@ -89,6 +89,7 @@ import {
} from './transform'
import {
RunSubProcess,
CancelSubProcess,
CreateToDo,
UpdateCard,
CreateCard,
@@ -489,6 +490,7 @@ export * from './utils'
export default async () => ({
func: {
RunSubProcess,
CancelSubProcess,
CreateToDo,
UpdateCard,
CreateCard,
+1
View File
@@ -48,6 +48,7 @@ export default plugin(serverProcessId, {
},
func: {
RunSubProcess: '' as Resource<ExecuteFunc>,
CancelSubProcess: '' as Resource<ExecuteFunc>,
CreateToDo: '' as Resource<ExecuteFunc>,
UpdateCard: '' as Resource<ExecuteFunc>,
CreateCard: '' as Resource<ExecuteFunc>,
+7 -9
View File
@@ -488,10 +488,10 @@ async function executeTransition (
)
await client.tx(apply)
await sendEvent(control, execution, transition, card, isDone)
if (isDone && execution.parentId !== undefined) {
await checkParent(execution, control)
}
TxProcessor.applyUpdate(execution, executionUpdate)
if (execution.parentId !== undefined) {
await checkParent(execution, control, isDone)
}
currTransition = transition
transition = await checkNext(control, execution, context)
nested = true
@@ -734,28 +734,26 @@ async function fillParams<T extends Doc> (
return res
}
async function checkParent (execution: Execution, control: ProcessControl): Promise<void> {
async function checkParent (execution: Execution, control: ProcessControl, isDone: boolean): Promise<void> {
try {
const subProcesses = await control.client.findAll(process.class.Execution, {
parentId: execution.parentId,
status: ExecutionStatus.Active
})
const filtered = subProcesses.filter((it) => it._id !== execution._id)
if (filtered.length !== 0) return
const parent = (await control.client.findAll(process.class.Execution, { _id: execution.parentId }))[0]
if (parent === undefined) return
const _process = control.client.getModel().findObject(parent.process)
if (_process === undefined) return
if (parent.status !== ExecutionStatus.Active) return
const triggers: Ref<Trigger>[] = [process.trigger.OnSubProcessMatch]
if (filtered.length === 0 && isDone) triggers.push(process.trigger.OnSubProcessesDone)
const transitions = control.client.getModel().findAllSync(
process.class.Transition,
{
from: parent.currentState,
process: parent.process,
trigger:
filtered.length === 0
? { $in: [process.trigger.OnSubProcessesDone, process.trigger.OnSubProcessMatch] }
: process.trigger.OnSubProcessMatch
trigger: { $in: triggers }
},
{
sort: { rank: SortingOrder.Ascending }