Merge branch 'develop' into staging-new

This commit is contained in:
Denis Bykhov
2026-02-27 05:31:49 +05:00
25 changed files with 337 additions and 25 deletions
+17
View File
@@ -221,6 +221,9 @@ export class TApproveRequest extends TProcessToDo implements ApproveRequest {
group!: string
card!: Ref<Card>
@Prop(TypeString(), process.string.ActionType)
actionType?: 'approve' | 'review'
}
@Model(process.class.Method, core.class.Doc, DOMAIN_MODEL)
@@ -478,7 +481,17 @@ export function createModel (builder: Builder): void {
strict: true
},
config: [
{
key: 'execution',
label: process.string.Process,
presenter: process.component.ExecutionRefPresenter
},
'user',
{
key: 'actionType',
label: process.string.ActionType,
presenter: process.component.ActionTypePresenter
},
{
key: '',
presenter: view.component.GrowPresenter,
@@ -495,6 +508,10 @@ export function createModel (builder: Builder): void {
process.viewlet.CardRequests
)
builder.mixin(process.class.ApproveRequest, core.class.Class, view.mixin.IgnoreActions, {
actions: [view.action.Delete]
})
builder.createDoc(
view.class.Viewlet,
core.space.Model,
+6 -1
View File
@@ -145,7 +145,12 @@
"CancelToDo": "Zrušit akční položku",
"Export": "Exportovat",
"Import": "Importovat",
"CancelProcess": "Zrušit proces"
"CancelProcess": "Zrušit proces",
"SyncWithField": "Synchronizovat s polem",
"ActionType": "Typ",
"ApproveAction": "Schválit",
"ReviewAction": "Zkontrolovat",
"Review": "Zkontrolovat"
},
"error": {
"MethodNotFound": "Metoda nenalezena: {methodId}",
+6 -1
View File
@@ -145,7 +145,12 @@
"CancelToDo": "Action Item abbrechen",
"Export": "Exportieren",
"Import": "Importieren",
"CancelProcess": "Prozess abbrechen"
"CancelProcess": "Prozess abbrechen",
"SyncWithField": "Synchronisieren mit Feld",
"ActionType": "Typ",
"ApproveAction": "Genehmigen",
"ReviewAction": "Prüfen",
"Review": "Prüfen"
},
"error": {
"MethodNotFound": "Methode nicht gefunden: {methodId}",
+6 -1
View File
@@ -150,7 +150,12 @@
"CancelToDo": "Cancel Action item",
"Export": "Export",
"Import": "Import",
"CancelProcess": "Cancel process"
"CancelProcess": "Cancel process",
"SyncWithField": "Sync with field",
"ActionType": "Type",
"ApproveAction": "Approve",
"ReviewAction": "Review",
"Review": "Review"
},
"error": {
"MethodNotFound": "Method not found: {methodId}",
+6 -1
View File
@@ -150,7 +150,12 @@
"CancelToDo": "Cancelar acción",
"Export": "Exportar",
"Import": "Importar",
"CancelProcess": "Cancelar proceso"
"CancelProcess": "Cancelar proceso",
"SyncWithField": "Sincronizar con campo",
"ActionType": "Tipo",
"ApproveAction": "Aprobar",
"ReviewAction": "Revisar",
"Review": "Revisar"
},
"error": {
"MethodNotFound": "Método no encontrado: {methodId}",
+6 -1
View File
@@ -150,7 +150,12 @@
"CancelToDo": "Annuler l'action",
"Export": "Exporter",
"Import": "Importer",
"CancelProcess": "Annuler le processus"
"CancelProcess": "Annuler le processus",
"SyncWithField": "Synchroniser avec le champ",
"ActionType": "Type",
"ApproveAction": "Approuver",
"ReviewAction": "Revoir",
"Review": "Revoir"
},
"error": {
"MethodNotFound": "Méthode introuvable : {methodId}",
+6 -1
View File
@@ -150,7 +150,12 @@
"CancelToDo": "Annulla azione",
"Export": "Esporta",
"Import": "Importa",
"CancelProcess": "Annulla processo"
"CancelProcess": "Annulla processo",
"SyncWithField": "Sincronizza con campo",
"ActionType": "Tipo",
"ApproveAction": "Approva",
"ReviewAction": "Revisa",
"Review": "Revisa"
},
"error": {
"MethodNotFound": "Metodo non trovato: {methodId}",
+6 -1
View File
@@ -149,7 +149,12 @@
"CancelToDo": "アクションをキャンセル",
"Export": "エクスポート",
"Import": "インポート",
"CancelProcess": "プロセスをキャンセル"
"CancelProcess": "プロセスをキャンセル",
"SyncWithField": "フィールドと同期",
"ActionType": "タイプ",
"ApproveAction": "承認",
"ReviewAction": "レビュー",
"Review": "レビュー"
},
"error": {
"MethodNotFound": "メソッドが見つかりません: {methodId}",
+6 -1
View File
@@ -138,7 +138,12 @@
"CancelToDo": "Cancelar ação",
"Export": "Exportar",
"Import": "Importar",
"CancelProcess": "Cancelar processo"
"CancelProcess": "Cancelar processo",
"SyncWithField": "Sincronizar com campo",
"ActionType": "Tipo",
"ApproveAction": "Aprovar",
"ReviewAction": "Revisar",
"Review": "Revisar"
},
"error": {
"MethodNotFound": "Método não encontrado: {methodId}",
+6 -1
View File
@@ -150,7 +150,12 @@
"CancelToDo": "Cancelar ação",
"Export": "Exportar",
"Import": "Importar",
"CancelProcess": "Cancelar processo"
"CancelProcess": "Cancelar processo",
"SyncWithField": "Sincronizar com campo",
"ActionType": "Tipo",
"ApproveAction": "Aprovar",
"ReviewAction": "Revisar",
"Review": "Revisar"
},
"error": {
"MethodNotFound": "Método não encontrado: {methodId}",
+6 -1
View File
@@ -150,7 +150,12 @@
"CancelToDo": "Отменить задачу",
"Export": "Экспорт",
"Import": "Импорт",
"CancelProcess": "Отменить процесс"
"CancelProcess": "Отменить процесс",
"SyncWithField": "Синхронизировать с полем",
"ActionType": "Тип",
"ApproveAction": "Утверждение",
"ReviewAction": "Рецензирование",
"Review": "Рецензировать"
},
"error": {
"MethodNotFound": "Метод не найден: {methodId}",
+6 -1
View File
@@ -147,7 +147,12 @@
"CancelToDo": "Eylem öğesini iptal et",
"Export": "Dışa aktar",
"Import": "İçe aktar",
"CancelProcess": "Süreci iptal et"
"CancelProcess": "Süreci iptal et",
"SyncWithField": "Alan ile senkronize et",
"ActionType": "Tür",
"ApproveAction": "Onayla",
"ReviewAction": "Gözden geçir",
"Review": "Gözden geçir"
},
"error": {
"MethodNotFound": "Metod bulunamadı: {methodId}",
+6 -1
View File
@@ -150,7 +150,12 @@
"CancelToDo": "取消任务",
"Export": "导出",
"Import": "导入",
"CancelProcess": "取消过程"
"CancelProcess": "取消过程",
"SyncWithField": "字段同步",
"ActionType": "类型",
"ApproveAction": "批准",
"ReviewAction": "审查",
"Review": "审查"
},
"error": {
"MethodNotFound": "找不到方法:{methodId}",
@@ -0,0 +1,22 @@
<!--
// 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 { Label } from '@hcengineering/ui'
import plugin from '../plugin'
export let value: 'approve' | 'review' | undefined
</script>
<Label label={value === 'review' ? plugin.string.ReviewAction : plugin.string.ApproveAction} />
@@ -45,5 +45,9 @@
}
</script>
<Button label={process.string.Approve} kind="positive" on:click={(ev) => changeApprovalRequestState(ev, false)} />
<Button
label={todo.actionType === 'review' ? process.string.Review : process.string.Approve}
kind="positive"
on:click={(ev) => changeApprovalRequestState(ev, false)}
/>
<Button label={process.string.Reject} kind="negative" on:click={(ev) => changeApprovalRequestState(ev, true)} />
@@ -0,0 +1,38 @@
<!--
// 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 { Ref } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Execution } from '@hcengineering/process'
import plugin from '../plugin'
export let value: Ref<Execution>
const client = getClient()
const query = createQuery()
let execution: Execution | undefined
$: query.query(plugin.class.Execution, { _id: value }, (res) => {
execution = res[0]
})
$: process = execution !== undefined ? client.getModel().findObject(execution.process) : undefined
</script>
{#if process}
{process.name}
{/if}
@@ -16,8 +16,8 @@
import contact from '@hcengineering/contact'
import core, { AnyAttribute } from '@hcengineering/core'
import { getAttributeEditor, getAttributePresenterClass, getClient } from '@hcengineering/presentation'
import { ApproveRequest, Process, Step } from '@hcengineering/process'
import { AnySvelteComponent } from '@hcengineering/ui'
import { ApproveRequest, parseContext, Process, Step } from '@hcengineering/process'
import { AnySvelteComponent, Label, Toggle } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import plugin from '../../plugin'
import { getContext, getMockAttribute } from '../../utils'
@@ -78,6 +78,33 @@
}
getBaseEditor(attribute)
$: contextValue = typeof params.user === 'string' ? parseContext(params.user) : undefined
$: userContext = contextValue?.type === 'attribute'
$: if (!userContext && params.field) {
delete (params as any).field
step.params = params
dispatch('change', step)
}
function toggleField (e: CustomEvent<boolean>): void {
if (e.detail) {
if (contextValue?.type === 'attribute') {
params.field = contextValue.key
}
} else {
delete (params as any).field
}
step.params = params
dispatch('change', step)
}
function toggleActionType (e: CustomEvent<any>): void {
params.actionType = e.detail ? 'review' : 'approve'
step.params = params
dispatch('change', step)
}
</script>
<div class="grid">
@@ -93,6 +120,14 @@
on:remove
on:change={onChange}
/>
<div>
<Label label={plugin.string.SyncWithField} />
</div>
<Toggle disabled={!userContext} on={params.field !== undefined} on:change={toggleField} />
<div>
<Label label={plugin.string.ReviewAction} />
</div>
<Toggle on={params.actionType === 'review'} on:change={toggleActionType} />
</div>
<ParamsEditor _class={plugin.class.ApproveRequest} {process} {keys} {params} on:change={changeParams} />
@@ -13,7 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { Process, ProcessToDo, Step, UserResult } from '@hcengineering/process'
import { parseContext, Process, ProcessToDo, Step, UserResult } from '@hcengineering/process'
import { createEventDispatcher } from 'svelte'
import plugin from '../../plugin'
import ParamsEditor from './ParamsEditor.svelte'
@@ -43,6 +43,27 @@
}
const keys = ['title', 'user', 'dueDate']
$: contextValue = typeof params.user === 'string' ? parseContext(params.user) : undefined
$: userContext = contextValue?.type === 'attribute'
$: if (!userContext && params.field) {
delete (params as any).field
step.params = params
dispatch('change', step)
}
function toggleField (e: CustomEvent<boolean>): void {
if (e.detail) {
if (contextValue?.type === 'attribute') {
params.field = contextValue.key
}
} else {
delete (params as any).field
}
step.params = params
dispatch('change', step)
}
</script>
<ParamsEditor _class={plugin.class.ProcessToDo} {process} {keys} {params} on:change={changeParams} />
@@ -60,6 +81,12 @@
}}
/>
</div>
<div class="grid">
<div>
<Label label={plugin.string.SyncWithField} />
</div>
<Toggle disabled={!userContext} on={params.field !== undefined} on:change={toggleField} />
</div>
<div class="divider" />
<ResultsEditor {process} result={step.results} on:change={changeResults} />
+6 -1
View File
@@ -89,6 +89,9 @@ import ReplaceEditor from './components/transformEditors/ReplaceEditor.svelte'
import SplitEditor from './components/transformEditors/SplitEditor.svelte'
import NumberPresenter from './components/transformPresenters/NumberPresenter.svelte'
import RolePresenter from './components/transformPresenters/RolePresenter.svelte'
import ExecutionRefPresenter from './components/ExecutionRefPresenter.svelte'
import ActionTypePresenter from './components/ActionTypePresenter.svelte'
import { exportProcesses, importProcess } from './exporter'
import { ProcessMiddleware } from './middleware'
import {
@@ -175,7 +178,9 @@ export default async (): Promise<Resources> => ({
UnLockSectionPresenter,
CancelToDoEditor,
CancelSubProcessEditor,
ToDoValuePresenter
ToDoValuePresenter,
ExecutionRefPresenter,
ActionTypePresenter
},
criteriaEditor: {
BaseCriteria,
+3 -1
View File
@@ -90,7 +90,9 @@ export default mergeIds(processId, process, {
LockSectionPresenter: '' as AnyComponent,
UnLockSectionPresenter: '' as AnyComponent,
CancelToDoEditor: '' as AnyComponent,
ToDoValuePresenter: '' as AnyComponent
ToDoValuePresenter: '' as AnyComponent,
ActionTypePresenter: '' as AnyComponent,
ExecutionRefPresenter: '' as AnyComponent
},
criteriaEditor: {
BaseCriteria: '' as AnyComponent,
+9 -1
View File
@@ -124,6 +124,7 @@ export interface ProcessToDo extends ToDo {
withRollback: boolean
results?: UserResult[]
field?: string
}
export interface ApproveRequest extends ProcessToDo {
@@ -134,6 +135,8 @@ export interface ApproveRequest extends ProcessToDo {
reason?: string
group: string
field?: string
actionType?: 'approve' | 'review'
}
export type MethodParams<T extends Doc> = {
@@ -291,7 +294,12 @@ export default plugin(processId, {
Actions: '' as IntlString,
ProcessRunned: '' as IntlString,
ProcessStateChanged: '' as IntlString,
ProcessFinished: '' as IntlString
ProcessFinished: '' as IntlString,
SyncWithField: '' as IntlString,
ActionType: '' as IntlString,
ApproveAction: '' as IntlString,
ReviewAction: '' as IntlString,
Review: '' as IntlString
},
error: {
MethodNotFound: '' as IntlString,
@@ -465,7 +465,9 @@ export async function RequestApproval (
control: ProcessControl,
results: UserResult[] | undefined
): Promise<ExecuteResult> {
if (params.user === undefined) throw processError(process.error.RequiredParamsNotProvided, { params: 'user' })
if (params.user === undefined || params.user.length === 0) {
throw processError(process.error.RequiredParamsNotProvided, { params: 'user' })
}
const group = generateId()
const res: TxCreateDoc<ApproveRequest>[] = []
const rollback: Tx[] = []
@@ -491,7 +493,9 @@ export async function RequestApproval (
rank: '',
withRollback: params.withRollback ?? false,
results,
group
group,
actionType: params.actionType,
field: (params as any).field
},
id
)
@@ -652,7 +656,8 @@ export async function CreateToDo (
doneOn: null,
rank: '',
withRollback: params.withRollback ?? false,
results
results,
field: (params as any).field
},
id
)
@@ -17,6 +17,8 @@ import cardPlugin, { Card } from '@hcengineering/card'
import core, {
ArrOf,
Doc,
DocumentUpdate,
generateId,
Ref,
RefTo,
Tx,
@@ -227,6 +229,7 @@ export async function OnProcessToDoRemove (txes: Tx[], control: TriggerControl):
if (tx._class !== core.class.TxRemoveDoc) continue
const removeTx = tx as TxRemoveDoc<ProcessToDo>
if (!control.hierarchy.isDerived(removeTx.objectClass, process.class.ProcessToDo)) continue
if (removeTx.space === core.space.DerivedTx) continue
const removedTodo = control.removedMap.get(removeTx.objectId) as ProcessToDo
if (removedTodo === undefined) continue
await putEventToQueue(
@@ -346,6 +349,83 @@ async function getExecutionReassignTxes (card: Card, control: TriggerControl): P
return res
}
async function reassignToDos (card: Card, ops: DocumentUpdate<Card>, control: TriggerControl): Promise<Tx[]> {
const res: Tx[] = []
const todos = await control.findAll(control.ctx, process.class.ProcessToDo, {
attachedTo: card._id,
doneOn: null,
field: { $ne: null }
} as any)
const handledGroups = new Set<string>()
for (const todo of todos as any[]) {
if (todo.field === undefined || !TxProcessor.hasUpdate(ops, todo.field)) continue
if (todo._class === process.class.ApproveRequest) {
const request = todo as ApproveRequest
if (handledGroups.has(request.group)) continue
handledGroups.add(request.group)
const newUsers = (card[todo.field as keyof Card] as any[]) ?? []
if (newUsers.length === 0) {
continue
}
const currentRequests = await control.findAll(control.ctx, process.class.ApproveRequest, {
group: request.group,
doneOn: null
})
const currentUsers = currentRequests.map((r) => r.user)
// Remove users not in new list
for (const req of currentRequests) {
if (!newUsers.includes(req.user)) {
res.push(control.txFactory.createTxRemoveDoc(req._class, req.space, req._id))
}
}
// Add users not in current list
for (const user of newUsers) {
if (!currentUsers.includes(user)) {
const id = generateId<ApproveRequest>()
const { _id, modifiedBy, modifiedOn, ...data } = request as any
res.push(
control.txFactory.createTxCreateDoc(
request._class,
request.space,
{
...data,
user
},
id
)
)
}
}
} else {
const newUser = card[todo.field as keyof Card]
if (newUser === todo.user) continue
if (newUser != null) {
res.push(control.txFactory.createTxRemoveDoc(todo._class, todo.space, todo._id))
const id = generateId<ProcessToDo>()
const { _id, modifiedBy, modifiedOn, ...data } = todo
res.push(
control.txFactory.createTxCreateDoc(
todo._class,
todo.space,
{
...data,
user: newUser
},
id
)
)
}
}
}
return res
}
export async function OnCardCreate (txes: Tx[], control: TriggerControl): Promise<Tx[]> {
const res: Tx[] = []
for (const tx of txes) {
@@ -403,6 +483,8 @@ export async function OnCardUpdate (txes: Tx[], control: TriggerControl): Promis
},
control
)
const reassignTxes = await reassignToDos(card[0], ops ?? {}, control)
res.push(...reassignTxes)
}
return res
}
+9 -2
View File
@@ -284,7 +284,7 @@ async function processExecution (control: ProcessControl, record: ProcessMessage
}
}
if (isRollback(record)) {
await rollback(control, record, execution)
await rollback(control, execution)
}
}
@@ -298,7 +298,8 @@ function isRollback (record: ProcessMessage): boolean {
return false
}
async function rollback (control: ProcessControl, record: ProcessMessage, execution: Execution): Promise<void> {
async function rollback (control: ProcessControl, execution: Execution): Promise<void> {
if (execution.rollback.length === 0) return
const rollbackTxes = execution.rollback.pop() ?? []
for (const tx of rollbackTxes) {
const timeout = setTimeout(() => {
@@ -719,6 +720,12 @@ async function fillParams<T extends Doc> (
execution: Execution,
control: ProcessControl
): Promise<MethodParams<T>> {
if (!control.cache.has(execution.card)) {
const card = await control.client.findOne(cardPlugin.class.Card, { _id: execution.card })
if (card !== undefined) {
control.cache.set(execution.card, card)
}
}
const res: MethodParams<T> = {}
for (const key in params) {
const value = control.client.getHierarchy().clone((params as any)[key])
+1 -1
View File
@@ -26,7 +26,7 @@ export interface Config {
const config: Config = {
DbUrl: process.env.DB_URL ?? 'postgres://localhost:5432/huly',
PollInterval: process.env.POLL_INTERVAL != null ? Number(process.env.POLL_INTERVAL) : 20000,
QueueRegion: process.env.QUEUE_REGION ?? 'cockroach',
QueueRegion: process.env.QUEUE_REGION ?? '',
QueueConfig: process.env.QUEUE_CONFIG ?? ''
}