@nocobase/client-v2 3.0.0-alpha.8 → 3.0.0-alpha.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "3.0.0-alpha.8",
3
+ "version": "3.0.0-alpha.9",
4
4
  "license": "Apache-2.0",
5
5
  "main": "lib/index.js",
6
6
  "module": "es/index.mjs",
@@ -27,11 +27,11 @@
27
27
  "@formily/antd-v5": "1.2.3",
28
28
  "@formily/react": "^2.2.27",
29
29
  "@formily/shared": "^2.2.27",
30
- "@nocobase/evaluators": "3.0.0-alpha.8",
31
- "@nocobase/flow-engine": "3.0.0-alpha.8",
32
- "@nocobase/sdk": "3.0.0-alpha.8",
33
- "@nocobase/shared": "3.0.0-alpha.8",
34
- "@nocobase/utils": "3.0.0-alpha.8",
30
+ "@nocobase/evaluators": "3.0.0-alpha.9",
31
+ "@nocobase/flow-engine": "3.0.0-alpha.9",
32
+ "@nocobase/sdk": "3.0.0-alpha.9",
33
+ "@nocobase/shared": "3.0.0-alpha.9",
34
+ "@nocobase/utils": "3.0.0-alpha.9",
35
35
  "ahooks": "^3.7.2",
36
36
  "antd": "5.24.2",
37
37
  "antd-style": "3.7.1",
@@ -48,5 +48,5 @@
48
48
  "react-i18next": "^11.15.1",
49
49
  "react-router-dom": "^6.30.1"
50
50
  },
51
- "gitHead": "ce017f3deb8b2414c6b13818042c4187270d1d8a"
51
+ "gitHead": "82aaad40c5be1120839a11301a48e7fe690f48c3"
52
52
  }
@@ -8,9 +8,10 @@
8
8
  */
9
9
 
10
10
  import { ACLRolesCheckProvider, Plugin } from '@nocobase/client-v2';
11
- import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
11
+ import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
12
12
  import React from 'react';
13
13
  import { message } from 'antd';
14
+ import { vi } from 'vitest';
14
15
  import { AdminSettingsLayoutModel as ClientV2AdminSettingsLayoutModel } from '../settings-center';
15
16
  import { AdminSettingsLayoutModel as ClientV1AdminSettingsLayoutModel } from '../../../client/src/pm/AdminSettingsLayoutModel';
16
17
  import zhCN from '../../../client/src/locale/zh-CN.json';
@@ -253,6 +254,50 @@ describe('settings center', () => {
253
254
  expect(await screen.findByText('Demo plugin')).toBeInTheDocument();
254
255
  });
255
256
 
257
+ it('should redirect to the admin app when the role cannot access settings', async () => {
258
+ const originalLocation = globalThis.window.location;
259
+ const originalModernClientPrefix = window.__nocobase_modern_client_prefix__;
260
+ const replace = vi.fn();
261
+ Object.defineProperty(globalThis.window, 'location', {
262
+ configurable: true,
263
+ value: {
264
+ ...originalLocation,
265
+ replace,
266
+ },
267
+ });
268
+ window.__nocobase_modern_client_prefix__ = 'v';
269
+
270
+ try {
271
+ const app = createMockSettingsClient({
272
+ plugins: [SettingsBuildInPlugin, TestAclPlugin],
273
+ router: { type: 'memory', initialEntries: ['/settings/system-settings'] },
274
+ });
275
+ mockAdminRuntime(app, {
276
+ snippets: ['!pm', '!pm.system-settings.system-settings'],
277
+ });
278
+
279
+ await renderApp(app);
280
+ await waitForGetRequests(app, ['/auth:check', 'roles:check']);
281
+
282
+ await waitFor(() => {
283
+ expect(replace).toHaveBeenCalledWith('/v/admin');
284
+ });
285
+ expect(
286
+ screen.queryByRole('heading', { name: 'Your current role cannot access Settings' }),
287
+ ).not.toBeInTheDocument();
288
+ } finally {
289
+ Object.defineProperty(globalThis.window, 'location', {
290
+ configurable: true,
291
+ value: originalLocation,
292
+ });
293
+ if (originalModernClientPrefix === undefined) {
294
+ delete window.__nocobase_modern_client_prefix__;
295
+ } else {
296
+ window.__nocobase_modern_client_prefix__ = originalModernClientPrefix;
297
+ }
298
+ }
299
+ });
300
+
256
301
  it('should hide plugin-manager menu item when pm snippet is missing', async () => {
257
302
  const app = createMockSettingsClient({
258
303
  plugins: [SettingsBuildInPlugin, TestAclPlugin],
@@ -274,7 +319,9 @@ describe('settings center', () => {
274
319
  plugins: [SettingsBuildInPlugin, TestAclPlugin],
275
320
  router: { type: 'memory', initialEntries: ['/settings/unknown'] },
276
321
  });
277
- mockAdminRuntime(app);
322
+ mockAdminRuntime(app, {
323
+ snippets: ['!pm', '!pm.system-settings.system-settings'],
324
+ });
278
325
 
279
326
  await renderApp(app);
280
327
  await waitForGetRequests(app, ['/auth:check', 'roles:check']);
@@ -284,6 +331,7 @@ describe('settings center', () => {
284
331
  expect(
285
332
  within(result).getByText('The settings page you requested does not exist or has been removed.'),
286
333
  ).toBeInTheDocument();
334
+ expect(app.router.state.location.pathname).toBe('/settings/unknown');
287
335
  });
288
336
 
289
337
  it('should allow direct access to hidden page without showing menu entry', async () => {
@@ -311,6 +359,7 @@ describe('settings center', () => {
311
359
 
312
360
  expect(await screen.findByText('Hidden settings page')).toBeInTheDocument();
313
361
  expect(screen.queryByRole('menuitem', { name: 'Hidden demo' })).not.toBeInTheDocument();
362
+ expect(app.router.state.location.pathname).toBe('/settings/hidden-demo');
314
363
  });
315
364
 
316
365
  it('should show route empty state when direct access page has no permission', async () => {
@@ -344,43 +393,67 @@ describe('settings center', () => {
344
393
  expect(within(result).getByRole('heading', { name: '当前角色无权访问设置中心' })).toBeInTheDocument();
345
394
  expect(within(result).getByText('请切换至有权限的角色,或联系管理员获取访问权限。')).toBeInTheDocument();
346
395
  expect(screen.queryByText('Secure settings page')).not.toBeInTheDocument();
396
+ expect(app.router.state.location.pathname).toBe('/settings/secure-demo');
347
397
  });
348
398
 
349
- it('should deny a protected child page when a sibling remains visible', async () => {
350
- class MixedAccessSettingsPlugin extends Plugin {
399
+ it('should redirect a denied settings tab to the first accessible tab', async () => {
400
+ const renderDeniedTab = vi.fn(() => <div>Denied tab content</div>);
401
+
402
+ class ProtectedSettingsTabsPlugin extends Plugin {
351
403
  async load() {
352
- this.pluginSettingsManager.addMenuItem({ key: 'mixed-access', title: 'Mixed access' });
404
+ this.pluginSettingsManager.addMenuItem({ key: 'protected-tabs', title: 'Protected tabs' });
353
405
  this.pluginSettingsManager.addPageTabItem({
354
- menuKey: 'mixed-access',
355
- key: 'index',
356
- title: 'Allowed settings',
357
- Component: () => <div>Allowed settings page</div>,
406
+ menuKey: 'protected-tabs',
407
+ key: 'a-second',
408
+ title: 'Second accessible tab',
409
+ aclSnippet: 'pm.protected-tabs.second',
410
+ sort: 20,
411
+ Component: () => <div>Second accessible tab content</div>,
412
+ });
413
+ this.pluginSettingsManager.addPageTabItem({
414
+ menuKey: 'protected-tabs',
415
+ key: 'z-first',
416
+ title: 'First accessible tab',
417
+ aclSnippet: 'pm.protected-tabs.first',
418
+ sort: 10,
419
+ Component: () => <div>First accessible tab content</div>,
358
420
  });
359
421
  this.pluginSettingsManager.addPageTabItem({
360
- menuKey: 'mixed-access',
361
- key: 'restricted',
362
- title: 'Restricted settings',
363
- aclSnippet: 'pm.mixed-access.restricted',
364
- Component: () => <div>Restricted settings page</div>,
422
+ menuKey: 'protected-tabs',
423
+ key: 'denied',
424
+ title: 'Denied tab',
425
+ aclSnippet: 'pm.protected-tabs.denied',
426
+ sort: 30,
427
+ Component: renderDeniedTab,
365
428
  });
366
429
  }
367
430
  }
368
431
 
369
432
  const app = createMockSettingsClient({
370
- plugins: [SettingsBuildInPlugin, TestAclPlugin, MixedAccessSettingsPlugin],
371
- router: { type: 'memory', initialEntries: ['/settings/mixed-access/restricted'] },
433
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, ProtectedSettingsTabsPlugin],
434
+ router: { type: 'memory', initialEntries: ['/settings/protected-tabs/denied'] },
372
435
  });
373
436
  mockAdminRuntime(app, {
374
- snippets: ['pm', 'pm.system-settings.system-settings', '!pm.mixed-access.restricted'],
437
+ snippets: ['pm', '!pm.protected-tabs.denied'],
375
438
  });
376
439
 
377
440
  await renderApp(app);
378
441
  await waitForGetRequests(app, ['/auth:check', 'roles:check']);
379
442
 
380
- expect(
381
- await screen.findByRole('heading', { name: 'Your current role cannot access Settings' }),
382
- ).toBeInTheDocument();
383
- expect(screen.queryByText('Restricted settings page')).not.toBeInTheDocument();
443
+ await waitFor(() => {
444
+ expect(app.router.router.state.location.pathname).toBe('/settings/protected-tabs/z-first');
445
+ });
446
+ expect(app.router.router.state.historyAction).toBe('REPLACE');
447
+ expect(await screen.findByText('First accessible tab content')).toBeInTheDocument();
448
+ expect(screen.queryByText('Denied tab content')).not.toBeInTheDocument();
449
+ expect(renderDeniedTab).not.toHaveBeenCalled();
450
+
451
+ await act(async () => {
452
+ await app.router.router.navigate('/settings/protected-tabs/a-second');
453
+ });
454
+
455
+ expect(await screen.findByText('Second accessible tab content')).toBeInTheDocument();
456
+ expect(app.router.router.state.location.pathname).toBe('/settings/protected-tabs/a-second');
384
457
  });
385
458
 
386
459
  it.each(['/settings/fallback-access', '/settings/fallback-access/'])(
@@ -424,6 +497,134 @@ describe('settings center', () => {
424
497
  },
425
498
  );
426
499
 
500
+ it('should skip an accessible dynamic tab when its route params cannot be resolved', async () => {
501
+ class DynamicSettingsTabsPlugin extends Plugin {
502
+ async load() {
503
+ this.pluginSettingsManager.addMenuItem({ key: 'dynamic-tabs', title: 'Dynamic tabs' });
504
+ this.pluginSettingsManager.addPageTabItem({
505
+ menuKey: 'dynamic-tabs',
506
+ key: ':name',
507
+ title: 'Dynamic tab',
508
+ aclSnippet: 'pm.dynamic-tabs.dynamic',
509
+ sort: 1,
510
+ Component: () => <div>Dynamic tab content</div>,
511
+ });
512
+ this.pluginSettingsManager.addPageTabItem({
513
+ menuKey: 'dynamic-tabs',
514
+ key: 'fallback',
515
+ title: 'Static fallback tab',
516
+ aclSnippet: 'pm.dynamic-tabs.fallback',
517
+ sort: 2,
518
+ Component: () => <div>Static fallback tab content</div>,
519
+ });
520
+ this.pluginSettingsManager.addPageTabItem({
521
+ menuKey: 'dynamic-tabs',
522
+ key: 'denied',
523
+ title: 'Denied tab',
524
+ aclSnippet: 'pm.dynamic-tabs.denied',
525
+ sort: 3,
526
+ Component: () => <div>Denied dynamic tab content</div>,
527
+ });
528
+ }
529
+ }
530
+
531
+ const app = createMockSettingsClient({
532
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, DynamicSettingsTabsPlugin],
533
+ router: { type: 'memory', initialEntries: ['/settings/dynamic-tabs/denied'] },
534
+ });
535
+ mockAdminRuntime(app, {
536
+ snippets: ['pm', '!pm.dynamic-tabs.denied'],
537
+ });
538
+
539
+ await renderApp(app);
540
+ await waitForGetRequests(app, ['/auth:check', 'roles:check']);
541
+
542
+ await waitFor(() => {
543
+ expect(app.router.router.state.location.pathname).toBe('/settings/dynamic-tabs/fallback');
544
+ });
545
+ expect(await screen.findByText('Static fallback tab content')).toBeInTheDocument();
546
+ });
547
+
548
+ it('should preserve resolved route params when redirecting between dynamic tabs', async () => {
549
+ class DynamicSiblingTabsPlugin extends Plugin {
550
+ async load() {
551
+ this.pluginSettingsManager.addMenuItem({ key: 'dynamic-siblings', title: 'Dynamic sibling tabs' });
552
+ this.pluginSettingsManager.addPageTabItem({
553
+ menuKey: 'dynamic-siblings',
554
+ key: ':name/channels',
555
+ title: 'Dynamic channels tab',
556
+ aclSnippet: 'pm.dynamic-siblings.channels',
557
+ sort: 1,
558
+ Component: () => <div>Dynamic channels tab content</div>,
559
+ });
560
+ this.pluginSettingsManager.addPageTabItem({
561
+ menuKey: 'dynamic-siblings',
562
+ key: ':name/logs',
563
+ title: 'Dynamic logs tab',
564
+ aclSnippet: 'pm.dynamic-siblings.logs',
565
+ sort: 2,
566
+ Component: () => <div>Dynamic logs tab content</div>,
567
+ });
568
+ }
569
+ }
570
+
571
+ const app = createMockSettingsClient({
572
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, DynamicSiblingTabsPlugin],
573
+ router: { type: 'memory', initialEntries: ['/settings/dynamic-siblings/email:primary/logs'] },
574
+ });
575
+ mockAdminRuntime(app, {
576
+ snippets: ['pm', '!pm.dynamic-siblings.logs'],
577
+ });
578
+
579
+ await renderApp(app);
580
+ await waitForGetRequests(app, ['/auth:check', 'roles:check']);
581
+
582
+ await waitFor(() => {
583
+ expect(app.router.router.state.location.pathname).toBe('/settings/dynamic-siblings/email:primary/channels');
584
+ });
585
+ expect(await screen.findByText('Dynamic channels tab content')).toBeInTheDocument();
586
+ });
587
+
588
+ it('should resolve hyphenated route param names when redirecting between dynamic tabs', async () => {
589
+ class HyphenatedParamTabsPlugin extends Plugin {
590
+ async load() {
591
+ this.pluginSettingsManager.addMenuItem({ key: 'hyphenated-params', title: 'Hyphenated param tabs' });
592
+ this.pluginSettingsManager.addPageTabItem({
593
+ menuKey: 'hyphenated-params',
594
+ key: ':data-source/channels',
595
+ title: 'Hyphenated channels tab',
596
+ aclSnippet: 'pm.hyphenated-params.channels',
597
+ sort: 1,
598
+ Component: () => <div>Hyphenated channels tab content</div>,
599
+ });
600
+ this.pluginSettingsManager.addPageTabItem({
601
+ menuKey: 'hyphenated-params',
602
+ key: ':data-source/logs',
603
+ title: 'Hyphenated logs tab',
604
+ aclSnippet: 'pm.hyphenated-params.logs',
605
+ sort: 2,
606
+ Component: () => <div>Hyphenated logs tab content</div>,
607
+ });
608
+ }
609
+ }
610
+
611
+ const app = createMockSettingsClient({
612
+ plugins: [SettingsBuildInPlugin, TestAclPlugin, HyphenatedParamTabsPlugin],
613
+ router: { type: 'memory', initialEntries: ['/settings/hyphenated-params/email/logs'] },
614
+ });
615
+ mockAdminRuntime(app, {
616
+ snippets: ['pm', '!pm.hyphenated-params.logs'],
617
+ });
618
+
619
+ await renderApp(app);
620
+ await waitForGetRequests(app, ['/auth:check', 'roles:check']);
621
+
622
+ await waitFor(() => {
623
+ expect(app.router.router.state.location.pathname).toBe('/settings/hyphenated-params/email/channels');
624
+ });
625
+ expect(await screen.findByText('Hyphenated channels tab content')).toBeInTheDocument();
626
+ });
627
+
427
628
  it('should keep menu visible when menu acl is denied but child page is visible', async () => {
428
629
  class MenuAclPlugin extends Plugin {
429
630
  async load() {
@@ -72,6 +72,23 @@ const flowKeepMobileHorizontalClassName = css`
72
72
  function isGridDelegatedStep(flowKey: string, stepKey: string): boolean {
73
73
  return !!GRID_DELEGATED_STEP_KEYS[flowKey]?.has(stepKey);
74
74
  }
75
+
76
+ interface FormModelWithAvailableData {
77
+ hasAvailableData(): boolean;
78
+ }
79
+
80
+ interface FormModelWithSubmit {
81
+ submit(): Promise<unknown>;
82
+ }
83
+
84
+ function hasAvailableDataCapability(model: object): model is FormModelWithAvailableData {
85
+ return 'hasAvailableData' in model && typeof model.hasAvailableData === 'function';
86
+ }
87
+
88
+ function hasSubmitCapability(model: object): model is FormModelWithSubmit {
89
+ return 'submit' in model && typeof model.submit === 'function';
90
+ }
91
+
75
92
  export class FormBlockModel<
76
93
  T extends DefaultCollectionBlockModelStructure = DefaultCollectionBlockModelStructure,
77
94
  > extends CollectionBlockModel<T> {
@@ -87,6 +104,32 @@ export class FormBlockModel<
87
104
  return this.context.form as FormInstance;
88
105
  }
89
106
 
107
+ submitFromRunJs() {
108
+ if (hasAvailableDataCapability(this) && !this.hasAvailableData()) {
109
+ return;
110
+ }
111
+
112
+ const submitAction = this.mapSubModels('actions', (action) => action).find((action) =>
113
+ action.getFlow('submitSettings'),
114
+ );
115
+ if (submitAction) {
116
+ return submitAction.onClick(undefined);
117
+ }
118
+
119
+ const isCoreForm = ['CreateFormModel', 'EditFormModel'].some((modelName) => {
120
+ const ModelClass = this.flowEngine.getModelClass(modelName);
121
+ return ModelClass && this instanceof ModelClass;
122
+ });
123
+ if (this.context.publicFormRuntime || !isCoreForm || !hasSubmitCapability(this)) {
124
+ return;
125
+ }
126
+
127
+ this.submit().catch((error: unknown) => {
128
+ this.context.message.error(this.context.t('Save failed'));
129
+ console.error('Form submission error:', error);
130
+ });
131
+ }
132
+
90
133
  _defaultCustomModelClasses = {
91
134
  FormActionGroupModel: 'FormActionGroupModel',
92
135
  FormItemModel: 'FormItemModel',
@@ -0,0 +1,131 @@
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 } from '@nocobase/flow-engine';
11
+ import { describe, expect, it, vi } from 'vitest';
12
+ import { CreateFormModel } from '../CreateFormModel';
13
+ import { EditFormModel } from '../EditFormModel';
14
+ import { FormActionModel } from '../FormActionModel';
15
+
16
+ function prepareFormModel<T extends CreateFormModel | EditFormModel>(model: T, publicFormRuntime = false) {
17
+ const engine = new FlowEngine();
18
+ engine.registerModels({ CreateFormModel, EditFormModel });
19
+ model.flowEngine = engine;
20
+ Object.defineProperty(model, 'context', {
21
+ value: {
22
+ publicFormRuntime,
23
+ message: { error: vi.fn() },
24
+ t: (value: string) => value,
25
+ },
26
+ });
27
+ return model;
28
+ }
29
+
30
+ function createSubmitAction() {
31
+ const onClick = vi.fn();
32
+ const action = {
33
+ getFlow: vi.fn((key: string) => (key === 'submitSettings' ? {} : undefined)),
34
+ onClick,
35
+ } as unknown as FormActionModel;
36
+ return { action, onClick };
37
+ }
38
+
39
+ describe('FormBlockModel.submitFromRunJs', () => {
40
+ it('dispatches the first action with a submitSettings flow', () => {
41
+ const { action, onClick } = createSubmitAction();
42
+ const { action: secondAction, onClick: secondOnClick } = createSubmitAction();
43
+ const blockModel = prepareFormModel(
44
+ Object.assign(Object.create(CreateFormModel.prototype), {
45
+ mapSubModels: (_key: string, callback: (item: FormActionModel) => FormActionModel) => [
46
+ callback(action),
47
+ callback(secondAction),
48
+ ],
49
+ }) as CreateFormModel,
50
+ );
51
+
52
+ blockModel.submitFromRunJs();
53
+
54
+ expect(onClick).toHaveBeenCalledWith(undefined);
55
+ expect(secondOnClick).not.toHaveBeenCalled();
56
+ });
57
+
58
+ it('does not dispatch an edit submit action without an available record', () => {
59
+ const { action, onClick } = createSubmitAction();
60
+ const blockModel = prepareFormModel(
61
+ Object.assign(Object.create(EditFormModel.prototype), {
62
+ hasAvailableData: () => false,
63
+ mapSubModels: (_key: string, callback: (item: FormActionModel) => FormActionModel) => [callback(action)],
64
+ }) as EditFormModel,
65
+ );
66
+
67
+ blockModel.submitFromRunJs();
68
+
69
+ expect(onClick).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it('falls back to the core form submit handler when no submit action exists', () => {
73
+ const submit = vi.fn().mockResolvedValue(undefined);
74
+ const blockModel = prepareFormModel(
75
+ Object.assign(Object.create(CreateFormModel.prototype), {
76
+ mapSubModels: vi.fn(() => []),
77
+ submit,
78
+ }) as CreateFormModel,
79
+ );
80
+
81
+ blockModel.submitFromRunJs();
82
+
83
+ expect(submit).toHaveBeenCalledOnce();
84
+ });
85
+
86
+ it('falls back to the core edit submit handler when a record exists', () => {
87
+ const submit = vi.fn().mockResolvedValue(undefined);
88
+ const blockModel = prepareFormModel(
89
+ Object.assign(Object.create(EditFormModel.prototype), {
90
+ hasAvailableData: () => true,
91
+ mapSubModels: vi.fn(() => []),
92
+ submit,
93
+ }) as EditFormModel,
94
+ );
95
+
96
+ blockModel.submitFromRunJs();
97
+
98
+ expect(submit).toHaveBeenCalledOnce();
99
+ });
100
+
101
+ it('uses the core fallback for specialized form subclasses', () => {
102
+ class SpecializedCreateFormModel extends CreateFormModel {}
103
+
104
+ const submit = vi.fn().mockResolvedValue(undefined);
105
+ const blockModel = prepareFormModel(
106
+ Object.assign(Object.create(SpecializedCreateFormModel.prototype), {
107
+ mapSubModels: vi.fn(() => []),
108
+ submit,
109
+ }) as SpecializedCreateFormModel,
110
+ );
111
+
112
+ blockModel.submitFromRunJs();
113
+
114
+ expect(submit).toHaveBeenCalledOnce();
115
+ });
116
+
117
+ it('does not use the core fallback for public forms without a submit action', () => {
118
+ const submit = vi.fn().mockResolvedValue(undefined);
119
+ const blockModel = prepareFormModel(
120
+ Object.assign(Object.create(CreateFormModel.prototype), {
121
+ mapSubModels: vi.fn(() => []),
122
+ submit,
123
+ }) as CreateFormModel,
124
+ true,
125
+ );
126
+
127
+ blockModel.submitFromRunJs();
128
+
129
+ expect(submit).not.toHaveBeenCalled();
130
+ });
131
+ });
@@ -71,7 +71,7 @@ function formatBigNumberWithSeparator(value, format = '0.00', step = 1, formatSt
71
71
  let number = value;
72
72
 
73
73
  if (formatStyle) {
74
- number = new BigNumber(value).toString();
74
+ number = new BigNumber(value).toFixed();
75
75
  }
76
76
 
77
77
  let formattedNumber = '';
@@ -20,7 +20,7 @@ function toSafeNumber(value) {
20
20
  return value;
21
21
  }
22
22
  if (value > Number.MAX_SAFE_INTEGER || value < Number.MIN_SAFE_INTEGER) {
23
- return new BigNumber(value).toString();
23
+ return new BigNumber(value).toFixed();
24
24
  } else {
25
25
  return Number(value);
26
26
  }
@@ -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
+ });