@nocobase/client-v2 2.1.0-alpha.30 → 2.1.0-alpha.32
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/components/form/JsonTextArea.d.ts +18 -0
- package/es/components/index.d.ts +1 -0
- package/es/flow/actions/dateRangeLimit.d.ts +9 -0
- package/es/flow/actions/index.d.ts +1 -0
- package/es/flow/actions/linkageRulesFormValueRefresh.d.ts +10 -0
- package/es/flow/index.d.ts +1 -0
- package/es/flow/models/actions/AssociateActionModel.d.ts +19 -0
- package/es/flow/models/actions/AssociationActionUtils.d.ts +17 -0
- package/es/flow/models/actions/DisassociateActionModel.d.ts +16 -0
- package/es/flow/models/actions/index.d.ts +3 -0
- package/es/flow/models/base/GridModel.d.ts +3 -1
- package/es/flow/models/base/PageModel/PageModel.d.ts +4 -0
- package/es/flow/models/base/PageModel/RootPageModel.d.ts +9 -0
- package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +15 -6
- package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +7 -0
- package/es/flow/models/blocks/shared/filterOperators.d.ts +9 -0
- package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +3 -0
- package/es/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.d.ts +9 -0
- package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +20 -0
- package/es/flow/models/fields/JSEditableFieldModel.d.ts +4 -0
- package/es/flow-compat/data.d.ts +9 -2
- package/es/flow-compat/index.d.ts +1 -1
- package/es/index.d.ts +1 -0
- package/es/index.mjs +100 -93
- package/lib/index.js +101 -94
- package/package.json +6 -5
- package/src/BaseApplication.tsx +1 -1
- package/src/__tests__/app.test.tsx +23 -6
- package/src/__tests__/globalDeps.test.ts +5 -0
- package/src/components/form/JsonTextArea.tsx +129 -0
- package/src/components/index.ts +1 -0
- package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +478 -0
- package/src/flow/actions/__tests__/linkageRules.formValueDrivenRefresh.test.ts +438 -0
- package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +42 -0
- package/src/flow/actions/__tests__/pattern.test.ts +190 -0
- package/src/flow/actions/dateRangeLimit.tsx +66 -0
- package/src/flow/actions/index.ts +1 -0
- package/src/flow/actions/linkageRules.tsx +119 -14
- package/src/flow/actions/linkageRulesFormValueRefresh.ts +492 -0
- package/src/flow/actions/linkageRulesRefresh.tsx +4 -2
- package/src/flow/actions/openView.tsx +2 -1
- package/src/flow/actions/pattern.tsx +25 -2
- package/src/flow/actions/titleField.tsx +8 -3
- package/src/flow/admin-shell/AdminLayoutRouteCoordinator.ts +7 -1
- package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +117 -0
- package/src/flow/components/FieldAssignValueInput.tsx +1 -0
- package/src/flow/components/filter/LinkageFilterItem.tsx +6 -5
- package/src/flow/components/filter/VariableFilterItem.tsx +14 -13
- package/src/flow/components/filter/__tests__/LinkageFilterItem.test.tsx +33 -0
- package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +48 -5
- package/src/flow/index.ts +1 -0
- package/src/flow/internal/utils/__tests__/titleFieldQuickSync.test.ts +1 -0
- package/src/flow/internal/utils/titleFieldQuickSync.ts +2 -2
- package/src/flow/models/actions/AssociateActionModel.tsx +196 -0
- package/src/flow/models/actions/AssociationActionUtils.ts +90 -0
- package/src/flow/models/actions/DisassociateActionModel.tsx +57 -0
- package/src/flow/models/actions/FilterActionModel.tsx +17 -9
- package/src/flow/models/actions/__tests__/AssociationActionModel.test.ts +250 -0
- package/src/flow/models/actions/index.ts +3 -0
- package/src/flow/models/base/GridModel.tsx +21 -1
- package/src/flow/models/base/PageModel/PageModel.tsx +15 -3
- package/src/flow/models/base/PageModel/RootPageModel.tsx +37 -2
- package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +73 -0
- package/src/flow/models/base/PageModel/__tests__/RootPageModel.test.ts +116 -0
- package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +98 -0
- package/src/flow/models/blocks/details/DetailsItemModel.tsx +3 -0
- package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +200 -36
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +270 -1
- package/src/flow/models/blocks/filter-form/__tests__/customFieldOperators.test.tsx +23 -0
- package/src/flow/models/blocks/filter-form/customFieldOperators.ts +12 -1
- package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +22 -8
- package/src/flow/models/blocks/filter-form/fields/__tests__/FilterFormCustomFieldModel.recordSelect.test.tsx +18 -0
- package/src/flow/models/blocks/filter-manager/FilterManager.ts +51 -1
- package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +75 -0
- package/src/flow/models/blocks/form/FormItemModel.tsx +48 -28
- package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +167 -1
- package/src/flow/models/blocks/form/value-runtime/runtime.ts +103 -11
- package/src/flow/models/blocks/shared/filterOperators.ts +14 -0
- package/src/flow/models/blocks/table/TableBlockModel.tsx +19 -3
- package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +5 -1
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +48 -8
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +47 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +42 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.refresh.test.tsx +122 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +2 -0
- package/src/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.ts +20 -0
- package/src/flow/models/fields/ClickableFieldModel.tsx +21 -9
- package/src/flow/models/fields/DateTimeFieldModel/DateOnlyFieldModel.tsx +9 -0
- package/src/flow/models/fields/DateTimeFieldModel/DateTimeFieldModel.tsx +4 -0
- package/src/flow/models/fields/DateTimeFieldModel/DateTimeNoTzFieldModel.tsx +9 -0
- package/src/flow/models/fields/DateTimeFieldModel/DateTimeTzFieldModel.tsx +9 -0
- package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +242 -0
- package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +152 -0
- package/src/flow/models/fields/DividerItemModel.tsx +30 -15
- package/src/flow/models/fields/JSEditableFieldModel.tsx +110 -14
- package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +87 -0
- package/src/flow/models/fields/__tests__/JSEditableFieldModel.test.tsx +210 -0
- package/src/flow/models/fields/mobile-components/MobileSelect.tsx +11 -3
- package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +235 -0
- package/src/flow-compat/data.ts +25 -3
- package/src/flow-compat/index.ts +7 -1
- package/src/index.ts +1 -0
- package/src/utils/globalDeps.ts +6 -0
|
@@ -0,0 +1,438 @@
|
|
|
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 { FlowContext, FlowRuntimeContext } from '@nocobase/flow-engine';
|
|
11
|
+
import { waitFor } from '@testing-library/react';
|
|
12
|
+
import { EventEmitter } from 'events';
|
|
13
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
14
|
+
import { actionLinkageRules, blockLinkageRules } from '../linkageRules';
|
|
15
|
+
|
|
16
|
+
function createRule(overrides: any = {}) {
|
|
17
|
+
return {
|
|
18
|
+
key: 'r1',
|
|
19
|
+
title: 'r1',
|
|
20
|
+
enable: true,
|
|
21
|
+
condition: { logic: '$and', items: [] },
|
|
22
|
+
actions: [],
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createRuntime(
|
|
28
|
+
params: any,
|
|
29
|
+
options: { fieldIndex?: string[]; fieldIndexRef?: { current: string[] }; actionHandler?: any; engineEmitter?: any } = {},
|
|
30
|
+
) {
|
|
31
|
+
const formEmitter = new EventEmitter();
|
|
32
|
+
const formBlock: any = {
|
|
33
|
+
uid: 'form-block',
|
|
34
|
+
emitter: formEmitter,
|
|
35
|
+
formValueRuntime: {},
|
|
36
|
+
};
|
|
37
|
+
const actionHandler = options.actionHandler || vi.fn(async () => {});
|
|
38
|
+
const linkageRunjsHandler = vi.fn(async () => {});
|
|
39
|
+
const modelContext: any = new FlowContext();
|
|
40
|
+
modelContext.defineProperty('blockModel', { value: formBlock });
|
|
41
|
+
modelContext.defineProperty('app', {
|
|
42
|
+
value: {
|
|
43
|
+
jsonLogic: {
|
|
44
|
+
apply: () => true,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
modelContext.defineProperty('fieldIndex', {
|
|
49
|
+
get: () => options.fieldIndexRef?.current || options.fieldIndex || [],
|
|
50
|
+
cache: false,
|
|
51
|
+
});
|
|
52
|
+
modelContext.defineMethod('resolveJsonTemplate', async (_template: any) => _template);
|
|
53
|
+
modelContext.defineMethod('getAction', (name: string) => {
|
|
54
|
+
if (name === 'actionLinkageRules') {
|
|
55
|
+
return { useRawParams: true, handler: actionHandler };
|
|
56
|
+
}
|
|
57
|
+
if (name === 'blockLinkageRules') {
|
|
58
|
+
return { useRawParams: true, handler: actionHandler };
|
|
59
|
+
}
|
|
60
|
+
if (name === 'linkageRunjs') {
|
|
61
|
+
return { handler: linkageRunjsHandler };
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
});
|
|
65
|
+
modelContext.defineMethod('getActions', () => new Map());
|
|
66
|
+
modelContext.defineMethod('t', (s: string) => s);
|
|
67
|
+
|
|
68
|
+
const model: any = {
|
|
69
|
+
uid: 'action-model',
|
|
70
|
+
context: modelContext,
|
|
71
|
+
flowEngine: options.engineEmitter ? { emitter: options.engineEmitter } : undefined,
|
|
72
|
+
isFork: false,
|
|
73
|
+
forks: new Set(),
|
|
74
|
+
getFlow: vi.fn(() => ({})),
|
|
75
|
+
getStepParams: vi.fn(() => params),
|
|
76
|
+
getAction: (name: string) => modelContext.getAction(name),
|
|
77
|
+
getActions: () => new Map(),
|
|
78
|
+
translate: (s: string) => s,
|
|
79
|
+
};
|
|
80
|
+
const ctx: any = new FlowRuntimeContext(model, 'buttonSettings');
|
|
81
|
+
ctx.defineMethod('resolveJsonTemplate', async (_template: any) => _template);
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
ctx,
|
|
85
|
+
model,
|
|
86
|
+
formEmitter,
|
|
87
|
+
actionHandler,
|
|
88
|
+
linkageRunjsHandler,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe('linkageRules: form value driven refresh', () => {
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
vi.useRealTimers();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('refreshes action linkage rules when a ctx.formValues dependency changes', async () => {
|
|
98
|
+
const params = {
|
|
99
|
+
value: [
|
|
100
|
+
createRule({
|
|
101
|
+
condition: {
|
|
102
|
+
logic: '$and',
|
|
103
|
+
items: [{ path: '{{ ctx.formValues.status }}', operator: '$eq', value: 'active' }],
|
|
104
|
+
},
|
|
105
|
+
}),
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
const { ctx, formEmitter, actionHandler } = createRuntime(params);
|
|
109
|
+
|
|
110
|
+
await actionLinkageRules.handler(ctx, params);
|
|
111
|
+
formEmitter.emit('formValuesChange', {
|
|
112
|
+
source: 'user',
|
|
113
|
+
txId: 'tx-1',
|
|
114
|
+
changedPaths: [['other']],
|
|
115
|
+
});
|
|
116
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
117
|
+
expect(actionHandler).not.toHaveBeenCalled();
|
|
118
|
+
|
|
119
|
+
formEmitter.emit('formValuesChange', {
|
|
120
|
+
source: 'user',
|
|
121
|
+
txId: 'tx-2',
|
|
122
|
+
changedPaths: [['status']],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await waitFor(() => expect(actionHandler).toHaveBeenCalledTimes(1));
|
|
126
|
+
expect(actionHandler.mock.calls[0][0].inputArgs).toMatchObject({
|
|
127
|
+
source: 'user',
|
|
128
|
+
txId: 'tx-2',
|
|
129
|
+
linkageTxId: 'tx-2',
|
|
130
|
+
changedPaths: [['status']],
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('refreshes block linkage rules when a ctx.formValues dependency changes', async () => {
|
|
135
|
+
const params = {
|
|
136
|
+
value: [
|
|
137
|
+
createRule({
|
|
138
|
+
condition: {
|
|
139
|
+
logic: '$and',
|
|
140
|
+
items: [{ path: '{{ ctx.formValues.status }}', operator: '$eq', value: 'active' }],
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
143
|
+
],
|
|
144
|
+
};
|
|
145
|
+
const { ctx, formEmitter, actionHandler } = createRuntime(params);
|
|
146
|
+
|
|
147
|
+
await blockLinkageRules.handler(ctx, params);
|
|
148
|
+
formEmitter.emit('formValuesChange', {
|
|
149
|
+
source: 'user',
|
|
150
|
+
txId: 'tx-1',
|
|
151
|
+
changedPaths: [['status']],
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await waitFor(() => expect(actionHandler).toHaveBeenCalledTimes(1));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('dedupes subscriptions for repeated handler runs', async () => {
|
|
158
|
+
const params = {
|
|
159
|
+
value: [
|
|
160
|
+
createRule({
|
|
161
|
+
condition: {
|
|
162
|
+
logic: '$and',
|
|
163
|
+
items: [{ path: '{{ ctx.formValues.status }}', operator: '$eq', value: 'active' }],
|
|
164
|
+
},
|
|
165
|
+
}),
|
|
166
|
+
],
|
|
167
|
+
};
|
|
168
|
+
const { ctx, formEmitter, actionHandler } = createRuntime(params);
|
|
169
|
+
|
|
170
|
+
await actionLinkageRules.handler(ctx, params);
|
|
171
|
+
await actionLinkageRules.handler(ctx, params);
|
|
172
|
+
|
|
173
|
+
expect(formEmitter.listenerCount('formValuesChange')).toBe(1);
|
|
174
|
+
|
|
175
|
+
formEmitter.emit('formValuesChange', {
|
|
176
|
+
source: 'user',
|
|
177
|
+
txId: 'tx-1',
|
|
178
|
+
changedPaths: [['status']],
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
await waitFor(() => expect(actionHandler).toHaveBeenCalledTimes(1));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('keeps action refresh subscription after the action is hidden and unmounted', async () => {
|
|
185
|
+
const engineEmitter = new EventEmitter();
|
|
186
|
+
const params = {
|
|
187
|
+
value: [
|
|
188
|
+
createRule({
|
|
189
|
+
condition: {
|
|
190
|
+
logic: '$and',
|
|
191
|
+
items: [{ path: '{{ ctx.formValues.status }}', operator: '$eq', value: 'active' }],
|
|
192
|
+
},
|
|
193
|
+
}),
|
|
194
|
+
],
|
|
195
|
+
};
|
|
196
|
+
const { ctx, model, formEmitter, actionHandler } = createRuntime(params, { engineEmitter });
|
|
197
|
+
|
|
198
|
+
await actionLinkageRules.handler(ctx, params);
|
|
199
|
+
expect(formEmitter.listenerCount('formValuesChange')).toBe(1);
|
|
200
|
+
|
|
201
|
+
engineEmitter.emit('model:unmounted', { model });
|
|
202
|
+
expect(formEmitter.listenerCount('formValuesChange')).toBe(1);
|
|
203
|
+
|
|
204
|
+
formEmitter.emit('formValuesChange', {
|
|
205
|
+
source: 'user',
|
|
206
|
+
txId: 'tx-1',
|
|
207
|
+
changedPaths: [['status']],
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await waitFor(() => expect(actionHandler).toHaveBeenCalledTimes(1));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('maps ctx.item.value dependencies to the current row path', async () => {
|
|
214
|
+
const params = {
|
|
215
|
+
value: [
|
|
216
|
+
createRule({
|
|
217
|
+
condition: {
|
|
218
|
+
logic: '$and',
|
|
219
|
+
items: [{ path: '{{ ctx.item.value.nickname }}', operator: '$eq', value: 'A' }],
|
|
220
|
+
},
|
|
221
|
+
}),
|
|
222
|
+
],
|
|
223
|
+
};
|
|
224
|
+
const { ctx, formEmitter, actionHandler } = createRuntime(params, { fieldIndex: ['users:1'] });
|
|
225
|
+
|
|
226
|
+
await actionLinkageRules.handler(ctx, params);
|
|
227
|
+
formEmitter.emit('formValuesChange', {
|
|
228
|
+
source: 'user',
|
|
229
|
+
txId: 'tx-1',
|
|
230
|
+
changedPaths: [['users', 0, 'nickname']],
|
|
231
|
+
});
|
|
232
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
233
|
+
expect(actionHandler).not.toHaveBeenCalled();
|
|
234
|
+
|
|
235
|
+
formEmitter.emit('formValuesChange', {
|
|
236
|
+
source: 'user',
|
|
237
|
+
txId: 'tx-2',
|
|
238
|
+
changedPaths: [['users', 1, 'nickname']],
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await waitFor(() => expect(actionHandler).toHaveBeenCalledTimes(1));
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('parses object-patch changedPaths before matching ctx.item.value dependencies', async () => {
|
|
245
|
+
const params = {
|
|
246
|
+
value: [
|
|
247
|
+
createRule({
|
|
248
|
+
condition: {
|
|
249
|
+
logic: '$and',
|
|
250
|
+
items: [{ path: '{{ ctx.item.value.nickname }}', operator: '$eq', value: 'A' }],
|
|
251
|
+
},
|
|
252
|
+
}),
|
|
253
|
+
],
|
|
254
|
+
};
|
|
255
|
+
const { ctx, formEmitter, actionHandler } = createRuntime(params, { fieldIndex: ['users:1'] });
|
|
256
|
+
|
|
257
|
+
await actionLinkageRules.handler(ctx, params);
|
|
258
|
+
formEmitter.emit('formValuesChange', {
|
|
259
|
+
source: 'user',
|
|
260
|
+
txId: 'tx-1',
|
|
261
|
+
changedPaths: [['users[1].nickname']],
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
await waitFor(() => expect(actionHandler).toHaveBeenCalledTimes(1));
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('recomputes ctx.item.value dependencies from the latest fieldIndex', async () => {
|
|
268
|
+
const fieldIndexRef = { current: ['users:1'] };
|
|
269
|
+
const params = {
|
|
270
|
+
value: [
|
|
271
|
+
createRule({
|
|
272
|
+
condition: {
|
|
273
|
+
logic: '$and',
|
|
274
|
+
items: [{ path: '{{ ctx.item.value.nickname }}', operator: '$eq', value: 'A' }],
|
|
275
|
+
},
|
|
276
|
+
}),
|
|
277
|
+
],
|
|
278
|
+
};
|
|
279
|
+
const { ctx, formEmitter, actionHandler } = createRuntime(params, { fieldIndexRef });
|
|
280
|
+
|
|
281
|
+
await actionLinkageRules.handler(ctx, params);
|
|
282
|
+
fieldIndexRef.current = ['users:0'];
|
|
283
|
+
formEmitter.emit('formValuesChange', {
|
|
284
|
+
source: 'user',
|
|
285
|
+
txId: 'tx-1',
|
|
286
|
+
changedPaths: [['users', 0, 'nickname']],
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
await waitFor(() => expect(actionHandler).toHaveBeenCalledTimes(1));
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('maps ctx.item.index dependencies to the current list path', async () => {
|
|
293
|
+
const params = {
|
|
294
|
+
value: [
|
|
295
|
+
createRule({
|
|
296
|
+
condition: {
|
|
297
|
+
logic: '$and',
|
|
298
|
+
items: [{ path: '{{ ctx.item.index }}', operator: '$eq', value: 1 }],
|
|
299
|
+
},
|
|
300
|
+
}),
|
|
301
|
+
],
|
|
302
|
+
};
|
|
303
|
+
const { ctx, formEmitter, actionHandler } = createRuntime(params, { fieldIndex: ['users:1'] });
|
|
304
|
+
|
|
305
|
+
await actionLinkageRules.handler(ctx, params);
|
|
306
|
+
formEmitter.emit('formValuesChange', {
|
|
307
|
+
source: 'user',
|
|
308
|
+
txId: 'tx-1',
|
|
309
|
+
changedPaths: [['users', 1, 'nickname']],
|
|
310
|
+
});
|
|
311
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
312
|
+
expect(actionHandler).not.toHaveBeenCalled();
|
|
313
|
+
|
|
314
|
+
formEmitter.emit('formValuesChange', {
|
|
315
|
+
source: 'user',
|
|
316
|
+
txId: 'tx-2',
|
|
317
|
+
changedPaths: [['users']],
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
await waitFor(() => expect(actionHandler).toHaveBeenCalledTimes(1));
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('collects ctx.formValues dependencies from linkageRunjs scripts without resolving the script early', async () => {
|
|
324
|
+
const params = {
|
|
325
|
+
value: [
|
|
326
|
+
createRule({
|
|
327
|
+
actions: [
|
|
328
|
+
{
|
|
329
|
+
key: 'a1',
|
|
330
|
+
name: 'linkageRunjs',
|
|
331
|
+
params: {
|
|
332
|
+
value: {
|
|
333
|
+
script: 'return ctx.formValues.amount',
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
],
|
|
338
|
+
}),
|
|
339
|
+
],
|
|
340
|
+
};
|
|
341
|
+
const { ctx, formEmitter, actionHandler, linkageRunjsHandler } = createRuntime(params);
|
|
342
|
+
|
|
343
|
+
await actionLinkageRules.handler(ctx, params);
|
|
344
|
+
expect(linkageRunjsHandler).toHaveBeenCalledTimes(1);
|
|
345
|
+
|
|
346
|
+
formEmitter.emit('formValuesChange', {
|
|
347
|
+
source: 'user',
|
|
348
|
+
txId: 'tx-1',
|
|
349
|
+
changedPaths: [['amount']],
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
await waitFor(() => expect(actionHandler).toHaveBeenCalledTimes(1));
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('reruns once with the latest relevant change after a refresh is already running', async () => {
|
|
356
|
+
let resolveFirstRefresh: () => void = () => undefined;
|
|
357
|
+
const firstRefresh = new Promise<void>((resolve) => {
|
|
358
|
+
resolveFirstRefresh = resolve;
|
|
359
|
+
});
|
|
360
|
+
const actionHandler = vi
|
|
361
|
+
.fn()
|
|
362
|
+
.mockImplementationOnce(() => firstRefresh)
|
|
363
|
+
.mockImplementation(async () => undefined);
|
|
364
|
+
const params = {
|
|
365
|
+
value: [
|
|
366
|
+
createRule({
|
|
367
|
+
condition: {
|
|
368
|
+
logic: '$and',
|
|
369
|
+
items: [{ path: '{{ ctx.formValues.status }}', operator: '$eq', value: 'active' }],
|
|
370
|
+
},
|
|
371
|
+
}),
|
|
372
|
+
],
|
|
373
|
+
};
|
|
374
|
+
const { ctx, formEmitter } = createRuntime(params, { actionHandler });
|
|
375
|
+
|
|
376
|
+
await actionLinkageRules.handler(ctx, params);
|
|
377
|
+
formEmitter.emit('formValuesChange', {
|
|
378
|
+
source: 'user',
|
|
379
|
+
txId: 'tx-1',
|
|
380
|
+
changedPaths: [['status']],
|
|
381
|
+
});
|
|
382
|
+
await waitFor(() => expect(actionHandler).toHaveBeenCalledTimes(1));
|
|
383
|
+
|
|
384
|
+
formEmitter.emit('formValuesChange', {
|
|
385
|
+
source: 'user',
|
|
386
|
+
txId: 'tx-2',
|
|
387
|
+
changedPaths: [['status']],
|
|
388
|
+
});
|
|
389
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
390
|
+
expect(actionHandler).toHaveBeenCalledTimes(1);
|
|
391
|
+
|
|
392
|
+
resolveFirstRefresh();
|
|
393
|
+
await waitFor(() => expect(actionHandler).toHaveBeenCalledTimes(2));
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('does not recursively refresh while handling its own linkage write event', async () => {
|
|
397
|
+
const params = {
|
|
398
|
+
value: [
|
|
399
|
+
createRule({
|
|
400
|
+
condition: {
|
|
401
|
+
logic: '$and',
|
|
402
|
+
items: [{ path: '{{ ctx.formValues.status }}', operator: '$eq', value: 'active' }],
|
|
403
|
+
},
|
|
404
|
+
}),
|
|
405
|
+
],
|
|
406
|
+
};
|
|
407
|
+
const formEmitter = new EventEmitter();
|
|
408
|
+
const actionHandler = vi.fn(async () => {
|
|
409
|
+
formEmitter.emit('formValuesChange', {
|
|
410
|
+
source: 'linkage',
|
|
411
|
+
txId: 'tx-linkage',
|
|
412
|
+
linkageTxId: 'tx-1',
|
|
413
|
+
changedPaths: [['status']],
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
const runtime = createRuntime(params, { actionHandler });
|
|
417
|
+
runtime.formEmitter.removeAllListeners();
|
|
418
|
+
formEmitter.on('formValuesChange', (...args) => runtime.formEmitter.emit('formValuesChange', ...args));
|
|
419
|
+
|
|
420
|
+
await actionLinkageRules.handler(runtime.ctx, params);
|
|
421
|
+
formEmitter.emit('formValuesChange', {
|
|
422
|
+
source: 'user',
|
|
423
|
+
txId: 'tx-1',
|
|
424
|
+
changedPaths: [['status']],
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
await waitFor(() => expect(actionHandler).toHaveBeenCalledTimes(1));
|
|
428
|
+
|
|
429
|
+
runtime.formEmitter.emit('formValuesChange', {
|
|
430
|
+
source: 'linkage',
|
|
431
|
+
txId: 'tx-linkage-late',
|
|
432
|
+
linkageTxId: 'tx-1',
|
|
433
|
+
changedPaths: [['status']],
|
|
434
|
+
});
|
|
435
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
436
|
+
expect(actionHandler).toHaveBeenCalledTimes(1);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
@@ -161,4 +161,46 @@ describe('linkageRulesRefresh action', () => {
|
|
|
161
161
|
expect(ctx.resolveJsonTemplate).toHaveBeenCalled();
|
|
162
162
|
expect(handler).toHaveBeenCalledWith(ctx, { value: ['x'] });
|
|
163
163
|
});
|
|
164
|
+
|
|
165
|
+
it('passes raw params to useRawParams linkage actions', async () => {
|
|
166
|
+
const handler = vi.fn(async () => {});
|
|
167
|
+
const rawParams = {
|
|
168
|
+
value: [
|
|
169
|
+
{
|
|
170
|
+
key: 'r1',
|
|
171
|
+
enable: true,
|
|
172
|
+
condition: { logic: '$and', items: [] },
|
|
173
|
+
actions: [
|
|
174
|
+
{
|
|
175
|
+
name: 'linkageRunjs',
|
|
176
|
+
params: {
|
|
177
|
+
value: {
|
|
178
|
+
script: 'return ctx.formValues.amount',
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
};
|
|
186
|
+
const model: any = {
|
|
187
|
+
isFork: false,
|
|
188
|
+
forks: new Set(),
|
|
189
|
+
getFlow: vi.fn(() => ({})),
|
|
190
|
+
getStepParams: vi.fn(() => rawParams),
|
|
191
|
+
};
|
|
192
|
+
const ctx: any = {
|
|
193
|
+
model,
|
|
194
|
+
resolveJsonTemplate: vi.fn(async () => ({ value: ['resolved'] })),
|
|
195
|
+
getAction: vi.fn(() => ({ useRawParams: true, handler })),
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
await linkageRulesRefresh.handler(ctx, {
|
|
199
|
+
actionName: 'actionLinkageRules',
|
|
200
|
+
flowKey: 'buttonSettings',
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(ctx.resolveJsonTemplate).not.toHaveBeenCalled();
|
|
204
|
+
expect(handler).toHaveBeenCalledWith(ctx, rawParams);
|
|
205
|
+
});
|
|
164
206
|
});
|
|
@@ -0,0 +1,190 @@
|
|
|
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, vi } from 'vitest';
|
|
11
|
+
import { FlowEngine, FlowModel } from '@nocobase/flow-engine';
|
|
12
|
+
import { FieldModel } from '../../models/base/FieldModel';
|
|
13
|
+
import { JSEditableFieldModel } from '../../models/fields/JSEditableFieldModel';
|
|
14
|
+
import { DetailsItemModel } from '../../models/blocks/details/DetailsItemModel';
|
|
15
|
+
import { pattern } from '../pattern';
|
|
16
|
+
|
|
17
|
+
class DummyDisplayFieldModel extends FieldModel {}
|
|
18
|
+
|
|
19
|
+
class DummyFormItemModel extends FlowModel<{ subModels: { field?: FieldModel } }> {
|
|
20
|
+
collectionField: any;
|
|
21
|
+
|
|
22
|
+
static getDefaultBindingByField() {
|
|
23
|
+
return { modelName: 'FieldModel' };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
getFieldSettingsInitParams() {
|
|
27
|
+
return { mock: true };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeCollectionField() {
|
|
32
|
+
return {
|
|
33
|
+
targetCollection: undefined,
|
|
34
|
+
isAssociationField: () => false,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeCtx(parent: DummyFormItemModel) {
|
|
39
|
+
const collectionField = makeCollectionField();
|
|
40
|
+
parent.collectionField = collectionField;
|
|
41
|
+
return {
|
|
42
|
+
model: parent,
|
|
43
|
+
collectionField,
|
|
44
|
+
engine: parent.flowEngine,
|
|
45
|
+
} as any;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('pattern action', () => {
|
|
49
|
+
it('keeps JS editable field model when switching to display only', async () => {
|
|
50
|
+
const engine = new FlowEngine();
|
|
51
|
+
engine.registerModels({
|
|
52
|
+
DummyFormItemModel,
|
|
53
|
+
FieldModel,
|
|
54
|
+
JSEditableFieldModel,
|
|
55
|
+
DummyDisplayFieldModel,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const parent = engine.createModel<DummyFormItemModel>({
|
|
59
|
+
use: DummyFormItemModel,
|
|
60
|
+
uid: 'form-item-js',
|
|
61
|
+
subModels: {
|
|
62
|
+
field: {
|
|
63
|
+
use: FieldModel,
|
|
64
|
+
uid: 'field-js',
|
|
65
|
+
stepParams: {
|
|
66
|
+
fieldBinding: {
|
|
67
|
+
use: 'JSEditableFieldModel',
|
|
68
|
+
},
|
|
69
|
+
jsSettings: {
|
|
70
|
+
runJs: {
|
|
71
|
+
code: 'ctx.render("hello")',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const field = parent.subModels.field;
|
|
79
|
+
const saveSpy = vi.spyOn(engine, 'saveModel');
|
|
80
|
+
const applyJsSettingsSpy = vi.spyOn(field as JSEditableFieldModel, 'scheduleApplyJsSettings');
|
|
81
|
+
|
|
82
|
+
await pattern.afterParamsSave?.(makeCtx(parent), { pattern: 'readPretty' }, { pattern: 'editable' });
|
|
83
|
+
|
|
84
|
+
expect(parent.subModels.field).toBe(field);
|
|
85
|
+
expect(parent.subModels.field).toBeInstanceOf(JSEditableFieldModel);
|
|
86
|
+
expect(parent.subModels.field?.uid).toBe('field-js');
|
|
87
|
+
expect(parent.subModels.field?.getStepParams('jsSettings', 'runJs')).toMatchObject({
|
|
88
|
+
code: 'ctx.render("hello")',
|
|
89
|
+
});
|
|
90
|
+
expect(saveSpy).not.toHaveBeenCalled();
|
|
91
|
+
expect(applyJsSettingsSpy).toHaveBeenCalledTimes(1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('keeps JS editable field model when leaving display only', async () => {
|
|
95
|
+
const engine = new FlowEngine();
|
|
96
|
+
engine.registerModels({
|
|
97
|
+
DummyFormItemModel,
|
|
98
|
+
FieldModel,
|
|
99
|
+
JSEditableFieldModel,
|
|
100
|
+
DummyDisplayFieldModel,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const parent = engine.createModel<DummyFormItemModel>({
|
|
104
|
+
use: DummyFormItemModel,
|
|
105
|
+
uid: 'form-item-js-leave',
|
|
106
|
+
subModels: {
|
|
107
|
+
field: {
|
|
108
|
+
use: FieldModel,
|
|
109
|
+
uid: 'field-js-leave',
|
|
110
|
+
stepParams: {
|
|
111
|
+
fieldBinding: {
|
|
112
|
+
use: 'JSEditableFieldModel',
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
const field = parent.subModels.field;
|
|
119
|
+
const saveSpy = vi.spyOn(engine, 'saveModel');
|
|
120
|
+
const applyJsSettingsSpy = vi.spyOn(field as JSEditableFieldModel, 'scheduleApplyJsSettings');
|
|
121
|
+
|
|
122
|
+
await pattern.afterParamsSave?.(makeCtx(parent), { pattern: 'editable' }, { pattern: 'readPretty' });
|
|
123
|
+
|
|
124
|
+
expect(parent.subModels.field).toBe(field);
|
|
125
|
+
expect(parent.subModels.field).toBeInstanceOf(JSEditableFieldModel);
|
|
126
|
+
expect(saveSpy).not.toHaveBeenCalled();
|
|
127
|
+
expect(applyJsSettingsSpy).toHaveBeenCalledTimes(1);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('does not reapply JS settings when pattern is unchanged', async () => {
|
|
131
|
+
const engine = new FlowEngine();
|
|
132
|
+
engine.registerModels({
|
|
133
|
+
DummyFormItemModel,
|
|
134
|
+
FieldModel,
|
|
135
|
+
JSEditableFieldModel,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const parent = engine.createModel<DummyFormItemModel>({
|
|
139
|
+
use: DummyFormItemModel,
|
|
140
|
+
uid: 'form-item-js-unchanged',
|
|
141
|
+
subModels: {
|
|
142
|
+
field: {
|
|
143
|
+
use: FieldModel,
|
|
144
|
+
uid: 'field-js-unchanged',
|
|
145
|
+
stepParams: {
|
|
146
|
+
fieldBinding: {
|
|
147
|
+
use: 'JSEditableFieldModel',
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
const applyJsSettingsSpy = vi.spyOn(parent.subModels.field as JSEditableFieldModel, 'scheduleApplyJsSettings');
|
|
154
|
+
|
|
155
|
+
await pattern.afterParamsSave?.(makeCtx(parent), { pattern: 'readPretty' }, { pattern: 'readPretty' });
|
|
156
|
+
|
|
157
|
+
expect(applyJsSettingsSpy).not.toHaveBeenCalled();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('still rebuilds regular fields when switching to display only', async () => {
|
|
161
|
+
const engine = new FlowEngine();
|
|
162
|
+
engine.registerModels({
|
|
163
|
+
DummyFormItemModel,
|
|
164
|
+
FieldModel,
|
|
165
|
+
DummyDisplayFieldModel,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const parent = engine.createModel<DummyFormItemModel>({
|
|
169
|
+
use: DummyFormItemModel,
|
|
170
|
+
uid: 'form-item-regular',
|
|
171
|
+
subModels: {
|
|
172
|
+
field: {
|
|
173
|
+
use: FieldModel,
|
|
174
|
+
uid: 'field-regular',
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
const ctx = makeCtx(parent);
|
|
179
|
+
const displayBinding = { modelName: 'DummyDisplayFieldModel', defaultProps: { display: true } } as any;
|
|
180
|
+
const getDisplayBindingSpy = vi.spyOn(DetailsItemModel, 'getDefaultBindingByField').mockReturnValue(displayBinding);
|
|
181
|
+
|
|
182
|
+
await pattern.afterParamsSave?.(ctx, { pattern: 'readPretty' }, { pattern: 'editable' });
|
|
183
|
+
|
|
184
|
+
expect(parent.subModels.field).toBeInstanceOf(DummyDisplayFieldModel);
|
|
185
|
+
expect(parent.subModels.field?.uid).toBe('field-regular');
|
|
186
|
+
expect(parent.subModels.field?.props).toMatchObject({ display: true });
|
|
187
|
+
|
|
188
|
+
getDisplayBindingSpy.mockRestore();
|
|
189
|
+
});
|
|
190
|
+
});
|