@nocobase/client-v2 2.1.37 → 2.1.39

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.1.37",
3
+ "version": "2.1.39",
4
4
  "license": "Apache-2.0",
5
5
  "main": "lib/index.js",
6
6
  "module": "es/index.mjs",
@@ -27,11 +27,11 @@
27
27
  "@formily/antd-v5": "1.2.3",
28
28
  "@formily/react": "^2.2.27",
29
29
  "@formily/shared": "^2.2.27",
30
- "@nocobase/evaluators": "2.1.37",
31
- "@nocobase/flow-engine": "2.1.37",
32
- "@nocobase/sdk": "2.1.37",
33
- "@nocobase/shared": "2.1.37",
34
- "@nocobase/utils": "2.1.37",
30
+ "@nocobase/evaluators": "2.1.39",
31
+ "@nocobase/flow-engine": "2.1.39",
32
+ "@nocobase/sdk": "2.1.39",
33
+ "@nocobase/shared": "2.1.39",
34
+ "@nocobase/utils": "2.1.39",
35
35
  "ahooks": "^3.7.2",
36
36
  "antd": "5.24.2",
37
37
  "antd-style": "3.7.1",
@@ -48,5 +48,5 @@
48
48
  "react-i18next": "^11.15.1",
49
49
  "react-router-dom": "^6.30.1"
50
50
  },
51
- "gitHead": "a0c3201363ae028058845af0176ee902a9eeb3e1"
51
+ "gitHead": "ddbf88ac2a5de1ad94ff4779618cdf1b81aae037"
52
52
  }
@@ -72,17 +72,64 @@ const flowKeepMobileHorizontalClassName = css`
72
72
  function isGridDelegatedStep(flowKey: string, stepKey: string): boolean {
73
73
  return !!GRID_DELEGATED_STEP_KEYS[flowKey]?.has(stepKey);
74
74
  }
75
+
76
+ interface FormModelWithAvailableData {
77
+ hasAvailableData(): boolean;
78
+ }
79
+
80
+ interface FormModelWithSubmit {
81
+ submit(): Promise<unknown>;
82
+ }
83
+
84
+ function hasAvailableDataCapability(model: object): model is FormModelWithAvailableData {
85
+ return 'hasAvailableData' in model && typeof model.hasAvailableData === 'function';
86
+ }
87
+
88
+ function hasSubmitCapability(model: object): model is FormModelWithSubmit {
89
+ return 'submit' in model && typeof model.submit === 'function';
90
+ }
91
+
75
92
  export class FormBlockModel<
76
93
  T extends DefaultCollectionBlockModelStructure = DefaultCollectionBlockModelStructure,
77
94
  > extends CollectionBlockModel<T> {
78
95
  formValueRuntime?: FormValueRuntime;
79
96
 
97
+ serialize() {
98
+ return { ...super.serialize(), variableContractType: { type: 'form', use: this.use } };
99
+ }
100
+
80
101
  private userModifiedTopLevelFields = new Set<string>();
81
102
 
82
103
  get form() {
83
104
  return this.context.form as FormInstance;
84
105
  }
85
106
 
107
+ submitFromRunJs() {
108
+ if (hasAvailableDataCapability(this) && !this.hasAvailableData()) {
109
+ return;
110
+ }
111
+
112
+ const submitAction = this.mapSubModels('actions', (action) => action).find((action) =>
113
+ action.getFlow('submitSettings'),
114
+ );
115
+ if (submitAction) {
116
+ return submitAction.onClick(undefined);
117
+ }
118
+
119
+ const isCoreForm = ['CreateFormModel', 'EditFormModel'].some((modelName) => {
120
+ const ModelClass = this.flowEngine.getModelClass(modelName);
121
+ return ModelClass && this instanceof ModelClass;
122
+ });
123
+ if (this.context.publicFormRuntime || !isCoreForm || !hasSubmitCapability(this)) {
124
+ return;
125
+ }
126
+
127
+ this.submit().catch((error: unknown) => {
128
+ this.context.message.error(this.context.t('Save failed'));
129
+ console.error('Form submission error:', error);
130
+ });
131
+ }
132
+
86
133
  _defaultCustomModelClasses = {
87
134
  FormActionGroupModel: 'FormActionGroupModel',
88
135
  FormItemModel: 'FormItemModel',
@@ -21,6 +21,10 @@ export type DefaultFormGridStructure = {
21
21
  };
22
22
 
23
23
  export class FormGridModel<T extends DefaultFormGridStructure = DefaultFormGridStructure> extends GridModel<T> {
24
+ serialize() {
25
+ return { ...super.serialize(), variableContractType: { type: 'formGrid', use: this.use } };
26
+ }
27
+
24
28
  itemFallback = (<Skeleton.Input block size="small" style={{ marginBottom: '0.5rem' }} />);
25
29
  itemSettingsMenuLevel = 2;
26
30
  itemFlowSettings = {
@@ -0,0 +1,131 @@
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 { FlowEngine } from '@nocobase/flow-engine';
11
+ import { describe, expect, it, vi } from 'vitest';
12
+ import { CreateFormModel } from '../CreateFormModel';
13
+ import { EditFormModel } from '../EditFormModel';
14
+ import { FormActionModel } from '../FormActionModel';
15
+
16
+ function prepareFormModel<T extends CreateFormModel | EditFormModel>(model: T, publicFormRuntime = false) {
17
+ const engine = new FlowEngine();
18
+ engine.registerModels({ CreateFormModel, EditFormModel });
19
+ model.flowEngine = engine;
20
+ Object.defineProperty(model, 'context', {
21
+ value: {
22
+ publicFormRuntime,
23
+ message: { error: vi.fn() },
24
+ t: (value: string) => value,
25
+ },
26
+ });
27
+ return model;
28
+ }
29
+
30
+ function createSubmitAction() {
31
+ const onClick = vi.fn();
32
+ const action = {
33
+ getFlow: vi.fn((key: string) => (key === 'submitSettings' ? {} : undefined)),
34
+ onClick,
35
+ } as unknown as FormActionModel;
36
+ return { action, onClick };
37
+ }
38
+
39
+ describe('FormBlockModel.submitFromRunJs', () => {
40
+ it('dispatches the first action with a submitSettings flow', () => {
41
+ const { action, onClick } = createSubmitAction();
42
+ const { action: secondAction, onClick: secondOnClick } = createSubmitAction();
43
+ const blockModel = prepareFormModel(
44
+ Object.assign(Object.create(CreateFormModel.prototype), {
45
+ mapSubModels: (_key: string, callback: (item: FormActionModel) => FormActionModel) => [
46
+ callback(action),
47
+ callback(secondAction),
48
+ ],
49
+ }) as CreateFormModel,
50
+ );
51
+
52
+ blockModel.submitFromRunJs();
53
+
54
+ expect(onClick).toHaveBeenCalledWith(undefined);
55
+ expect(secondOnClick).not.toHaveBeenCalled();
56
+ });
57
+
58
+ it('does not dispatch an edit submit action without an available record', () => {
59
+ const { action, onClick } = createSubmitAction();
60
+ const blockModel = prepareFormModel(
61
+ Object.assign(Object.create(EditFormModel.prototype), {
62
+ hasAvailableData: () => false,
63
+ mapSubModels: (_key: string, callback: (item: FormActionModel) => FormActionModel) => [callback(action)],
64
+ }) as EditFormModel,
65
+ );
66
+
67
+ blockModel.submitFromRunJs();
68
+
69
+ expect(onClick).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it('falls back to the core form submit handler when no submit action exists', () => {
73
+ const submit = vi.fn().mockResolvedValue(undefined);
74
+ const blockModel = prepareFormModel(
75
+ Object.assign(Object.create(CreateFormModel.prototype), {
76
+ mapSubModels: vi.fn(() => []),
77
+ submit,
78
+ }) as CreateFormModel,
79
+ );
80
+
81
+ blockModel.submitFromRunJs();
82
+
83
+ expect(submit).toHaveBeenCalledOnce();
84
+ });
85
+
86
+ it('falls back to the core edit submit handler when a record exists', () => {
87
+ const submit = vi.fn().mockResolvedValue(undefined);
88
+ const blockModel = prepareFormModel(
89
+ Object.assign(Object.create(EditFormModel.prototype), {
90
+ hasAvailableData: () => true,
91
+ mapSubModels: vi.fn(() => []),
92
+ submit,
93
+ }) as EditFormModel,
94
+ );
95
+
96
+ blockModel.submitFromRunJs();
97
+
98
+ expect(submit).toHaveBeenCalledOnce();
99
+ });
100
+
101
+ it('uses the core fallback for specialized form subclasses', () => {
102
+ class SpecializedCreateFormModel extends CreateFormModel {}
103
+
104
+ const submit = vi.fn().mockResolvedValue(undefined);
105
+ const blockModel = prepareFormModel(
106
+ Object.assign(Object.create(SpecializedCreateFormModel.prototype), {
107
+ mapSubModels: vi.fn(() => []),
108
+ submit,
109
+ }) as SpecializedCreateFormModel,
110
+ );
111
+
112
+ blockModel.submitFromRunJs();
113
+
114
+ expect(submit).toHaveBeenCalledOnce();
115
+ });
116
+
117
+ it('does not use the core fallback for public forms without a submit action', () => {
118
+ const submit = vi.fn().mockResolvedValue(undefined);
119
+ const blockModel = prepareFormModel(
120
+ Object.assign(Object.create(CreateFormModel.prototype), {
121
+ mapSubModels: vi.fn(() => []),
122
+ submit,
123
+ }) as CreateFormModel,
124
+ true,
125
+ );
126
+
127
+ blockModel.submitFromRunJs();
128
+
129
+ expect(submit).not.toHaveBeenCalled();
130
+ });
131
+ });
@@ -445,6 +445,43 @@ describe('FormValueRuntime (default rules)', () => {
445
445
  });
446
446
 
447
447
  describe('FormValueRuntime (form assign rules)', () => {
448
+ it('uses the form grid as the variable contract owner', async () => {
449
+ const engineEmitter = new EventEmitter();
450
+ const formStub = createFormStub({ b: 'licensed' });
451
+ const onResolveJsonTemplate = vi.fn();
452
+ const blockModel: any = {
453
+ uid: 'form-contract-owner',
454
+ subModels: { grid: { uid: 'form-contract-owner-grid' } },
455
+ flowEngine: { emitter: engineEmitter },
456
+ emitter: new EventEmitter(),
457
+ dispatchEvent: vi.fn(),
458
+ getAclActionName: () => 'create',
459
+ };
460
+ const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
461
+ const blockCtx = createFieldContext(runtime);
462
+ const resolveJsonTemplate = blockCtx.resolveJsonTemplate.bind(blockCtx);
463
+ blockCtx.defineMethod('resolveJsonTemplate', async (template: unknown, options?: unknown) => {
464
+ onResolveJsonTemplate(options);
465
+ return resolveJsonTemplate(template, options);
466
+ });
467
+ blockModel.context = blockCtx;
468
+ runtime.mount({ sync: true });
469
+
470
+ runtime.syncAssignRules([
471
+ {
472
+ key: 'license-version',
473
+ enable: true,
474
+ targetPath: 'a',
475
+ mode: 'assign',
476
+ condition: { logic: '$and', items: [] },
477
+ value: '__B__',
478
+ },
479
+ ]);
480
+
481
+ await waitFor(() => expect(formStub.getFieldValue(['a'])).toBe('licensed'));
482
+ expect(onResolveJsonTemplate).toHaveBeenCalledWith({ contractModelUid: 'form-contract-owner-grid' });
483
+ });
484
+
448
485
  it('migrates block-level rule to field instance on mount and restores on unmount', async () => {
449
486
  const engineEmitter = new EventEmitter();
450
487
  const blockEmitter = new EventEmitter();
@@ -8,7 +8,14 @@
8
8
  */
9
9
 
10
10
  import { isObservable, reaction, toJS } from '@formily/reactive';
11
- import { FlowContext, FlowModel, isRunJSValue, normalizeRunJSValue, runjsWithSafeGlobals } from '@nocobase/flow-engine';
11
+ import {
12
+ FlowContext,
13
+ FlowModel,
14
+ isRunJSValue,
15
+ normalizeRunJSValue,
16
+ runjsWithSafeGlobals,
17
+ type ResolveJsonTemplateOptions,
18
+ } from '@nocobase/flow-engine';
12
19
  import { getValuesByPath } from '@nocobase/shared';
13
20
  import _ from 'lodash';
14
21
  import { dayjs } from '@nocobase/utils/client';
@@ -49,6 +56,7 @@ type RuntimeRule = {
49
56
  getValue: () => any;
50
57
  getCondition?: () => any;
51
58
  getContext: () => any;
59
+ getContractModelUid?: () => string | undefined;
52
60
  };
53
61
 
54
62
  type ObservableBinding = {
@@ -94,6 +102,7 @@ type RuntimeItemChain = {
94
102
 
95
103
  export type RuleEngineOptions = {
96
104
  getBlockModelUid: () => string;
105
+ getAssignRulesModelUid: () => string | undefined;
97
106
  getActionName: () => string | undefined;
98
107
  getBlockContext: () => any;
99
108
  getEngine: () => any;
@@ -217,6 +226,7 @@ export class RuleEngine {
217
226
  getValue: () => template?.value,
218
227
  getCondition: () => template?.condition,
219
228
  getContext: () => this.options.getBlockContext(),
229
+ getContractModelUid: this.options.getAssignRulesModelUid,
220
230
  };
221
231
 
222
232
  this.rules.set(id, { rule, state: { deps: new Set(), depDisposers: [], runSeq: 0, scheduledAtWriteSeq: 0 } });
@@ -554,6 +564,7 @@ export class RuleEngine {
554
564
  getValue: () => template?.value,
555
565
  getCondition: () => template?.condition,
556
566
  getContext: () => this.options.getBlockContext(),
567
+ getContractModelUid: this.options.getAssignRulesModelUid,
557
568
  };
558
569
 
559
570
  this.rules.set(id, { rule, state: { deps: new Set(), depDisposers: [], runSeq: 0, scheduledAtWriteSeq: 0 } });
@@ -922,6 +933,7 @@ export class RuleEngine {
922
933
  getValue: () => template?.value,
923
934
  getCondition: () => template?.condition,
924
935
  getContext: () => model?.context,
936
+ getContractModelUid: this.options.getAssignRulesModelUid,
925
937
  };
926
938
 
927
939
  this.rules.set(id, { rule, state: { deps: new Set(), depDisposers: [], runSeq: 0, scheduledAtWriteSeq: 0 } });
@@ -1671,7 +1683,7 @@ export class RuleEngine {
1671
1683
  }
1672
1684
  }
1673
1685
 
1674
- const evalCtx = this.createRuleEvaluationContext(baseCtx, collector, targetNamePath);
1686
+ const evalCtx = this.createRuleEvaluationContext(baseCtx, collector, targetNamePath, rule.getContractModelUid?.());
1675
1687
  return { collector, evalCtx, rawValue, isRunJS };
1676
1688
  }
1677
1689
 
@@ -2010,7 +2022,12 @@ export class RuleEngine {
2010
2022
  return canOverwrite;
2011
2023
  }
2012
2024
 
2013
- private createRuleEvaluationContext(baseCtx: any, collector: DepCollector, targetNamePath: NamePath | null) {
2025
+ private createRuleEvaluationContext(
2026
+ baseCtx: any,
2027
+ collector: DepCollector,
2028
+ targetNamePath: NamePath | null,
2029
+ contractModelUid?: string,
2030
+ ) {
2014
2031
  const trackingFormValues = this.options.createTrackingFormValues(collector);
2015
2032
  const ctx: any = new FlowContext();
2016
2033
  try {
@@ -2030,12 +2047,19 @@ export class RuleEngine {
2030
2047
  }
2031
2048
 
2032
2049
  const delegatedResolveJsonTemplate =
2033
- typeof ctx.resolveJsonTemplate === 'function' ? ctx.resolveJsonTemplate.bind(ctx) : undefined;
2050
+ typeof ctx.resolveJsonTemplate === 'function'
2051
+ ? (ctx.resolveJsonTemplate.bind(ctx) as (
2052
+ template: unknown,
2053
+ options?: ResolveJsonTemplateOptions,
2054
+ ) => Promise<unknown>)
2055
+ : undefined;
2034
2056
  ctx.defineMethod('resolveJsonTemplate', async (template: unknown) => {
2035
2057
  const tokenStore = this.createLocalTemplateTokenStore();
2036
2058
  const localResolved = this.resolveLocalFormValuesTemplates(baseCtx, template, collector, tokenStore);
2037
2059
  const nextTemplate = localResolved.matched ? localResolved.value : template;
2038
- const resolved = delegatedResolveJsonTemplate ? await delegatedResolveJsonTemplate(nextTemplate) : nextTemplate;
2060
+ const resolved = delegatedResolveJsonTemplate
2061
+ ? await delegatedResolveJsonTemplate(nextTemplate, contractModelUid ? { contractModelUid } : undefined)
2062
+ : nextTemplate;
2039
2063
  return this.restoreLocalTemplateTokens(resolved, tokenStore);
2040
2064
  });
2041
2065
 
@@ -81,6 +81,10 @@ export class FormValueRuntime {
81
81
 
82
82
  this.ruleEngine = new RuleEngine({
83
83
  getBlockModelUid: () => String(this.model?.uid),
84
+ getAssignRulesModelUid: () => {
85
+ const grid = this.model?.subModels?.grid;
86
+ return !Array.isArray(grid) && grid?.uid ? String(grid.uid) : undefined;
87
+ },
84
88
  getActionName: () => this.model?.getAclActionName?.() ?? this.model?.context?.actionName,
85
89
  getBlockContext: () => this.model?.context,
86
90
  getEngine: () => this.model?.context?.engine,