@nocobase/plugin-ui-templates 2.0.0-alpha.58 → 2.0.0-alpha.60

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.
@@ -40,11 +40,6 @@ describe('SubModelTemplateImporterModel', () => {
40
40
  parentId: grid.uid,
41
41
  subKey: 'items',
42
42
  subType: 'array',
43
- props: {
44
- mountToParentLevel: 1,
45
- defaultSourcePath: 'subModels.grid',
46
- defaultMountSubKey: 'grid',
47
- },
48
43
  });
49
44
  importer.setParent(grid);
50
45
 
@@ -76,8 +71,6 @@ describe('SubModelTemplateImporterModel', () => {
76
71
  expect(saved.mode).toBe('reference');
77
72
  expect(saved.targetUid).toBe('tpl-root');
78
73
  expect(saved.templateName).toBe('Template 1');
79
- expect(saved.targetPath).toBe('subModels.grid');
80
- expect(saved.mountSubKey).toBe('grid');
81
74
  });
82
75
 
83
76
  it('filters by expectedRootUse and disables mismatched dataSource/collection', async () => {
@@ -307,11 +300,6 @@ describe('SubModelTemplateImporterModel', () => {
307
300
  parentId: grid.uid,
308
301
  subKey: 'items',
309
302
  subType: 'array',
310
- props: {
311
- mountToParentLevel: 1,
312
- defaultSourcePath: 'subModels.grid',
313
- defaultMountSubKey: 'grid',
314
- },
315
303
  });
316
304
  grid.addSubModel('items', importer);
317
305
 
@@ -342,6 +330,86 @@ describe('SubModelTemplateImporterModel', () => {
342
330
  expect(view.close).not.toHaveBeenCalled();
343
331
  });
344
332
 
333
+ it('prompts confirm when details grid has existing items', async () => {
334
+ const engine = new FlowEngine();
335
+
336
+ class DetailsBlockModel extends FlowModel {}
337
+ class DetailsGridModel extends FlowModel {}
338
+ class DummyItemModel extends FlowModel {}
339
+ class FormItemModel extends FlowModel {}
340
+ class FormCustomItemModel extends FlowModel {}
341
+ class FormJSFieldItemModel extends FlowModel {}
342
+
343
+ engine.registerModels({
344
+ DetailsBlockModel,
345
+ DetailsGridModel,
346
+ DummyItemModel,
347
+ FormItemModel,
348
+ FormCustomItemModel,
349
+ FormJSFieldItemModel,
350
+ SubModelTemplateImporterModel,
351
+ });
352
+
353
+ const block = engine.createModel<DetailsBlockModel>({ uid: 'host-details', use: 'DetailsBlockModel' });
354
+ const grid = engine.createModel<DetailsGridModel>({
355
+ uid: 'host-grid',
356
+ use: 'DetailsGridModel',
357
+ parentId: block.uid,
358
+ subKey: 'grid',
359
+ subType: 'object',
360
+ });
361
+ block.setSubModel('grid', grid);
362
+
363
+ const dummy = engine.createModel<DummyItemModel>({
364
+ uid: 'dummy-item',
365
+ use: 'DummyItemModel',
366
+ parentId: grid.uid,
367
+ subKey: 'items',
368
+ subType: 'array',
369
+ });
370
+ grid.addSubModel('items', dummy);
371
+
372
+ const importer = engine.createModel<SubModelTemplateImporterModel>({
373
+ uid: 'importer-1',
374
+ use: 'SubModelTemplateImporterModel',
375
+ parentId: grid.uid,
376
+ subKey: 'items',
377
+ subType: 'array',
378
+ });
379
+ grid.addSubModel('items', importer);
380
+
381
+ const flow: any = importer.getFlow('subModelTemplateImportSettings');
382
+ const step: any = flow?.getStep?.('selectTemplate')?.serialize?.();
383
+ expect(typeof step?.beforeParamsSave).toBe('function');
384
+
385
+ const viewer = {
386
+ dialog: vi.fn((opts: any) => {
387
+ // 默认模拟用户关闭弹窗 => 视为取消
388
+ opts?.onClose?.();
389
+ }),
390
+ };
391
+ const view = { close: vi.fn() };
392
+ const ctx: any = {
393
+ model: importer,
394
+ api: {
395
+ resource: () => ({
396
+ get: async () => ({ data: { data: { uid: 'tpl-1', name: 'Template 1', targetUid: 'tpl-root' } } }),
397
+ }),
398
+ },
399
+ viewer,
400
+ view,
401
+ t: (k: string) => k,
402
+ message: { warning: vi.fn(), error: vi.fn() },
403
+ };
404
+
405
+ const params: any = { templateUid: 'tpl-1', mode: 'reference' };
406
+ importer.setStepParams('subModelTemplateImportSettings', 'selectTemplate', params);
407
+ await expect(step.beforeParamsSave(ctx, params)).rejects.toBeTruthy();
408
+
409
+ expect(view.close).toHaveBeenCalled();
410
+ expect(viewer.dialog).toHaveBeenCalled();
411
+ });
412
+
345
413
  it('waits parent grid saveStepParams before replaceModel save', async () => {
346
414
  const engine = new FlowEngine();
347
415
 
@@ -411,11 +479,6 @@ describe('SubModelTemplateImporterModel', () => {
411
479
  parentId: grid.uid,
412
480
  subKey: 'items',
413
481
  subType: 'array',
414
- props: {
415
- mountToParentLevel: 1,
416
- defaultSourcePath: 'subModels.grid',
417
- defaultMountSubKey: 'grid',
418
- },
419
482
  stepParams: {
420
483
  subModelTemplateImportSettings: {
421
484
  selectTemplate: {
@@ -423,8 +486,6 @@ describe('SubModelTemplateImporterModel', () => {
423
486
  targetUid: 'tpl-root',
424
487
  templateName: 'Template 1',
425
488
  mode: 'reference',
426
- targetPath: 'subModels.grid',
427
- mountSubKey: 'grid',
428
489
  },
429
490
  },
430
491
  },
@@ -25,6 +25,14 @@ export function ensureBlockScopedEngine(flowEngine: FlowEngine, scopedEngine?: F
25
25
  return scopedEngine ?? createBlockScopedEngine(flowEngine);
26
26
  }
27
27
 
28
+ export function ensureScopedEngineView(engine: FlowEngine, hostContext?: FlowContext): void {
29
+ if (!engine?.context || !hostContext) return;
30
+ engine.context.defineProperty('view', {
31
+ cache: false,
32
+ get: () => hostContext.view,
33
+ });
34
+ }
35
+
28
36
  export function unlinkScopedEngine(engine?: FlowEngine): void {
29
37
  engine?.unlinkFromStack?.();
30
38
  }
@@ -0,0 +1,67 @@
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 { describe, expect, it } from 'vitest';
11
+ import { FlowEngine, FlowModel } from '@nocobase/flow-engine';
12
+ import { patchGridOptionsFromTemplateRoot } from '../templateCopy';
13
+
14
+ describe('patchGridOptionsFromTemplateRoot', () => {
15
+ it('fills missing layout/linkageRules from template root', () => {
16
+ const engine = new FlowEngine();
17
+ class RootModel extends FlowModel {}
18
+ engine.registerModels({ RootModel });
19
+
20
+ const root = engine.createModel<RootModel>({
21
+ uid: 'tpl-root',
22
+ use: 'RootModel',
23
+ stepParams: {
24
+ formModelSettings: { layout: { layout: 'horizontal', labelWidth: 160 } },
25
+ eventSettings: { linkageRules: { value: [{ key: 'r1' }] } },
26
+ },
27
+ });
28
+
29
+ const gridOptions: any = { uid: 'dup-grid', use: 'GridModel', stepParams: {} };
30
+ const merged = patchGridOptionsFromTemplateRoot(root, gridOptions);
31
+ expect(merged.patched).toBe(true);
32
+ expect(merged.options.stepParams.formModelSettings.layout).toEqual({ layout: 'horizontal', labelWidth: 160 });
33
+ expect(merged.options.stepParams.eventSettings.linkageRules).toEqual({ value: [{ key: 'r1' }] });
34
+
35
+ // should be deep-cloned (mutating merged options must not affect template root)
36
+ merged.options.stepParams.formModelSettings.layout.layout = 'vertical';
37
+ expect(root.getStepParams('formModelSettings', 'layout')).toEqual({ layout: 'horizontal', labelWidth: 160 });
38
+ });
39
+
40
+ it('does not override existing grid values', () => {
41
+ const engine = new FlowEngine();
42
+ class RootModel extends FlowModel {}
43
+ engine.registerModels({ RootModel });
44
+
45
+ const root = engine.createModel<RootModel>({
46
+ uid: 'tpl-root',
47
+ use: 'RootModel',
48
+ stepParams: {
49
+ formModelSettings: { layout: { layout: 'horizontal', labelWidth: 160 } },
50
+ eventSettings: { linkageRules: { value: [{ key: 'r1' }] } },
51
+ },
52
+ });
53
+
54
+ const gridOptions: any = {
55
+ uid: 'dup-grid',
56
+ use: 'GridModel',
57
+ stepParams: {
58
+ formModelSettings: { layout: { layout: 'vertical', labelWidth: 120 } },
59
+ eventSettings: { linkageRules: { value: [] } },
60
+ },
61
+ };
62
+ const merged = patchGridOptionsFromTemplateRoot(root, gridOptions);
63
+ expect(merged.patched).toBe(false);
64
+ expect(merged.options.stepParams.formModelSettings.layout).toEqual({ layout: 'vertical', labelWidth: 120 });
65
+ expect(merged.options.stepParams.eventSettings.linkageRules).toEqual({ value: [] });
66
+ });
67
+ });
@@ -0,0 +1,59 @@
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 _ from 'lodash';
11
+ import { FlowModel } from '@nocobase/flow-engine';
12
+
13
+ const DELEGATED_STEP_PARAMS: Array<{ flowKey: string; stepKey: string }> = [
14
+ { flowKey: 'formModelSettings', stepKey: 'layout' },
15
+ { flowKey: 'eventSettings', stepKey: 'linkageRules' },
16
+ ];
17
+
18
+ function isPlainObject(val: unknown): val is Record<string, any> {
19
+ return !!val && typeof val === 'object' && !Array.isArray(val);
20
+ }
21
+
22
+ /**
23
+ * 字段模板 copy 模式只会 duplicate `subModels.grid`,而历史模板可能把部分配置(如布局/连动规则)
24
+ * 存在模板 root 上。reference 模式通过 getStepParams 的 fallback 能读到这些值,但 copy 模式需要
25
+ * 主动把这些 stepParams 合并进 grid 的 stepParams,避免丢失。
26
+ *
27
+ * - 仅在 grid 上缺失对应 stepKey 时才回填(不覆盖 grid 上已有值)
28
+ * - 返回 patched 标记,方便调用方决定是否需要 `saveStepParams()`
29
+ */
30
+ export function patchGridOptionsFromTemplateRoot(
31
+ templateRoot: FlowModel | undefined,
32
+ gridOptions: any,
33
+ ): { options: any; patched: boolean } {
34
+ if (!templateRoot || !isPlainObject(gridOptions)) {
35
+ return { options: gridOptions, patched: false };
36
+ }
37
+
38
+ const baseStepParams = isPlainObject(gridOptions.stepParams) ? (gridOptions.stepParams as Record<string, any>) : {};
39
+ const nextStepParams: Record<string, any> = { ...baseStepParams };
40
+ let patched = false;
41
+
42
+ for (const { flowKey, stepKey } of DELEGATED_STEP_PARAMS) {
43
+ const val = templateRoot.getStepParams?.(flowKey, stepKey);
44
+ if (typeof val === 'undefined') continue;
45
+
46
+ const flow = isPlainObject(nextStepParams[flowKey]) ? { ...(nextStepParams[flowKey] as any) } : {};
47
+ const has = Object.prototype.hasOwnProperty.call(flow, stepKey);
48
+ if (!has || typeof flow[stepKey] === 'undefined') {
49
+ flow[stepKey] = _.cloneDeep(val);
50
+ nextStepParams[flowKey] = flow;
51
+ patched = true;
52
+ }
53
+ }
54
+
55
+ if (!patched) {
56
+ return { options: gridOptions, patched: false };
57
+ }
58
+ return { options: { ...gridOptions, stepParams: nextStepParams }, patched: true };
59
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "UI templates": "界面模板",
2
+ "UI templates": "UI 模板",
3
3
  "Block templates (v2)": "区块模板 (v2)",
4
4
  "Popup templates (v2)": "弹窗模板 (v2)",
5
5
  "Actions": "操作",
@@ -1,10 +0,0 @@
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
- import type { FlowEngine } from '@nocobase/flow-engine';
10
- export declare function registerSubModelMenuExtensions(engine: FlowEngine): void;
@@ -1,20 +0,0 @@
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
- import type { FlowModel } from '@nocobase/flow-engine';
10
- export type RefHostInfo = {
11
- ref?: {
12
- mountSubKey?: unknown;
13
- mode?: unknown;
14
- [key: string]: unknown;
15
- };
16
- [key: string]: unknown;
17
- };
18
- export declare function findRefHostInfoFromAncestors(model: FlowModel, options?: {
19
- maxDepth?: number;
20
- }): RefHostInfo | undefined;
@@ -1,103 +0,0 @@
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 type { FlowEngine, FlowModel, FlowModelContext } from '@nocobase/flow-engine';
11
- import { NAMESPACE } from './locale';
12
- import { findRefHostInfoFromAncestors } from './utils/refHost';
13
-
14
- function findFormModel(model: FlowModel): FlowModel | undefined {
15
- let cur: FlowModel | undefined = model;
16
- let depth = 0;
17
- while (cur && depth < 6) {
18
- const use = cur?.use;
19
- if (use === 'CreateFormModel' || use === 'EditFormModel') return cur;
20
- cur = cur?.parent as FlowModel | undefined;
21
- depth++;
22
- }
23
- return undefined;
24
- }
25
-
26
- function isFormInsideReferenceBlock(formModel: FlowModel | undefined): boolean {
27
- if (!formModel) return false;
28
- // 检查表单的 parent 是否是 ReferenceBlockModel
29
- const parent = formModel?.parent as FlowModel | undefined;
30
- return parent?.use === 'ReferenceBlockModel';
31
- }
32
-
33
- export function registerSubModelMenuExtensions(engine: FlowEngine) {
34
- type ModelClass = typeof FlowModel & {
35
- __subModelTemplateMenuPatched?: boolean;
36
- defineChildren?: (ctx: FlowModelContext) => any;
37
- };
38
- const FormCustomItemModel = engine.getModelClass?.('FormCustomItemModel') as ModelClass | undefined;
39
- if (!FormCustomItemModel || FormCustomItemModel.__subModelTemplateMenuPatched) return;
40
- FormCustomItemModel.__subModelTemplateMenuPatched = true;
41
-
42
- const originalDefineChildren = FormCustomItemModel.defineChildren;
43
-
44
- FormCustomItemModel.defineChildren = async function patchedDefineChildren(ctx: FlowModelContext) {
45
- const raw = originalDefineChildren ? await originalDefineChildren.call(this, ctx) : [];
46
- const children = Array.isArray(raw) ? raw : [];
47
-
48
- const label = ctx.t?.('Field template', { ns: [NAMESPACE, 'client'], nsMode: 'fallback' }) || 'Field template';
49
- const formModel = findFormModel(ctx.model);
50
- const parentUse = formModel?.use || ctx.model.parent?.use;
51
- const expectedRootUse =
52
- parentUse === 'CreateFormModel' || parentUse === 'EditFormModel'
53
- ? ['CreateFormModel', 'EditFormModel']
54
- : parentUse;
55
- const resourceInit = formModel?.getStepParams?.('resourceSettings', 'init') || {};
56
- const expectedDataSourceKey =
57
- typeof resourceInit?.dataSourceKey === 'string' ? resourceInit.dataSourceKey : undefined;
58
- const expectedCollectionName =
59
- typeof resourceInit?.collectionName === 'string' ? resourceInit.collectionName : undefined;
60
-
61
- const fromTemplateItem = {
62
- key: '__fromTemplate__',
63
- label,
64
- sort: -999,
65
- hide: async (innerCtx: FlowModelContext) => {
66
- // 1) 若处于"字段模板引用"内部(当前正在渲染模板 grid),直接隐藏,避免误清空模板侧字段
67
- const hostInfo = findRefHostInfoFromAncestors(innerCtx.model);
68
- const hostRef = hostInfo?.ref;
69
- if (hostRef && hostRef.mountSubKey === 'grid' && hostRef.mode !== 'copy') {
70
- return true;
71
- }
72
-
73
- // 2) 若当前表单是 ReferenceBlockModel 渲染的 target,隐藏 "From template"
74
- // 因为在 ReferenceBlockModel 内部编辑字段会直接影响被引用的模板
75
- const fm = findFormModel(innerCtx.model);
76
- if (isFormInsideReferenceBlock(fm)) {
77
- return true;
78
- }
79
-
80
- // 3) 常规:当前表单区块已经使用引用 grid(字段模板),隐藏 "From template"
81
- const mountTarget = fm || (innerCtx.model.parent as FlowModel | undefined);
82
- if (!mountTarget) return false;
83
- const grid = (mountTarget?.subModels as any)?.grid;
84
- const isReferenceGrid = !!grid && String(grid?.use || '') === 'ReferenceFormGridModel';
85
- return isReferenceGrid;
86
- },
87
- createModelOptions: () => ({
88
- use: 'SubModelTemplateImporterModel',
89
- props: {
90
- // 表单字段的复用以 grid 为单位引入,保留模板里的布局
91
- defaultSourcePath: 'subModels.grid',
92
- defaultMountSubKey: 'grid',
93
- mountToParentLevel: 1,
94
- expectedRootUse,
95
- expectedDataSourceKey,
96
- expectedCollectionName,
97
- },
98
- }),
99
- };
100
-
101
- return [fromTemplateItem, ...children].filter(Boolean);
102
- };
103
- }
@@ -1,44 +0,0 @@
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 type { FlowModel } from '@nocobase/flow-engine';
11
- import { REF_HOST_CTX_KEY } from '../constants';
12
-
13
- export type RefHostInfo = {
14
- ref?: {
15
- mountSubKey?: unknown;
16
- mode?: unknown;
17
- [key: string]: unknown;
18
- };
19
- [key: string]: unknown;
20
- };
21
-
22
- export function findRefHostInfoFromAncestors(
23
- model: FlowModel,
24
- options?: { maxDepth?: number },
25
- ): RefHostInfo | undefined {
26
- const maxDepth = options?.maxDepth ?? 8;
27
- let cur: FlowModel | undefined = model;
28
- let depth = 0;
29
- while (cur && depth < maxDepth) {
30
- try {
31
- const ctx = cur.context as unknown as Record<string, unknown>;
32
- // 只读取自身 context 上定义的 host 信息,避免从 delegate 链“误捡到”其他 view/弹窗的标记
33
- if (ctx && Object.prototype.hasOwnProperty.call(ctx, REF_HOST_CTX_KEY)) {
34
- const v = ctx[REF_HOST_CTX_KEY] as RefHostInfo | undefined;
35
- if (v) return v;
36
- }
37
- } catch {
38
- // ignore
39
- }
40
- cur = cur.parent as FlowModel | undefined;
41
- depth++;
42
- }
43
- return undefined;
44
- }