@nocobase/client-v2 2.3.0-alpha.1 → 2.3.0-beta.11

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 (173) hide show
  1. package/es/APIClient.d.ts +12 -0
  2. package/es/components/form/ScanInput/useCodeScanner.d.ts +12 -2
  3. package/es/components/form/ScanInput/zxingWasmDecoder.d.ts +9 -0
  4. package/es/flow/actions/linkageRules.d.ts +25 -0
  5. package/es/flow/components/FieldAssignValueInput.d.ts +6 -30
  6. package/es/flow/components/FlowRoute.d.ts +2 -2
  7. package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
  8. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +32 -0
  9. package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
  10. package/es/flow/components/field-value-variable/index.d.ts +11 -0
  11. package/es/flow/components/filter/VariableFilterItem.d.ts +7 -0
  12. package/es/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
  13. package/es/flow/internal/registerDeviceTypeContext.d.ts +10 -0
  14. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
  15. package/es/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
  16. package/es/flow/models/base/ActionModelCore.d.ts +4 -2
  17. package/es/flow/models/base/BlockGridModel.d.ts +3 -0
  18. package/es/flow/models/base/CollectionBlockModel.d.ts +3 -0
  19. package/es/flow/models/blocks/details/DetailsBlockModel.d.ts +3 -0
  20. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
  21. package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
  22. package/es/flow/models/blocks/form/FormBlockModel.d.ts +7 -0
  23. package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
  24. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
  25. package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
  26. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  27. package/es/flow/models/blocks/table/TableColumnModel.d.ts +12 -0
  28. package/es/flow/models/blocks/table/utils.d.ts +1 -0
  29. package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +2 -1
  30. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +0 -17
  31. package/es/flow/models/fields/AssociationFieldModel/recordSelectShared.d.ts +41 -0
  32. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
  33. package/es/flow-compat/passwordUtils.d.ts +1 -1
  34. package/es/index.d.ts +1 -0
  35. package/es/index.mjs +148 -145
  36. package/es/ui-operation/index.d.ts +15 -0
  37. package/es/ui-operation/ui-operation-codec.d.ts +10 -0
  38. package/lib/index.js +170 -167
  39. package/lib/locale/languageCodes.js +1 -0
  40. package/package.json +10 -8
  41. package/src/APIClient.ts +92 -0
  42. package/src/Application.tsx +3 -1
  43. package/src/__tests__/APIClient.test.tsx +58 -0
  44. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +7 -2
  45. package/src/__tests__/settings-center.test.tsx +270 -6
  46. package/src/acl/ACLProvider.tsx +2 -0
  47. package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
  48. package/src/collection-manager/__tests__/field-configure.test.ts +52 -0
  49. package/src/collection-manager/field-configure.ts +2 -2
  50. package/src/components/form/ScanInput/CodeScanner.tsx +64 -35
  51. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +107 -0
  52. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +294 -24
  53. package/src/components/form/ScanInput/__tests__/zxingWasmDecoder.test.ts +78 -0
  54. package/src/components/form/ScanInput/useCodeScanner.ts +281 -23
  55. package/src/components/form/ScanInput/zxingWasmDecoder.ts +64 -0
  56. package/src/components/form/filter/CollectionFilterItem.tsx +4 -2
  57. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +17 -0
  58. package/src/components/form/table/Table.tsx +81 -6
  59. package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
  60. package/src/flow/__tests__/FlowRoute.test.tsx +126 -8
  61. package/src/flow/__tests__/PluginFlowEngine.test.ts +58 -0
  62. package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +314 -0
  63. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
  64. package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
  65. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
  66. package/src/flow/actions/__tests__/validation.test.ts +48 -0
  67. package/src/flow/actions/linkageRules.tsx +59 -16
  68. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +78 -59
  69. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +228 -0
  70. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +42 -23
  71. package/src/flow/common/Markdown/style.ts +4 -0
  72. package/src/flow/components/BlockItemCard.tsx +2 -2
  73. package/src/flow/components/FieldAssignValueInput.tsx +42 -621
  74. package/src/flow/components/FlowRoute.tsx +71 -29
  75. package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
  76. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +335 -0
  77. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +389 -0
  78. package/src/flow/components/field-value-variable/dateValue.ts +223 -0
  79. package/src/flow/components/field-value-variable/index.ts +12 -0
  80. package/src/flow/components/filter/VariableFilterItem.tsx +23 -6
  81. package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
  82. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +125 -0
  83. package/src/flow/components/placeholders/BlockPlaceholder.tsx +70 -23
  84. package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +57 -7
  85. package/src/flow/index.ts +2 -0
  86. package/src/flow/internal/components/Markdown/DisplayMarkdown.tsx +14 -9
  87. package/src/flow/internal/components/Markdown/__tests__/DisplayMarkdown.test.tsx +46 -0
  88. package/src/flow/internal/registerDeviceTypeContext.ts +39 -0
  89. package/src/flow/internal/utils/operatorSchemaHelper.ts +3 -2
  90. package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
  91. package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
  92. package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
  93. package/src/flow/models/base/ActionModelCore.tsx +11 -2
  94. package/src/flow/models/base/BlockGridModel.tsx +26 -0
  95. package/src/flow/models/base/CollectionBlockModel.tsx +38 -3
  96. package/src/flow/models/base/GridModel.tsx +0 -1
  97. package/src/flow/models/base/PageModel/PageTabModel.tsx +67 -17
  98. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +509 -12
  99. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
  100. package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
  101. package/src/flow/models/base/__tests__/CollectionBlockModel.addAppends.test.ts +41 -0
  102. package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +125 -9
  103. package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +22 -1
  104. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +36 -50
  105. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -0
  106. package/src/flow/models/blocks/details/DetailsBlockModel.tsx +8 -3
  107. package/src/flow/models/blocks/details/__tests__/DetailsBlockModel.initialPaginationChangeRefresh.test.ts +32 -1
  108. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +27 -3
  109. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
  110. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +129 -14
  111. package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
  112. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
  113. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +29 -0
  114. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +359 -1
  115. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
  116. package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
  117. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +32 -4
  118. package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +1 -1
  119. package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx +61 -0
  120. package/src/flow/models/blocks/filter-manager/FilterManager.ts +5 -0
  121. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +40 -0
  122. package/src/flow/models/blocks/form/FormBlockModel.tsx +73 -5
  123. package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
  124. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +153 -2
  125. package/src/flow/models/blocks/form/__tests__/popupLinkage.test.tsx +175 -0
  126. package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
  127. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
  128. package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
  129. package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
  130. package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
  131. package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
  132. package/src/flow/models/blocks/table/TableBlockModel.tsx +28 -2
  133. package/src/flow/models/blocks/table/TableColumnModel.tsx +48 -9
  134. package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
  135. package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -0
  136. package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
  137. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
  138. package/src/flow/models/blocks/table/utils.ts +7 -0
  139. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
  140. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +12 -3
  141. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
  142. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
  143. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +29 -3
  144. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -136
  145. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +5 -1
  146. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +191 -3
  147. package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
  148. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
  149. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
  150. package/src/flow/models/fields/DisplayAssociationField/DisplaySubTableFieldModel.tsx +42 -7
  151. package/src/flow/models/fields/DisplayAssociationField/__tests__/DisplaySubTableFieldModel.test.tsx +351 -0
  152. package/src/flow/models/fields/DisplayHtmlFieldModel.tsx +2 -1
  153. package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
  154. package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
  155. package/src/flow/models/fields/NumberFieldModel.tsx +1 -1
  156. package/src/flow/models/fields/RichTextFieldModel/__tests__/RichTextFieldModel.test.tsx +74 -0
  157. package/src/flow/models/fields/RichTextFieldModel/index.tsx +27 -6
  158. package/src/flow/models/fields/__tests__/DisplayNumberFieldModel.test.ts +33 -0
  159. package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
  160. package/src/flow/models/fields/__tests__/NumberFieldModel.test.tsx +47 -0
  161. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +6 -0
  162. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
  163. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +139 -13
  164. package/src/flow/models/topbar/TopbarActionModel.tsx +5 -5
  165. package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
  166. package/src/index.ts +1 -0
  167. package/src/layout-manager/LayoutContentRoute.tsx +2 -1
  168. package/src/layout-manager/LayoutRoute.tsx +2 -1
  169. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
  170. package/src/locale/languageCodes.ts +1 -0
  171. package/src/settings-center/AdminSettingsLayout.tsx +23 -2
  172. package/src/ui-operation/index.ts +33 -0
  173. package/src/ui-operation/ui-operation-codec.ts +54 -0
@@ -9,6 +9,8 @@
9
9
 
10
10
  import React from 'react';
11
11
  import { largeField, EditableItemModel } from '@nocobase/flow-engine';
12
+ import { sanitizeRichTextHtml } from '@nocobase/utils/client';
13
+ import type ReactQuillComponent from 'react-quill';
12
14
  import { lazy } from '../../../../flow-compat';
13
15
  import { useRichTextStyles } from './style';
14
16
  import { FieldModel } from '../../base';
@@ -26,6 +28,8 @@ const ReactQuill = lazy(async () => {
26
28
  return import('react-quill');
27
29
  });
28
30
 
31
+ type ReactQuillValue = ReactQuillComponent.ReactQuillProps['value'];
32
+
29
33
  export const RichTextField = (props) => {
30
34
  const richTextClass = useRichTextStyles();
31
35
  const boundsClass = React.useMemo(() => `quill-bounds-${Math.random().toString(36).slice(2, 9)}`, []);
@@ -83,19 +87,36 @@ export const RichTextField = (props) => {
83
87
  'break',
84
88
  ];
85
89
  const { value, onChange, disabled, modules: propsModules, formats: propsFormats } = props;
90
+ const previousIncomingValueRef = React.useRef(value);
91
+ const pendingEditorValueRef = React.useRef<{ value: ReactQuillValue }>();
92
+ const [editorValue, setEditorValue] = React.useState<ReactQuillValue>(() =>
93
+ typeof value === 'string' ? sanitizeRichTextHtml(value) : value,
94
+ );
95
+
96
+ React.useEffect(() => {
97
+ if (Object.is(value, previousIncomingValueRef.current)) {
98
+ return;
99
+ }
100
+ previousIncomingValueRef.current = value;
101
+ const pendingEditorValue = pendingEditorValueRef.current;
102
+ pendingEditorValueRef.current = undefined;
103
+ if (pendingEditorValue && Object.is(value, pendingEditorValue.value)) {
104
+ return;
105
+ }
106
+ setEditorValue(typeof value === 'string' ? sanitizeRichTextHtml(value) : value);
107
+ }, [value]);
86
108
 
87
109
  return (
88
110
  <ReactQuill
89
111
  className={`${richTextClass} ${boundsClass}`}
90
112
  modules={propsModules || modules}
91
113
  formats={propsFormats || formats}
92
- value={value}
114
+ value={editorValue}
93
115
  onChange={(value) => {
94
- if (value === '<p><br></p>') {
95
- onChange('');
96
- } else {
97
- onChange(value);
98
- }
116
+ const nextValue = value === '<p><br></p>' ? '' : value;
117
+ pendingEditorValueRef.current = { value: nextValue };
118
+ setEditorValue(nextValue);
119
+ onChange(nextValue);
99
120
  }}
100
121
  readOnly={disabled}
101
122
  bounds={`.${boundsClass}`}
@@ -0,0 +1,33 @@
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 } from 'vitest';
11
+ import { formatNumber } from '../DisplayNumberFieldModel';
12
+
13
+ describe('formatNumber', () => {
14
+ it('formats a 30-digit decimal without scientific notation or precision loss', () => {
15
+ expect(
16
+ formatNumber({
17
+ value: '123456789012345678901234567890',
18
+ formatStyle: 'normal',
19
+ step: '1',
20
+ }),
21
+ ).toBe('123,456,789,012,345,678,901,234,567,890');
22
+ });
23
+
24
+ it('formats the 22-digit value from the reported v2 scenario', () => {
25
+ expect(
26
+ formatNumber({
27
+ value: '1234567890123458152112',
28
+ formatStyle: 'normal',
29
+ step: '1',
30
+ }),
31
+ ).toBe('1,234,567,890,123,458,152,112');
32
+ });
33
+ });
@@ -0,0 +1,82 @@
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, FlowModel } from '@nocobase/flow-engine';
11
+ import { describe, expect, it } from 'vitest';
12
+ import { JsonFieldModel } from '../JsonFieldModel';
13
+
14
+ interface ValidationRule {
15
+ required?: boolean;
16
+ type?: string;
17
+ validator?: (_rule: unknown, value: unknown) => Promise<void>;
18
+ }
19
+
20
+ function createModels(rules: ValidationRule[]) {
21
+ const engine = new FlowEngine();
22
+ engine.registerModels({ JsonFieldModel });
23
+ const parent = engine.createModel<FlowModel<{ subModels: { field: JsonFieldModel } }>>({
24
+ use: FlowModel,
25
+ uid: 'json-form-item',
26
+ props: { rules },
27
+ subModels: {
28
+ field: {
29
+ use: JsonFieldModel,
30
+ uid: 'json-field',
31
+ },
32
+ },
33
+ });
34
+
35
+ return { field: parent.subModels.field, parent };
36
+ }
37
+
38
+ function initializeJsonValidation(field: JsonFieldModel) {
39
+ const step = field.getFlow('jsonInitSetting')?.steps.initValidation;
40
+ if (!step) {
41
+ throw new Error('JSON validation step is not registered');
42
+ }
43
+ step.handler(field.context, {});
44
+ }
45
+
46
+ describe('JsonFieldModel validation', () => {
47
+ it('replaces legacy empty rules with one serializable JSON validation rule', async () => {
48
+ const { field, parent } = createModels([{}, { required: true }]);
49
+
50
+ initializeJsonValidation(field);
51
+
52
+ const rules = parent.props.rules as ValidationRule[];
53
+ expect(rules).toHaveLength(2);
54
+ expect(rules[0]).toEqual({ required: true });
55
+ expect(rules[1]).toMatchObject({ type: 'any' });
56
+ expect(rules[1].validator).toBeTypeOf('function');
57
+ if (!rules[1].validator) {
58
+ throw new Error('JSON validator is missing');
59
+ }
60
+ await expect(rules[1].validator({}, { name: 'NocoBase' })).resolves.toBeUndefined();
61
+ await expect(rules[1].validator({}, '{"name":')).rejects.toContain('Invalid JSON format');
62
+
63
+ const persistedRules = JSON.parse(JSON.stringify(parent.serialize())).props.rules as ValidationRule[];
64
+ expect(persistedRules).toHaveLength(2);
65
+ expect(persistedRules[1]).toMatchObject({ type: 'any' });
66
+ expect(persistedRules[1].validator).toBeUndefined();
67
+ });
68
+
69
+ it('re-registers the runtime validator without duplicating its persisted placeholder', () => {
70
+ const { field, parent } = createModels([{}]);
71
+ initializeJsonValidation(field);
72
+ const persistedRules = JSON.parse(JSON.stringify(parent.serialize())).props.rules as ValidationRule[];
73
+ parent.setProps({ rules: persistedRules });
74
+
75
+ initializeJsonValidation(field);
76
+
77
+ const rules = parent.props.rules as ValidationRule[];
78
+ expect(rules).toHaveLength(1);
79
+ expect(rules[0]).toMatchObject({ type: 'any' });
80
+ expect(rules[0].validator).toBeTypeOf('function');
81
+ });
82
+ });
@@ -0,0 +1,47 @@
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 { fireEvent, render, screen } from '@testing-library/react';
11
+ import React from 'react';
12
+ import { describe, expect, it, vi } from 'vitest';
13
+ import { InputNumberField } from '../NumberFieldModel';
14
+
15
+ describe('InputNumberField', () => {
16
+ it('keeps the existing number value type for regular input', () => {
17
+ const onChange = vi.fn();
18
+
19
+ render(<InputNumberField stringMode onChange={onChange} />);
20
+ fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '123' } });
21
+
22
+ expect(onChange).toHaveBeenLastCalledWith(123);
23
+ expect(onChange).not.toHaveBeenCalledWith('123');
24
+ });
25
+
26
+ it('preserves a high-precision decimal string in string mode', () => {
27
+ const onChange = vi.fn();
28
+ const value = '123456789012345678901234567890';
29
+
30
+ render(<InputNumberField stringMode onChange={onChange} />);
31
+ fireEvent.change(screen.getByRole('spinbutton'), { target: { value } });
32
+
33
+ expect(onChange).toHaveBeenLastCalledWith(value);
34
+ expect(screen.getByRole('spinbutton')).toHaveValue(value);
35
+ });
36
+
37
+ it('does not switch to scientific notation at the 22-digit boundary', () => {
38
+ const onChange = vi.fn();
39
+ const value = '1234567890123458152112';
40
+
41
+ render(<InputNumberField stringMode onChange={onChange} />);
42
+ fireEvent.change(screen.getByRole('spinbutton'), { target: { value } });
43
+
44
+ expect(onChange).toHaveBeenLastCalledWith(value);
45
+ expect(onChange).not.toHaveBeenCalledWith('1.234567890123458152112e+21');
46
+ });
47
+ });
@@ -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') {
@@ -291,6 +296,7 @@ export function MobileLazySelect(props: Readonly<LazySelectProps>) {
291
296
  value={searchText}
292
297
  onChange={handleSearchChange}
293
298
  onCancel={handleClose}
299
+ cancelText={t('Cancel')}
294
300
  showCancelButton
295
301
  />
296
302
  </div>
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { useFlowModelContext } from '@nocobase/flow-engine';
11
11
  import { Select } from 'antd';
12
- import { Button, CheckList, Popup, SearchBar } from 'antd-mobile';
12
+ import { Button, CheckList, ConfigProvider, Popup, SearchBar, useConfig } from 'antd-mobile';
13
13
  import React, { useEffect, useMemo, useState } from 'react';
14
14
 
15
15
  const mobileSelectSafeAreaPaddingBottom = 'calc(12px + env(safe-area-inset-bottom, 0px))';
@@ -26,11 +26,28 @@ function getMobileSelectListStyle(hasConfirmFooter: boolean): React.CSSPropertie
26
26
  };
27
27
  }
28
28
 
29
+ function normalizeMobileLocale(locale?: string) {
30
+ return locale === 'zh-CH' ? 'zh-CN' : locale;
31
+ }
32
+
29
33
  export function MobileSelect(props) {
30
34
  const { value, displayValue, onChange, onChangeComplete, disabled, options = [], mode } = props;
31
35
  const isMultiple = ['multiple', 'tags'].includes(mode);
32
36
  const ctx = useFlowModelContext();
33
37
  const t = ctx.t;
38
+ const { locale: mobileLocale } = useConfig();
39
+ const searchBarLocale = useMemo(() => {
40
+ const currentLocale = normalizeMobileLocale(ctx.locale);
41
+ const inheritedLocale = normalizeMobileLocale(mobileLocale.locale);
42
+
43
+ return {
44
+ ...mobileLocale,
45
+ SearchBar: {
46
+ ...mobileLocale.SearchBar,
47
+ name: currentLocale === inheritedLocale ? mobileLocale.SearchBar.name : t('Search'),
48
+ },
49
+ };
50
+ }, [ctx.locale, mobileLocale, t]);
34
51
  const [visible, setVisible] = useState(false);
35
52
  const [selected, setSelected] = useState(value || []);
36
53
  const [searchText, setSearchText] = useState(null);
@@ -77,7 +94,15 @@ export function MobileSelect(props) {
77
94
  destroyOnClose
78
95
  >
79
96
  <div style={{ margin: '10px' }}>
80
- <SearchBar placeholder={t('search')} value={searchText} onChange={(v) => setSearchText(v)} showCancelButton />
97
+ <ConfigProvider locale={searchBarLocale}>
98
+ <SearchBar
99
+ placeholder={t('search')}
100
+ value={searchText}
101
+ onChange={(v) => setSearchText(v)}
102
+ cancelText={t('Cancel')}
103
+ showCancelButton
104
+ />
105
+ </ConfigProvider>
81
106
  </div>
82
107
  <div style={getMobileSelectListStyle(isMultiple)}>
83
108
  <CheckList
@@ -7,7 +7,7 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import React from 'react';
10
+ import React, { type ComponentProps, type ReactNode } from 'react';
11
11
  import { beforeEach, describe, it, expect, vi } from 'vitest';
12
12
  import { act, fireEvent, render, screen } from '@nocobase/test/client';
13
13
  import { MobileLazySelect } from '../MobileLazySelect';
@@ -23,11 +23,29 @@ const RELATION_OPTIONS = [
23
23
  { uuid: 'c7d99828-a1de-9e70-4c2d-b0139abdf02e' },
24
24
  ];
25
25
 
26
+ type SelectProps = ComponentProps<(typeof import('antd'))['Select']>;
27
+ type PopupProps = ComponentProps<(typeof import('antd-mobile'))['Popup']>;
28
+ type CheckListProps = ComponentProps<(typeof import('antd-mobile'))['CheckList']>;
29
+ type ButtonProps = ComponentProps<(typeof import('antd-mobile'))['Button']>;
30
+ type SearchBarProps = ComponentProps<(typeof import('antd-mobile'))['SearchBar']>;
31
+ type ConfigProviderProps = ComponentProps<(typeof import('antd-mobile'))['ConfigProvider']>;
32
+ type MobileLocale = {
33
+ locale: string;
34
+ common: { cancel: string };
35
+ SearchBar: { name: string };
36
+ };
37
+
26
38
  const mockState = vi.hoisted(() => ({
27
- selectProps: undefined as any,
28
- popupProps: undefined as any,
29
- checklistProps: undefined as any,
30
- confirmButtonProps: undefined as any,
39
+ selectProps: undefined as SelectProps | undefined,
40
+ popupProps: undefined as PopupProps | undefined,
41
+ checklistProps: undefined as CheckListProps | undefined,
42
+ confirmButtonProps: undefined as ButtonProps | undefined,
43
+ mobileLocale: {
44
+ locale: 'zh-CH',
45
+ common: { cancel: '取消' },
46
+ SearchBar: { name: '搜索框' },
47
+ },
48
+ flowLocale: 'en-US',
31
49
  }));
32
50
 
33
51
  function resetMockState() {
@@ -35,6 +53,12 @@ function resetMockState() {
35
53
  mockState.popupProps = undefined;
36
54
  mockState.checklistProps = undefined;
37
55
  mockState.confirmButtonProps = undefined;
56
+ mockState.mobileLocale = {
57
+ locale: 'zh-CH',
58
+ common: { cancel: '取消' },
59
+ SearchBar: { name: '搜索框' },
60
+ };
61
+ mockState.flowLocale = 'en-US';
38
62
  }
39
63
 
40
64
  function clickTrigger() {
@@ -115,7 +139,20 @@ vi.mock('@nocobase/flow-engine', async () => {
115
139
  return {
116
140
  ...actual,
117
141
  useFlowModelContext: () => ({
118
- t: (value: string) => value,
142
+ locale: mockState.flowLocale,
143
+ t: (value: string) =>
144
+ ({
145
+ 'zh-CN': {
146
+ Cancel: '取消',
147
+ Search: '搜索',
148
+ search: '搜索',
149
+ },
150
+ 'zh-TW': {
151
+ Cancel: '取消',
152
+ Search: '搜尋',
153
+ search: '搜尋',
154
+ },
155
+ })[mockState.flowLocale]?.[value] || value,
119
156
  }),
120
157
  useFlowModel: () => ({
121
158
  context: {
@@ -130,7 +167,7 @@ vi.mock('antd', async () => {
130
167
  const actual = await vi.importActual<any>('antd');
131
168
  return {
132
169
  ...actual,
133
- Select: (props: any) => {
170
+ Select: (props: SelectProps) => {
134
171
  mockState.selectProps = props;
135
172
  return <div data-testid="antd-select" />;
136
173
  },
@@ -138,15 +175,22 @@ vi.mock('antd', async () => {
138
175
  });
139
176
 
140
177
  vi.mock('antd-mobile', () => {
141
- const MockCheckList: any = (props: any) => {
178
+ const MockCheckList = (props: CheckListProps) => {
142
179
  mockState.checklistProps = props;
143
180
  return <div data-testid="checklist">{props.children}</div>;
144
181
  };
145
182
 
146
- MockCheckList.Item = ({ value, children }: any) => <div data-testid={`item-${value}`}>{children}</div>;
183
+ MockCheckList.Item = ({ value, children }: { value: string | number; children?: ReactNode }) => (
184
+ <div data-testid={`item-${value}`}>{children}</div>
185
+ );
147
186
 
148
187
  return {
149
- Button: (props: any) => {
188
+ ConfigProvider: ({ children, locale }: ConfigProviderProps) => {
189
+ mockState.mobileLocale = locale as MobileLocale;
190
+ return <>{children}</>;
191
+ },
192
+ useConfig: () => ({ locale: mockState.mobileLocale }),
193
+ Button: (props: ButtonProps) => {
150
194
  mockState.confirmButtonProps = props;
151
195
  return (
152
196
  <button type="button" data-testid="confirm" onClick={props.onClick}>
@@ -154,12 +198,26 @@ vi.mock('antd-mobile', () => {
154
198
  </button>
155
199
  );
156
200
  },
157
- Popup: (props: any) => {
201
+ Popup: (props: PopupProps) => {
158
202
  mockState.popupProps = props;
159
203
  return props.visible ? <div data-testid="popup">{props.children}</div> : null;
160
204
  },
161
- SearchBar: ({ value, onChange }: any) => (
162
- <input data-testid="search" value={value ?? ''} onChange={(e) => onChange?.(e.target.value)} />
205
+ SearchBar: ({ value, onChange, onCancel, cancelText, placeholder, showCancelButton }: SearchBarProps) => (
206
+ <div>
207
+ <input
208
+ aria-label={mockState.mobileLocale.SearchBar.name}
209
+ data-testid="search"
210
+ placeholder={placeholder}
211
+ type="search"
212
+ value={value ?? ''}
213
+ onChange={(e) => onChange?.(e.target.value)}
214
+ />
215
+ {showCancelButton && value ? (
216
+ <button type="button" onClick={onCancel}>
217
+ {cancelText ?? mockState.mobileLocale.common.cancel}
218
+ </button>
219
+ ) : null}
220
+ </div>
163
221
  ),
164
222
  CheckList: MockCheckList,
165
223
  };
@@ -199,6 +257,47 @@ describe('MobileSelect', () => {
199
257
  expect(onChangeComplete).toHaveBeenCalledTimes(1);
200
258
  });
201
259
 
260
+ it('localizes the search input accessible name', () => {
261
+ renderMobileSelect();
262
+
263
+ openPopup();
264
+
265
+ expect(screen.getByRole('searchbox', { name: 'Search' })).toBeInTheDocument();
266
+ });
267
+
268
+ it('renders a translated cancel action after searching', () => {
269
+ renderMobileSelect();
270
+
271
+ openPopup();
272
+ act(() => {
273
+ fireEvent.change(screen.getByTestId('search'), { target: { value: 'Option' } });
274
+ });
275
+
276
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
277
+ });
278
+
279
+ it('preserves the Chinese search input accessible name', () => {
280
+ mockState.flowLocale = 'zh-CN';
281
+ renderMobileSelect();
282
+
283
+ openPopup();
284
+ act(() => {
285
+ fireEvent.change(screen.getByTestId('search'), { target: { value: 'Option' } });
286
+ });
287
+
288
+ expect(screen.getByRole('searchbox', { name: '搜索框' })).toBeInTheDocument();
289
+ expect(screen.getByRole('button', { name: '取消' })).toBeInTheDocument();
290
+ });
291
+
292
+ it('does not reuse the simplified Chinese accessible name for traditional Chinese', () => {
293
+ mockState.flowLocale = 'zh-TW';
294
+ renderMobileSelect();
295
+
296
+ openPopup();
297
+
298
+ expect(screen.getByRole('searchbox', { name: '搜尋' })).toBeInTheDocument();
299
+ });
300
+
202
301
  it('defers commit until confirm in multiple mode', () => {
203
302
  const { onChange, onChangeComplete } = renderMobileSelect({ value: [], mode: 'multiple' });
204
303
  openPopup();
@@ -289,6 +388,33 @@ describe('MobileLazySelect', () => {
289
388
  resetMockState();
290
389
  });
291
390
 
391
+ it('renders a translated cancel action after searching', () => {
392
+ renderMobileLazySelect();
393
+
394
+ openLazyPopup();
395
+ act(() => {
396
+ fireEvent.change(screen.getByTestId('search'), { target: { value: '11' } });
397
+ });
398
+
399
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
400
+ });
401
+
402
+ it('clears the selected relation record when it is tapped again in single mode', () => {
403
+ const { onChange } = renderMobileLazySelect({
404
+ value: RELATION_OPTIONS[0],
405
+ multiple: false,
406
+ });
407
+
408
+ openLazyPopup();
409
+ expect(mockState.checklistProps?.value).toEqual([RELATION_OPTIONS[0].uuid]);
410
+
411
+ selectValues([]);
412
+
413
+ expect(onChange).toHaveBeenCalledTimes(1);
414
+ expect(onChange).toHaveBeenCalledWith(undefined);
415
+ expect(screen.queryByTestId('popup')).not.toBeInTheDocument();
416
+ });
417
+
292
418
  it('keeps pending relation records selected until confirm', () => {
293
419
  const { onChange, rerender } = renderMobileLazySelect();
294
420
 
@@ -201,12 +201,12 @@ function TopbarInternalSettingsLabel(props: { title: React.ReactNode; path?: str
201
201
  adminRoutePath: getTopbarAdminRoutePath(app),
202
202
  });
203
203
 
204
+ if (!shouldOpenInNewWindow) {
205
+ return <Link to={stripTopbarRouterBasePath(targetPathInCurrentApp, basename)}>{props.title}</Link>;
206
+ }
207
+
204
208
  return (
205
- <a
206
- href={href}
207
- target={shouldOpenInNewWindow ? '_blank' : undefined}
208
- rel={shouldOpenInNewWindow ? 'noopener noreferrer' : undefined}
209
- >
209
+ <a href={href} target="_blank" rel="noopener noreferrer">
210
210
  {props.title}
211
211
  </a>
212
212
  );
@@ -105,9 +105,6 @@ function resolveItemDependencyPath(ctx: FlowContext, depPath: NamePath): DataSco
105
105
  ctx,
106
106
  parseFieldIndexEntries((ctx.model as any)?.context?.fieldIndex ?? (ctx as any)?.fieldIndex),
107
107
  );
108
- if (!entries.length) {
109
- return wildcardDeps();
110
- }
111
108
 
112
109
  let parentDepth = 0;
113
110
  let cursor = [...depPath];
@@ -121,6 +118,10 @@ function resolveItemDependencyPath(ctx: FlowContext, depPath: NamePath): DataSco
121
118
  return wildcardDeps();
122
119
  }
123
120
 
121
+ if (!entries.length) {
122
+ return parentDepth === 0 ? resolveRootItemDependencyPath(cursor) : emptyDeps();
123
+ }
124
+
124
125
  if (parentDepth === entries.length) {
125
126
  return resolveRootItemDependencyPath(cursor);
126
127
  }
package/src/index.ts CHANGED
@@ -53,4 +53,5 @@ export {
53
53
  } from './flow-compat';
54
54
  export type { NocoBaseDesktopRoute } from './flow-compat';
55
55
  export * from './utils/markdownSanitize';
56
+ export * from './ui-operation';
56
57
  export { default as AntdAppProvider } from './theme/AntdAppProvider';
@@ -38,11 +38,12 @@ export const LayoutContentRoute = (props: LayoutContentRouteProps) => {
38
38
  id: lastMatch?.id,
39
39
  name: lastMatch?.id,
40
40
  pathname: location.pathname,
41
+ state: location.state,
41
42
  params: (lastMatch?.params || {}) as Record<string, string | undefined>,
42
43
  layoutRouteName: layout.routeName,
43
44
  layoutBasePathname: layoutMatch?.pathname,
44
45
  };
45
- }, [layout.routeName, location.pathname, matches]);
46
+ }, [layout.routeName, location.pathname, location.state, matches]);
46
47
  if (!model) {
47
48
  throw new Error(`[NocoBase] Layout '${layout.routeName}' model '${layout.uid}' is not mounted.`);
48
49
  }
@@ -71,11 +71,12 @@ export const LayoutRoute = (props: LayoutRouteProps) => {
71
71
  id: lastMatch?.id,
72
72
  name: lastMatch?.id,
73
73
  pathname: location.pathname,
74
+ state: location.state,
74
75
  params: lastMatchParams,
75
76
  layoutRouteName: layout.routeName,
76
77
  layoutBasePathname: layoutMatch?.pathname,
77
78
  };
78
- }, [lastMatch?.id, lastMatchParams, layout.routeName, layoutMatch?.pathname, location.pathname]);
79
+ }, [lastMatch?.id, lastMatchParams, layout.routeName, layoutMatch?.pathname, location.pathname, location.state]);
79
80
  const { loading, data, error } = useRequest(
80
81
  async () => {
81
82
  const existingModel = flowEngine.getModel<BaseLayoutModel>(layout.uid);