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