@nocobase/client-v2 2.1.38 → 2.1.40

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 (20) hide show
  1. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
  2. package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
  3. package/es/flow/models/blocks/form/FormBlockModel.d.ts +1 -0
  4. package/es/index.mjs +60 -60
  5. package/lib/index.js +92 -92
  6. package/package.json +7 -7
  7. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
  8. package/src/flow/actions/linkageRules.tsx +8 -3
  9. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
  10. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +129 -14
  11. package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
  12. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
  13. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +359 -1
  14. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
  15. package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
  16. package/src/flow/models/blocks/form/FormBlockModel.tsx +43 -0
  17. package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
  18. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
  19. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +5 -0
  20. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +16 -0
@@ -0,0 +1,90 @@
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 { define, observable } from '@formily/reactive';
11
+ import { describe, expect, it } from 'vitest';
12
+ import { FilterFormSubmitActionModel } from '../FilterFormSubmitActionModel';
13
+
14
+ describe('FilterFormSubmitActionModel lifecycle', () => {
15
+ const createAction = (blockModel: { autoTriggerFilter: boolean }, hidden = false, lifecycleAction?: object) => {
16
+ const action = Object.create(FilterFormSubmitActionModel.prototype) as FilterFormSubmitActionModel;
17
+
18
+ Object.defineProperty(action, 'props', {
19
+ value: {},
20
+ });
21
+ Object.defineProperty(action, 'context', {
22
+ value: { blockModel, model: lifecycleAction || action, defineProperty: () => undefined },
23
+ });
24
+ action.hidden = hidden;
25
+ define(action, {
26
+ hidden: observable,
27
+ });
28
+ action.onInit({});
29
+
30
+ return action;
31
+ };
32
+
33
+ it('keeps automatic filtering disabled when the submit action remounts', () => {
34
+ const blockModel = { autoTriggerFilter: true };
35
+ const action = createAction(blockModel);
36
+
37
+ action.onMount();
38
+ expect(blockModel.autoTriggerFilter).toBe(false);
39
+
40
+ action.onUnmount();
41
+ expect(blockModel.autoTriggerFilter).toBe(true);
42
+
43
+ action.onMount();
44
+ expect(blockModel.autoTriggerFilter).toBe(false);
45
+ });
46
+
47
+ it('keeps automatic filtering disabled until every mounted submit action unmounts', () => {
48
+ const blockModel = { autoTriggerFilter: true };
49
+ const firstAction = createAction(blockModel);
50
+ const secondAction = createAction(blockModel);
51
+
52
+ firstAction.onMount();
53
+ secondAction.onMount();
54
+ firstAction.onUnmount();
55
+ expect(blockModel.autoTriggerFilter).toBe(false);
56
+
57
+ secondAction.onUnmount();
58
+ expect(blockModel.autoTriggerFilter).toBe(true);
59
+ });
60
+
61
+ it('only disables automatic filtering while a visible submit action is mounted', () => {
62
+ const blockModel = { autoTriggerFilter: true };
63
+ const action = createAction(blockModel, true);
64
+
65
+ action.onMount();
66
+ expect(blockModel.autoTriggerFilter).toBe(true);
67
+
68
+ action.hidden = false;
69
+ expect(blockModel.autoTriggerFilter).toBe(false);
70
+
71
+ action.hidden = true;
72
+ expect(blockModel.autoTriggerFilter).toBe(true);
73
+ });
74
+
75
+ it('tracks fork lifecycles independently when submit actions share the same uid', () => {
76
+ const blockModel = { autoTriggerFilter: true };
77
+ const firstFork = {};
78
+ const secondFork = {};
79
+ const firstAction = createAction(blockModel, false, firstFork);
80
+ const secondAction = createAction(blockModel, false, secondFork);
81
+
82
+ firstAction.onMount();
83
+ secondAction.onMount();
84
+ firstAction.onUnmount();
85
+ expect(blockModel.autoTriggerFilter).toBe(false);
86
+
87
+ secondAction.onUnmount();
88
+ expect(blockModel.autoTriggerFilter).toBe(true);
89
+ });
90
+ });
@@ -72,6 +72,23 @@ 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> {
@@ -87,6 +104,32 @@ export class FormBlockModel<
87
104
  return this.context.form as FormInstance;
88
105
  }
89
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
+
90
133
  _defaultCustomModelClasses = {
91
134
  FormActionGroupModel: 'FormActionGroupModel',
92
135
  FormItemModel: 'FormItemModel',
@@ -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
+ });
@@ -8,7 +8,13 @@
8
8
  */
9
9
 
10
10
  import { Cascader, Space, Button } from 'antd';
11
- import { CollectionField, EditableItemModel, tExpr, MultiRecordResource } from '@nocobase/flow-engine';
11
+ import {
12
+ CollectionField,
13
+ EditableItemModel,
14
+ FilterableItemModel,
15
+ MultiRecordResource,
16
+ tExpr,
17
+ } from '@nocobase/flow-engine';
12
18
  import { DeleteOutlined } from '@ant-design/icons';
13
19
  import { css, cx } from '@emotion/css';
14
20
  import { debounce, castArray, omit, last, isEqual } from 'lodash';
@@ -31,6 +37,23 @@ function normalizeLabelKeys(labelKeyOrKeys?: string | string[]): string[] {
31
37
  return Array.from(new Set(list.filter((item): item is string => !!item && typeof item === 'string')));
32
38
  }
33
39
 
40
+ function normalizeCascadeFieldKey(key: unknown): string | undefined {
41
+ if (typeof key === 'string' && key) {
42
+ return key;
43
+ }
44
+ if (Array.isArray(key) && key.length === 1 && typeof key[0] === 'string') {
45
+ return key[0];
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ function getDefaultCascadeFieldNames(field: CollectionField) {
51
+ const targetCollection = field?.targetCollection;
52
+ const value = normalizeCascadeFieldKey(targetCollection?.filterTargetKey) || 'id';
53
+ const label = targetCollection?.titleCollectionField?.name || value;
54
+ return { label, value };
55
+ }
56
+
34
57
  function getRecordDisplayLabel(item: any, labelKeyOrKeys?: string | string[]): string | null {
35
58
  if (!isPlainRecord(item)) return null;
36
59
  const labelKeys = normalizeLabelKeys(labelKeyOrKeys);
@@ -815,6 +838,15 @@ EditableItemModel.bindModelToInterface('CascadeSelectFieldModel', ['m2o', 'o2o',
815
838
  order: 60,
816
839
  });
817
840
 
841
+ FilterableItemModel.bindModelToInterface('CascadeSelectFieldModel', ['m2o', 'o2o', 'oho', 'obo'], {
842
+ when: (ctx, field) => field.targetCollection?.template === 'tree',
843
+ isDefault: true,
844
+ order: 60,
845
+ defaultProps: (ctx, field) => ({
846
+ fieldNames: getDefaultCascadeFieldNames(field),
847
+ }),
848
+ });
849
+
818
850
  EditableItemModel.bindModelToInterface('CascadeSelectListFieldModel', ['m2m', 'o2m', 'mbm'], {
819
851
  when: (ctx, field) => field.targetCollection?.template === 'tree',
820
852
  isDefault: true,
@@ -206,6 +206,11 @@ export function MobileLazySelect(props: Readonly<LazySelectProps>) {
206
206
  return;
207
207
  }
208
208
  const selectedId = vals[0];
209
+ if (selectedId === undefined) {
210
+ onChange(undefined as unknown as AssociationOption);
211
+ handleClose();
212
+ return;
213
+ }
209
214
  const record = optionMap.get(selectedId);
210
215
  if (record) {
211
216
  if (valueMode === 'value') {
@@ -307,6 +307,22 @@ describe('MobileLazySelect', () => {
307
307
  expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
308
308
  });
309
309
 
310
+ it('clears the selected relation record when it is tapped again in single mode', () => {
311
+ const { onChange } = renderMobileLazySelect({
312
+ value: RELATION_OPTIONS[0],
313
+ multiple: false,
314
+ });
315
+
316
+ openLazyPopup();
317
+ expect(mockState.checklistProps?.value).toEqual([RELATION_OPTIONS[0].uuid]);
318
+
319
+ selectValues([]);
320
+
321
+ expect(onChange).toHaveBeenCalledTimes(1);
322
+ expect(onChange).toHaveBeenCalledWith(undefined);
323
+ expect(screen.queryByTestId('popup')).not.toBeInTheDocument();
324
+ });
325
+
310
326
  it('keeps pending relation records selected until confirm', () => {
311
327
  const { onChange, rerender } = renderMobileLazySelect();
312
328