@nocobase/client-v2 2.4.0-alpha.5 → 2.4.0-alpha.6

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 (28) hide show
  1. package/es/components/form/ScanInput/useCodeScanner.d.ts +12 -2
  2. package/es/components/form/ScanInput/zxingWasmDecoder.d.ts +9 -0
  3. package/es/flow/components/FieldAssignValueInput.d.ts +2 -0
  4. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +1 -0
  5. package/es/flow/components/filter/VariableFilterItem.d.ts +7 -0
  6. package/es/index.mjs +16 -16
  7. package/lib/index.js +111 -111
  8. package/package.json +9 -8
  9. package/src/components/form/ScanInput/CodeScanner.tsx +3 -1
  10. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +7 -1
  11. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +182 -8
  12. package/src/components/form/ScanInput/__tests__/zxingWasmDecoder.test.ts +78 -0
  13. package/src/components/form/ScanInput/useCodeScanner.ts +135 -20
  14. package/src/components/form/ScanInput/zxingWasmDecoder.ts +64 -0
  15. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +39 -0
  16. package/src/flow/components/FieldAssignValueInput.tsx +4 -0
  17. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +21 -12
  18. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +9 -0
  19. package/src/flow/components/filter/VariableFilterItem.tsx +11 -3
  20. package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
  21. package/src/flow/models/base/GridModel.tsx +0 -1
  22. package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +22 -1
  23. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +14 -3
  24. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -0
  25. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +29 -0
  26. package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +1 -1
  27. package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx +61 -0
  28. package/src/flow/models/blocks/form/__tests__/popupLinkage.test.tsx +175 -0
@@ -47,6 +47,146 @@ class MockFlowModelRepository implements IFlowModelRepository {
47
47
  }
48
48
 
49
49
  describe('assignFieldValuesFlow (editor)', () => {
50
+ it('shows the deleted-field placeholder for a stale assigned value', async () => {
51
+ const engine = new FlowEngine();
52
+ engine.setModelRepository(new MockFlowModelRepository());
53
+ engine.registerModels({
54
+ AssignFormModel,
55
+ AssignFormGridModel,
56
+ AssignFormItemModel,
57
+ InputFieldModel,
58
+ VariableFieldFormModel,
59
+ });
60
+ engine.context.defineProperty('location', { value: { search: '' } });
61
+ engine.context.defineProperty('themeToken', { value: { marginLG: 24 } });
62
+ engine.context.defineProperty('flowSettingsEnabled', { value: true });
63
+
64
+ const main = engine.context.dataSourceManager.getDataSource('main');
65
+ main.addCollection({
66
+ name: 'users',
67
+ fields: [{ name: 'nickname', type: 'string', interface: 'input' }],
68
+ });
69
+ const users = engine.context.dataSourceManager.getCollection('main', 'users');
70
+
71
+ const action = engine.createModel({
72
+ use: 'FlowModel',
73
+ uid: 'act-assign-deleted-field',
74
+ });
75
+ action.setStepParams('assignSettings', 'assignFieldValues', {
76
+ assignedValues: { deletedField: 'stale value' },
77
+ });
78
+ action.context.defineProperty('blockModel', { value: { collection: users } });
79
+
80
+ const step = createAssignFieldValuesStep({ settingsFlowKey: 'assignSettings' });
81
+ const Editor = step.uiSchema().editor?.['x-component'] as React.ComponentType;
82
+ const flowSettingsCtx = new FlowRuntimeContext(action, 'assignSettings', 'settings');
83
+
84
+ render(
85
+ <FlowEngineProvider engine={engine}>
86
+ <ConfigProvider>
87
+ <App>
88
+ <FlowSettingsContextProvider value={flowSettingsCtx}>
89
+ <Editor />
90
+ </FlowSettingsContextProvider>
91
+ </App>
92
+ </ConfigProvider>
93
+ </FlowEngineProvider>,
94
+ );
95
+
96
+ await waitFor(() => {
97
+ expect(screen.getByRole('button', { name: /Fields/ })).toBeInTheDocument();
98
+ expect(screen.getByText(/deletedField.*may have been deleted/)).toBeInTheDocument();
99
+ const form = engine.findModelByParentId<AssignFormModel>(action.uid, 'assignForm');
100
+ expect(form).toBeDefined();
101
+ expect(form?.subModels.grid.subModels.items || []).toHaveLength(1);
102
+ expect(form?.getAssignedValues()).toEqual({});
103
+ });
104
+ });
105
+
106
+ it('shows the standard deleted-field placeholder for a persisted assigned field model', async () => {
107
+ const engine = new FlowEngine();
108
+ engine.setModelRepository(new MockFlowModelRepository());
109
+ engine.registerModels({
110
+ AssignFormModel,
111
+ AssignFormGridModel,
112
+ AssignFormItemModel,
113
+ InputFieldModel,
114
+ VariableFieldFormModel,
115
+ });
116
+ engine.context.defineProperty('location', { value: { search: '' } });
117
+ engine.context.defineProperty('themeToken', { value: { marginLG: 24 } });
118
+ engine.context.defineProperty('flowSettingsEnabled', { value: true });
119
+
120
+ const main = engine.context.dataSourceManager.getDataSource('main');
121
+ main.addCollection({
122
+ name: 'users',
123
+ fields: [{ name: 'nickname', type: 'string', interface: 'input' }],
124
+ });
125
+ const users = engine.context.dataSourceManager.getCollection('main', 'users');
126
+
127
+ const action = engine.createModel({
128
+ use: 'FlowModel',
129
+ uid: 'act-assign-persisted-deleted-field',
130
+ });
131
+ action.setStepParams('assignSettings', 'assignFieldValues', {
132
+ assignedValues: { deletedField: 'stale value' },
133
+ });
134
+ action.context.defineProperty('blockModel', { value: { collection: users } });
135
+
136
+ const form = engine.createModel<AssignFormModel>({
137
+ use: 'AssignFormModel',
138
+ uid: 'form-assign-persisted-deleted-field',
139
+ parentId: action.uid,
140
+ subKey: 'assignForm',
141
+ stepParams: {
142
+ resourceSettings: {
143
+ init: {
144
+ dataSourceKey: 'main',
145
+ collectionName: 'users',
146
+ },
147
+ },
148
+ },
149
+ });
150
+ action.setSubModel('assignForm', form);
151
+ form.subModels.grid.addSubModel('items', {
152
+ use: 'AssignFormItemModel',
153
+ uid: 'item-assign-persisted-deleted-field',
154
+ stepParams: {
155
+ fieldSettings: {
156
+ init: {
157
+ dataSourceKey: 'main',
158
+ collectionName: 'users',
159
+ fieldPath: 'deletedField',
160
+ },
161
+ assignValue: { value: 'stale value' },
162
+ },
163
+ },
164
+ });
165
+ form.subModels.grid.resetRows(true);
166
+
167
+ const step = createAssignFieldValuesStep({ settingsFlowKey: 'assignSettings' });
168
+ const Editor = step.uiSchema().editor?.['x-component'] as React.ComponentType;
169
+ const flowSettingsCtx = new FlowRuntimeContext(action, 'assignSettings', 'settings');
170
+
171
+ render(
172
+ <FlowEngineProvider engine={engine}>
173
+ <ConfigProvider>
174
+ <App>
175
+ <FlowSettingsContextProvider value={flowSettingsCtx}>
176
+ <Editor />
177
+ </FlowSettingsContextProvider>
178
+ </App>
179
+ </ConfigProvider>
180
+ </FlowEngineProvider>,
181
+ );
182
+
183
+ await waitFor(() => {
184
+ expect(screen.getByText(/deletedField.*may have been deleted/)).toBeInTheDocument();
185
+ expect(form.subModels.grid.subModels.items).toHaveLength(1);
186
+ expect(form.getAssignedValues()).toEqual({});
187
+ });
188
+ });
189
+
50
190
  it('repairs AssignFormModel resource init and clears cached collection', async () => {
51
191
  const engine = new FlowEngine();
52
192
  engine.setModelRepository(new MockFlowModelRepository());
@@ -21,6 +21,35 @@ describe('FilterFormGridModel.toggleFormFieldsCollapse', () => {
21
21
  engine.registerModels({ FilterFormGridModel });
22
22
  });
23
23
 
24
+ it.each([false, true])('preserves collapsed fields in mobile layout with settings enabled: %s', (settingsEnabled) => {
25
+ engine.flowSettings[settingsEnabled ? 'enable' : 'disable']();
26
+ const rows = {
27
+ first: [['field-1']],
28
+ second: [['field-2']],
29
+ third: [['field-3']],
30
+ };
31
+ const model = engine.createModel<FilterFormGridModel>({
32
+ uid: 'mobile-filter-grid',
33
+ use: 'FilterFormGridModel',
34
+ props: { rows },
35
+ subModels: {
36
+ items: ['field-1', 'field-2', 'field-3'].map((uid) => ({ use: 'FlowModel', uid })),
37
+ },
38
+ });
39
+ model.context.defineProperty('isMobileLayout', { value: true });
40
+ model.setStepParams(GRID_FLOW_KEY, GRID_STEP, { rows });
41
+ type VisibleLayoutReader = { getVisibleLayout(): { rows: Record<string, string[][]> } };
42
+ const renderedItems = () =>
43
+ Object.values((model as unknown as VisibleLayoutReader).getVisibleLayout().rows).flat(2);
44
+
45
+ expect(renderedItems()).toEqual(['field-1', 'field-2', 'field-3']);
46
+ model.toggleFormFieldsCollapse(true, 1);
47
+ expect(renderedItems()).toEqual(['field-1']);
48
+ model.toggleFormFieldsCollapse(false, 1);
49
+ expect(renderedItems()).toEqual(['field-1', 'field-2', 'field-3']);
50
+ expect(model.getStepParams(GRID_FLOW_KEY, GRID_STEP).rows).toEqual(rows);
51
+ });
52
+
24
53
  it('uses rowOrder from the full layout when collapsing after reorder', () => {
25
54
  const model = engine.createModel<FilterFormGridModel>({
26
55
  uid: 'filter-grid-collapse-order',
@@ -351,7 +351,7 @@ export const FieldComponentProps: React.FC<{ fieldModel: string; source: string[
351
351
  <FormItem label={t('Options')}>
352
352
  <Space direction="vertical" size={8} style={{ width: '100%' }}>
353
353
  {options.map((option: any, index: number) => (
354
- <Space key={`${option.value}-${index}`} style={{ width: '100%' }} size={8} wrap align="start">
354
+ <Space key={index} style={{ width: '100%' }} size={8} wrap align="start">
355
355
  <Input
356
356
  style={{ flex: 1, minWidth: 120 }}
357
357
  placeholder={t('Option label')}
@@ -0,0 +1,61 @@
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 React from 'react';
11
+ import { describe, expect, it } from 'vitest';
12
+ import { createForm } from '@formily/core';
13
+ import { Field, FormProvider } from '@formily/react';
14
+ import { render, screen } from '@nocobase/test/client';
15
+ import userEvent from '@testing-library/user-event';
16
+ import { FlowEngine, FlowEngineProvider, FlowModel, FlowModelProvider } from '@nocobase/flow-engine';
17
+ import { FieldComponentProps } from '../FieldComponentProps';
18
+
19
+ class HostModel extends FlowModel {
20
+ render() {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ describe.each(['RadioGroupFieldModel', 'CheckboxGroupFieldModel', 'SelectFieldModel'])('%s options', (fieldModel) => {
26
+ it('keeps focus while editing option values and preserves remaining rows after removal', async () => {
27
+ const form = createForm();
28
+ const engine = new FlowEngine();
29
+ engine.registerModels({ HostModel });
30
+ const model = engine.createModel<HostModel>({ use: 'HostModel' });
31
+ render(
32
+ <FlowEngineProvider engine={engine}>
33
+ <FlowModelProvider model={model}>
34
+ <FormProvider form={form}>
35
+ <Field name="props" component={[FieldComponentProps, { fieldModel, source: [] }]} />
36
+ </FormProvider>
37
+ </FlowModelProvider>
38
+ </FlowEngineProvider>,
39
+ );
40
+
41
+ await userEvent.click(screen.getByRole('button', { name: 'plus Add' }));
42
+ const valueInput = screen.getByPlaceholderText('Option value');
43
+ await userEvent.type(valueInput, 'abcdef');
44
+ expect(screen.getByPlaceholderText('Option value')).toHaveValue('abcdef');
45
+ expect(valueInput).toHaveFocus();
46
+
47
+ await userEvent.keyboard('{Backspace}{Backspace}xy');
48
+ expect(valueInput).toHaveValue('abcdxy');
49
+ expect(valueInput).toHaveFocus();
50
+ await userEvent.type(screen.getByPlaceholderText('Option label'), 'First');
51
+ await userEvent.click(screen.getByRole('button', { name: 'plus Add' }));
52
+ await userEvent.type(screen.getAllByPlaceholderText('Option label')[1], 'Second');
53
+ await userEvent.type(screen.getAllByPlaceholderText('Option value')[1], 'second');
54
+ await userEvent.click(screen.getAllByRole('button', { name: 'close' })[0]);
55
+ expect(screen.getByPlaceholderText('Option label')).toHaveValue('Second');
56
+ expect(screen.getByPlaceholderText('Option value')).toHaveValue('second');
57
+ await userEvent.type(screen.getByPlaceholderText('Option value'), '2');
58
+ expect(screen.getByPlaceholderText('Option value')).toHaveFocus();
59
+ expect(form.values.props.options).toEqual([{ label: 'Second', value: 'second2' }]);
60
+ });
61
+ });
@@ -0,0 +1,175 @@
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 React from 'react';
11
+ import { act, render, screen, waitFor, cleanup } from '@testing-library/react';
12
+ import { Form, Input } from 'antd';
13
+ import { afterEach, expect, it, vi } from 'vitest';
14
+ import { FlowEngine, SingleRecordResource } from '@nocobase/flow-engine';
15
+ import { generateFlowModelRdFromToken } from '@nocobase/utils/client';
16
+ import { CreateFormModel, EditFormModel, FormGridModel, FormItemModel, FormComponent } from '../../../..';
17
+ import { fieldLinkageRules, linkageAssignField } from '../../../../actions/linkageRules';
18
+
19
+ afterEach(cleanup);
20
+
21
+ it.each([
22
+ { use: 'CreateFormModel', mode: 'default', expected: 'STAFF-001' },
23
+ { use: 'EditFormModel', mode: 'default', expected: '' },
24
+ { use: 'EditFormModel', mode: 'override', expected: 'STAFF-001' },
25
+ ])('$use respects $mode linkage after a delayed popup variable response', async ({ use, mode, expected }) => {
26
+ const uid = `popup-${use}-${mode}`;
27
+ const engine = new FlowEngine();
28
+ engine.registerModels({ CreateFormModel, EditFormModel, FormGridModel, FormItemModel });
29
+ engine.registerActions({ fieldLinkageRules, linkageAssignField });
30
+ engine.context.dataSourceManager.getDataSource('main').addCollection({
31
+ name: 't1_user',
32
+ filterTargetKey: 'id',
33
+ fields: [
34
+ { name: 'id', type: 'integer', interface: 'number' },
35
+ { name: 'staffseq', type: 'string', interface: 'input' },
36
+ { name: 'staffname', type: 'string', interface: 'input' },
37
+ ],
38
+ });
39
+ const form = engine.createModel<CreateFormModel | EditFormModel>({
40
+ uid,
41
+ use,
42
+ stepParams: { resourceSettings: { init: { dataSourceKey: 'main', collectionName: 't1_user', filterByTk: 1 } } },
43
+ subModels: {
44
+ grid: {
45
+ uid: `${uid}-grid`,
46
+ use: 'FormGridModel',
47
+ subModels: {
48
+ items: [
49
+ {
50
+ uid: `${uid}-staffname`,
51
+ use: 'FormItemModel',
52
+ props: { name: 'staffname' },
53
+ stepParams: {
54
+ fieldSettings: { init: { dataSourceKey: 'main', collectionName: 't1_user', fieldPath: 'staffname' } },
55
+ },
56
+ },
57
+ ],
58
+ },
59
+ },
60
+ },
61
+ });
62
+ const configured = '{{ ctx.popup.record.staffseq }}';
63
+ const rules = {
64
+ value: [
65
+ {
66
+ key: 'rule',
67
+ title: 'Rule',
68
+ enable: true,
69
+ condition: { logic: '$and', items: [] },
70
+ actions: [
71
+ {
72
+ key: 'assign',
73
+ name: 'linkageAssignField',
74
+ params: {
75
+ value: [
76
+ {
77
+ key: 'field',
78
+ enable: true,
79
+ mode,
80
+ condition: { logic: '$and', items: [] },
81
+ targetPath: 'staffname',
82
+ value: configured,
83
+ },
84
+ ],
85
+ },
86
+ },
87
+ ],
88
+ },
89
+ ],
90
+ };
91
+ form.setStepParams('eventSettings', 'linkageRules', rules);
92
+ const saved = JSON.parse(JSON.stringify(form.serialize()));
93
+ expect(saved.subModels.grid.stepParams.eventSettings.linkageRules).toEqual(rules);
94
+ expect(saved.stepParams.eventSettings?.linkageRules).toBeUndefined();
95
+
96
+ const token = `test.${Buffer.from(JSON.stringify({ userId: 2, signInTime: 'member-form' })).toString('base64url')}.sig`;
97
+ type Item = { id: string; rd: string; template: unknown; contextParams: unknown };
98
+ let resolveResponse: (() => void) | undefined;
99
+ const responseReady = new Promise<void>((resolve) => {
100
+ resolveResponse = resolve;
101
+ });
102
+ const request = vi.fn(async ({ data }: { data: { values: { batch: Item[] } } }) => {
103
+ const batch = data.values.batch;
104
+ expect(batch[0].rd).toBe(generateFlowModelRdFromToken(form.uid, token));
105
+ expect(batch[0].contextParams).toEqual({
106
+ 'popup.record': { collection: 't1_user', dataSourceKey: 'main', filterByTk: '1' },
107
+ });
108
+ await responseReady;
109
+ return {
110
+ data: {
111
+ data: {
112
+ results: batch.map((item) => ({
113
+ id: item.id,
114
+ data: JSON.parse(JSON.stringify(item.template).replaceAll(configured, 'STAFF-001')),
115
+ })),
116
+ },
117
+ },
118
+ };
119
+ });
120
+ engine.context.defineProperty('api', { value: { auth: { token, role: 'member' }, request } });
121
+ form.context.defineProperty('popup', {
122
+ value: { record: { id: 1 } },
123
+ resolveOnServer: true,
124
+ meta: {
125
+ type: 'object',
126
+ buildVariablesParams: () => ({ record: { collection: 't1_user', dataSourceKey: 'main', filterByTk: '1' } }),
127
+ },
128
+ });
129
+ const resource = form.resource as SingleRecordResource;
130
+ resource.setData(use === 'EditFormModel' ? { id: 1, staffname: null } : {});
131
+ function View() {
132
+ form.useHooksBeforeRender();
133
+ return (
134
+ <FormComponent model={form}>
135
+ <Form.Item name="staffname">
136
+ <Input aria-label="staffname" />
137
+ </Form.Item>
138
+ </FormComponent>
139
+ );
140
+ }
141
+ const dispatchEvent = vi.spyOn(form, 'dispatchEvent');
142
+ const view = render(<View />);
143
+ try {
144
+ form.formValueRuntime?.mount({ sync: true });
145
+ // Simulate the field mounting, without involving the page layout and field-renderer plugins.
146
+ engine.emitter.emit('model:mounted', { model: form.subModels.grid.subModels.items[0] });
147
+ let pending: Promise<unknown> | undefined;
148
+ await act(async () => {
149
+ pending = form.applyFlow('eventSettings');
150
+ });
151
+ await waitFor(() => expect(request).toHaveBeenCalled());
152
+ await act(async () => {
153
+ resolveResponse?.();
154
+ await pending;
155
+ });
156
+ if (mode === 'default') {
157
+ // Resolving a default value must not overwrite an existing edit record.
158
+ expect(form.subModels.grid.subModels.items[0].props.initialValue).toBe('STAFF-001');
159
+ }
160
+ await waitFor(() => expect((screen.getByLabelText('staffname') as HTMLInputElement).value).toBe(expected));
161
+ expect(form.form.getFieldValue('staffname')).toBe(expected || null);
162
+ } finally {
163
+ resolveResponse?.();
164
+ await act(async () => {
165
+ view.unmount();
166
+ form.formValueRuntime?.dispose();
167
+ // Drain automatic linkage refreshes before another engine starts its variable batch.
168
+ await Promise.all(
169
+ dispatchEvent.mock.results.filter((result) => result.type === 'return').map((result) => result.value),
170
+ );
171
+ });
172
+ dispatchEvent.mockRestore();
173
+ engine.disposeScheduler();
174
+ }
175
+ });