@nocobase/plugin-ui-templates 2.0.0-alpha.57
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/LICENSE.txt +172 -0
- package/build.config.ts +12 -0
- package/client.js +1 -0
- package/dist/client/collections/flowModelTemplates.d.ts +67 -0
- package/dist/client/components/FlowModelTemplatesPage.d.ts +12 -0
- package/dist/client/components/TemplateSelectOption.d.ts +20 -0
- package/dist/client/constants.d.ts +9 -0
- package/dist/client/hooks/useFlowModelTemplateActions.d.ts +24 -0
- package/dist/client/index.d.ts +13 -0
- package/dist/client/index.js +10 -0
- package/dist/client/locale.d.ts +18 -0
- package/dist/client/menuExtensions.d.ts +9 -0
- package/dist/client/models/ReferenceBlockModel.d.ts +47 -0
- package/dist/client/models/ReferenceFormGridModel.d.ts +38 -0
- package/dist/client/models/SubModelTemplateImporterModel.d.ts +55 -0
- package/dist/client/models/referenceShared.d.ts +23 -0
- package/dist/client/openViewActionExtensions.d.ts +10 -0
- package/dist/client/schemas/flowModelTemplates.d.ts +11 -0
- package/dist/client/subModelMenuExtensions.d.ts +10 -0
- package/dist/client/utils/infiniteSelect.d.ts +28 -0
- package/dist/client/utils/refHost.d.ts +20 -0
- package/dist/client/utils/templateCompatibility.d.ts +91 -0
- package/dist/client.d.ts +9 -0
- package/dist/client.js +42 -0
- package/dist/externalVersion.js +24 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +48 -0
- package/dist/locale/de-DE.json +14 -0
- package/dist/locale/en-US.json +72 -0
- package/dist/locale/es-ES.json +14 -0
- package/dist/locale/fr-FR.json +14 -0
- package/dist/locale/hu-HU.json +14 -0
- package/dist/locale/id-ID.json +14 -0
- package/dist/locale/it-IT.json +14 -0
- package/dist/locale/ja-JP.json +14 -0
- package/dist/locale/ko-KR.json +14 -0
- package/dist/locale/nl-NL.json +14 -0
- package/dist/locale/pt-BR.json +14 -0
- package/dist/locale/ru-RU.json +14 -0
- package/dist/locale/tr-TR.json +14 -0
- package/dist/locale/uk-UA.json +14 -0
- package/dist/locale/vi-VN.json +14 -0
- package/dist/locale/zh-CN.json +71 -0
- package/dist/locale/zh-TW.json +14 -0
- package/dist/server/collections/flowModelTemplateUsages.d.ts +11 -0
- package/dist/server/collections/flowModelTemplateUsages.js +71 -0
- package/dist/server/collections/flowModelTemplates.d.ts +11 -0
- package/dist/server/collections/flowModelTemplates.js +96 -0
- package/dist/server/index.d.ts +9 -0
- package/dist/server/index.js +42 -0
- package/dist/server/plugin.d.ts +17 -0
- package/dist/server/plugin.js +242 -0
- package/dist/server/resources/flowModelTemplateUsages.d.ts +19 -0
- package/dist/server/resources/flowModelTemplateUsages.js +91 -0
- package/dist/server/resources/flowModelTemplates.d.ts +20 -0
- package/dist/server/resources/flowModelTemplates.js +267 -0
- package/package.json +37 -0
- package/server.js +1 -0
- package/src/client/__tests__/openViewActionExtensions.test.ts +1208 -0
- package/src/client/collections/flowModelTemplates.ts +131 -0
- package/src/client/components/FlowModelTemplatesPage.tsx +78 -0
- package/src/client/components/TemplateSelectOption.tsx +106 -0
- package/src/client/constants.ts +10 -0
- package/src/client/hooks/useFlowModelTemplateActions.tsx +137 -0
- package/src/client/index.ts +54 -0
- package/src/client/locale.ts +40 -0
- package/src/client/menuExtensions.tsx +1033 -0
- package/src/client/models/ReferenceBlockModel.tsx +793 -0
- package/src/client/models/ReferenceFormGridModel.tsx +302 -0
- package/src/client/models/SubModelTemplateImporterModel.tsx +634 -0
- package/src/client/models/__tests__/ReferenceBlockModel.test.tsx +482 -0
- package/src/client/models/__tests__/ReferenceFormGridModel.test.tsx +175 -0
- package/src/client/models/__tests__/SubModelTemplateImporterModel.test.ts +447 -0
- package/src/client/models/referenceShared.tsx +99 -0
- package/src/client/openViewActionExtensions.tsx +981 -0
- package/src/client/schemas/flowModelTemplates.ts +264 -0
- package/src/client/subModelMenuExtensions.ts +103 -0
- package/src/client/utils/infiniteSelect.ts +150 -0
- package/src/client/utils/refHost.ts +44 -0
- package/src/client/utils/templateCompatibility.ts +374 -0
- package/src/client.ts +10 -0
- package/src/index.ts +11 -0
- package/src/locale/de-DE.json +14 -0
- package/src/locale/en-US.json +72 -0
- package/src/locale/es-ES.json +14 -0
- package/src/locale/fr-FR.json +14 -0
- package/src/locale/hu-HU.json +14 -0
- package/src/locale/id-ID.json +14 -0
- package/src/locale/it-IT.json +14 -0
- package/src/locale/ja-JP.json +14 -0
- package/src/locale/ko-KR.json +14 -0
- package/src/locale/nl-NL.json +14 -0
- package/src/locale/pt-BR.json +14 -0
- package/src/locale/ru-RU.json +14 -0
- package/src/locale/tr-TR.json +14 -0
- package/src/locale/uk-UA.json +14 -0
- package/src/locale/vi-VN.json +14 -0
- package/src/locale/zh-CN.json +71 -0
- package/src/locale/zh-TW.json +14 -0
- package/src/server/__tests__/template-usage.test.ts +351 -0
- package/src/server/collections/flowModelTemplateUsages.ts +51 -0
- package/src/server/collections/flowModelTemplates.ts +76 -0
- package/src/server/index.ts +10 -0
- package/src/server/plugin.ts +236 -0
- package/src/server/resources/flowModelTemplateUsages.ts +61 -0
- package/src/server/resources/flowModelTemplates.ts +251 -0
|
@@ -0,0 +1,1033 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React, { useState } from 'react';
|
|
11
|
+
import { Button, Form, Input, Modal, Radio, Space, Tooltip, Typography } from 'antd';
|
|
12
|
+
import { ExclamationCircleFilled, QuestionCircleOutlined } from '@ant-design/icons';
|
|
13
|
+
import _ from 'lodash';
|
|
14
|
+
import { FlowModel, createBlockScopedEngine } from '@nocobase/flow-engine';
|
|
15
|
+
import { BlockModel } from '@nocobase/client';
|
|
16
|
+
import { ReferenceBlockModel } from './models/ReferenceBlockModel';
|
|
17
|
+
import { NAMESPACE, tStr, getPluginT } from './locale';
|
|
18
|
+
import {
|
|
19
|
+
extractPopupTemplateContextFlagsFromParams,
|
|
20
|
+
inferPopupTemplateContextFlags,
|
|
21
|
+
normalizeStr,
|
|
22
|
+
resolveActionScene,
|
|
23
|
+
type PopupTemplateContextFlags,
|
|
24
|
+
} from './utils/templateCompatibility';
|
|
25
|
+
|
|
26
|
+
type MenuItem = {
|
|
27
|
+
key: string;
|
|
28
|
+
label: React.ReactNode;
|
|
29
|
+
group?: string;
|
|
30
|
+
sort?: number;
|
|
31
|
+
onClick?: () => void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const unwrapData = (val: any) => val?.data?.data ?? val?.data ?? val;
|
|
35
|
+
|
|
36
|
+
const normalizeTitle = (val?: string) => {
|
|
37
|
+
if (!val) return '';
|
|
38
|
+
return String(val).replace(/\s+/g, ' ').trim();
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const openConvertDialogs = new WeakSet<FlowModel>();
|
|
42
|
+
const openCopyDialogs = new WeakSet<FlowModel>();
|
|
43
|
+
const openFieldsCopyDialogs = new WeakSet<FlowModel>();
|
|
44
|
+
const openSavePopupTemplateDialogs = new Set<string>();
|
|
45
|
+
const openConvertPopupTemplateDialogs = new Set<string>();
|
|
46
|
+
|
|
47
|
+
const GRID_REF_FLOW_KEY = 'referenceSettings';
|
|
48
|
+
const GRID_REF_STEP_KEY = 'useTemplate';
|
|
49
|
+
|
|
50
|
+
type ReferenceFormGridTargetSettings = {
|
|
51
|
+
templateUid: string;
|
|
52
|
+
templateName?: string;
|
|
53
|
+
targetUid: string;
|
|
54
|
+
targetPath?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function getReferenceFormGridSettings(
|
|
58
|
+
block: FlowModel,
|
|
59
|
+
): { grid: FlowModel; settings: ReferenceFormGridTargetSettings } | null {
|
|
60
|
+
const grid = (block.subModels as any)?.grid as FlowModel | undefined;
|
|
61
|
+
if (!grid) return null;
|
|
62
|
+
if (grid.use !== 'ReferenceFormGridModel') return null;
|
|
63
|
+
const raw = grid.getStepParams(GRID_REF_FLOW_KEY, GRID_REF_STEP_KEY);
|
|
64
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
65
|
+
const templateUid = String((raw as any).templateUid || '').trim();
|
|
66
|
+
const targetUid = String((raw as any).targetUid || '').trim();
|
|
67
|
+
if (!templateUid || !targetUid) return null;
|
|
68
|
+
const templateName = String((raw as any).templateName || '').trim() || undefined;
|
|
69
|
+
const targetPath = String((raw as any).targetPath || '').trim() || undefined;
|
|
70
|
+
return { grid, settings: { templateUid, templateName, targetUid, targetPath } };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function handleConvertToTemplate(model: FlowModel, _t: (k: string, opt?: any) => string) {
|
|
74
|
+
const t = getPluginT(model);
|
|
75
|
+
const api = model.context.api;
|
|
76
|
+
const viewer = model.context.viewer;
|
|
77
|
+
if (!api?.resource || !viewer?.dialog) {
|
|
78
|
+
model.context.message?.error?.('[block-reference] api/viewer is unavailable.');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (openConvertDialogs.has(model)) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
openConvertDialogs.add(model);
|
|
85
|
+
const viewArgs = model.context.view?.inputArgs || {};
|
|
86
|
+
const resourceCtx = model.context.resource || {};
|
|
87
|
+
const resourceInit = model.getStepParams('resourceSettings', 'init') || {};
|
|
88
|
+
|
|
89
|
+
const getResourceVal = (key: string) =>
|
|
90
|
+
resourceCtx?.[key] ||
|
|
91
|
+
(typeof resourceCtx?.[`get${_.upperFirst(key)}`] === 'function'
|
|
92
|
+
? resourceCtx[`get${_.upperFirst(key)}`]()
|
|
93
|
+
: undefined) ||
|
|
94
|
+
viewArgs?.[key];
|
|
95
|
+
|
|
96
|
+
const getRawInitVal = (key: string) => {
|
|
97
|
+
const val = resourceInit?.[key];
|
|
98
|
+
if (val === undefined || val === null) return undefined;
|
|
99
|
+
if (typeof val === 'string') {
|
|
100
|
+
const trimmed = val.trim();
|
|
101
|
+
return trimmed ? trimmed : undefined;
|
|
102
|
+
}
|
|
103
|
+
return val;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const release = () => openConvertDialogs.delete(model);
|
|
107
|
+
|
|
108
|
+
const TemplateDialogContent: React.FC<{ currentDialog: any }> = ({ currentDialog }) => {
|
|
109
|
+
const [form] = Form.useForm();
|
|
110
|
+
const [submitting, setSubmitting] = useState(false);
|
|
111
|
+
const [closed, setClosed] = useState(false);
|
|
112
|
+
|
|
113
|
+
const handleSubmit = async () => {
|
|
114
|
+
const values = await form.validateFields();
|
|
115
|
+
const isConvertMode = values.saveMode === 'convert';
|
|
116
|
+
let targetUid = model.uid;
|
|
117
|
+
if (!isConvertMode) {
|
|
118
|
+
const duplicated = await api.resource('flowModels').duplicate({ uid: model.uid });
|
|
119
|
+
const dupBody = unwrapData(duplicated);
|
|
120
|
+
const duplicatedUid =
|
|
121
|
+
(dupBody && typeof dupBody === 'object' && (dupBody.uid || dupBody.data?.uid)) ||
|
|
122
|
+
(typeof dupBody === 'string' ? dupBody : undefined);
|
|
123
|
+
if (!duplicatedUid) {
|
|
124
|
+
throw new Error('[block-reference] Duplicate block failed.');
|
|
125
|
+
}
|
|
126
|
+
targetUid = duplicatedUid;
|
|
127
|
+
}
|
|
128
|
+
const payload = {
|
|
129
|
+
name: values.name,
|
|
130
|
+
description: values.description,
|
|
131
|
+
targetUid,
|
|
132
|
+
useModel: model.use,
|
|
133
|
+
type: 'block',
|
|
134
|
+
dataSourceKey: getResourceVal('dataSourceKey') || getResourceVal('dataSource') || resourceInit?.dataSourceKey,
|
|
135
|
+
collectionName: getResourceVal('collectionName') || resourceInit?.collectionName,
|
|
136
|
+
associationName: getResourceVal('associationName') || resourceInit?.associationName,
|
|
137
|
+
filterByTk: getRawInitVal('filterByTk') ?? getResourceVal('filterByTk'),
|
|
138
|
+
sourceId: getRawInitVal('sourceId') ?? getResourceVal('sourceId'),
|
|
139
|
+
detachParent: isConvertMode,
|
|
140
|
+
};
|
|
141
|
+
const res = await api.resource('flowModelTemplates').create({
|
|
142
|
+
values: payload,
|
|
143
|
+
});
|
|
144
|
+
const tpl = unwrapData(res);
|
|
145
|
+
const tplUid = tpl?.uid || tpl?.data?.uid || tpl?.data?.data?.uid || targetUid;
|
|
146
|
+
model.context.message?.success?.(t('Template created'));
|
|
147
|
+
|
|
148
|
+
if (isConvertMode) {
|
|
149
|
+
const parent = model.parent as FlowModel | undefined;
|
|
150
|
+
const subKey = model.subKey;
|
|
151
|
+
const subType = model.subType;
|
|
152
|
+
const engine = model.flowEngine;
|
|
153
|
+
if (!parent || !subKey || !engine) {
|
|
154
|
+
model.context.message?.error?.(t('Parent not found, cannot replace block'));
|
|
155
|
+
} else {
|
|
156
|
+
const newModel = engine.createModel<FlowModel>({
|
|
157
|
+
use: 'ReferenceBlockModel',
|
|
158
|
+
parentId: parent.uid,
|
|
159
|
+
subKey,
|
|
160
|
+
subType,
|
|
161
|
+
});
|
|
162
|
+
newModel.setParent(parent);
|
|
163
|
+
newModel.setStepParams('referenceSettings', 'target', {
|
|
164
|
+
targetUid,
|
|
165
|
+
mode: 'reference',
|
|
166
|
+
});
|
|
167
|
+
newModel.setStepParams('referenceSettings', 'useTemplate', {
|
|
168
|
+
templateUid: tplUid,
|
|
169
|
+
templateName: tpl?.name || tpl?.data?.name || values.name,
|
|
170
|
+
templateDescription: tpl?.description || tpl?.data?.description || values.description,
|
|
171
|
+
targetUid,
|
|
172
|
+
mode: 'reference',
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (subType === 'array') {
|
|
176
|
+
let arr = ((parent.subModels as any)[subKey] || []) as FlowModel[];
|
|
177
|
+
if (!Array.isArray(arr)) {
|
|
178
|
+
(parent.subModels as any)[subKey] = [];
|
|
179
|
+
arr = (parent.subModels as any)[subKey] as FlowModel[];
|
|
180
|
+
}
|
|
181
|
+
const idx = arr.findIndex((m) => m?.uid === model.uid);
|
|
182
|
+
const insertIndex = idx >= 0 ? idx : arr.length;
|
|
183
|
+
arr.splice(insertIndex, idx >= 0 ? 1 : 0, newModel);
|
|
184
|
+
arr.forEach((m, i) => (m.sortIndex = i));
|
|
185
|
+
|
|
186
|
+
const gridParams = parent.getStepParams('gridSettings', 'grid') || {};
|
|
187
|
+
if (gridParams?.rows && typeof gridParams.rows === 'object') {
|
|
188
|
+
const newRows = _.cloneDeep(gridParams.rows);
|
|
189
|
+
for (const rowId of Object.keys(newRows)) {
|
|
190
|
+
const columns = newRows[rowId];
|
|
191
|
+
if (Array.isArray(columns)) {
|
|
192
|
+
for (let ci = 0; ci < columns.length; ci++) {
|
|
193
|
+
const col = columns[ci];
|
|
194
|
+
if (Array.isArray(col)) {
|
|
195
|
+
for (let ii = 0; ii < col.length; ii++) {
|
|
196
|
+
if (col[ii] === model.uid) {
|
|
197
|
+
col[ii] = newModel.uid;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
parent.setStepParams('gridSettings', 'grid', { rows: newRows, sizes: gridParams.sizes || {} });
|
|
205
|
+
parent.setProps('rows', newRows);
|
|
206
|
+
}
|
|
207
|
+
newModel.sortIndex = insertIndex;
|
|
208
|
+
await newModel.afterAddAsSubModel();
|
|
209
|
+
} else {
|
|
210
|
+
parent.setSubModel(subKey, newModel);
|
|
211
|
+
await newModel.afterAddAsSubModel();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
await newModel.save();
|
|
215
|
+
await parent.saveStepParams();
|
|
216
|
+
model.context.message?.success?.(t('Replaced with template block'));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const handleConfirm = async () => {
|
|
222
|
+
if (submitting) return;
|
|
223
|
+
try {
|
|
224
|
+
setSubmitting(true);
|
|
225
|
+
await handleSubmit();
|
|
226
|
+
setTimeout(() => {
|
|
227
|
+
if (closed) return;
|
|
228
|
+
setClosed(true);
|
|
229
|
+
release();
|
|
230
|
+
currentDialog.close();
|
|
231
|
+
}, 0);
|
|
232
|
+
} catch (err) {
|
|
233
|
+
if ((err as any)?.errorFields) return;
|
|
234
|
+
console.error(err);
|
|
235
|
+
model.context.message?.error?.(err instanceof Error ? err.message : String(err));
|
|
236
|
+
return;
|
|
237
|
+
} finally {
|
|
238
|
+
// 保持 loading 直到关闭,避免用户重复点击
|
|
239
|
+
if (!closed) {
|
|
240
|
+
setSubmitting(false);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const handleCancel = () => {
|
|
246
|
+
release();
|
|
247
|
+
currentDialog.close();
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<>
|
|
252
|
+
<Form
|
|
253
|
+
form={form}
|
|
254
|
+
layout="vertical"
|
|
255
|
+
initialValues={{
|
|
256
|
+
name: normalizeTitle(model.title),
|
|
257
|
+
description: '',
|
|
258
|
+
saveMode: 'convert',
|
|
259
|
+
}}
|
|
260
|
+
>
|
|
261
|
+
<Form.Item
|
|
262
|
+
name="name"
|
|
263
|
+
label={<Typography.Text strong>{t('Template name')}</Typography.Text>}
|
|
264
|
+
rules={[{ required: true, message: t('Template name is required') }]}
|
|
265
|
+
>
|
|
266
|
+
<Input />
|
|
267
|
+
</Form.Item>
|
|
268
|
+
<Form.Item name="description" label={<Typography.Text strong>{t('Template description')}</Typography.Text>}>
|
|
269
|
+
<Input.TextArea rows={3} />
|
|
270
|
+
</Form.Item>
|
|
271
|
+
<Form.Item name="saveMode" label={<Typography.Text strong>{t('Save mode')}</Typography.Text>}>
|
|
272
|
+
<Radio.Group>
|
|
273
|
+
<Space direction="vertical">
|
|
274
|
+
<Radio value="convert">
|
|
275
|
+
{t('Convert current block to template')}
|
|
276
|
+
<Tooltip title={t('Convert current block to template description')}>
|
|
277
|
+
<QuestionCircleOutlined style={{ marginLeft: 4, color: '#999' }} />
|
|
278
|
+
</Tooltip>
|
|
279
|
+
</Radio>
|
|
280
|
+
<Radio value="duplicate">
|
|
281
|
+
{t('Duplicate current block as template')}
|
|
282
|
+
<Tooltip title={t('Duplicate current block as template description')}>
|
|
283
|
+
<QuestionCircleOutlined style={{ marginLeft: 4, color: '#999' }} />
|
|
284
|
+
</Tooltip>
|
|
285
|
+
</Radio>
|
|
286
|
+
</Space>
|
|
287
|
+
</Radio.Group>
|
|
288
|
+
</Form.Item>
|
|
289
|
+
</Form>
|
|
290
|
+
<currentDialog.Footer>
|
|
291
|
+
<Space align="end">
|
|
292
|
+
<Button onClick={handleCancel}>{t('Cancel')}</Button>
|
|
293
|
+
<Button type="primary" loading={submitting} disabled={submitting} onClick={handleConfirm}>
|
|
294
|
+
{t('Confirm')}
|
|
295
|
+
</Button>
|
|
296
|
+
</Space>
|
|
297
|
+
</currentDialog.Footer>
|
|
298
|
+
</>
|
|
299
|
+
);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const dialog = viewer.dialog({
|
|
303
|
+
title: t('Save as template'),
|
|
304
|
+
width: 600,
|
|
305
|
+
destroyOnClose: true,
|
|
306
|
+
content: (currentDialog: any) => <TemplateDialogContent currentDialog={currentDialog} />,
|
|
307
|
+
onClose: release,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
return dialog;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function handleConvertToCopy(model: FlowModel, _t: (k: string, opt?: any) => string) {
|
|
314
|
+
const t = getPluginT(model);
|
|
315
|
+
const flow = (model.constructor as typeof FlowModel).globalFlowRegistry.getFlow('referenceSettings');
|
|
316
|
+
const stepDef = flow?.steps?.target as any;
|
|
317
|
+
if (!stepDef?.beforeParamsSave) {
|
|
318
|
+
model.context.message?.error?.(t('Convert reference to duplicate is unavailable'));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const viewer = model.context.viewer;
|
|
322
|
+
if (!viewer || typeof viewer.dialog !== 'function') {
|
|
323
|
+
model.context.message?.error?.('[block-reference] viewer is unavailable.');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (openCopyDialogs.has(model)) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
openCopyDialogs.add(model);
|
|
330
|
+
const release = () => openCopyDialogs.delete(model);
|
|
331
|
+
|
|
332
|
+
Modal.confirm({
|
|
333
|
+
title: t('Are you sure to convert this template block to copy mode?'),
|
|
334
|
+
icon: <ExclamationCircleFilled />,
|
|
335
|
+
content: t('Duplicate mode description'),
|
|
336
|
+
okText: t('Confirm'),
|
|
337
|
+
cancelText: t('Cancel'),
|
|
338
|
+
onOk: async () => {
|
|
339
|
+
try {
|
|
340
|
+
const targetStep = model.getStepParams('referenceSettings', 'target') || {};
|
|
341
|
+
const params = { ...targetStep, mode: 'copy' };
|
|
342
|
+
await stepDef.beforeParamsSave({ engine: model.flowEngine, model, exit: () => {} } as any, params);
|
|
343
|
+
} catch (e) {
|
|
344
|
+
console.error(e);
|
|
345
|
+
model.context.message?.error?.(e instanceof Error ? e.message : String(e));
|
|
346
|
+
throw e;
|
|
347
|
+
} finally {
|
|
348
|
+
release();
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
onCancel: release,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function handleConvertFieldsToCopy(model: FlowModel, _t: (k: string, opt?: any) => string) {
|
|
356
|
+
const t = getPluginT(model);
|
|
357
|
+
const resolved = getReferenceFormGridSettings(model);
|
|
358
|
+
if (!resolved) {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const { grid: currentGrid, settings } = resolved;
|
|
362
|
+
const viewer = model.context.viewer;
|
|
363
|
+
if (openFieldsCopyDialogs.has(model)) return;
|
|
364
|
+
openFieldsCopyDialogs.add(model);
|
|
365
|
+
|
|
366
|
+
const release = () => openFieldsCopyDialogs.delete(model);
|
|
367
|
+
|
|
368
|
+
const doConvert = async () => {
|
|
369
|
+
try {
|
|
370
|
+
// 收集当前区块已持久化的 grid 子模型(避免 object subModel 多份导致刷新后随机选中旧 grid)
|
|
371
|
+
const existingGridUids: string[] = [];
|
|
372
|
+
model.flowEngine.forEachModel((m: any) => {
|
|
373
|
+
if (!m || m.uid === model.uid) return;
|
|
374
|
+
if (m.subKey === 'grid' && m.parent?.uid === model.uid) {
|
|
375
|
+
existingGridUids.push(String(m.uid));
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const targetUid = settings.targetUid;
|
|
380
|
+
const targetPath = settings.targetPath?.trim() || 'subModels.grid';
|
|
381
|
+
if (targetPath !== 'subModels.grid') {
|
|
382
|
+
throw new Error(`[block-reference] Only 'subModels.grid' is supported (got '${targetPath}').`);
|
|
383
|
+
}
|
|
384
|
+
const scoped = createBlockScopedEngine(model.flowEngine);
|
|
385
|
+
const root = await scoped.loadModel<FlowModel>({ uid: targetUid });
|
|
386
|
+
const gridModel = _.get(root, targetPath) as FlowModel;
|
|
387
|
+
if (!gridModel) {
|
|
388
|
+
throw new Error(t('Target block is invalid'));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const duplicated = await model.flowEngine.duplicateModel(gridModel.uid);
|
|
392
|
+
|
|
393
|
+
// 将复制出的 grid(默认脱离父级)移动到当前表单 grid 位置,避免再走 save 重建整棵树
|
|
394
|
+
await model.flowEngine.modelRepository.move(duplicated.uid, currentGrid.uid, 'after');
|
|
395
|
+
|
|
396
|
+
const newGrid = model.flowEngine.createModel<FlowModel>({
|
|
397
|
+
...(duplicated as any),
|
|
398
|
+
parentId: model.uid,
|
|
399
|
+
subKey: 'grid',
|
|
400
|
+
subType: 'object',
|
|
401
|
+
});
|
|
402
|
+
model.setSubModel('grid', newGrid);
|
|
403
|
+
await newGrid.afterAddAsSubModel();
|
|
404
|
+
|
|
405
|
+
// 引用已清理,回退临时标题(移除“字段模板”标记)
|
|
406
|
+
const clearTemplateTitle = (m: FlowModel) => {
|
|
407
|
+
const curTitle = m.title || '';
|
|
408
|
+
const labelCandidates = [t('Field template'), 'Field template', '字段模板', '字段模板']
|
|
409
|
+
.concat(tStr('Field template'))
|
|
410
|
+
.map((s) => (s ? String(s) : ''))
|
|
411
|
+
.filter(Boolean);
|
|
412
|
+
const union = labelCandidates.map((s) => _.escapeRegExp(s)).join('|');
|
|
413
|
+
const reg = new RegExp(`\\s*\\((${union})[::]?[^)]*\\)\\s*$`);
|
|
414
|
+
if (!reg.test(curTitle)) return;
|
|
415
|
+
const nextTitle = curTitle.replace(reg, '').trim();
|
|
416
|
+
m.setTitle(nextTitle || '');
|
|
417
|
+
};
|
|
418
|
+
const masterModel: any = (model as any)?.isFork ? (model as any).master || model : model;
|
|
419
|
+
clearTemplateTitle(model);
|
|
420
|
+
clearTemplateTitle(masterModel);
|
|
421
|
+
masterModel?.forks?.forEach?.((f: any) => f instanceof FlowModel && clearTemplateTitle(f));
|
|
422
|
+
|
|
423
|
+
// 删除旧的 grid(若存在)
|
|
424
|
+
for (const oldUid of existingGridUids) {
|
|
425
|
+
if (oldUid && oldUid !== newGrid.uid) {
|
|
426
|
+
await model.flowEngine.destroyModel(oldUid);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
model.context.message?.success?.(t('Saved'));
|
|
431
|
+
} catch (e) {
|
|
432
|
+
console.error(e);
|
|
433
|
+
model.context.message?.error?.(
|
|
434
|
+
e instanceof Error ? e.message : t('Convert reference to duplicate is unavailable'),
|
|
435
|
+
);
|
|
436
|
+
} finally {
|
|
437
|
+
release();
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
if (!viewer || typeof viewer.dialog !== 'function') {
|
|
442
|
+
const ok = window.confirm(t('Are you sure to convert referenced fields to copy mode?'));
|
|
443
|
+
if (ok) await doConvert();
|
|
444
|
+
release();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
Modal.confirm({
|
|
449
|
+
title: t('Are you sure to convert referenced fields to copy mode?'),
|
|
450
|
+
icon: <ExclamationCircleFilled />,
|
|
451
|
+
content: t('Duplicate mode description'),
|
|
452
|
+
okText: t('Confirm'),
|
|
453
|
+
cancelText: t('Cancel'),
|
|
454
|
+
onOk: async () => {
|
|
455
|
+
try {
|
|
456
|
+
await doConvert();
|
|
457
|
+
} catch (e) {
|
|
458
|
+
console.error(e);
|
|
459
|
+
model.context.message?.error?.(e instanceof Error ? e.message : String(e));
|
|
460
|
+
throw e;
|
|
461
|
+
} finally {
|
|
462
|
+
release();
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
onCancel: release,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function handleSavePopupAsTemplate(model: FlowModel, _t: (k: string, opt?: any) => string) {
|
|
470
|
+
const api = model.context.api;
|
|
471
|
+
const viewer = model.context.viewer;
|
|
472
|
+
const pluginT = getPluginT(model);
|
|
473
|
+
const tNs = (key: string, opt?: Record<string, any>) => {
|
|
474
|
+
const tt = (model.context as any)?.t;
|
|
475
|
+
if (typeof tt !== 'function') return pluginT(key, opt);
|
|
476
|
+
return tt(key, { ns: [NAMESPACE, 'client'], nsMode: 'fallback', ...(opt || {}) });
|
|
477
|
+
};
|
|
478
|
+
const tClient = (key: string, opt?: Record<string, any>) => {
|
|
479
|
+
const tt = (model.context as any)?.t;
|
|
480
|
+
if (typeof tt !== 'function') return pluginT(key, opt);
|
|
481
|
+
return tt(key, { ns: ['client'], nsMode: 'fallback', ...(opt || {}) });
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const dialogKey = model?.uid || '';
|
|
485
|
+
if (dialogKey && openSavePopupTemplateDialogs.has(dialogKey)) {
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (dialogKey) {
|
|
489
|
+
openSavePopupTemplateDialogs.add(dialogKey);
|
|
490
|
+
}
|
|
491
|
+
const release = () => {
|
|
492
|
+
if (dialogKey) {
|
|
493
|
+
openSavePopupTemplateDialogs.delete(dialogKey);
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
if (!api?.resource) {
|
|
498
|
+
model.context.message?.error?.('[block-reference] api is unavailable.');
|
|
499
|
+
release();
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const popupFlow = model.getFlow?.('popupSettings');
|
|
503
|
+
if (!popupFlow) {
|
|
504
|
+
model.context.message?.error?.(tNs('Only Popup can be saved as popup template'));
|
|
505
|
+
release();
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Try to get field title for field popup templates
|
|
510
|
+
const collectionField = (model.context as any)?.collectionField;
|
|
511
|
+
const fieldTitle = collectionField?.title ? normalizeTitle(collectionField.title) : '';
|
|
512
|
+
const defaultName =
|
|
513
|
+
fieldTitle ||
|
|
514
|
+
normalizeTitle((model as any)?.getTitle?.()) ||
|
|
515
|
+
normalizeTitle((model as any)?.title) ||
|
|
516
|
+
normalizeTitle(model.uid);
|
|
517
|
+
const resolveOpenViewStepKey = (flow: any): string | undefined => {
|
|
518
|
+
const steps = flow?.steps || {};
|
|
519
|
+
if (steps?.openView) return 'openView';
|
|
520
|
+
const found = Object.entries(steps).find(([, def]) => (def as any)?.use === 'openView');
|
|
521
|
+
return found?.[0];
|
|
522
|
+
};
|
|
523
|
+
const openViewStepKey =
|
|
524
|
+
(model.getStepParams('popupSettings', 'openView') !== undefined && 'openView') ||
|
|
525
|
+
resolveOpenViewStepKey(popupFlow) ||
|
|
526
|
+
'openView';
|
|
527
|
+
const openViewParams = model.getStepParams('popupSettings', openViewStepKey) || {};
|
|
528
|
+
|
|
529
|
+
const toNonEmptyString = (val: any): string | undefined => {
|
|
530
|
+
if (val === undefined || val === null) return undefined;
|
|
531
|
+
const s = String(val).trim();
|
|
532
|
+
return s ? s : undefined;
|
|
533
|
+
};
|
|
534
|
+
const getDefaultFilterByTkExpr = (): string | undefined => {
|
|
535
|
+
// 与 openView 默认行为对齐:尽量落表达式而非具体值
|
|
536
|
+
const recordKeyPath = model.context?.collection?.filterTargetKey || 'id';
|
|
537
|
+
return `{{ ctx.record.${recordKeyPath} }}`;
|
|
538
|
+
};
|
|
539
|
+
const getDefaultSourceIdExpr = (): string | undefined => {
|
|
540
|
+
// 如果有 associationName,说明是关系资源弹窗,默认需要 sourceId
|
|
541
|
+
if (toNonEmptyString(openViewParams?.associationName)) {
|
|
542
|
+
return `{{ ctx.resource.sourceId }}`;
|
|
543
|
+
}
|
|
544
|
+
try {
|
|
545
|
+
const sid = model.context?.resource?.getSourceId?.();
|
|
546
|
+
if (sid !== undefined && sid !== null && String(sid) !== '') {
|
|
547
|
+
return `{{ ctx.resource.sourceId }}`;
|
|
548
|
+
}
|
|
549
|
+
} catch (e) {
|
|
550
|
+
// ignore
|
|
551
|
+
}
|
|
552
|
+
return undefined;
|
|
553
|
+
};
|
|
554
|
+
const templateFilterByTk = toNonEmptyString(openViewParams?.filterByTk) || getDefaultFilterByTkExpr();
|
|
555
|
+
const templateSourceId = toNonEmptyString(openViewParams?.sourceId) || getDefaultSourceIdExpr();
|
|
556
|
+
|
|
557
|
+
const getUidFromAny = (obj: any): string | undefined => {
|
|
558
|
+
const candidates = [
|
|
559
|
+
obj?.uid,
|
|
560
|
+
obj?.data?.uid,
|
|
561
|
+
obj?.data?.data?.uid,
|
|
562
|
+
obj?.data?.data?.data?.uid,
|
|
563
|
+
obj?.data?.data?.data?.data?.uid,
|
|
564
|
+
];
|
|
565
|
+
for (const c of candidates) {
|
|
566
|
+
const v = toNonEmptyString(c);
|
|
567
|
+
if (v) return v;
|
|
568
|
+
}
|
|
569
|
+
return undefined;
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
const duplicatePopupTarget = async (): Promise<string> => {
|
|
573
|
+
const duplicated = await model.flowEngine?.duplicateModel?.(model.uid);
|
|
574
|
+
const newUid = getUidFromAny(duplicated);
|
|
575
|
+
if (!newUid) {
|
|
576
|
+
throw new Error(tNs('Failed to duplicate pop-up'));
|
|
577
|
+
}
|
|
578
|
+
return newUid;
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
const doCreate = async (values: { name: string; description?: string; targetUid: string }) => {
|
|
582
|
+
const payload = {
|
|
583
|
+
name: values.name,
|
|
584
|
+
description: values.description,
|
|
585
|
+
targetUid: values.targetUid,
|
|
586
|
+
useModel: model.use,
|
|
587
|
+
type: 'popup',
|
|
588
|
+
dataSourceKey: openViewParams?.dataSourceKey,
|
|
589
|
+
collectionName: openViewParams?.collectionName,
|
|
590
|
+
associationName: openViewParams?.associationName,
|
|
591
|
+
filterByTk: templateFilterByTk,
|
|
592
|
+
sourceId: templateSourceId,
|
|
593
|
+
};
|
|
594
|
+
const res = await api.resource('flowModelTemplates').create({ values: payload });
|
|
595
|
+
return unwrapData(res);
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
if (!viewer || typeof viewer.dialog !== 'function') {
|
|
599
|
+
try {
|
|
600
|
+
const name = window.prompt(tNs('Template name'), defaultName || '') || '';
|
|
601
|
+
const trimmed = normalizeTitle(name);
|
|
602
|
+
if (!trimmed) return;
|
|
603
|
+
const targetUid = await duplicatePopupTarget();
|
|
604
|
+
await doCreate({ name: trimmed, targetUid });
|
|
605
|
+
model.context.message?.success?.(tNs('Saved'));
|
|
606
|
+
} catch (err) {
|
|
607
|
+
console.error(err);
|
|
608
|
+
model.context.message?.error?.(err instanceof Error ? err.message : String(err));
|
|
609
|
+
} finally {
|
|
610
|
+
release();
|
|
611
|
+
}
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
viewer.dialog({
|
|
616
|
+
title: tNs('Save as template'),
|
|
617
|
+
width: 520,
|
|
618
|
+
destroyOnClose: true,
|
|
619
|
+
content: (currentDialog: any) => {
|
|
620
|
+
const TemplateDialogContent: React.FC = () => {
|
|
621
|
+
const [form] = Form.useForm();
|
|
622
|
+
const [submitting, setSubmitting] = useState(false);
|
|
623
|
+
|
|
624
|
+
const handleSubmit = async () => {
|
|
625
|
+
const values = await form.validateFields();
|
|
626
|
+
const name = normalizeTitle(values?.name);
|
|
627
|
+
if (!name) {
|
|
628
|
+
model.context.message?.error?.(tNs('Template name is required'));
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
setSubmitting(true);
|
|
632
|
+
try {
|
|
633
|
+
// 无论是否选择 replace,都需要先复制弹窗作为模板的 target
|
|
634
|
+
// 这样可以保证模板和原弹窗是独立的
|
|
635
|
+
const targetUid = await duplicatePopupTarget();
|
|
636
|
+
const tpl = await doCreate({
|
|
637
|
+
name,
|
|
638
|
+
description: normalizeTitle(values?.description) || undefined,
|
|
639
|
+
targetUid,
|
|
640
|
+
});
|
|
641
|
+
const tplUid = getUidFromAny(tpl);
|
|
642
|
+
|
|
643
|
+
if (values?.saveMode === 'convert' && tplUid) {
|
|
644
|
+
// 选择替换时,当前弹窗改为引用新创建的模板
|
|
645
|
+
const nextOpenView: any = { ...(openViewParams || {}) };
|
|
646
|
+
nextOpenView.uid = targetUid;
|
|
647
|
+
nextOpenView.popupTemplateUid = tplUid;
|
|
648
|
+
delete nextOpenView.popupTemplateContext;
|
|
649
|
+
// 推断模板"是否需要 record/source 上下文",避免 collection 模板被误判为 record 模板(尤其是默认值里带 `{{ ctx.record.* }}` 的情况)。
|
|
650
|
+
// 注:这里使用 model.constructor.name 推断 scene(当前正在配置的 action 类型),
|
|
651
|
+
// 而 inferFromTemplateRow 使用 tplRow.useModel(模板保存时的 action 类型)。两者语义相同:都是获取触发弹窗的 action 场景。
|
|
652
|
+
const ctor: any = (model as any)?.constructor;
|
|
653
|
+
const scene = resolveActionScene((use: string) => model.flowEngine?.getModelClass?.(use), ctor?.name);
|
|
654
|
+
const inferred = inferPopupTemplateContextFlags(
|
|
655
|
+
scene,
|
|
656
|
+
openViewParams?.filterByTk,
|
|
657
|
+
openViewParams?.sourceId,
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
if (inferred.hasFilterByTk) {
|
|
661
|
+
if (!toNonEmptyString(nextOpenView.filterByTk) && templateFilterByTk) {
|
|
662
|
+
nextOpenView.filterByTk = templateFilterByTk;
|
|
663
|
+
}
|
|
664
|
+
} else if ('filterByTk' in nextOpenView) {
|
|
665
|
+
delete nextOpenView.filterByTk;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// sourceId 是否需要完全由 inferred.hasSourceId 决定
|
|
669
|
+
if (inferred.hasSourceId) {
|
|
670
|
+
if (!toNonEmptyString(nextOpenView.sourceId) && templateSourceId) {
|
|
671
|
+
nextOpenView.sourceId = templateSourceId;
|
|
672
|
+
}
|
|
673
|
+
} else if ('sourceId' in nextOpenView) {
|
|
674
|
+
delete nextOpenView.sourceId;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// 保存模板侧的 filterByTk/sourceId 可用性:运行时可能解析为空/undefined,需用布尔标记避免误判为"模板未提供"
|
|
678
|
+
nextOpenView.popupTemplateHasFilterByTk = inferred.hasFilterByTk;
|
|
679
|
+
nextOpenView.popupTemplateHasSourceId = inferred.hasSourceId;
|
|
680
|
+
model.setStepParams('popupSettings', { [openViewStepKey]: nextOpenView });
|
|
681
|
+
await model.saveStepParams();
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
model.context.message?.success?.(tNs('Saved'));
|
|
685
|
+
currentDialog.close();
|
|
686
|
+
} catch (err) {
|
|
687
|
+
console.error(err);
|
|
688
|
+
model.context.message?.error?.(err instanceof Error ? err.message : String(err));
|
|
689
|
+
} finally {
|
|
690
|
+
setSubmitting(false);
|
|
691
|
+
}
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
return (
|
|
695
|
+
<>
|
|
696
|
+
<Form
|
|
697
|
+
form={form}
|
|
698
|
+
layout="vertical"
|
|
699
|
+
initialValues={{
|
|
700
|
+
name: defaultName,
|
|
701
|
+
description: '',
|
|
702
|
+
saveMode: 'convert',
|
|
703
|
+
}}
|
|
704
|
+
>
|
|
705
|
+
<Form.Item
|
|
706
|
+
name="name"
|
|
707
|
+
label={tNs('Template name')}
|
|
708
|
+
rules={[{ required: true, message: tNs('Template name is required') }]}
|
|
709
|
+
>
|
|
710
|
+
<Input autoFocus />
|
|
711
|
+
</Form.Item>
|
|
712
|
+
<Form.Item name="description" label={tNs('Template description')}>
|
|
713
|
+
<Input.TextArea rows={3} />
|
|
714
|
+
</Form.Item>
|
|
715
|
+
<Form.Item name="saveMode" label={<Typography.Text strong>{tNs('Save mode')}</Typography.Text>}>
|
|
716
|
+
<Radio.Group>
|
|
717
|
+
<Space direction="vertical">
|
|
718
|
+
<Radio value="convert">
|
|
719
|
+
{tNs('Convert current popup to template')}
|
|
720
|
+
<Tooltip title={tNs('Convert current popup to template description')}>
|
|
721
|
+
<QuestionCircleOutlined style={{ marginLeft: 4, color: '#999' }} />
|
|
722
|
+
</Tooltip>
|
|
723
|
+
</Radio>
|
|
724
|
+
<Radio value="duplicate">
|
|
725
|
+
{tNs('Duplicate current popup as template')}
|
|
726
|
+
<Tooltip title={tNs('Duplicate current popup as template description')}>
|
|
727
|
+
<QuestionCircleOutlined style={{ marginLeft: 4, color: '#999' }} />
|
|
728
|
+
</Tooltip>
|
|
729
|
+
</Radio>
|
|
730
|
+
</Space>
|
|
731
|
+
</Radio.Group>
|
|
732
|
+
</Form.Item>
|
|
733
|
+
</Form>
|
|
734
|
+
<currentDialog.Footer>
|
|
735
|
+
<Space align="end">
|
|
736
|
+
<Button
|
|
737
|
+
onClick={() => {
|
|
738
|
+
currentDialog.close();
|
|
739
|
+
}}
|
|
740
|
+
>
|
|
741
|
+
{tClient('Cancel')}
|
|
742
|
+
</Button>
|
|
743
|
+
<Button type="primary" loading={submitting} onClick={handleSubmit}>
|
|
744
|
+
{tClient('Confirm')}
|
|
745
|
+
</Button>
|
|
746
|
+
</Space>
|
|
747
|
+
</currentDialog.Footer>
|
|
748
|
+
</>
|
|
749
|
+
);
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
return <TemplateDialogContent />;
|
|
753
|
+
},
|
|
754
|
+
onClose: release,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async function handleConvertPopupTemplateToCopy(model: FlowModel, _t: (k: string, opt?: any) => string) {
|
|
759
|
+
const api = model.context.api;
|
|
760
|
+
const viewer = model.context.viewer;
|
|
761
|
+
const pluginT = getPluginT(model);
|
|
762
|
+
const tNs = (key: string, opt?: Record<string, any>) => {
|
|
763
|
+
const tt = (model.context as any)?.t;
|
|
764
|
+
if (typeof tt !== 'function') return pluginT(key, opt);
|
|
765
|
+
return tt(key, { ns: [NAMESPACE, 'client'], nsMode: 'fallback', ...(opt || {}) });
|
|
766
|
+
};
|
|
767
|
+
const tClient = (key: string, opt?: Record<string, any>) => {
|
|
768
|
+
const tt = (model.context as any)?.t;
|
|
769
|
+
if (typeof tt !== 'function') return pluginT(key, opt);
|
|
770
|
+
return tt(key, { ns: ['client'], nsMode: 'fallback', ...(opt || {}) });
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
const dialogKey = model?.uid || '';
|
|
774
|
+
if (dialogKey && openConvertPopupTemplateDialogs.has(dialogKey)) {
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
if (dialogKey) {
|
|
778
|
+
openConvertPopupTemplateDialogs.add(dialogKey);
|
|
779
|
+
}
|
|
780
|
+
const release = () => {
|
|
781
|
+
if (dialogKey) {
|
|
782
|
+
openConvertPopupTemplateDialogs.delete(dialogKey);
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
const popupFlow = model.getFlow?.('popupSettings');
|
|
787
|
+
if (!popupFlow) {
|
|
788
|
+
model.context.message?.error?.(tNs('Only Popup can be saved as popup template'));
|
|
789
|
+
release();
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
const resolveOpenViewStepKey = (flow: any): string | undefined => {
|
|
793
|
+
const steps = flow?.steps || {};
|
|
794
|
+
if (steps?.openView) return 'openView';
|
|
795
|
+
const found = Object.entries(steps).find(([, def]) => (def as any)?.use === 'openView');
|
|
796
|
+
return found?.[0];
|
|
797
|
+
};
|
|
798
|
+
const openViewStepKey =
|
|
799
|
+
(model.getStepParams('popupSettings', 'openView') !== undefined && 'openView') ||
|
|
800
|
+
resolveOpenViewStepKey(popupFlow) ||
|
|
801
|
+
'openView';
|
|
802
|
+
const openViewParams = model.getStepParams('popupSettings', openViewStepKey) || {};
|
|
803
|
+
const templateUid =
|
|
804
|
+
typeof openViewParams?.popupTemplateUid === 'string' ? openViewParams.popupTemplateUid.trim() : '';
|
|
805
|
+
if (!templateUid) {
|
|
806
|
+
model.context.message?.error?.(tNs('This pop-up is not using a popup template'));
|
|
807
|
+
release();
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const unwrap = (val: any) => val?.data?.data ?? val?.data ?? val;
|
|
812
|
+
const fetchTemplateRow = async (): Promise<Record<string, any> | null> => {
|
|
813
|
+
if (!api?.resource) {
|
|
814
|
+
return null;
|
|
815
|
+
}
|
|
816
|
+
const res = await api.resource('flowModelTemplates').get({ filterByTk: templateUid });
|
|
817
|
+
const row = unwrap(res);
|
|
818
|
+
return row && typeof row === 'object' ? (row as Record<string, any>) : null;
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
const inferFromTemplateRow = (tplRow: Record<string, any>): PopupTemplateContextFlags => {
|
|
822
|
+
const scene = resolveActionScene((use: string) => model.flowEngine?.getModelClass?.(use), tplRow?.useModel);
|
|
823
|
+
return inferPopupTemplateContextFlags(scene, tplRow?.filterByTk, tplRow?.sourceId);
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
const doConvert = async () => {
|
|
827
|
+
const tplRow = await fetchTemplateRow();
|
|
828
|
+
const targetUid = normalizeStr(tplRow?.targetUid) || normalizeStr(openViewParams?.uid);
|
|
829
|
+
if (!targetUid) {
|
|
830
|
+
throw new Error(tNs('Popup template not found'));
|
|
831
|
+
}
|
|
832
|
+
const duplicated = await model.flowEngine.duplicateModel(targetUid);
|
|
833
|
+
const newUid = duplicated?.uid || duplicated?.data?.uid || duplicated?.data?.data?.uid;
|
|
834
|
+
if (!newUid) {
|
|
835
|
+
throw new Error(tNs('Failed to copy popup from template'));
|
|
836
|
+
}
|
|
837
|
+
const inferred: PopupTemplateContextFlags = tplRow
|
|
838
|
+
? inferFromTemplateRow(tplRow)
|
|
839
|
+
: extractPopupTemplateContextFlagsFromParams(openViewParams);
|
|
840
|
+
|
|
841
|
+
const nextOpenView: any = { ...(openViewParams || {}), uid: newUid };
|
|
842
|
+
delete (nextOpenView as any).popupTemplateUid;
|
|
843
|
+
// 保持与"模板引用"一致的运行时上下文覆写逻辑(特别是关联字段复用非关系弹窗时 filterByTk<-sourceId)
|
|
844
|
+
(nextOpenView as any).popupTemplateContext = true;
|
|
845
|
+
// sourceId 是否需要完全由 inferred.hasSourceId 决定
|
|
846
|
+
// 关键:copy 模式下不再有 popupTemplateUid 可用于运行时推断,因此这里要把"模板是否需要 record/source 上下文"固化下来。
|
|
847
|
+
nextOpenView.popupTemplateHasFilterByTk = inferred.hasFilterByTk;
|
|
848
|
+
nextOpenView.popupTemplateHasSourceId = inferred.hasSourceId;
|
|
849
|
+
// 同步清理 params 侧的 filterByTk/sourceId,避免 record action 复用 collection 弹窗时泄漏 filterByTk
|
|
850
|
+
if (!inferred.hasFilterByTk && 'filterByTk' in nextOpenView) {
|
|
851
|
+
delete nextOpenView.filterByTk;
|
|
852
|
+
}
|
|
853
|
+
if (!inferred.hasSourceId && 'sourceId' in nextOpenView) {
|
|
854
|
+
delete nextOpenView.sourceId;
|
|
855
|
+
}
|
|
856
|
+
model.setStepParams('popupSettings', { [openViewStepKey]: nextOpenView });
|
|
857
|
+
await model.saveStepParams();
|
|
858
|
+
model.context.message?.success?.(tNs('Converted'));
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
if (!viewer || typeof viewer.dialog !== 'function') {
|
|
862
|
+
const ok = window.confirm(tNs('Are you sure to convert this pop-up to copy mode?'));
|
|
863
|
+
if (ok) {
|
|
864
|
+
try {
|
|
865
|
+
await doConvert();
|
|
866
|
+
} catch (e) {
|
|
867
|
+
console.error(e);
|
|
868
|
+
model.context.message?.error?.(e instanceof Error ? e.message : String(e));
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
release();
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
Modal.confirm({
|
|
876
|
+
title: tNs('Are you sure to convert this pop-up to copy mode?'),
|
|
877
|
+
icon: <ExclamationCircleFilled />,
|
|
878
|
+
content: tNs('Duplicate mode description'),
|
|
879
|
+
okText: tClient('Confirm'),
|
|
880
|
+
cancelText: tClient('Cancel'),
|
|
881
|
+
onOk: async () => {
|
|
882
|
+
try {
|
|
883
|
+
await doConvert();
|
|
884
|
+
} catch (e) {
|
|
885
|
+
console.error(e);
|
|
886
|
+
model.context.message?.error?.(e instanceof Error ? e.message : String(e));
|
|
887
|
+
throw e;
|
|
888
|
+
} finally {
|
|
889
|
+
release();
|
|
890
|
+
}
|
|
891
|
+
},
|
|
892
|
+
onCancel: release,
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Check if the model is used as a reference target (either is a ReferenceBlockModel
|
|
898
|
+
* or its parent is a ReferenceBlockModel)
|
|
899
|
+
*/
|
|
900
|
+
function isReferenceTarget(model: FlowModel): boolean {
|
|
901
|
+
if (model instanceof ReferenceBlockModel) {
|
|
902
|
+
return true;
|
|
903
|
+
}
|
|
904
|
+
// Check if model's parent is a ReferenceBlockModel (model is the target sub-model)
|
|
905
|
+
const parent = model.parent;
|
|
906
|
+
if (parent instanceof ReferenceBlockModel) {
|
|
907
|
+
return true;
|
|
908
|
+
}
|
|
909
|
+
return false;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
export function registerMenuExtensions() {
|
|
913
|
+
BlockModel.registerExtraMenuItems({
|
|
914
|
+
group: 'common-actions',
|
|
915
|
+
sort: -10,
|
|
916
|
+
matcher: (model) => !isReferenceTarget(model),
|
|
917
|
+
items: async (model: FlowModel, t) => {
|
|
918
|
+
const pluginT = getPluginT(model);
|
|
919
|
+
const items: MenuItem[] = [];
|
|
920
|
+
const hasReferenceFields = !!getReferenceFormGridSettings(model);
|
|
921
|
+
if (hasReferenceFields) {
|
|
922
|
+
items.push({
|
|
923
|
+
key: 'block-reference:convert-fields-to-copy',
|
|
924
|
+
label: pluginT('Convert reference fields to duplicate'),
|
|
925
|
+
onClick: () => handleConvertFieldsToCopy(model, t),
|
|
926
|
+
sort: -6,
|
|
927
|
+
group: 'common-actions',
|
|
928
|
+
});
|
|
929
|
+
} else {
|
|
930
|
+
items.push({
|
|
931
|
+
key: 'block-reference:convert-to-template',
|
|
932
|
+
label: pluginT('Save as template'),
|
|
933
|
+
onClick: () => handleConvertToTemplate(model, t),
|
|
934
|
+
sort: -10,
|
|
935
|
+
group: 'common-actions',
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
return items;
|
|
939
|
+
},
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
ReferenceBlockModel.registerExtraMenuItems({
|
|
943
|
+
group: 'common-actions',
|
|
944
|
+
sort: -5,
|
|
945
|
+
matcher: () => true,
|
|
946
|
+
items: async (model: FlowModel, t) => {
|
|
947
|
+
const pluginT = getPluginT(model);
|
|
948
|
+
const items: MenuItem[] = [];
|
|
949
|
+
const tpl = model.getStepParams('referenceSettings', 'useTemplate') || {};
|
|
950
|
+
const hasTpl = !!tpl?.templateUid;
|
|
951
|
+
if (hasTpl) {
|
|
952
|
+
items.push({
|
|
953
|
+
key: 'block-reference:convert-to-copy',
|
|
954
|
+
label: pluginT('Convert reference to duplicate'),
|
|
955
|
+
onClick: () => handleConvertToCopy(model, t),
|
|
956
|
+
sort: -5,
|
|
957
|
+
group: 'common-actions',
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
return items;
|
|
961
|
+
},
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
// Register popup template menu items on FlowModel base class with matcher
|
|
965
|
+
// This ensures any model with popupSettings flow (not just PopupActionModel) gets these menu items
|
|
966
|
+
FlowModel.registerExtraMenuItems({
|
|
967
|
+
group: 'common-actions',
|
|
968
|
+
sort: -8,
|
|
969
|
+
matcher: (model) => {
|
|
970
|
+
// Check if model has popupSettings flow
|
|
971
|
+
const popupFlow = model.getFlow?.('popupSettings');
|
|
972
|
+
if (!popupFlow) return false;
|
|
973
|
+
|
|
974
|
+
// 对于字段,检查是否启用了 click-to-open
|
|
975
|
+
// 如果是字段但没有启用 click-to-open,不显示弹窗相关菜单
|
|
976
|
+
const displayFieldSettingsFlow = model.getFlow?.('displayFieldSettings');
|
|
977
|
+
if (displayFieldSettingsFlow) {
|
|
978
|
+
const clickToOpen = model.getStepParams?.('displayFieldSettings', 'clickToOpen')?.clickToOpen;
|
|
979
|
+
// 如果显式设置了 clickToOpen 为 false,不显示菜单
|
|
980
|
+
if (clickToOpen === false) {
|
|
981
|
+
return false;
|
|
982
|
+
}
|
|
983
|
+
// 如果未设置 clickToOpen,对于非关联字段默认不显示
|
|
984
|
+
if (clickToOpen === undefined) {
|
|
985
|
+
const collectionField = (model.context as any)?.collectionField;
|
|
986
|
+
if (collectionField && !collectionField?.isAssociationField?.()) {
|
|
987
|
+
return false;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
return true;
|
|
993
|
+
},
|
|
994
|
+
items: async (model: FlowModel, t) => {
|
|
995
|
+
const popupFlow = model.getFlow?.('popupSettings');
|
|
996
|
+
const resolveOpenViewStepKey = (flow: any): string | undefined => {
|
|
997
|
+
const steps = flow?.steps || {};
|
|
998
|
+
if (steps?.openView) return 'openView';
|
|
999
|
+
const found = Object.entries(steps).find(([, def]) => (def as any)?.use === 'openView');
|
|
1000
|
+
return found?.[0];
|
|
1001
|
+
};
|
|
1002
|
+
const openViewStepKey =
|
|
1003
|
+
(model.getStepParams('popupSettings', 'openView') !== undefined && 'openView') ||
|
|
1004
|
+
resolveOpenViewStepKey(popupFlow) ||
|
|
1005
|
+
'openView';
|
|
1006
|
+
const openViewParams = model.getStepParams('popupSettings', openViewStepKey) || {};
|
|
1007
|
+
const templateUid =
|
|
1008
|
+
typeof (openViewParams as any)?.popupTemplateUid === 'string'
|
|
1009
|
+
? (openViewParams as any).popupTemplateUid.trim()
|
|
1010
|
+
: '';
|
|
1011
|
+
const hasTemplate = !!templateUid;
|
|
1012
|
+
const pluginT = getPluginT(model);
|
|
1013
|
+
if (hasTemplate) {
|
|
1014
|
+
return [
|
|
1015
|
+
{
|
|
1016
|
+
key: 'block-reference:convert-popup-template-to-copy',
|
|
1017
|
+
label: pluginT('Convert reference to duplicate'),
|
|
1018
|
+
onClick: () => handleConvertPopupTemplateToCopy(model, t),
|
|
1019
|
+
sort: -8,
|
|
1020
|
+
},
|
|
1021
|
+
];
|
|
1022
|
+
}
|
|
1023
|
+
return [
|
|
1024
|
+
{
|
|
1025
|
+
key: 'block-reference:save-popup-as-template',
|
|
1026
|
+
label: pluginT('Save as template'),
|
|
1027
|
+
onClick: () => handleSavePopupAsTemplate(model, t),
|
|
1028
|
+
sort: -8,
|
|
1029
|
+
},
|
|
1030
|
+
];
|
|
1031
|
+
},
|
|
1032
|
+
});
|
|
1033
|
+
}
|