@nocobase/client-v2 2.2.0-beta.3 → 2.2.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/es/RouteRepository.d.ts +21 -6
  2. package/es/components/form/JsonTextArea.d.ts +2 -1
  3. package/es/components/form/VariableJsonTextArea.d.ts +19 -0
  4. package/es/components/form/index.d.ts +1 -0
  5. package/es/components/index.d.ts +1 -0
  6. package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +2 -0
  7. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.d.ts +2 -1
  8. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +2 -0
  9. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.d.ts +3 -2
  10. package/es/flow/admin-shell/admin-layout/AdminLayoutSlotModels.d.ts +4 -0
  11. package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +6 -0
  12. package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
  13. package/es/flow/models/base/GridModel.d.ts +1 -1
  14. package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +5 -0
  15. package/es/flow/models/blocks/form/FormBlockModel.d.ts +4 -0
  16. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +21 -0
  17. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +21 -0
  18. package/es/flow/models/blocks/form/value-runtime/types.d.ts +2 -2
  19. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +2 -0
  20. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +1 -0
  21. package/es/flow/utils/dateTimeDisplayProps.d.ts +49 -0
  22. package/es/flow-compat/FieldValidation.d.ts +2 -1
  23. package/es/index.d.ts +2 -1
  24. package/es/index.mjs +167 -121
  25. package/lib/index.js +167 -121
  26. package/package.json +7 -7
  27. package/src/RouteRepository.ts +126 -24
  28. package/src/__tests__/RouteRepository.test.ts +216 -0
  29. package/src/__tests__/exports.test.ts +16 -0
  30. package/src/__tests__/settings-center.test.tsx +33 -0
  31. package/src/components/form/JsonTextArea.tsx +21 -11
  32. package/src/components/form/VariableJsonTextArea.tsx +175 -0
  33. package/src/components/form/__tests__/JsonTextArea.test.tsx +43 -0
  34. package/src/components/form/__tests__/VariableJsonTextArea.test.tsx +86 -0
  35. package/src/components/form/index.tsx +1 -0
  36. package/src/components/index.ts +1 -0
  37. package/src/flow/__tests__/FlowRoute.test.tsx +443 -5
  38. package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +150 -1
  39. package/src/flow/actions/__tests__/linkageAssignField.legacy.test.ts +135 -0
  40. package/src/flow/actions/__tests__/linkageRules.hiddenSync.restore.test.ts +19 -5
  41. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +92 -0
  42. package/src/flow/actions/customVariable.tsx +1 -2
  43. package/src/flow/actions/dateTimeFormat.tsx +42 -28
  44. package/src/flow/actions/linkageRules.tsx +122 -34
  45. package/src/flow/actions/runjs.tsx +2 -14
  46. package/src/flow/actions/validation.tsx +62 -30
  47. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +33 -10
  48. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +310 -0
  49. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +44 -8
  50. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.ts +5 -3
  51. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +61 -9
  52. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +71 -8
  53. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +1 -1
  54. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +21 -8
  55. package/src/flow/admin-shell/admin-layout/HelpLite.tsx +1 -3
  56. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +188 -0
  57. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +99 -0
  58. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +166 -2
  59. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +20 -0
  60. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +33 -5
  61. package/src/flow/components/DefaultValue.tsx +1 -2
  62. package/src/flow/components/DynamicFlowsIcon.tsx +447 -126
  63. package/src/flow/components/FieldAssignRulesEditor.tsx +62 -12
  64. package/src/flow/components/FlowRoute.tsx +56 -11
  65. package/src/flow/components/__tests__/DynamicFlowsIcon.test.tsx +148 -2
  66. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +81 -4
  67. package/src/flow/components/code-editor/__tests__/useCodeRunner.test.tsx +2 -17
  68. package/src/flow/components/code-editor/hooks/useCodeRunner.ts +2 -14
  69. package/src/flow/components/code-editor/runjsDiagnostics.ts +8 -45
  70. package/src/flow/flows/editMarkdownFlow.tsx +1 -1
  71. package/src/flow/index.ts +1 -1
  72. package/src/flow/internal/utils/operatorSchemaHelper.ts +15 -1
  73. package/src/flow/models/actions/JSActionModel.tsx +2 -7
  74. package/src/flow/models/actions/JSCollectionActionModel.tsx +2 -7
  75. package/src/flow/models/actions/JSItemActionModel.tsx +2 -7
  76. package/src/flow/models/actions/JSRecordActionModel.tsx +2 -7
  77. package/src/flow/models/base/ActionModel.tsx +12 -1
  78. package/src/flow/models/base/GridModel.tsx +38 -7
  79. package/src/flow/models/base/PageModel/PageModel.tsx +4 -1
  80. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +28 -4
  81. package/src/flow/models/base/__tests__/ActionModel.test.ts +83 -0
  82. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +239 -1
  83. package/src/flow/models/base/__tests__/transformRowsToSingleColumn.test.ts +48 -0
  84. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +31 -3
  85. package/src/flow/models/blocks/filter-form/FilterFormJSActionModel.tsx +2 -7
  86. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +77 -0
  87. package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/customizeFilterRender.test.tsx +56 -1
  88. package/src/flow/models/blocks/form/EditFormModel.tsx +1 -0
  89. package/src/flow/models/blocks/form/FormBlockModel.tsx +7 -0
  90. package/src/flow/models/blocks/form/JSFormActionModel.tsx +2 -7
  91. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
  92. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +880 -102
  93. package/src/flow/models/blocks/form/value-runtime/rules.ts +445 -13
  94. package/src/flow/models/blocks/form/value-runtime/runtime.ts +257 -13
  95. package/src/flow/models/blocks/form/value-runtime/types.ts +2 -2
  96. package/src/flow/models/blocks/js-block/JSBlock.tsx +2 -7
  97. package/src/flow/models/blocks/table/JSColumnModel.tsx +1 -9
  98. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +1 -1
  99. package/src/flow/models/blocks/table/TableBlockModel.tsx +13 -3
  100. package/src/flow/models/blocks/table/TableColumnModel.tsx +36 -3
  101. package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +54 -0
  102. package/src/flow/models/blocks/table/__tests__/TableBlockModel.mobileSettingsButtons.test.tsx +78 -0
  103. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +159 -1
  104. package/src/flow/models/fields/AssociationFieldModel/AssociationFieldModel.tsx +1 -1
  105. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +29 -6
  106. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +16 -4
  107. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +24 -1
  108. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +13 -1
  109. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +12 -0
  110. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableFieldModel.reset.test.ts +180 -0
  111. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -4
  112. package/src/flow/models/fields/AssociationFieldModel/__tests__/AssociationFieldModel.updateAssociation.test.ts +72 -0
  113. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +23 -0
  114. package/src/flow/models/fields/ClickableFieldModel.tsx +5 -0
  115. package/src/flow/models/fields/DisplayDateTimeFieldModel.tsx +29 -1
  116. package/src/flow/models/fields/InputFieldModel.tsx +14 -22
  117. package/src/flow/models/fields/JSEditableFieldModel.tsx +2 -11
  118. package/src/flow/models/fields/JSFieldModel.tsx +2 -7
  119. package/src/flow/models/fields/JSItemModel.tsx +2 -14
  120. package/src/flow/models/fields/SelectFieldModel.tsx +6 -4
  121. package/src/flow/models/fields/__tests__/DisplayDateTimeFieldModel.test.tsx +96 -0
  122. package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +17 -0
  123. package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +43 -0
  124. package/src/flow/models/topbar/TopbarActionModel.tsx +93 -10
  125. package/src/flow/utils/__tests__/dateTimeFormat.test.ts +258 -0
  126. package/src/flow/utils/dateTimeDisplayProps.ts +135 -0
  127. package/src/flow-compat/FieldValidation.tsx +122 -60
  128. package/src/flow-compat/Popover.tsx +43 -4
  129. package/src/flow-compat/__tests__/Popover.test.tsx +34 -0
  130. package/src/index.ts +8 -1
  131. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +41 -1
  132. package/src/settings-center/AdminSettingsLayout.tsx +3 -0
  133. package/src/settings-center/plugin-manager/index.tsx +3 -0
@@ -13,6 +13,7 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom';
13
13
  import { render, screen, waitFor } from '@testing-library/react';
14
14
  import { FlowContextProvider, FlowEngine, FlowEngineProvider, type FlowModel } from '@nocobase/flow-engine';
15
15
  import FlowRoute from '../components/FlowRoute';
16
+ import { RouteRepository } from '../../RouteRepository';
16
17
 
17
18
  type MockAdminLayoutModel = FlowModel & {
18
19
  registerRoutePage: ReturnType<typeof vi.fn>;
@@ -321,6 +322,94 @@ describe('FlowRoute', () => {
321
322
  });
322
323
  });
323
324
 
325
+ it('should ensure accessible routes for the current layout before bridging', async () => {
326
+ const engine = new FlowEngine();
327
+ const request = vi.fn().mockResolvedValue({
328
+ data: {
329
+ data: [
330
+ {
331
+ type: 'flowPage',
332
+ schemaUid: 'mobile-page',
333
+ },
334
+ ],
335
+ },
336
+ });
337
+ const routeRepository = new RouteRepository({
338
+ api: {
339
+ request,
340
+ resource: vi.fn(),
341
+ },
342
+ } as never);
343
+ routeRepository.setRoutes([], 'admin-layout-model');
344
+ const deactivateLayout = routeRepository.activateLayout({
345
+ uid: 'mobile-layout-model',
346
+ });
347
+ engine.context.defineProperty('routeRepository', {
348
+ value: routeRepository,
349
+ });
350
+ engine.context.defineProperty('app', {
351
+ value: {
352
+ getPublicPath: () => '/v2/',
353
+ router: {
354
+ getBasename: () => '/v2',
355
+ },
356
+ },
357
+ });
358
+ const routeModel = engine.createModel({
359
+ uid: 'mobile-route-model',
360
+ use: 'FlowModel',
361
+ });
362
+ routeModel.context.defineProperty('layout', {
363
+ value: {
364
+ routeName: 'mobile',
365
+ routePath: '/mobile',
366
+ rootRouteName: 'mobile',
367
+ uid: 'mobile-layout-model',
368
+ layoutModelClass: 'MobileLayoutModel',
369
+ rootPageModelClass: 'MobileRootPageModel',
370
+ childPageModelClass: 'MobileChildPageModel',
371
+ authCheck: true,
372
+ },
373
+ });
374
+
375
+ const mobileLayoutModel: MockAdminLayoutModel = Object.assign(
376
+ engine.createModel({
377
+ uid: 'mobile-layout-model',
378
+ use: 'FlowModel',
379
+ }),
380
+ {
381
+ registerRoutePage: vi.fn(),
382
+ updateRoutePage: vi.fn(),
383
+ unregisterRoutePage: vi.fn(),
384
+ },
385
+ );
386
+
387
+ render(
388
+ <FlowEngineProvider engine={engine}>
389
+ <FlowContextProvider context={routeModel.context}>
390
+ <MemoryRouter initialEntries={['/mobile/mobile-page']}>
391
+ <Routes>
392
+ <Route path="/mobile/:name" element={<FlowRoute />} />
393
+ </Routes>
394
+ </MemoryRouter>
395
+ </FlowContextProvider>
396
+ </FlowEngineProvider>,
397
+ );
398
+
399
+ await waitFor(() => {
400
+ expect(request).toHaveBeenCalledWith({
401
+ url: '/desktopRoutes:listAccessible',
402
+ params: {
403
+ tree: true,
404
+ sort: 'sort',
405
+ layout: 'mobile-layout-model',
406
+ },
407
+ });
408
+ expect(mobileLayoutModel.registerRoutePage).toHaveBeenCalledWith('mobile-page', expect.any(Object));
409
+ });
410
+ deactivateLayout();
411
+ });
412
+
324
413
  it('should fail fast when admin-layout-model is missing', () => {
325
414
  const engine = new FlowEngine();
326
415
  engine.context.defineProperty('route', {
@@ -420,6 +509,75 @@ describe('FlowRoute', () => {
420
509
  });
421
510
  });
422
511
 
512
+ it('should not rerun accessible guard when layout reference changes but fields stay the same', async () => {
513
+ const engine = new FlowEngine();
514
+ const ensureAccessibleLoaded = vi.fn().mockResolvedValue([]);
515
+ const getRouteBySchemaUid = vi.fn(() => ({ type: 'flowPage', schemaUid: 'test-page' }));
516
+ engine.context.defineProperty('routeRepository', {
517
+ value: {
518
+ refreshAccessible: hookState.refresh,
519
+ isAccessibleLoaded: () => true,
520
+ ensureAccessibleLoaded,
521
+ getRouteBySchemaUid,
522
+ },
523
+ });
524
+ engine.context.defineProperty('app', {
525
+ value: {
526
+ getPublicPath: () => '/v2/',
527
+ router: {
528
+ getBasename: () => '/v2',
529
+ },
530
+ },
531
+ });
532
+
533
+ const layoutModel: MockAdminLayoutModel = Object.assign(
534
+ engine.createModel({ uid: 'legacy-admin-layout-model', use: 'FlowModel' }),
535
+ {
536
+ registerRoutePage: vi.fn(),
537
+ updateRoutePage: vi.fn(),
538
+ unregisterRoutePage: vi.fn(),
539
+ },
540
+ );
541
+ let layoutReadCount = 0;
542
+ const stableLayout = {
543
+ uid: 'admin-layout-model',
544
+ routeName: 'admin',
545
+ routePath: '/admin',
546
+ authCheck: true,
547
+ };
548
+ Object.defineProperty(layoutModel, 'layout', {
549
+ get: () => {
550
+ layoutReadCount += 1;
551
+ return layoutReadCount <= 2 ? { ...stableLayout } : stableLayout;
552
+ },
553
+ });
554
+
555
+ const element = (
556
+ <FlowEngineProvider engine={engine}>
557
+ <MemoryRouter initialEntries={['/flow/test-page']}>
558
+ <Routes>
559
+ <Route path="/flow/:name" element={<FlowRoute getLayoutModel={() => layoutModel as any} />} />
560
+ </Routes>
561
+ </MemoryRouter>
562
+ </FlowEngineProvider>
563
+ );
564
+ const { rerender } = render(element);
565
+
566
+ await waitFor(() => {
567
+ expect(layoutModel.registerRoutePage).toHaveBeenCalledWith('test-page', expect.any(Object));
568
+ });
569
+ expect(ensureAccessibleLoaded).not.toHaveBeenCalled();
570
+ expect(getRouteBySchemaUid).toHaveBeenCalledTimes(1);
571
+
572
+ rerender(element);
573
+
574
+ await waitFor(() => {
575
+ expect(layoutModel.updateRoutePage).toHaveBeenCalled();
576
+ });
577
+ expect(ensureAccessibleLoaded).not.toHaveBeenCalled();
578
+ expect(getRouteBySchemaUid).toHaveBeenCalledTimes(1);
579
+ });
580
+
423
581
  it('should show 404 when current route is a legacy page in v2 runtime', async () => {
424
582
  const originalLocation = window.location;
425
583
  const replace = vi.fn();
@@ -571,6 +729,22 @@ describe('FlowRoute', () => {
571
729
  },
572
730
  },
573
731
  });
732
+ const routeModel = engine.createModel({
733
+ uid: 'public-form-route-model',
734
+ use: 'FlowModel',
735
+ });
736
+ routeModel.context.defineProperty('layout', {
737
+ value: {
738
+ routeName: 'public-forms',
739
+ routePath: '/public-forms',
740
+ rootRouteName: 'public-forms',
741
+ uid: 'public-form-layout-model',
742
+ layoutModelClass: 'PublicFormLayoutModel',
743
+ rootPageModelClass: 'PublicFormPageModel',
744
+ childPageModelClass: 'ChildPageModel',
745
+ authCheck: false,
746
+ },
747
+ });
574
748
 
575
749
  const layoutModel: MockAdminLayoutModel = Object.assign(
576
750
  engine.createModel({ uid: 'public-form-layout-model', use: 'FlowModel' }),
@@ -581,13 +755,82 @@ describe('FlowRoute', () => {
581
755
  },
582
756
  );
583
757
 
758
+ render(
759
+ <FlowEngineProvider engine={engine}>
760
+ <FlowContextProvider context={routeModel.context}>
761
+ <MemoryRouter initialEntries={['/public-forms/public-form-1']}>
762
+ <Routes>
763
+ <Route
764
+ path="/public-forms/:name"
765
+ element={<FlowRoute legacyPageBehavior="notFound" getLayoutModel={() => layoutModel} />}
766
+ />
767
+ </Routes>
768
+ </MemoryRouter>
769
+ </FlowContextProvider>
770
+ </FlowEngineProvider>,
771
+ );
772
+
773
+ await waitFor(() => {
774
+ expect(layoutModel.registerRoutePage).toHaveBeenCalledWith('public-form-1', expect.any(Object));
775
+ });
776
+ expect(screen.queryByText('404')).not.toBeInTheDocument();
777
+ });
778
+
779
+ it('should use getLayoutModel layout authCheck when FlowContext has no layout', async () => {
780
+ const engine = new FlowEngine();
781
+ engine.setModelRepository({
782
+ findOne: vi.fn().mockResolvedValue({
783
+ uid: 'public-form-1',
784
+ use: 'FlowModel',
785
+ }),
786
+ save: vi.fn(),
787
+ destroy: vi.fn(),
788
+ } as any);
789
+ engine.context.defineProperty('routeRepository', {
790
+ value: {
791
+ refreshAccessible: hookState.refresh,
792
+ isAccessibleLoaded: () => true,
793
+ ensureAccessibleLoaded: vi.fn().mockResolvedValue([]),
794
+ getRouteBySchemaUid: vi.fn(() => undefined),
795
+ },
796
+ });
797
+ engine.context.defineProperty('app', {
798
+ value: {
799
+ getPublicPath: () => '/v2/',
800
+ router: {
801
+ getBasename: () => '/v2',
802
+ },
803
+ },
804
+ });
805
+
806
+ const layoutModel: MockAdminLayoutModel = Object.assign(
807
+ engine.createModel({ uid: 'public-form-layout-model', use: 'FlowModel' }),
808
+ {
809
+ registerRoutePage: vi.fn(),
810
+ updateRoutePage: vi.fn(),
811
+ unregisterRoutePage: vi.fn(),
812
+ },
813
+ );
814
+ Object.defineProperty(layoutModel, 'layout', {
815
+ value: {
816
+ routeName: 'public-forms',
817
+ routePath: '/public-forms',
818
+ rootRouteName: 'public-forms',
819
+ uid: 'public-form-layout-model',
820
+ layoutModelClass: 'PublicFormLayoutModel',
821
+ rootPageModelClass: 'PublicFormPageModel',
822
+ childPageModelClass: 'ChildPageModel',
823
+ authCheck: false,
824
+ },
825
+ });
826
+
584
827
  render(
585
828
  <FlowEngineProvider engine={engine}>
586
829
  <MemoryRouter initialEntries={['/public-forms/public-form-1']}>
587
830
  <Routes>
588
831
  <Route
589
832
  path="/public-forms/:name"
590
- element={<FlowRoute legacyPageBehavior="notFound" getLayoutModel={() => layoutModel} />}
833
+ element={<FlowRoute legacyPageBehavior="notFound" getLayoutModel={() => layoutModel as any} />}
591
834
  />
592
835
  </Routes>
593
836
  </MemoryRouter>
@@ -600,6 +843,202 @@ describe('FlowRoute', () => {
600
843
  expect(screen.queryByText('404')).not.toBeInTheDocument();
601
844
  });
602
845
 
846
+ it('should render not found when admin route is not accessible', async () => {
847
+ const engine = new FlowEngine();
848
+ engine.setModelRepository({
849
+ findOne: vi.fn().mockResolvedValue({
850
+ uid: 'admin-denied-page',
851
+ use: 'FlowModel',
852
+ }),
853
+ save: vi.fn(),
854
+ destroy: vi.fn(),
855
+ } as any);
856
+ engine.context.defineProperty('routeRepository', {
857
+ value: {
858
+ refreshAccessible: hookState.refresh,
859
+ isAccessibleLoaded: () => true,
860
+ ensureAccessibleLoaded: vi.fn().mockResolvedValue([]),
861
+ getRouteBySchemaUid: vi.fn(() => undefined),
862
+ },
863
+ });
864
+ engine.context.defineProperty('app', {
865
+ value: {
866
+ getPublicPath: () => '/v2/',
867
+ router: {
868
+ getBasename: () => '/v2',
869
+ },
870
+ },
871
+ });
872
+
873
+ const adminLayoutModel: MockAdminLayoutModel = Object.assign(
874
+ engine.createModel({ uid: 'admin-layout-model', use: 'FlowModel' }),
875
+ {
876
+ registerRoutePage: vi.fn(),
877
+ updateRoutePage: vi.fn(),
878
+ unregisterRoutePage: vi.fn(),
879
+ },
880
+ );
881
+
882
+ render(
883
+ <FlowEngineProvider engine={engine}>
884
+ <MemoryRouter initialEntries={['/admin/admin-denied-page']}>
885
+ <Routes>
886
+ <Route path="/admin/:name" element={<FlowRoute />} />
887
+ </Routes>
888
+ </MemoryRouter>
889
+ </FlowEngineProvider>,
890
+ );
891
+
892
+ expect(await screen.findByText('404')).toBeInTheDocument();
893
+ expect(adminLayoutModel.registerRoutePage).not.toHaveBeenCalled();
894
+ });
895
+
896
+ it('should not use flowModels:findOne to bridge a missing route for protected layouts', async () => {
897
+ const engine = new FlowEngine();
898
+ const findOne = vi.fn().mockResolvedValue({
899
+ uid: 'mobile-denied-page',
900
+ use: 'FlowModel',
901
+ });
902
+ engine.setModelRepository({
903
+ findOne,
904
+ save: vi.fn(),
905
+ destroy: vi.fn(),
906
+ } as any);
907
+ engine.context.defineProperty('routeRepository', {
908
+ value: {
909
+ refreshAccessible: hookState.refresh,
910
+ isAccessibleLoaded: () => true,
911
+ ensureAccessibleLoaded: vi.fn().mockResolvedValue([]),
912
+ getRouteBySchemaUid: vi.fn(() => undefined),
913
+ },
914
+ });
915
+ engine.context.defineProperty('app', {
916
+ value: {
917
+ getPublicPath: () => '/v2/',
918
+ router: {
919
+ getBasename: () => '/v2',
920
+ },
921
+ },
922
+ });
923
+ const routeModel = engine.createModel({
924
+ uid: 'mobile-route-model',
925
+ use: 'FlowModel',
926
+ });
927
+ routeModel.context.defineProperty('layout', {
928
+ value: {
929
+ routeName: 'mobile',
930
+ routePath: '/mobile',
931
+ rootRouteName: 'mobile',
932
+ uid: 'mobile-layout-model',
933
+ layoutModelClass: 'MobileLayoutModel',
934
+ rootPageModelClass: 'MobileRootPageModel',
935
+ childPageModelClass: 'MobileChildPageModel',
936
+ authCheck: true,
937
+ },
938
+ });
939
+
940
+ const layoutModel: MockAdminLayoutModel = Object.assign(
941
+ engine.createModel({ uid: 'mobile-layout-model', use: 'FlowModel' }),
942
+ {
943
+ registerRoutePage: vi.fn(),
944
+ updateRoutePage: vi.fn(),
945
+ unregisterRoutePage: vi.fn(),
946
+ },
947
+ );
948
+
949
+ render(
950
+ <FlowEngineProvider engine={engine}>
951
+ <FlowContextProvider context={routeModel.context}>
952
+ <MemoryRouter initialEntries={['/mobile/mobile-denied-page']}>
953
+ <Routes>
954
+ <Route
955
+ path="/mobile/:name"
956
+ element={<FlowRoute legacyPageBehavior="notFound" getLayoutModel={() => layoutModel} />}
957
+ />
958
+ </Routes>
959
+ </MemoryRouter>
960
+ </FlowContextProvider>
961
+ </FlowEngineProvider>,
962
+ );
963
+
964
+ expect(await screen.findByText('404')).toBeInTheDocument();
965
+ expect(findOne).not.toHaveBeenCalled();
966
+ expect(layoutModel.registerRoutePage).not.toHaveBeenCalled();
967
+ });
968
+
969
+ it('should not load a known page uid when a protected custom layout has no accessible route', async () => {
970
+ const engine = new FlowEngine();
971
+ const findOne = vi.fn().mockResolvedValue({
972
+ uid: 'known-denied-custom-page',
973
+ use: 'FlowModel',
974
+ });
975
+ engine.setModelRepository({
976
+ findOne,
977
+ save: vi.fn(),
978
+ destroy: vi.fn(),
979
+ } as any);
980
+ engine.context.defineProperty('routeRepository', {
981
+ value: {
982
+ refreshAccessible: hookState.refresh,
983
+ isAccessibleLoaded: () => true,
984
+ ensureAccessibleLoaded: vi.fn().mockResolvedValue([]),
985
+ getRouteBySchemaUid: vi.fn(() => undefined),
986
+ },
987
+ });
988
+ engine.context.defineProperty('app', {
989
+ value: {
990
+ getPublicPath: () => '/v2/',
991
+ router: {
992
+ getBasename: () => '/v2',
993
+ },
994
+ },
995
+ });
996
+ const routeModel = engine.createModel({
997
+ uid: 'custom-layout-route-model',
998
+ use: 'FlowModel',
999
+ });
1000
+ routeModel.context.defineProperty('layout', {
1001
+ value: {
1002
+ routeName: 'admin2',
1003
+ routePath: '/admin2',
1004
+ rootRouteName: 'admin2',
1005
+ uid: 'custom-protected-layout-model',
1006
+ layoutModelClass: 'AdminLayoutModel',
1007
+ rootPageModelClass: 'RootPageModel',
1008
+ childPageModelClass: 'ChildPageModel',
1009
+ authCheck: true,
1010
+ },
1011
+ });
1012
+
1013
+ const layoutModel: MockAdminLayoutModel = Object.assign(
1014
+ engine.createModel({ uid: 'custom-protected-layout-model', use: 'FlowModel' }),
1015
+ {
1016
+ registerRoutePage: vi.fn(),
1017
+ updateRoutePage: vi.fn(),
1018
+ unregisterRoutePage: vi.fn(),
1019
+ },
1020
+ );
1021
+
1022
+ render(
1023
+ <FlowEngineProvider engine={engine}>
1024
+ <FlowContextProvider context={routeModel.context}>
1025
+ <MemoryRouter initialEntries={['/admin2/known-denied-custom-page']}>
1026
+ <Routes>
1027
+ <Route
1028
+ path="/admin2/:name"
1029
+ element={<FlowRoute legacyPageBehavior="notFound" getLayoutModel={() => layoutModel} />}
1030
+ />
1031
+ </Routes>
1032
+ </MemoryRouter>
1033
+ </FlowContextProvider>
1034
+ </FlowEngineProvider>,
1035
+ );
1036
+
1037
+ expect(await screen.findByText('404')).toBeInTheDocument();
1038
+ expect(findOne).not.toHaveBeenCalled();
1039
+ expect(layoutModel.registerRoutePage).not.toHaveBeenCalled();
1040
+ });
1041
+
603
1042
  it('should check model existence without occupying the route model uid', async () => {
604
1043
  const engine = new FlowEngine();
605
1044
  const findOne = vi.fn().mockResolvedValue({
@@ -817,7 +1256,7 @@ describe('FlowRoute', () => {
817
1256
  }
818
1257
  });
819
1258
 
820
- it('should not redirect when route does not exist', async () => {
1259
+ it('should render not found without redirecting when admin route does not exist', async () => {
821
1260
  const originalLocation = window.location;
822
1261
  const replace = vi.fn();
823
1262
  Object.defineProperty(window, 'location', {
@@ -866,9 +1305,8 @@ describe('FlowRoute', () => {
866
1305
  </FlowEngineProvider>,
867
1306
  );
868
1307
 
869
- await waitFor(() => {
870
- expect(adminLayoutModel.registerRoutePage).toHaveBeenCalled();
871
- });
1308
+ expect(await screen.findByText('404')).toBeInTheDocument();
1309
+ expect(adminLayoutModel.registerRoutePage).not.toHaveBeenCalled();
872
1310
  expect(replace).not.toHaveBeenCalled();
873
1311
  } finally {
874
1312
  Object.defineProperty(window, 'location', {
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { describe, expect, it, vi } from 'vitest';
11
11
  import { FlowEngine, FlowModel } from '@nocobase/flow-engine';
12
- import { fieldLinkageRules } from '../linkageRules';
12
+ import { fieldLinkageRules, linkageAssignField } from '../linkageRules';
13
13
 
14
14
  describe('fieldLinkageRules action - linkage scope metadata', () => {
15
15
  const targetCollection: any = {
@@ -868,6 +868,155 @@ describe('fieldLinkageRules action - linkage scope metadata', () => {
868
868
  expect(setFormValues).toHaveBeenCalledTimes(2);
869
869
  });
870
870
 
871
+ it('keeps row-scoped override patches editable after the target is user edited', async () => {
872
+ const store = {
873
+ roles: [{ uid: 'role-uid-1', name: 'existing' }],
874
+ };
875
+ const getAt = (path: Array<string | number>) => path.reduce((acc: any, seg) => acc?.[seg], store as any);
876
+ const setAt = (path: Array<string | number>, value: any) => {
877
+ let cur: any = store;
878
+ for (const seg of path.slice(0, -1)) {
879
+ cur = cur[seg];
880
+ }
881
+ cur[path[path.length - 1]] = value;
882
+ };
883
+ const editedKeys = new Set<string>();
884
+ const toKey = (path: Array<string | number>) => JSON.stringify(path);
885
+ const form = {
886
+ getFieldValue: (path: Array<string | number>) => getAt(path),
887
+ };
888
+ const formValueRuntime = {
889
+ canApplyOverrideValuePatch: vi.fn((path: Array<string | number>) => !editedKeys.has(toKey(path))),
890
+ };
891
+ const setFormValues = vi.fn(async (patches: Array<{ path: Array<string | number>; value: any }>) => {
892
+ for (const patch of patches) {
893
+ setAt(patch.path, patch.value);
894
+ }
895
+ });
896
+
897
+ const engine = new FlowEngine();
898
+ const rowFork = new FlowModel({ uid: 'role-override-row-fork', flowEngine: engine }) as any;
899
+ rowFork.context.defineProperty('fieldIndex', {
900
+ value: ['roles:0'],
901
+ });
902
+ rowFork.context.defineProperty('form', {
903
+ value: form,
904
+ });
905
+ rowFork.context.defineProperty('setFormValues', {
906
+ value: setFormValues,
907
+ });
908
+ rowFork.context.defineProperty('app', {
909
+ value: {
910
+ jsonLogic: {
911
+ apply: () => true,
912
+ },
913
+ },
914
+ });
915
+ rowFork.subModels = { items: [] };
916
+ rowFork.getAction = vi.fn((name: string) => {
917
+ if (name === 'linkageAssignField') {
918
+ return linkageAssignField;
919
+ }
920
+ });
921
+
922
+ const masterModel: any = {
923
+ uid: 'master-role-override-grid',
924
+ forks: new Set([rowFork]),
925
+ };
926
+ const rolesField: any = {
927
+ type: 'hasMany',
928
+ isAssociationField: () => true,
929
+ targetCollection: {
930
+ getField: (name: string) => ({ name, isAssociationField: () => false }),
931
+ },
932
+ };
933
+ const blockModel: any = {
934
+ collection: {
935
+ getField: (name: string) => (name === 'roles' ? rolesField : null),
936
+ },
937
+ formValueRuntime,
938
+ };
939
+ rowFork.context.defineProperty('blockModel', {
940
+ value: blockModel,
941
+ });
942
+ const gridModel: any = {
943
+ uid: 'grid-model-role-override-row',
944
+ context: {
945
+ blockModel,
946
+ },
947
+ getAction: vi.fn((name: string) => {
948
+ if (name === 'linkageAssignField') {
949
+ return linkageAssignField;
950
+ }
951
+ }),
952
+ __allModels: [],
953
+ };
954
+ const ctx: any = {
955
+ model: gridModel,
956
+ engine: {
957
+ forEachModel: (cb: (m: any) => void) => {
958
+ cb(masterModel);
959
+ },
960
+ },
961
+ flowKey: 'eventSettings',
962
+ inputArgs: {
963
+ source: 'user',
964
+ txId: 'tx-role-override-row',
965
+ changedPaths: [['roles', 0, 'uid']],
966
+ },
967
+ app: {
968
+ jsonLogic: {
969
+ apply: () => true,
970
+ },
971
+ },
972
+ resolveJsonTemplate: async (v: any) => v,
973
+ };
974
+ const makeParams = (value: string) => ({
975
+ value: [
976
+ {
977
+ key: `rule-${value}`,
978
+ title: `rule-${value}`,
979
+ enable: true,
980
+ condition: { logic: '$and', items: [] },
981
+ actions: [
982
+ {
983
+ name: 'linkageAssignField',
984
+ params: {
985
+ value: [
986
+ {
987
+ key: `r-${value}`,
988
+ enable: true,
989
+ targetPath: 'roles.name',
990
+ mode: 'override',
991
+ value,
992
+ condition: { logic: '$and', items: [] },
993
+ },
994
+ ],
995
+ },
996
+ },
997
+ ],
998
+ },
999
+ ],
1000
+ });
1001
+
1002
+ await fieldLinkageRules.handler(ctx, makeParams('role-uid-1'));
1003
+ expect(store.roles[0].name).toBe('role-uid-1');
1004
+
1005
+ store.roles[0].uid = 'role-uid-2';
1006
+ await fieldLinkageRules.handler(ctx, makeParams('role-uid-2'));
1007
+ expect(store.roles[0].name).toBe('role-uid-2');
1008
+ expect(setFormValues).toHaveBeenCalledTimes(2);
1009
+
1010
+ store.roles[0].name = 'manual';
1011
+ store.roles[0].uid = 'role-uid-3';
1012
+ editedKeys.add(toKey(['roles', 0, 'name']));
1013
+ await fieldLinkageRules.handler(ctx, makeParams('role-uid-3'));
1014
+
1015
+ expect(store.roles[0].name).toBe('manual');
1016
+ expect(setFormValues).toHaveBeenCalledTimes(2);
1017
+ expect(formValueRuntime.canApplyOverrideValuePatch).toHaveBeenCalledWith(['roles', 0, 'name']);
1018
+ });
1019
+
871
1020
  it('skips stale subtable row forks after the row has been removed', async () => {
872
1021
  const setFormValues = vi.fn(async () => undefined);
873
1022
  const defaultHandler = vi.fn((actionCtx: any, { addFormValuePatch }: any) => {