@haiiro/docuflow 0.0.16 → 0.0.17

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.
Files changed (28) hide show
  1. package/README.md +8 -2
  2. package/manifest.json +34 -19
  3. package/package.json +1 -1
  4. package/public/app/docflow-app.js +5 -5
  5. package/src/front-components/docflow-documents.front-component.mjs +1 -1
  6. package/src/front-components/docflow-processes.front-component.mjs +35 -35
  7. package/src/front-components/docflow-processes.front-component.tsx +187 -85
  8. package/src/front-components/docflow-tasks.front-component.mjs +24 -24
  9. package/src/front-components/launch-process.front-component.mjs +19 -19
  10. package/src/front-components/launch-process.front-component.tsx +36 -60
  11. package/src/front-components/process-steps.front-component.mjs +17 -17
  12. package/src/front-components/task-form.front-component.mjs +10 -10
  13. package/src/logic-functions/bitrix-app.logic-function.mjs +1 -1
  14. package/src/logic-functions/complete-task.logic-function.mjs +39 -8
  15. package/src/logic-functions/docflow-app.logic-function.mjs +1 -1
  16. package/src/logic-functions/hand-out-form-step.logic-function.mjs +71278 -11217
  17. package/src/logic-functions/hand-out-form-step.logic-function.ts +1 -1
  18. package/src/logic-functions/launch-process.logic-function.mjs +269 -33
  19. package/src/logic-functions/launch-process.logic-function.ts +42 -37
  20. package/src/logic-functions/on-task-updated.logic-function.mjs +39 -8
  21. package/src/logic-functions/on-workflow-run-updated.logic-function.mjs +35 -9
  22. package/src/logic-functions/on-workflow-run-updated.logic-function.ts +6 -3
  23. package/src/logic-functions/process-catalog.logic-function.mjs +119726 -0
  24. package/src/logic-functions/process-catalog.logic-function.ts +84 -0
  25. package/src/logic-functions/publish-process.logic-function.mjs +218 -16
  26. package/src/logic-functions/publish-process.logic-function.ts +13 -5
  27. package/src/logic-functions/save-launch-fields.logic-function.mjs +5 -2
  28. package/src/logic-functions/save-step-settings.logic-function.mjs +5 -2
@@ -1,7 +1,7 @@
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, IconPlayerStop, IconRocket, IconSettings, IconTrash } from 'twenty-ui/icon';
4
+ import { IconPencil, IconPlayerPlay, IconPlayerStop, IconPlus, IconRocket, IconSettings, IconTrash, IconX } from 'twenty-ui/icon';
5
5
  import { useTheme } from 'twenty-ui/theme';
6
6
 
7
7
  import {
@@ -40,27 +40,24 @@ import {
40
40
  useRowSelection,
41
41
  } from 'src/front/ui/components';
42
42
  import { FormFieldInputs } from 'src/front/ui/form-field-inputs';
43
- import { Button, type MenuAction, MenuButton, PromptDialog, QuickViews, StatusChip } from 'src/front/ui/kit';
43
+ import { Button, type MenuAction, MenuButton, PromptDialog, QuickViews, StatusChip, ValueDialog } from 'src/front/ui/kit';
44
44
  import { Timeline } from 'src/front/ui/timeline';
45
- import { restAnd, restEq, restIlike, restIn } from 'src/shared/api/rest-filter.util';
45
+ import { restAnd, restEq } from 'src/shared/api/rest-filter.util';
46
46
  import { DOCFLOW_SECTION } from 'src/shared/docflow.constants';
47
47
  import { type FormField, type FormValues } from 'src/shared/form/form-field.type';
48
48
  import { findMissingRequiredFields } from 'src/shared/form/form-fields.util';
49
49
  import { logDocflow, logDocflowError } from 'src/shared/log.util';
50
+ import { buildLaunchParameters, isLaunchableTrigger, isLaunchParametersStep } from 'src/shared/workflow/launch-step.util';
50
51
  import {
51
- buildLaunchParameters,
52
- isLaunchableTrigger,
53
- isLaunchParametersStep,
54
- isLaunchTrigger,
55
- parseLaunchFields,
56
- parseLaunchObject,
57
- } from 'src/shared/workflow/launch-step.util';
52
+ type CatalogProcess,
53
+ type CatalogVersion,
54
+ type OtherProcess,
55
+ } from 'src/shared/workflow/process-catalog.type';
58
56
  import { orderRunSteps } from 'src/shared/workflow/run-timeline.util';
59
- import { type WorkflowStep, type WorkflowTrigger } from 'src/shared/workflow/workflow-run.type';
57
+ import { type WorkflowStep } from 'src/shared/workflow/workflow-run.type';
60
58
  import { getFormStepAssignment, getFormStepFields, isFormStep } from 'src/shared/workflow/workflow-run.util';
61
59
  import { PROCESSES_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/universal-identifiers';
62
60
 
63
- const PROCESSES_LIMIT = 200;
64
61
  const SEARCH_DEBOUNCE_MS = 300;
65
62
  const DRAFT_WAIT_ATTEMPTS = 10;
66
63
  const DRAFT_WAIT_MS = 400;
@@ -71,23 +68,14 @@ type Panel = { kind: 'closed' } | { kind: 'process'; workflowId: string };
71
68
  type ProcessView = 'all' | 'active' | 'draft';
72
69
  type ParametersFilter = 'any' | 'with' | 'without';
73
70
 
74
- type WorkflowRecord = {
75
- id: string;
76
- name?: string | null;
77
- smbLaunchFields?: unknown;
78
- smbLaunchObject?: string | null;
79
- updatedAt?: string | null;
80
- };
81
- type WorkflowVersionRecord = {
82
- id: string;
83
- workflowId: string;
84
- status?: string | null;
85
- trigger?: WorkflowTrigger | null;
86
- steps?: WorkflowStep[] | null;
87
- };
71
+ type WorkflowRecord = { id: string };
88
72
 
89
73
  type DocflowProcess = {
74
+ // The process engine's id: the editor page, runs and launching take it
90
75
  workflowId: string;
76
+ // The process record of Twenty 2.45: renaming and deleting go through it.
77
+ // Twenty 2.50 has none, those are done in its editor.
78
+ workspaceWorkflowId: string | null;
91
79
  // The published version people launch; none while the process is a draft
92
80
  workflowVersionId: string | undefined;
93
81
  name: string;
@@ -124,66 +112,51 @@ const VIEWS: { value: ProcessView; label: string }[] = [
124
112
  { value: 'draft', label: 'Черновики' },
125
113
  ];
126
114
 
127
- const stagesOf = (version: WorkflowVersionRecord | undefined) =>
115
+ const stagesOf = (version: CatalogVersion | undefined) =>
128
116
  orderRunSteps({ flow: { trigger: version?.trigger, steps: version?.steps } }).filter((step) => !isLaunchParametersStep(step));
129
117
 
130
- // Docflow processes are the standard workflows with the docflow section:
131
- // published ones started by hand are launched here, drafts are shown to be
132
- // finished in the editor. Both lists are filtered by the server.
133
- const loadProcesses = async (searchQuery: string): Promise<DocflowProcess[]> => {
134
- const { records: workflows } = await listRecords<WorkflowRecord>('workflows', {
135
- filter: restAnd(
136
- restEq('smbSection', DOCFLOW_SECTION),
137
- searchQuery.trim().length > 0 ? restIlike('name', searchQuery) : undefined,
138
- ),
139
- order_by: 'name[AscNullsLast]',
140
- limit: PROCESSES_LIMIT,
141
- });
142
-
143
- if (workflows.length === 0) {
144
- return [];
145
- }
118
+ const toDocflowProcess = (process: CatalogProcess): DocflowProcess => {
119
+ const { active, draft, deactivated } = process;
120
+ const isLaunchable = active !== undefined && isLaunchableTrigger(active.trigger);
121
+
122
+ return {
123
+ workflowId: process.workflowId,
124
+ workspaceWorkflowId: process.workspaceWorkflowId,
125
+ workflowVersionId: isLaunchable ? active.id : undefined,
126
+ name: process.name,
127
+ isPublished: isLaunchable,
128
+ isActive: active !== undefined,
129
+ hasUnpublishedChanges: active !== undefined && draft !== undefined,
130
+ launchFields: process.launchFields,
131
+ launchObject: process.launchObject,
132
+ stages: stagesOf(active ?? draft ?? deactivated),
133
+ updatedAt: process.updatedAt,
134
+ };
135
+ };
146
136
 
147
- const { records: versions } = await listRecords<WorkflowVersionRecord>('workflowVersions', {
148
- filter: restAnd(
149
- restIn('status', ['ACTIVE', 'DRAFT', 'DEACTIVATED']),
150
- restIn('workflowId', workflows.map(({ id }) => id)),
151
- ),
152
- // Newest first: the latest of the versions taken off comes first
153
- order_by: 'createdAt[DescNullsLast]',
154
- limit: PROCESSES_LIMIT * 5,
137
+ // Docflow processes come from the app's route, which reads them through the
138
+ // Twenty process engine (2.50 keeps no process records) and knows which are
139
+ // docflow ones. The list is short: searched here.
140
+ const loadProcesses = async (searchQuery: string): Promise<DocflowProcess[]> => {
141
+ const { processes } = await callAppRoute<{ processes: CatalogProcess[] }>('/docflow/processes/catalog', {
142
+ action: 'list',
155
143
  });
144
+ const query = searchQuery.trim().toLowerCase();
156
145
 
157
- return workflows.map((workflow) => {
158
- const active = versions.find((version) => version.workflowId === workflow.id && version.status === 'ACTIVE');
159
- const draft = versions.find((version) => version.workflowId === workflow.id && version.status === 'DRAFT');
160
- // Taken off publishing: its steps still show
161
- const deactivated = versions.find((version) => version.workflowId === workflow.id && version.status === 'DEACTIVATED');
162
- const isLaunchable = active !== undefined && isLaunchableTrigger(active.trigger);
163
-
164
- return {
165
- workflowId: workflow.id,
166
- workflowVersionId: isLaunchable ? active.id : undefined,
167
- name: workflow.name?.trim() || 'Без названия',
168
- isPublished: isLaunchable,
169
- isActive: active !== undefined,
170
- hasUnpublishedChanges: active !== undefined && draft !== undefined,
171
- launchFields: parseLaunchFields(workflow.smbLaunchFields),
172
- launchObject: isLaunchTrigger(active?.trigger) ? parseLaunchObject(workflow.smbLaunchObject) : undefined,
173
- stages: stagesOf(active ?? draft ?? deactivated),
174
- updatedAt: workflow.updatedAt,
175
- };
176
- });
146
+ return processes
147
+ .map(toDocflowProcess)
148
+ .filter((process) => query.length === 0 || process.name.toLowerCase().includes(query));
177
149
  };
178
150
 
179
- // Runs per process: all of them and the ones still going
151
+ // Runs per process: all of them and the ones still going. A run names its
152
+ // process by the engine's id on every Twenty version.
180
153
  const loadRunCounts = async (workflowIds: string[]): Promise<Record<string, RunCounts>> =>
181
154
  Object.fromEntries(
182
155
  await Promise.all(
183
156
  workflowIds.map(async (workflowId) => {
184
157
  const [total, running] = await Promise.all([
185
- countRecords('workflowRuns', restEq('workflowId', workflowId)),
186
- countRecords('workflowRuns', restAnd(restEq('workflowId', workflowId), restEq('status', 'RUNNING'))),
158
+ countRecords('workflowRuns', restEq('coreWorkflowId', workflowId)),
159
+ countRecords('workflowRuns', restAnd(restEq('coreWorkflowId', workflowId), restEq('status', 'RUNNING'))),
187
160
  ]);
188
161
 
189
162
  return [workflowId, { total, running }];
@@ -191,8 +164,10 @@ const loadRunCounts = async (workflowIds: string[]): Promise<Record<string, RunC
191
164
  ),
192
165
  );
193
166
 
194
- const openEditor = (workflowId: string) =>
195
- navigate(AppPath.RecordShowPage, { objectNameSingular: 'workflow', objectRecordId: workflowId });
167
+ const openEditor = (workflowId: string) => navigate(AppPath.WorkflowCoreShowPage, { coreWorkflowId: workflowId });
168
+
169
+ // What Twenty 2.50 does only in its own editor
170
+ const EDITOR_ONLY_MESSAGE = 'В этой версии Twenty это делается в редакторе процесса: «Открыть редактор»';
196
171
 
197
172
  const openRun = (runId: string) =>
198
173
  navigate(AppPath.RecordShowPage, { objectNameSingular: 'workflowRun', objectRecordId: runId });
@@ -234,7 +209,7 @@ const RecentRuns = ({ workflowId }: { workflowId: string }) => {
234
209
  async () =>
235
210
  (
236
211
  await listRecords<DocflowWorkflowRun>('workflowRuns', {
237
- filter: restEq('workflowId', workflowId),
212
+ filter: restEq('coreWorkflowId', workflowId),
238
213
  order_by: 'createdAt[DescNullsLast]',
239
214
  limit: RECENT_RUNS_LIMIT,
240
215
  })
@@ -387,7 +362,7 @@ const ProcessPanel = ({
387
362
 
388
363
  try {
389
364
  await callAppRoute('/docflow/processes/launch', {
390
- workflowVersionId: process.workflowVersionId ?? '',
365
+ workflowId: process.workflowId,
391
366
  values: parameters,
392
367
  });
393
368
  await enqueueSnackbar({ message: 'Процесс запущен', variant: 'success' });
@@ -460,6 +435,70 @@ const ProcessPanel = ({
460
435
  );
461
436
  };
462
437
 
438
+ // Processes of the workspace made in the Twenty editor, added to docflow:
439
+ // on Twenty 2.50 that is how a process gets here, the app cannot create one.
440
+ const AddFromTwentyDialog = ({ onClose, onAdded }: { onClose: () => void; onAdded: () => void }) => {
441
+ const theme = useTheme();
442
+ const [checkedIds, setCheckedIds] = useState<string[]>([]);
443
+ const [errorMessage, setErrorMessage] = useState<string | undefined>();
444
+ const { data: others, loading, error } = useAsync(
445
+ async () =>
446
+ (await callAppRoute<{ processes: OtherProcess[] }>('/docflow/processes/catalog', { action: 'others' })).processes,
447
+ [],
448
+ );
449
+
450
+ const toggle = (workflowId: string) =>
451
+ setCheckedIds((current) =>
452
+ current.includes(workflowId) ? current.filter((id) => id !== workflowId) : [...current, workflowId],
453
+ );
454
+
455
+ return (
456
+ <ValueDialog
457
+ title="Добавить процессы Twenty"
458
+ hint="Процессы из «Рабочих процессов» Twenty станут процессами документооборота: их можно будет запускать отсюда, а их задания попадут в «Задания»."
459
+ confirmLabel="Добавить"
460
+ canSubmit={checkedIds.length > 0}
461
+ onClose={onClose}
462
+ onSubmit={async () => {
463
+ try {
464
+ await callAppRoute('/docflow/processes/catalog', { action: 'add', workflowIds: checkedIds });
465
+ await enqueueSnackbar({ message: `Добавлено: ${checkedIds.length}`, variant: 'success' });
466
+ onAdded();
467
+ } catch (addError) {
468
+ setErrorMessage(getErrorMessage(addError, 'Не удалось добавить'));
469
+ }
470
+ }}
471
+ >
472
+ {loading && others === undefined && <ListSkeleton rows={3} />}
473
+ {error !== undefined && <Message tone="danger">{getErrorMessage(error, 'Не удалось загрузить процессы')}</Message>}
474
+ {others !== undefined && others.length === 0 && <Message tone="hint">Других процессов нет.</Message>}
475
+ <div style={{ display: 'flex', flexDirection: 'column', maxHeight: '320px', overflowY: 'auto' }}>
476
+ {(others ?? []).map((other) => (
477
+ <label
478
+ key={other.workflowId}
479
+ style={{
480
+ alignItems: 'center',
481
+ cursor: 'pointer',
482
+ display: 'flex',
483
+ fontSize: theme.font.size.md,
484
+ gap: theme.spacing[2],
485
+ padding: `${theme.spacing[1]} 0`,
486
+ }}
487
+ >
488
+ <input
489
+ type="checkbox"
490
+ checked={checkedIds.includes(other.workflowId)}
491
+ onChange={() => toggle(other.workflowId)}
492
+ />
493
+ {other.name}
494
+ </label>
495
+ ))}
496
+ </div>
497
+ {errorMessage !== undefined && <Message tone="danger">{errorMessage}</Message>}
498
+ </ValueDialog>
499
+ );
500
+ };
501
+
463
502
  // The server gives a new workflow its first draft version right after the
464
503
  // record; the launch step can only be put into an existing draft.
465
504
  const waitForDraft = async (workflowId: string) => {
@@ -488,6 +527,7 @@ const DocflowProcesses = () => {
488
527
  const [panel, setPanel] = useState<Panel>({ kind: 'closed' });
489
528
  const [renaming, setRenaming] = useState<DocflowProcess | undefined>();
490
529
  const [isCreating, setIsCreating] = useState(false);
530
+ const [isAdding, setIsAdding] = useState(false);
491
531
  const [hiddenColumns, setHiddenColumns] = useHiddenColumns(['updatedAt']);
492
532
  const { data: loaded, loading, error, reload } = useAsync(() => loadProcesses(searchQuery), [searchQuery]);
493
533
  const workflowIdsKey = (loaded ?? []).map(({ workflowId }) => workflowId).join();
@@ -537,18 +577,43 @@ const DocflowProcesses = () => {
537
577
  },
538
578
  });
539
579
 
580
+ // The 2.45 process records of the given processes; none on Twenty 2.50,
581
+ // which renames and deletes processes only in its editor
582
+ const recordIdsOf = (ids: string[]): string[] | undefined => {
583
+ const recordIds = ids.map((id) => processes.find(({ workflowId }) => workflowId === id)?.workspaceWorkflowId);
584
+
585
+ return recordIds.every((recordId): recordId is string => typeof recordId === 'string') ? recordIds : undefined;
586
+ };
587
+
540
588
  // The processes go to the Twenty trash ("Рабочие процессы → Удалённые"),
541
589
  // their runs and tasks stay.
542
590
  const removeProcesses = (ids: string[]) =>
543
591
  bulk.run(
544
592
  async () => {
545
- await deleteRecords('workflows', ids);
593
+ const recordIds = recordIdsOf(ids);
594
+
595
+ if (recordIds === undefined) {
596
+ throw new Error(EDITOR_ONLY_MESSAGE);
597
+ }
598
+
599
+ await deleteRecords('workflows', recordIds);
546
600
  setPanel((current) => (current.kind === 'process' && ids.includes(current.workflowId) ? { kind: 'closed' } : current));
547
601
  },
548
602
  ids.length === 1 ? 'Процесс удалён' : `Удалено процессов: ${ids.length}`,
549
603
  'Не удалось удалить',
550
604
  );
551
605
 
606
+ // Only the mark goes: the process stays in Twenty with its runs
607
+ const takeOutOfDocflow = (ids: string[]) =>
608
+ bulk.run(
609
+ async () => {
610
+ await callAppRoute('/docflow/processes/catalog', { action: 'remove', workflowIds: ids });
611
+ setPanel((current) => (current.kind === 'process' && ids.includes(current.workflowId) ? { kind: 'closed' } : current));
612
+ },
613
+ ids.length === 1 ? 'Процесс убран из документооборота' : `Убрано из документооборота: ${ids.length}`,
614
+ 'Не удалось убрать',
615
+ );
616
+
552
617
  // Publishing goes through the app's route, one process at a time: the
553
618
  // workflow engine takes a version per call.
554
619
  const publishProcesses = (ids: string[], publish: boolean) =>
@@ -559,7 +624,11 @@ const DocflowProcesses = () => {
559
624
 
560
625
  const handleRenameProcess = async (process: DocflowProcess, name: string) => {
561
626
  try {
562
- await updateRecord('workflows', process.workflowId, { name });
627
+ if (process.workspaceWorkflowId === null) {
628
+ throw new Error(EDITOR_ONLY_MESSAGE);
629
+ }
630
+
631
+ await updateRecord('workflows', process.workspaceWorkflowId, { name });
563
632
  await enqueueSnackbar({ message: 'Процесс переименован', variant: 'success' });
564
633
  setRenaming(undefined);
565
634
  await reload();
@@ -593,10 +662,21 @@ const DocflowProcesses = () => {
593
662
  });
594
663
  } catch (createError) {
595
664
  logDocflowError('process: create failed', createError);
596
- await enqueueSnackbar({
597
- message: getErrorMessage(createError, 'Не удалось создать процесс'),
598
- variant: 'error',
599
- });
665
+ // Twenty 2.50 lets no app create a process: it is made in the Twenty
666
+ // editor and added here
667
+ const message = getErrorMessage(createError, 'Не удалось создать процесс');
668
+
669
+ if (/Method not allowed/i.test(message)) {
670
+ await enqueueSnackbar({
671
+ message: 'В этой версии Twenty процесс создаётся в «Рабочих процессах» Twenty, затем — «Добавить из Twenty»',
672
+ variant: 'info',
673
+ });
674
+ await navigate(AppPath.RecordIndexPage, { objectNamePlural: 'workflows' });
675
+
676
+ return;
677
+ }
678
+
679
+ await enqueueSnackbar({ message, variant: 'error' });
600
680
  } finally {
601
681
  setIsCreating(false);
602
682
  }
@@ -628,6 +708,12 @@ const DocflowProcesses = () => {
628
708
  : []),
629
709
  { key: 'edit', label: 'Открыть редактор', icon: IconSettings, onSelect: () => void openEditor(workflowId) },
630
710
  { key: 'rename', label: 'Переименовать', icon: IconPencil, onSelect: () => setRenaming(process) },
711
+ {
712
+ key: 'take-out',
713
+ label: 'Убрать из документооборота',
714
+ icon: IconX,
715
+ onSelect: () => void takeOutOfDocflow([workflowId]),
716
+ },
631
717
  {
632
718
  key: 'delete',
633
719
  label: 'Удалить',
@@ -692,6 +778,11 @@ const DocflowProcesses = () => {
692
778
  }))}
693
779
  />
694
780
  }
781
+ actions={
782
+ <Button size="md" icon={IconPlus} onClick={() => setIsAdding(true)}>
783
+ Добавить из Twenty
784
+ </Button>
785
+ }
695
786
  onCreate={() => void handleCreate()}
696
787
  isCreating={isCreating}
697
788
  />
@@ -702,7 +793,9 @@ const DocflowProcesses = () => {
702
793
  )}
703
794
  {loading && loaded === undefined && <TableSkeleton />}
704
795
  {!loading && error === undefined && processes.length === 0 && (
705
- <Message>Процессов нет. «Создать» добавит процесс в раздел «Документооборот».</Message>
796
+ <Message>
797
+ Процессов нет. «Создать» добавит новый процесс, «Добавить из Twenty» — уже настроенный в «Рабочих процессах».
798
+ </Message>
706
799
  )}
707
800
  {processes.length > 0 && (
708
801
  <DataTable
@@ -779,6 +872,15 @@ const DocflowProcesses = () => {
779
872
  />
780
873
  </SelectionBar>
781
874
  </SplitView>
875
+ {isAdding && (
876
+ <AddFromTwentyDialog
877
+ onClose={() => setIsAdding(false)}
878
+ onAdded={() => {
879
+ setIsAdding(false);
880
+ void reloadAll();
881
+ }}
882
+ />
883
+ )}
782
884
  {renaming !== undefined && (
783
885
  <PromptDialog
784
886
  title="Переименовать процесс"