@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.
- package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
- package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
- package/es/flow/models/blocks/form/FormBlockModel.d.ts +1 -0
- package/es/index.mjs +60 -60
- package/lib/index.js +92 -92
- package/package.json +7 -7
- package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
- package/src/flow/actions/linkageRules.tsx +8 -3
- package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
- package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +129 -14
- package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +359 -1
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
- package/src/flow/models/blocks/form/FormBlockModel.tsx +43 -0
- package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
- package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
- package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +5 -0
- package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +16 -0
package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts
ADDED
|
@@ -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 {
|
|
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
|
|