@nocobase/client-v2 2.1.0-beta.25 → 2.1.0-beta.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/es/flow/actions/linkageRulesFormValueRefresh.d.ts +10 -0
  2. package/es/flow/index.d.ts +1 -0
  3. package/es/flow/models/actions/AssociateActionModel.d.ts +19 -0
  4. package/es/flow/models/actions/AssociationActionUtils.d.ts +17 -0
  5. package/es/flow/models/actions/DisassociateActionModel.d.ts +16 -0
  6. package/es/flow/models/actions/index.d.ts +3 -0
  7. package/es/flow/models/base/GridModel.d.ts +3 -1
  8. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +1 -0
  9. package/es/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.d.ts +9 -0
  10. package/es/index.d.ts +1 -0
  11. package/es/index.mjs +80 -80
  12. package/lib/index.js +87 -87
  13. package/package.json +5 -5
  14. package/src/__tests__/globalDeps.test.ts +5 -0
  15. package/src/flow/actions/__tests__/linkageRules.formValueDrivenRefresh.test.ts +438 -0
  16. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +42 -0
  17. package/src/flow/actions/linkageRules.tsx +8 -1
  18. package/src/flow/actions/linkageRulesFormValueRefresh.ts +492 -0
  19. package/src/flow/actions/linkageRulesRefresh.tsx +4 -2
  20. package/src/flow/index.ts +1 -0
  21. package/src/flow/models/actions/AssociateActionModel.tsx +196 -0
  22. package/src/flow/models/actions/AssociationActionUtils.ts +90 -0
  23. package/src/flow/models/actions/DisassociateActionModel.tsx +57 -0
  24. package/src/flow/models/actions/__tests__/AssociationActionModel.test.ts +250 -0
  25. package/src/flow/models/actions/index.ts +3 -0
  26. package/src/flow/models/base/GridModel.tsx +21 -1
  27. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +98 -0
  28. package/src/flow/models/blocks/details/DetailsItemModel.tsx +3 -0
  29. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +5 -1
  30. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +21 -5
  31. package/src/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.ts +20 -0
  32. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +11 -3
  33. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +235 -0
  34. package/src/index.ts +1 -0
  35. package/src/utils/globalDeps.ts +6 -0
@@ -0,0 +1,492 @@
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 {
11
+ extractUsedVariablePathsFromRunJS,
12
+ FlowContext,
13
+ FlowModel,
14
+ FlowRuntimeContext,
15
+ isRunJSValue,
16
+ } from '@nocobase/flow-engine';
17
+ import _ from 'lodash';
18
+ import {
19
+ namePathToPathKey,
20
+ parsePathString,
21
+ pathKeyToNamePath,
22
+ } from '../models/blocks/form/value-runtime/path';
23
+ import {
24
+ collectStaticDepsFromRunJSValue,
25
+ collectStaticDepsFromTemplateValue,
26
+ recordDep,
27
+ type DepCollector,
28
+ } from '../models/blocks/form/value-runtime/deps';
29
+ import { linkageRulesRefresh } from './linkageRulesRefresh';
30
+
31
+ type NamePath = Array<string | number>;
32
+
33
+ type LinkageRefreshDeps = {
34
+ wildcard: boolean;
35
+ valuePaths: NamePath[];
36
+ structuralPaths: NamePath[];
37
+ };
38
+
39
+ type LinkageRefreshBinding = {
40
+ signature: string;
41
+ running: boolean;
42
+ linkageTxIds: Set<string>;
43
+ pendingPayload: any;
44
+ dispose: () => void;
45
+ };
46
+
47
+ type FieldIndexEntry = {
48
+ name: string;
49
+ index: number;
50
+ };
51
+
52
+ const FORM_VALUES_CHANGE_EVENT = 'formValuesChange';
53
+ const LINKAGE_REFRESH_BINDINGS_KEY = '__formValueDrivenLinkageRefreshBindings';
54
+
55
+ function isSameNamePath(a: NamePath, b: NamePath) {
56
+ return a.length === b.length && a.every((seg, index) => seg === b[index]);
57
+ }
58
+
59
+ function isNamePathPrefix(prefix: NamePath, path: NamePath) {
60
+ if (prefix.length > path.length) return false;
61
+ return prefix.every((seg, index) => seg === path[index]);
62
+ }
63
+
64
+ function dedupeNamePaths(paths: NamePath[]) {
65
+ const byKey = new Map<string, NamePath>();
66
+ for (const path of paths) {
67
+ if (!path?.length) continue;
68
+ byKey.set(namePathToPathKey(path), path);
69
+ }
70
+ return Array.from(byKey.values());
71
+ }
72
+
73
+ function minimizeValueNamePaths(paths: NamePath[]) {
74
+ const deduped = dedupeNamePaths(paths);
75
+ return deduped.filter((path, index) => {
76
+ return !deduped.some((other, otherIndex) => otherIndex !== index && isNamePathPrefix(path, other));
77
+ });
78
+ }
79
+
80
+ function parseFieldIndexEntries(fieldIndex: unknown): FieldIndexEntry[] {
81
+ const arr = Array.isArray(fieldIndex) ? fieldIndex : [];
82
+ const entries: FieldIndexEntry[] = [];
83
+ for (const it of arr) {
84
+ if (typeof it !== 'string') continue;
85
+ const [name, indexStr] = it.split(':');
86
+ const index = Number(indexStr);
87
+ if (!name || Number.isNaN(index)) continue;
88
+ entries.push({ name, index });
89
+ }
90
+ return entries;
91
+ }
92
+
93
+ function getFieldIndexEntriesFromContext(ctx: any): FieldIndexEntry[] {
94
+ return parseFieldIndexEntries(ctx?.model?.context?.fieldIndex ?? ctx?.fieldIndex);
95
+ }
96
+
97
+ function buildItemRowPath(entries: FieldIndexEntry[], parentDepth: number): NamePath | null {
98
+ const targetIndex = entries.length - 1 - parentDepth;
99
+ if (targetIndex < 0) return null;
100
+
101
+ const out: NamePath = [];
102
+ for (let i = 0; i <= targetIndex; i++) {
103
+ out.push(entries[i].name, entries[i].index);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ function buildItemListRootPath(entries: FieldIndexEntry[], parentDepth: number): NamePath | null {
109
+ const rowPath = buildItemRowPath(entries, parentDepth);
110
+ if (!rowPath?.length) return null;
111
+ return rowPath.slice(0, -1);
112
+ }
113
+
114
+ function resolveItemDependencyPath(ctx: FlowContext, depPath: NamePath): LinkageRefreshDeps {
115
+ const entries = getFieldIndexEntriesFromContext(ctx as any);
116
+ if (!entries.length) {
117
+ return { wildcard: true, valuePaths: [], structuralPaths: [] };
118
+ }
119
+
120
+ let parentDepth = 0;
121
+ let cursor = [...depPath];
122
+ while (cursor[0] === 'parentItem') {
123
+ parentDepth += 1;
124
+ cursor = cursor.slice(1);
125
+ }
126
+
127
+ const head = cursor[0];
128
+ if (!head) {
129
+ return { wildcard: true, valuePaths: [], structuralPaths: [] };
130
+ }
131
+
132
+ if (head === 'value') {
133
+ const rowPath = buildItemRowPath(entries, parentDepth);
134
+ if (!rowPath) return { wildcard: true, valuePaths: [], structuralPaths: [] };
135
+ const listRootPath = buildItemListRootPath(entries, parentDepth);
136
+ return {
137
+ wildcard: false,
138
+ valuePaths: [[...rowPath, ...cursor.slice(1)]],
139
+ structuralPaths: listRootPath ? [listRootPath] : [],
140
+ };
141
+ }
142
+
143
+ if (head === 'index' || head === 'length') {
144
+ const listRootPath = buildItemListRootPath(entries, parentDepth);
145
+ if (!listRootPath) return { wildcard: true, valuePaths: [], structuralPaths: [] };
146
+ return {
147
+ wildcard: false,
148
+ valuePaths: [],
149
+ structuralPaths: [listRootPath],
150
+ };
151
+ }
152
+
153
+ if (head === '__is_new__' || head === '__is_stored__') {
154
+ const rowPath = buildItemRowPath(entries, parentDepth);
155
+ if (!rowPath) return { wildcard: true, valuePaths: [], structuralPaths: [] };
156
+ return {
157
+ wildcard: false,
158
+ valuePaths: [[...rowPath, head]],
159
+ structuralPaths: [],
160
+ };
161
+ }
162
+
163
+ return { wildcard: true, valuePaths: [], structuralPaths: [] };
164
+ }
165
+
166
+ function addRunjsUsageToCollector(script: string, collector: DepCollector) {
167
+ if (typeof script !== 'string' || !script.trim()) return;
168
+ const usage = extractUsedVariablePathsFromRunJS(script) || {};
169
+ for (const [varName, rawPaths] of Object.entries(usage)) {
170
+ const paths = Array.isArray(rawPaths) ? rawPaths : [];
171
+ const normalized = paths.length ? paths : [''];
172
+ for (const subPath of normalized) {
173
+ if (varName === 'formValues') {
174
+ if (!subPath) {
175
+ collector.wildcard = true;
176
+ continue;
177
+ }
178
+ const segs = parsePathString(String(subPath)).filter((seg) => typeof seg !== 'object') as NamePath;
179
+ recordDep(segs, collector);
180
+ continue;
181
+ }
182
+ if (varName === 'item') {
183
+ const key = subPath ? `ctx:item:${String(subPath)}` : 'ctx:item';
184
+ collector.deps.add(key);
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ function collectRunjsDepsFromLinkageRules(params: any, collector: DepCollector) {
191
+ const seen = new WeakSet<object>();
192
+ const visit = (value: any) => {
193
+ if (!value || typeof value !== 'object') return;
194
+ if (seen.has(value)) return;
195
+ seen.add(value);
196
+
197
+ if (isRunJSValue(value)) {
198
+ collectStaticDepsFromRunJSValue(value, collector);
199
+ }
200
+
201
+ if (Array.isArray(value)) {
202
+ value.forEach(visit);
203
+ return;
204
+ }
205
+
206
+ const actionName = (value as any)?.name;
207
+ if (actionName === 'linkageRunjs' || actionName === 'runjs') {
208
+ addRunjsUsageToCollector(_.get(value, ['params', 'value', 'script']), collector);
209
+ addRunjsUsageToCollector(_.get(value, ['params', 'code']), collector);
210
+ }
211
+
212
+ Object.values(value).forEach(visit);
213
+ };
214
+
215
+ visit(params);
216
+ }
217
+
218
+ function collectLinkageRefreshDeps(ctx: FlowContext, params: any): LinkageRefreshDeps {
219
+ const collector: DepCollector = { deps: new Set(), wildcard: false };
220
+
221
+ collectStaticDepsFromTemplateValue(params, collector);
222
+ collectRunjsDepsFromLinkageRules(params, collector);
223
+
224
+ const valuePaths: NamePath[] = [];
225
+ const structuralPaths: NamePath[] = [];
226
+ let wildcard = collector.wildcard;
227
+
228
+ for (const depKey of collector.deps) {
229
+ if (depKey === 'fv:*') {
230
+ wildcard = true;
231
+ continue;
232
+ }
233
+
234
+ if (depKey.startsWith('fv:')) {
235
+ const inner = depKey.slice('fv:'.length);
236
+ if (!inner) {
237
+ wildcard = true;
238
+ continue;
239
+ }
240
+ valuePaths.push(pathKeyToNamePath(inner));
241
+ continue;
242
+ }
243
+
244
+ if (depKey === 'ctx:item' || depKey.startsWith('ctx:item:')) {
245
+ const subPath = depKey === 'ctx:item' ? '' : depKey.slice('ctx:item:'.length);
246
+ const depPath = subPath
247
+ ? (parsePathString(subPath).filter((seg) => typeof seg !== 'object') as NamePath)
248
+ : [];
249
+ const resolved = resolveItemDependencyPath(ctx, depPath);
250
+ wildcard ||= resolved.wildcard;
251
+ valuePaths.push(...resolved.valuePaths);
252
+ structuralPaths.push(...resolved.structuralPaths);
253
+ }
254
+ }
255
+
256
+ return {
257
+ wildcard,
258
+ valuePaths: minimizeValueNamePaths(valuePaths),
259
+ structuralPaths: dedupeNamePaths(structuralPaths),
260
+ };
261
+ }
262
+
263
+ function hasLinkageRefreshDeps(deps: LinkageRefreshDeps) {
264
+ return deps.wildcard || deps.valuePaths.length > 0 || deps.structuralPaths.length > 0;
265
+ }
266
+
267
+ function getChangedPathsFromPayload(payload: any): NamePath[] {
268
+ const rawChangedPaths = Array.isArray(payload?.changedPaths) ? payload.changedPaths : [];
269
+ const out: NamePath[] = [];
270
+
271
+ for (const path of rawChangedPaths) {
272
+ if (Array.isArray(path)) {
273
+ if (path.length === 1 && typeof path[0] === 'string') {
274
+ const namePath = pathKeyToNamePath(path[0]);
275
+ if (namePath.length) out.push(namePath);
276
+ continue;
277
+ }
278
+ const segs = path.filter((seg) => typeof seg === 'string' || typeof seg === 'number') as NamePath;
279
+ if (segs.length) out.push(segs);
280
+ continue;
281
+ }
282
+ if (typeof path === 'string' && path) {
283
+ out.push(pathKeyToNamePath(path));
284
+ }
285
+ }
286
+
287
+ if (out.length) {
288
+ return out;
289
+ }
290
+
291
+ const changedValues = payload?.changedValues;
292
+ if (changedValues && typeof changedValues === 'object') {
293
+ for (const key of Object.keys(changedValues)) {
294
+ const namePath = pathKeyToNamePath(key);
295
+ if (namePath.length) out.push(namePath);
296
+ }
297
+ }
298
+
299
+ return out;
300
+ }
301
+
302
+ function linkageRefreshDepsMatchPayload(deps: LinkageRefreshDeps, payload: any) {
303
+ if (!hasLinkageRefreshDeps(deps)) return false;
304
+ const changedPaths = getChangedPathsFromPayload(payload);
305
+ if (deps.wildcard) return true;
306
+ if (!changedPaths.length) return true;
307
+
308
+ for (const changedPath of changedPaths) {
309
+ for (const depPath of deps.valuePaths) {
310
+ if (isNamePathPrefix(depPath, changedPath) || isNamePathPrefix(changedPath, depPath)) {
311
+ return true;
312
+ }
313
+ }
314
+
315
+ for (const depPath of deps.structuralPaths) {
316
+ if (isSameNamePath(depPath, changedPath) || isNamePathPrefix(changedPath, depPath)) {
317
+ return true;
318
+ }
319
+ }
320
+ }
321
+
322
+ return false;
323
+ }
324
+
325
+ function getDepsSignature(deps: LinkageRefreshDeps, formBlock: any) {
326
+ const toKeys = (paths: NamePath[]) => paths.map((path) => namePathToPathKey(path)).sort();
327
+ return JSON.stringify({
328
+ formBlockUid: formBlock?.uid,
329
+ wildcard: deps.wildcard,
330
+ valuePaths: toKeys(deps.valuePaths),
331
+ structuralPaths: toKeys(deps.structuralPaths),
332
+ });
333
+ }
334
+
335
+ function getLinkageRefreshBindings(model: any): Map<string, LinkageRefreshBinding> {
336
+ return (model[LINKAGE_REFRESH_BINDINGS_KEY] ||= new Map<string, LinkageRefreshBinding>());
337
+ }
338
+
339
+ function isFormBlockForLinkageRefresh(model: any) {
340
+ if (!model || typeof model !== 'object') return false;
341
+ if (!model.emitter || typeof model.emitter.on !== 'function' || typeof model.emitter.off !== 'function') return false;
342
+ return !!model.formValueRuntime || !!model.context?.form || typeof model.context?.setFormValues === 'function';
343
+ }
344
+
345
+ function findFormBlockForLinkageRefresh(ctx: FlowContext): any | null {
346
+ const candidates: any[] = [];
347
+ const push = (model: any) => {
348
+ if (model && !candidates.includes(model)) candidates.push(model);
349
+ };
350
+
351
+ push((ctx.model as any)?.context?.blockModel);
352
+ push(ctx.model);
353
+
354
+ let cursor: any = (ctx.model as any)?.parent;
355
+ while (cursor) {
356
+ push(cursor);
357
+ cursor = cursor?.parent;
358
+ }
359
+
360
+ return candidates.find(isFormBlockForLinkageRefresh) || null;
361
+ }
362
+
363
+ function disposeLinkageRefreshBinding(model: any, key: string) {
364
+ const bindings = getLinkageRefreshBindings(model);
365
+ const existing = bindings.get(key);
366
+ if (existing) {
367
+ existing.dispose();
368
+ }
369
+ }
370
+
371
+ export function ensureFormValueDrivenLinkageRefresh(ctx: FlowContext, params: any, actionName: string) {
372
+ const model: any = ctx.model;
373
+ const flowKey = (ctx as any)?.flowKey;
374
+ if (!model || !flowKey) return;
375
+
376
+ const stepKey = 'linkageRules';
377
+ const bindingKey = `${flowKey}:${stepKey}:${actionName}`;
378
+ const bindings = getLinkageRefreshBindings(model);
379
+ const deps = collectLinkageRefreshDeps(ctx, params);
380
+ if (!hasLinkageRefreshDeps(deps)) {
381
+ disposeLinkageRefreshBinding(model, bindingKey);
382
+ return;
383
+ }
384
+
385
+ const formBlock = findFormBlockForLinkageRefresh(ctx);
386
+ if (!formBlock) {
387
+ disposeLinkageRefreshBinding(model, bindingKey);
388
+ return;
389
+ }
390
+
391
+ const signature = getDepsSignature(deps, formBlock);
392
+ const existing = bindings.get(bindingKey);
393
+ if (existing?.signature === signature) {
394
+ return;
395
+ }
396
+ if (existing) {
397
+ existing.dispose();
398
+ }
399
+
400
+ const binding: LinkageRefreshBinding = {
401
+ signature,
402
+ running: false,
403
+ linkageTxIds: new Set(),
404
+ pendingPayload: null,
405
+ dispose: () => {},
406
+ };
407
+
408
+ const engineEmitter = model?.flowEngine?.emitter || (ctx as any)?.engine?.emitter || model?.context?.engine?.emitter;
409
+
410
+ const dispose = () => {
411
+ formBlock.emitter?.off?.(FORM_VALUES_CHANGE_EVENT, listener);
412
+ engineEmitter?.off?.('model:unmounted', cleanupOnUnmount);
413
+ engineEmitter?.off?.('model:destroyed', cleanupOnDestroyed);
414
+ if (bindings.get(bindingKey) === binding) {
415
+ bindings.delete(bindingKey);
416
+ }
417
+ };
418
+
419
+ const rememberLinkageTxId = (linkageTxId: unknown) => {
420
+ if (typeof linkageTxId !== 'string' || !linkageTxId) return;
421
+ binding.linkageTxIds.add(linkageTxId);
422
+ if (binding.linkageTxIds.size <= 20) return;
423
+ const oldest = binding.linkageTxIds.values().next().value;
424
+ if (oldest) binding.linkageTxIds.delete(oldest);
425
+ };
426
+
427
+ const listener = (payload: any) => {
428
+ const payloadLinkageTxId = typeof payload?.linkageTxId === 'string' ? payload.linkageTxId : undefined;
429
+ if (payload?.source === 'linkage' && payloadLinkageTxId && binding.linkageTxIds.has(payloadLinkageTxId)) {
430
+ return;
431
+ }
432
+ if (model.disposed || formBlock.disposed) {
433
+ dispose();
434
+ return;
435
+ }
436
+ const latestDeps = collectLinkageRefreshDeps(ctx, params);
437
+ if (!linkageRefreshDepsMatchPayload(latestDeps, payload)) return;
438
+ if (binding.running) {
439
+ binding.pendingPayload = payload;
440
+ return;
441
+ }
442
+
443
+ const refreshLinkageTxId = payloadLinkageTxId || (typeof payload?.txId === 'string' ? payload.txId : undefined);
444
+ rememberLinkageTxId(refreshLinkageTxId);
445
+ binding.running = true;
446
+ const refreshCtx = new FlowRuntimeContext(model, flowKey);
447
+ refreshCtx.defineProperty('inputArgs', {
448
+ value: {
449
+ ...(payload || {}),
450
+ linkageTxId: refreshLinkageTxId,
451
+ },
452
+ });
453
+
454
+ void linkageRulesRefresh
455
+ .handler(refreshCtx, {
456
+ actionName,
457
+ flowKey,
458
+ stepKey,
459
+ })
460
+ .catch((error) => {
461
+ console.warn('[linkageRules] Failed to refresh form value driven linkage rules', error);
462
+ })
463
+ .finally(() => {
464
+ binding.running = false;
465
+ const pendingPayload = binding.pendingPayload;
466
+ binding.pendingPayload = null;
467
+ if (pendingPayload) {
468
+ listener(pendingPayload);
469
+ }
470
+ });
471
+ };
472
+
473
+ const cleanupOnUnmount = ({ model: unmountedModel }: { model: FlowModel }) => {
474
+ // Action linkage may hide the action itself, which unmounts its renderer.
475
+ // Keep the watcher alive so later form changes can restore the action state.
476
+ if (unmountedModel === formBlock || (unmountedModel === model && model.disposed)) {
477
+ dispose();
478
+ }
479
+ };
480
+
481
+ const cleanupOnDestroyed = ({ model: destroyedModel }: { model: FlowModel }) => {
482
+ if (destroyedModel === model || destroyedModel === formBlock) {
483
+ dispose();
484
+ }
485
+ };
486
+
487
+ binding.dispose = dispose;
488
+ bindings.set(bindingKey, binding);
489
+ formBlock.emitter.on(FORM_VALUES_CHANGE_EVENT, listener);
490
+ engineEmitter?.on?.('model:unmounted', cleanupOnUnmount);
491
+ engineEmitter?.on?.('model:destroyed', cleanupOnDestroyed);
492
+ }
@@ -65,10 +65,12 @@ export const linkageRulesRefresh = defineAction({
65
65
 
66
66
  const run = (async () => {
67
67
  const raw = model?.getStepParams?.(flowKey, stepKey);
68
- const resolved = await ctx.resolveJsonTemplate({ value: [], ...(raw || {}) });
69
68
  const action = ctx.getAction?.(actionName);
69
+ const paramsForAction = action?.useRawParams
70
+ ? { value: [], ...(raw || {}) }
71
+ : await ctx.resolveJsonTemplate({ value: [], ...(raw || {}) });
70
72
  if (action?.handler) {
71
- await action.handler(ctx, resolved);
73
+ await action.handler(ctx, paramsForAction);
72
74
  }
73
75
  })();
74
76
 
package/src/flow/index.ts CHANGED
@@ -104,5 +104,6 @@ export * from './admin-shell/AdminLayoutRouteCoordinator';
104
104
  export * from '../settings-center';
105
105
  export { openViewFlow } from './flows/openViewFlow';
106
106
  export { editMarkdownFlow } from './flows/editMarkdownFlow';
107
+ export { resolveDynamicNamePath } from './models/blocks/form/value-runtime/path';
107
108
 
108
109
  export { TextAreaWithContextSelector } from './components/TextAreaWithContextSelector';
@@ -0,0 +1,196 @@
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 { FlowModel, FlowModelRenderer, tExpr, useFlowViewContext } from '@nocobase/flow-engine';
11
+ import { useRequest } from 'ahooks';
12
+ import { Button } from 'antd';
13
+ import type { ButtonProps } from 'antd';
14
+ import React from 'react';
15
+ import { SkeletonFallback } from '../../components/SkeletonFallback';
16
+ import { ActionModel, ActionSceneEnum } from '../base';
17
+ import {
18
+ applyAssociateAction,
19
+ getAssociationTargetResourceSettings,
20
+ isAssociationBlockContext,
21
+ } from './AssociationActionUtils';
22
+
23
+ function AssociateSelectorGridRenderer({ options }: { options: any }) {
24
+ const ctx = useFlowViewContext();
25
+ const { data, loading } = useRequest(
26
+ async () => {
27
+ return await ctx.engine.loadOrCreateModel(options, {
28
+ delegateToParent: false,
29
+ delegate: ctx,
30
+ skipSave: !ctx.flowSettingsEnabled,
31
+ });
32
+ },
33
+ {
34
+ refreshDeps: [ctx, options],
35
+ },
36
+ );
37
+
38
+ if (loading || !data?.uid) {
39
+ return <SkeletonFallback style={{ margin: 16 }} />;
40
+ }
41
+ return <FlowModelRenderer model={data as FlowModel} fallback={<SkeletonFallback style={{ margin: 16 }} />} />;
42
+ }
43
+
44
+ function AssociateSelectorContent({ model }: { model: AssociateActionModel }) {
45
+ const ctx = useFlowViewContext();
46
+ const { Header, Footer, type } = ctx.view;
47
+ return (
48
+ <div>
49
+ <Header
50
+ title={
51
+ type === 'dialog' ? (
52
+ <div
53
+ style={{
54
+ padding: `${ctx.themeToken.paddingLG}px ${ctx.themeToken.paddingLG}px 0`,
55
+ marginBottom: -ctx.themeToken.marginSM,
56
+ backgroundColor: 'var(--colorBgLayout)',
57
+ }}
58
+ >
59
+ {ctx.t('Select record')}
60
+ </div>
61
+ ) : (
62
+ ctx.t('Select record')
63
+ )
64
+ }
65
+ />
66
+ <AssociateSelectorGridRenderer
67
+ options={{
68
+ parentId: ctx.view.inputArgs.parentId,
69
+ subKey: 'associate-selector-grid',
70
+ async: true,
71
+ delegateToParent: false,
72
+ subType: 'object',
73
+ use: 'BlockGridModel',
74
+ }}
75
+ />
76
+ <Footer>
77
+ {type === 'dialog' ? (
78
+ <div style={{ padding: `0 ${ctx.themeToken.paddingLG}px ${ctx.themeToken.paddingLG}px` }}>
79
+ <Button
80
+ type="primary"
81
+ onClick={async () => {
82
+ await model.associateSelectedRows();
83
+ ctx.view.close();
84
+ }}
85
+ >
86
+ {ctx.t('Submit')}
87
+ </Button>
88
+ </div>
89
+ ) : (
90
+ <Button
91
+ type="primary"
92
+ onClick={async () => {
93
+ await model.associateSelectedRows();
94
+ ctx.view.close();
95
+ }}
96
+ >
97
+ {ctx.t('Submit')}
98
+ </Button>
99
+ )}
100
+ </Footer>
101
+ </div>
102
+ );
103
+ }
104
+
105
+ export class AssociateActionModel extends ActionModel {
106
+ static scene = ActionSceneEnum.collection;
107
+ static capabilityActionName = 'update';
108
+
109
+ defaultPopupTitle = tExpr('Select record');
110
+ selectedRows: any[] = [];
111
+
112
+ defaultProps: ButtonProps = {
113
+ title: tExpr('Associate'),
114
+ icon: 'LinkOutlined',
115
+ };
116
+
117
+ getAclActionName() {
118
+ return 'update';
119
+ }
120
+
121
+ async associateSelectedRows() {
122
+ await applyAssociateAction(this.context, this.selectedRows);
123
+ this.selectedRows = [];
124
+ }
125
+ }
126
+
127
+ AssociateActionModel.define({
128
+ label: tExpr('Associate'),
129
+ sort: 15,
130
+ hide(ctx) {
131
+ return !isAssociationBlockContext(ctx);
132
+ },
133
+ });
134
+
135
+ AssociateActionModel.registerFlow({
136
+ key: 'associateSettings',
137
+ title: tExpr('Associate settings'),
138
+ on: 'click',
139
+ steps: {
140
+ openSelector: {
141
+ async handler(ctx, params) {
142
+ const blockModel = ctx.blockModel;
143
+ const targetResourceSettings = getAssociationTargetResourceSettings(ctx);
144
+ const openMode = ctx.inputArgs?.isMobileLayout ? 'embed' : ctx.inputArgs?.mode || params?.mode || 'drawer';
145
+ const size = ctx.inputArgs?.size || params?.size || 'medium';
146
+ const sizeToWidthMap: Record<string, Record<string, string | undefined>> = {
147
+ drawer: {
148
+ small: '30%',
149
+ medium: '50%',
150
+ large: '70%',
151
+ },
152
+ dialog: {
153
+ small: '40%',
154
+ medium: '50%',
155
+ large: '80%',
156
+ },
157
+ embed: {},
158
+ };
159
+
160
+ ctx.model.selectedRows = [];
161
+ await ctx.viewer.open({
162
+ type: openMode,
163
+ width: sizeToWidthMap[openMode][size],
164
+ inheritContext: false,
165
+ target: ctx.layoutContentElement,
166
+ inputArgs: {
167
+ parentId: ctx.model.uid,
168
+ scene: 'select',
169
+ dataSourceKey: targetResourceSettings.dataSourceKey,
170
+ collectionName: targetResourceSettings.collectionName,
171
+ rowSelectionProps: {
172
+ type: 'checkbox',
173
+ defaultSelectedRows: () => blockModel?.resource?.getData?.() || [],
174
+ renderCell: undefined,
175
+ selectedRowKeys: undefined,
176
+ onChange: (_, selectedRows) => {
177
+ ctx.model.selectedRows = selectedRows || [];
178
+ },
179
+ },
180
+ },
181
+ content: () => <AssociateSelectorContent model={ctx.model as AssociateActionModel} />,
182
+ styles: {
183
+ content: {
184
+ padding: 0,
185
+ backgroundColor: ctx.model.flowEngine.context.themeToken.colorBgLayout,
186
+ ...(openMode === 'embed' ? { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 } : {}),
187
+ },
188
+ body: {
189
+ padding: 0,
190
+ },
191
+ },
192
+ });
193
+ },
194
+ },
195
+ },
196
+ });