@haiiro/docuflow 0.0.12 → 0.0.13

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 (26) hide show
  1. package/README.md +38 -8
  2. package/manifest.json +99 -13
  3. package/package.json +6 -3
  4. package/public/app/docflow-app.css +2 -0
  5. package/public/app/docflow-app.js +94 -0
  6. package/src/front-components/docflow-documents.front-component.mjs +3 -3
  7. package/src/front-components/docflow-processes.front-component.mjs +19 -19
  8. package/src/front-components/docflow-processes.front-component.tsx +18 -2
  9. package/src/front-components/docflow-tasks.front-component.mjs +3 -3
  10. package/src/front-components/launch-process.front-component.mjs +75 -0
  11. package/src/front-components/launch-process.front-component.tsx +270 -0
  12. package/src/front-components/process-steps.front-component.mjs +24 -24
  13. package/src/front-components/process-steps.front-component.tsx +54 -9
  14. package/src/front-components/task-form.front-component.mjs +21 -21
  15. package/src/logic-functions/bitrix-app.logic-function.mjs +160 -87
  16. package/src/logic-functions/bitrix-app.logic-function.ts +22 -59
  17. package/src/logic-functions/bitrix-install.logic-function.mjs +0 -11
  18. package/src/logic-functions/bitrix-install.logic-function.ts +0 -3
  19. package/src/logic-functions/docflow-app-session.logic-function.mjs +60043 -0
  20. package/src/logic-functions/docflow-app-session.logic-function.ts +54 -0
  21. package/src/logic-functions/docflow-app.logic-function.mjs +60122 -0
  22. package/src/logic-functions/docflow-app.logic-function.ts +37 -0
  23. package/src/logic-functions/launch-process.logic-function.mjs +113 -29
  24. package/src/logic-functions/launch-process.logic-function.ts +83 -22
  25. package/src/logic-functions/save-launch-fields.logic-function.mjs +176 -9
  26. package/src/logic-functions/save-launch-fields.logic-function.ts +24 -6
@@ -0,0 +1,270 @@
1
+ import { useState } from 'react';
2
+ import { defineFrontComponent } from 'twenty-sdk/define';
3
+ import {
4
+ enqueueSnackbar,
5
+ openSidePanelPage,
6
+ SidePanelPages,
7
+ useSelectedObjectMetadata,
8
+ useSelectedRecordIds,
9
+ } from 'twenty-sdk/front-component';
10
+ import { IconPlayerPlay } from 'twenty-ui/icon';
11
+ import { useTheme } from 'twenty-ui/theme';
12
+
13
+ import { callAppRoute, getErrorMessage, listRecords } from 'src/front/api';
14
+ import { useAsync } from 'src/front/hooks/use-async';
15
+ import { DocflowRoot, Message, pluralize, SectionTitle } from 'src/front/ui/components';
16
+ import { FormFieldInputs } from 'src/front/ui/form-field-inputs';
17
+ import { Button, MenuRow } from 'src/front/ui/kit';
18
+ import { restAnd, restEq, restIn } from 'src/shared/api/rest-filter.util';
19
+ import { DOCFLOW_SECTION, LAUNCH_RECORDS_LIMIT } from 'src/shared/docflow.constants';
20
+ import { type FormField, type FormValues } from 'src/shared/form/form-field.type';
21
+ import { findMissingRequiredFields } from 'src/shared/form/form-fields.util';
22
+ import { logDocflowError } from 'src/shared/log.util';
23
+ import {
24
+ buildLaunchParameters,
25
+ isLaunchTrigger,
26
+ parseLaunchFields,
27
+ parseLaunchObject,
28
+ } from 'src/shared/workflow/launch-step.util';
29
+ import { type WorkflowTrigger } from 'src/shared/workflow/workflow-run.type';
30
+ import { LAUNCH_PROCESS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/universal-identifiers';
31
+
32
+ const PROCESSES_LIMIT = 200;
33
+ const RECORD_NOUN: [string, string, string] = ['запись', 'записи', 'записей'];
34
+
35
+ type WorkflowRecord = {
36
+ id: string;
37
+ name?: string | null;
38
+ smbLaunchFields?: unknown;
39
+ smbLaunchObject?: string | null;
40
+ };
41
+ type WorkflowVersionRecord = { id: string; workflowId: string; trigger?: WorkflowTrigger | null };
42
+
43
+ type LaunchableProcess = {
44
+ workflowVersionId: string;
45
+ name: string;
46
+ launchFields: FormField[];
47
+ // Launched on the selected records, or without a record
48
+ isOnRecords: boolean;
49
+ };
50
+
51
+ // A plain manual trigger offered everywhere: the process needs no record
52
+ const isGlobalManualTrigger = (trigger: WorkflowTrigger | null | undefined) => {
53
+ const availability = (trigger?.settings as { availability?: { type?: string } } | undefined)?.availability;
54
+
55
+ return trigger?.type === 'MANUAL' && (availability?.type ?? 'GLOBAL') === 'GLOBAL';
56
+ };
57
+
58
+ // The published docflow processes this place can launch: on the selected
59
+ // records the ones of their object, and everywhere the ones without a record.
60
+ const loadLaunchableProcesses = async (objectName: string | undefined): Promise<LaunchableProcess[]> => {
61
+ const { records: workflows } = await listRecords<WorkflowRecord>('workflows', {
62
+ filter: restEq('smbSection', DOCFLOW_SECTION),
63
+ order_by: 'name[AscNullsLast]',
64
+ limit: PROCESSES_LIMIT,
65
+ });
66
+
67
+ if (workflows.length === 0) {
68
+ return [];
69
+ }
70
+
71
+ const { records: versions } = await listRecords<WorkflowVersionRecord>('workflowVersions', {
72
+ filter: restAnd(restEq('status', 'ACTIVE'), restIn('workflowId', workflows.map(({ id }) => id))),
73
+ limit: PROCESSES_LIMIT,
74
+ });
75
+
76
+ const processes = workflows.flatMap((workflow): LaunchableProcess[] => {
77
+ const active = versions.find((version) => version.workflowId === workflow.id);
78
+ const launchObject = isLaunchTrigger(active?.trigger) ? parseLaunchObject(workflow.smbLaunchObject) : undefined;
79
+ const isLaunchable =
80
+ active !== undefined &&
81
+ (isLaunchTrigger(active.trigger) || isGlobalManualTrigger(active.trigger)) &&
82
+ (launchObject === undefined || launchObject === objectName);
83
+
84
+ return isLaunchable
85
+ ? [
86
+ {
87
+ workflowVersionId: active.id,
88
+ name: workflow.name?.trim() || 'Без названия',
89
+ launchFields: isLaunchTrigger(active.trigger) ? parseLaunchFields(workflow.smbLaunchFields) : [],
90
+ isOnRecords: launchObject !== undefined,
91
+ },
92
+ ]
93
+ : [];
94
+ });
95
+
96
+ // The processes of the record come first
97
+ return [...processes.filter(({ isOnRecords }) => isOnRecords), ...processes.filter(({ isOnRecords }) => !isOnRecords)];
98
+ };
99
+
100
+ const LaunchForm = ({
101
+ process,
102
+ recordIds,
103
+ onBack,
104
+ }: {
105
+ process: LaunchableProcess;
106
+ recordIds: string[];
107
+ onBack: (() => void) | undefined;
108
+ }) => {
109
+ const theme = useTheme();
110
+ const [values, setValues] = useState<FormValues>({});
111
+ const [isLaunching, setIsLaunching] = useState(false);
112
+ const [errorMessage, setErrorMessage] = useState<string | undefined>();
113
+ const launchRecordIds = process.isOnRecords ? recordIds : [];
114
+
115
+ const launch = async () => {
116
+ const parameters = buildLaunchParameters({ fields: process.launchFields, values });
117
+ const missingFields = findMissingRequiredFields({ fields: process.launchFields, values: parameters });
118
+
119
+ if (missingFields.length > 0) {
120
+ setErrorMessage(`Заполните: ${missingFields.map(({ label }) => label).join(', ')}`);
121
+
122
+ return;
123
+ }
124
+
125
+ setIsLaunching(true);
126
+ setErrorMessage(undefined);
127
+
128
+ try {
129
+ const { workflowRunIds } = await callAppRoute<{ workflowRunIds: string[] }>('/docflow/processes/launch', {
130
+ workflowVersionId: process.workflowVersionId,
131
+ values: parameters,
132
+ recordIds: launchRecordIds,
133
+ });
134
+
135
+ if (workflowRunIds.length > 1) {
136
+ await enqueueSnackbar({
137
+ message: `Запущено процессов: ${workflowRunIds.length}`,
138
+ variant: 'success',
139
+ });
140
+ }
141
+
142
+ // Like the standard manual launch: the run opens in the side panel
143
+ await openSidePanelPage({
144
+ page: SidePanelPages.ViewRecord,
145
+ objectNameSingular: 'workflowRun',
146
+ recordId: workflowRunIds[0],
147
+ resetNavigationStack: true,
148
+ });
149
+ } catch (error) {
150
+ logDocflowError('launch: failed', error, { workflowVersionId: process.workflowVersionId });
151
+ setErrorMessage(getErrorMessage(error, 'Не удалось запустить процесс'));
152
+ setIsLaunching(false);
153
+ }
154
+ };
155
+
156
+ return (
157
+ <section style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[3] }} aria-label="Запуск процесса">
158
+ <SectionTitle
159
+ action={
160
+ onBack !== undefined ? (
161
+ <Button size="sm" variant="ghost" disabled={isLaunching} onClick={onBack}>
162
+ Другой процесс
163
+ </Button>
164
+ ) : undefined
165
+ }
166
+ >
167
+ {process.name}
168
+ </SectionTitle>
169
+ {process.isOnRecords && launchRecordIds.length > 1 && (
170
+ <Message tone="hint">
171
+ Выбрано {launchRecordIds.length} {pluralize(launchRecordIds.length, RECORD_NOUN)}: процесс запустится на
172
+ каждой, с одними параметрами.
173
+ </Message>
174
+ )}
175
+ {process.launchFields.length > 0 ? (
176
+ <FormFieldInputs fields={process.launchFields} values={values} onChange={setValues} disabled={isLaunching} />
177
+ ) : (
178
+ <Message tone="hint">Процесс запускается без параметров.</Message>
179
+ )}
180
+ {errorMessage !== undefined && <Message tone="danger">{errorMessage}</Message>}
181
+ <Button
182
+ variant="solid"
183
+ tone="accent"
184
+ size="md"
185
+ fullWidth
186
+ icon={IconPlayerPlay}
187
+ loading={isLaunching}
188
+ onClick={() => void launch()}
189
+ >
190
+ Запустить
191
+ </Button>
192
+ </section>
193
+ );
194
+ };
195
+
196
+ // "Запустить процесс" of the command menu: the manual launch of a docflow
197
+ // process with its parameters, in the side panel. From a record it offers the
198
+ // processes launched on records of its object and passes the record in; from
199
+ // anywhere else, the processes that need no record. The standard manual launch
200
+ // asks nothing, so a process with parameters is launched here.
201
+ const LaunchProcess = () => {
202
+ const theme = useTheme();
203
+ const selectedRecordIds = useSelectedRecordIds();
204
+ const selectedObject = useSelectedObjectMetadata();
205
+ const objectName = selectedRecordIds.length > 0 ? selectedObject?.nameSingular : undefined;
206
+ const [selectedVersionId, setSelectedVersionId] = useState<string | undefined>();
207
+ const { data: processes, error, loading } = useAsync(
208
+ () => loadLaunchableProcesses(objectName),
209
+ [objectName],
210
+ );
211
+ const onlyProcess = processes?.length === 1 ? processes[0] : undefined;
212
+ const selectedProcess = onlyProcess ?? processes?.find(({ workflowVersionId }) => workflowVersionId === selectedVersionId);
213
+
214
+ return (
215
+ <DocflowRoot>
216
+ <div style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[3], padding: theme.spacing[3] }}>
217
+ {loading && processes === undefined && <Message>Загрузка…</Message>}
218
+ {error !== undefined && <Message tone="danger">{getErrorMessage(error, 'Не удалось прочитать процессы')}</Message>}
219
+ {selectedRecordIds.length > LAUNCH_RECORDS_LIMIT && (
220
+ <Message tone="danger">За один раз — не больше {LAUNCH_RECORDS_LIMIT} записей.</Message>
221
+ )}
222
+ {processes !== undefined && processes.length === 0 && (
223
+ <Message tone="hint">
224
+ {objectName !== undefined
225
+ ? 'Для этих записей нет опубликованных процессов. Объект, на записях которого запускается процесс, выбирается в «Параметрах запуска» процесса.'
226
+ : 'Нет опубликованных процессов, которые запускаются вручную.'}
227
+ </Message>
228
+ )}
229
+ {selectedProcess !== undefined ? (
230
+ <LaunchForm
231
+ key={selectedProcess.workflowVersionId}
232
+ process={selectedProcess}
233
+ recordIds={selectedRecordIds}
234
+ onBack={onlyProcess === undefined ? () => setSelectedVersionId(undefined) : undefined}
235
+ />
236
+ ) : (
237
+ processes !== undefined &&
238
+ processes.length > 0 && (
239
+ <div style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[1] }}>
240
+ {processes.map((process, index) => (
241
+ <div key={process.workflowVersionId}>
242
+ {objectName !== undefined && index === processes.findIndex(({ isOnRecords }) => isOnRecords === process.isOnRecords) && (
243
+ <SectionTitle>{process.isOnRecords ? 'На выбранной записи' : 'Без записи'}</SectionTitle>
244
+ )}
245
+ <MenuRow
246
+ icon={IconPlayerPlay}
247
+ label={process.name}
248
+ trailing={
249
+ process.launchFields.length > 0
250
+ ? `${process.launchFields.length} ${pluralize(process.launchFields.length, ['параметр', 'параметра', 'параметров'])}`
251
+ : undefined
252
+ }
253
+ onClick={() => setSelectedVersionId(process.workflowVersionId)}
254
+ />
255
+ </div>
256
+ ))}
257
+ </div>
258
+ )
259
+ )}
260
+ </div>
261
+ </DocflowRoot>
262
+ );
263
+ };
264
+
265
+ export default defineFrontComponent({
266
+ universalIdentifier: LAUNCH_PROCESS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
267
+ name: 'docflow-launch-process',
268
+ description: 'Запуск процесса документооборота с параметрами из командного меню',
269
+ component: LaunchProcess,
270
+ });