@nocobase/client-v2 2.2.0-beta.7 → 2.2.0-beta.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.
Files changed (113) hide show
  1. package/es/BaseApplication.d.ts +2 -0
  2. package/es/RouteRepository.d.ts +8 -0
  3. package/es/RouterManager.d.ts +15 -0
  4. package/es/authRedirect.d.ts +1 -0
  5. package/es/entry-actions/EntryActionManager.d.ts +24 -0
  6. package/es/entry-actions/index.d.ts +9 -0
  7. package/es/flow/actions/afterSuccess.d.ts +8 -1
  8. package/es/flow/actions/index.d.ts +1 -1
  9. package/es/flow/admin-shell/BaseLayoutModel.d.ts +6 -0
  10. package/es/flow/admin-shell/BaseLayoutRouteCoordinator.d.ts +7 -0
  11. package/es/flow/admin-shell/admin-layout/AdminLayoutSlotModels.d.ts +1 -0
  12. package/es/flow/admin-shell/admin-layout/AppListRender.d.ts +2 -1
  13. package/es/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.d.ts +24 -0
  14. package/es/flow/admin-shell/admin-layout/index.d.ts +1 -0
  15. package/es/flow/admin-shell/admin-layout/useApplications.d.ts +7 -2
  16. package/es/flow/models/base/ActionModelCore.d.ts +2 -0
  17. package/es/flow/models/blocks/form/submitHandler.d.ts +1 -1
  18. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +9 -0
  19. package/es/flow/models/blocks/js-block/JSBlock.d.ts +1 -0
  20. package/es/flow/models/blocks/table/TableActionsColumnModel.d.ts +1 -0
  21. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  22. package/es/flow/models/blocks/table/dragSort/dragSortHooks.d.ts +1 -1
  23. package/es/flow/models/blocks/table/dragSort/dragSortSettings.d.ts +2 -1
  24. package/es/flow/models/blocks/table/dragSort/dragSortUtils.d.ts +6 -4
  25. package/es/flow/resolveViewParamsToViewList.d.ts +1 -1
  26. package/es/flow/routeTransientInputArgs.d.ts +14 -0
  27. package/es/index.d.ts +2 -0
  28. package/es/index.mjs +243 -141
  29. package/es/utils/markdownSanitize.d.ts +11 -0
  30. package/lib/index.js +232 -130
  31. package/package.json +7 -7
  32. package/src/BaseApplication.tsx +2 -0
  33. package/src/RouteRepository.ts +25 -0
  34. package/src/RouterManager.tsx +142 -1
  35. package/src/__tests__/RouteRepository.test.ts +23 -0
  36. package/src/__tests__/RouterManager.test.ts +125 -0
  37. package/src/__tests__/authRedirect.test.ts +17 -0
  38. package/src/authRedirect.ts +38 -0
  39. package/src/collection-manager/field-configure.ts +1 -1
  40. package/src/collection-manager/interfaces/id.ts +1 -1
  41. package/src/collection-manager/interfaces/m2m.tsx +2 -2
  42. package/src/collection-manager/interfaces/m2o.tsx +2 -2
  43. package/src/collection-manager/interfaces/nanoid.ts +1 -1
  44. package/src/collection-manager/interfaces/o2m.tsx +2 -2
  45. package/src/collection-manager/interfaces/obo.tsx +2 -2
  46. package/src/collection-manager/interfaces/oho.tsx +2 -2
  47. package/src/collection-manager/interfaces/properties/index.ts +2 -2
  48. package/src/collection-manager/interfaces/uuid.ts +1 -1
  49. package/src/entry-actions/EntryActionManager.ts +76 -0
  50. package/src/entry-actions/index.ts +10 -0
  51. package/src/flow/FlowPage.tsx +29 -2
  52. package/src/flow/__tests__/FlowPage.test.tsx +152 -25
  53. package/src/flow/__tests__/FlowRoute.test.tsx +547 -2
  54. package/src/flow/__tests__/getKey.test.ts +7 -0
  55. package/src/flow/__tests__/resolveViewParamsToViewList.test.ts +34 -0
  56. package/src/flow/actions/__tests__/afterSuccess.test.ts +73 -0
  57. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +62 -0
  58. package/src/flow/actions/__tests__/openView.defineProps.route.test.tsx +164 -0
  59. package/src/flow/actions/afterSuccess.tsx +142 -3
  60. package/src/flow/actions/dataScopeFilter.ts +10 -1
  61. package/src/flow/actions/dateTimeFormat.tsx +2 -2
  62. package/src/flow/actions/index.ts +1 -1
  63. package/src/flow/actions/openView.tsx +59 -5
  64. package/src/flow/admin-shell/BaseLayoutModel.tsx +149 -3
  65. package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +187 -42
  66. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +1020 -110
  67. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +42 -4
  68. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +177 -1
  69. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +20 -0
  70. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +5 -4
  71. package/src/flow/admin-shell/admin-layout/AppListRender.tsx +13 -2
  72. package/src/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.tsx +289 -0
  73. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +518 -2
  74. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +48 -0
  75. package/src/flow/admin-shell/admin-layout/index.ts +1 -0
  76. package/src/flow/admin-shell/admin-layout/useApplications.tsx +81 -12
  77. package/src/flow/common/Markdown/Display.tsx +14 -3
  78. package/src/flow/common/Markdown/Edit.tsx +19 -7
  79. package/src/flow/components/FlowRoute.tsx +128 -16
  80. package/src/flow/getViewDiffAndUpdateHidden.tsx +1 -0
  81. package/src/flow/internal/components/Markdown/util.ts +2 -1
  82. package/src/flow/models/base/ActionModel.tsx +10 -8
  83. package/src/flow/models/base/ActionModelCore.tsx +11 -4
  84. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +37 -0
  85. package/src/flow/models/blocks/filter-form/fields/FilterFormCustomFieldModel.tsx +1 -1
  86. package/src/flow/models/blocks/form/FormActionModel.tsx +1 -1
  87. package/src/flow/models/blocks/form/__tests__/submitHandler.test.ts +54 -1
  88. package/src/flow/models/blocks/form/submitHandler.ts +17 -3
  89. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +87 -0
  90. package/src/flow/models/blocks/form/value-runtime/runtime.ts +91 -0
  91. package/src/flow/models/blocks/js-block/JSBlock.tsx +223 -2
  92. package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +150 -0
  93. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +35 -12
  94. package/src/flow/models/blocks/table/TableBlockModel.tsx +25 -14
  95. package/src/flow/models/blocks/table/TableColumnModel.tsx +6 -2
  96. package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +57 -1
  97. package/src/flow/models/blocks/table/__tests__/TableBlockModel.dragSort.test.ts +127 -0
  98. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowSelection.test.tsx +1 -0
  99. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +51 -0
  100. package/src/flow/models/blocks/table/dragSort/dragSortHooks.tsx +28 -17
  101. package/src/flow/models/blocks/table/dragSort/dragSortSettings.ts +13 -6
  102. package/src/flow/models/blocks/table/dragSort/dragSortUtils.ts +15 -2
  103. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +45 -13
  104. package/src/flow/models/fields/TextareaFieldModel.tsx +42 -19
  105. package/src/flow/models/fields/__tests__/TextareaFieldModel.test.tsx +32 -1
  106. package/src/flow/models/topbar/TopbarActionModel.tsx +78 -3
  107. package/src/flow/resolveViewParamsToViewList.tsx +11 -3
  108. package/src/flow/routeTransientInputArgs.ts +27 -0
  109. package/src/flow/utils/__tests__/dateTimeFormat.test.ts +42 -0
  110. package/src/index.ts +2 -0
  111. package/src/nocobase-buildin-plugin/index.tsx +7 -1
  112. package/src/settings-center/SystemSettingsPage.tsx +1 -1
  113. package/src/utils/markdownSanitize.ts +88 -0
@@ -285,13 +285,44 @@ function SetIsMobileLayout(props: { isMobile: boolean; children: any; model?: Ad
285
285
  const flowEngine = useFlowEngine();
286
286
  const adminLayoutModel = props.model || flowEngine.getModel<AdminLayoutModel>(ADMIN_LAYOUT_MODEL_UID);
287
287
 
288
- useEffect(() => {
288
+ useLayoutEffect(() => {
289
289
  adminLayoutModel?.setIsMobileLayout(props.isMobile);
290
290
  }, [adminLayoutModel, props.isMobile]);
291
291
 
292
292
  return props.children;
293
293
  }
294
294
 
295
+ function AdminLayoutContentWithMobileState(props: {
296
+ isMobile: boolean;
297
+ model?: AdminLayoutModel;
298
+ designable?: boolean;
299
+ layout?: AdminLayoutModel['layout'];
300
+ onContentElementChange?: (element: HTMLDivElement | null) => void;
301
+ }) {
302
+ const modelRef = useRef(props.model);
303
+ const isMobileRef = useRef(props.isMobile);
304
+ const onContentElementChangeRef = useRef(props.onContentElementChange);
305
+
306
+ modelRef.current = props.model;
307
+ isMobileRef.current = props.isMobile;
308
+ onContentElementChangeRef.current = props.onContentElementChange;
309
+
310
+ const handleContentElementChange = useCallback((element: HTMLDivElement | null) => {
311
+ if (element) {
312
+ modelRef.current?.setIsMobileLayout(isMobileRef.current);
313
+ }
314
+ onContentElementChangeRef.current?.(element);
315
+ }, []);
316
+
317
+ return (
318
+ <AdminLayoutContent
319
+ designable={props.designable}
320
+ layout={props.layout}
321
+ onContentElementChange={handleContentElementChange}
322
+ />
323
+ );
324
+ }
325
+
295
326
  const DesignerButtonMenuItem: FC<{ item: AdminLayoutMenuNode; fallbackParentRoute?: NocoBaseDesktopRoute }> = (
296
327
  props,
297
328
  ) => {
@@ -410,8 +441,8 @@ export const AdminLayoutComponent = observer((props: any) => {
410
441
  );
411
442
  const designable = !isMobileSider && preferredFlowSettingsEnabled;
412
443
  const { styles } = useHeaderStyle();
413
- const { appList } = useApplications();
414
- const appListRender = useAppListRender();
444
+ const { appList, appSwitcherModel } = useApplications(adminLayoutModel);
445
+ const appListRender = useAppListRender(appSwitcherModel);
415
446
  const flowSettingsSyncRef = useRef(0);
416
447
  const desiredFlowSettingsEnabledRef = useRef(false);
417
448
  const handleLayoutContentElementChange = useCallback(
@@ -634,6 +665,10 @@ export const AdminLayoutComponent = observer((props: any) => {
634
665
  z-index: 2000 !important;
635
666
  }
636
667
 
668
+ .ant-pro-layout-apps-popover .ant-popover-content {
669
+ margin-top: 12px;
670
+ }
671
+
637
672
  .ant-pro-layout-apps-icon {
638
673
  color: ${customToken.colorTextHeaderMenu || 'rgba(255, 255, 255, 0.85)'};
639
674
  }
@@ -717,7 +752,10 @@ export const AdminLayoutComponent = observer((props: any) => {
717
752
  <SetIsMobileLayout isMobile={isMobile} model={adminLayoutModel}>
718
753
  <ConfigProvider theme={isMobile ? mobileTheme : theme}>
719
754
  <GlobalStyle />
720
- <AdminLayoutContent
755
+ <AdminLayoutContentWithMobileState
756
+ isMobile={isMobile}
757
+ model={adminLayoutModel}
758
+ designable={designable}
721
759
  layout={adminLayoutModel?.layout}
722
760
  onContentElementChange={handleLayoutContentElementChange}
723
761
  />
@@ -10,8 +10,9 @@
10
10
  import { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine';
11
11
  import { act, render, screen, waitFor } from '@testing-library/react';
12
12
  import React from 'react';
13
- import { createMemoryRouter, RouterProvider } from 'react-router-dom';
13
+ import { createMemoryRouter, Outlet, RouterProvider } from 'react-router-dom';
14
14
  import { describe, expect, it, vi } from 'vitest';
15
+ import { NocoBaseDesktopRouteType } from '../../../flow-compat';
15
16
  import { RouteRepository } from '../../../RouteRepository';
16
17
  import { AdminLayoutEntryGuard } from './AdminLayoutEntryGuard';
17
18
  import type { AdminLayoutModel } from './AdminLayoutModel';
@@ -307,4 +308,179 @@ describe('AdminLayoutEntryGuard', () => {
307
308
  expect(activateLayout).toHaveBeenCalledTimes(2);
308
309
  expect(activateLayout).toHaveBeenLastCalledWith(expect.objectContaining({ uid: 'admin-layout-next' }));
309
310
  });
311
+
312
+ it('should redirect group id paths to the first v2 landing route', async () => {
313
+ const modernWindow = window as Window & { __nocobase_modern_client_prefix__?: string };
314
+ modernWindow.__nocobase_modern_client_prefix__ = 'v2';
315
+ const engine = new FlowEngine();
316
+ const request = vi.fn().mockResolvedValue({
317
+ data: {
318
+ data: [],
319
+ },
320
+ });
321
+ const routeRepository = new RouteRepository({
322
+ api: {
323
+ request,
324
+ resource: vi.fn(),
325
+ },
326
+ } as never);
327
+ routeRepository.setRoutes([
328
+ {
329
+ id: 1,
330
+ title: 'Group',
331
+ type: NocoBaseDesktopRouteType.group,
332
+ children: [
333
+ {
334
+ id: 11,
335
+ title: 'Flow page',
336
+ schemaUid: 'flow-page-1',
337
+ type: NocoBaseDesktopRouteType.flowPage,
338
+ },
339
+ ],
340
+ },
341
+ ]);
342
+ engine.context.defineProperty('routeRepository', {
343
+ value: routeRepository,
344
+ });
345
+ engine.context.defineProperty('app', {
346
+ value: {
347
+ getPublicPath: () => '/apps/demo/v2/',
348
+ router: {
349
+ getBasename: () => '/apps/demo/v2',
350
+ },
351
+ },
352
+ });
353
+
354
+ const model = {
355
+ layout: {
356
+ routeName: 'admin',
357
+ routePath: '/admin',
358
+ uid: 'admin-layout-model',
359
+ },
360
+ } as AdminLayoutModel;
361
+ const router = createMemoryRouter(
362
+ [
363
+ {
364
+ path: '/admin',
365
+ id: 'admin',
366
+ element: (
367
+ <FlowEngineProvider engine={engine}>
368
+ <AdminLayoutEntryGuard model={model}>
369
+ <Outlet />
370
+ </AdminLayoutEntryGuard>
371
+ </FlowEngineProvider>
372
+ ),
373
+ children: [
374
+ {
375
+ path: ':name',
376
+ id: 'admin.__page',
377
+ element: <div>Admin page shell</div>,
378
+ },
379
+ ],
380
+ },
381
+ ],
382
+ {
383
+ initialEntries: ['/admin/1'],
384
+ },
385
+ );
386
+
387
+ try {
388
+ render(<RouterProvider router={router} />);
389
+
390
+ await waitFor(() => {
391
+ expect(router.state.location.pathname).toBe('/admin/flow-page-1');
392
+ });
393
+ expect(await screen.findByText('Admin page shell')).toBeInTheDocument();
394
+ } finally {
395
+ delete modernWindow.__nocobase_modern_client_prefix__;
396
+ }
397
+ });
398
+
399
+ it('should prefer schema uid routes before resolving numeric group ids', async () => {
400
+ const modernWindow = window as Window & { __nocobase_modern_client_prefix__?: string };
401
+ modernWindow.__nocobase_modern_client_prefix__ = 'v2';
402
+ const engine = new FlowEngine();
403
+ const routeRepository = {
404
+ activateLayout: vi.fn(() => vi.fn()),
405
+ ensureAccessibleLoaded: vi.fn().mockResolvedValue([]),
406
+ isAccessibleLoaded: vi.fn(() => true),
407
+ getRouteBySchemaUid: vi.fn((pageUid: string) =>
408
+ pageUid === '1'
409
+ ? {
410
+ schemaUid: '1',
411
+ type: NocoBaseDesktopRouteType.flowPage,
412
+ }
413
+ : undefined,
414
+ ),
415
+ getRouteById: vi.fn((routeId: string) =>
416
+ routeId === '1'
417
+ ? {
418
+ id: 1,
419
+ type: NocoBaseDesktopRouteType.group,
420
+ children: [
421
+ {
422
+ schemaUid: 'flow-page-1',
423
+ type: NocoBaseDesktopRouteType.flowPage,
424
+ },
425
+ ],
426
+ }
427
+ : undefined,
428
+ ),
429
+ listAccessible: vi.fn(() => []),
430
+ };
431
+ engine.context.defineProperty('routeRepository', {
432
+ value: routeRepository,
433
+ });
434
+ engine.context.defineProperty('app', {
435
+ value: {
436
+ getPublicPath: () => '/apps/demo/v2/',
437
+ router: {
438
+ getBasename: () => '/apps/demo/v2',
439
+ },
440
+ },
441
+ });
442
+
443
+ const model = {
444
+ layout: {
445
+ routeName: 'admin',
446
+ routePath: '/admin',
447
+ uid: 'admin-layout-model',
448
+ },
449
+ } as AdminLayoutModel;
450
+ const router = createMemoryRouter(
451
+ [
452
+ {
453
+ path: '/admin',
454
+ id: 'admin',
455
+ element: (
456
+ <FlowEngineProvider engine={engine}>
457
+ <AdminLayoutEntryGuard model={model}>
458
+ <Outlet />
459
+ </AdminLayoutEntryGuard>
460
+ </FlowEngineProvider>
461
+ ),
462
+ children: [
463
+ {
464
+ path: ':name',
465
+ id: 'admin.__page',
466
+ element: <div>Admin page shell</div>,
467
+ },
468
+ ],
469
+ },
470
+ ],
471
+ {
472
+ initialEntries: ['/admin/1'],
473
+ },
474
+ );
475
+
476
+ try {
477
+ render(<RouterProvider router={router} />);
478
+
479
+ await screen.findByText('Admin page shell');
480
+ expect(router.state.location.pathname).toBe('/admin/1');
481
+ expect(routeRepository.getRouteById).not.toHaveBeenCalled();
482
+ } finally {
483
+ delete modernWindow.__nocobase_modern_client_prefix__;
484
+ }
485
+ });
310
486
  });
@@ -132,6 +132,26 @@ export const AdminLayoutEntryGuard: FC<{ children: React.ReactNode; model?: Admi
132
132
  }
133
133
  }
134
134
 
135
+ const groupRoute = currentRoute ? undefined : routeRepository?.getRouteById?.(pageUid);
136
+ if (!currentRoute && groupRoute?.type === NocoBaseDesktopRouteType.group) {
137
+ const target = resolveAdminRouteRuntimeTarget({
138
+ app,
139
+ route: groupRoute,
140
+ layout: layoutRuntime,
141
+ });
142
+
143
+ if (target.runtimePath) {
144
+ replaceTriggeredRef.current = true;
145
+ if (target.navigationMode === 'document') {
146
+ window.location.replace(target.runtimePath);
147
+ return;
148
+ }
149
+
150
+ navigate(toRouterNavigationPath(target.runtimePath, app.router.getBasename()), { replace: true });
151
+ return;
152
+ }
153
+ }
154
+
135
155
  if (active) {
136
156
  setReady(true);
137
157
  }
@@ -26,6 +26,7 @@ import {
26
26
  type AdminLayoutContentProps = {
27
27
  onContentElementChange?: (element: HTMLDivElement | null) => void;
28
28
  layout?: (AdminLayoutRoutePathLike & { routeName?: string }) | null;
29
+ designable?: boolean;
29
30
  };
30
31
 
31
32
  const layoutContentClass = css`
@@ -70,7 +71,7 @@ function isDvhSupported() {
70
71
  return testEl.style.height === '1dvh';
71
72
  }
72
73
 
73
- const ShowTipWhenNoPages = observer((props: { layout?: AdminLayoutRoutePathLike | null }) => {
74
+ const ShowTipWhenNoPages = observer((props: { designable?: boolean; layout?: AdminLayoutRoutePathLike | null }) => {
74
75
  const flowEngine = useFlowEngine();
75
76
  const { token } = antdTheme.useToken();
76
77
  const { t } = useTranslation();
@@ -79,7 +80,7 @@ const ShowTipWhenNoPages = observer((props: { layout?: AdminLayoutRoutePathLike
79
80
  const visibleRoutes = isV2AdminRuntime(flowEngine.context.app)
80
81
  ? allAccessRoutes.filter((route) => isV2MenuRoute(route))
81
82
  : allAccessRoutes;
82
- const designable = !!flowEngine.context.flowSettingsEnabled;
83
+ const designable = !!props.designable || !!flowEngine.context.flowSettingsEnabled;
83
84
  const layoutRoutePath = getAdminLayoutRoutePath(props.layout);
84
85
 
85
86
  if (
@@ -104,7 +105,7 @@ const ShowTipWhenNoPages = observer((props: { layout?: AdminLayoutRoutePathLike
104
105
  *
105
106
  * 内容区不再依赖独立 FlowModel,而是通过回调把挂载目标同步给 root model。
106
107
  */
107
- export const AdminLayoutContent: FC<AdminLayoutContentProps> = ({ onContentElementChange, layout }) => {
108
+ export const AdminLayoutContent: FC<AdminLayoutContentProps> = ({ designable, onContentElementChange, layout }) => {
108
109
  const style = useMemo(() => (isDvhSupported() ? mobileHeight : undefined), []);
109
110
  const params = useParams();
110
111
  const matches = useMatches();
@@ -131,7 +132,7 @@ export const AdminLayoutContent: FC<AdminLayoutContentProps> = ({ onContentEleme
131
132
  >
132
133
  <div style={pageContentStyle}>
133
134
  {shouldKeepAlive && pageUid ? <KeepAlive uid={pageUid}>{() => <Outlet />}</KeepAlive> : <Outlet />}
134
- <ShowTipWhenNoPages layout={layout} />
135
+ <ShowTipWhenNoPages designable={designable} layout={layout} />
135
136
  </div>
136
137
  </div>
137
138
  );
@@ -10,17 +10,28 @@
10
10
  import type { AppListProps } from '@ant-design/pro-layout/es/components/AppsLogoComponents/types';
11
11
  import { css } from '@emotion/css';
12
12
  import { Card, Typography, theme } from 'antd';
13
+ import { observer } from '@nocobase/flow-engine';
13
14
  import React from 'react';
15
+ import type { AppSwitcherActionPanelModel } from './AppSwitcherActionPanelModel';
14
16
 
15
17
  function renderIcon(icon: React.ReactNode | (() => React.ReactNode)) {
16
18
  return typeof icon === 'function' ? icon() : icon;
17
19
  }
18
20
 
19
- export function useAppListRender() {
21
+ const AppSwitcherActionsContent = observer(
22
+ (props: { model: AppSwitcherActionPanelModel }) => <>{props.model.renderContent()}</>,
23
+ { displayName: 'AppSwitcherActionsContent' },
24
+ );
25
+
26
+ export function useAppListRender(appSwitcherModel?: AppSwitcherActionPanelModel) {
20
27
  const { token } = theme.useToken();
21
28
 
22
29
  return React.useCallback(
23
30
  (appList: AppListProps) => {
31
+ if (appSwitcherModel?.hasActions() || appSwitcherModel?.context.flowSettingsEnabled) {
32
+ return <AppSwitcherActionsContent model={appSwitcherModel} />;
33
+ }
34
+
24
35
  const columnCount = Math.min(Math.max(appList.length, 1), 2);
25
36
 
26
37
  return (
@@ -134,6 +145,6 @@ export function useAppListRender() {
134
145
  </div>
135
146
  );
136
147
  },
137
- [token],
148
+ [appSwitcherModel, token],
138
149
  );
139
150
  }
@@ -0,0 +1,289 @@
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 { SettingOutlined } from '@ant-design/icons';
11
+ import { css } from '@emotion/css';
12
+ import { Icon } from '../../../components';
13
+ import { ActionModel } from '../../models/base';
14
+ import {
15
+ AddSubModelButton,
16
+ DndProvider,
17
+ DragHandler,
18
+ Droppable,
19
+ FlowModel,
20
+ FlowModelRenderer,
21
+ FlowSettingsButton,
22
+ mergeSubModelItems,
23
+ observer,
24
+ type SubModelItem,
25
+ type SubModelItemsType,
26
+ } from '@nocobase/flow-engine';
27
+ import { Avatar, Button, Card, Tooltip, Typography } from 'antd';
28
+ import React from 'react';
29
+
30
+ const defaultIconColors = ['#3d8bff', '#00a8b5', '#35b26b', '#f5a623', '#ff7a45', '#e85d75', '#9254de', '#597ef7'];
31
+
32
+ function getDefaultIconColor(title?: React.ReactNode) {
33
+ const source = typeof title === 'string' ? title : '';
34
+ const hash = Array.from(source).reduce((sum, char) => sum + char.charCodeAt(0), 0);
35
+ return defaultIconColors[hash % defaultIconColors.length];
36
+ }
37
+
38
+ type AppSwitcherActionPanelStructure = {
39
+ subModels: {
40
+ actions?: ActionModel[];
41
+ };
42
+ };
43
+
44
+ function isRenderableActionModel(action: unknown): action is ActionModel {
45
+ if (!action || typeof action !== 'object') {
46
+ return false;
47
+ }
48
+
49
+ const candidate = action as Partial<ActionModel>;
50
+ return typeof candidate.getTitle === 'function' && typeof candidate.onClick === 'function';
51
+ }
52
+
53
+ type EntryActionAvailability = {
54
+ isEntryActionAvailable?: () => boolean;
55
+ getEntryActionUnavailableMessage?: () => string | undefined;
56
+ };
57
+
58
+ function isEntryActionUnavailable(action: ActionModel) {
59
+ const candidate = action as ActionModel & EntryActionAvailability;
60
+ return typeof candidate.isEntryActionAvailable === 'function' && !candidate.isEntryActionAvailable();
61
+ }
62
+
63
+ function getEntryActionUnavailableMessage(action: ActionModel) {
64
+ const candidate = action as ActionModel & EntryActionAvailability;
65
+ return candidate.getEntryActionUnavailableMessage?.();
66
+ }
67
+
68
+ export class AppSwitcherActionPanelModel extends FlowModel<AppSwitcherActionPanelStructure> {
69
+ getConfigureActionsItems(): SubModelItemsType {
70
+ const linkAction: SubModelItem = {
71
+ key: 'app-switcher:link',
72
+ label: this.context.t('Link'),
73
+ createModelOptions: {
74
+ use: 'LinkActionModel',
75
+ },
76
+ };
77
+ const jsAction: SubModelItem = {
78
+ key: 'app-switcher:js-action',
79
+ label: this.context.t('JS action'),
80
+ createModelOptions: {
81
+ use: 'JSActionModel',
82
+ },
83
+ };
84
+
85
+ return mergeSubModelItems([[linkAction], this.context.app.entryActionManager.getItems('app-switcher'), [jsAction]]);
86
+ }
87
+
88
+ renderConfigureActions() {
89
+ return <AppSwitcherConfigureActionsButton model={this} />;
90
+ }
91
+
92
+ private getRenderableActions(options: { includeHidden?: boolean; includeUnavailable?: boolean } = {}) {
93
+ return this.mapSubModels('actions', (action) => action)
94
+ .filter(isRenderableActionModel)
95
+ .filter((action) => options.includeUnavailable || !isEntryActionUnavailable(action))
96
+ .filter((action) => options.includeHidden || !action.hidden);
97
+ }
98
+
99
+ hasActions() {
100
+ return this.getRenderableActions().length > 0;
101
+ }
102
+
103
+ renderContent() {
104
+ const token = this.context.themeToken;
105
+ const designable = !!this.context.flowSettingsEnabled;
106
+ const actions = this.getRenderableActions({ includeHidden: designable, includeUnavailable: designable });
107
+ const columnCount = Math.min(Math.max(actions.length || 1, 1), 2);
108
+ const buttonResetClass = css`
109
+ &.ant-btn {
110
+ display: block;
111
+ width: 100%;
112
+ height: 100%;
113
+ padding: 0;
114
+ border: none;
115
+ box-shadow: none;
116
+ background: none;
117
+ color: ${token.colorText};
118
+ text-align: left;
119
+ }
120
+ &.ant-btn > span {
121
+ display: block;
122
+ width: 100%;
123
+ height: 100%;
124
+ }
125
+ .ant-btn-icon {
126
+ display: none;
127
+ }
128
+ `;
129
+ const contentClass = css`
130
+ max-width: calc(100vw - 48px);
131
+ max-height: calc(100vh - 48px);
132
+ overflow: auto;
133
+ padding: ${token.paddingXS}px;
134
+ `;
135
+ const listClass = css`
136
+ display: grid;
137
+ grid-template-columns: repeat(${columnCount}, 260px);
138
+ gap: ${token.marginXXS}px;
139
+ margin: 0;
140
+ padding: 0;
141
+ list-style: none;
142
+ `;
143
+
144
+ return (
145
+ <div className={contentClass}>
146
+ {actions.length ? (
147
+ <DndProvider>
148
+ <ul className={listClass}>
149
+ {actions.map((action) => {
150
+ const { icon = 'AppstoreOutlined', color } = action.props;
151
+ const title = action.getTitle();
152
+ const entryActionUnavailable = isEntryActionUnavailable(action);
153
+ const renderActionContent = (compact = false) => (
154
+ <Card
155
+ bordered={false}
156
+ className={css`
157
+ height: 56px;
158
+ border-radius: ${token.borderRadius}px;
159
+ box-shadow: none !important;
160
+ opacity: ${compact ? token.opacityLoading : 1};
161
+
162
+ &:hover {
163
+ background: ${token.colorBgTextHover};
164
+ box-shadow: none !important;
165
+ }
166
+
167
+ .ant-card-body {
168
+ height: 100%;
169
+ padding: ${token.paddingXS}px;
170
+ display: flex;
171
+ align-items: center;
172
+ gap: ${token.marginXS}px;
173
+ }
174
+ `}
175
+ styles={{ body: { background: 'transparent' } }}
176
+ >
177
+ <Avatar
178
+ size={32}
179
+ shape="square"
180
+ icon={<Icon type={icon as string} />}
181
+ style={{
182
+ flex: '0 0 auto',
183
+ borderRadius: 6,
184
+ background: color || getDefaultIconColor(title),
185
+ }}
186
+ />
187
+ <Typography.Text
188
+ ellipsis
189
+ title={typeof title === 'string' ? title : undefined}
190
+ style={{
191
+ minWidth: 0,
192
+ color: token.colorText,
193
+ fontSize: token.fontSize,
194
+ lineHeight: token.lineHeight,
195
+ }}
196
+ >
197
+ {title}
198
+ </Typography.Text>
199
+ </Card>
200
+ );
201
+ action.enableEditDanger = false;
202
+ action.enableEditType = false;
203
+ action.enableEditIconOnly = false;
204
+ action.enableEditColor = true;
205
+ action.renderButton = () => {
206
+ if (entryActionUnavailable) {
207
+ return (
208
+ <Tooltip title={getEntryActionUnavailableMessage(action)}>
209
+ <Button className={buttonResetClass} onClick={action.onClick.bind(action)}>
210
+ {renderActionContent(true)}
211
+ </Button>
212
+ </Tooltip>
213
+ );
214
+ }
215
+ return (
216
+ <Button className={buttonResetClass} onClick={action.onClick.bind(action)}>
217
+ {renderActionContent()}
218
+ </Button>
219
+ );
220
+ };
221
+ action.renderHiddenInConfig = () => (
222
+ <Tooltip
223
+ title={
224
+ entryActionUnavailable
225
+ ? getEntryActionUnavailableMessage(action)
226
+ : this.context.t('The button is hidden and only visible when the UI Editor is active')
227
+ }
228
+ >
229
+ <Button className={buttonResetClass} onClick={action.onClick.bind(action)}>
230
+ {renderActionContent(true)}
231
+ </Button>
232
+ </Tooltip>
233
+ );
234
+
235
+ return (
236
+ <li key={action.uid} style={{ width: 260, height: 56, listStyle: 'none' }}>
237
+ <Droppable model={action}>
238
+ <FlowModelRenderer
239
+ model={action}
240
+ showFlowSettings={{ showBackground: false, showBorder: false, toolbarPosition: 'above' }}
241
+ extraToolbarItems={
242
+ designable
243
+ ? [
244
+ {
245
+ key: 'drag-handler',
246
+ component: DragHandler,
247
+ sort: 1,
248
+ },
249
+ ]
250
+ : []
251
+ }
252
+ />
253
+ </Droppable>
254
+ </li>
255
+ );
256
+ })}
257
+ </ul>
258
+ </DndProvider>
259
+ ) : (
260
+ <div style={{ width: 260, minHeight: token.marginXS }} />
261
+ )}
262
+ {designable && <div style={{ marginTop: token.marginXS }}>{this.renderConfigureActions()}</div>}
263
+ </div>
264
+ );
265
+ }
266
+ }
267
+
268
+ AppSwitcherActionPanelModel.define({
269
+ label: 'App switcher actions',
270
+ children: false,
271
+ createModelOptions: {
272
+ use: 'AppSwitcherActionPanelModel',
273
+ },
274
+ });
275
+
276
+ const AppSwitcherConfigureActionsButton = observer(({ model }: { model: AppSwitcherActionPanelModel }) => {
277
+ const entryActionsRevision = model.context.app.entryActionManager.revision;
278
+ return (
279
+ <AddSubModelButton
280
+ key={`app-switcher-add-actions-${entryActionsRevision}`}
281
+ model={model}
282
+ items={model.getConfigureActionsItems()}
283
+ subModelKey="actions"
284
+ keepDropdownOpen
285
+ >
286
+ <FlowSettingsButton icon={<SettingOutlined />}>{model.context.t('Actions')}</FlowSettingsButton>
287
+ </AddSubModelButton>
288
+ );
289
+ });