@nocobase/client-v2 2.3.0-alpha.1 → 2.3.0-beta.3
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/APIClient.d.ts +12 -0
- package/es/components/form/ScanInput/useCodeScanner.d.ts +2 -2
- package/es/flow/actions/linkageRules.d.ts +25 -0
- package/es/flow/components/FieldAssignValueInput.d.ts +4 -30
- package/es/flow/components/FlowRoute.d.ts +2 -2
- package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
- package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +31 -0
- package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
- package/es/flow/components/field-value-variable/index.d.ts +11 -0
- package/es/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
- package/es/flow/internal/registerDeviceTypeContext.d.ts +10 -0
- package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
- package/es/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
- package/es/flow/models/base/ActionModelCore.d.ts +4 -2
- package/es/flow/models/base/BlockGridModel.d.ts +3 -0
- package/es/flow/models/base/CollectionBlockModel.d.ts +3 -0
- package/es/flow/models/blocks/details/DetailsBlockModel.d.ts +3 -0
- 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 +7 -0
- package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
- package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
- package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
- package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
- package/es/flow/models/blocks/table/TableColumnModel.d.ts +12 -0
- package/es/flow/models/blocks/table/utils.d.ts +1 -0
- package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +2 -1
- package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +0 -17
- package/es/flow/models/fields/AssociationFieldModel/recordSelectShared.d.ts +41 -0
- package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
- package/es/flow-compat/passwordUtils.d.ts +1 -1
- package/es/index.d.ts +1 -0
- package/es/index.mjs +147 -144
- package/es/ui-operation/index.d.ts +15 -0
- package/es/ui-operation/ui-operation-codec.d.ts +10 -0
- package/lib/index.js +170 -167
- package/lib/locale/languageCodes.js +1 -0
- package/package.json +8 -7
- package/src/APIClient.ts +92 -0
- package/src/Application.tsx +3 -1
- package/src/__tests__/APIClient.test.tsx +58 -0
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +7 -2
- package/src/__tests__/settings-center.test.tsx +270 -6
- package/src/acl/ACLProvider.tsx +2 -0
- package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
- package/src/components/form/ScanInput/CodeScanner.tsx +61 -34
- package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +101 -0
- package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +114 -18
- package/src/components/form/ScanInput/useCodeScanner.ts +166 -23
- package/src/components/form/filter/CollectionFilterItem.tsx +4 -2
- package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +17 -0
- package/src/components/form/table/Table.tsx +81 -6
- package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
- package/src/flow/__tests__/FlowRoute.test.tsx +126 -8
- package/src/flow/__tests__/PluginFlowEngine.test.ts +58 -0
- package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +314 -0
- package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
- package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
- package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
- package/src/flow/actions/linkageRules.tsx +59 -16
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +78 -59
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +228 -0
- package/src/flow/common/Markdown/style.ts +4 -0
- package/src/flow/components/BlockItemCard.tsx +2 -2
- package/src/flow/components/FieldAssignValueInput.tsx +38 -621
- package/src/flow/components/FlowRoute.tsx +71 -29
- package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
- package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +326 -0
- package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +380 -0
- package/src/flow/components/field-value-variable/dateValue.ts +223 -0
- package/src/flow/components/field-value-variable/index.ts +12 -0
- package/src/flow/components/filter/VariableFilterItem.tsx +12 -3
- package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +125 -0
- package/src/flow/components/placeholders/BlockPlaceholder.tsx +70 -23
- package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +57 -7
- package/src/flow/index.ts +2 -0
- package/src/flow/internal/registerDeviceTypeContext.ts +39 -0
- package/src/flow/internal/utils/operatorSchemaHelper.ts +3 -2
- package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
- package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
- package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
- package/src/flow/models/base/ActionModelCore.tsx +11 -2
- package/src/flow/models/base/BlockGridModel.tsx +26 -0
- package/src/flow/models/base/CollectionBlockModel.tsx +38 -3
- package/src/flow/models/base/PageModel/PageTabModel.tsx +67 -17
- package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +509 -12
- package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
- package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
- package/src/flow/models/base/__tests__/CollectionBlockModel.addAppends.test.ts +41 -0
- package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +125 -9
- package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +28 -53
- package/src/flow/models/blocks/details/DetailsBlockModel.tsx +8 -3
- package/src/flow/models/blocks/details/__tests__/DetailsBlockModel.initialPaginationChangeRefresh.test.ts +32 -1
- package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +27 -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/filter-form/__tests__/defaultValues.wiring.test.ts +32 -4
- package/src/flow/models/blocks/filter-manager/FilterManager.ts +5 -0
- package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +40 -0
- package/src/flow/models/blocks/form/FormBlockModel.tsx +47 -0
- package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
- package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +8 -2
- package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
- package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
- package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
- package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
- package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
- package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
- package/src/flow/models/blocks/table/TableBlockModel.tsx +28 -2
- package/src/flow/models/blocks/table/TableColumnModel.tsx +48 -9
- package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
- package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -0
- package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
- package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
- package/src/flow/models/blocks/table/utils.ts +7 -0
- package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +8 -0
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
- package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +24 -1
- package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -136
- package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +167 -3
- package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
- package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
- package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
- package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
- package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
- package/src/flow/models/fields/NumberFieldModel.tsx +1 -1
- package/src/flow/models/fields/__tests__/DisplayNumberFieldModel.test.ts +33 -0
- package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
- package/src/flow/models/fields/__tests__/NumberFieldModel.test.tsx +47 -0
- package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +6 -0
- package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
- package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +139 -13
- package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
- package/src/index.ts +1 -0
- package/src/layout-manager/LayoutContentRoute.tsx +2 -1
- package/src/layout-manager/LayoutRoute.tsx +2 -1
- package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
- package/src/locale/languageCodes.ts +1 -0
- package/src/settings-center/AdminSettingsLayout.tsx +23 -2
- package/src/ui-operation/index.ts +33 -0
- package/src/ui-operation/ui-operation-codec.ts +54 -0
|
@@ -13,6 +13,22 @@ import JSON5 from 'json5';
|
|
|
13
13
|
import React, { useState, useEffect, useRef } from 'react';
|
|
14
14
|
import { FieldModel } from '../base/FieldModel';
|
|
15
15
|
|
|
16
|
+
const jsonValidationRuleMarker = '__jsonSyntaxValidation';
|
|
17
|
+
|
|
18
|
+
interface JsonValidationRule {
|
|
19
|
+
[jsonValidationRuleMarker]: true;
|
|
20
|
+
type: 'any';
|
|
21
|
+
validator?: (_rule: unknown, value: unknown) => Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isRuleObject(rule: unknown): rule is Record<string, unknown> {
|
|
25
|
+
return typeof rule === 'object' && rule !== null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isJsonValidationRule(rule: unknown): rule is JsonValidationRule {
|
|
29
|
+
return isRuleObject(rule) && rule[jsonValidationRuleMarker] === true;
|
|
30
|
+
}
|
|
31
|
+
|
|
16
32
|
const jsonCss = css`
|
|
17
33
|
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
|
|
18
34
|
font-size: 14px;
|
|
@@ -40,7 +56,7 @@ export const JsonInput = observer((props: any) => {
|
|
|
40
56
|
}
|
|
41
57
|
|
|
42
58
|
internalChange.current = false;
|
|
43
|
-
}, [value]);
|
|
59
|
+
}, [_JSON, space, value]);
|
|
44
60
|
|
|
45
61
|
// 用户输入时
|
|
46
62
|
const handleChange = (ev) => {
|
|
@@ -91,23 +107,30 @@ JsonFieldModel.registerFlow({
|
|
|
91
107
|
key: 'jsonInitSetting',
|
|
92
108
|
steps: {
|
|
93
109
|
initValidation: {
|
|
94
|
-
handler(ctx
|
|
95
|
-
const
|
|
110
|
+
handler(ctx) {
|
|
111
|
+
const currentRules = Array.isArray(ctx.model.parent.props.rules) ? ctx.model.parent.props.rules : [];
|
|
112
|
+
const rules = currentRules.filter((rule: unknown) => {
|
|
113
|
+
return (!isRuleObject(rule) || Object.keys(rule).length > 0) && !isJsonValidationRule(rule);
|
|
114
|
+
});
|
|
96
115
|
// 添加 JSON 语法校验规则
|
|
97
116
|
rules.push({
|
|
98
|
-
validator
|
|
117
|
+
// validator 函数序列化后会被丢弃;保留 any 类型可避免持久化规则退化为默认字符串校验。
|
|
118
|
+
type: 'any',
|
|
119
|
+
[jsonValidationRuleMarker]: true,
|
|
120
|
+
validator: (_rule: unknown, value: unknown) => {
|
|
99
121
|
// 允许空
|
|
100
122
|
if (value === null || value === undefined || value === '') return Promise.resolve();
|
|
101
123
|
// 如果已经是对象/数组,直接通过
|
|
102
124
|
if (typeof value === 'object') return Promise.resolve();
|
|
103
125
|
try {
|
|
104
|
-
JSON.parse(value);
|
|
126
|
+
JSON.parse(String(value));
|
|
105
127
|
return Promise.resolve();
|
|
106
|
-
} catch (
|
|
107
|
-
|
|
128
|
+
} catch (error) {
|
|
129
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
130
|
+
return Promise.reject(ctx.t('Invalid JSON format') + ': ' + message);
|
|
108
131
|
}
|
|
109
132
|
},
|
|
110
|
-
});
|
|
133
|
+
} satisfies JsonValidationRule);
|
|
111
134
|
ctx.model.parent.setProps({
|
|
112
135
|
rules,
|
|
113
136
|
});
|
|
@@ -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
|
-
<
|
|
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
|
|
28
|
-
popupProps: undefined as
|
|
29
|
-
checklistProps: undefined as
|
|
30
|
-
confirmButtonProps: undefined as
|
|
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
|
-
|
|
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:
|
|
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
|
|
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 }:
|
|
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
|
-
|
|
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:
|
|
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 }:
|
|
162
|
-
<
|
|
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
|
|
|
@@ -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
|
@@ -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);
|