@haiiro/docuflow 0.0.9 → 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 +8 -3
- package/manifest.json +82 -24
- 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 +206 -94
- package/src/front-components/docflow-processes.front-component.mjs +42 -42
- package/src/front-components/docflow-processes.front-component.tsx +157 -71
- package/src/front-components/docflow-tasks.front-component.mjs +42 -42
- package/src/front-components/docflow-tasks.front-component.tsx +57 -47
- package/src/front-components/process-steps.front-component.mjs +36 -36
- package/src/front-components/process-steps.front-component.tsx +1 -1
- 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, deleteRecord, 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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
.filter((step) => !isLaunchParametersStep(step))
|
|
87
|
-
.map((step) => step.name),
|
|
88
|
-
},
|
|
89
|
-
];
|
|
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');
|
|
87
|
+
|
|
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,25 +191,41 @@ 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
|
+
}
|
|
188
214
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const process = processes?.find((candidate) => candidate.workflowVersionId === workflowVersionId);
|
|
215
|
+
if (sort.key === 'status') {
|
|
216
|
+
return (Number(first.isPublished) - Number(second.isPublished)) * direction;
|
|
217
|
+
}
|
|
193
218
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
219
|
+
return first.name.localeCompare(second.name, 'ru') * direction;
|
|
220
|
+
});
|
|
197
221
|
|
|
222
|
+
// The process goes to the Twenty trash ("Рабочие процессы → Удалённые"),
|
|
223
|
+
// its runs and tasks stay.
|
|
224
|
+
const handleDeleteProcess = async (process: DocflowProcess) => {
|
|
198
225
|
try {
|
|
199
226
|
await deleteRecord('workflows', process.workflowId);
|
|
200
227
|
setPanel((current) =>
|
|
201
|
-
current.kind === 'process' && current.
|
|
228
|
+
current.kind === 'process' && current.workflowId === process.workflowId ? { kind: 'closed' } : current,
|
|
202
229
|
);
|
|
203
230
|
await enqueueSnackbar({ message: `Процесс «${process.name}» удалён`, variant: 'success' });
|
|
204
231
|
await reload();
|
|
@@ -208,6 +235,17 @@ const DocflowProcesses = () => {
|
|
|
208
235
|
}
|
|
209
236
|
};
|
|
210
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
|
+
};
|
|
248
|
+
|
|
211
249
|
// As "Создать" of the standard list: a process with a manual trigger in the
|
|
212
250
|
// docflow section, opened in the editor. Launch parameters are set later
|
|
213
251
|
// with the process's "Параметры запуска" command.
|
|
@@ -250,7 +288,7 @@ const DocflowProcesses = () => {
|
|
|
250
288
|
panel={
|
|
251
289
|
selectedProcess !== undefined ? (
|
|
252
290
|
<ProcessLaunchForm
|
|
253
|
-
key={selectedProcess.
|
|
291
|
+
key={selectedProcess.workflowId}
|
|
254
292
|
process={selectedProcess}
|
|
255
293
|
onLaunched={() => setPanel({ kind: 'closed' })}
|
|
256
294
|
/>
|
|
@@ -258,15 +296,26 @@ const DocflowProcesses = () => {
|
|
|
258
296
|
}
|
|
259
297
|
>
|
|
260
298
|
<Toolbar>
|
|
261
|
-
<
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
+
/>
|
|
270
319
|
<div style={{ marginLeft: 'auto' }}>
|
|
271
320
|
<Button variant="solid" size="sm" loading={isCreating} onClick={() => void handleCreate()}>
|
|
272
321
|
Создать
|
|
@@ -278,29 +327,57 @@ const DocflowProcesses = () => {
|
|
|
278
327
|
<Message tone="danger">{getErrorMessage(error, 'Не удалось загрузить процессы')}</Message>
|
|
279
328
|
</div>
|
|
280
329
|
)}
|
|
281
|
-
{loading &&
|
|
282
|
-
{!loading && error === undefined && processes
|
|
283
|
-
<Message>
|
|
284
|
-
Процессов для запуска нет. Здесь видны опубликованные процессы с ручным запуском и разделом «Документооборот». Параметры запуска задаются кнопкой «Параметры запуска» вверху процесса.
|
|
285
|
-
</Message>
|
|
330
|
+
{loading && loaded === undefined && <Message>Загрузка…</Message>}
|
|
331
|
+
{!loading && error === undefined && processes.length === 0 && (
|
|
332
|
+
<Message>Процессов нет. «Создать» добавит процесс в раздел «Документооборот».</Message>
|
|
286
333
|
)}
|
|
287
|
-
{processes
|
|
334
|
+
{processes.length > 0 && (
|
|
288
335
|
<DataTable
|
|
289
336
|
columns={[
|
|
290
|
-
{ key: 'name', label: 'Процесс', width: '
|
|
291
|
-
{ 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' },
|
|
292
340
|
{ key: 'stages', label: 'Этапы' },
|
|
293
341
|
]}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
+
}}
|
|
297
369
|
rows={processes.map((process) => {
|
|
298
370
|
const parameterCount = process.launchFields.length;
|
|
299
371
|
|
|
300
372
|
return {
|
|
301
|
-
id: process.
|
|
373
|
+
id: process.workflowId,
|
|
302
374
|
cells: {
|
|
303
375
|
name: process.name,
|
|
376
|
+
status: (
|
|
377
|
+
<Tag color={process.isPublished ? 'green' : 'gray'}>
|
|
378
|
+
{process.isPublished ? 'Опубликован' : 'Черновик'}
|
|
379
|
+
</Tag>
|
|
380
|
+
),
|
|
304
381
|
parameters: parameterCount > 0 ? String(parameterCount) : 'нет',
|
|
305
382
|
stages: process.stageNames.join(' → '),
|
|
306
383
|
},
|
|
@@ -309,6 +386,15 @@ const DocflowProcesses = () => {
|
|
|
309
386
|
/>
|
|
310
387
|
)}
|
|
311
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
|
+
)}
|
|
312
398
|
</DocflowRoot>
|
|
313
399
|
);
|
|
314
400
|
};
|