@haiiro/docuflow 0.0.11 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -9
- package/manifest.json +117 -16
- package/package.json +6 -3
- package/public/app/docflow-app.css +2 -0
- package/public/app/docflow-app.js +94 -0
- package/src/front-components/docflow-documents.front-component.mjs +42 -42
- package/src/front-components/docflow-documents.front-component.tsx +336 -128
- package/src/front-components/docflow-processes.front-component.mjs +35 -35
- package/src/front-components/docflow-processes.front-component.tsx +527 -131
- package/src/front-components/docflow-tasks.front-component.mjs +46 -41
- package/src/front-components/docflow-tasks.front-component.tsx +489 -132
- package/src/front-components/launch-process.front-component.mjs +75 -0
- package/src/front-components/launch-process.front-component.tsx +270 -0
- package/src/front-components/process-steps.front-component.mjs +25 -25
- package/src/front-components/process-steps.front-component.tsx +55 -10
- package/src/front-components/task-form.front-component.mjs +46 -41
- package/src/logic-functions/bitrix-app.logic-function.mjs +424 -198
- package/src/logic-functions/bitrix-app.logic-function.ts +54 -82
- package/src/logic-functions/bitrix-install.logic-function.mjs +0 -11
- package/src/logic-functions/bitrix-install.logic-function.ts +0 -3
- package/src/logic-functions/capture-file.logic-function.mjs +2 -1
- package/src/logic-functions/docflow-app-session.logic-function.mjs +60043 -0
- package/src/logic-functions/docflow-app-session.logic-function.ts +54 -0
- package/src/logic-functions/docflow-app.logic-function.mjs +60122 -0
- package/src/logic-functions/docflow-app.logic-function.ts +37 -0
- package/src/logic-functions/hand-out-form-step.logic-function.mjs +2 -1
- package/src/logic-functions/launch-process.logic-function.mjs +113 -29
- package/src/logic-functions/launch-process.logic-function.ts +83 -22
- package/src/logic-functions/on-workflow-run-updated.logic-function.mjs +2 -1
- package/src/logic-functions/publish-process.logic-function.mjs +119519 -0
- package/src/logic-functions/publish-process.logic-function.ts +91 -0
- package/src/logic-functions/save-launch-fields.logic-function.mjs +178 -10
- package/src/logic-functions/save-launch-fields.logic-function.ts +24 -6
|
@@ -1,15 +1,47 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import { defineFrontComponent } from 'twenty-sdk/define';
|
|
3
3
|
import { AppPath, enqueueSnackbar, navigate } from 'twenty-sdk/front-component';
|
|
4
|
-
import { IconPencil, IconPlayerPlay, IconSettings, IconTrash } from 'twenty-ui/icon';
|
|
5
|
-
import { Tag } from 'twenty-ui/primitives/data-display';
|
|
4
|
+
import { IconPencil, IconPlayerPlay, IconPlayerStop, IconRocket, IconSettings, IconTrash } from 'twenty-ui/icon';
|
|
6
5
|
import { useTheme } from 'twenty-ui/theme';
|
|
7
6
|
|
|
8
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
callAppRoute,
|
|
9
|
+
countRecords,
|
|
10
|
+
createRecord,
|
|
11
|
+
deleteRecords,
|
|
12
|
+
getErrorMessage,
|
|
13
|
+
listRecords,
|
|
14
|
+
updateRecord,
|
|
15
|
+
} from 'src/front/api';
|
|
16
|
+
import {
|
|
17
|
+
formatDate,
|
|
18
|
+
formatDateTime,
|
|
19
|
+
formatMemberName,
|
|
20
|
+
RUN_STATUS_COLORS,
|
|
21
|
+
RUN_STATUS_LABELS,
|
|
22
|
+
STEP_TYPE_LABELS,
|
|
23
|
+
} from 'src/front/format';
|
|
9
24
|
import { useAsync, useDebouncedValue } from 'src/front/hooks/use-async';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
25
|
+
import { useBulkAction } from 'src/front/hooks/use-bulk-action';
|
|
26
|
+
import { useWorkspaceMembers } from 'src/front/hooks/use-workspace-members';
|
|
27
|
+
import { type DocflowWorkflowRun, type WorkspaceMember } from 'src/front/types';
|
|
28
|
+
import {
|
|
29
|
+
DataTable,
|
|
30
|
+
DocflowRoot,
|
|
31
|
+
formatTotal,
|
|
32
|
+
ListToolbar,
|
|
33
|
+
Message,
|
|
34
|
+
SectionTitle,
|
|
35
|
+
SelectionBar,
|
|
36
|
+
SplitView,
|
|
37
|
+
type TableColumn,
|
|
38
|
+
type TableSort,
|
|
39
|
+
useHiddenColumns,
|
|
40
|
+
useRowSelection,
|
|
41
|
+
} from 'src/front/ui/components';
|
|
12
42
|
import { FormFieldInputs } from 'src/front/ui/form-field-inputs';
|
|
43
|
+
import { Button, type MenuAction, MenuButton, PromptDialog, StatusChip, ViewTabs } from 'src/front/ui/kit';
|
|
44
|
+
import { Timeline } from 'src/front/ui/timeline';
|
|
13
45
|
import { restAnd, restEq, restIlike, restIn } from 'src/shared/api/rest-filter.util';
|
|
14
46
|
import { DOCFLOW_SECTION } from 'src/shared/docflow.constants';
|
|
15
47
|
import { type FormField, type FormValues } from 'src/shared/form/form-field.type';
|
|
@@ -19,20 +51,33 @@ import {
|
|
|
19
51
|
buildLaunchParameters,
|
|
20
52
|
isLaunchableTrigger,
|
|
21
53
|
isLaunchParametersStep,
|
|
54
|
+
isLaunchTrigger,
|
|
22
55
|
parseLaunchFields,
|
|
56
|
+
parseLaunchObject,
|
|
23
57
|
} from 'src/shared/workflow/launch-step.util';
|
|
58
|
+
import { orderRunSteps } from 'src/shared/workflow/run-timeline.util';
|
|
24
59
|
import { type WorkflowStep, type WorkflowTrigger } from 'src/shared/workflow/workflow-run.type';
|
|
60
|
+
import { getFormStepAssignment, getFormStepFields, isFormStep } from 'src/shared/workflow/workflow-run.util';
|
|
25
61
|
import { PROCESSES_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/universal-identifiers';
|
|
26
62
|
|
|
27
63
|
const PROCESSES_LIMIT = 200;
|
|
28
64
|
const SEARCH_DEBOUNCE_MS = 300;
|
|
29
65
|
const DRAFT_WAIT_ATTEMPTS = 10;
|
|
30
66
|
const DRAFT_WAIT_MS = 400;
|
|
67
|
+
const RECENT_RUNS_LIMIT = 5;
|
|
68
|
+
const PROCESS_NOUN: [string, string, string] = ['процесс', 'процесса', 'процессов'];
|
|
31
69
|
|
|
32
70
|
type Panel = { kind: 'closed' } | { kind: 'process'; workflowId: string };
|
|
33
|
-
type
|
|
71
|
+
type ProcessView = 'all' | 'active' | 'draft';
|
|
72
|
+
type ParametersFilter = 'any' | 'with' | 'without';
|
|
34
73
|
|
|
35
|
-
type WorkflowRecord = {
|
|
74
|
+
type WorkflowRecord = {
|
|
75
|
+
id: string;
|
|
76
|
+
name?: string | null;
|
|
77
|
+
smbLaunchFields?: unknown;
|
|
78
|
+
smbLaunchObject?: string | null;
|
|
79
|
+
updatedAt?: string | null;
|
|
80
|
+
};
|
|
36
81
|
type WorkflowVersionRecord = {
|
|
37
82
|
id: string;
|
|
38
83
|
workflowId: string;
|
|
@@ -46,13 +91,41 @@ type DocflowProcess = {
|
|
|
46
91
|
// The published version people launch; none while the process is a draft
|
|
47
92
|
workflowVersionId: string | undefined;
|
|
48
93
|
name: string;
|
|
94
|
+
// Launched from here: published and started by hand
|
|
49
95
|
isPublished: boolean;
|
|
96
|
+
// Has a published version, whatever starts it
|
|
97
|
+
isActive: boolean;
|
|
98
|
+
// A draft edited after the last publishing
|
|
99
|
+
hasUnpublishedChanges: boolean;
|
|
50
100
|
launchFields: FormField[];
|
|
51
|
-
|
|
101
|
+
// The object whose records the published version is launched on: such a
|
|
102
|
+
// process starts from a record, with "Запустить процесс"
|
|
103
|
+
launchObject: string | undefined;
|
|
104
|
+
// The steps in the order a run goes through them
|
|
105
|
+
stages: WorkflowStep[];
|
|
106
|
+
updatedAt?: string | null;
|
|
52
107
|
};
|
|
53
108
|
|
|
54
|
-
|
|
55
|
-
|
|
109
|
+
type RunCounts = { total: number; running: number };
|
|
110
|
+
|
|
111
|
+
const COLUMNS: TableColumn[] = [
|
|
112
|
+
{ key: 'name', label: 'Процесс', width: '26%', sortKey: 'name' },
|
|
113
|
+
{ key: 'status', label: 'Статус', width: '13%', sortKey: 'status' },
|
|
114
|
+
{ key: 'parameters', label: 'Параметры', width: '10%', sortKey: 'parameters' },
|
|
115
|
+
{ key: 'stageCount', label: 'Этапов', width: '8%', sortKey: 'stageCount' },
|
|
116
|
+
{ key: 'runs', label: 'Запуски', width: '12%', sortKey: 'runs' },
|
|
117
|
+
{ key: 'stages', label: 'Этапы' },
|
|
118
|
+
{ key: 'updatedAt', label: 'Изменён', width: '11%', sortKey: 'updatedAt' },
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
const VIEWS: { value: ProcessView; label: string }[] = [
|
|
122
|
+
{ value: 'all', label: 'Все' },
|
|
123
|
+
{ value: 'active', label: 'Опубликованные' },
|
|
124
|
+
{ value: 'draft', label: 'Черновики' },
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
const stagesOf = (version: WorkflowVersionRecord | undefined) =>
|
|
128
|
+
orderRunSteps({ flow: { trigger: version?.trigger, steps: version?.steps } }).filter((step) => !isLaunchParametersStep(step));
|
|
56
129
|
|
|
57
130
|
// Docflow processes are the standard workflows with the docflow section:
|
|
58
131
|
// published ones started by hand are launched here, drafts are shown to be
|
|
@@ -73,38 +146,226 @@ const loadProcesses = async (searchQuery: string): Promise<DocflowProcess[]> =>
|
|
|
73
146
|
|
|
74
147
|
const { records: versions } = await listRecords<WorkflowVersionRecord>('workflowVersions', {
|
|
75
148
|
filter: restAnd(
|
|
76
|
-
restIn('status', ['ACTIVE', 'DRAFT']),
|
|
149
|
+
restIn('status', ['ACTIVE', 'DRAFT', 'DEACTIVATED']),
|
|
77
150
|
restIn('workflowId', workflows.map(({ id }) => id)),
|
|
78
151
|
),
|
|
79
|
-
|
|
152
|
+
// Newest first: the latest of the versions taken off comes first
|
|
153
|
+
order_by: 'createdAt[DescNullsLast]',
|
|
154
|
+
limit: PROCESSES_LIMIT * 5,
|
|
80
155
|
});
|
|
81
156
|
|
|
82
157
|
return workflows.map((workflow) => {
|
|
83
|
-
const active = versions.find(
|
|
84
|
-
(version) => version.workflowId === workflow.id && version.status === 'ACTIVE' && isLaunchableTrigger(version.trigger),
|
|
85
|
-
);
|
|
158
|
+
const active = versions.find((version) => version.workflowId === workflow.id && version.status === 'ACTIVE');
|
|
86
159
|
const draft = versions.find((version) => version.workflowId === workflow.id && version.status === 'DRAFT');
|
|
160
|
+
// Taken off publishing: its steps still show
|
|
161
|
+
const deactivated = versions.find((version) => version.workflowId === workflow.id && version.status === 'DEACTIVATED');
|
|
162
|
+
const isLaunchable = active !== undefined && isLaunchableTrigger(active.trigger);
|
|
87
163
|
|
|
88
164
|
return {
|
|
89
165
|
workflowId: workflow.id,
|
|
90
|
-
workflowVersionId: active
|
|
166
|
+
workflowVersionId: isLaunchable ? active.id : undefined,
|
|
91
167
|
name: workflow.name?.trim() || 'Без названия',
|
|
92
|
-
isPublished:
|
|
168
|
+
isPublished: isLaunchable,
|
|
169
|
+
isActive: active !== undefined,
|
|
170
|
+
hasUnpublishedChanges: active !== undefined && draft !== undefined,
|
|
93
171
|
launchFields: parseLaunchFields(workflow.smbLaunchFields),
|
|
94
|
-
|
|
172
|
+
launchObject: isLaunchTrigger(active?.trigger) ? parseLaunchObject(workflow.smbLaunchObject) : undefined,
|
|
173
|
+
stages: stagesOf(active ?? draft ?? deactivated),
|
|
174
|
+
updatedAt: workflow.updatedAt,
|
|
95
175
|
};
|
|
96
176
|
});
|
|
97
177
|
};
|
|
98
178
|
|
|
179
|
+
// Runs per process: all of them and the ones still going
|
|
180
|
+
const loadRunCounts = async (workflowIds: string[]): Promise<Record<string, RunCounts>> =>
|
|
181
|
+
Object.fromEntries(
|
|
182
|
+
await Promise.all(
|
|
183
|
+
workflowIds.map(async (workflowId) => {
|
|
184
|
+
const [total, running] = await Promise.all([
|
|
185
|
+
countRecords('workflowRuns', restEq('workflowId', workflowId)),
|
|
186
|
+
countRecords('workflowRuns', restAnd(restEq('workflowId', workflowId), restEq('status', 'RUNNING'))),
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
return [workflowId, { total, running }];
|
|
190
|
+
}),
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
|
|
99
194
|
const openEditor = (workflowId: string) =>
|
|
100
195
|
navigate(AppPath.RecordShowPage, { objectNameSingular: 'workflow', objectRecordId: workflowId });
|
|
101
196
|
|
|
102
|
-
const
|
|
197
|
+
const openRun = (runId: string) =>
|
|
198
|
+
navigate(AppPath.RecordShowPage, { objectNameSingular: 'workflowRun', objectRecordId: runId });
|
|
199
|
+
|
|
200
|
+
// The way of the process before it runs: every stage is still ahead
|
|
201
|
+
const ProcessStages = ({ process, members }: { process: DocflowProcess; members: WorkspaceMember[] }) => {
|
|
202
|
+
const [now] = useState(() => new Date());
|
|
203
|
+
|
|
204
|
+
if (process.stages.length === 0) {
|
|
205
|
+
return <Message tone="hint">Этапов пока нет: добавьте шаги в редакторе.</Message>;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<Timeline
|
|
210
|
+
items={[
|
|
211
|
+
{ key: 'start', title: 'Запуск', meta: process.launchFields.length > 0 ? `с параметрами: ${process.launchFields.length}` : 'без параметров', state: 'upcoming' },
|
|
212
|
+
...process.stages.map((step) => {
|
|
213
|
+
const assigneeId = isFormStep(step)
|
|
214
|
+
? getFormStepAssignment({ fields: getFormStepFields(step), now }).assigneeWorkspaceMemberId
|
|
215
|
+
: undefined;
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
key: step.id,
|
|
219
|
+
title: step.name,
|
|
220
|
+
meta: [STEP_TYPE_LABELS[step.type] ?? step.type, formatMemberName(members.find(({ id }) => id === assigneeId))]
|
|
221
|
+
.filter(Boolean)
|
|
222
|
+
.join(' · '),
|
|
223
|
+
state: 'upcoming' as const,
|
|
224
|
+
};
|
|
225
|
+
}),
|
|
226
|
+
]}
|
|
227
|
+
/>
|
|
228
|
+
);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const RecentRuns = ({ workflowId }: { workflowId: string }) => {
|
|
232
|
+
const theme = useTheme();
|
|
233
|
+
const { data: runs, loading } = useAsync(
|
|
234
|
+
async () =>
|
|
235
|
+
(
|
|
236
|
+
await listRecords<DocflowWorkflowRun>('workflowRuns', {
|
|
237
|
+
filter: restEq('workflowId', workflowId),
|
|
238
|
+
order_by: 'createdAt[DescNullsLast]',
|
|
239
|
+
limit: RECENT_RUNS_LIMIT,
|
|
240
|
+
})
|
|
241
|
+
).records,
|
|
242
|
+
[workflowId],
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
if (loading && runs === undefined) {
|
|
246
|
+
return <Message tone="hint">Загрузка…</Message>;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if ((runs ?? []).length === 0) {
|
|
250
|
+
return <Message tone="hint">Запусков ещё не было.</Message>;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
|
255
|
+
{(runs ?? []).map((run) => (
|
|
256
|
+
<button
|
|
257
|
+
key={run.id}
|
|
258
|
+
type="button"
|
|
259
|
+
onClick={() => void openRun(run.id)}
|
|
260
|
+
style={{
|
|
261
|
+
alignItems: 'center',
|
|
262
|
+
background: 'transparent',
|
|
263
|
+
border: 'none',
|
|
264
|
+
borderTop: `1px solid ${theme.border.color.light}`,
|
|
265
|
+
color: theme.font.color.secondary,
|
|
266
|
+
cursor: 'pointer',
|
|
267
|
+
display: 'flex',
|
|
268
|
+
fontFamily: 'inherit',
|
|
269
|
+
fontSize: theme.font.size.sm,
|
|
270
|
+
gap: theme.spacing[2],
|
|
271
|
+
justifyContent: 'space-between',
|
|
272
|
+
padding: `${theme.spacing[2]} 0`,
|
|
273
|
+
textAlign: 'left',
|
|
274
|
+
}}
|
|
275
|
+
>
|
|
276
|
+
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
277
|
+
{formatDateTime(run.startedAt ?? run.createdAt)}
|
|
278
|
+
{run.createdBy?.name ? ` · ${run.createdBy.name}` : ''}
|
|
279
|
+
</span>
|
|
280
|
+
<StatusChip color={RUN_STATUS_COLORS[run.status ?? ''] ?? 'gray'}>
|
|
281
|
+
{RUN_STATUS_LABELS[run.status ?? ''] ?? run.status}
|
|
282
|
+
</StatusChip>
|
|
283
|
+
</button>
|
|
284
|
+
))}
|
|
285
|
+
</div>
|
|
286
|
+
);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const ProcessStatusTags = ({ process }: { process: DocflowProcess }) => (
|
|
290
|
+
<>
|
|
291
|
+
<StatusChip color={process.isActive ? 'green' : 'gray'}>{process.isActive ? 'Опубликован' : 'Черновик'}</StatusChip>
|
|
292
|
+
{process.hasUnpublishedChanges && <StatusChip color="orange">Есть неопубликованные изменения</StatusChip>}
|
|
293
|
+
</>
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
// The name of the process, edited in place: saved on Enter or on leaving
|
|
297
|
+
const ProcessName = ({ name, onSave }: { name: string; onSave: (name: string) => Promise<void> }) => {
|
|
298
|
+
const theme = useTheme();
|
|
299
|
+
const [value, setValue] = useState(name);
|
|
300
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
301
|
+
|
|
302
|
+
const save = async () => {
|
|
303
|
+
const trimmed = value.trim();
|
|
304
|
+
|
|
305
|
+
if (trimmed.length === 0) {
|
|
306
|
+
setValue(name);
|
|
307
|
+
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (trimmed !== name) {
|
|
312
|
+
await onSave(trimmed);
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
return (
|
|
317
|
+
<input
|
|
318
|
+
type="text"
|
|
319
|
+
value={value}
|
|
320
|
+
aria-label="Название процесса"
|
|
321
|
+
title="Нажмите, чтобы переименовать"
|
|
322
|
+
onChange={(event) => setValue(event.target.value)}
|
|
323
|
+
onFocus={() => setIsFocused(true)}
|
|
324
|
+
onBlur={() => {
|
|
325
|
+
setIsFocused(false);
|
|
326
|
+
void save();
|
|
327
|
+
}}
|
|
328
|
+
onKeyDown={(event) => {
|
|
329
|
+
if (event.key === 'Enter') {
|
|
330
|
+
void save();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (event.key === 'Escape') {
|
|
334
|
+
setValue(name);
|
|
335
|
+
}
|
|
336
|
+
}}
|
|
337
|
+
style={{
|
|
338
|
+
background: isFocused ? theme.background.transparent.lighter : 'transparent',
|
|
339
|
+
border: `1px solid ${isFocused ? theme.border.color.medium : 'transparent'}`,
|
|
340
|
+
borderRadius: theme.border.radius.sm,
|
|
341
|
+
boxSizing: 'border-box',
|
|
342
|
+
color: theme.font.color.primary,
|
|
343
|
+
fontFamily: 'inherit',
|
|
344
|
+
fontSize: theme.font.size.lg,
|
|
345
|
+
fontWeight: theme.font.weight.semiBold,
|
|
346
|
+
margin: `0 -${theme.spacing[2]}`,
|
|
347
|
+
outline: 'none',
|
|
348
|
+
padding: `${theme.spacing[1]} ${theme.spacing[2]}`,
|
|
349
|
+
width: `calc(100% + ${theme.spacing[4]})`,
|
|
350
|
+
}}
|
|
351
|
+
/>
|
|
352
|
+
);
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const ProcessPanel = ({
|
|
103
356
|
process,
|
|
357
|
+
members,
|
|
358
|
+
isBusy,
|
|
104
359
|
onLaunched,
|
|
360
|
+
onRename,
|
|
361
|
+
onPublish,
|
|
105
362
|
}: {
|
|
106
363
|
process: DocflowProcess;
|
|
364
|
+
members: WorkspaceMember[];
|
|
365
|
+
isBusy: boolean;
|
|
107
366
|
onLaunched: () => void;
|
|
367
|
+
onRename: (name: string) => Promise<void>;
|
|
368
|
+
onPublish: (publish: boolean) => void;
|
|
108
369
|
}) => {
|
|
109
370
|
const theme = useTheme();
|
|
110
371
|
const [values, setValues] = useState<FormValues>({});
|
|
@@ -130,6 +391,7 @@ const ProcessLaunchForm = ({
|
|
|
130
391
|
values: parameters,
|
|
131
392
|
});
|
|
132
393
|
await enqueueSnackbar({ message: 'Процесс запущен', variant: 'success' });
|
|
394
|
+
setValues({});
|
|
133
395
|
onLaunched();
|
|
134
396
|
} catch (error) {
|
|
135
397
|
setErrorMessage(getErrorMessage(error, 'Не удалось запустить процесс'));
|
|
@@ -138,34 +400,62 @@ const ProcessLaunchForm = ({
|
|
|
138
400
|
}
|
|
139
401
|
};
|
|
140
402
|
|
|
141
|
-
if (!process.isPublished) {
|
|
142
|
-
return (
|
|
143
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[3] }}>
|
|
144
|
-
<Message>Процесс ещё не опубликован: доделайте его в редакторе и нажмите «Активировать».</Message>
|
|
145
|
-
<Button variant="solid" size="md" fullWidth icon={IconSettings} onClick={() => void openEditor(process.workflowId)}>
|
|
146
|
-
Открыть редактор
|
|
147
|
-
</Button>
|
|
148
|
-
</div>
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
403
|
return (
|
|
153
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[
|
|
154
|
-
{
|
|
155
|
-
<
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
404
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[4] }}>
|
|
405
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[2] }}>
|
|
406
|
+
<ProcessName key={process.name} name={process.name} onSave={onRename} />
|
|
407
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: theme.spacing[1] }}>
|
|
408
|
+
<ProcessStatusTags process={process} />
|
|
409
|
+
{process.updatedAt && <StatusChip color="gray">Изменён {formatDate(process.updatedAt)}</StatusChip>}
|
|
410
|
+
</div>
|
|
411
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: theme.spacing[1] }}>
|
|
412
|
+
{(!process.isActive || process.hasUnpublishedChanges) && (
|
|
413
|
+
<Button variant="solid" tone="accent" icon={IconRocket} loading={isBusy} onClick={() => onPublish(true)}>
|
|
414
|
+
{process.isActive ? 'Опубликовать изменения' : 'Опубликовать'}
|
|
415
|
+
</Button>
|
|
416
|
+
)}
|
|
417
|
+
{process.isActive && (
|
|
418
|
+
<Button icon={IconPlayerStop} disabled={isBusy} onClick={() => onPublish(false)}>
|
|
419
|
+
Снять с публикации
|
|
420
|
+
</Button>
|
|
421
|
+
)}
|
|
422
|
+
<Button icon={IconSettings} onClick={() => void openEditor(process.workflowId)}>
|
|
423
|
+
Открыть редактор
|
|
424
|
+
</Button>
|
|
425
|
+
</div>
|
|
426
|
+
</div>
|
|
427
|
+
{process.isPublished && process.launchObject !== undefined ? (
|
|
428
|
+
<Message tone="hint">
|
|
429
|
+
Процесс запускается на записи: откройте или выберите запись и нажмите «Запустить процесс» в её меню.
|
|
430
|
+
</Message>
|
|
431
|
+
) : process.isPublished ? (
|
|
432
|
+
<section style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[3] }}>
|
|
433
|
+
<SectionTitle>Запуск</SectionTitle>
|
|
434
|
+
{process.launchFields.length > 0 ? (
|
|
435
|
+
<FormFieldInputs fields={process.launchFields} values={values} onChange={setValues} disabled={isLaunching} />
|
|
436
|
+
) : (
|
|
437
|
+
<Message tone="hint">Процесс запускается без параметров.</Message>
|
|
438
|
+
)}
|
|
439
|
+
{errorMessage !== undefined && <Message tone="danger">{errorMessage}</Message>}
|
|
440
|
+
<Button variant="solid" tone="accent" size="md" fullWidth icon={IconPlayerPlay} loading={isLaunching} onClick={() => void handleLaunch()}>
|
|
441
|
+
Запустить
|
|
442
|
+
</Button>
|
|
443
|
+
</section>
|
|
159
444
|
) : (
|
|
160
|
-
<Message
|
|
445
|
+
<Message tone="hint">
|
|
446
|
+
{process.isActive
|
|
447
|
+
? 'Процесс опубликован, но запускается не вручную: его запускает событие или расписание.'
|
|
448
|
+
: 'Процесс ещё не опубликован: проверьте этапы и нажмите «Опубликовать».'}
|
|
449
|
+
</Message>
|
|
161
450
|
)}
|
|
162
|
-
{
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
</
|
|
166
|
-
<
|
|
167
|
-
|
|
168
|
-
|
|
451
|
+
<section style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[3] }}>
|
|
452
|
+
<SectionTitle>Этапы</SectionTitle>
|
|
453
|
+
<ProcessStages process={process} members={members} />
|
|
454
|
+
</section>
|
|
455
|
+
<section style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[1] }}>
|
|
456
|
+
<SectionTitle>Последние запуски</SectionTitle>
|
|
457
|
+
<RecentRuns workflowId={process.workflowId} />
|
|
458
|
+
</section>
|
|
169
459
|
</div>
|
|
170
460
|
);
|
|
171
461
|
};
|
|
@@ -189,51 +479,83 @@ const waitForDraft = async (workflowId: string) => {
|
|
|
189
479
|
|
|
190
480
|
const DocflowProcesses = () => {
|
|
191
481
|
const theme = useTheme();
|
|
482
|
+
const { members } = useWorkspaceMembers();
|
|
192
483
|
const [searchInput, setSearchInput] = useState('');
|
|
193
484
|
const searchQuery = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
|
|
194
|
-
const [
|
|
485
|
+
const [view, setView] = useState<ProcessView>('all');
|
|
486
|
+
const [parametersFilter, setParametersFilter] = useState<ParametersFilter>('any');
|
|
195
487
|
const [sort, setSort] = useState<TableSort>({ key: 'name', direction: 'asc' });
|
|
196
488
|
const [panel, setPanel] = useState<Panel>({ kind: 'closed' });
|
|
197
489
|
const [renaming, setRenaming] = useState<DocflowProcess | undefined>();
|
|
198
490
|
const [isCreating, setIsCreating] = useState(false);
|
|
491
|
+
const [hiddenColumns, setHiddenColumns] = useHiddenColumns(['updatedAt']);
|
|
199
492
|
const { data: loaded, loading, error, reload } = useAsync(() => loadProcesses(searchQuery), [searchQuery]);
|
|
493
|
+
const workflowIdsKey = (loaded ?? []).map(({ workflowId }) => workflowId).join();
|
|
494
|
+
const { data: runCounts, reload: reloadRunCounts } = useAsync(
|
|
495
|
+
() => loadRunCounts(workflowIdsKey.length > 0 ? workflowIdsKey.split(',') : []),
|
|
496
|
+
[workflowIdsKey],
|
|
497
|
+
);
|
|
200
498
|
const selectedWorkflowId = panel.kind === 'process' ? panel.workflowId : undefined;
|
|
201
499
|
const selectedProcess = loaded?.find((process) => process.workflowId === selectedWorkflowId);
|
|
202
500
|
|
|
501
|
+
const byParameters = (loaded ?? []).filter((process) =>
|
|
502
|
+
parametersFilter === 'any' ? true : (process.launchFields.length > 0) === (parametersFilter === 'with'),
|
|
503
|
+
);
|
|
504
|
+
const isInView = (process: DocflowProcess, candidate: ProcessView) =>
|
|
505
|
+
candidate === 'all' ? true : candidate === 'active' ? process.isActive : !process.isActive;
|
|
506
|
+
|
|
203
507
|
// The list is short: filtered and sorted here
|
|
204
|
-
const processes =
|
|
205
|
-
.filter((process) =>
|
|
206
|
-
statusFilter === 'all' ? true : statusFilter === 'active' ? process.isPublished : !process.isPublished,
|
|
207
|
-
)
|
|
508
|
+
const processes = byParameters
|
|
509
|
+
.filter((process) => isInView(process, view))
|
|
208
510
|
.sort((first, second) => {
|
|
209
511
|
const direction = sort.direction === 'asc' ? 1 : -1;
|
|
512
|
+
const byKey: Record<string, (process: DocflowProcess) => number> = {
|
|
513
|
+
parameters: (process) => process.launchFields.length,
|
|
514
|
+
status: (process) => Number(process.isActive),
|
|
515
|
+
stageCount: (process) => process.stages.length,
|
|
516
|
+
runs: (process) => runCounts?.[process.workflowId]?.total ?? 0,
|
|
517
|
+
updatedAt: (process) => new Date(process.updatedAt ?? 0).getTime(),
|
|
518
|
+
};
|
|
519
|
+
const value = byKey[sort.key];
|
|
520
|
+
|
|
521
|
+
return value === undefined
|
|
522
|
+
? first.name.localeCompare(second.name, 'ru') * direction
|
|
523
|
+
: (value(first) - value(second)) * direction;
|
|
524
|
+
});
|
|
210
525
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
526
|
+
const selection = useRowSelection(processes.map(({ workflowId }) => workflowId));
|
|
527
|
+
const selectedIds = selection.selectedIds;
|
|
214
528
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
529
|
+
const reloadAll = async () => {
|
|
530
|
+
await Promise.all([reload(), reloadRunCounts()]);
|
|
531
|
+
};
|
|
218
532
|
|
|
219
|
-
|
|
220
|
-
|
|
533
|
+
const bulk = useBulkAction({
|
|
534
|
+
onDone: async () => {
|
|
535
|
+
selection.clear();
|
|
536
|
+
await reloadAll();
|
|
537
|
+
},
|
|
538
|
+
});
|
|
221
539
|
|
|
222
|
-
// The
|
|
223
|
-
//
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
current.kind === 'process' && current.workflowId
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
540
|
+
// The processes go to the Twenty trash ("Рабочие процессы → Удалённые"),
|
|
541
|
+
// their runs and tasks stay.
|
|
542
|
+
const removeProcesses = (ids: string[]) =>
|
|
543
|
+
bulk.run(
|
|
544
|
+
async () => {
|
|
545
|
+
await deleteRecords('workflows', ids);
|
|
546
|
+
setPanel((current) => (current.kind === 'process' && ids.includes(current.workflowId) ? { kind: 'closed' } : current));
|
|
547
|
+
},
|
|
548
|
+
ids.length === 1 ? 'Процесс удалён' : `Удалено процессов: ${ids.length}`,
|
|
549
|
+
'Не удалось удалить',
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
// Publishing goes through the app's route, one process at a time: the
|
|
553
|
+
// workflow engine takes a version per call.
|
|
554
|
+
const publishProcesses = (ids: string[], publish: boolean) =>
|
|
555
|
+
bulk.runEach(ids, (workflowId) => callAppRoute('/docflow/processes/publish', { workflowId, publish }), {
|
|
556
|
+
done: (count) => (publish ? `Опубликовано: ${count}` : `Снято с публикации: ${count}`),
|
|
557
|
+
failed: publish ? 'Не опубликованы' : 'Не сняты с публикации',
|
|
558
|
+
});
|
|
237
559
|
|
|
238
560
|
const handleRenameProcess = async (process: DocflowProcess, name: string) => {
|
|
239
561
|
try {
|
|
@@ -280,48 +602,103 @@ const DocflowProcesses = () => {
|
|
|
280
602
|
}
|
|
281
603
|
};
|
|
282
604
|
|
|
605
|
+
const rowActions = (workflowId: string): MenuAction[] => {
|
|
606
|
+
const process = processes.find((candidate) => candidate.workflowId === workflowId);
|
|
607
|
+
|
|
608
|
+
if (process === undefined) {
|
|
609
|
+
return [];
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
return [
|
|
613
|
+
...(process.isPublished
|
|
614
|
+
? [{ key: 'launch', label: 'Запустить', icon: IconPlayerPlay, onSelect: () => setPanel({ kind: 'process', workflowId }) }]
|
|
615
|
+
: []),
|
|
616
|
+
...(!process.isActive || process.hasUnpublishedChanges
|
|
617
|
+
? [
|
|
618
|
+
{
|
|
619
|
+
key: 'publish',
|
|
620
|
+
label: process.isActive ? 'Опубликовать изменения' : 'Опубликовать',
|
|
621
|
+
icon: IconRocket,
|
|
622
|
+
onSelect: () => void publishProcesses([workflowId], true),
|
|
623
|
+
},
|
|
624
|
+
]
|
|
625
|
+
: []),
|
|
626
|
+
...(process.isActive
|
|
627
|
+
? [{ key: 'unpublish', label: 'Снять с публикации', icon: IconPlayerStop, onSelect: () => void publishProcesses([workflowId], false) }]
|
|
628
|
+
: []),
|
|
629
|
+
{ key: 'edit', label: 'Открыть редактор', icon: IconSettings, onSelect: () => void openEditor(workflowId) },
|
|
630
|
+
{ key: 'rename', label: 'Переименовать', icon: IconPencil, onSelect: () => setRenaming(process) },
|
|
631
|
+
{
|
|
632
|
+
key: 'delete',
|
|
633
|
+
label: 'Удалить',
|
|
634
|
+
confirmLabel: 'Точно удалить?',
|
|
635
|
+
icon: IconTrash,
|
|
636
|
+
tone: 'danger',
|
|
637
|
+
onSelect: () => void removeProcesses([workflowId]),
|
|
638
|
+
},
|
|
639
|
+
];
|
|
640
|
+
};
|
|
641
|
+
|
|
283
642
|
return (
|
|
284
643
|
<DocflowRoot>
|
|
285
644
|
<SplitView
|
|
286
|
-
panelTitle=
|
|
645
|
+
panelTitle="Процесс"
|
|
287
646
|
onClosePanel={() => setPanel({ kind: 'closed' })}
|
|
288
647
|
panel={
|
|
289
648
|
selectedProcess !== undefined ? (
|
|
290
|
-
<
|
|
649
|
+
<ProcessPanel
|
|
291
650
|
key={selectedProcess.workflowId}
|
|
292
651
|
process={selectedProcess}
|
|
293
|
-
|
|
652
|
+
members={members}
|
|
653
|
+
isBusy={bulk.isBusy}
|
|
654
|
+
onLaunched={() => void reloadRunCounts()}
|
|
655
|
+
onRename={(name) => handleRenameProcess(selectedProcess, name)}
|
|
656
|
+
onPublish={(publish) => void publishProcesses([selectedProcess.workflowId], publish)}
|
|
294
657
|
/>
|
|
295
658
|
) : undefined
|
|
296
659
|
}
|
|
297
660
|
>
|
|
298
|
-
<
|
|
299
|
-
|
|
300
|
-
value
|
|
301
|
-
onChange
|
|
302
|
-
placeholder
|
|
303
|
-
activeFilterCount
|
|
304
|
-
onResetFilters
|
|
305
|
-
filters
|
|
661
|
+
<ListToolbar
|
|
662
|
+
search={{
|
|
663
|
+
value: searchInput,
|
|
664
|
+
onChange: setSearchInput,
|
|
665
|
+
placeholder: 'Поиск процессов',
|
|
666
|
+
activeFilterCount: parametersFilter === 'any' ? 0 : 1,
|
|
667
|
+
onResetFilters: () => setParametersFilter('any'),
|
|
668
|
+
filters: [
|
|
306
669
|
{
|
|
307
|
-
key: '
|
|
308
|
-
title: '
|
|
309
|
-
value:
|
|
670
|
+
key: 'parameters',
|
|
671
|
+
title: 'Параметры запуска',
|
|
672
|
+
value: parametersFilter,
|
|
310
673
|
options: [
|
|
311
|
-
{ value: '
|
|
312
|
-
{ value: '
|
|
313
|
-
{ value: '
|
|
674
|
+
{ value: 'any', label: 'Любые' },
|
|
675
|
+
{ value: 'with', label: 'С параметрами' },
|
|
676
|
+
{ value: 'without', label: 'Без параметров' },
|
|
314
677
|
],
|
|
315
|
-
onChange: (value) =>
|
|
678
|
+
onChange: (value) => setParametersFilter(value as ParametersFilter),
|
|
316
679
|
},
|
|
317
|
-
]
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
<
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
680
|
+
],
|
|
681
|
+
}}
|
|
682
|
+
views={
|
|
683
|
+
<ViewTabs
|
|
684
|
+
value={view}
|
|
685
|
+
onChange={(next) => {
|
|
686
|
+
setView(next);
|
|
687
|
+
selection.clear();
|
|
688
|
+
}}
|
|
689
|
+
options={VIEWS.map((option) => ({
|
|
690
|
+
...option,
|
|
691
|
+
count: loaded === undefined ? undefined : byParameters.filter((process) => isInView(process, option.value)).length,
|
|
692
|
+
}))}
|
|
693
|
+
/>
|
|
694
|
+
}
|
|
695
|
+
total={loaded === undefined ? undefined : formatTotal(processes.length, undefined, PROCESS_NOUN)}
|
|
696
|
+
columns={{ columns: COLUMNS, hiddenKeys: hiddenColumns, onChange: setHiddenColumns }}
|
|
697
|
+
onRefresh={() => void reloadAll()}
|
|
698
|
+
isRefreshing={loading}
|
|
699
|
+
onCreate={() => void handleCreate()}
|
|
700
|
+
isCreating={isCreating}
|
|
701
|
+
/>
|
|
325
702
|
{error !== undefined && (
|
|
326
703
|
<div style={{ padding: theme.spacing[4] }}>
|
|
327
704
|
<Message tone="danger">{getErrorMessage(error, 'Не удалось загрузить процессы')}</Message>
|
|
@@ -333,58 +710,77 @@ const DocflowProcesses = () => {
|
|
|
333
710
|
)}
|
|
334
711
|
{processes.length > 0 && (
|
|
335
712
|
<DataTable
|
|
336
|
-
columns={
|
|
337
|
-
|
|
338
|
-
{ key: 'status', label: 'Статус', width: '18%', sortKey: 'status' },
|
|
339
|
-
{ key: 'parameters', label: 'Параметры', width: '14%', sortKey: 'parameters' },
|
|
340
|
-
{ key: 'stages', label: 'Этапы' },
|
|
341
|
-
]}
|
|
713
|
+
columns={COLUMNS}
|
|
714
|
+
hiddenColumnKeys={hiddenColumns}
|
|
342
715
|
sort={sort}
|
|
343
716
|
onSortChange={setSort}
|
|
344
717
|
selectedRowId={selectedWorkflowId}
|
|
718
|
+
selectedIds={selectedIds}
|
|
719
|
+
onSelectionChange={selection.setSelectedIds}
|
|
345
720
|
onRowClick={(workflowId) => setPanel({ kind: 'process', workflowId })}
|
|
346
|
-
rowActions={
|
|
347
|
-
const process = processes.find((candidate) => candidate.workflowId === workflowId);
|
|
348
|
-
|
|
349
|
-
if (process === undefined) {
|
|
350
|
-
return [];
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
return [
|
|
354
|
-
...(process.isPublished
|
|
355
|
-
? [{ key: 'launch', label: 'Запустить', icon: IconPlayerPlay, onSelect: () => setPanel({ kind: 'process', workflowId }) }]
|
|
356
|
-
: []),
|
|
357
|
-
{ key: 'edit', label: 'Открыть редактор', icon: IconSettings, onSelect: () => void openEditor(workflowId) },
|
|
358
|
-
{ key: 'rename', label: 'Переименовать', icon: IconPencil, onSelect: () => setRenaming(process) },
|
|
359
|
-
{
|
|
360
|
-
key: 'delete',
|
|
361
|
-
label: 'Удалить',
|
|
362
|
-
confirmLabel: 'Точно удалить?',
|
|
363
|
-
icon: IconTrash,
|
|
364
|
-
tone: 'danger' as const,
|
|
365
|
-
onSelect: () => void handleDeleteProcess(process),
|
|
366
|
-
},
|
|
367
|
-
];
|
|
368
|
-
}}
|
|
721
|
+
rowActions={rowActions}
|
|
369
722
|
rows={processes.map((process) => {
|
|
370
723
|
const parameterCount = process.launchFields.length;
|
|
724
|
+
const counts = runCounts?.[process.workflowId];
|
|
371
725
|
|
|
372
726
|
return {
|
|
373
727
|
id: process.workflowId,
|
|
374
728
|
cells: {
|
|
375
729
|
name: process.name,
|
|
376
730
|
status: (
|
|
377
|
-
<
|
|
378
|
-
{process.
|
|
379
|
-
|
|
731
|
+
<span style={{ display: 'inline-flex', gap: theme.spacing[1] }}>
|
|
732
|
+
<StatusChip color={process.isActive ? 'green' : 'gray'}>
|
|
733
|
+
{process.isActive ? 'Опубликован' : 'Черновик'}
|
|
734
|
+
</StatusChip>
|
|
735
|
+
{process.hasUnpublishedChanges && <StatusChip color="orange">Изменён</StatusChip>}
|
|
736
|
+
</span>
|
|
380
737
|
),
|
|
381
738
|
parameters: parameterCount > 0 ? String(parameterCount) : 'нет',
|
|
382
|
-
|
|
739
|
+
stageCount: String(process.stages.length),
|
|
740
|
+
runs:
|
|
741
|
+
counts === undefined ? (
|
|
742
|
+
'…'
|
|
743
|
+
) : (
|
|
744
|
+
<span>
|
|
745
|
+
{counts.total}
|
|
746
|
+
{counts.running > 0 && <span style={{ color: theme.color.blue }}> · идёт {counts.running}</span>}
|
|
747
|
+
</span>
|
|
748
|
+
),
|
|
749
|
+
stages: process.stages.map(({ name }) => name).join(' → ') || '—',
|
|
750
|
+
updatedAt: formatDate(process.updatedAt),
|
|
383
751
|
},
|
|
384
752
|
};
|
|
385
753
|
})}
|
|
386
754
|
/>
|
|
387
755
|
)}
|
|
756
|
+
<SelectionBar count={selectedIds.length} noun={PROCESS_NOUN} onClear={selection.clear}>
|
|
757
|
+
{selectedIds.length === 1 && (
|
|
758
|
+
<Button icon={IconSettings} onClick={() => void openEditor(selectedIds[0])}>
|
|
759
|
+
Открыть редактор
|
|
760
|
+
</Button>
|
|
761
|
+
)}
|
|
762
|
+
<Button icon={IconRocket} loading={bulk.isBusy} onClick={() => void publishProcesses(selectedIds, true)}>
|
|
763
|
+
Опубликовать
|
|
764
|
+
</Button>
|
|
765
|
+
<Button icon={IconPlayerStop} disabled={bulk.isBusy} onClick={() => void publishProcesses(selectedIds, false)}>
|
|
766
|
+
Снять с публикации
|
|
767
|
+
</Button>
|
|
768
|
+
<MenuButton
|
|
769
|
+
label="Удалить"
|
|
770
|
+
icon={IconTrash}
|
|
771
|
+
disabled={bulk.isBusy}
|
|
772
|
+
actions={[
|
|
773
|
+
{
|
|
774
|
+
key: 'delete',
|
|
775
|
+
label: `Удалить выбранные (${selectedIds.length})`,
|
|
776
|
+
confirmLabel: 'Точно удалить? Запуски и задания останутся',
|
|
777
|
+
icon: IconTrash,
|
|
778
|
+
tone: 'danger',
|
|
779
|
+
onSelect: () => void removeProcesses(selectedIds),
|
|
780
|
+
},
|
|
781
|
+
]}
|
|
782
|
+
/>
|
|
783
|
+
</SelectionBar>
|
|
388
784
|
</SplitView>
|
|
389
785
|
{renaming !== undefined && (
|
|
390
786
|
<PromptDialog
|