@nocobase/client-v2 2.2.0-beta.17 → 2.2.0-beta.18
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/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
- package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
- 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 +1 -0
- package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
- package/es/flow/models/blocks/table/TableColumnModel.d.ts +11 -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-compat/passwordUtils.d.ts +1 -1
- package/es/index.mjs +125 -116
- package/lib/index.js +162 -153
- package/package.json +7 -7
- package/src/APIClient.ts +92 -0
- package/src/Application.tsx +3 -1
- package/src/__tests__/APIClient.test.tsx +58 -0
- 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/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/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
- package/src/flow/actions/linkageRules.tsx +8 -3
- 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/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/internal/utils/operatorSchemaHelper.ts +3 -2
- package/src/flow/models/base/CollectionBlockModel.tsx +23 -1
- package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +65 -9
- 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/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-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 +43 -0
- package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
- package/src/flow/models/blocks/table/TableBlockModel.tsx +25 -1
- package/src/flow/models/blocks/table/TableColumnModel.tsx +18 -8
- package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -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/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 +121 -3
- package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
- package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
- 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__/NumberFieldModel.test.tsx +47 -0
- package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +5 -0
- package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
- package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +122 -14
- package/src/settings-center/AdminSettingsLayout.tsx +23 -2
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { FlowContext } from '@nocobase/flow-engine';
|
|
10
|
+
import { FlowContext, FlowEngine, FlowModel } from '@nocobase/flow-engine';
|
|
11
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
11
12
|
import { describe, expect, it, vi } from 'vitest';
|
|
12
13
|
import {
|
|
13
14
|
buildCurrentItemTitle,
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
} from '../itemChain';
|
|
22
23
|
import { injectRecordPickerPopupContext } from '@nocobase/client-v2';
|
|
23
24
|
import {
|
|
25
|
+
RecordPickerFieldModel,
|
|
24
26
|
canRecordPickerSelectMultiple,
|
|
25
27
|
getRecordPickerClearedValue,
|
|
26
28
|
getRecordPickerEmptyValue,
|
|
@@ -28,8 +30,12 @@ import {
|
|
|
28
30
|
normalizeRecordPickerValue,
|
|
29
31
|
shouldClearRecordPickerValueOnMultipleChange,
|
|
30
32
|
} from '../RecordPickerFieldModel';
|
|
31
|
-
import {
|
|
32
|
-
|
|
33
|
+
import {
|
|
34
|
+
collectAssociationHydrationCandidates,
|
|
35
|
+
useAssociationValueHydration,
|
|
36
|
+
getAssociationHydrationNamePath,
|
|
37
|
+
getAssociationHydrationSetterContext,
|
|
38
|
+
} from '../recordSelectShared';
|
|
33
39
|
function createMockCollection() {
|
|
34
40
|
return {
|
|
35
41
|
name: 'users',
|
|
@@ -78,6 +84,118 @@ describe('RecordPickerFieldModel item context', () => {
|
|
|
78
84
|
expect(normalizeRecordPickerValue(undefined, fieldNames, false)).toBeUndefined();
|
|
79
85
|
});
|
|
80
86
|
|
|
87
|
+
it('queues ID-only picker values for association label hydration', () => {
|
|
88
|
+
const statusMap = new Map<string, 'pending' | 'done'>();
|
|
89
|
+
const fieldNames = { label: 'name', value: 'id' };
|
|
90
|
+
|
|
91
|
+
expect(
|
|
92
|
+
collectAssociationHydrationCandidates({
|
|
93
|
+
value: [{ id: 1 }, { id: 2, name: 'Already hydrated' }],
|
|
94
|
+
isMultiple: true,
|
|
95
|
+
valueKey: fieldNames.value,
|
|
96
|
+
labelKey: fieldNames.label,
|
|
97
|
+
statusMap,
|
|
98
|
+
}),
|
|
99
|
+
).toEqual([{ item: { id: 1 }, tk: 1, tkKey: '1' }]);
|
|
100
|
+
expect(statusMap).toEqual(new Map([['1', 'pending']]));
|
|
101
|
+
|
|
102
|
+
expect(
|
|
103
|
+
collectAssociationHydrationCandidates({
|
|
104
|
+
value: { id: 1 },
|
|
105
|
+
isMultiple: false,
|
|
106
|
+
valueKey: fieldNames.value,
|
|
107
|
+
labelKey: fieldNames.label,
|
|
108
|
+
statusMap,
|
|
109
|
+
}),
|
|
110
|
+
).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('hydrates an ID-only picker value back into the form', async () => {
|
|
114
|
+
const resource = {
|
|
115
|
+
get: vi.fn().mockResolvedValue({ id: 1, name: '北京启明教育' }),
|
|
116
|
+
};
|
|
117
|
+
const setFormValue = vi.fn();
|
|
118
|
+
const onChange = vi.fn();
|
|
119
|
+
const model = {
|
|
120
|
+
resource,
|
|
121
|
+
props: { name: 'company' },
|
|
122
|
+
context: {
|
|
123
|
+
fieldPathArray: ['company'],
|
|
124
|
+
setFormValue,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const { rerender } = renderHook(() =>
|
|
129
|
+
useAssociationValueHydration({
|
|
130
|
+
model,
|
|
131
|
+
value: { id: 1 },
|
|
132
|
+
isMultiple: false,
|
|
133
|
+
fieldNames: { label: 'name', value: 'id' },
|
|
134
|
+
onChange,
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
await waitFor(() => {
|
|
139
|
+
expect(setFormValue).toHaveBeenCalledWith(
|
|
140
|
+
['company'],
|
|
141
|
+
{ id: 1, name: '北京启明教育' },
|
|
142
|
+
{ source: 'default', markExplicit: false, triggerEvent: false },
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
rerender();
|
|
146
|
+
|
|
147
|
+
expect(resource.get).toHaveBeenCalledTimes(1);
|
|
148
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('initializes a target resource before hydrating an ID-only picker value', async () => {
|
|
152
|
+
const engine = new FlowEngine();
|
|
153
|
+
engine.registerModels({ RecordPickerFieldModel });
|
|
154
|
+
|
|
155
|
+
const formItem = engine.createModel<FlowModel>({
|
|
156
|
+
use: 'FlowModel',
|
|
157
|
+
uid: 'form-item',
|
|
158
|
+
});
|
|
159
|
+
const field = engine.createModel<RecordPickerFieldModel>({
|
|
160
|
+
use: 'RecordPickerFieldModel',
|
|
161
|
+
uid: 'company-field',
|
|
162
|
+
parentId: formItem.uid,
|
|
163
|
+
});
|
|
164
|
+
const setFormValue = vi.fn();
|
|
165
|
+
field.context.defineProperty('collectionField', {
|
|
166
|
+
value: {
|
|
167
|
+
dataSourceKey: 'main',
|
|
168
|
+
target: 'rp_na_companies',
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
field.context.defineProperty('fieldPathArray', { value: ['company'] });
|
|
172
|
+
field.context.defineProperty('setFormValue', { value: setFormValue });
|
|
173
|
+
|
|
174
|
+
await field.applyFlow('recordPickerSettings');
|
|
175
|
+
const get = vi.spyOn(field.resource, 'get').mockResolvedValue({ id: 1, name: '北京启明教育' });
|
|
176
|
+
const onChange = vi.fn();
|
|
177
|
+
|
|
178
|
+
renderHook(() =>
|
|
179
|
+
useAssociationValueHydration({
|
|
180
|
+
model: field,
|
|
181
|
+
value: { id: 1 },
|
|
182
|
+
isMultiple: false,
|
|
183
|
+
fieldNames: { label: 'name', value: 'id' },
|
|
184
|
+
onChange,
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
await waitFor(() => {
|
|
189
|
+
expect(get).toHaveBeenCalledWith(1);
|
|
190
|
+
expect(setFormValue).toHaveBeenCalledWith(
|
|
191
|
+
['company'],
|
|
192
|
+
{ id: 1, name: '北京启明教育' },
|
|
193
|
+
{ source: 'default', markExplicit: false, triggerEvent: false },
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
197
|
+
});
|
|
198
|
+
|
|
81
199
|
it('returns the empty popup select value for the current multiple mode', () => {
|
|
82
200
|
expect(getRecordPickerEmptyValue(true)).toEqual([]);
|
|
83
201
|
expect(getRecordPickerEmptyValue(false)).toBeUndefined();
|
|
@@ -119,6 +119,162 @@ export interface LazySelectProps extends Omit<SelectProps<any>, 'mode' | 'option
|
|
|
119
119
|
allowEdit?: boolean;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
type AssociationHydrationSetterOptions = {
|
|
123
|
+
source?: string;
|
|
124
|
+
markExplicit?: boolean;
|
|
125
|
+
triggerEvent?: boolean;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
type AssociationHydrationSetterContext = {
|
|
129
|
+
setFormValue?: (
|
|
130
|
+
namePath: unknown,
|
|
131
|
+
value: unknown,
|
|
132
|
+
options?: AssociationHydrationSetterOptions,
|
|
133
|
+
) => Promise<unknown> | void;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export type AssociationHydrationModel = {
|
|
137
|
+
resource?: {
|
|
138
|
+
get?: (tk: unknown) => Promise<unknown> | unknown;
|
|
139
|
+
};
|
|
140
|
+
context?: unknown;
|
|
141
|
+
props?: {
|
|
142
|
+
name?: unknown;
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
type AssociationHydrationStatus = 'pending' | 'done';
|
|
147
|
+
|
|
148
|
+
type AssociationHydrationCandidate = {
|
|
149
|
+
item: AssociationOption;
|
|
150
|
+
tk: unknown;
|
|
151
|
+
tkKey: string;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export function isAssociationRecord(value: unknown): value is AssociationOption {
|
|
155
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function collectAssociationHydrationCandidates(options: {
|
|
159
|
+
value: LazySelectProps['value'];
|
|
160
|
+
isMultiple: boolean;
|
|
161
|
+
valueKey: string;
|
|
162
|
+
labelKey: string;
|
|
163
|
+
statusMap: Map<string, AssociationHydrationStatus>;
|
|
164
|
+
}): AssociationHydrationCandidate[] {
|
|
165
|
+
const { value, isMultiple, valueKey, labelKey, statusMap } = options;
|
|
166
|
+
const list = isMultiple ? (Array.isArray(value) ? value : []) : value != null ? [value] : [];
|
|
167
|
+
const activeTkKeys = new Set<string>();
|
|
168
|
+
|
|
169
|
+
for (const item of list) {
|
|
170
|
+
if (!isAssociationRecord(item)) continue;
|
|
171
|
+
const tk = item[valueKey];
|
|
172
|
+
if (tk == null) continue;
|
|
173
|
+
const tkKey = typeof tk === 'object' ? JSON.stringify(tk) : String(tk);
|
|
174
|
+
if (tkKey) activeTkKeys.add(tkKey);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const key of Array.from(statusMap.keys())) {
|
|
178
|
+
if (!activeTkKeys.has(key)) statusMap.delete(key);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const candidates: AssociationHydrationCandidate[] = [];
|
|
182
|
+
for (const item of list) {
|
|
183
|
+
if (!isAssociationRecord(item)) continue;
|
|
184
|
+
const tk = item[valueKey];
|
|
185
|
+
if (tk == null || item[labelKey] != null) continue;
|
|
186
|
+
|
|
187
|
+
const tkKey = typeof tk === 'object' ? JSON.stringify(tk) : String(tk);
|
|
188
|
+
if (!tkKey || statusMap.has(tkKey)) continue;
|
|
189
|
+
|
|
190
|
+
statusMap.set(tkKey, 'pending');
|
|
191
|
+
candidates.push({ item, tk, tkKey });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return candidates;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function hasAssociationHydrationSetter(value: unknown): value is AssociationHydrationSetterContext {
|
|
198
|
+
return isAssociationRecord(value) && typeof value.setFormValue === 'function';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function getAssociationHydrationNamePath(model: AssociationHydrationModel) {
|
|
202
|
+
const context = isAssociationRecord(model.context) ? model.context : undefined;
|
|
203
|
+
return context?.fieldPathArray ?? context?.fieldPath ?? model.props?.name;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function getAssociationHydrationSetterContext(model: AssociationHydrationModel) {
|
|
207
|
+
if (hasAssociationHydrationSetter(model.context)) return model.context;
|
|
208
|
+
|
|
209
|
+
const context = isAssociationRecord(model.context) ? model.context : undefined;
|
|
210
|
+
const blockModel = isAssociationRecord(context?.blockModel) ? context.blockModel : undefined;
|
|
211
|
+
return hasAssociationHydrationSetter(blockModel?.context) ? blockModel.context : undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function useAssociationValueHydration(options: {
|
|
215
|
+
model: AssociationHydrationModel;
|
|
216
|
+
value: LazySelectProps['value'];
|
|
217
|
+
isMultiple: boolean;
|
|
218
|
+
fieldNames: AssociationFieldNames;
|
|
219
|
+
onChange: LazySelectProps['onChange'];
|
|
220
|
+
}) {
|
|
221
|
+
const { model, value, isMultiple, fieldNames, onChange } = options;
|
|
222
|
+
const hydrateStatusRef = React.useRef<Map<string, AssociationHydrationStatus>>(new Map());
|
|
223
|
+
|
|
224
|
+
React.useEffect(() => {
|
|
225
|
+
const resource = model.resource;
|
|
226
|
+
if (typeof resource?.get !== 'function' || !fieldNames.value || !fieldNames.label) return;
|
|
227
|
+
|
|
228
|
+
const current = value;
|
|
229
|
+
const candidates = collectAssociationHydrationCandidates({
|
|
230
|
+
value: current,
|
|
231
|
+
isMultiple,
|
|
232
|
+
valueKey: fieldNames.value,
|
|
233
|
+
labelKey: fieldNames.label,
|
|
234
|
+
statusMap: hydrateStatusRef.current,
|
|
235
|
+
});
|
|
236
|
+
if (!candidates.length) return;
|
|
237
|
+
|
|
238
|
+
const namePath = getAssociationHydrationNamePath(model);
|
|
239
|
+
const setterContext = getAssociationHydrationSetterContext(model);
|
|
240
|
+
|
|
241
|
+
const hydrateCandidate = async ({ item, tk, tkKey }: AssociationHydrationCandidate) => {
|
|
242
|
+
try {
|
|
243
|
+
const record = await resource.get?.(tk);
|
|
244
|
+
if (!isAssociationRecord(record)) return;
|
|
245
|
+
|
|
246
|
+
const merged = { ...item, ...record };
|
|
247
|
+
if (merged[fieldNames.label] == null) return;
|
|
248
|
+
|
|
249
|
+
const nextValue = isMultiple
|
|
250
|
+
? (Array.isArray(current) ? current : []).map((entry) =>
|
|
251
|
+
isAssociationRecord(entry) && entry[fieldNames.value] === tk ? merged : entry,
|
|
252
|
+
)
|
|
253
|
+
: merged;
|
|
254
|
+
|
|
255
|
+
if (typeof setterContext?.setFormValue === 'function' && namePath != null) {
|
|
256
|
+
await setterContext.setFormValue(namePath, nextValue, {
|
|
257
|
+
source: 'default',
|
|
258
|
+
markExplicit: false,
|
|
259
|
+
triggerEvent: false,
|
|
260
|
+
});
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
onChange(nextValue);
|
|
265
|
+
} catch {
|
|
266
|
+
// Keep the original ID-only value when the related record cannot be loaded.
|
|
267
|
+
} finally {
|
|
268
|
+
hydrateStatusRef.current.set(tkKey, 'done');
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
candidates.forEach((candidate) => {
|
|
273
|
+
hydrateCandidate(candidate);
|
|
274
|
+
});
|
|
275
|
+
}, [fieldNames.label, fieldNames.value, isMultiple, model, onChange, value]);
|
|
276
|
+
}
|
|
277
|
+
|
|
122
278
|
export interface LabelByFieldProps {
|
|
123
279
|
option: AssociationOption;
|
|
124
280
|
fieldNames: AssociationFieldNames;
|
|
@@ -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,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') {
|
|
@@ -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,16 +198,23 @@ 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, onCancel, cancelText, showCancelButton }:
|
|
205
|
+
SearchBar: ({ value, onChange, onCancel, cancelText, placeholder, showCancelButton }: SearchBarProps) => (
|
|
162
206
|
<div>
|
|
163
|
-
<input
|
|
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
|
+
/>
|
|
164
215
|
{showCancelButton && value ? (
|
|
165
216
|
<button type="button" onClick={onCancel}>
|
|
166
|
-
{cancelText ??
|
|
217
|
+
{cancelText ?? mockState.mobileLocale.common.cancel}
|
|
167
218
|
</button>
|
|
168
219
|
) : null}
|
|
169
220
|
</div>
|
|
@@ -206,6 +257,47 @@ describe('MobileSelect', () => {
|
|
|
206
257
|
expect(onChangeComplete).toHaveBeenCalledTimes(1);
|
|
207
258
|
});
|
|
208
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
|
+
|
|
209
301
|
it('defers commit until confirm in multiple mode', () => {
|
|
210
302
|
const { onChange, onChangeComplete } = renderMobileSelect({ value: [], mode: 'multiple' });
|
|
211
303
|
openPopup();
|
|
@@ -307,6 +399,22 @@ describe('MobileLazySelect', () => {
|
|
|
307
399
|
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
|
|
308
400
|
});
|
|
309
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
|
+
|
|
310
418
|
it('keeps pending relation records selected until confirm', () => {
|
|
311
419
|
const { onChange, rerender } = renderMobileLazySelect();
|
|
312
420
|
|