refactor(core): host app and layout models (#8710)

* refactor(admin-layout): host layout model

* refactor(admin-layout): add route coordinator

* refactor(admin-layout): wire coordinator in model

* refactor(flow-route): delegate to layout model

* test(admin-layout): cover route coordinator

* test(flow-route): add bridge and fail-fast

* test(router): add v1 v2 compat regressions

* fix(admin-layout): restore v2 render target

* refactor(admin-layout): extract model file

* refactor(pm): host settings layout model

* refactor(application): host application model

* refactor(flow): simplify imports in FlowPage

* test(client): update stale snapshots

* fix(admin-layout): keep forceStop asserted through page unregister/cleanup (#8712)

* Initial plan

* fix(admin-layout): keep forceStop asserted through cleanupPage/unregisterPage

Co-authored-by: zhangzhonghe <38434641+zhangzhonghe@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: zhangzhonghe <38434641+zhangzhonghe@users.noreply.github.com>

* refactor(admin-layout): break model cycle

* refactor(pm): break settings model cycle

* fix(application): sync children before paint

* fix(pm): guard settings menu spread

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Zeke Zhang
2026-03-04 09:49:59 +08:00
committed by GitHub
co-authored by zhangzhonghe copilot-swe-agent[bot]
parent 3f0fc0f67b
commit 416360fed5
19 changed files with 1598 additions and 586 deletions
@@ -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<string, any> = {};
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<ApplicationModel>({
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 }) => (
<AppComponent app={this}>{children}</AppComponent>
);
const Root: FC<{ children?: React.ReactNode }> = ({ children }) => {
// 第一阶段仅切换根渲染宿主,保持 AppComponent 现有语义不变。
React.useLayoutEffect(() => {
this.model.setProps({ children });
}, [children]);
return (
<FlowEngineProvider engine={this.flowEngine}>
<FlowModelRenderer model={this.model} fallback={this.renderComponent('AppSpin', { app: this })} />
</FlowEngineProvider>
);
};
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 <AppComponent app={app}>{this.props.children}</AppComponent>;
}
}
@@ -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<typeof import('@nocobase/flow-engine')>();
return {
...actual,
FlowModelRenderer: (props: any) => {
flowModelRendererSpy(props);
return <div data-testid="flow-model-renderer" />;
},
};
});
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(<Root />);
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(
<Root>
<div data-testid="child">child</div>
</Root>,
);
await waitFor(() => {
expect(app.model.props.children).toBeTruthy();
});
});
});
@@ -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);
};
+40 -217
View File
@@ -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<ViewItem[]>([]);
const hasStepNavigatedRef = useRef(false);
const pageUidRef = useRef(route?.params?.name);
const { designable } = useDesignable();
const { active } = useKeepAlive();
const forceStopRef = useRef(false);
const layoutContentRef = useRef<HTMLDivElement>(null);
const pageUid = pageUidRef.current;
const adminLayoutModel = flowEngine.getModel<AdminLayoutModel>('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<boolean> (use ctx.pageActive.value to read/write).',
detail: 'observable.ref<boolean>',
},
});
}, [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 <div ref={layoutContentRef} />;
};
@@ -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<typeof import('../../route-switch')>();
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(
<FlowEngineProvider engine={engine}>
<MemoryRouter initialEntries={['/flow/test']}>
<Routes>
<Route path="/flow/:name" element={<FlowRoute />} />
</Routes>
</MemoryRouter>
</FlowEngineProvider>,
);
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(
<FlowEngineProvider engine={engine}>
<MemoryRouter initialEntries={[`/flow/${routeName}`]}>
<MemoryRouter initialEntries={['/flow/test-page']}>
<Routes>
<Route path="/flow/:name" element={<FlowRoute />} />
</Routes>
@@ -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(
<FlowEngineProvider engine={engine}>
<MemoryRouter initialEntries={['/flow/test-page']}>
<Routes>
<Route path="/flow/:name" element={<FlowRoute />} />
</Routes>
</MemoryRouter>
</FlowEngineProvider>,
);
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(
<FlowEngineProvider engine={engine}>
<MemoryRouter initialEntries={['/flow/test-page']}>
<Routes>
<Route path="/flow/:name" element={<FlowRoute />} />
</Routes>
</MemoryRouter>
</FlowEngineProvider>,
);
}).toThrowError(/admin-layout-model/);
} finally {
consoleErrorSpy.mockRestore();
}
});
});
@@ -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');
});
});
@@ -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}</>;
}
}
+33 -4
View File
@@ -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<any>({});
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 <Navigate replace to={getFirstDeepChildPath(settings)} />;
}
@@ -258,3 +263,27 @@ export const AdminSettingsLayout = () => {
</div>
);
};
export const AdminSettingsLayout = (props) => {
const flowEngine = useFlowEngine();
const modelRef = useRef<AdminSettingsLayoutModel>(null);
const modelChildren = <InternalAdminSettingsLayout {...props} />;
if (!modelRef.current) {
modelRef.current =
flowEngine.getModel<AdminSettingsLayoutModel>(ADMIN_SETTINGS_LAYOUT_MODEL_UID) ||
flowEngine.createModel<AdminSettingsLayoutModel>({
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 <FlowModelRenderer model={model} />;
};
@@ -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<typeof import('@nocobase/flow-engine')>();
return {
...actual,
FlowModelRenderer: (props: any) => {
flowModelRendererSpy(props);
return <div data-testid="flow-model-renderer" />;
},
};
});
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(
<FlowEngineProvider engine={engine}>
<AdminSettingsLayout testFlag="first-render" />
</FlowEngineProvider>,
);
await waitFor(() => {
expect(flowModelRendererSpy).toHaveBeenCalled();
});
const model = engine.getModel<AdminSettingsLayoutModel>('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<AdminSettingsLayoutModel>({
uid: 'admin-settings-layout-model',
use: AdminSettingsLayoutModel,
props: { testFlag: 'existing' },
});
render(
<FlowEngineProvider engine={engine}>
<AdminSettingsLayout testFlag="reused-model" />
</FlowEngineProvider>,
);
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(
<FlowEngineProvider engine={engine}>
<AdminSettingsLayout testFlag="v1" />
</FlowEngineProvider>,
);
const model = engine.getModel<AdminSettingsLayoutModel>('admin-settings-layout-model');
expect(model).toBeTruthy();
rerender(
<FlowEngineProvider engine={engine}>
<AdminSettingsLayout testFlag="v2" />
</FlowEngineProvider>,
);
await waitFor(() => {
expect(model.props.testFlag).toBe('v2');
});
});
});
+3
View File
@@ -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();
@@ -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<string, RoutePageMeta>();
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<RoutePageMeta>) {
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}</>;
}
}
@@ -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<string, any>;
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<string, ViewRuntimeState>;
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<string, RoutePageRuntime>();
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<RoutePageMeta>) {
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<boolean> (use ctx.pageActive.value to read/write).',
detail: 'observable.ref<boolean>',
},
});
}
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);
}
@@ -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<typeof import('@nocobase/flow-engine')>();
return {
...actual,
FlowModelRenderer: (props: any) => {
flowModelRendererSpy(props);
return <div data-testid="flow-model-renderer" />;
},
};
});
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(
<FlowEngineProvider engine={engine}>
<AdminLayout testFlag="first-render" />
</FlowEngineProvider>,
);
await waitFor(() => {
expect(flowModelRendererSpy).toHaveBeenCalled();
});
const model = engine.getModel<AdminLayoutModel>('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<AdminLayoutModel>({
uid: 'admin-layout-model',
use: AdminLayoutModel,
props: { testFlag: 'existing' },
});
render(
<FlowEngineProvider engine={engine}>
<AdminLayout testFlag="reused-model" />
</FlowEngineProvider>,
);
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(
<FlowEngineProvider engine={engine}>
<AdminLayout testFlag="v1" />
</FlowEngineProvider>,
);
const model = engine.getModel<AdminLayoutModel>('admin-layout-model');
expect(model).toBeTruthy();
rerender(
<FlowEngineProvider engine={engine}>
<AdminLayout testFlag="v2" />
</FlowEngineProvider>,
);
await waitFor(() => {
expect(model.props.testFlag).toBe('v2');
});
});
});
@@ -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<typeof import('@nocobase/flow-engine')>();
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<RouteModel>('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<FlowModel>({
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<RouteModel>('page-1') as RouteModel;
const page2RouteModel = engine.getModel<RouteModel>('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<FlowModel>((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<FlowModel>({
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<RouteModel>('page-1') as RouteModel;
page1RouteModel.dispatchEvent = vi.fn() as any;
let resolveLoad: (model: FlowModel) => void;
const loadPromise = new Promise<FlowModel>((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<FlowModel>({
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<RouteModel>('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);
});
});
@@ -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<NocoBaseDesktopRoute | null>(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<HTMLDivElement>(null);
useEffect(() => {
const model = flowEngine.getModel<AdminLayoutModel>(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 (
<div className={`${layoutContentClass} nb-subpages-slot-without-header-and-side`} style={style}>
<div
ref={layoutContentRef}
className={`${layoutContentClass} nb-subpages-slot-without-header-and-side`}
style={style}
>
<div style={pageContentStyle}>
<Outlet />
<ShowTipWhenNoPages />
@@ -986,11 +1003,31 @@ export const AdminProvider = (props) => {
};
export const AdminLayout = (props) => {
return (
const flowEngine = useFlowEngine();
const modelRef = useRef<AdminLayoutModel>(null);
const modelChildren = (
<AdminProvider>
<InternalAdminLayout {...props} />
</AdminProvider>
);
if (!modelRef.current) {
modelRef.current =
flowEngine.getModel<AdminLayoutModel>(ADMIN_LAYOUT_MODEL_UID) ||
flowEngine.createModel<AdminLayoutModel>({
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 <FlowModelRenderer model={model} />;
};
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);
@@ -44,113 +44,115 @@ describe('CollectionSelect', () => {
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="css-dev-only-do-not-override-nlgwwc ant-app"
style="height: 100%;"
>
<div>
<div
aria-label="block-item-demo title"
class="nb-block-item nb-form-item css-1qomz6v ant-nb-block-item css-dev-only-do-not-override-nlgwwc"
role="button"
class="css-dev-only-do-not-override-nlgwwc ant-app"
style="height: 100%;"
>
<div
class="css-hd29i9 ant-formily-item ant-formily-item-layout-horizontal ant-formily-item-feedback-layout-loose ant-formily-item-label-align-right ant-formily-item-control-align-left css-dev-only-do-not-override-nlgwwc"
aria-label="block-item-demo title"
class="nb-block-item nb-form-item css-1qomz6v ant-nb-block-item css-dev-only-do-not-override-nlgwwc"
role="button"
>
<div
class="ant-formily-item-label"
class="css-hd29i9 ant-formily-item ant-formily-item-layout-horizontal ant-formily-item-feedback-layout-loose ant-formily-item-label-align-right ant-formily-item-control-align-left css-dev-only-do-not-override-nlgwwc"
>
<div
class="ant-formily-item-label-content"
>
<span>
<label>
demo title
</label>
</span>
</div>
<span
class="ant-formily-item-colon"
>
:
</span>
</div>
<div
class="ant-formily-item-control"
>
<div
class="ant-formily-item-control-content"
class="ant-formily-item-label"
>
<div
class="ant-formily-item-control-content-component"
class="ant-formily-item-label-content"
>
<span>
<label>
demo title
</label>
</span>
</div>
<span
class="ant-formily-item-colon"
>
:
</span>
</div>
<div
class="ant-formily-item-control"
>
<div
class="ant-formily-item-control-content"
>
<div
class="ant-select ant-select-outlined css-dev-only-do-not-override-nlgwwc ant-select-focused ant-select-single ant-select-show-arrow ant-select-show-search"
data-testid="select-collection"
role="button"
class="ant-formily-item-control-content-component"
>
<span
aria-live="polite"
style="width: 0px; height: 0px; position: absolute; overflow: hidden; opacity: 0;"
>
Users
</span>
<div
class="ant-select-selector"
class="ant-select ant-select-outlined css-dev-only-do-not-override-nlgwwc ant-select-focused ant-select-single ant-select-show-arrow ant-select-show-search"
data-testid="select-collection"
role="button"
>
<span
class="ant-select-selection-wrap"
aria-live="polite"
style="width: 0px; height: 0px; position: absolute; overflow: hidden; opacity: 0;"
>
Users
</span>
<div
class="ant-select-selector"
>
<span
class="ant-select-selection-search"
class="ant-select-selection-wrap"
>
<input
aria-autocomplete="list"
aria-controls="rc_select_TEST_OR_SSR_list"
aria-expanded="false"
aria-haspopup="listbox"
aria-owns="rc_select_TEST_OR_SSR_list"
autocomplete="off"
class="ant-select-selection-search-input"
id="rc_select_TEST_OR_SSR"
role="button"
type="search"
value=""
/>
<span
class="ant-select-selection-search"
>
<input
aria-autocomplete="list"
aria-controls="rc_select_TEST_OR_SSR_list"
aria-expanded="false"
aria-haspopup="listbox"
aria-owns="rc_select_TEST_OR_SSR_list"
autocomplete="off"
class="ant-select-selection-search-input"
id="rc_select_TEST_OR_SSR"
role="button"
type="search"
value=""
/>
</span>
<span
class="ant-select-selection-item"
title="Users"
>
Users
</span>
</span>
</div>
<span
aria-hidden="true"
class="ant-select-arrow"
style="user-select: none;"
unselectable="on"
>
<span
class="ant-select-selection-item"
title="Users"
aria-label="down"
class="anticon anticon-down ant-select-suffix"
role="img"
>
Users
<svg
aria-hidden="true"
data-icon="down"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M884 256h-75c-5.1 0-9.9 2.5-12.9 6.6L512 654.2 227.9 262.6c-3-4.1-7.8-6.6-12.9-6.6h-75c-6.5 0-10.3 7.4-6.5 12.7l352.6 486.1c12.8 17.6 39 17.6 51.7 0l352.6-486.1c3.9-5.3.1-12.7-6.4-12.7z"
/>
</svg>
</span>
</span>
</div>
<span
aria-hidden="true"
class="ant-select-arrow"
style="user-select: none;"
unselectable="on"
>
<span
aria-label="down"
class="anticon anticon-down ant-select-suffix"
role="img"
>
<svg
aria-hidden="true"
data-icon="down"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M884 256h-75c-5.1 0-9.9 2.5-12.9 6.6L512 654.2 227.9 262.6c-3-4.1-7.8-6.6-12.9-6.6h-75c-6.5 0-10.3 7.4-6.5 12.7l352.6 486.1c12.8 17.6 39 17.6 51.7 0l352.6-486.1c3.9-5.3.1-12.7-6.4-12.7z"
/>
</svg>
</span>
</span>
</div>
</div>
</div>
@@ -185,51 +187,53 @@ describe('CollectionSelect', () => {
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="css-dev-only-do-not-override-nlgwwc ant-app"
style="height: 100%;"
>
<div>
<div
aria-label="block-item-demo title"
class="nb-block-item nb-form-item css-1qomz6v ant-nb-block-item css-dev-only-do-not-override-nlgwwc"
role="button"
class="css-dev-only-do-not-override-nlgwwc ant-app"
style="height: 100%;"
>
<div
class="css-hd29i9 ant-formily-item ant-formily-item-layout-horizontal ant-formily-item-feedback-layout-loose ant-formily-item-label-align-right ant-formily-item-control-align-left css-dev-only-do-not-override-nlgwwc"
aria-label="block-item-demo title"
class="nb-block-item nb-form-item css-1qomz6v ant-nb-block-item css-dev-only-do-not-override-nlgwwc"
role="button"
>
<div
class="ant-formily-item-label"
class="css-hd29i9 ant-formily-item ant-formily-item-layout-horizontal ant-formily-item-feedback-layout-loose ant-formily-item-label-align-right ant-formily-item-control-align-left css-dev-only-do-not-override-nlgwwc"
>
<div
class="ant-formily-item-label-content"
>
<span>
<label>
demo title
</label>
</span>
</div>
<span
class="ant-formily-item-colon"
>
:
</span>
</div>
<div
class="ant-formily-item-control"
>
<div
class="ant-formily-item-control-content"
class="ant-formily-item-label"
>
<div
class="ant-formily-item-control-content-component"
class="ant-formily-item-label-content"
>
<div>
<span
class="ant-tag css-dev-only-do-not-override-nlgwwc"
>
Users
</span>
<span>
<label>
demo title
</label>
</span>
</div>
<span
class="ant-formily-item-colon"
>
:
</span>
</div>
<div
class="ant-formily-item-control"
>
<div
class="ant-formily-item-control-content"
>
<div
class="ant-formily-item-control-content-component"
>
<div>
<span
class="ant-tag css-dev-only-do-not-override-nlgwwc"
>
Users
</span>
</div>
</div>
</div>
</div>
@@ -25,26 +25,28 @@ describe('ColorPicker', () => {
});
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="css-dev-only-do-not-override-nlgwwc ant-app"
style="height: 100%;"
>
<div>
<div
aria-label="color-picker-normal"
role="button"
style="display: inline-block;"
class="css-dev-only-do-not-override-nlgwwc ant-app"
style="height: 100%;"
>
<div
aria-describedby="test-id"
class="ant-color-picker-trigger css-dev-only-do-not-override-nlgwwc"
aria-label="color-picker-normal"
role="button"
style="display: inline-block;"
>
<div
class="ant-color-picker-color-block"
aria-describedby="test-id"
class="ant-color-picker-trigger css-dev-only-do-not-override-nlgwwc"
>
<div
class="ant-color-picker-color-block-inner"
style="background: rgb(139, 187, 17);"
/>
class="ant-color-picker-color-block"
>
<div
class="ant-color-picker-color-block-inner"
style="background: rgb(139, 187, 17);"
/>
</div>
</div>
</div>
</div>
@@ -90,26 +92,28 @@ describe('ColorPicker', () => {
});
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="css-dev-only-do-not-override-nlgwwc ant-app"
style="height: 100%;"
>
<div>
<div
aria-label="color-picker-read-pretty"
class="ant-description-color-picker css-gy8kge"
role="button"
class="css-dev-only-do-not-override-nlgwwc ant-app"
style="height: 100%;"
>
<div
aria-describedby="test-id"
class="ant-color-picker-trigger ant-color-picker-sm css-dev-only-do-not-override-nlgwwc ant-color-picker-trigger-disabled"
aria-label="color-picker-read-pretty"
class="ant-description-color-picker css-gy8kge"
role="button"
>
<div
class="ant-color-picker-color-block"
aria-describedby="test-id"
class="ant-color-picker-trigger ant-color-picker-sm css-dev-only-do-not-override-nlgwwc ant-color-picker-trigger-disabled"
>
<div
class="ant-color-picker-color-block-inner"
style="background: rgb(139, 187, 17);"
/>
class="ant-color-picker-color-block"
>
<div
class="ant-color-picker-color-block-inner"
style="background: rgb(139, 187, 17);"
/>
</div>
</div>
</div>
</div>
@@ -20,101 +20,103 @@ describe('Pagination', () => {
});
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="css-dev-only-do-not-override-nlgwwc ant-app"
style="height: 100%;"
>
<div>
<ul
class="ant-pagination css-dev-only-do-not-override-nlgwwc"
>
<li
aria-disabled="true"
class="ant-pagination-prev ant-pagination-disabled"
title="Previous Page"
<div>
<div
class="css-dev-only-do-not-override-nlgwwc ant-app"
style="height: 100%;"
>
<div>
<ul
class="ant-pagination css-dev-only-do-not-override-nlgwwc"
>
<button
class="ant-pagination-item-link"
disabled=""
tabindex="-1"
type="button"
<li
aria-disabled="true"
class="ant-pagination-prev ant-pagination-disabled"
title="Previous Page"
>
<span
aria-label="left"
class="anticon anticon-left"
role="img"
<button
class="ant-pagination-item-link"
disabled=""
tabindex="-1"
type="button"
>
<svg
aria-hidden="true"
data-icon="left"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
<span
aria-label="left"
class="anticon anticon-left"
role="img"
>
<path
d="M724 218.3V141c0-6.7-7.7-10.4-12.9-6.3L260.3 486.8a31.86 31.86 0 000 50.3l450.8 352.1c5.3 4.1 12.9.4 12.9-6.3v-77.3c0-4.9-2.3-9.6-6.1-12.6l-360-281 360-281.1c3.8-3 6.1-7.7 6.1-12.6z"
/>
</svg>
</span>
</button>
</li>
<li
class="ant-pagination-item ant-pagination-item-1 ant-pagination-item-active"
tabindex="0"
title="1"
>
<a
rel="nofollow"
<svg
aria-hidden="true"
data-icon="left"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M724 218.3V141c0-6.7-7.7-10.4-12.9-6.3L260.3 486.8a31.86 31.86 0 000 50.3l450.8 352.1c5.3 4.1 12.9.4 12.9-6.3v-77.3c0-4.9-2.3-9.6-6.1-12.6l-360-281 360-281.1c3.8-3 6.1-7.7 6.1-12.6z"
/>
</svg>
</span>
</button>
</li>
<li
class="ant-pagination-item ant-pagination-item-1 ant-pagination-item-active"
tabindex="0"
title="1"
>
1
</a>
</li>
<li
class="ant-pagination-item ant-pagination-item-2"
tabindex="0"
title="2"
>
<a
rel="nofollow"
>
2
</a>
</li>
<li
aria-disabled="false"
class="ant-pagination-next"
tabindex="0"
title="Next Page"
>
<button
class="ant-pagination-item-link"
tabindex="-1"
type="button"
>
<span
aria-label="right"
class="anticon anticon-right"
role="img"
<a
rel="nofollow"
>
<svg
aria-hidden="true"
data-icon="right"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
1
</a>
</li>
<li
class="ant-pagination-item ant-pagination-item-2"
tabindex="0"
title="2"
>
<a
rel="nofollow"
>
2
</a>
</li>
<li
aria-disabled="false"
class="ant-pagination-next"
tabindex="0"
title="Next Page"
>
<button
class="ant-pagination-item-link"
tabindex="-1"
type="button"
>
<span
aria-label="right"
class="anticon anticon-right"
role="img"
>
<path
d="M765.7 486.8L314.9 134.7A7.97 7.97 0 00302 141v77.3c0 4.9 2.3 9.6 6.1 12.6l360 281.1-360 281.1c-3.9 3-6.1 7.7-6.1 12.6V883c0 6.7 7.7 10.4 12.9 6.3l450.8-352.1a31.96 31.96 0 000-50.4z"
/>
</svg>
</span>
</button>
</li>
</ul>
<svg
aria-hidden="true"
data-icon="right"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M765.7 486.8L314.9 134.7A7.97 7.97 0 00302 141v77.3c0 4.9 2.3 9.6 6.1 12.6l360 281.1-360 281.1c-3.9 3-6.1 7.7-6.1 12.6V883c0 6.7 7.7 10.4 12.9 6.3l450.8-352.1a31.96 31.96 0 000-50.4z"
/>
</svg>
</span>
</button>
</li>
</ul>
</div>
</div>
</div>
</div>
@@ -130,10 +132,12 @@ describe('Pagination', () => {
});
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="css-dev-only-do-not-override-nlgwwc ant-app"
style="height: 100%;"
/>
<div>
<div
class="css-dev-only-do-not-override-nlgwwc ant-app"
style="height: 100%;"
/>
</div>
</div>
`);
});
@@ -19,46 +19,48 @@ describe('UnixTimestamp', () => {
});
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="css-dev-only-do-not-override-nlgwwc ant-app"
style="height: 100%;"
>
<div>
<div
class="ant-picker ant-picker-outlined css-dev-only-do-not-override-nlgwwc"
class="css-dev-only-do-not-override-nlgwwc ant-app"
style="height: 100%;"
>
<div
class="ant-picker-input"
class="ant-picker ant-picker-outlined css-dev-only-do-not-override-nlgwwc"
>
<input
aria-invalid="false"
autocomplete="off"
placeholder="Select date"
size="12"
value=""
/>
<span
class="ant-picker-suffix"
<div
class="ant-picker-input"
>
<input
aria-invalid="false"
autocomplete="off"
placeholder="Select date"
size="12"
value=""
/>
<span
aria-label="calendar"
class="anticon anticon-calendar"
role="img"
class="ant-picker-suffix"
>
<svg
aria-hidden="true"
data-icon="calendar"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
<span
aria-label="calendar"
class="anticon anticon-calendar"
role="img"
>
<path
d="M880 184H712v-64c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H384v-64c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H144c-17.7 0-32 14.3-32 32v664c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V216c0-17.7-14.3-32-32-32zm-40 656H184V460h656v380zM184 392V256h128v48c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8v-48h256v48c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8v-48h128v136H184z"
/>
</svg>
<svg
aria-hidden="true"
data-icon="calendar"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M880 184H712v-64c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H384v-64c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H144c-17.7 0-32 14.3-32 32v664c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V216c0-17.7-14.3-32-32-32zm-40 656H184V460h656v380zM184 392V256h128v48c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8v-48h256v48c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8v-48h128v136H184z"
/>
</svg>
</span>
</span>
</span>
</div>
</div>
</div>
</div>
@@ -75,14 +77,16 @@ describe('UnixTimestamp', () => {
expect(screen.getByText('2024-04-11')).toBeInTheDocument();
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="css-dev-only-do-not-override-nlgwwc ant-app"
style="height: 100%;"
>
<div>
<div
class="ant-description-date-picker"
class="css-dev-only-do-not-override-nlgwwc ant-app"
style="height: 100%;"
>
2024-04-11
<div
class="ant-description-date-picker"
>
2024-04-11
</div>
</div>
</div>
</div>