@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.
- package/README.md +38 -8
- package/manifest.json +99 -13
- package/package.json +6 -3
- package/public/app/docflow-app.css +2 -0
- package/public/app/docflow-app.js +94 -0
- package/src/front-components/docflow-documents.front-component.mjs +3 -3
- package/src/front-components/docflow-processes.front-component.mjs +19 -19
- package/src/front-components/docflow-processes.front-component.tsx +18 -2
- package/src/front-components/docflow-tasks.front-component.mjs +3 -3
- package/src/front-components/launch-process.front-component.mjs +75 -0
- package/src/front-components/launch-process.front-component.tsx +270 -0
- package/src/front-components/process-steps.front-component.mjs +24 -24
- package/src/front-components/process-steps.front-component.tsx +54 -9
- package/src/front-components/task-form.front-component.mjs +21 -21
- package/src/logic-functions/bitrix-app.logic-function.mjs +160 -87
- package/src/logic-functions/bitrix-app.logic-function.ts +22 -59
- package/src/logic-functions/bitrix-install.logic-function.mjs +0 -11
- package/src/logic-functions/bitrix-install.logic-function.ts +0 -3
- package/src/logic-functions/docflow-app-session.logic-function.mjs +60043 -0
- package/src/logic-functions/docflow-app-session.logic-function.ts +54 -0
- package/src/logic-functions/docflow-app.logic-function.mjs +60122 -0
- package/src/logic-functions/docflow-app.logic-function.ts +37 -0
- package/src/logic-functions/launch-process.logic-function.mjs +113 -29
- package/src/logic-functions/launch-process.logic-function.ts +83 -22
- package/src/logic-functions/save-launch-fields.logic-function.mjs +176 -9
- 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
|
+
});
|