diff --git a/packages/core/client/src/application/Application.tsx b/packages/core/client/src/application/Application.tsx index df8bf609b25..52b75a79025 100644 --- a/packages/core/client/src/application/Application.tsx +++ b/packages/core/client/src/application/Application.tsx @@ -44,6 +44,8 @@ import { FlowEngine, FlowEngineContext, FlowEngineGlobalsContextProvider, + FlowModel, + FlowModelRenderer, FlowEngineProvider, } from '@nocobase/flow-engine'; import type { CollectionFieldInterfaceFactory } from '../data-source'; @@ -141,6 +143,7 @@ export class Application { public globalVarCtxs: Record = {}; public jsonLogic: JsonLogic; public flowEngine: FlowEngine; + public model: ApplicationModel; public context: FlowEngineContext & { pluginSettingsRouter: PluginSettingsManager; pluginManager: PluginManager; @@ -219,6 +222,11 @@ export class Application { this.schemaInitializerManager = new SchemaInitializerManager(options.schemaInitializers, this); this.dataSourceManager = new DataSourceManager(options.dataSourceManager, this); this.flowEngine = new FlowEngine(); + this.flowEngine.registerModels({ ApplicationModel }); + this.model = this.flowEngine.createModel({ + uid: '__app_model__', + use: 'ApplicationModel', + }); this.context = this.flowEngine.context as any; this.context.defineProperty('pluginManager', { get: () => this.pluginManager, @@ -571,9 +579,18 @@ export class Application { } getRootComponent() { - const Root: FC<{ children?: React.ReactNode }> = ({ children }) => ( - {children} - ); + const Root: FC<{ children?: React.ReactNode }> = ({ children }) => { + // 第一阶段仅切换根渲染宿主,保持 AppComponent 现有语义不变。 + React.useLayoutEffect(() => { + this.model.setProps({ children }); + }, [children]); + + return ( + + + + ); + }; return Root; } @@ -673,3 +690,10 @@ export class Application { this.apps.Component = Component; } } + +class ApplicationModel extends FlowModel { + render() { + const app = this.context.app as Application; + return {this.props.children}; + } +} diff --git a/packages/core/client/src/application/__tests__/ApplicationModel.host.test.tsx b/packages/core/client/src/application/__tests__/ApplicationModel.host.test.tsx new file mode 100644 index 00000000000..a9be3336a8e --- /dev/null +++ b/packages/core/client/src/application/__tests__/ApplicationModel.host.test.tsx @@ -0,0 +1,71 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { flowModelRendererSpy } = vi.hoisted(() => { + return { + flowModelRendererSpy: vi.fn(), + }; +}); + +vi.mock('@nocobase/flow-engine', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + FlowModelRenderer: (props: any) => { + flowModelRendererSpy(props); + return
; + }, + }; +}); + +import { Application } from '../Application'; + +describe('ApplicationModel (phase-1 host)', () => { + beforeEach(() => { + flowModelRendererSpy.mockClear(); + }); + + it('should create app model with fixed uid', () => { + const app = new Application(); + + expect(app.model).toBeTruthy(); + expect(app.flowEngine.getModel('__app_model__')).toBe(app.model); + }); + + it('should render root by FlowModelRenderer with app model', async () => { + const app = new Application(); + const Root = app.getRootComponent(); + + render(); + + await waitFor(() => { + expect(flowModelRendererSpy).toHaveBeenCalled(); + }); + expect(flowModelRendererSpy).toHaveBeenLastCalledWith(expect.objectContaining({ model: app.model })); + }); + + it('should sync root children to app model props', async () => { + const app = new Application(); + const Root = app.getRootComponent(); + + render( + +
child
+
, + ); + + await waitFor(() => { + expect(app.model.props.children).toBeTruthy(); + }); + }); +}); diff --git a/packages/core/client/src/application/hooks/useApp.ts b/packages/core/client/src/application/hooks/useApp.ts index 559b6b814c4..b2cf5b89e7c 100644 --- a/packages/core/client/src/application/hooks/useApp.ts +++ b/packages/core/client/src/application/hooks/useApp.ts @@ -7,10 +7,14 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ +import { useFlowEngine } from '@nocobase/flow-engine'; import { useContext } from 'react'; import type { Application } from '../Application'; import { ApplicationContext } from '../context'; export const useApp = () => { - return useContext(ApplicationContext) || ({} as Application); + const appFromContext = useContext(ApplicationContext); + const flowEngine = useFlowEngine({ throwError: false }); + const appFromFlowContext = flowEngine?.context?.app as Application; + return appFromFlowContext || appFromContext || ({} as Application); }; diff --git a/packages/core/client/src/flow/FlowPage.tsx b/packages/core/client/src/flow/FlowPage.tsx index 3f124770343..97a3afe369d 100644 --- a/packages/core/client/src/flow/FlowPage.tsx +++ b/packages/core/client/src/flow/FlowPage.tsx @@ -7,26 +7,15 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { - FlowModelRenderer, - observable, - parsePathnameToViewParams, - reaction, - useFlowEngine, - useFlowModelById, - useFlowViewContext, - ViewNavigation, -} from '@nocobase/flow-engine'; +import { FlowModelRenderer, useFlowEngine, useFlowModelById, useFlowViewContext } from '@nocobase/flow-engine'; import type { FlowModel } from '@nocobase/flow-engine'; import { useRequest } from 'ahooks'; -import React, { useEffect, useMemo, useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; import { useAllAccessDesktopRoutes, useCurrentRoute, useKeepAlive, useMobileLayout } from '../route-switch'; import { SkeletonFallback } from './components/SkeletonFallback'; -import { resolveViewParamsToViewList, ViewItem, updateViewListHidden } from './resolveViewParamsToViewList'; -import { getKey, getViewDiffAndUpdateHidden } from './getViewDiffAndUpdateHidden'; -import { getOpenViewStepParams } from './flows/openViewFlow'; import { useDesignable } from '../schema-component'; import { deviceType } from 'react-device-detect'; +import { AdminLayoutModel } from '../route-switch/antd/admin-layout/AdminLayoutModel'; function InternalFlowPage({ uid, ...props }) { const model = useFlowModelById(uid); @@ -46,42 +35,32 @@ function InternalFlowPage({ uid, ...props }) { } export const FlowRoute = () => { - const layoutContentRef = useRef(null); const flowEngine = useFlowEngine(); + const route = flowEngine.context.route || {}; const currentRoute = useCurrentRoute(); const { refresh } = useAllAccessDesktopRoutes(); const { isMobileLayout } = useMobileLayout(); - const pageUidRef = useRef(flowEngine.context.route.params.name); - const viewStateRef = useRef<{ - [uid in string]: { destroy: (force?: boolean) => void; update: (value: any) => void; navigation: ViewNavigation }; - }>({}); - const prevViewListRef = useRef([]); - const hasStepNavigatedRef = useRef(false); + const pageUidRef = useRef(route?.params?.name); const { designable } = useDesignable(); const { active } = useKeepAlive(); - const forceStopRef = useRef(false); + const layoutContentRef = useRef(null); + const pageUid = pageUidRef.current; + const adminLayoutModel = flowEngine.getModel('admin-layout-model'); + const activeRef = useRef(active); + const currentRouteRef = useRef(currentRoute); + const refreshRef = useRef(refresh); - const routeModel = useMemo(() => { - return flowEngine.createModel({ - uid: pageUidRef.current, - use: 'RouteModel', - }); - }, [flowEngine]); + activeRef.current = active; + currentRouteRef.current = currentRoute; + refreshRef.current = refresh; - useEffect(() => { - routeModel.context.defineProperty('pageActive', { - value: observable.ref(false), - info: { - description: - 'Whether current page route is active (keep-alive). This is an observable.ref (use ctx.pageActive.value to read/write).', - detail: 'observable.ref', - }, - }); - }, [routeModel]); + if (!adminLayoutModel) { + throw new Error('[NocoBase] FlowRoute requires admin-layout-model. Please render FlowRoute under AdminLayout.'); + } - useEffect(() => { - routeModel.context.pageActive.value = active; - }, [active, routeModel]); + if (!pageUid) { + throw new Error('[NocoBase] FlowRoute requires route.params.name.'); + } useEffect(() => { flowEngine.context.defineProperty('isMobileLayout', { @@ -128,186 +107,30 @@ export const FlowRoute = () => { }, [designable, flowEngine, isMobileLayout]); useEffect(() => { - if (!layoutContentRef.current) { - return; - } - flowEngine.context.defineProperty('layoutContentElement', { - get: () => layoutContentRef.current, + adminLayoutModel.registerRoutePage(pageUid, { + active: activeRef.current, + currentRoute: currentRouteRef.current, + refreshDesktopRoutes: refreshRef.current, + layoutContentElement: layoutContentRef.current, }); - routeModel.context.defineProperty('currentRoute', { - get: () => currentRoute, - }); - // Also expose currentRoute on engine context so view-scoped engines - // can still read it for default title fallback. - flowEngine.context.defineProperty('currentRoute', { - get: () => currentRoute, - }); - routeModel.context.defineProperty('refreshDesktopRoutes', { - get: () => refresh, - }); - }, [routeModel, currentRoute, refresh, flowEngine]); + return () => { + adminLayoutModel.unregisterRoutePage(pageUid); + }; + }, [adminLayoutModel, pageUid]); useEffect(() => { - const dispose = reaction( - () => flowEngine.context.route, - (newRoute) => { - if (newRoute.params.name !== pageUidRef.current) { - forceStopRef.current = true; - return; - } + adminLayoutModel.updateRoutePage(pageUid, { + active, + }); + }, [adminLayoutModel, pageUid, active]); - try { - forceStopRef.current = false; - - // 1. 把 pathname 解析成一个数组 - const viewStack = parsePathnameToViewParams(newRoute.pathname); - - // 2. 根据视图参数获取更多信息 - const viewList = resolveViewParamsToViewList(flowEngine, viewStack, routeModel); - - // 特殊处理:当通过一个多级 url 打开时,需要把这个 url 分成多步,然后逐步打开。这样做是为了能在点击返回按钮时返回到上一级 - if (prevViewListRef.current.length === 0 && viewList.length > 1 && !hasStepNavigatedRef.current) { - const navigateTo = (index: number) => { - if (!viewList[index]) { - return; - } - - if (index === 0) { - new ViewNavigation(flowEngine.context, []).navigateTo(viewList[index].params, { replace: true }); - } else { - new ViewNavigation( - flowEngine.context, - viewList.slice(0, index).map((item) => item.params), - ).navigateTo(viewList[index].params); - } - - navigateTo(index + 1); - }; - - navigateTo(0); - hasStepNavigatedRef.current = true; - return; - } - - // 3. 对比新旧列表,区分开需要打开和关闭的视图 - const { viewsToClose, viewsToOpen } = getViewDiffAndUpdateHidden(prevViewListRef.current, viewList); - - console.log('[NocoBase] FlowRoute view diff:', { viewsToClose, viewsToOpen }); - - // 4. 处理需要关闭的视图(强制关闭,确保触发 onClose 并绕过 preventClose) - if (viewsToClose.length) { - viewsToClose.forEach((viewItem) => { - viewStateRef.current[getKey(viewItem)]?.destroy?.(true); - delete viewStateRef.current[getKey(viewItem)]; - }); - - // 重新计算 hidden 状态 - updateViewListHidden(viewList); - } - - // 5. 处理需要打开的视图 - if (viewsToOpen.length) { - const handleOpenViews = async () => { - const missingModels = viewsToOpen.filter((v) => !v.model); - if (missingModels.length > 0) { - await Promise.all( - missingModels.map(async (viewItem) => { - try { - viewItem.model = await flowEngine.loadModel({ uid: viewItem.modelUid }); - } catch (error) { - console.error(`[NocoBase] Failed to load model ${viewItem.modelUid}:`, error); - } - }), - ); - } - - if (forceStopRef.current) { - return; - } - - // 重新计算 hidden 状态 - updateViewListHidden(viewList); - - const openView = (index: number) => { - if (!viewsToOpen[index]) { - return; - } - - const viewItem = viewsToOpen[index]; - - if (!viewItem.model) { - return; - } - - const destroyRef = React.createRef<(result?: any, force?: boolean) => void>(); - const updateRef = React.createRef<(value: any) => void>(); - const openViewParams = getOpenViewStepParams(viewItem.model); - const openerUids = viewList.slice(0, viewItem.index).map((item) => item.params.viewUid); - const navigation = new ViewNavigation( - flowEngine.context, - viewList.slice(0, viewItem.index + 1).map((item) => item.params), - ); - - viewItem.model.dispatchEvent('click', { - target: layoutContentRef.current, - collectionName: openViewParams?.collectionName, - associationName: openViewParams?.associationName, - dataSourceKey: openViewParams?.dataSourceKey, - destroyRef, - updateRef, - openerUids, - ...viewItem.params, - navigation, - onOpen() { - openView(index + 1); // 递归打开下一个视图 - }, - hidden: viewItem.hidden, // 是否隐藏视图 - isMobileLayout, - triggerByRouter: true, // 标记该事件是由路由系统触发 - }); - - viewStateRef.current[getKey(viewItem)] = { - destroy: (force?: boolean) => destroyRef.current?.(), - update: (value: any) => updateRef.current?.(value), - navigation, - }; - }; - - openView(0); - }; - - handleOpenViews(); - } - - // 6. 当没有视图需要打开和关闭时,说明只是更新了当前视图的参数,比如切换 tab。这是需要更新当前视图 navigation 的 viewStack,避免 URL 错乱 - if (viewsToClose.length === 0 && viewsToOpen.length === 0) { - const currentViewItem = viewList.at(-1); - if (currentViewItem) { - viewStateRef.current[getKey(currentViewItem)]?.navigation.setViewStack( - viewList.map((item) => item.params), - ); - } - } - - prevViewListRef.current = [...viewList]; - } catch (error) { - console.error(`[NocoBase] Failed to resolve view params to view list:`, error); - } - }, - { - fireImmediately: true, - }, - ); - - // Cleanup: on unmount, force-close all opened views and remove their models - return () => { - dispose?.(); - prevViewListRef.current.forEach((viewItem) => { - flowEngine.removeModelWithSubModels(viewItem.params.viewUid); - viewStateRef.current[getKey(viewItem)]?.destroy(); - }); - }; - }, [flowEngine, isMobileLayout, routeModel]); + useEffect(() => { + adminLayoutModel.updateRoutePage(pageUid, { + currentRoute, + refreshDesktopRoutes: refresh, + layoutContentElement: layoutContentRef.current, + }); + }, [adminLayoutModel, pageUid, currentRoute, refresh]); return
; }; diff --git a/packages/core/client/src/flow/__tests__/FlowRoute.test.tsx b/packages/core/client/src/flow/__tests__/FlowRoute.test.tsx index d57330c4eca..cef5f2f1ee3 100644 --- a/packages/core/client/src/flow/__tests__/FlowRoute.test.tsx +++ b/packages/core/client/src/flow/__tests__/FlowRoute.test.tsx @@ -8,109 +8,63 @@ */ import React from 'react'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { render, waitFor } from '@testing-library/react'; import { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine'; -import { resolveViewParamsToViewList } from '../resolveViewParamsToViewList'; -import { getViewDiffAndUpdateHidden } from '../getViewDiffAndUpdateHidden'; -import { getOpenViewStepParams } from '../flows/openViewFlow'; - -// 被测组件 import { FlowRoute } from '../FlowPage'; -import { RouteModel } from '../models/base/RouteModel'; -// mock 路由相关 hooks -vi.mock('../../route-switch', () => ({ - useCurrentRoute: () => ({ name: 'testRoute' }), - useKeepAlive: () => ({ active: true }), - useMobileLayout: () => ({ isMobileLayout: true }), - useAllAccessDesktopRoutes: () => ({ refresh: vi.fn() }), -})); - -vi.mock('../resolveViewParamsToViewList', () => ({ - resolveViewParamsToViewList: vi.fn(), - updateViewListHidden: vi.fn(), -})); - -vi.mock('../getViewDiffAndUpdateHidden', () => ({ - getViewDiffAndUpdateHidden: vi.fn(), - getKey: vi.fn(), -})); - -vi.mock('../flows/openViewFlow', async (importOriginal) => { - const actual = await importOriginal(); +const { hookState } = vi.hoisted(() => { return { - ...(actual as any), - getOpenViewStepParams: vi.fn(), + hookState: { + currentRoute: { title: 'Route A' }, + active: true, + isMobileLayout: true, + refresh: vi.fn(), + }, }; }); -const mockResolveViewParamsToViewList = vi.mocked(resolveViewParamsToViewList); -const mockGetViewDiffAndUpdateHidden = vi.mocked(getViewDiffAndUpdateHidden); -const mockGetOpenViewStepParams = vi.mocked(getOpenViewStepParams); +vi.mock('../../route-switch', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useCurrentRoute: () => hookState.currentRoute, + useKeepAlive: () => ({ active: hookState.active }), + useMobileLayout: () => ({ isMobileLayout: hookState.isMobileLayout }), + useAllAccessDesktopRoutes: () => ({ refresh: hookState.refresh }), + }; +}); describe('FlowRoute', () => { beforeEach(() => { vi.clearAllMocks(); - mockResolveViewParamsToViewList.mockReturnValue([]); - mockGetViewDiffAndUpdateHidden.mockReturnValue({ viewsToClose: [], viewsToOpen: [] }); - mockGetOpenViewStepParams.mockReturnValue({} as any); - }); - it('should define isMobileLayout on model context', async () => { - // 使用真实的 FlowEngine 与 FlowModel,不再 mock - const engine = new FlowEngine(); - engine.registerModels({ RouteModel }); - // 仅设置路由参数 name,避免依赖其它路由字段(如 pathname) - engine.context.defineProperty('route', { value: { params: { name: 'test' } } }); - - render( - - - - } /> - - - , - ); - - await waitFor(() => { - const model = engine.getModel('test'); - expect(model.context.isMobileLayout).toBe(true); - }); + hookState.currentRoute = { title: 'Route A' }; + hookState.active = true; + hookState.isMobileLayout = true; + hookState.refresh = vi.fn(); }); - it('cleans up with removeModelWithSubModels for open views', async () => { + it('should bridge page lifecycle to admin-layout-model', async () => { const engine = new FlowEngine(); - engine.registerModels({ RouteModel }); - const routeName = 'test-route'; engine.context.defineProperty('route', { value: { - params: { name: routeName }, - pathname: '/admin/test-view', + params: { name: 'test-page' }, + pathname: '/admin/test-page', }, }); - const removeSpy = vi.spyOn(engine, 'removeModelWithSubModels'); + const adminLayoutModel = engine.createModel({ + uid: 'admin-layout-model', + use: 'FlowModel', + }) as any; + adminLayoutModel.registerRoutePage = vi.fn(); + adminLayoutModel.updateRoutePage = vi.fn(); + adminLayoutModel.unregisterRoutePage = vi.fn(); - mockResolveViewParamsToViewList.mockImplementation((_engine, viewParams) => { - return viewParams.map((params, index) => ({ - params, - modelUid: params.viewUid, - model: { dispatchEvent: vi.fn(() => Promise.resolve()) } as any, - hidden: { value: false }, - index, - })); - }); - mockGetViewDiffAndUpdateHidden.mockImplementation((_prev, current) => ({ - viewsToClose: [], - viewsToOpen: current, - })); - mockGetOpenViewStepParams.mockReturnValue({} as any); - - const { unmount } = render( + const { rerender, unmount } = render( - + } /> @@ -119,16 +73,83 @@ describe('FlowRoute', () => { ); await waitFor(() => { - expect(mockResolveViewParamsToViewList).toHaveBeenCalled(); + expect(adminLayoutModel.registerRoutePage).toHaveBeenCalledWith( + 'test-page', + expect.objectContaining({ + active: true, + currentRoute: hookState.currentRoute, + refreshDesktopRoutes: hookState.refresh, + layoutContentElement: expect.any(HTMLDivElement), + }), + ); + }); + + await waitFor(() => { + expect(adminLayoutModel.updateRoutePage).toHaveBeenCalledWith('test-page', { active: true }); + expect(adminLayoutModel.updateRoutePage).toHaveBeenCalledWith( + 'test-page', + expect.objectContaining({ + currentRoute: hookState.currentRoute, + refreshDesktopRoutes: hookState.refresh, + layoutContentElement: expect.any(HTMLDivElement), + }), + ); + }); + + hookState.active = false; + hookState.currentRoute = { title: 'Route B' }; + hookState.refresh = vi.fn(); + + rerender( + + + + } /> + + + , + ); + + await waitFor(() => { + expect(adminLayoutModel.updateRoutePage).toHaveBeenCalledWith('test-page', { active: false }); + expect(adminLayoutModel.updateRoutePage).toHaveBeenCalledWith( + 'test-page', + expect.objectContaining({ + currentRoute: hookState.currentRoute, + refreshDesktopRoutes: hookState.refresh, + layoutContentElement: expect.any(HTMLDivElement), + }), + ); }); unmount(); + expect(adminLayoutModel.unregisterRoutePage).toHaveBeenCalledWith('test-page'); + }); - await waitFor(() => { - expect(removeSpy).toHaveBeenCalledTimes(1); + it('should fail fast when admin-layout-model is missing', () => { + const engine = new FlowEngine(); + engine.context.defineProperty('route', { + value: { + params: { name: 'test-page' }, + pathname: '/admin/test-page', + }, }); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const viewParams = mockResolveViewParamsToViewList.mock.calls[0][1]; - expect(removeSpy).toHaveBeenCalledWith(viewParams[0].viewUid); + try { + expect(() => { + render( + + + + } /> + + + , + ); + }).toThrowError(/admin-layout-model/); + } finally { + consoleErrorSpy.mockRestore(); + } }); }); diff --git a/packages/core/client/src/nocobase-buildin-plugin/__tests__/admin-v1-v2-compat.test.ts b/packages/core/client/src/nocobase-buildin-plugin/__tests__/admin-v1-v2-compat.test.ts new file mode 100644 index 00000000000..5af5d5b7dc9 --- /dev/null +++ b/packages/core/client/src/nocobase-buildin-plugin/__tests__/admin-v1-v2-compat.test.ts @@ -0,0 +1,48 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { parsePathnameToViewParams } from '@nocobase/flow-engine'; +import { describe, expect, it, vi } from 'vitest'; +import { removeLastPopupPath } from '../../schema-component/antd/page/pagePopupUtils'; +import { NocoBaseBuildInPlugin } from '../index'; + +describe('admin v1/v2 compatibility', () => { + it('should register both v1 and v2 admin route paths', () => { + const add = vi.fn(); + NocoBaseBuildInPlugin.prototype.addRoutes.call({ + router: { add }, + } as any); + + const paths = add.mock.calls.map((call) => call[1]?.path).filter(Boolean); + + expect(paths).toEqual( + expect.arrayContaining([ + '/admin/:name/tabs/:tabUid', + '/admin/:name/popups/*', + '/admin/:name/tabs/:tabUid/popups/*', + '/admin/:name/tab/:tabUid', + '/admin/:name/view/*', + '/admin/:name/tab/:tabUid/view/*', + ]), + ); + }); + + it('should keep v2 tab/view deep link replay semantics', () => { + const result = parsePathnameToViewParams('/admin/pageA/tab/tabA/view/popupA/tab/tabB/filterbytk/1/sourceid/2'); + expect(result).toEqual([ + { viewUid: 'pageA', tabUid: 'tabA' }, + { viewUid: 'popupA', tabUid: 'tabB', filterByTk: '1', sourceId: '2' }, + ]); + }); + + it('should keep v1 tabs/popups nested close semantics', () => { + const path = '/admin/pageA/tabs/tabA/popups/popup1/filterbytk/1/popups/popup2/sourceid/2'; + expect(removeLastPopupPath(path)).toBe('/admin/pageA/tabs/tabA/popups/popup1/filterbytk/1'); + }); +}); diff --git a/packages/core/client/src/pm/AdminSettingsLayoutModel.tsx b/packages/core/client/src/pm/AdminSettingsLayoutModel.tsx new file mode 100644 index 00000000000..810fa04ae65 --- /dev/null +++ b/packages/core/client/src/pm/AdminSettingsLayoutModel.tsx @@ -0,0 +1,17 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { FlowModel } from '@nocobase/flow-engine'; +import React from 'react'; + +export class AdminSettingsLayoutModel extends FlowModel { + render() { + return <>{this.props.children}; + } +} diff --git a/packages/core/client/src/pm/PluginSetting.tsx b/packages/core/client/src/pm/PluginSetting.tsx index 5c6bd4c8893..f76cdce49ba 100644 --- a/packages/core/client/src/pm/PluginSetting.tsx +++ b/packages/core/client/src/pm/PluginSetting.tsx @@ -10,9 +10,10 @@ import { ApiOutlined } from '@ant-design/icons'; import { PageHeader } from '@ant-design/pro-layout'; import { css } from '@emotion/css'; +import { FlowModelRenderer, useFlowEngine } from '@nocobase/flow-engine'; import { Layout, Menu } from 'antd'; import _ from 'lodash'; -import React, { createContext, useCallback, useEffect, useMemo } from 'react'; +import React, { createContext, useCallback, useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Navigate, Outlet, useLocation, useNavigate, useParams } from 'react-router-dom'; import { useACLRoleContext } from '../acl'; @@ -20,11 +21,14 @@ import { ADMIN_SETTINGS_PATH, PluginSettingsPageType, useApp } from '../applicat import { AppNotFound } from '../common/AppNotFound'; import { useDocumentTitle } from '../document-title'; import { useCompile } from '../schema-component'; +import { AdminSettingsLayoutModel } from './AdminSettingsLayoutModel'; import { useStyles } from './style'; export const SettingsCenterContext = createContext({}); SettingsCenterContext.displayName = 'SettingsCenterContext'; +const ADMIN_SETTINGS_LAYOUT_MODEL_UID = 'admin-settings-layout-model'; + function getMenuItems(list: PluginSettingsPageType[]) { const pinnedList = list.filter((item) => item.isPinned && !item.hidden); const otherList = list.filter((item) => !item.isPinned && !item.hidden); @@ -76,7 +80,7 @@ function replaceRouteParams(urlTemplate, params) { }); } -export const AdminSettingsLayout = () => { +export const InternalAdminSettingsLayout = () => { const { styles, theme } = useStyles(); const app = useApp(); const navigate = useNavigate(); @@ -169,9 +173,10 @@ export const AdminSettingsLayout = () => { snippets.includes('pm') && { type: 'divider', }, - ...getMenuItems(settings.filter((v) => v.isTopLevel !== false).map((item) => ({ ...item, children: null }))), + ...(getMenuItems(settings.filter((v) => v.isTopLevel !== false).map((item) => ({ ...item, children: null }))) || + []), ].filter(Boolean) as any[]; - }, [settings]); + }, [settings, snippets, t]); if (!currentSetting || location.pathname === ADMIN_SETTINGS_PATH || location.pathname === ADMIN_SETTINGS_PATH + '/') { return ; } @@ -258,3 +263,27 @@ export const AdminSettingsLayout = () => {
); }; + +export const AdminSettingsLayout = (props) => { + const flowEngine = useFlowEngine(); + const modelRef = useRef(null); + const modelChildren = ; + + if (!modelRef.current) { + modelRef.current = + flowEngine.getModel(ADMIN_SETTINGS_LAYOUT_MODEL_UID) || + flowEngine.createModel({ + uid: ADMIN_SETTINGS_LAYOUT_MODEL_UID, + use: AdminSettingsLayoutModel, + props: { ...props, children: modelChildren }, + }); + } + + const model = modelRef.current; + + useEffect(() => { + model.setProps({ ...props, children: modelChildren }); + }, [model, modelChildren, props]); + + return ; +}; diff --git a/packages/core/client/src/pm/__tests__/admin-settings-layout-model.test.tsx b/packages/core/client/src/pm/__tests__/admin-settings-layout-model.test.tsx new file mode 100644 index 00000000000..cfd158d34bc --- /dev/null +++ b/packages/core/client/src/pm/__tests__/admin-settings-layout-model.test.tsx @@ -0,0 +1,102 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { flowModelRendererSpy } = vi.hoisted(() => { + return { + flowModelRendererSpy: vi.fn(), + }; +}); + +vi.mock('@nocobase/flow-engine', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + FlowModelRenderer: (props: any) => { + flowModelRendererSpy(props); + return
; + }, + }; +}); + +import { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine'; +import { AdminSettingsLayoutModel } from '../AdminSettingsLayoutModel'; +import { AdminSettingsLayout } from '../PluginSetting'; + +describe('AdminSettingsLayout (model host)', () => { + beforeEach(() => { + flowModelRendererSpy.mockClear(); + }); + + it('should create AdminSettingsLayoutModel and pass it to FlowModelRenderer', async () => { + const engine = new FlowEngine(); + + render( + + + , + ); + + await waitFor(() => { + expect(flowModelRendererSpy).toHaveBeenCalled(); + }); + + const model = engine.getModel('admin-settings-layout-model'); + expect(model).toBeInstanceOf(AdminSettingsLayoutModel); + expect(model.props.testFlag).toBe('first-render'); + expect(flowModelRendererSpy).toHaveBeenLastCalledWith(expect.objectContaining({ model })); + }); + + it('should reuse existing AdminSettingsLayoutModel instance', async () => { + const engine = new FlowEngine(); + const existingModel = engine.createModel({ + uid: 'admin-settings-layout-model', + use: AdminSettingsLayoutModel, + props: { testFlag: 'existing' }, + }); + + render( + + + , + ); + + await waitFor(() => { + expect(existingModel.props.testFlag).toBe('reused-model'); + }); + + expect(engine.getModel('admin-settings-layout-model')).toBe(existingModel); + expect(flowModelRendererSpy).toHaveBeenLastCalledWith(expect.objectContaining({ model: existingModel })); + }); + + it('should update model props on rerender', async () => { + const engine = new FlowEngine(); + const { rerender } = render( + + + , + ); + + const model = engine.getModel('admin-settings-layout-model'); + expect(model).toBeTruthy(); + + rerender( + + + , + ); + + await waitFor(() => { + expect(model.props.testFlag).toBe('v2'); + }); + }); +}); diff --git a/packages/core/client/src/pm/index.tsx b/packages/core/client/src/pm/index.tsx index a3dfdc736cf..2d9871a7737 100644 --- a/packages/core/client/src/pm/index.tsx +++ b/packages/core/client/src/pm/index.tsx @@ -16,14 +16,17 @@ import { BlockTemplatesPane } from '../schema-templates'; import { SystemSettingsPane } from '../system-settings'; import { PluginManager } from './PluginManager'; import { PluginManagerLink, SettingsCenterDropdown } from './PluginManagerLink'; +import { AdminSettingsLayoutModel } from './AdminSettingsLayoutModel'; import { AdminSettingsLayout } from './PluginSetting'; +export * from './AdminSettingsLayoutModel'; export * from './PluginManager'; export * from './PluginManagerLink'; export * from './PluginSetting'; export class PMPlugin extends Plugin { async load() { + this.app.flowEngine.registerModels({ AdminSettingsLayoutModel }); this.addComponents(); this.addRoutes(); this.addSettings(); diff --git a/packages/core/client/src/route-switch/antd/admin-layout/AdminLayoutModel.tsx b/packages/core/client/src/route-switch/antd/admin-layout/AdminLayoutModel.tsx new file mode 100644 index 00000000000..52234eebb4b --- /dev/null +++ b/packages/core/client/src/route-switch/antd/admin-layout/AdminLayoutModel.tsx @@ -0,0 +1,102 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { reaction } from '@formily/reactive'; +import { FlowModel } from '@nocobase/flow-engine'; +import React from 'react'; +import { AdminLayoutRouteCoordinator, type RoutePageMeta } from './AdminLayoutRouteCoordinator'; + +export class AdminLayoutModel extends FlowModel { + private routeCoordinator?: AdminLayoutRouteCoordinator; + private routeDisposer?: () => void; + private activePageUid = ''; + private layoutContentElement: HTMLElement | null = null; + private routePageMetaMap = new Map(); + + private getCoordinator() { + if (!this.routeCoordinator) { + this.routeCoordinator = new AdminLayoutRouteCoordinator(this.flowEngine); + } + return this.routeCoordinator; + } + + private getCurrentRouteByActivePage() { + return this.routePageMetaMap.get(this.activePageUid)?.currentRoute || {}; + } + + registerRoutePage(pageUid: string, meta: RoutePageMeta) { + this.routePageMetaMap.set(pageUid, { + ...meta, + currentRoute: meta.currentRoute || {}, + }); + return this.getCoordinator().registerPage(pageUid, meta); + } + + updateRoutePage(pageUid: string, meta: Partial) { + const prev = this.routePageMetaMap.get(pageUid) || { active: false, currentRoute: {} }; + const next = { + ...prev, + ...meta, + active: typeof meta.active === 'boolean' ? meta.active : prev.active, + currentRoute: meta.currentRoute ?? prev.currentRoute ?? {}, + }; + this.routePageMetaMap.set(pageUid, next); + this.getCoordinator().syncPageMeta(pageUid, next); + } + + unregisterRoutePage(pageUid: string) { + this.routePageMetaMap.delete(pageUid); + if (this.activePageUid === pageUid) { + this.activePageUid = ''; + } + this.getCoordinator().unregisterPage(pageUid); + } + + setLayoutContentElement(element: HTMLElement | null) { + this.layoutContentElement = element; + this.getCoordinator().setLayoutContentElement(element); + } + + protected onMount(): void { + super.onMount(); + if (!this.routeDisposer) { + this.flowEngine.context.defineProperty('currentRoute', { + get: () => this.getCurrentRouteByActivePage(), + }); + this.flowEngine.context.defineProperty('layoutContentElement', { + get: () => this.layoutContentElement, + }); + this.routeDisposer = reaction( + () => this.flowEngine.context.route, + (route) => { + this.activePageUid = route?.params?.name || ''; + this.getCoordinator().syncRoute(route || {}); + }, + { + fireImmediately: true, + }, + ); + } + } + + protected onUnmount(): void { + this.routeDisposer?.(); + this.routeDisposer = undefined; + this.routeCoordinator?.destroy(); + this.routeCoordinator = undefined; + this.routePageMetaMap.clear(); + this.activePageUid = ''; + this.layoutContentElement = null; + super.onUnmount(); + } + + render() { + return <>{this.props.children}; + } +} diff --git a/packages/core/client/src/route-switch/antd/admin-layout/AdminLayoutRouteCoordinator.ts b/packages/core/client/src/route-switch/antd/admin-layout/AdminLayoutRouteCoordinator.ts new file mode 100644 index 00000000000..d65ecd99636 --- /dev/null +++ b/packages/core/client/src/route-switch/antd/admin-layout/AdminLayoutRouteCoordinator.ts @@ -0,0 +1,331 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { + FlowEngine, + FlowModel, + observable, + parsePathnameToViewParams, + ViewNavigation, + type ViewParam, +} from '@nocobase/flow-engine'; +import React from 'react'; +import { getViewDiffAndUpdateHidden, getKey } from '../../../flow/getViewDiffAndUpdateHidden'; +import { getOpenViewStepParams } from '../../../flow/flows/openViewFlow'; +import { + resolveViewParamsToViewList, + updateViewListHidden, + type ViewItem, +} from '../../../flow/resolveViewParamsToViewList'; + +export interface RoutePageMeta { + active: boolean; + currentRoute?: Record; + refreshDesktopRoutes?: () => void; + layoutContentElement?: HTMLElement | null; +} + +interface ViewRuntimeState { + destroy: (force?: boolean) => void; + update: (value: any) => void; + navigation: ViewNavigation; +} + +interface RoutePageRuntime { + pageUid: string; + routeModel: FlowModel; + meta: RoutePageMeta; + viewState: Record; + prevViewList: ViewItem[]; + hasStepNavigated: boolean; + forceStop: boolean; +} + +interface RouteLike { + params?: { name?: string }; + pathname?: string; +} + +/** + * 管理 admin 场景下每个 page 的 v2 视图栈编排。 + * 该协调器只负责状态机和开关视图,不直接绑定 React 生命周期。 + */ +export class AdminLayoutRouteCoordinator { + private readonly flowEngine: FlowEngine; + private readonly runtimes = new Map(); + private layoutContentElement: HTMLElement | null = null; + + constructor(flowEngine: FlowEngine) { + this.flowEngine = flowEngine; + } + + setLayoutContentElement(element: HTMLElement | null) { + this.layoutContentElement = element; + } + + registerPage(pageUid: string, meta: RoutePageMeta) { + const routeModel = this.getOrCreateRouteModel(pageUid); + const runtime: RoutePageRuntime = { + pageUid, + routeModel, + meta: { + active: !!meta.active, + currentRoute: meta.currentRoute || {}, + refreshDesktopRoutes: meta.refreshDesktopRoutes, + layoutContentElement: meta.layoutContentElement || null, + }, + viewState: {}, + prevViewList: [], + hasStepNavigated: false, + forceStop: false, + }; + + this.ensureRouteModelContext(runtime); + this.runtimes.set(pageUid, runtime); + this.syncPageMeta(pageUid, meta); + this.syncRoute(this.flowEngine.context.route || {}); + + return runtime.routeModel; + } + + syncPageMeta(pageUid: string, meta: Partial) { + const runtime = this.runtimes.get(pageUid); + if (!runtime) { + return; + } + + runtime.meta = { + ...runtime.meta, + ...meta, + active: typeof meta.active === 'boolean' ? meta.active : runtime.meta.active, + currentRoute: meta.currentRoute ?? runtime.meta.currentRoute ?? {}, + layoutContentElement: + typeof meta.layoutContentElement === 'undefined' + ? runtime.meta.layoutContentElement + : meta.layoutContentElement, + }; + + if (runtime.routeModel.context.pageActive?.value !== runtime.meta.active) { + runtime.routeModel.context.pageActive.value = runtime.meta.active; + } + } + + unregisterPage(pageUid: string) { + this.cleanupPage(pageUid); + this.runtimes.delete(pageUid); + } + + syncRoute(routeLike: RouteLike) { + const activePageUid = routeLike?.params?.name; + const pathname = routeLike?.pathname; + if (!activePageUid || !pathname) { + return; + } + + this.runtimes.forEach((runtime) => { + if (runtime.pageUid !== activePageUid) { + runtime.forceStop = true; + } + }); + + const runtime = this.runtimes.get(activePageUid); + if (!runtime) { + return; + } + + runtime.forceStop = false; + this.syncRuntimeWithPathname(runtime, pathname); + } + + cleanupPage(pageUid: string) { + const runtime = this.runtimes.get(pageUid); + if (!runtime) { + return; + } + + runtime.forceStop = true; + runtime.prevViewList.forEach((viewItem) => { + this.flowEngine.removeModelWithSubModels(viewItem.params.viewUid); + runtime.viewState[getKey(viewItem)]?.destroy?.(); + delete runtime.viewState[getKey(viewItem)]; + }); + runtime.prevViewList = []; + runtime.hasStepNavigated = false; + } + + destroy() { + Array.from(this.runtimes.keys()).forEach((pageUid) => { + this.cleanupPage(pageUid); + this.runtimes.delete(pageUid); + }); + } + + private syncRuntimeWithPathname(runtime: RoutePageRuntime, pathname: string) { + try { + const viewStack = parsePathnameToViewParams(pathname); + const viewList = resolveViewParamsToViewList(this.flowEngine, viewStack, runtime.routeModel); + + if (this.shouldStepNavigate(runtime, viewList)) { + this.stepNavigate(viewList, 0); + runtime.hasStepNavigated = true; + return; + } + + const { viewsToClose, viewsToOpen } = getViewDiffAndUpdateHidden(runtime.prevViewList, viewList); + + if (viewsToClose.length) { + viewsToClose.forEach((viewItem) => { + runtime.viewState[getKey(viewItem)]?.destroy?.(true); + delete runtime.viewState[getKey(viewItem)]; + }); + updateViewListHidden(viewList); + } + + if (viewsToOpen.length) { + this.handleOpenViews(runtime, viewList, viewsToOpen); + } + + if (viewsToClose.length === 0 && viewsToOpen.length === 0) { + const currentViewItem = viewList.at(-1); + if (currentViewItem) { + runtime.viewState[getKey(currentViewItem)]?.navigation.setViewStack(viewList.map((item) => item.params)); + } + } + + runtime.prevViewList = [...viewList]; + } catch (error) { + console.error(`[NocoBase] Failed to resolve view params to view list:`, error); + } + } + + private shouldStepNavigate(runtime: RoutePageRuntime, viewList: ViewItem[]) { + return runtime.prevViewList.length === 0 && viewList.length > 1 && !runtime.hasStepNavigated; + } + + private stepNavigate(viewList: ViewItem[], index: number) { + if (!viewList[index]) { + return; + } + + if (index === 0) { + new ViewNavigation(this.flowEngine.context, []).navigateTo(viewList[index].params, { replace: true }); + } else { + new ViewNavigation( + this.flowEngine.context, + viewList.slice(0, index).map((item) => item.params), + ).navigateTo(viewList[index].params); + } + + this.stepNavigate(viewList, index + 1); + } + + private async handleOpenViews(runtime: RoutePageRuntime, viewList: ViewItem[], viewsToOpen: ViewItem[]) { + const missingModels = viewsToOpen.filter((v) => !v.model); + if (missingModels.length > 0) { + await Promise.all( + missingModels.map(async (viewItem) => { + try { + viewItem.model = await this.flowEngine.loadModel({ uid: viewItem.modelUid }); + } catch (error) { + console.error(`[NocoBase] Failed to load model ${viewItem.modelUid}:`, error); + } + }), + ); + } + + if (runtime.forceStop) { + return; + } + + updateViewListHidden(viewList); + this.openViews(runtime, viewList, viewsToOpen, 0); + } + + private openViews(runtime: RoutePageRuntime, viewList: ViewItem[], viewsToOpen: ViewItem[], index: number) { + if (!viewsToOpen[index]) { + return; + } + + const viewItem = viewsToOpen[index]; + if (!viewItem.model) { + return; + } + + const destroyRef = React.createRef<(result?: any, force?: boolean) => void>(); + const updateRef = React.createRef<(value: any) => void>(); + const openViewParams = getOpenViewStepParams(viewItem.model); + const openerUids = viewList.slice(0, viewItem.index).map((item) => item.params.viewUid); + const navigation = new ViewNavigation( + this.flowEngine.context, + viewList.slice(0, viewItem.index + 1).map((item) => item.params), + ); + + viewItem.model.dispatchEvent('click', { + target: runtime.meta.layoutContentElement || this.layoutContentElement, + collectionName: openViewParams?.collectionName, + associationName: openViewParams?.associationName, + dataSourceKey: openViewParams?.dataSourceKey, + destroyRef, + updateRef, + openerUids, + ...viewItem.params, + navigation, + onOpen: () => { + this.openViews(runtime, viewList, viewsToOpen, index + 1); + }, + hidden: viewItem.hidden, + isMobileLayout: !!this.flowEngine.context.isMobileLayout, + triggerByRouter: true, + }); + + runtime.viewState[getKey(viewItem)] = { + destroy: (_force?: boolean) => destroyRef.current?.(), + update: (value: any) => updateRef.current?.(value), + navigation, + }; + } + + private ensureRouteModelContext(runtime: RoutePageRuntime) { + if (!runtime.routeModel.context.pageActive) { + runtime.routeModel.context.defineProperty('pageActive', { + value: observable.ref(false), + info: { + description: + 'Whether current page route is active (keep-alive). This is an observable.ref (use ctx.pageActive.value to read/write).', + detail: 'observable.ref', + }, + }); + } + + runtime.routeModel.context.defineProperty('currentRoute', { + get: () => runtime.meta.currentRoute || {}, + }); + + runtime.routeModel.context.defineProperty('refreshDesktopRoutes', { + get: () => runtime.meta.refreshDesktopRoutes, + }); + } + + private getOrCreateRouteModel(pageUid: string): FlowModel { + return ( + this.flowEngine.getModel(pageUid) || + this.flowEngine.createModel({ + uid: pageUid, + use: 'RouteModel', + }) + ); + } +} + +/** + * 将 pathname 解析结果和 pageUid 对齐,便于测试里复用。 + */ +export function toViewStack(pathname: string): ViewParam[] { + return parsePathnameToViewParams(pathname); +} diff --git a/packages/core/client/src/route-switch/antd/admin-layout/__tests__/admin-layout-model.test.tsx b/packages/core/client/src/route-switch/antd/admin-layout/__tests__/admin-layout-model.test.tsx new file mode 100644 index 00000000000..1ef8fc8f3a8 --- /dev/null +++ b/packages/core/client/src/route-switch/antd/admin-layout/__tests__/admin-layout-model.test.tsx @@ -0,0 +1,102 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { flowModelRendererSpy } = vi.hoisted(() => { + return { + flowModelRendererSpy: vi.fn(), + }; +}); + +vi.mock('@nocobase/flow-engine', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + FlowModelRenderer: (props: any) => { + flowModelRendererSpy(props); + return
; + }, + }; +}); + +import { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine'; +import { AdminLayout } from '..'; +import { AdminLayoutModel } from '../AdminLayoutModel'; + +describe('AdminLayout (phase-1 host)', () => { + beforeEach(() => { + flowModelRendererSpy.mockClear(); + }); + + it('should create AdminLayoutModel and pass it to FlowModelRenderer', async () => { + const engine = new FlowEngine(); + + render( + + + , + ); + + await waitFor(() => { + expect(flowModelRendererSpy).toHaveBeenCalled(); + }); + + const model = engine.getModel('admin-layout-model'); + expect(model).toBeInstanceOf(AdminLayoutModel); + expect(model.props.testFlag).toBe('first-render'); + expect(flowModelRendererSpy).toHaveBeenLastCalledWith(expect.objectContaining({ model })); + }); + + it('should reuse existing AdminLayoutModel instance', async () => { + const engine = new FlowEngine(); + const existingModel = engine.createModel({ + uid: 'admin-layout-model', + use: AdminLayoutModel, + props: { testFlag: 'existing' }, + }); + + render( + + + , + ); + + await waitFor(() => { + expect(existingModel.props.testFlag).toBe('reused-model'); + }); + + expect(engine.getModel('admin-layout-model')).toBe(existingModel); + expect(flowModelRendererSpy).toHaveBeenLastCalledWith(expect.objectContaining({ model: existingModel })); + }); + + it('should update model props on rerender', async () => { + const engine = new FlowEngine(); + const { rerender } = render( + + + , + ); + + const model = engine.getModel('admin-layout-model'); + expect(model).toBeTruthy(); + + rerender( + + + , + ); + + await waitFor(() => { + expect(model.props.testFlag).toBe('v2'); + }); + }); +}); diff --git a/packages/core/client/src/route-switch/antd/admin-layout/__tests__/admin-layout-route-coordinator.test.ts b/packages/core/client/src/route-switch/antd/admin-layout/__tests__/admin-layout-route-coordinator.test.ts new file mode 100644 index 00000000000..8ce79d06b69 --- /dev/null +++ b/packages/core/client/src/route-switch/antd/admin-layout/__tests__/admin-layout-route-coordinator.test.ts @@ -0,0 +1,281 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { navigateToSpy, setViewStackSpy } = vi.hoisted(() => { + return { + navigateToSpy: vi.fn(), + setViewStackSpy: vi.fn(), + }; +}); + +vi.mock('@nocobase/flow-engine', async (importOriginal) => { + const actual = await importOriginal(); + class MockViewNavigation { + constructor( + public context: any, + public viewStack: any[], + ) {} + navigateTo(params: any, options?: any) { + navigateToSpy({ params, options, viewStack: this.viewStack }); + } + setViewStack(viewStack: any[]) { + setViewStackSpy({ viewStack }); + this.viewStack = viewStack; + } + } + + return { + ...actual, + ViewNavigation: MockViewNavigation, + }; +}); + +import { FlowEngine, FlowModel } from '@nocobase/flow-engine'; +import { RouteModel } from '../../../../flow/models/base/RouteModel'; +import { AdminLayoutRouteCoordinator } from '../AdminLayoutRouteCoordinator'; + +const flushPromises = async (times = 3) => { + for (let i = 0; i < times; i += 1) { + await Promise.resolve(); + } +}; + +const createEngine = () => { + const engine = new FlowEngine(); + engine.registerModels({ RouteModel }); + return engine; +}; + +describe('AdminLayoutRouteCoordinator', () => { + beforeEach(() => { + navigateToSpy.mockClear(); + setViewStackSpy.mockClear(); + }); + + it('should split deep link to step navigation on first sync', () => { + const engine = createEngine(); + const coordinator = new AdminLayoutRouteCoordinator(engine); + const loadSpy = vi.spyOn(engine, 'loadModel'); + + coordinator.registerPage('page-1', { + active: true, + currentRoute: { title: 'Page 1' }, + }); + + coordinator.syncRoute({ + params: { name: 'page-1' }, + pathname: '/admin/page-1/view/popup-1', + }); + + expect(navigateToSpy).toHaveBeenCalledTimes(2); + expect(loadSpy).not.toHaveBeenCalled(); + }); + + it('should sync current route immediately after page registration', () => { + const engine = createEngine(); + engine.context.defineProperty('route', { + value: { + params: { name: 'page-1' }, + pathname: '/admin/page-1/view/popup-1', + }, + }); + const coordinator = new AdminLayoutRouteCoordinator(engine); + + coordinator.registerPage('page-1', { + active: true, + currentRoute: { title: 'Page 1' }, + }); + + expect(navigateToSpy).toHaveBeenCalledTimes(2); + }); + + it('should cleanup opened views and remove models', async () => { + const engine = createEngine(); + const coordinator = new AdminLayoutRouteCoordinator(engine); + const removeSpy = vi.spyOn(engine, 'removeModelWithSubModels'); + coordinator.setLayoutContentElement(document.createElement('div')); + + coordinator.registerPage('page-1', { + active: true, + currentRoute: { title: 'Page 1' }, + }); + + const routeModel = engine.getModel('page-1') as RouteModel; + const routeDestroySpy = vi.fn(); + routeModel.dispatchEvent = vi.fn(async (_eventName: string, args: any) => { + args.destroyRef.current = routeDestroySpy; + args.onOpen?.(); + }) as any; + + const popupModel = engine.createModel({ + uid: 'popup-1', + use: 'FlowModel', + }); + const popupDestroySpy = vi.fn(); + popupModel.dispatchEvent = vi.fn(async (_eventName: string, args: any) => { + args.destroyRef.current = popupDestroySpy; + }) as any; + + coordinator.syncRoute({ + params: { name: 'page-1' }, + pathname: '/admin/page-1/view/popup-1', + }); + coordinator.syncRoute({ + params: { name: 'page-1' }, + pathname: '/admin/page-1/view/popup-1', + }); + await flushPromises(); + + coordinator.cleanupPage('page-1'); + + expect(removeSpy).toHaveBeenCalledWith('page-1'); + expect(removeSpy).toHaveBeenCalledWith('popup-1'); + expect(routeDestroySpy).toHaveBeenCalled(); + expect(popupDestroySpy).toHaveBeenCalled(); + }); + + it('should stop opening stale views when route switched to another page', async () => { + const engine = createEngine(); + const coordinator = new AdminLayoutRouteCoordinator(engine); + + coordinator.registerPage('page-1', { + active: true, + currentRoute: { title: 'Page 1' }, + }); + coordinator.registerPage('page-2', { + active: true, + currentRoute: { title: 'Page 2' }, + }); + + const page1RouteModel = engine.getModel('page-1') as RouteModel; + const page2RouteModel = engine.getModel('page-2') as RouteModel; + page1RouteModel.dispatchEvent = vi.fn() as any; + page2RouteModel.dispatchEvent = vi.fn() as any; + + let resolveLoad: (model: FlowModel) => void; + const loadPromise = new Promise((resolve) => { + resolveLoad = resolve; + }); + vi.spyOn(engine, 'loadModel').mockImplementation(async ({ uid }: { uid: string }) => { + if (uid === 'popup-1') { + return loadPromise; + } + return undefined; + }); + + coordinator.syncRoute({ + params: { name: 'page-1' }, + pathname: '/admin/page-1/view/popup-1', + }); + coordinator.syncRoute({ + params: { name: 'page-1' }, + pathname: '/admin/page-1/view/popup-1', + }); + + coordinator.syncRoute({ + params: { name: 'page-2' }, + pathname: '/admin/page-2', + }); + + const popupModel = engine.createModel({ + uid: 'popup-1', + use: 'FlowModel', + }); + const popupDispatchSpy = vi.fn(); + popupModel.dispatchEvent = popupDispatchSpy as any; + + if (!resolveLoad) { + throw new Error('resolveLoad should be initialized'); + } + resolveLoad(popupModel); + await flushPromises(5); + + expect(popupDispatchSpy).not.toHaveBeenCalled(); + expect(page1RouteModel.dispatchEvent).not.toHaveBeenCalled(); + }); + + it('should not open views after page is unregistered while model loading is in flight', async () => { + const engine = createEngine(); + const coordinator = new AdminLayoutRouteCoordinator(engine); + + coordinator.registerPage('page-1', { + active: true, + currentRoute: { title: 'Page 1' }, + }); + + const page1RouteModel = engine.getModel('page-1') as RouteModel; + page1RouteModel.dispatchEvent = vi.fn() as any; + + let resolveLoad: (model: FlowModel) => void; + const loadPromise = new Promise((resolve) => { + resolveLoad = resolve; + }); + vi.spyOn(engine, 'loadModel').mockImplementation(async ({ uid }: { uid: string }) => { + if (uid === 'popup-1') { + return loadPromise; + } + return undefined; + }); + + coordinator.syncRoute({ + params: { name: 'page-1' }, + pathname: '/admin/page-1/view/popup-1', + }); + + // Unregister the page while model loading is still in flight + coordinator.unregisterPage('page-1'); + + const popupModel = engine.createModel({ + uid: 'popup-1', + use: 'FlowModel', + }); + const popupDispatchSpy = vi.fn(); + popupModel.dispatchEvent = popupDispatchSpy as any; + + if (!resolveLoad) { + throw new Error('resolveLoad should be initialized'); + } + resolveLoad(popupModel); + await flushPromises(5); + + expect(popupDispatchSpy).not.toHaveBeenCalled(); + expect(page1RouteModel.dispatchEvent).not.toHaveBeenCalled(); + }); + + it('should prefer page specific layout content element as target', async () => { + const engine = createEngine(); + const coordinator = new AdminLayoutRouteCoordinator(engine); + const globalElement = document.createElement('div'); + const pageElement = document.createElement('div'); + coordinator.setLayoutContentElement(globalElement); + + coordinator.registerPage('page-1', { + active: true, + currentRoute: { title: 'Page 1' }, + layoutContentElement: pageElement, + }); + + const routeModel = engine.getModel('page-1') as RouteModel; + const dispatchSpy = vi.fn(async (_eventName: string, args: any) => { + args.destroyRef.current = vi.fn(); + }); + routeModel.dispatchEvent = dispatchSpy as any; + + coordinator.syncRoute({ + params: { name: 'page-1' }, + pathname: '/admin/page-1', + }); + await flushPromises(); + + expect(dispatchSpy).toHaveBeenCalled(); + expect(dispatchSpy.mock.calls[0][1]?.target).toBe(pageElement); + }); +}); diff --git a/packages/core/client/src/route-switch/antd/admin-layout/index.tsx b/packages/core/client/src/route-switch/antd/admin-layout/index.tsx index 09858fda0e1..ef274660ad7 100644 --- a/packages/core/client/src/route-switch/antd/admin-layout/index.tsx +++ b/packages/core/client/src/route-switch/antd/admin-layout/index.tsx @@ -11,6 +11,7 @@ import { EllipsisOutlined, HighlightOutlined } from '@ant-design/icons'; import ProLayout, { RouteContext, RouteContextType } from '@ant-design/pro-layout'; import { HeaderViewProps } from '@ant-design/pro-layout/es/components/Header'; import { css } from '@emotion/css'; +import { FlowModelRenderer, useFlowEngine, useFlowEngineContext } from '@nocobase/flow-engine'; import { theme as antdTheme, Badge, ConfigProvider, Popover, Result, Tooltip } from 'antd'; import { createStyles, createGlobalStyle } from 'antd-style'; import React, { createContext, FC, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; @@ -62,7 +63,7 @@ import { MenuSchemaToolbar, ResetThemeTokenAndKeepAlgorithm } from './menuItemSe import { runAfterMobileMenuClosed } from './mobileMenuNavigation'; import { userCenterSettings } from './userCenterSettings'; import { useApplications } from './useApplications'; -import { useFlowEngineContext } from '@nocobase/flow-engine'; +import { AdminLayoutModel } from './AdminLayoutModel'; export * from './useDeleteRouteSchema'; export { KeepAlive, NocoBaseDesktopRouteType, useKeepAlive }; @@ -70,6 +71,8 @@ export { KeepAlive, NocoBaseDesktopRouteType, useKeepAlive }; export const NocoBaseRouteContext = createContext(null); NocoBaseRouteContext.displayName = 'NocoBaseRouteContext'; +const ADMIN_LAYOUT_MODEL_UID = 'admin-layout-model'; + export const CurrentRouteProvider: FC<{ uid: string }> = memo(({ children, uid }) => { const { allAccessRoutes } = useAllAccessDesktopRoutes(); const routeNode = useMemo(() => findRouteBySchemaUid(uid, allAccessRoutes), [uid, allAccessRoutes]); @@ -266,10 +269,24 @@ function isDvhSupported() { export const LayoutContent = () => { const style = useMemo(() => (isDvhSupported() ? mobileHeight : undefined), []); + const flowEngine = useFlowEngine(); + const layoutContentRef = useRef(null); + + useEffect(() => { + const model = flowEngine.getModel(ADMIN_LAYOUT_MODEL_UID); + model?.setLayoutContentElement(layoutContentRef.current); + return () => { + model?.setLayoutContentElement(null); + }; + }, [flowEngine]); /* Use the "nb-subpages-slot-without-header-and-side" class name to locate the position of the subpages */ return ( -
+
@@ -986,11 +1003,31 @@ export const AdminProvider = (props) => { }; export const AdminLayout = (props) => { - return ( + const flowEngine = useFlowEngine(); + const modelRef = useRef(null); + const modelChildren = ( ); + + if (!modelRef.current) { + modelRef.current = + flowEngine.getModel(ADMIN_LAYOUT_MODEL_UID) || + flowEngine.createModel({ + uid: ADMIN_LAYOUT_MODEL_UID, + use: AdminLayoutModel, + props: { ...props, children: modelChildren }, + }); + } + + const model = modelRef.current; + + useEffect(() => { + model.setProps({ ...props, children: modelChildren }); + }, [model, modelChildren, props]); + + return ; }; export class AdminLayoutPlugin extends Plugin { @@ -998,6 +1035,7 @@ export class AdminLayoutPlugin extends Plugin { await this.app.pm.add(RemoteSchemaTemplateManagerPlugin); } async load() { + this.app.flowEngine.registerModels({ AdminLayoutModel }); this.app.schemaSettingsManager.add(userCenterSettings); this.app.addComponents({ AdminLayout, AdminDynamicPage }); this.app.use(MobileLayoutProvider); diff --git a/packages/core/client/src/schema-component/antd/collection-select/__tests__/collection-select.test.tsx b/packages/core/client/src/schema-component/antd/collection-select/__tests__/collection-select.test.tsx index 82da6cb85d2..c09909d1cb0 100644 --- a/packages/core/client/src/schema-component/antd/collection-select/__tests__/collection-select.test.tsx +++ b/packages/core/client/src/schema-component/antd/collection-select/__tests__/collection-select.test.tsx @@ -44,113 +44,115 @@ describe('CollectionSelect', () => { expect(container).toMatchInlineSnapshot(`
-
+