@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.
@@ -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, 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, 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
- }
78
-
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
- ];
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: processes, loading, error, reload } = 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
+ }
188
214
 
189
- // The process goes to the Twenty trash ("Рабочие процессы → Удалённые"),
190
- // its runs and tasks stay.
191
- const handleDeleteProcess = async (workflowVersionId: string) => {
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
- if (process === undefined) {
195
- return;
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.workflowVersionId === workflowVersionId ? { kind: 'closed' } : 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.workflowVersionId}
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
- <div style={{ flex: '0 1 240px' }}>
262
- <TextField
263
- type="search"
264
- variant="bare"
265
- placeholder="Поиск процессов"
266
- value={searchInput}
267
- onChange={setSearchInput}
268
- />
269
- </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
+ />
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 && processes === undefined && <Message>Загрузка…</Message>}
282
- {!loading && error === undefined && processes?.length === 0 && (
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 !== undefined && processes.length > 0 && (
334
+ {processes.length > 0 && (
288
335
  <DataTable
289
336
  columns={[
290
- { key: 'name', label: 'Процесс', width: '40%' },
291
- { 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' },
292
340
  { key: 'stages', label: 'Этапы' },
293
341
  ]}
294
- selectedRowId={selectedVersionId}
295
- onRowClick={(workflowVersionId) => setPanel({ kind: 'process', workflowVersionId })}
296
- onDeleteRow={handleDeleteProcess}
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.workflowVersionId,
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
  };