@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.
@@ -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 { Button } from 'twenty-ui/primitives/input';
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, TextField, Toolbar } from 'src/front/ui/components';
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'; workflowVersionId: string };
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
- workflowVersionId: string;
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
- // Docflow processes are the standard workflows with the docflow section,
49
- // published, started by hand. Both lists are filtered by the server.
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
- restEq('status', 'ACTIVE'),
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.flatMap((workflow) => {
73
- const version = versions.find((candidate) => candidate.workflowId === workflow.id);
74
-
75
- if (version === undefined || !isLaunchableTrigger(version.trigger)) {
76
- return [];
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
- workflowId: workflow.id,
82
- workflowVersionId: version.id,
83
- name: workflow.name?.trim() || 'Без названия',
84
- launchFields: parseLaunchFields(workflow.smbLaunchFields),
85
- stageNames: (version.steps ?? [])
86
- .filter((step) => !isLaunchParametersStep(step))
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: processes, loading, error } = useAsync(() => loadProcesses(searchQuery), [searchQuery]);
186
- const selectedVersionId = panel.kind === 'process' ? panel.workflowVersionId : undefined;
187
- const selectedProcess = processes?.find((process) => process.workflowVersionId === selectedVersionId);
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.workflowVersionId}
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
- <div style={{ flex: '0 1 240px' }}>
240
- <TextField
241
- type="search"
242
- variant="bare"
243
- placeholder="Поиск процессов"
244
- value={searchInput}
245
- onChange={setSearchInput}
246
- />
247
- </div>
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 && processes === undefined && <Message>Загрузка…</Message>}
260
- {!loading && error === undefined && processes?.length === 0 && (
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 !== undefined && processes.length > 0 && (
334
+ {processes.length > 0 && (
266
335
  <DataTable
267
336
  columns={[
268
- { key: 'name', label: 'Процесс', width: '40%' },
269
- { key: 'parameters', label: 'Параметры', width: '18%' },
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
- selectedRowId={selectedVersionId}
273
- onRowClick={(workflowVersionId) => setPanel({ kind: 'process', workflowVersionId })}
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.workflowVersionId,
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
  };