@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,981 @@
|
|
|
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, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
|
11
|
+
import type { ActionDefinition, FlowEngine, FlowSettingsContext } from '@nocobase/flow-engine';
|
|
12
|
+
import { createEphemeralContext, useFlowSettingsContext } from '@nocobase/flow-engine';
|
|
13
|
+
import { useForm } from '@formily/react';
|
|
14
|
+
import { Select } from 'antd';
|
|
15
|
+
import type { BaseSelectRef } from 'rc-select';
|
|
16
|
+
import debounce from 'lodash/debounce';
|
|
17
|
+
import { NAMESPACE } from './locale';
|
|
18
|
+
import { renderTemplateSelectLabel, renderTemplateSelectOption } from './components/TemplateSelectOption';
|
|
19
|
+
import {
|
|
20
|
+
TEMPLATE_LIST_PAGE_SIZE,
|
|
21
|
+
calcHasMore,
|
|
22
|
+
getTemplateAvailabilityDisabledReason,
|
|
23
|
+
inferPopupTemplateContextFlags,
|
|
24
|
+
normalizeStr,
|
|
25
|
+
parseResourceListResponse,
|
|
26
|
+
resolveActionScene,
|
|
27
|
+
resolveBaseResourceByAssociation,
|
|
28
|
+
resolveExpectedResourceInfoByModelChain,
|
|
29
|
+
resolveTargetResourceByAssociation,
|
|
30
|
+
tWithNs,
|
|
31
|
+
type PopupTemplateContextFlags,
|
|
32
|
+
} from './utils/templateCompatibility';
|
|
33
|
+
import { mergeSelectOptions } from './utils/infiniteSelect';
|
|
34
|
+
import _ from 'lodash';
|
|
35
|
+
|
|
36
|
+
type TemplateRow = {
|
|
37
|
+
uid: string;
|
|
38
|
+
name?: string;
|
|
39
|
+
description?: string;
|
|
40
|
+
targetUid?: string;
|
|
41
|
+
useModel?: string;
|
|
42
|
+
type?: string;
|
|
43
|
+
dataSourceKey?: string;
|
|
44
|
+
collectionName?: string;
|
|
45
|
+
associationName?: string;
|
|
46
|
+
filterByTk?: string;
|
|
47
|
+
sourceId?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type ExpectedResourceInfo = { dataSourceKey?: string; collectionName?: string; associationName?: string };
|
|
51
|
+
|
|
52
|
+
const isAssociationField = (field: any): boolean => !!field?.isAssociationField?.();
|
|
53
|
+
|
|
54
|
+
const inferPopupTemplateMeta = (ctx: any, tpl: TemplateRow): PopupTemplateContextFlags => {
|
|
55
|
+
const engine = (ctx as any)?.engine;
|
|
56
|
+
const getModelClass = engine?.getModelClass?.bind(engine);
|
|
57
|
+
const scene = resolveActionScene(getModelClass, tpl?.useModel);
|
|
58
|
+
return inferPopupTemplateContextFlags(scene, tpl?.filterByTk, tpl?.sourceId);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const resolveAssociationFieldFromCtx = (ctx: any): any | undefined => {
|
|
62
|
+
const field = (ctx as any)?.collectionField;
|
|
63
|
+
const associationPathName = (ctx as any)?.model?.parent?.['associationPathName'];
|
|
64
|
+
const blockModel = (ctx as any)?.blockModel;
|
|
65
|
+
const fieldCollection = (ctx as any)?.collection || blockModel?.collection;
|
|
66
|
+
const associationField =
|
|
67
|
+
!isAssociationField(field) && associationPathName && typeof fieldCollection?.getFieldByPath === 'function'
|
|
68
|
+
? fieldCollection.getFieldByPath(associationPathName)
|
|
69
|
+
: undefined;
|
|
70
|
+
const assocField = isAssociationField(field) ? field : associationField;
|
|
71
|
+
return isAssociationField(assocField) ? assocField : undefined;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const buildAssociationName = (collectionName: string, fieldName: string): string =>
|
|
75
|
+
collectionName && fieldName ? `${collectionName}.${fieldName}` : '';
|
|
76
|
+
|
|
77
|
+
type AssociationInfoType = 'target' | 'source';
|
|
78
|
+
|
|
79
|
+
const resolveExpectedAssociationInfoFromCtx = (
|
|
80
|
+
ctx: any,
|
|
81
|
+
type: AssociationInfoType,
|
|
82
|
+
): ExpectedResourceInfo | undefined => {
|
|
83
|
+
const assocField = resolveAssociationFieldFromCtx(ctx);
|
|
84
|
+
if (!assocField) return undefined;
|
|
85
|
+
|
|
86
|
+
const collection =
|
|
87
|
+
type === 'target'
|
|
88
|
+
? assocField?.targetCollection
|
|
89
|
+
: assocField?.collection || (ctx as any)?.blockModel?.collection || (ctx as any)?.collection;
|
|
90
|
+
|
|
91
|
+
const dataSourceKey = normalizeStr(collection?.dataSourceKey);
|
|
92
|
+
const collectionName = normalizeStr(collection?.name);
|
|
93
|
+
if (!dataSourceKey || !collectionName) return undefined;
|
|
94
|
+
|
|
95
|
+
const associationName =
|
|
96
|
+
normalizeStr(assocField?.resourceName) ||
|
|
97
|
+
buildAssociationName(
|
|
98
|
+
normalizeStr(type === 'target' ? assocField?.collection?.name : collection?.name),
|
|
99
|
+
normalizeStr(assocField?.name),
|
|
100
|
+
);
|
|
101
|
+
return associationName ? { dataSourceKey, collectionName, associationName } : { dataSourceKey, collectionName };
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
type AssociationResolveMode = 'target' | 'base';
|
|
105
|
+
|
|
106
|
+
const resolveResourceInfoFromActionParams = (
|
|
107
|
+
ctx: any,
|
|
108
|
+
params: any,
|
|
109
|
+
mode: AssociationResolveMode,
|
|
110
|
+
): ExpectedResourceInfo | undefined => {
|
|
111
|
+
if (!params || typeof params !== 'object') return undefined;
|
|
112
|
+
const rawDataSourceKey = normalizeStr(params?.dataSourceKey);
|
|
113
|
+
const rawCollectionName = normalizeStr(params?.collectionName);
|
|
114
|
+
const rawAssociationName = normalizeStr(params?.associationName);
|
|
115
|
+
|
|
116
|
+
if (!rawDataSourceKey && !rawCollectionName && !rawAssociationName) return undefined;
|
|
117
|
+
|
|
118
|
+
let fallbackDataSourceKey = '';
|
|
119
|
+
let fallbackCollectionName = '';
|
|
120
|
+
try {
|
|
121
|
+
const ctxCollection = (ctx as any)?.collection;
|
|
122
|
+
fallbackDataSourceKey = normalizeStr(ctxCollection?.dataSourceKey);
|
|
123
|
+
fallbackCollectionName = normalizeStr(ctxCollection?.name);
|
|
124
|
+
} catch {
|
|
125
|
+
// ignore
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const init = {
|
|
129
|
+
dataSourceKey: rawDataSourceKey || fallbackDataSourceKey,
|
|
130
|
+
collectionName: rawCollectionName || fallbackCollectionName,
|
|
131
|
+
associationName: rawAssociationName,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const resolved =
|
|
135
|
+
mode === 'target'
|
|
136
|
+
? resolveTargetResourceByAssociation(ctx, init)
|
|
137
|
+
: rawAssociationName
|
|
138
|
+
? resolveBaseResourceByAssociation(init)
|
|
139
|
+
: undefined;
|
|
140
|
+
if (resolved) {
|
|
141
|
+
return { ...resolved, ...(rawAssociationName ? { associationName: rawAssociationName } : {}) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (init.dataSourceKey && init.collectionName) {
|
|
145
|
+
return {
|
|
146
|
+
dataSourceKey: init.dataSourceKey,
|
|
147
|
+
collectionName: init.collectionName,
|
|
148
|
+
...(rawAssociationName ? { associationName: rawAssociationName } : {}),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return undefined;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const resolveExpectedResourceInfo = (ctx: any, actionParams?: any): ExpectedResourceInfo => {
|
|
156
|
+
const rawAssociationName =
|
|
157
|
+
actionParams && typeof actionParams === 'object' ? normalizeStr((actionParams as any)?.associationName) : '';
|
|
158
|
+
const fromParams = resolveResourceInfoFromActionParams(ctx, actionParams, 'target');
|
|
159
|
+
if (rawAssociationName && fromParams) return fromParams;
|
|
160
|
+
|
|
161
|
+
const fromCtxAssociation = resolveExpectedAssociationInfoFromCtx(ctx, 'target');
|
|
162
|
+
if (fromCtxAssociation?.associationName) return fromCtxAssociation;
|
|
163
|
+
|
|
164
|
+
const fromModelChain = resolveExpectedResourceInfoByModelChain(ctx, ctx?.model, {
|
|
165
|
+
includeAssociationName: true,
|
|
166
|
+
fallbackCollectionFromCtx: true,
|
|
167
|
+
});
|
|
168
|
+
if (fromModelChain?.associationName) return fromModelChain;
|
|
169
|
+
return fromParams || fromModelChain || {};
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const resolveExpectedSourceResourceInfo = (ctx: any, actionParams?: any): ExpectedResourceInfo => {
|
|
173
|
+
const rawAssociationName =
|
|
174
|
+
actionParams && typeof actionParams === 'object' ? normalizeStr((actionParams as any)?.associationName) : '';
|
|
175
|
+
const fromParams = resolveResourceInfoFromActionParams(ctx, actionParams, 'base');
|
|
176
|
+
if (rawAssociationName && fromParams) return fromParams;
|
|
177
|
+
|
|
178
|
+
const fromCtxAssociation = resolveExpectedAssociationInfoFromCtx(ctx, 'source');
|
|
179
|
+
if (fromCtxAssociation) return fromCtxAssociation;
|
|
180
|
+
if (fromParams) return fromParams;
|
|
181
|
+
|
|
182
|
+
let cur: any = ctx?.model;
|
|
183
|
+
let depth = 0;
|
|
184
|
+
while (cur && depth < 8) {
|
|
185
|
+
const init = cur?.getStepParams?.('resourceSettings', 'init') || {};
|
|
186
|
+
const associationName = normalizeStr(init?.associationName);
|
|
187
|
+
|
|
188
|
+
let dataSourceKey = normalizeStr(init?.dataSourceKey);
|
|
189
|
+
let collectionName = normalizeStr(init?.collectionName);
|
|
190
|
+
|
|
191
|
+
// 次优先:读取运行时注入的 collection(更贴近“当前上下文集合”)
|
|
192
|
+
try {
|
|
193
|
+
const c = (cur as any)?.collection || (ctx as any)?.collection;
|
|
194
|
+
dataSourceKey = dataSourceKey || normalizeStr(c?.dataSourceKey);
|
|
195
|
+
collectionName = collectionName || normalizeStr(c?.name);
|
|
196
|
+
} catch {
|
|
197
|
+
// ignore
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (associationName && dataSourceKey) {
|
|
201
|
+
const baseResolved = resolveBaseResourceByAssociation({ dataSourceKey, collectionName, associationName });
|
|
202
|
+
if (baseResolved) return { ...baseResolved, associationName };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (dataSourceKey && collectionName) {
|
|
206
|
+
return { dataSourceKey, collectionName, ...(associationName ? { associationName } : {}) };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
cur = cur?.parent;
|
|
210
|
+
depth++;
|
|
211
|
+
}
|
|
212
|
+
return {};
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const getPopupTemplateDisabledReason = async (
|
|
216
|
+
ctx: any,
|
|
217
|
+
tpl: TemplateRow,
|
|
218
|
+
expected: ExpectedResourceInfo,
|
|
219
|
+
_expectedSource: ExpectedResourceInfo,
|
|
220
|
+
actionParams?: any,
|
|
221
|
+
): Promise<string | undefined> => {
|
|
222
|
+
const engine = (ctx as any)?.engine;
|
|
223
|
+
const getModelClass = engine?.getModelClass?.bind(engine);
|
|
224
|
+
const useKey = normalizeStr((ctx as any)?.model?.use) || normalizeStr((ctx as any)?.model?.constructor?.name);
|
|
225
|
+
const scene = resolveActionScene(getModelClass, useKey);
|
|
226
|
+
const meta = inferPopupTemplateMeta(ctx, tpl);
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 弹窗模板的兼容性判断说明:
|
|
230
|
+
*
|
|
231
|
+
* openView 弹窗通常在某个「当前记录/资源」上下文中触发(ctx.record / ctx.resource)。
|
|
232
|
+
* 弹窗模板在创建时会把 dataSourceKey/collectionName(以及 associationName)固化到模板记录,
|
|
233
|
+
* 模板内部的区块/变量表达式也往往默认依赖这些信息(例如默认 filterByTk 会引用 ctx.record.<pk>,
|
|
234
|
+
* sourceId 可能引用 ctx.resource.sourceId)。
|
|
235
|
+
*
|
|
236
|
+
* 如果在另一个 dataSourceKey/collectionName 的上下文里引用该弹窗模板:
|
|
237
|
+
* - 弹窗里获取数据会落到错误的数据源/数据表,或由于上下文不一致导致变量无法解析;
|
|
238
|
+
* - 即使 openView 参数被"回填"为模板侧的 dataSourceKey/collectionName,也会让当前触发点
|
|
239
|
+
* 的 ctx.record 与弹窗目标集合不一致,从而形成「配置上看似可用、运行时必坏」的问题。
|
|
240
|
+
*
|
|
241
|
+
* 因此这里将 dataSourceKey/collectionName 不匹配视为"模板不兼容",在选择器里禁用并给出原因,
|
|
242
|
+
* 同时在 beforeParamsSave 做硬校验防止绕过 UI。
|
|
243
|
+
*
|
|
244
|
+
* 额外:collectionName 有时并不可靠(例如资源是由 associationName 推导而来),所以会优先
|
|
245
|
+
* 尝试根据 associationName 解析真实 targetCollection 再比较(best-effort,解析失败则回退)。
|
|
246
|
+
*/
|
|
247
|
+
const resourceReason = getTemplateAvailabilityDisabledReason(ctx, tpl, expected, {
|
|
248
|
+
associationMatch: 'exactIfTemplateHasAssociationName',
|
|
249
|
+
});
|
|
250
|
+
if (resourceReason) return resourceReason;
|
|
251
|
+
|
|
252
|
+
// 如果模板不需要 filterByTk,直接允许
|
|
253
|
+
if (!meta?.hasFilterByTk) return undefined;
|
|
254
|
+
|
|
255
|
+
// 如果当前 scene 是 collection(如添加按钮),且模板需要 filterByTk,应该禁止选择
|
|
256
|
+
// 因为 collection 场景无法提供 filterByTk
|
|
257
|
+
if (scene === 'collection') {
|
|
258
|
+
return tWithNs(ctx, 'Cannot resolve template parameter {{param}}', { param: 'filterByTk' });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// record/both 场景默认应能从 ctx.record 推断出 filterByTk
|
|
262
|
+
if (scene === 'record' || scene === 'both') return undefined;
|
|
263
|
+
|
|
264
|
+
// 以下检查用于 scene 为 undefined 的情况(如 FieldModel)
|
|
265
|
+
const expectedAssociationName = normalizeStr(expected?.associationName);
|
|
266
|
+
// 非关系字段场景,且 scene 未知时,默认允许
|
|
267
|
+
if (!expectedAssociationName) return undefined;
|
|
268
|
+
|
|
269
|
+
// 关系字段场景:检查是否能提供 filterByTk
|
|
270
|
+
const explicitFilterByTk = normalizeStr(actionParams?.filterByTk);
|
|
271
|
+
if (explicitFilterByTk) return undefined;
|
|
272
|
+
|
|
273
|
+
const defaultKeys = Array.isArray(actionParams?.defaultInputKeys) ? actionParams.defaultInputKeys : [];
|
|
274
|
+
if (defaultKeys.some((k: any) => String(k) === 'filterByTk')) return undefined;
|
|
275
|
+
|
|
276
|
+
let hasRuntimeFilterByTk = !!(ctx.inputArgs?.filterByTk || ctx.view?.inputArgs?.filterByTk);
|
|
277
|
+
if (ctx.model.forks?.size > 0) {
|
|
278
|
+
const firstModel = [...ctx.model.forks][0];
|
|
279
|
+
hasRuntimeFilterByTk = !!firstModel.context.record;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (hasRuntimeFilterByTk) return undefined;
|
|
283
|
+
|
|
284
|
+
return tWithNs(ctx, 'Cannot resolve template parameter {{param}}', { param: 'filterByTk' });
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/** 预览/配置态下无法获取真实 record 时使用的占位符 */
|
|
288
|
+
const POPUP_TEMPLATE_FILTER_BY_TK_PLACEHOLDER = '__popupTemplateFilterByTk__';
|
|
289
|
+
|
|
290
|
+
const isUsableKeyValue = (value: any): boolean => {
|
|
291
|
+
if (value === null || typeof value === 'undefined') return false;
|
|
292
|
+
if (value === POPUP_TEMPLATE_FILTER_BY_TK_PLACEHOLDER) return false;
|
|
293
|
+
if (typeof value === 'string') return value.trim() !== '';
|
|
294
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
295
|
+
if (typeof value === 'object') return Object.keys(value).length > 0;
|
|
296
|
+
return true;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const resolveAssociationReferenceValues = (
|
|
300
|
+
ctx: any,
|
|
301
|
+
assocField: any,
|
|
302
|
+
): {
|
|
303
|
+
targetFilterKey: string;
|
|
304
|
+
associationTargetKey: string;
|
|
305
|
+
filterTargetKeyValue?: any;
|
|
306
|
+
associationTargetKeyValue?: any;
|
|
307
|
+
} | null => {
|
|
308
|
+
if (!assocField) return null;
|
|
309
|
+
|
|
310
|
+
const targetFilterKey = normalizeStr(assocField?.targetCollection?.filterTargetKey) || 'id';
|
|
311
|
+
const associationTargetKey = normalizeStr(assocField?.targetKey);
|
|
312
|
+
const assocName = normalizeStr(assocField?.name);
|
|
313
|
+
const foreignKey = normalizeStr(assocField?.foreignKey);
|
|
314
|
+
const record = (ctx as any)?.record || (ctx as any)?.inputArgs?.record || (ctx as any)?.view?.inputArgs?.record;
|
|
315
|
+
|
|
316
|
+
let filterTargetKeyValue: any | undefined;
|
|
317
|
+
let associationTargetKeyValue: any | undefined;
|
|
318
|
+
|
|
319
|
+
if (record) {
|
|
320
|
+
const assocValue = record[assocName];
|
|
321
|
+
if (assocName && assocValue) {
|
|
322
|
+
let assocRecord;
|
|
323
|
+
if (_.isArray(assocValue)) {
|
|
324
|
+
// 对多
|
|
325
|
+
assocRecord = _.find(assocValue, { [associationTargetKey]: ctx.inputArgs?.filterByTk });
|
|
326
|
+
} else {
|
|
327
|
+
assocRecord = assocValue;
|
|
328
|
+
}
|
|
329
|
+
if (assocRecord) {
|
|
330
|
+
associationTargetKeyValue = assocRecord[targetFilterKey];
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (!associationTargetKeyValue && foreignKey) {
|
|
335
|
+
const foreignKeyValue = record[foreignKey];
|
|
336
|
+
if (isUsableKeyValue(foreignKeyValue)) {
|
|
337
|
+
associationTargetKeyValue = foreignKeyValue;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// 路由二阶段/record 缺失时:ctx.inputArgs.filterByTk 往往就是 associationTargetKey 的值
|
|
343
|
+
const inputFilterByTk = (ctx as any)?.inputArgs?.filterByTk;
|
|
344
|
+
if (!associationTargetKeyValue && isUsableKeyValue(inputFilterByTk)) {
|
|
345
|
+
associationTargetKeyValue = inputFilterByTk;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return { targetFilterKey, associationTargetKey, filterTargetKeyValue, associationTargetKeyValue };
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const stripTemplateParams = (params: any) => {
|
|
352
|
+
if (!params || typeof params !== 'object') return params;
|
|
353
|
+
const next = { ...params };
|
|
354
|
+
delete next.popupTemplateUid;
|
|
355
|
+
delete (next as any).popupTemplateContext;
|
|
356
|
+
delete (next as any).popupTemplateHasFilterByTk;
|
|
357
|
+
delete (next as any).popupTemplateHasSourceId;
|
|
358
|
+
// Avoid overriding runtime defaults with null/empty values.
|
|
359
|
+
const isEmptyValue = (v: any) => {
|
|
360
|
+
if (v === null || typeof v === 'undefined') return true;
|
|
361
|
+
if (typeof v === 'string') return v.trim() === '';
|
|
362
|
+
if (Array.isArray(v)) return v.length === 0;
|
|
363
|
+
if (typeof v === 'object') return Object.keys(v).length === 0;
|
|
364
|
+
return false;
|
|
365
|
+
};
|
|
366
|
+
if (isEmptyValue(next.filterByTk)) delete next.filterByTk;
|
|
367
|
+
if (isEmptyValue(next.sourceId)) delete next.sourceId;
|
|
368
|
+
return next;
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const buildPopupTemplateShadowCtx = async (ctx: any, params: Record<string, any>) => {
|
|
372
|
+
const baseInputArgs = ctx.inputArgs || {};
|
|
373
|
+
const nextInputArgs: Record<string, any> = { ...(baseInputArgs as any) };
|
|
374
|
+
|
|
375
|
+
// 资源三元组(dataSourceKey/collectionName/associationName)以模板为准:通过覆盖 ctx.inputArgs,让 base openView 按原规则生效。
|
|
376
|
+
if (typeof params?.dataSourceKey !== 'undefined') {
|
|
377
|
+
nextInputArgs.dataSourceKey =
|
|
378
|
+
typeof params.dataSourceKey === 'string' ? params.dataSourceKey.trim() : params.dataSourceKey;
|
|
379
|
+
}
|
|
380
|
+
if (typeof params?.collectionName !== 'undefined') {
|
|
381
|
+
nextInputArgs.collectionName =
|
|
382
|
+
typeof params.collectionName === 'string' ? params.collectionName.trim() : params.collectionName;
|
|
383
|
+
}
|
|
384
|
+
const tplAssociationName = typeof params?.associationName === 'string' ? params.associationName.trim() : '';
|
|
385
|
+
if (tplAssociationName) {
|
|
386
|
+
nextInputArgs.associationName = tplAssociationName;
|
|
387
|
+
} else {
|
|
388
|
+
delete nextInputArgs.associationName;
|
|
389
|
+
}
|
|
390
|
+
const isNonRelationTemplate = !tplAssociationName;
|
|
391
|
+
|
|
392
|
+
// 模板是否显式携带 filterByTk/sourceId:用于避免从 actionDefaults "透传"到模板弹窗(尤其是 Record action 复用 Collection 模板)。
|
|
393
|
+
// 注意:运行时 params 可能已被解析(缺少 ctx.record 时会解析为空/undefined),因此优先使用保存时写入的布尔标记兜底。
|
|
394
|
+
const hasTemplateFilterByTk =
|
|
395
|
+
typeof params.popupTemplateHasFilterByTk === 'boolean'
|
|
396
|
+
? params.popupTemplateHasFilterByTk
|
|
397
|
+
: isUsableKeyValue((params as any)?.filterByTk);
|
|
398
|
+
const hasTemplateSourceId =
|
|
399
|
+
typeof params.popupTemplateHasSourceId === 'boolean'
|
|
400
|
+
? params.popupTemplateHasSourceId
|
|
401
|
+
: normalizeStr(params.sourceId) !== '';
|
|
402
|
+
// sourceId 是否需要保留完全由模板的 popupTemplateHasSourceId 或 sourceId 表达式决定
|
|
403
|
+
const shouldKeepSourceId = hasTemplateSourceId;
|
|
404
|
+
const assocField = resolveAssociationFieldFromCtx(ctx);
|
|
405
|
+
const assocTargetCollectionName = normalizeStr(assocField?.targetCollection?.name);
|
|
406
|
+
const tplCollectionName = normalizeStr(params?.collectionName);
|
|
407
|
+
const shouldInferFilterByTkFromAssociation =
|
|
408
|
+
isNonRelationTemplate &&
|
|
409
|
+
!!assocTargetCollectionName &&
|
|
410
|
+
!!tplCollectionName &&
|
|
411
|
+
assocTargetCollectionName === tplCollectionName;
|
|
412
|
+
|
|
413
|
+
let didOverrideFilterByTk = false;
|
|
414
|
+
let didOverrideSourceId = false;
|
|
415
|
+
if (!hasTemplateFilterByTk) {
|
|
416
|
+
// 防止 openView 回落到 actionDefaults.filterByTk(如 Record action 默认 {{ctx.record.id}})
|
|
417
|
+
nextInputArgs.filterByTk = null;
|
|
418
|
+
didOverrideFilterByTk = true;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// 自动推断:关系字段上下文复用"目标集合(非关系)弹窗模板"时,确保 filterByTk 使用目标集合的 filterTargetKey
|
|
422
|
+
if (hasTemplateFilterByTk && !didOverrideFilterByTk && shouldInferFilterByTkFromAssociation) {
|
|
423
|
+
const info = resolveAssociationReferenceValues(ctx, assocField);
|
|
424
|
+
|
|
425
|
+
// 1) 优先:如果 record/关联对象里已经有目标集合 filterTargetKey(如 id),直接使用
|
|
426
|
+
if (info && isUsableKeyValue(info.filterTargetKeyValue)) {
|
|
427
|
+
nextInputArgs.filterByTk = info.filterTargetKeyValue;
|
|
428
|
+
didOverrideFilterByTk = true;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// 2) 退化:targetKey == filterTargetKey 或无法识别差异时,直接使用可用的 key 值
|
|
432
|
+
if (info && !didOverrideFilterByTk && isUsableKeyValue(info.associationTargetKeyValue)) {
|
|
433
|
+
nextInputArgs.filterByTk = info.associationTargetKeyValue;
|
|
434
|
+
didOverrideFilterByTk = true;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// 预览/配置态下可能拿不到真实 record,导致 filterByTk 被解析为 undefined;但模板若明确需要 record 上下文,
|
|
439
|
+
// 仍需提供一个 truthy 值以保证区块菜单按 record 场景展示(否则只能添加 new 场景区块)。
|
|
440
|
+
if (hasTemplateFilterByTk && !didOverrideFilterByTk) {
|
|
441
|
+
const existing = (nextInputArgs as any).filterByTk;
|
|
442
|
+
if (!isUsableKeyValue(existing)) {
|
|
443
|
+
const fallback = (ctx as any)?.view?.inputArgs?.filterByTk;
|
|
444
|
+
nextInputArgs.filterByTk = isUsableKeyValue(fallback) ? fallback : POPUP_TEMPLATE_FILTER_BY_TK_PLACEHOLDER;
|
|
445
|
+
didOverrideFilterByTk = true;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (!shouldKeepSourceId) {
|
|
450
|
+
// 模板不需要 sourceId(且不是关系资源弹窗),清除来自关系字段上下文的 sourceId
|
|
451
|
+
nextInputArgs.sourceId = null;
|
|
452
|
+
didOverrideSourceId = true;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// 覆盖 filterByTk/sourceId 时,确保它们不再被视作 defaultInputKeys(否则可能被误判为“默认值”透传)。
|
|
456
|
+
if (Array.isArray(nextInputArgs.defaultInputKeys) && (didOverrideFilterByTk || didOverrideSourceId)) {
|
|
457
|
+
const remove = new Set<string>([
|
|
458
|
+
...(didOverrideFilterByTk ? ['filterByTk'] : []),
|
|
459
|
+
...(didOverrideSourceId ? ['sourceId'] : []),
|
|
460
|
+
]);
|
|
461
|
+
const nextKeys = nextInputArgs.defaultInputKeys.filter((k: any) => !remove.has(String(k)));
|
|
462
|
+
if (nextKeys.length > 0) {
|
|
463
|
+
nextInputArgs.defaultInputKeys = nextKeys;
|
|
464
|
+
} else {
|
|
465
|
+
delete nextInputArgs.defaultInputKeys;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const flowCtx = await createEphemeralContext(ctx as any, {
|
|
470
|
+
defineProperties: {
|
|
471
|
+
inputArgs: { value: nextInputArgs },
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
return flowCtx;
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const fetchPopupTemplates = async (
|
|
478
|
+
ctx: any,
|
|
479
|
+
keyword?: string,
|
|
480
|
+
pagination?: { page?: number; pageSize?: number },
|
|
481
|
+
): Promise<{ rows: TemplateRow[]; count?: number }> => {
|
|
482
|
+
const api = ctx?.api;
|
|
483
|
+
if (!api?.resource) return { rows: [] };
|
|
484
|
+
const page = Math.max(1, Number(pagination?.page || 1));
|
|
485
|
+
const pageSize = Math.max(1, Number(pagination?.pageSize || TEMPLATE_LIST_PAGE_SIZE));
|
|
486
|
+
const res = await api.resource('flowModelTemplates').list({
|
|
487
|
+
page,
|
|
488
|
+
pageSize,
|
|
489
|
+
search: keyword || undefined,
|
|
490
|
+
filter: {
|
|
491
|
+
type: 'popup',
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
const parsed = parseResourceListResponse<TemplateRow>(res);
|
|
495
|
+
return { rows: parsed.rows, count: parsed.count };
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const fetchTemplateByUid = async (ctx: any, templateUid: string): Promise<TemplateRow | null> => {
|
|
499
|
+
const api = ctx?.api;
|
|
500
|
+
if (!api?.resource) return null;
|
|
501
|
+
const res = await api.resource('flowModelTemplates').get({ filterByTk: templateUid });
|
|
502
|
+
const body = res.data?.data;
|
|
503
|
+
if (!body) return null;
|
|
504
|
+
return body as TemplateRow;
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
type PopupTemplateMeta = PopupTemplateContextFlags & { tpl: TemplateRow };
|
|
508
|
+
|
|
509
|
+
const popupTemplateMetaCacheByEngine = new WeakMap<object, Map<string, Promise<PopupTemplateMeta | null>>>();
|
|
510
|
+
const popupTemplateMetaCacheFallback = new Map<string, Promise<PopupTemplateMeta | null>>();
|
|
511
|
+
|
|
512
|
+
const getPopupTemplateMetaCache = (ctx: any): Map<string, Promise<PopupTemplateMeta | null>> => {
|
|
513
|
+
const engine = (ctx as any)?.engine;
|
|
514
|
+
if (engine && typeof engine === 'object') {
|
|
515
|
+
let cache = popupTemplateMetaCacheByEngine.get(engine);
|
|
516
|
+
if (!cache) {
|
|
517
|
+
cache = new Map<string, Promise<PopupTemplateMeta | null>>();
|
|
518
|
+
popupTemplateMetaCacheByEngine.set(engine, cache);
|
|
519
|
+
}
|
|
520
|
+
return cache;
|
|
521
|
+
}
|
|
522
|
+
return popupTemplateMetaCacheFallback;
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const getPopupTemplateMeta = async (ctx: any, templateUid: string): Promise<PopupTemplateMeta | null> => {
|
|
526
|
+
const uid = typeof templateUid === 'string' ? templateUid.trim() : '';
|
|
527
|
+
if (!uid) return null;
|
|
528
|
+
const cache = getPopupTemplateMetaCache(ctx);
|
|
529
|
+
if (cache.has(uid)) return cache.get(uid)!;
|
|
530
|
+
const p = (async () => {
|
|
531
|
+
try {
|
|
532
|
+
const tpl = await fetchTemplateByUid(ctx, uid);
|
|
533
|
+
if (!tpl) return null;
|
|
534
|
+
return { ...inferPopupTemplateMeta(ctx, tpl), tpl } as PopupTemplateMeta;
|
|
535
|
+
} catch (e) {
|
|
536
|
+
console.error('[block-reference] getPopupTemplateMeta failed:', e);
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
})();
|
|
540
|
+
cache.set(uid, p);
|
|
541
|
+
return p;
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
function PopupTemplateSelect(props: any) {
|
|
545
|
+
const { value, onChange } = props as { value?: string; onChange?: (v: string | undefined) => void };
|
|
546
|
+
const ctx = useFlowSettingsContext();
|
|
547
|
+
const form = useForm();
|
|
548
|
+
const expectedResource = useMemo(
|
|
549
|
+
() => resolveExpectedResourceInfo(ctx as any, form?.values),
|
|
550
|
+
[
|
|
551
|
+
ctx,
|
|
552
|
+
(form as any)?.values?.dataSourceKey,
|
|
553
|
+
(form as any)?.values?.collectionName,
|
|
554
|
+
(form as any)?.values?.associationName,
|
|
555
|
+
],
|
|
556
|
+
);
|
|
557
|
+
const expectedSourceResource = useMemo(
|
|
558
|
+
() => resolveExpectedSourceResourceInfo(ctx as any, form?.values),
|
|
559
|
+
[
|
|
560
|
+
ctx,
|
|
561
|
+
(form as any)?.values?.dataSourceKey,
|
|
562
|
+
(form as any)?.values?.collectionName,
|
|
563
|
+
(form as any)?.values?.associationName,
|
|
564
|
+
],
|
|
565
|
+
);
|
|
566
|
+
const [options, setOptions] = useState<
|
|
567
|
+
Array<{
|
|
568
|
+
label: React.ReactNode;
|
|
569
|
+
value: string;
|
|
570
|
+
raw?: TemplateRow;
|
|
571
|
+
description?: string;
|
|
572
|
+
disabled?: boolean;
|
|
573
|
+
disabledReason?: string;
|
|
574
|
+
rawName?: string;
|
|
575
|
+
}>
|
|
576
|
+
>([]);
|
|
577
|
+
const [loading, setLoading] = useState(false);
|
|
578
|
+
const [selectLoading, setSelectLoading] = useState(false);
|
|
579
|
+
const [page, setPage] = useState(0);
|
|
580
|
+
const [keyword, setKeyword] = useState('');
|
|
581
|
+
const [hasMore, setHasMore] = useState(true);
|
|
582
|
+
const selectRef = useRef<BaseSelectRef | null>(null);
|
|
583
|
+
const isComposingRef = useRef(false);
|
|
584
|
+
const listVersionRef = useRef(0);
|
|
585
|
+
const loadingMoreRef = useRef(false);
|
|
586
|
+
|
|
587
|
+
const t = useCallback((key: string, opt?: Record<string, any>) => tWithNs(ctx, key, opt), [ctx]);
|
|
588
|
+
|
|
589
|
+
const toOption = useCallback(
|
|
590
|
+
async (tpl: TemplateRow) => {
|
|
591
|
+
const name = tpl?.name || tpl?.uid || '';
|
|
592
|
+
const desc = tpl?.description;
|
|
593
|
+
const disabledReason = await getPopupTemplateDisabledReason(
|
|
594
|
+
ctx as any,
|
|
595
|
+
tpl,
|
|
596
|
+
expectedResource,
|
|
597
|
+
expectedSourceResource,
|
|
598
|
+
form?.values,
|
|
599
|
+
);
|
|
600
|
+
return {
|
|
601
|
+
label: renderTemplateSelectLabel(name),
|
|
602
|
+
value: tpl.uid,
|
|
603
|
+
raw: tpl,
|
|
604
|
+
description: desc,
|
|
605
|
+
disabled: !!disabledReason,
|
|
606
|
+
disabledReason,
|
|
607
|
+
rawName: name,
|
|
608
|
+
};
|
|
609
|
+
},
|
|
610
|
+
[ctx, expectedResource, expectedSourceResource, form],
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
const resetAndLoad = useCallback(
|
|
614
|
+
async (nextKeyword?: string) => {
|
|
615
|
+
const v = (typeof nextKeyword === 'string' ? nextKeyword : '').trim();
|
|
616
|
+
const nextVersion = listVersionRef.current + 1;
|
|
617
|
+
listVersionRef.current = nextVersion;
|
|
618
|
+
loadingMoreRef.current = false;
|
|
619
|
+
setKeyword(v);
|
|
620
|
+
setPage(0);
|
|
621
|
+
setHasMore(true);
|
|
622
|
+
setLoading(true);
|
|
623
|
+
try {
|
|
624
|
+
const { rows, count } = await fetchPopupTemplates(ctx, v, { page: 1, pageSize: TEMPLATE_LIST_PAGE_SIZE });
|
|
625
|
+
const rawLength = rows.length;
|
|
626
|
+
const withIndex = await Promise.all(rows.map(async (r, idx) => ({ ...(await toOption(r)), __idx: idx })));
|
|
627
|
+
if (listVersionRef.current !== nextVersion) return;
|
|
628
|
+
setOptions(mergeSelectOptions([], withIndex));
|
|
629
|
+
setPage(1);
|
|
630
|
+
setHasMore(calcHasMore({ page: 1, pageSize: TEMPLATE_LIST_PAGE_SIZE, rowsLength: rawLength, count }));
|
|
631
|
+
} catch (e) {
|
|
632
|
+
console.error('fetch popup template options failed', e);
|
|
633
|
+
if (listVersionRef.current !== nextVersion) return;
|
|
634
|
+
setOptions([]);
|
|
635
|
+
setPage(1);
|
|
636
|
+
setHasMore(false);
|
|
637
|
+
} finally {
|
|
638
|
+
if (listVersionRef.current === nextVersion) {
|
|
639
|
+
setLoading(false);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
},
|
|
643
|
+
[ctx, toOption],
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
const loadMore = useCallback(async () => {
|
|
647
|
+
if (loadingMoreRef.current || loading || !hasMore) return;
|
|
648
|
+
const version = listVersionRef.current;
|
|
649
|
+
const nextPage = Math.max(1, page || 1) + 1;
|
|
650
|
+
loadingMoreRef.current = true;
|
|
651
|
+
setLoading(true);
|
|
652
|
+
try {
|
|
653
|
+
const { rows, count } = await fetchPopupTemplates(ctx, keyword, {
|
|
654
|
+
page: nextPage,
|
|
655
|
+
pageSize: TEMPLATE_LIST_PAGE_SIZE,
|
|
656
|
+
});
|
|
657
|
+
const rawLength = rows.length;
|
|
658
|
+
const withIndex = await Promise.all(
|
|
659
|
+
rows.map(async (r, idx) => ({ ...(await toOption(r)), __idx: (nextPage - 1) * TEMPLATE_LIST_PAGE_SIZE + idx })),
|
|
660
|
+
);
|
|
661
|
+
if (listVersionRef.current !== version) return;
|
|
662
|
+
setOptions((prev) => mergeSelectOptions(prev || [], withIndex));
|
|
663
|
+
setPage(nextPage);
|
|
664
|
+
setHasMore(calcHasMore({ page: nextPage, pageSize: TEMPLATE_LIST_PAGE_SIZE, rowsLength: rawLength, count }));
|
|
665
|
+
} catch (e) {
|
|
666
|
+
console.error('fetch more popup templates failed', e);
|
|
667
|
+
if (listVersionRef.current !== version) return;
|
|
668
|
+
setHasMore(false);
|
|
669
|
+
} finally {
|
|
670
|
+
loadingMoreRef.current = false;
|
|
671
|
+
if (listVersionRef.current === version) {
|
|
672
|
+
setLoading(false);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}, [ctx, hasMore, keyword, loading, page, toOption]);
|
|
676
|
+
|
|
677
|
+
useEffect(() => {
|
|
678
|
+
let alive = true;
|
|
679
|
+
const run = async () => {
|
|
680
|
+
const v = typeof value === 'string' ? value.trim() : '';
|
|
681
|
+
if (!v) return;
|
|
682
|
+
// Ensure current value is visible even if dropdown hasn't been opened yet.
|
|
683
|
+
if (options.some((o) => o.value === v)) return;
|
|
684
|
+
try {
|
|
685
|
+
const tpl = await fetchTemplateByUid(ctx, v);
|
|
686
|
+
if (!alive || !tpl?.uid) return;
|
|
687
|
+
const opt = await toOption(tpl);
|
|
688
|
+
setOptions((prev) => mergeSelectOptions(prev || [], [{ ...(opt as any), __idx: -1 }]));
|
|
689
|
+
} catch (e) {
|
|
690
|
+
// ignore
|
|
691
|
+
}
|
|
692
|
+
};
|
|
693
|
+
run();
|
|
694
|
+
return () => {
|
|
695
|
+
alive = false;
|
|
696
|
+
};
|
|
697
|
+
}, [ctx, options, toOption, value]);
|
|
698
|
+
|
|
699
|
+
const setOpenViewValue = useCallback(
|
|
700
|
+
(k: string, v: any) => {
|
|
701
|
+
try {
|
|
702
|
+
form.setValuesIn(k, v);
|
|
703
|
+
} catch (e) {
|
|
704
|
+
console.error(e);
|
|
705
|
+
// ignore
|
|
706
|
+
}
|
|
707
|
+
},
|
|
708
|
+
[form],
|
|
709
|
+
);
|
|
710
|
+
|
|
711
|
+
const handleSelect = useCallback(
|
|
712
|
+
async (nextUid?: string) => {
|
|
713
|
+
const next = typeof nextUid === 'string' ? nextUid.trim() : '';
|
|
714
|
+
if (!next) {
|
|
715
|
+
onChange?.(undefined);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
onChange?.(next);
|
|
719
|
+
try {
|
|
720
|
+
setSelectLoading(true);
|
|
721
|
+
const tpl = await fetchTemplateByUid(ctx, next);
|
|
722
|
+
const targetUid = tpl?.targetUid;
|
|
723
|
+
if (targetUid) {
|
|
724
|
+
setOpenViewValue('uid', targetUid);
|
|
725
|
+
}
|
|
726
|
+
// best-effort: backfill common openView params from template record
|
|
727
|
+
const shouldApplyTemplateField = (v: any) => {
|
|
728
|
+
if (v === undefined || v === null) return false;
|
|
729
|
+
if (typeof v === 'string') return v.trim() !== '';
|
|
730
|
+
return true;
|
|
731
|
+
};
|
|
732
|
+
(['dataSourceKey', 'collectionName', 'filterByTk', 'sourceId'] as const).forEach((k) => {
|
|
733
|
+
const tv = (tpl as any)?.[k];
|
|
734
|
+
if (shouldApplyTemplateField(tv)) {
|
|
735
|
+
setOpenViewValue(k, tv);
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
// associationName 仅在模板显式携带时回填;
|
|
739
|
+
// 模板未携带时不主动清空,避免影响“当前触发上下文”的兼容性判断(否则下次打开下拉会误以为不是 association 上下文)。
|
|
740
|
+
const tplAssociationName =
|
|
741
|
+
typeof (tpl as any)?.associationName === 'string' ? String((tpl as any).associationName) : '';
|
|
742
|
+
if (shouldApplyTemplateField(tplAssociationName)) {
|
|
743
|
+
setOpenViewValue('associationName', tplAssociationName);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Backfill defaults for important runtime params when template record doesn't carry them.
|
|
747
|
+
if (!shouldApplyTemplateField((tpl as any)?.filterByTk) && !shouldApplyTemplateField(form.values?.filterByTk)) {
|
|
748
|
+
const recordKeyPath = (ctx as any)?.collection?.filterTargetKey || 'id';
|
|
749
|
+
setOpenViewValue('filterByTk', `{{ ctx.record.${recordKeyPath} }}`);
|
|
750
|
+
}
|
|
751
|
+
if (!shouldApplyTemplateField((tpl as any)?.sourceId) && !shouldApplyTemplateField(form.values?.sourceId)) {
|
|
752
|
+
try {
|
|
753
|
+
const sid = (ctx as any)?.resource?.getSourceId?.();
|
|
754
|
+
if (sid !== undefined && sid !== null && String(sid) !== '') {
|
|
755
|
+
setOpenViewValue('sourceId', `{{ ctx.resource.sourceId }}`);
|
|
756
|
+
}
|
|
757
|
+
} catch (_) {
|
|
758
|
+
// ignore
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
} catch (e) {
|
|
762
|
+
console.error('load popup template failed', e);
|
|
763
|
+
} finally {
|
|
764
|
+
setSelectLoading(false);
|
|
765
|
+
}
|
|
766
|
+
},
|
|
767
|
+
[ctx, form.values, onChange, setOpenViewValue],
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
const debouncedSearch = useMemo(() => debounce((kw: string) => resetAndLoad(kw), 300), [resetAndLoad]);
|
|
771
|
+
|
|
772
|
+
useEffect(() => {
|
|
773
|
+
return () => {
|
|
774
|
+
debouncedSearch.cancel();
|
|
775
|
+
};
|
|
776
|
+
}, [debouncedSearch]);
|
|
777
|
+
|
|
778
|
+
// 使用 useEffect 来绑定 composition 事件
|
|
779
|
+
useEffect(() => {
|
|
780
|
+
let inputNode: HTMLInputElement | null = null;
|
|
781
|
+
const handleCompositionStart = () => {
|
|
782
|
+
isComposingRef.current = true;
|
|
783
|
+
};
|
|
784
|
+
const handleCompositionEnd = () => {
|
|
785
|
+
isComposingRef.current = false;
|
|
786
|
+
setTimeout(() => {
|
|
787
|
+
const value = inputNode?.value;
|
|
788
|
+
const kw = typeof value === 'string' ? value.trim() : '';
|
|
789
|
+
if (kw) {
|
|
790
|
+
debouncedSearch(kw);
|
|
791
|
+
}
|
|
792
|
+
}, 0);
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
const timer = setTimeout(() => {
|
|
796
|
+
const root = selectRef.current?.nativeElement;
|
|
797
|
+
const node = root?.querySelector?.('input.ant-select-selection-search-input');
|
|
798
|
+
if (!(node instanceof HTMLInputElement)) return;
|
|
799
|
+
inputNode = node;
|
|
800
|
+
|
|
801
|
+
inputNode.addEventListener('compositionstart', handleCompositionStart);
|
|
802
|
+
inputNode.addEventListener('compositionend', handleCompositionEnd);
|
|
803
|
+
}, 100);
|
|
804
|
+
|
|
805
|
+
return () => {
|
|
806
|
+
clearTimeout(timer);
|
|
807
|
+
if (!inputNode) return;
|
|
808
|
+
inputNode.removeEventListener('compositionstart', handleCompositionStart);
|
|
809
|
+
inputNode.removeEventListener('compositionend', handleCompositionEnd);
|
|
810
|
+
};
|
|
811
|
+
}, [debouncedSearch]);
|
|
812
|
+
|
|
813
|
+
return (
|
|
814
|
+
<Select
|
|
815
|
+
ref={selectRef}
|
|
816
|
+
showSearch
|
|
817
|
+
allowClear
|
|
818
|
+
filterOption={false}
|
|
819
|
+
optionLabelProp="label"
|
|
820
|
+
placeholder={t('Select popup template')}
|
|
821
|
+
loading={loading || selectLoading}
|
|
822
|
+
options={options}
|
|
823
|
+
value={value}
|
|
824
|
+
onChange={handleSelect}
|
|
825
|
+
onDropdownVisibleChange={(open) => {
|
|
826
|
+
if (!open) return;
|
|
827
|
+
resetAndLoad('');
|
|
828
|
+
}}
|
|
829
|
+
onSearch={(v) => {
|
|
830
|
+
// 如果正在使用中文输入法,不触发搜索
|
|
831
|
+
if (isComposingRef.current) return;
|
|
832
|
+
const kw = typeof v === 'string' ? v.trim() : '';
|
|
833
|
+
debouncedSearch(kw);
|
|
834
|
+
}}
|
|
835
|
+
onPopupScroll={(e) => {
|
|
836
|
+
const target = e?.target as HTMLElement | undefined;
|
|
837
|
+
if (!target) return;
|
|
838
|
+
if (target.scrollTop + target.clientHeight < target.scrollHeight - 24) return;
|
|
839
|
+
loadMore();
|
|
840
|
+
}}
|
|
841
|
+
dropdownMatchSelectWidth
|
|
842
|
+
dropdownStyle={{ maxWidth: 560 }}
|
|
843
|
+
getPopupContainer={() => document.body}
|
|
844
|
+
optionRender={renderTemplateSelectOption}
|
|
845
|
+
/>
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const resolveTemplateToUid = async (ctx: FlowSettingsContext, params: any): Promise<void> => {
|
|
850
|
+
const templateUid = typeof params?.popupTemplateUid === 'string' ? params.popupTemplateUid.trim() : '';
|
|
851
|
+
if (!templateUid) return;
|
|
852
|
+
const tpl = await fetchTemplateByUid(ctx as any, templateUid);
|
|
853
|
+
if (!tpl?.targetUid) {
|
|
854
|
+
throw new Error(tWithNs(ctx, 'Popup template not found'));
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const expected = resolveExpectedResourceInfo(ctx as any, params);
|
|
858
|
+
const expectedSource = resolveExpectedSourceResourceInfo(ctx as any, params);
|
|
859
|
+
const disabledReason = await getPopupTemplateDisabledReason(ctx as any, tpl, expected, expectedSource, params);
|
|
860
|
+
if (disabledReason) {
|
|
861
|
+
throw new Error(disabledReason);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
params.uid = tpl.targetUid;
|
|
865
|
+
// collectionName / associationName / dataSourceKey 以模板为准(associationName 允许为空表示"非关系弹窗")
|
|
866
|
+
const tplDataSourceKey = normalizeStr(tpl?.dataSourceKey);
|
|
867
|
+
const tplCollectionName = normalizeStr(tpl?.collectionName);
|
|
868
|
+
const tplAssociationName = normalizeStr(tpl?.associationName);
|
|
869
|
+
if (tplDataSourceKey) {
|
|
870
|
+
params.dataSourceKey = tplDataSourceKey;
|
|
871
|
+
}
|
|
872
|
+
if (tplCollectionName) {
|
|
873
|
+
params.collectionName = tplCollectionName;
|
|
874
|
+
}
|
|
875
|
+
if (tplAssociationName) {
|
|
876
|
+
params.associationName = tplAssociationName;
|
|
877
|
+
} else if (params && typeof params === 'object' && 'associationName' in params) {
|
|
878
|
+
delete params.associationName;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// filterByTk/sourceId 也以模板为准:模板未提供时需要清理,避免从 Record action 默认值透传到 Collection 模板。
|
|
882
|
+
// sourceId 是否需要完全由模板的 hasSourceId 决定
|
|
883
|
+
const inferred = inferPopupTemplateMeta(ctx as any, tpl);
|
|
884
|
+
(params as any).popupTemplateHasFilterByTk = inferred.hasFilterByTk;
|
|
885
|
+
(params as any).popupTemplateHasSourceId = inferred.hasSourceId;
|
|
886
|
+
const applyTemplateParam = (key: 'filterByTk' | 'sourceId', hasInTemplate: boolean) => {
|
|
887
|
+
if (!hasInTemplate) {
|
|
888
|
+
if (params && typeof params === 'object' && key in params) {
|
|
889
|
+
delete (params as any)[key];
|
|
890
|
+
}
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
const tvStr = normalizeStr((tpl as any)?.[key]);
|
|
894
|
+
if (tvStr) {
|
|
895
|
+
(params as any)[key] = tvStr;
|
|
896
|
+
}
|
|
897
|
+
};
|
|
898
|
+
applyTemplateParam('filterByTk', inferred.hasFilterByTk);
|
|
899
|
+
applyTemplateParam('sourceId', inferred.hasSourceId);
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
export function registerOpenViewPopupTemplateAction(flowEngine: FlowEngine) {
|
|
903
|
+
const base = flowEngine.getAction('openView') as ActionDefinition | undefined;
|
|
904
|
+
if (!base) return;
|
|
905
|
+
|
|
906
|
+
const baseUiSchema: any = (base as any).uiSchema || {};
|
|
907
|
+
const { mode, size, uid, ...rest } = baseUiSchema;
|
|
908
|
+
|
|
909
|
+
const enhanced: ActionDefinition = {
|
|
910
|
+
...(base as any),
|
|
911
|
+
uiSchema: {
|
|
912
|
+
...(mode ? { mode } : {}),
|
|
913
|
+
...(size ? { size } : {}),
|
|
914
|
+
popupTemplateUid: {
|
|
915
|
+
type: 'string',
|
|
916
|
+
title: `{{t("Popup template", { ns: ['${NAMESPACE}', 'client'], nsMode: 'fallback' })}}`,
|
|
917
|
+
'x-decorator': 'FormItem',
|
|
918
|
+
'x-component': PopupTemplateSelect,
|
|
919
|
+
},
|
|
920
|
+
...(uid ? { uid } : {}),
|
|
921
|
+
...rest,
|
|
922
|
+
},
|
|
923
|
+
|
|
924
|
+
async beforeParamsSave(ctx: FlowSettingsContext, params: any, previousParams: any) {
|
|
925
|
+
// 1) resolve template -> uid (in-place mutation so it will be persisted)
|
|
926
|
+
await resolveTemplateToUid(ctx, params);
|
|
927
|
+
// 2) delegate to original beforeParamsSave (strip template params to avoid leaking)
|
|
928
|
+
await base.beforeParamsSave(ctx, stripTemplateParams(params), previousParams);
|
|
929
|
+
},
|
|
930
|
+
|
|
931
|
+
async handler(ctx, params) {
|
|
932
|
+
// 模板信息(uid、dataSourceKey、collectionName、associationName)在配置保存时已填充到 params,
|
|
933
|
+
const templateUid = params.popupTemplateUid;
|
|
934
|
+
const hydratedMeta =
|
|
935
|
+
typeof templateUid === 'string' && templateUid.trim()
|
|
936
|
+
? await getPopupTemplateMeta(ctx as any, templateUid.trim())
|
|
937
|
+
: null;
|
|
938
|
+
|
|
939
|
+
// 根据模板元数据补充运行时标志
|
|
940
|
+
const runtimeParams = { ...params };
|
|
941
|
+
if (hydratedMeta) {
|
|
942
|
+
if (hydratedMeta.confidentFilterByTk) {
|
|
943
|
+
runtimeParams.popupTemplateHasFilterByTk = hydratedMeta.hasFilterByTk;
|
|
944
|
+
}
|
|
945
|
+
// 始终设置 hasSourceId,用于判断模板是否需要 sourceId
|
|
946
|
+
if (typeof hydratedMeta.hasSourceId === 'boolean') {
|
|
947
|
+
runtimeParams.popupTemplateHasSourceId = hydratedMeta.hasSourceId;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// 运行时兜底:非关系模板必须清除 associationName,避免把关系资源语义带入目标集合模板
|
|
951
|
+
const tplAssociationName = normalizeStr((hydratedMeta as any)?.tpl?.associationName);
|
|
952
|
+
if (
|
|
953
|
+
!tplAssociationName &&
|
|
954
|
+
runtimeParams &&
|
|
955
|
+
typeof runtimeParams === 'object' &&
|
|
956
|
+
'associationName' in runtimeParams
|
|
957
|
+
) {
|
|
958
|
+
delete (runtimeParams as any).associationName;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const shouldUseTemplateCtx =
|
|
963
|
+
(typeof templateUid === 'string' && templateUid.trim()) || !!(runtimeParams as any)?.popupTemplateContext;
|
|
964
|
+
const nextParams = stripTemplateParams(runtimeParams);
|
|
965
|
+
|
|
966
|
+
// 如果模板不需要 sourceId,从 nextParams 中删除 sourceId
|
|
967
|
+
// 避免关系字段上下文的 sourceId 被传递到不需要它的弹窗
|
|
968
|
+
// sourceId 是否需要保留完全由模板的 popupTemplateHasSourceId 决定
|
|
969
|
+
const templateNeedsSourceId =
|
|
970
|
+
hydratedMeta?.hasSourceId === true || !!(runtimeParams as any)?.popupTemplateHasSourceId;
|
|
971
|
+
if (!templateNeedsSourceId && nextParams && typeof nextParams === 'object') {
|
|
972
|
+
delete (nextParams as any).sourceId;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const runtimeCtx = shouldUseTemplateCtx ? await buildPopupTemplateShadowCtx(ctx, runtimeParams) : ctx;
|
|
976
|
+
return base.handler(runtimeCtx, nextParams);
|
|
977
|
+
},
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
flowEngine.registerActions({ openView: enhanced });
|
|
981
|
+
}
|