@nocobase/client-v2 2.2.0-alpha.6 → 2.2.0-alpha.8

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 (49) hide show
  1. package/es/BaseApplication.d.ts +1 -0
  2. package/es/collection-field-interface/CollectionFieldInterface.d.ts +1 -0
  3. package/es/collection-field-interface/CollectionFieldInterfaceManager.d.ts +1 -0
  4. package/es/flow/components/FieldAssignExactDatePicker.d.ts +1 -0
  5. package/es/flow/components/FieldAssignValueInput.d.ts +7 -0
  6. package/es/flow/components/RunJSValueEditor.d.ts +1 -0
  7. package/es/flow/components/filter/FilterGroup.d.ts +1 -0
  8. package/es/flow/components/filter/VariableFilterItem.d.ts +1 -0
  9. package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +17 -2
  10. package/es/index.mjs +89 -89
  11. package/lib/index.js +97 -97
  12. package/package.json +8 -7
  13. package/src/BaseApplication.tsx +11 -6
  14. package/src/__tests__/app.test.tsx +55 -0
  15. package/src/collection-field-interface/CollectionFieldInterface.ts +1 -0
  16. package/src/collection-field-interface/CollectionFieldInterfaceManager.ts +1 -0
  17. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +145 -2
  18. package/src/components/form/ScanInput/useCodeScanner.ts +154 -2
  19. package/src/components/form/TypedVariableInput.tsx +12 -8
  20. package/src/components/form/__tests__/TypedVariableInput.test.tsx +44 -3
  21. package/src/flow/FlowPage.tsx +9 -1
  22. package/src/flow/__tests__/FlowPage.test.tsx +50 -3
  23. package/src/flow/__tests__/FlowRoute.test.tsx +2 -2
  24. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +7 -3
  25. package/src/flow/actions/linkageRulesRefresh.tsx +2 -6
  26. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +0 -1
  27. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +253 -6
  28. package/src/flow/components/FieldAssignExactDatePicker.tsx +25 -11
  29. package/src/flow/components/FieldAssignValueInput.tsx +81 -15
  30. package/src/flow/components/FlowRoute.tsx +7 -3
  31. package/src/flow/components/RunJSValueEditor.tsx +9 -1
  32. package/src/flow/components/__tests__/FieldAssignValueInput.context.test.tsx +216 -0
  33. package/src/flow/components/filter/FilterGroup.tsx +33 -5
  34. package/src/flow/components/filter/VariableFilterItem.tsx +139 -6
  35. package/src/flow/components/filter/__tests__/FilterGroup.test.tsx +45 -0
  36. package/src/flow/components/filter/__tests__/VariableFilterItem.leftMetaTree.test.tsx +9 -0
  37. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +21 -0
  38. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +1 -0
  39. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +27 -12
  40. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +36 -0
  41. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +34 -0
  42. package/src/flow/models/blocks/form/QuickEditFormModel.tsx +189 -36
  43. package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +350 -2
  44. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +2 -1
  45. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +1 -0
  46. package/src/flow/models/fields/VariableFieldFormModel.tsx +3 -3
  47. package/src/flow/models/fields/__tests__/VariableFieldFormModel.test.tsx +51 -0
  48. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +20 -10
  49. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +24 -12
@@ -60,7 +60,9 @@ type FlowPageProps = {
60
60
  type FlowPageViewContext = FlowEngineContext & {
61
61
  view?: {
62
62
  inputArgs?: {
63
+ filterByTk?: unknown;
63
64
  isMobileLayout?: unknown;
65
+ sourceId?: unknown;
64
66
  };
65
67
  };
66
68
  };
@@ -102,6 +104,10 @@ export const FlowPage = React.memo((props: FlowPageProps & Record<string, unknow
102
104
  subType: 'object',
103
105
  use: pageModelClass,
104
106
  };
107
+ const isRuntimeRecordScopedPage =
108
+ !flowEngine.context.flowSettingsEnabled &&
109
+ (typeof ctx?.view?.inputArgs?.filterByTk !== 'undefined' ||
110
+ typeof ctx?.view?.inputArgs?.sourceId !== 'undefined');
105
111
  if (shouldInjectDefaultChildTab) {
106
112
  const tabTitle = defaultTabTitle || flowEngine.translate?.('Details');
107
113
  options['subModels'] = {
@@ -128,7 +134,9 @@ export const FlowPage = React.memo((props: FlowPageProps & Record<string, unknow
128
134
  },
129
135
  };
130
136
  }
131
- const data = await flowEngine.loadOrCreateModel(options, { skipSave: !flowEngine.context.flowSettingsEnabled });
137
+ const data =
138
+ (isRuntimeRecordScopedPage && (await flowEngine.loadModel({ ...options, refresh: true }))) ||
139
+ (await flowEngine.loadOrCreateModel(options, { skipSave: !flowEngine.context.flowSettingsEnabled }));
132
140
  if (data?.uid && onModelLoaded) {
133
141
  data.context.addDelegate(ctx);
134
142
  bindViewLayoutState(data, ctx);
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { render, waitFor } from '@testing-library/react';
11
11
  import React from 'react';
12
- import { afterEach, describe, expect, it, vi } from 'vitest';
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
13
13
  import { FlowPage } from '../FlowPage';
14
14
 
15
15
  type TestContextProperty = {
@@ -71,10 +71,15 @@ const createPageModel = (): TestPageModel => {
71
71
  };
72
72
 
73
73
  const mocks = vi.hoisted(() => ({
74
+ flowSettingsEnabled: false,
75
+ loadModel: vi.fn(),
76
+ loadOrCreateModel: vi.fn(),
74
77
  rendererMobileStates: [] as boolean[],
75
78
  rendererProps: undefined as RendererProps | undefined,
76
79
  model: undefined as TestPageModel | undefined,
80
+ viewFilterByTk: undefined as unknown,
77
81
  viewInputMobileLayout: true as boolean | undefined,
82
+ viewSourceId: undefined as unknown,
78
83
  contextMobileLayout: undefined as boolean | undefined,
79
84
  }));
80
85
 
@@ -94,15 +99,20 @@ vi.mock('@nocobase/flow-engine', async () => {
94
99
  }),
95
100
  useFlowEngine: vi.fn(() => ({
96
101
  getModelClassAsync: vi.fn(async () => TestPageModelClass),
97
- loadOrCreateModel: vi.fn(async () => mocks.model),
98
- context: {},
102
+ loadModel: mocks.loadModel,
103
+ loadOrCreateModel: mocks.loadOrCreateModel,
104
+ context: {
105
+ flowSettingsEnabled: mocks.flowSettingsEnabled,
106
+ },
99
107
  translate: vi.fn((value: string) => value),
100
108
  })),
101
109
  useFlowModelById: vi.fn(() => mocks.model),
102
110
  useFlowViewContext: vi.fn(() => ({
103
111
  view: {
104
112
  inputArgs: {
113
+ ...(typeof mocks.viewFilterByTk !== 'undefined' ? { filterByTk: mocks.viewFilterByTk } : {}),
105
114
  ...(typeof mocks.viewInputMobileLayout === 'boolean' ? { isMobileLayout: mocks.viewInputMobileLayout } : {}),
115
+ ...(typeof mocks.viewSourceId !== 'undefined' ? { sourceId: mocks.viewSourceId } : {}),
106
116
  },
107
117
  },
108
118
  ...(typeof mocks.contextMobileLayout === 'boolean' ? { isMobileLayout: mocks.contextMobileLayout } : {}),
@@ -154,11 +164,21 @@ vi.mock('ahooks', async () => {
154
164
  });
155
165
 
156
166
  describe('FlowPage', () => {
167
+ beforeEach(() => {
168
+ mocks.loadModel.mockImplementation(async () => null);
169
+ mocks.loadOrCreateModel.mockImplementation(async () => mocks.model);
170
+ });
171
+
157
172
  afterEach(() => {
173
+ mocks.flowSettingsEnabled = false;
174
+ mocks.loadModel.mockReset();
175
+ mocks.loadOrCreateModel.mockReset();
158
176
  mocks.rendererMobileStates = [];
159
177
  mocks.rendererProps = undefined;
160
178
  mocks.model = undefined;
179
+ mocks.viewFilterByTk = undefined;
161
180
  mocks.viewInputMobileLayout = true;
181
+ mocks.viewSourceId = undefined;
162
182
  mocks.contextMobileLayout = undefined;
163
183
  });
164
184
 
@@ -203,4 +223,31 @@ describe('FlowPage', () => {
203
223
  expect(mocks.rendererMobileStates[0]).toBe(expected);
204
224
  },
205
225
  );
226
+
227
+ it('reloads runtime page models for record scoped views', async () => {
228
+ mocks.model = createPageModel();
229
+ mocks.viewFilterByTk = 1;
230
+ mocks.loadModel.mockImplementation(async () => mocks.model);
231
+
232
+ render(<FlowPage onModelLoaded={vi.fn()} />);
233
+
234
+ await waitFor(() => {
235
+ expect(mocks.loadModel).toHaveBeenCalled();
236
+ });
237
+ expect(mocks.loadModel.mock.calls[0]?.[0]).toMatchObject({ refresh: true });
238
+ expect(mocks.loadOrCreateModel).not.toHaveBeenCalled();
239
+ });
240
+
241
+ it('keeps configured page model cache behavior in flow settings mode', async () => {
242
+ mocks.flowSettingsEnabled = true;
243
+ mocks.model = createPageModel();
244
+ mocks.viewFilterByTk = 1;
245
+
246
+ render(<FlowPage onModelLoaded={vi.fn()} />);
247
+
248
+ await waitFor(() => {
249
+ expect(mocks.loadOrCreateModel).toHaveBeenCalled();
250
+ });
251
+ expect(mocks.loadModel).not.toHaveBeenCalled();
252
+ });
206
253
  });
@@ -1666,7 +1666,7 @@ describe('FlowRoute', () => {
1666
1666
  expect(engine.getModel('public-form-1')).toBeUndefined();
1667
1667
  });
1668
1668
 
1669
- it('should not skip accessible route loading just because layout authCheck is false', async () => {
1669
+ it('should skip accessible route loading when layout authCheck is false', async () => {
1670
1670
  const engine = new FlowEngine();
1671
1671
  const ensureAccessibleLoaded = vi.fn().mockRejectedValue(new Error('cannot load accessible routes'));
1672
1672
  const getRouteBySchemaUid = vi.fn();
@@ -1735,7 +1735,7 @@ describe('FlowRoute', () => {
1735
1735
  await waitFor(() => {
1736
1736
  expect(layoutModel.registerRoutePage).toHaveBeenCalledWith('public-form-1', expect.any(Object));
1737
1737
  });
1738
- expect(ensureAccessibleLoaded).toHaveBeenCalledTimes(1);
1738
+ expect(ensureAccessibleLoaded).not.toHaveBeenCalled();
1739
1739
  expect(getRouteBySchemaUid).not.toHaveBeenCalled();
1740
1740
  });
1741
1741
 
@@ -135,11 +135,15 @@ describe('linkageRulesRefresh action', () => {
135
135
  expect(handler).toHaveBeenCalledWith(ctx, { value: ['master-mounted'] });
136
136
  });
137
137
 
138
- it('runs linkage action on master model when forks exist in design mode', async () => {
138
+ it('skips master model when forks can handle flow in design mode', async () => {
139
139
  const handler = vi.fn(async () => {});
140
140
  const model: any = {
141
141
  isFork: false,
142
- forks: new Set([{}]),
142
+ forks: new Set([
143
+ {
144
+ getFlow: vi.fn(() => ({})),
145
+ },
146
+ ]),
143
147
  getFlow: vi.fn(() => ({})),
144
148
  getStepParams: vi.fn(() => ({ value: ['master'] })),
145
149
  context: {
@@ -157,7 +161,7 @@ describe('linkageRulesRefresh action', () => {
157
161
  flowKey: 'buttonSettings',
158
162
  });
159
163
 
160
- expect(handler).toHaveBeenCalledWith(ctx, { value: ['master'] });
164
+ expect(handler).not.toHaveBeenCalled();
161
165
  });
162
166
 
163
167
  it('runs linkage action on fork model and resolves params', async () => {
@@ -35,12 +35,8 @@ export const linkageRulesRefresh = defineAction({
35
35
  // Prefer running on the current model; fallback to blockModel when the current model doesn't own the flow.
36
36
  if (!hasFlow) return;
37
37
 
38
- // In runtime, only skip master when there are mounted forks that can handle the same flow.
38
+ // Skip master when unmounted forks can handle the same flow.
39
39
  // Otherwise master is likely the rendered model and still needs refresh.
40
- // In design mode, always refresh master so the currently edited model state stays in sync.
41
- const flowSettingsEnabled = Boolean(
42
- (ctx as any)?.flowSettingsEnabled || (model as any)?.context?.flowSettingsEnabled,
43
- );
44
40
  const hasForkWithFlow =
45
41
  !model?.isFork &&
46
42
  !!model?.forks?.size &&
@@ -49,7 +45,7 @@ export const linkageRulesRefresh = defineAction({
49
45
  return !!fork?.getFlow?.(flowKey);
50
46
  });
51
47
  const isMasterMounted = Boolean((model as any)?.context?.ref?.current);
52
- if (hasForkWithFlow && !isMasterMounted && !flowSettingsEnabled) {
48
+ if (hasForkWithFlow && !isMasterMounted) {
53
49
  return;
54
50
  }
55
51
 
@@ -712,7 +712,6 @@ export const AdminLayoutComponent = observer((props: any) => {
712
712
  <MobileMenuControlContext.Provider value={{ closeMobileMenu }}>
713
713
  <DndProvider collisionDetection={menuCollisionDetection} onDragEnd={handleMenuDragEnd}>
714
714
  <ProLayout
715
- key={`admin-layout-menu-${menuRouteRefreshVersion}`}
716
715
  {...props}
717
716
  contentStyle={contentStyle}
718
717
  siderWidth={customToken.siderWidth || 200}
@@ -11,23 +11,58 @@ import { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine';
11
11
  import { act, render, screen, waitFor } from '@testing-library/react';
12
12
  import React, { useEffect } from 'react';
13
13
  import { createMemoryRouter, RouterProvider, useParams } from 'react-router-dom';
14
- import { describe, expect, it, vi } from 'vitest';
14
+ import { afterEach, describe, expect, it, vi } from 'vitest';
15
+ import { NocoBaseDesktopRouteType, type NocoBaseDesktopRoute } from '../../../../flow-compat';
15
16
  import type { LayoutDefinition } from '../../../../layout-manager/types';
16
17
  import { AdminLayoutComponent } from '../AdminLayoutComponent';
17
18
  import { AdminLayoutModel } from '../AdminLayoutModel';
19
+ import { AdminLayoutMenuItemModel } from '../AdminLayoutMenuModels';
18
20
  import { AdminLayoutContent } from '../AdminLayoutSlotModels';
19
21
 
22
+ const proLayoutLifecycle = vi.hoisted(() => ({
23
+ events: [] as string[],
24
+ routes: [] as Array<{ children?: Array<{ name?: React.ReactNode }> }>,
25
+ }));
26
+
20
27
  vi.mock('@ant-design/pro-layout', async () => {
21
28
  const ReactModule = await import('react');
22
29
  const RouteContext = ReactModule.createContext({ isMobile: false });
30
+ const ProLayoutMock = (props: {
31
+ children?: React.ReactNode;
32
+ route?: { children?: Array<{ name?: React.ReactNode }> };
33
+ }) => {
34
+ ReactModule.useEffect(() => {
35
+ proLayoutLifecycle.events.push('pro-layout:mount');
36
+ return () => {
37
+ proLayoutLifecycle.events.push('pro-layout:unmount');
38
+ };
39
+ }, []);
40
+ proLayoutLifecycle.routes.push(props.route || {});
23
41
 
24
- return {
25
- default: (props: { children?: React.ReactNode }) =>
42
+ return ReactModule.createElement(
43
+ RouteContext.Provider,
44
+ { value: { isMobile: false } },
26
45
  ReactModule.createElement(
27
- RouteContext.Provider,
28
- { value: { isMobile: false } },
29
- ReactModule.createElement('div', { 'data-testid': 'pro-layout' }, props.children),
46
+ 'div',
47
+ { 'data-testid': 'pro-layout' },
48
+ ReactModule.createElement(
49
+ 'nav',
50
+ { 'data-testid': 'pro-layout-menu' },
51
+ (props.route?.children || []).map((item) =>
52
+ ReactModule.createElement(
53
+ 'span',
54
+ { key: String(item.name), 'data-testid': 'pro-layout-menu-item' },
55
+ item.name,
56
+ ),
57
+ ),
58
+ ),
59
+ props.children,
30
60
  ),
61
+ );
62
+ };
63
+
64
+ return {
65
+ default: ProLayoutMock,
31
66
  RouteContext,
32
67
  };
33
68
  });
@@ -53,6 +88,10 @@ vi.mock('../AppListRender', () => ({
53
88
  useAppListRender: () => undefined,
54
89
  }));
55
90
 
91
+ afterEach(() => {
92
+ delete (window as Window & { __nocobase_modern_client_prefix__?: string }).__nocobase_modern_client_prefix__;
93
+ });
94
+
56
95
  describe('AdminLayoutComponent', () => {
57
96
  it('keeps custom admin layout pages alive by layout route name', async () => {
58
97
  const layout: LayoutDefinition = {
@@ -185,4 +224,212 @@ describe('AdminLayoutComponent', () => {
185
224
 
186
225
  expect(deactivateLayout).toHaveBeenCalledTimes(1);
187
226
  });
227
+
228
+ it('keeps layout content mounted when the menu route tree refreshes', async () => {
229
+ proLayoutLifecycle.events = [];
230
+ proLayoutLifecycle.routes = [];
231
+ const layout: LayoutDefinition = {
232
+ routeName: 'admin',
233
+ routePath: '/admin',
234
+ rootRouteName: 'admin',
235
+ uid: 'admin-layout-model',
236
+ layoutModelClass: 'AdminLayoutModel',
237
+ rootPageModelClass: 'RootPageModel',
238
+ childPageModelClass: 'ChildPageModel',
239
+ authCheck: true,
240
+ };
241
+ const events: string[] = [];
242
+ const engine = new FlowEngine();
243
+ engine.context.defineProperty('routeRepository', {
244
+ value: {
245
+ activateLayout: vi.fn(() => vi.fn()),
246
+ ensureAccessibleLoaded: vi.fn(async () => []),
247
+ listAccessible: () => [],
248
+ subscribe: vi.fn(),
249
+ unsubscribe: vi.fn(),
250
+ },
251
+ });
252
+ engine.context.defineProperty('t', {
253
+ value: (key: string) => key,
254
+ });
255
+ const model = engine.createModel<AdminLayoutModel>({
256
+ uid: layout.uid,
257
+ use: AdminLayoutModel,
258
+ props: {
259
+ layout,
260
+ },
261
+ });
262
+
263
+ const Page = () => {
264
+ useEffect(() => {
265
+ events.push('page:mount');
266
+ return () => {
267
+ events.push('page:unmount');
268
+ };
269
+ }, []);
270
+
271
+ return <div data-testid="custom-admin-route">Workflow tasks</div>;
272
+ };
273
+
274
+ const router = createMemoryRouter(
275
+ [
276
+ {
277
+ id: layout.routeName,
278
+ path: layout.routePath,
279
+ element: <AdminLayoutComponent model={model} />,
280
+ children: [
281
+ {
282
+ id: 'admin.workflow.tasks',
283
+ path: 'workflow/tasks/:taskType?/:status?/:popupId?',
284
+ element: <Page />,
285
+ },
286
+ ],
287
+ },
288
+ ],
289
+ {
290
+ initialEntries: ['/admin/workflow/tasks/approval-apply/pending'],
291
+ },
292
+ );
293
+
294
+ render(
295
+ <FlowEngineProvider engine={engine}>
296
+ <RouterProvider router={router} />
297
+ </FlowEngineProvider>,
298
+ );
299
+
300
+ expect(await screen.findByTestId('custom-admin-route')).toBeInTheDocument();
301
+ expect(proLayoutLifecycle.events).toEqual(['pro-layout:mount']);
302
+
303
+ act(() => {
304
+ model.refreshMenuRouteTree();
305
+ });
306
+
307
+ await act(async () => {
308
+ await router.navigate('/admin/workflow/tasks/approval-apply/pending/1');
309
+ });
310
+
311
+ expect(screen.getByTestId('custom-admin-route')).toBeInTheDocument();
312
+ expect(proLayoutLifecycle.events).toEqual(['pro-layout:mount']);
313
+ expect(events).toEqual(['page:mount']);
314
+ });
315
+
316
+ it('updates ProLayout menu routes after accessible routes change without remounting the layout', async () => {
317
+ proLayoutLifecycle.events = [];
318
+ proLayoutLifecycle.routes = [];
319
+ (window as Window & { __nocobase_modern_client_prefix__?: string }).__nocobase_modern_client_prefix__ = 'v2';
320
+ const layout: LayoutDefinition = {
321
+ routeName: 'admin',
322
+ routePath: '/admin',
323
+ rootRouteName: 'admin',
324
+ uid: 'admin-layout-model',
325
+ layoutModelClass: 'AdminLayoutModel',
326
+ rootPageModelClass: 'RootPageModel',
327
+ childPageModelClass: 'ChildPageModel',
328
+ authCheck: true,
329
+ };
330
+ let accessibleRoutes: NocoBaseDesktopRoute[] = [
331
+ {
332
+ id: 1,
333
+ title: 'Visible page',
334
+ schemaUid: 'visible-page',
335
+ type: NocoBaseDesktopRouteType.flowPage,
336
+ },
337
+ ];
338
+ let routeRepositorySubscriber: (() => void) | undefined;
339
+ const events: string[] = [];
340
+ const engine = new FlowEngine();
341
+ engine.registerModels({
342
+ AdminLayoutMenuItemModel,
343
+ });
344
+ engine.context.defineProperty('routeRepository', {
345
+ value: {
346
+ activateLayout: vi.fn(() => vi.fn()),
347
+ ensureAccessibleLoaded: vi.fn(async () => accessibleRoutes),
348
+ listAccessible: () => accessibleRoutes,
349
+ subscribe: vi.fn((subscriber: () => void) => {
350
+ routeRepositorySubscriber = subscriber;
351
+ }),
352
+ unsubscribe: vi.fn(),
353
+ },
354
+ });
355
+ engine.context.defineProperty('app', {
356
+ value: {
357
+ getPublicPath: () => '/v/',
358
+ router: {
359
+ getBasename: () => '/v',
360
+ },
361
+ },
362
+ });
363
+ engine.context.defineProperty('t', {
364
+ value: (key: string) => key,
365
+ });
366
+ const model = engine.createModel<AdminLayoutModel>({
367
+ uid: layout.uid,
368
+ use: AdminLayoutModel,
369
+ props: {
370
+ layout,
371
+ },
372
+ });
373
+
374
+ const Page = () => {
375
+ useEffect(() => {
376
+ events.push('page:mount');
377
+ return () => {
378
+ events.push('page:unmount');
379
+ };
380
+ }, []);
381
+
382
+ return <div data-testid="custom-admin-route">Workflow tasks</div>;
383
+ };
384
+
385
+ const router = createMemoryRouter(
386
+ [
387
+ {
388
+ id: layout.routeName,
389
+ path: layout.routePath,
390
+ element: <AdminLayoutComponent model={model} />,
391
+ children: [
392
+ {
393
+ id: 'admin.workflow.tasks',
394
+ path: 'workflow/tasks/:taskType?/:status?/:popupId?',
395
+ element: <Page />,
396
+ },
397
+ ],
398
+ },
399
+ ],
400
+ {
401
+ initialEntries: ['/admin/workflow/tasks/approval-apply/pending'],
402
+ },
403
+ );
404
+
405
+ render(
406
+ <FlowEngineProvider engine={engine}>
407
+ <RouterProvider router={router} />
408
+ </FlowEngineProvider>,
409
+ );
410
+
411
+ expect(await screen.findByText('Visible page')).toBeInTheDocument();
412
+ expect(proLayoutLifecycle.events).toEqual(['pro-layout:mount']);
413
+
414
+ act(() => {
415
+ accessibleRoutes = [];
416
+ routeRepositorySubscriber?.();
417
+ });
418
+
419
+ await waitFor(() => {
420
+ expect(screen.queryByText('Visible page')).not.toBeInTheDocument();
421
+ });
422
+
423
+ await act(async () => {
424
+ await router.navigate('/admin/workflow/tasks/approval-apply/pending/1');
425
+ });
426
+
427
+ expect(screen.getByTestId('custom-admin-route')).toBeInTheDocument();
428
+ expect(proLayoutLifecycle.events).toEqual(['pro-layout:mount']);
429
+ expect(events).toEqual(['page:mount']);
430
+ expect(
431
+ proLayoutLifecycle.routes.some((route) => route.children?.some((item) => item.name === 'Visible page')),
432
+ ).toBe(true);
433
+ expect(proLayoutLifecycle.routes.at(-1)?.children?.some((item) => item.name === 'Visible page')).toBe(false);
434
+ });
188
435
  });
@@ -7,7 +7,7 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import React, { useMemo, useState } from 'react';
10
+ import React, { useCallback, useMemo, useState } from 'react';
11
11
  import { DatePicker, Select, Space } from 'antd';
12
12
  import { inferPickerType } from '../../flow-compat';
13
13
  import { dayjs, getDateTimeFormat, getPickerFormat } from '@nocobase/utils/client';
@@ -29,6 +29,7 @@ export interface FieldAssignExactDatePickerProps {
29
29
  isRange?: boolean;
30
30
  onChange?: (value: ExactDatePickerValue) => void;
31
31
  style?: React.CSSProperties;
32
+ disabled?: boolean;
32
33
  }
33
34
 
34
35
  function getRawSingleValue(value: unknown, isRange: boolean): unknown {
@@ -114,7 +115,7 @@ function parseRangeValue(value: unknown, format: string): [dayjs.Dayjs, dayjs.Da
114
115
  }
115
116
 
116
117
  export const FieldAssignExactDatePicker: React.FC<FieldAssignExactDatePickerProps> = (props) => {
117
- const { picker = 'date', format, showTime, timeFormat, value, isRange = false, onChange, style } = props;
118
+ const { picker = 'date', format, showTime, timeFormat, value, isRange = false, onChange, style, disabled } = props;
118
119
  const flowCtx = useFlowContext();
119
120
  const t = flowCtx.model.translate.bind(flowCtx.model);
120
121
 
@@ -124,17 +125,17 @@ export const FieldAssignExactDatePicker: React.FC<FieldAssignExactDatePickerProp
124
125
  return inferPickerFromRawValue(rawSingleValue, picker);
125
126
  });
126
127
 
127
- const getResolvedFormat = (nextPicker: ExactDatePickerMode) => {
128
- const baseFormat = nextPicker === picker && format ? format : getPickerFormat(nextPicker);
129
- const dateFormat = nextPicker === 'date' ? stripTimeFromFormat(baseFormat) : baseFormat;
130
- return getDateTimeFormat(nextPicker, dateFormat, showTime, timeFormat);
131
- };
132
-
133
- const resolvedFormat = useMemo(
134
- () => getResolvedFormat(targetPicker),
135
- [targetPicker, format, picker, showTime, timeFormat],
128
+ const getResolvedFormat = useCallback(
129
+ (nextPicker: ExactDatePickerMode) => {
130
+ const baseFormat = nextPicker === picker && format ? format : getPickerFormat(nextPicker);
131
+ const dateFormat = nextPicker === 'date' ? stripTimeFromFormat(baseFormat) : baseFormat;
132
+ return getDateTimeFormat(nextPicker, dateFormat, showTime, timeFormat);
133
+ },
134
+ [format, picker, showTime, timeFormat],
136
135
  );
137
136
 
137
+ const resolvedFormat = useMemo(() => getResolvedFormat(targetPicker), [getResolvedFormat, targetPicker]);
138
+
138
139
  const singleValue = useMemo(() => {
139
140
  if (isRange) return null;
140
141
  return parseDateFromRawValue(value, resolvedFormat);
@@ -156,6 +157,10 @@ export const FieldAssignExactDatePicker: React.FC<FieldAssignExactDatePickerProp
156
157
  );
157
158
 
158
159
  const handlePickerTypeChange = (nextPicker: ExactDatePickerMode) => {
160
+ if (disabled) {
161
+ return;
162
+ }
163
+
159
164
  setTargetPicker(nextPicker);
160
165
 
161
166
  if (!onChange) {
@@ -182,7 +187,11 @@ export const FieldAssignExactDatePicker: React.FC<FieldAssignExactDatePickerProp
182
187
  inputReadOnly: flowCtx.isMobileLayout,
183
188
  showTime: showTime ? { defaultValue: dayjs('00:00:00', 'HH:mm:ss') } : false,
184
189
  value: singleValue,
190
+ disabled,
185
191
  onChange: (nextDate: dayjs.Dayjs | null) => {
192
+ if (disabled) {
193
+ return;
194
+ }
186
195
  if (nextDate && dayjs.isDayjs(nextDate)) {
187
196
  onChange?.(nextDate);
188
197
  return;
@@ -200,7 +209,11 @@ export const FieldAssignExactDatePicker: React.FC<FieldAssignExactDatePickerProp
200
209
  inputReadOnly: flowCtx.isMobileLayout,
201
210
  showTime: showTime ? { defaultValue: [dayjs('00:00:00', 'HH:mm:ss'), dayjs('23:59:59', 'HH:mm:ss')] } : false,
202
211
  value: rangeValue,
212
+ disabled,
203
213
  onChange: (nextDates: [dayjs.Dayjs | null, dayjs.Dayjs | null] | null) => {
214
+ if (disabled) {
215
+ return;
216
+ }
204
217
  if (Array.isArray(nextDates) && nextDates[0] && nextDates[1]) {
205
218
  onChange?.([nextDates[0], nextDates[1]]);
206
219
  return;
@@ -218,6 +231,7 @@ export const FieldAssignExactDatePicker: React.FC<FieldAssignExactDatePickerProp
218
231
  value={targetPicker}
219
232
  options={pickerOptions}
220
233
  onChange={handlePickerTypeChange}
234
+ disabled={disabled}
221
235
  />
222
236
  {isRange ? <DatePicker.RangePicker {...rangePickerProps} /> : <DatePicker {...singlePickerProps} />}
223
237
  </Space.Compact>