@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/es/flow/models/blocks/form/FormBlockModel.d.ts +1 -0
- package/es/index.mjs +98 -98
- package/lib/index.js +98 -98
- package/package.json +7 -7
- package/src/__tests__/settings-center.test.tsx +222 -21
- 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/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/settings-center/AdminSettingsLayout.tsx +38 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/client-v2",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
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.
|
|
31
|
-
"@nocobase/flow-engine": "3.0.0-alpha.
|
|
32
|
-
"@nocobase/sdk": "3.0.0-alpha.
|
|
33
|
-
"@nocobase/shared": "3.0.0-alpha.
|
|
34
|
-
"@nocobase/utils": "3.0.0-alpha.
|
|
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": "
|
|
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
|
|
350
|
-
|
|
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: '
|
|
404
|
+
this.pluginSettingsManager.addMenuItem({ key: 'protected-tabs', title: 'Protected tabs' });
|
|
353
405
|
this.pluginSettingsManager.addPageTabItem({
|
|
354
|
-
menuKey: '
|
|
355
|
-
key: '
|
|
356
|
-
title: '
|
|
357
|
-
|
|
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: '
|
|
361
|
-
key: '
|
|
362
|
-
title: '
|
|
363
|
-
aclSnippet: 'pm.
|
|
364
|
-
|
|
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,
|
|
371
|
-
router: { type: 'memory', initialEntries: ['/settings/
|
|
433
|
+
plugins: [SettingsBuildInPlugin, TestAclPlugin, ProtectedSettingsTabsPlugin],
|
|
434
|
+
router: { type: 'memory', initialEntries: ['/settings/protected-tabs/denied'] },
|
|
372
435
|
});
|
|
373
436
|
mockAdminRuntime(app, {
|
|
374
|
-
snippets: ['pm', '
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
)
|
|
383
|
-
expect(
|
|
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
|
+
});
|
|
@@ -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
|
+
});
|