@haiiro/docuflow 0.0.8 → 0.0.10
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 +13 -1
- package/manifest.json +108 -25
- package/package.json +1 -1
- package/src/front-components/docflow-documents.front-component.mjs +35 -35
- package/src/front-components/docflow-documents.front-component.tsx +216 -88
- package/src/front-components/docflow-processes.front-component.mjs +42 -42
- package/src/front-components/docflow-processes.front-component.tsx +170 -61
- package/src/front-components/docflow-tasks.front-component.mjs +42 -42
- package/src/front-components/docflow-tasks.front-component.tsx +69 -47
- package/src/front-components/process-steps.front-component.mjs +36 -36
- package/src/front-components/process-steps.front-component.tsx +28 -2
- package/src/front-components/task-form.front-component.mjs +36 -36
|
@@ -1,12 +1,14 @@
|
|
|
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 {
|
|
4
|
+
import { IconPencil, IconPlayerPlay, IconSettings, IconTrash } from 'twenty-ui/icon';
|
|
5
|
+
import { Tag } from 'twenty-ui/primitives/data-display';
|
|
5
6
|
import { useTheme } from 'twenty-ui/theme';
|
|
6
7
|
|
|
7
|
-
import { callAppRoute, createRecord, getErrorMessage, listRecords } from 'src/front/api';
|
|
8
|
+
import { callAppRoute, createRecord, deleteRecord, getErrorMessage, listRecords, updateRecord } from 'src/front/api';
|
|
8
9
|
import { useAsync, useDebouncedValue } from 'src/front/hooks/use-async';
|
|
9
|
-
import { DataTable, DocflowRoot, Message, SplitView,
|
|
10
|
+
import { DataTable, DocflowRoot, Message, SplitView, type TableSort, Toolbar } from 'src/front/ui/components';
|
|
11
|
+
import { Button, PromptDialog, SearchWithFilters } from 'src/front/ui/kit';
|
|
10
12
|
import { FormFieldInputs } from 'src/front/ui/form-field-inputs';
|
|
11
13
|
import { restAnd, restEq, restIlike, restIn } from 'src/shared/api/rest-filter.util';
|
|
12
14
|
import { DOCFLOW_SECTION } from 'src/shared/docflow.constants';
|
|
@@ -27,26 +29,34 @@ const SEARCH_DEBOUNCE_MS = 300;
|
|
|
27
29
|
const DRAFT_WAIT_ATTEMPTS = 10;
|
|
28
30
|
const DRAFT_WAIT_MS = 400;
|
|
29
31
|
|
|
30
|
-
type Panel = { kind: 'closed' } | { kind: 'process';
|
|
32
|
+
type Panel = { kind: 'closed' } | { kind: 'process'; workflowId: string };
|
|
33
|
+
type StatusFilter = 'all' | 'active' | 'draft';
|
|
31
34
|
|
|
32
35
|
type WorkflowRecord = { id: string; name?: string | null; smbLaunchFields?: unknown };
|
|
33
36
|
type WorkflowVersionRecord = {
|
|
34
37
|
id: string;
|
|
35
38
|
workflowId: string;
|
|
39
|
+
status?: string | null;
|
|
36
40
|
trigger?: WorkflowTrigger | null;
|
|
37
41
|
steps?: WorkflowStep[] | null;
|
|
38
42
|
};
|
|
39
43
|
|
|
40
44
|
type DocflowProcess = {
|
|
41
45
|
workflowId: string;
|
|
42
|
-
|
|
46
|
+
// The published version people launch; none while the process is a draft
|
|
47
|
+
workflowVersionId: string | undefined;
|
|
43
48
|
name: string;
|
|
49
|
+
isPublished: boolean;
|
|
44
50
|
launchFields: FormField[];
|
|
45
51
|
stageNames: string[];
|
|
46
52
|
};
|
|
47
53
|
|
|
48
|
-
|
|
49
|
-
|
|
54
|
+
const stageNamesOf = (version: WorkflowVersionRecord | undefined) =>
|
|
55
|
+
(version?.steps ?? []).filter((step) => !isLaunchParametersStep(step)).map((step) => step.name);
|
|
56
|
+
|
|
57
|
+
// Docflow processes are the standard workflows with the docflow section:
|
|
58
|
+
// published ones started by hand are launched here, drafts are shown to be
|
|
59
|
+
// finished in the editor. Both lists are filtered by the server.
|
|
50
60
|
const loadProcesses = async (searchQuery: string): Promise<DocflowProcess[]> => {
|
|
51
61
|
const { records: workflows } = await listRecords<WorkflowRecord>('workflows', {
|
|
52
62
|
filter: restAnd(
|
|
@@ -63,33 +73,32 @@ const loadProcesses = async (searchQuery: string): Promise<DocflowProcess[]> =>
|
|
|
63
73
|
|
|
64
74
|
const { records: versions } = await listRecords<WorkflowVersionRecord>('workflowVersions', {
|
|
65
75
|
filter: restAnd(
|
|
66
|
-
|
|
76
|
+
restIn('status', ['ACTIVE', 'DRAFT']),
|
|
67
77
|
restIn('workflowId', workflows.map(({ id }) => id)),
|
|
68
78
|
),
|
|
69
|
-
limit: PROCESSES_LIMIT,
|
|
79
|
+
limit: PROCESSES_LIMIT * 2,
|
|
70
80
|
});
|
|
71
81
|
|
|
72
|
-
return workflows.
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
82
|
+
return workflows.map((workflow) => {
|
|
83
|
+
const active = versions.find(
|
|
84
|
+
(version) => version.workflowId === workflow.id && version.status === 'ACTIVE' && isLaunchableTrigger(version.trigger),
|
|
85
|
+
);
|
|
86
|
+
const draft = versions.find((version) => version.workflowId === workflow.id && version.status === 'DRAFT');
|
|
78
87
|
|
|
79
|
-
return
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
.map((step) => step.name),
|
|
88
|
-
},
|
|
89
|
-
];
|
|
88
|
+
return {
|
|
89
|
+
workflowId: workflow.id,
|
|
90
|
+
workflowVersionId: active?.id,
|
|
91
|
+
name: workflow.name?.trim() || 'Без названия',
|
|
92
|
+
isPublished: active !== undefined,
|
|
93
|
+
launchFields: parseLaunchFields(workflow.smbLaunchFields),
|
|
94
|
+
stageNames: stageNamesOf(active ?? draft),
|
|
95
|
+
};
|
|
90
96
|
});
|
|
91
97
|
};
|
|
92
98
|
|
|
99
|
+
const openEditor = (workflowId: string) =>
|
|
100
|
+
navigate(AppPath.RecordShowPage, { objectNameSingular: 'workflow', objectRecordId: workflowId });
|
|
101
|
+
|
|
93
102
|
const ProcessLaunchForm = ({
|
|
94
103
|
process,
|
|
95
104
|
onLaunched,
|
|
@@ -117,7 +126,7 @@ const ProcessLaunchForm = ({
|
|
|
117
126
|
|
|
118
127
|
try {
|
|
119
128
|
await callAppRoute('/docflow/processes/launch', {
|
|
120
|
-
workflowVersionId: process.workflowVersionId,
|
|
129
|
+
workflowVersionId: process.workflowVersionId ?? '',
|
|
121
130
|
values: parameters,
|
|
122
131
|
});
|
|
123
132
|
await enqueueSnackbar({ message: 'Процесс запущен', variant: 'success' });
|
|
@@ -129,6 +138,17 @@ const ProcessLaunchForm = ({
|
|
|
129
138
|
}
|
|
130
139
|
};
|
|
131
140
|
|
|
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
|
+
|
|
132
152
|
return (
|
|
133
153
|
<div style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[3] }}>
|
|
134
154
|
{process.stageNames.length > 0 && (
|
|
@@ -143,16 +163,7 @@ const ProcessLaunchForm = ({
|
|
|
143
163
|
<Button variant="solid" size="md" fullWidth loading={isLaunching} onClick={() => void handleLaunch()}>
|
|
144
164
|
Запустить
|
|
145
165
|
</Button>
|
|
146
|
-
<Button
|
|
147
|
-
variant="ghost"
|
|
148
|
-
size="sm"
|
|
149
|
-
onClick={() =>
|
|
150
|
-
void navigate(AppPath.RecordShowPage, {
|
|
151
|
-
objectNameSingular: 'workflow',
|
|
152
|
-
objectRecordId: process.workflowId,
|
|
153
|
-
})
|
|
154
|
-
}
|
|
155
|
-
>
|
|
166
|
+
<Button variant="ghost" size="sm" icon={IconSettings} onClick={() => void openEditor(process.workflowId)}>
|
|
156
167
|
Изменить процесс
|
|
157
168
|
</Button>
|
|
158
169
|
</div>
|
|
@@ -180,11 +191,60 @@ const DocflowProcesses = () => {
|
|
|
180
191
|
const theme = useTheme();
|
|
181
192
|
const [searchInput, setSearchInput] = useState('');
|
|
182
193
|
const searchQuery = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
|
|
194
|
+
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
|
195
|
+
const [sort, setSort] = useState<TableSort>({ key: 'name', direction: 'asc' });
|
|
183
196
|
const [panel, setPanel] = useState<Panel>({ kind: 'closed' });
|
|
197
|
+
const [renaming, setRenaming] = useState<DocflowProcess | undefined>();
|
|
184
198
|
const [isCreating, setIsCreating] = useState(false);
|
|
185
|
-
const { data:
|
|
186
|
-
const
|
|
187
|
-
const selectedProcess =
|
|
199
|
+
const { data: loaded, loading, error, reload } = useAsync(() => loadProcesses(searchQuery), [searchQuery]);
|
|
200
|
+
const selectedWorkflowId = panel.kind === 'process' ? panel.workflowId : undefined;
|
|
201
|
+
const selectedProcess = loaded?.find((process) => process.workflowId === selectedWorkflowId);
|
|
202
|
+
|
|
203
|
+
// The list is short: filtered and sorted here
|
|
204
|
+
const processes = (loaded ?? [])
|
|
205
|
+
.filter((process) =>
|
|
206
|
+
statusFilter === 'all' ? true : statusFilter === 'active' ? process.isPublished : !process.isPublished,
|
|
207
|
+
)
|
|
208
|
+
.sort((first, second) => {
|
|
209
|
+
const direction = sort.direction === 'asc' ? 1 : -1;
|
|
210
|
+
|
|
211
|
+
if (sort.key === 'parameters') {
|
|
212
|
+
return (first.launchFields.length - second.launchFields.length) * direction;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (sort.key === 'status') {
|
|
216
|
+
return (Number(first.isPublished) - Number(second.isPublished)) * direction;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return first.name.localeCompare(second.name, 'ru') * direction;
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// The process goes to the Twenty trash ("Рабочие процессы → Удалённые"),
|
|
223
|
+
// its runs and tasks stay.
|
|
224
|
+
const handleDeleteProcess = async (process: DocflowProcess) => {
|
|
225
|
+
try {
|
|
226
|
+
await deleteRecord('workflows', process.workflowId);
|
|
227
|
+
setPanel((current) =>
|
|
228
|
+
current.kind === 'process' && current.workflowId === process.workflowId ? { kind: 'closed' } : current,
|
|
229
|
+
);
|
|
230
|
+
await enqueueSnackbar({ message: `Процесс «${process.name}» удалён`, variant: 'success' });
|
|
231
|
+
await reload();
|
|
232
|
+
} catch (deleteError) {
|
|
233
|
+
logDocflowError('process: delete failed', deleteError, { workflowId: process.workflowId });
|
|
234
|
+
await enqueueSnackbar({ message: getErrorMessage(deleteError, 'Не удалось удалить процесс'), variant: 'error' });
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const handleRenameProcess = async (process: DocflowProcess, name: string) => {
|
|
239
|
+
try {
|
|
240
|
+
await updateRecord('workflows', process.workflowId, { name });
|
|
241
|
+
await enqueueSnackbar({ message: 'Процесс переименован', variant: 'success' });
|
|
242
|
+
setRenaming(undefined);
|
|
243
|
+
await reload();
|
|
244
|
+
} catch (renameError) {
|
|
245
|
+
await enqueueSnackbar({ message: getErrorMessage(renameError, 'Не удалось переименовать процесс'), variant: 'error' });
|
|
246
|
+
}
|
|
247
|
+
};
|
|
188
248
|
|
|
189
249
|
// As "Создать" of the standard list: a process with a manual trigger in the
|
|
190
250
|
// docflow section, opened in the editor. Launch parameters are set later
|
|
@@ -228,7 +288,7 @@ const DocflowProcesses = () => {
|
|
|
228
288
|
panel={
|
|
229
289
|
selectedProcess !== undefined ? (
|
|
230
290
|
<ProcessLaunchForm
|
|
231
|
-
key={selectedProcess.
|
|
291
|
+
key={selectedProcess.workflowId}
|
|
232
292
|
process={selectedProcess}
|
|
233
293
|
onLaunched={() => setPanel({ kind: 'closed' })}
|
|
234
294
|
/>
|
|
@@ -236,15 +296,26 @@ const DocflowProcesses = () => {
|
|
|
236
296
|
}
|
|
237
297
|
>
|
|
238
298
|
<Toolbar>
|
|
239
|
-
<
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
299
|
+
<SearchWithFilters
|
|
300
|
+
value={searchInput}
|
|
301
|
+
onChange={setSearchInput}
|
|
302
|
+
placeholder="Поиск процессов"
|
|
303
|
+
activeFilterCount={statusFilter === 'all' ? 0 : 1}
|
|
304
|
+
onResetFilters={() => setStatusFilter('all')}
|
|
305
|
+
filters={[
|
|
306
|
+
{
|
|
307
|
+
key: 'status',
|
|
308
|
+
title: 'Статус',
|
|
309
|
+
value: statusFilter,
|
|
310
|
+
options: [
|
|
311
|
+
{ value: 'all', label: 'Все' },
|
|
312
|
+
{ value: 'active', label: 'Опубликованные' },
|
|
313
|
+
{ value: 'draft', label: 'Черновики' },
|
|
314
|
+
],
|
|
315
|
+
onChange: (value) => setStatusFilter(value as StatusFilter),
|
|
316
|
+
},
|
|
317
|
+
]}
|
|
318
|
+
/>
|
|
248
319
|
<div style={{ marginLeft: 'auto' }}>
|
|
249
320
|
<Button variant="solid" size="sm" loading={isCreating} onClick={() => void handleCreate()}>
|
|
250
321
|
Создать
|
|
@@ -256,28 +327,57 @@ const DocflowProcesses = () => {
|
|
|
256
327
|
<Message tone="danger">{getErrorMessage(error, 'Не удалось загрузить процессы')}</Message>
|
|
257
328
|
</div>
|
|
258
329
|
)}
|
|
259
|
-
{loading &&
|
|
260
|
-
{!loading && error === undefined && processes
|
|
261
|
-
<Message>
|
|
262
|
-
Процессов для запуска нет. Здесь видны опубликованные процессы с ручным запуском и разделом «Документооборот». Параметры запуска задаются кнопкой «Параметры запуска» вверху процесса.
|
|
263
|
-
</Message>
|
|
330
|
+
{loading && loaded === undefined && <Message>Загрузка…</Message>}
|
|
331
|
+
{!loading && error === undefined && processes.length === 0 && (
|
|
332
|
+
<Message>Процессов нет. «Создать» добавит процесс в раздел «Документооборот».</Message>
|
|
264
333
|
)}
|
|
265
|
-
{processes
|
|
334
|
+
{processes.length > 0 && (
|
|
266
335
|
<DataTable
|
|
267
336
|
columns={[
|
|
268
|
-
{ key: 'name', label: 'Процесс', width: '
|
|
269
|
-
{ key: '
|
|
337
|
+
{ key: 'name', label: 'Процесс', width: '36%', sortKey: 'name' },
|
|
338
|
+
{ key: 'status', label: 'Статус', width: '18%', sortKey: 'status' },
|
|
339
|
+
{ key: 'parameters', label: 'Параметры', width: '14%', sortKey: 'parameters' },
|
|
270
340
|
{ key: 'stages', label: 'Этапы' },
|
|
271
341
|
]}
|
|
272
|
-
|
|
273
|
-
|
|
342
|
+
sort={sort}
|
|
343
|
+
onSortChange={setSort}
|
|
344
|
+
selectedRowId={selectedWorkflowId}
|
|
345
|
+
onRowClick={(workflowId) => setPanel({ kind: 'process', workflowId })}
|
|
346
|
+
rowActions={(workflowId) => {
|
|
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
|
+
}}
|
|
274
369
|
rows={processes.map((process) => {
|
|
275
370
|
const parameterCount = process.launchFields.length;
|
|
276
371
|
|
|
277
372
|
return {
|
|
278
|
-
id: process.
|
|
373
|
+
id: process.workflowId,
|
|
279
374
|
cells: {
|
|
280
375
|
name: process.name,
|
|
376
|
+
status: (
|
|
377
|
+
<Tag color={process.isPublished ? 'green' : 'gray'}>
|
|
378
|
+
{process.isPublished ? 'Опубликован' : 'Черновик'}
|
|
379
|
+
</Tag>
|
|
380
|
+
),
|
|
281
381
|
parameters: parameterCount > 0 ? String(parameterCount) : 'нет',
|
|
282
382
|
stages: process.stageNames.join(' → '),
|
|
283
383
|
},
|
|
@@ -286,6 +386,15 @@ const DocflowProcesses = () => {
|
|
|
286
386
|
/>
|
|
287
387
|
)}
|
|
288
388
|
</SplitView>
|
|
389
|
+
{renaming !== undefined && (
|
|
390
|
+
<PromptDialog
|
|
391
|
+
title="Переименовать процесс"
|
|
392
|
+
label="Название"
|
|
393
|
+
initialValue={renaming.name}
|
|
394
|
+
onClose={() => setRenaming(undefined)}
|
|
395
|
+
onSubmit={(name) => handleRenameProcess(renaming, name)}
|
|
396
|
+
/>
|
|
397
|
+
)}
|
|
289
398
|
</DocflowRoot>
|
|
290
399
|
);
|
|
291
400
|
};
|