mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-21 13:52:17 +08:00
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:
co-authored by
zhangzhonghe
copilot-swe-agent[bot]
parent
3f0fc0f67b
commit
416360fed5
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}</>;
|
||||
}
|
||||
}
|
||||
+331
@@ -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);
|
||||
}
|
||||
+102
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+281
@@ -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);
|
||||
|
||||
+122
-118
@@ -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>
|
||||
|
||||
+30
-26
@@ -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>
|
||||
|
||||
+96
-92
@@ -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>
|
||||
`);
|
||||
});
|
||||
|
||||
+41
-37
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user