Merge branch 'next' into develop

This commit is contained in:
Zeke Zhang 2025-03-06 11:58:38 +08:00
commit 2cf3486b56
70 changed files with 2075 additions and 1130 deletions

View File

@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [v1.5.21](https://github.com/nocobase/nocobase/compare/v1.5.20...v1.5.21) - 2025-03-05
### 🚀 Improvements
- **[Workflow]** Lazy load job result for better performance ([#6344](https://github.com/nocobase/nocobase/pull/6344)) by @mytharcher
- **[Workflow: Aggregate node]** Add round process for aggregated number based on double type ([#6358](https://github.com/nocobase/nocobase/pull/6358)) by @mytharcher
### 🐛 Bug Fixes
- **[client]**
- subform components not aligning with main form when label is hidden ([#6357](https://github.com/nocobase/nocobase/pull/6357)) by @katherinehhh
- association block not rendering in popup within collection inheritance ([#6303](https://github.com/nocobase/nocobase/pull/6303)) by @katherinehhh
- Fix error thrown when creating file collection ([#6363](https://github.com/nocobase/nocobase/pull/6363)) by @mytharcher
- **[Workflow]** Fix acl for getting job ([#6352](https://github.com/nocobase/nocobase/pull/6352)) by @mytharcher
## [v1.5.20](https://github.com/nocobase/nocobase/compare/v1.5.19...v1.5.20) - 2025-03-03
### 🐛 Bug Fixes

View File

@ -5,6 +5,25 @@
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
并且本项目遵循 [语义化版本](https://semver.org/spec/v2.0.0.html)。
## [v1.5.21](https://github.com/nocobase/nocobase/compare/v1.5.20...v1.5.21) - 2025-03-05
### 🚀 优化
- **[工作流]** 后置节点结果加载以提升执行记录画布性能 ([#6344](https://github.com/nocobase/nocobase/pull/6344)) by @mytharcher
- **[工作流:聚合查询节点]** 对聚合后的数字进行小数四舍五入的处理 ([#6358](https://github.com/nocobase/nocobase/pull/6358)) by @mytharcher
### 🐛 修复
- **[client]**
- 子表单隐藏字段标题时字段组件与主表单中的组件未对齐 ([#6357](https://github.com/nocobase/nocobase/pull/6357)) by @katherinehhh
- 数据表继承模型中关系区块在弹窗中未显示 ([#6303](https://github.com/nocobase/nocobase/pull/6303)) by @katherinehhh
- 修复创建文件表时的报错 ([#6363](https://github.com/nocobase/nocobase/pull/6363)) by @mytharcher
- **[工作流]** 修复加载节点结果的权限问题 ([#6352](https://github.com/nocobase/nocobase/pull/6352)) by @mytharcher
## [v1.5.20](https://github.com/nocobase/nocobase/compare/v1.5.19...v1.5.20) - 2025-03-03
### 🐛 修复

View File

@ -17,7 +17,7 @@ export default defineConfig({
title: 'Loading...',
devtool: process.env.NODE_ENV === 'development' ? 'source-map' : false,
favicons: [`${appPublicPath}favicon_no_exist.ico`], // 设置一个不存在的 favicon防止显示 Umi 默认的 favicon
metas: [{ name: 'viewport', content: 'initial-scale=0.1' }],
metas: [{ name: 'viewport', content: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no' }],
links: [{ rel: 'stylesheet', href: `${appPublicPath}global.css` }],
headScripts: [
{

View File

@ -9,7 +9,7 @@
"@ahooksjs/use-url-state": "3.5.1",
"@ant-design/cssinjs": "^1.11.1",
"@ant-design/icons": "^5.6.1",
"@ant-design/pro-layout": "^7.16.11",
"@ant-design/pro-layout": "^7.22.1",
"@antv/g2plot": "^2.4.18",
"@budibase/handlebars-helpers": "^0.14.0",
"@ctrl/tinycolor": "^3.6.0",

View File

@ -321,7 +321,7 @@ export const ACLActionProvider = (props) => {
() => actionPath && parseAction(actionPath, { schema, recordPkValue }),
[parseAction, actionPath, schema, recordPkValue],
);
if (uiButtonSchemasBlacklist.includes(currentUid)) {
if (uiButtonSchemasBlacklist?.includes(currentUid)) {
return <ACLActionParamsContext.Provider value={false}>{props.children}</ACLActionParamsContext.Provider>;
}
if (!actionPath) {

View File

@ -11,7 +11,8 @@ import { useFieldSchema } from '@formily/react';
import { useMemo, useContext } from 'react';
import { useBlockTemplateContext } from '../../schema-templates/BlockTemplateProvider';
import { BlockItemCardContext } from '../../schema-component/antd/block-item/BlockItemCard';
import { useCurrentRoute } from '../../route-switch';
import { useAllAccessDesktopRoutes, findRouteBySchemaUid } from '../../route-switch/antd/admin-layout';
import { useCurrentPageUid } from '../../application/CustomRouterContextProvider';
export const useBlockHeightProps = () => {
const fieldSchema = useFieldSchema();
@ -20,13 +21,19 @@ export const useBlockHeightProps = () => {
const pageSchema = useMemo(() => getPageSchema(blockTemplateSchema || fieldSchema), []);
const { disablePageHeader, enablePageTabs, hidePageTitle } = pageSchema?.['x-component-props'] || {};
const { titleHeight } = useContext(BlockItemCardContext) || ({} as any);
const currentRoute = useCurrentRoute();
const { allAccessRoutes } = useAllAccessDesktopRoutes();
const currentPageUid = useCurrentPageUid();
const currentRoute = useMemo(
() => findRouteBySchemaUid(currentPageUid, allAccessRoutes),
[currentPageUid, allAccessRoutes],
);
return {
heightProps: {
...cardItemSchema?.['x-component-props'],
title: cardItemSchema?.['x-component-props']?.title || cardItemSchema?.['x-component-props']?.description,
disablePageHeader,
enablePageTabs: currentRoute.enableTabs || enablePageTabs,
enablePageTabs: currentRoute?.enableTabs || enablePageTabs,
hidePageTitle,
titleHeight: titleHeight,
},

View File

@ -48,6 +48,7 @@ export const CSSVariableProvider = ({ children }) => {
document.body.style.setProperty('--colorBgSettingsHover', token.colorBgSettingsHover);
document.body.style.setProperty('--colorTemplateBgSettingsHover', token.colorTemplateBgSettingsHover);
document.body.style.setProperty('--colorBorderSettingsHover', token.colorBorderSettingsHover);
document.body.style.setProperty('--colorBgMenuItemSelected', token.colorBgHeaderMenuActive);
// 设置登录页面的背景色
document.body.style.setProperty('background-color', token.colorBgContainer);

View File

@ -27,9 +27,21 @@
.rc-virtual-list-scrollbar-thumb {
background: var(--colorBgScrollBar) !important;
}
.rc-virtual-list-scrollbar-thumb:hover {
background: var(--colorBgScrollBarHover) !important;
}
.rc-virtual-list-scrollbar-thumb:active {
background: var(--colorBgScrollBarActive) !important;
}
// Fix the style of the top collapsed menu button dropdown
.ant-menu-submenu-popup {
backdrop-filter: none;
}
// Fix the style of the top collapsed menu button dropdown when clicking
.ant-menu-item.ant-menu-item-only-child.ant-pro-base-menu-horizontal-menu-item:active {
background-color: var(--colorBgMenuItemSelected) !important;
}

View File

@ -71,16 +71,14 @@ export * from './modules/blocks/data-blocks/table';
export * from './modules/blocks/data-blocks/table-selector';
export * from './modules/blocks/index';
export * from './modules/blocks/useParentRecordCommon';
export { getGroupMenuSchema } from './modules/menu/GroupItem';
export { getLinkMenuSchema } from './modules/menu/LinkMenuItem';
export { getPageMenuSchema } from './modules/menu/PageMenuItem';
export { getPageMenuSchema, useInsertPageSchema } from './modules/menu/PageMenuItem';
export { OpenModeProvider, useOpenModeContext } from './modules/popup/OpenModeProvider';
export { PopupContextProvider } from './modules/popup/PopupContextProvider';
export { usePopupUtils } from './modules/popup/usePopupUtils';
export { VariablePopupRecordProvider } from './modules/variable/variablesProvider/VariablePopupRecordProvider';
export { useCurrentPopupRecord } from './modules/variable/variablesProvider/VariablePopupRecordProvider';
export { showFileName } from './modules/fields/component/FileManager/fileManagerComponentFieldSettings';
export { useCurrentPopupRecord } from './modules/variable/variablesProvider/VariablePopupRecordProvider';
export { languageCodes } from './locale';

View File

@ -884,6 +884,8 @@
"After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.": "After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.",
"If selected, the page will display Tab pages.": "If selected, the page will display Tab pages.",
"If selected, the route will be displayed in the menu.": "If selected, the route will be displayed in the menu.",
"Are you sure you want to hide this tab?": "Are you sure you want to hide this tab?",
"After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.": "After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.",
"Deprecated": "Deprecated",
"The following old template features have been deprecated and will be removed in next version.": "The following old template features have been deprecated and will be removed in next version."
}

View File

@ -801,6 +801,8 @@
"After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.": "Después de ocultar, este menú ya no aparecerá en la barra de menú. Para mostrarlo de nuevo, debe ir a la página de administración de rutas para configurarlo.",
"If selected, the page will display Tab pages.": "Si se selecciona, la página mostrará páginas de pestañas.",
"If selected, the route will be displayed in the menu.": "Si se selecciona, la ruta se mostrará en el menú.",
"Are you sure you want to hide this tab?": "¿Estás seguro de que quieres ocultar esta pestaña?",
"After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.": "Después de ocultar, esta pestaña ya no aparecerá en la barra de pestañas. Para mostrarla de nuevo, deberás ir a la página de gestión de rutas para configurarla.",
"Deprecated": "Obsoleto",
"The following old template features have been deprecated and will be removed in next version.": "Las siguientes características de plantilla antigua han quedado obsoletas y se eliminarán en la próxima versión."
}

View File

@ -821,6 +821,8 @@
"After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.": "Après avoir masqué, ce menu ne sera plus affiché dans la barre de menu. Pour le réafficher, vous devez aller à la page de gestion des routes pour le configurer.",
"If selected, the page will display Tab pages.": "Si sélectionné, la page affichera des onglets.",
"If selected, the route will be displayed in the menu.": "Si sélectionné, la route sera affichée dans le menu.",
"Are you sure you want to hide this tab?": "Êtes-vous sûr de vouloir masquer cet onglet ?",
"After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.": "Après avoir masqué, cette tab ne sera plus affichée dans la barre de tab. Pour la montrer à nouveau, vous devez vous rendre sur la page de gestion des routes pour la configurer.",
"Deprecated": "Déprécié",
"The following old template features have been deprecated and will be removed in next version.": "Les fonctionnalités des anciens modèles ont été dépréciées et seront supprimées dans la prochaine version."
}

View File

@ -1039,6 +1039,8 @@
"After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.": "非表示にすると、このメニューはメニューバーに表示されなくなります。再表示するには、ルート管理ページで設定する必要があります。",
"If selected, the page will display Tab pages.": "選択されている場合、ページはタブページを表示します。",
"If selected, the route will be displayed in the menu.": "選択されている場合、ルートはメニューに表示されます。",
"Are you sure you want to hide this tab?": "このタブを非表示にしますか?",
"After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.": "非表示にすると、このタブはタブバーに表示されなくなります。再表示するには、ルート管理ページで設定する必要があります。",
"Deprecated": "非推奨",
"The following old template features have been deprecated and will be removed in next version.": "次の古いテンプレート機能は非推奨になり、次のバージョンで削除されます。"
}

View File

@ -912,6 +912,8 @@
"After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.": "숨기면 이 메뉴는 메뉴 바에 더 이상 표시되지 않습니다. 다시 표시하려면 라우트 관리 페이지에서 설정해야 합니다.",
"If selected, the page will display Tab pages.": "선택되면 페이지는 탭 페이지를 표시합니다.",
"If selected, the route will be displayed in the menu.": "선택되면 라우트는 메뉴에 표시됩니다.",
"Are you sure you want to hide this tab?": "이 탭을 숨기시겠습니까?",
"After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.": "숨기면 이 탭은 탭 바에 더 이상 표시되지 않습니다. 다시 표시하려면 라우트 관리 페이지에서 설정해야 합니다.",
"Deprecated": "사용 중단됨",
"The following old template features have been deprecated and will be removed in next version.": "다음 오래된 템플릿 기능은 사용 중단되었으며 다음 버전에서 제거될 것입니다."
}

View File

@ -778,6 +778,8 @@
"After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.": "Depois de ocultar, este menu não aparecerá mais na barra de menus. Para mostrar novamente, você precisa ir à página de gerenciamento de rotas para configurá-lo.",
"If selected, the page will display Tab pages.": "Se selecionado, a página exibirá páginas de abas.",
"If selected, the route will be displayed in the menu.": "Se selecionado, a rota será exibida no menu.",
"Are you sure you want to hide this tab?": "Tem certeza de que deseja ocultar esta guia?",
"After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.": "Depois de ocultar, esta guia não aparecerá mais na barra de guias. Para mostrá-la novamente, você precisa ir à página de gerenciamento de rotas para configurá-la.",
"Deprecated": "Descontinuado",
"The following old template features have been deprecated and will be removed in next version.": "As seguintes funcionalidades de modelo antigo foram descontinuadas e serão removidas na próxima versão."
}

View File

@ -607,6 +607,8 @@
"After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.": "После скрытия этого меню он больше не будет отображаться в меню. Чтобы снова отобразить его, вам нужно будет перейти на страницу управления маршрутами и настроить его.",
"If selected, the page will display Tab pages.": "Если выбран, страница будет отображать страницы с вкладками.",
"If selected, the route will be displayed in the menu.": "Если выбран, маршрут будет отображаться в меню.",
"Are you sure you want to hide this tab?": "Вы уверены, что хотите скрыть эту вкладку?",
"After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.": "После скрытия этой вкладки она больше не будет отображаться во вкладке. Чтобы снова отобразить ее, вам нужно будет перейти на страницу управления маршрутами, чтобы установить ее.",
"Deprecated": "Устаревший",
"The following old template features have been deprecated and will be removed in next version.": "Следующие старые функции шаблонов устарели и будут удалены в следующей версии."
}

View File

@ -605,6 +605,8 @@
"After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.": "Gizlendikten sonra, bu menü artık menü çubuğunda görünmeyecektir. Tekrar görüntülemek için, yönlendirme yönetimi sayfasına gidip onu yapılandırmanız gerekecektir.",
"If selected, the page will display Tab pages.": "Seçildiğinde, sayfa Tab sayfalarını görüntüleyecektir.",
"If selected, the route will be displayed in the menu.": "Seçildiğinde, yol menüde görüntülenecektir.",
"Are you sure you want to hide this tab?": "Bu sekmeyi gizlemek istediğinizden emin misiniz?",
"After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.": "Gizlendikten sonra, bu sekme artık sekme çubuğunda görünmeyecek. Onu tekrar göstermek için, rotayı yönetim sayfasına gidip ayarlamanız gerekiyor.",
"Deprecated": "Kullanımdan kaldırıldı",
"The following old template features have been deprecated and will be removed in next version.": "Aşağıdaki eski şablon özellikleri kullanımdan kaldırıldı ve gelecek sürümlerde kaldırılacaktır."
}

View File

@ -821,6 +821,8 @@
"After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.": "Після приховування цього меню він більше не з'явиться в меню. Щоб знову показати його, вам потрібно перейти на сторінку керування маршрутами і налаштувати його.",
"If selected, the page will display Tab pages.": "Якщо вибрано, сторінка відобразить сторінки з вкладками.",
"If selected, the route will be displayed in the menu.": "Якщо вибрано, маршрут буде відображений в меню.",
"Are you sure you want to hide this tab?": "Ви впевнені, що хочете приховати цю вкладку?",
"After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.": "Після приховування цієї вкладки вона більше не з'явиться в панелі вкладок. Щоб знову показати її, вам потрібно перейти на сторінку керування маршрутами, щоб налаштувати її.",
"Deprecated": "Застаріло",
"The following old template features have been deprecated and will be removed in next version.": "Наступні старі функції шаблонів були застарілі і будуть видалені в наступній версії."
}

View File

@ -1080,6 +1080,8 @@
"After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.": "隐藏后,这个菜单将不再出现在菜单栏中。要再次显示它,你需要到路由管理页面进行设置。",
"If selected, the page will display Tab pages.": "如果选中,该页面将显示标签页。",
"If selected, the route will be displayed in the menu.": "如果选中,该路由将显示在菜单中。",
"Are you sure you want to hide this tab?": "你确定要隐藏该标签页吗?",
"After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.": "隐藏后,该标签将不再显示在标签栏中。要想再次显示它,你需要到路由管理页面进行设置。",
"Deprecated": "已弃用",
"The following old templates have been deprecated and will be removed in next version.": "以下旧的模板功能已弃用,将在下个版本移除。"
}

View File

@ -912,6 +912,8 @@
"After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.": "隱藏後,這個菜單將不再出現在菜單欄中。要再次顯示它,你需要到路由管理頁面進行設置。",
"If selected, the page will display Tab pages.": "如果選中,該頁面將顯示標籤頁。",
"If selected, the route will be displayed in the menu.": "如果選中,該路由將顯示在菜單中。",
"Are you sure you want to hide this tab?": "你確定要隱藏這個標籤嗎?",
"After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.": "隱藏後,這個標籤將不再出現在標籤欄中。要再次顯示它,你需要到路由管理頁面進行設置。",
"Deprecated": "已棄用",
"The following old template features have been deprecated and will be removed in next version.": "以下舊的模板功能已棄用,將在下個版本移除。"
}

View File

@ -884,7 +884,7 @@ export const URLSearchParamsUseAssociationFieldValue = {
linkageAction: true,
},
'x-component-props': {
url: '/admin/ids0d9esx8k',
url: '/admin/ocal3pnltf2',
params: [
{
name: 'roles',

View File

@ -3824,10 +3824,11 @@ export const T4334 = {
},
},
'x-uid': 'ribk031tkp8',
'x-async': false,
'x-async': true,
'x-index': 1,
},
},
'x-uid': '1j5z1j5z1j5',
},
};

View File

@ -12,7 +12,7 @@ import { SchemaOptionsContext } from '@formily/react';
import { uid } from '@formily/shared';
import React, { useCallback, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { SchemaInitializerItem, useSchemaInitializer } from '../../application';
import { SchemaInitializerItem } from '../../application';
import { useGlobalTheme } from '../../global-theme';
import { NocoBaseDesktopRouteType } from '../../route-switch/antd/admin-layout/convertRoutesToSchema';
import {
@ -25,7 +25,6 @@ import {
import { useStyles } from '../../schema-component/antd/menu/MenuItemInitializers';
export const GroupItem = () => {
const { insert } = useSchemaInitializer();
const { t } = useTranslation();
const options = useContext(SchemaOptionsContext);
const { theme } = useGlobalTheme();
@ -69,30 +68,13 @@ export const GroupItem = () => {
const schemaUid = uid();
// 创建一个路由到 desktopRoutes 表中
const { data } = await createRoute({
await createRoute({
type: NocoBaseDesktopRouteType.group,
title,
icon,
parentId: parentRoute?.id,
schemaUid,
});
// 同时插入一个对应的 Schema
insert(getGroupMenuSchema({ title, icon, schemaUid, route: data?.data }));
}, [insert, options.components, options.scope, t, theme]);
}, [options.components, options.scope, t, theme]);
return <SchemaInitializerItem title={t('Group')} onClick={handleClick} className={`${componentCls} ${hashId}`} />;
};
export function getGroupMenuSchema({ title, icon, schemaUid, route = undefined }) {
return {
type: 'void',
title,
'x-component': 'Menu.SubMenu',
'x-decorator': 'ACLMenuItemProvider',
'x-component-props': {
icon,
},
'x-uid': schemaUid,
__route__: route,
};
}

View File

@ -9,12 +9,11 @@
import { FormLayout } from '@formily/antd-v5';
import { SchemaOptionsContext } from '@formily/react';
import { uid } from '@formily/shared';
import { createMemoryHistory } from 'history';
import React, { useCallback, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { Router } from 'react-router-dom';
import { SchemaInitializerItem, useSchemaInitializer } from '../../application';
import { SchemaInitializerItem } from '../../application';
import { useGlobalTheme } from '../../global-theme';
import { NocoBaseDesktopRouteType } from '../../route-switch/antd/admin-layout/convertRoutesToSchema';
import {
@ -28,7 +27,6 @@ import { useStyles } from '../../schema-component/antd/menu/MenuItemInitializers
import { useURLAndHTMLSchema } from '../actions/link/useURLAndHTMLSchema';
export const LinkMenuItem = () => {
const { insert } = useSchemaInitializer();
const { t } = useTranslation();
const options = useContext(SchemaOptionsContext);
const { theme } = useGlobalTheme();
@ -75,40 +73,19 @@ export const LinkMenuItem = () => {
initialValues: {},
});
const { title, href, params, icon } = values;
const schemaUid = uid();
// 创建一个路由到 desktopRoutes 表中
const { data } = await createRoute({
await createRoute({
type: NocoBaseDesktopRouteType.link,
title: values.title,
icon: values.icon,
title,
icon,
parentId: parentRoute?.id,
schemaUid,
options: {
href,
params,
},
});
// 同时插入一个对应的 Schema
insert(getLinkMenuSchema({ title, icon, schemaUid, href, params, route: data?.data }));
}, [insert, options.components, options.scope, t, theme]);
}, [options.components, options.scope, t, theme]);
return <SchemaInitializerItem title={t('Link')} onClick={handleClick} className={`${componentCls} ${hashId}`} />;
};
export function getLinkMenuSchema({ title, icon, schemaUid, href, params, route = undefined }) {
return {
type: 'void',
title,
'x-component': 'Menu.URL',
'x-decorator': 'ACLMenuItemProvider',
'x-component-props': {
icon,
href,
params,
},
'x-uid': schemaUid,
__route__: route,
};
}

View File

@ -12,7 +12,8 @@ import { SchemaOptionsContext } from '@formily/react';
import { uid } from '@formily/shared';
import React, { useCallback, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { SchemaInitializerItem, useSchemaInitializer } from '../../application';
import { useAPIClient } from '../../api-client/hooks/useAPIClient';
import { SchemaInitializerItem } from '../../application';
import { useGlobalTheme } from '../../global-theme';
import { NocoBaseDesktopRouteType } from '../../route-switch/antd/admin-layout/convertRoutesToSchema';
import {
@ -24,14 +25,28 @@ import {
} from '../../schema-component';
import { useStyles } from '../../schema-component/antd/menu/MenuItemInitializers';
export const useInsertPageSchema = () => {
const api = useAPIClient();
return useCallback(
async (schema) => {
await api.request({
method: 'POST',
url: '/uiSchemas:insert',
data: schema,
});
},
[api],
);
};
export const PageMenuItem = () => {
const { insert } = useSchemaInitializer();
const { t } = useTranslation();
const options = useContext(SchemaOptionsContext);
const { theme } = useGlobalTheme();
const { componentCls, hashId } = useStyles();
const parentRoute = useParentRoute();
const { createRoute } = useNocoBaseRoutes();
const insertPageSchema = useInsertPageSchema();
const handleClick = useCallback(async () => {
const values = await FormDialog(
@ -65,16 +80,13 @@ export const PageMenuItem = () => {
).open({
initialValues: {},
});
const { title, icon } = values;
const menuSchemaUid = uid();
const pageSchemaUid = uid();
const tabSchemaUid = uid();
const tabSchemaName = uid();
// 创建一个路由到 desktopRoutes 表中
const {
data: { data: route },
} = await createRoute({
await createRoute({
type: NocoBaseDesktopRouteType.page,
title: values.title,
icon: values.icon,
@ -93,46 +105,25 @@ export const PageMenuItem = () => {
});
// 同时插入一个对应的 Schema
insert(getPageMenuSchema({ title, icon, pageSchemaUid, tabSchemaUid, menuSchemaUid, tabSchemaName, route }));
}, [createRoute, insert, options?.components, options?.scope, parentRoute?.id, t, theme]);
insertPageSchema(getPageMenuSchema({ pageSchemaUid, tabSchemaUid, tabSchemaName }));
}, [createRoute, insertPageSchema, options?.components, options?.scope, parentRoute?.id, t, theme]);
return <SchemaInitializerItem title={t('Page')} onClick={handleClick} className={`${componentCls} ${hashId}`} />;
};
export function getPageMenuSchema({
title,
icon,
pageSchemaUid,
tabSchemaUid,
menuSchemaUid,
tabSchemaName,
route = undefined,
}) {
export function getPageMenuSchema({ pageSchemaUid, tabSchemaUid, tabSchemaName }) {
return {
type: 'void',
title,
'x-component': 'Menu.Item',
'x-decorator': 'ACLMenuItemProvider',
'x-component-props': {
icon,
},
'x-component': 'Page',
properties: {
page: {
[tabSchemaName]: {
type: 'void',
'x-component': 'Page',
'x-component': 'Grid',
'x-initializer': 'page:addBlock',
properties: {},
'x-uid': tabSchemaUid,
'x-async': true,
properties: {
[tabSchemaName]: {
type: 'void',
'x-component': 'Grid',
'x-initializer': 'page:addBlock',
properties: {},
'x-uid': tabSchemaUid,
},
},
'x-uid': pageSchemaUid,
},
},
'x-uid': menuSchemaUid,
__route__: route,
'x-uid': pageSchemaUid,
};
}

View File

@ -18,7 +18,7 @@ test('single page', async ({ page, mockPage }) => {
await mockPage({ name: pageTitle2 }).goto();
await page.getByRole('menu').getByText(pageTitle1).click();
await page.getByRole('menu').getByText(pageTitle1).hover();
await page.getByLabel(pageTitle1).getByLabel('designer-schema-settings').hover();
await page.getByRole('button', { name: 'designer-schema-settings-' }).hover();
await page.getByRole('menuitem', { name: 'Move to' }).click();
await page.getByLabel('block-item-TreeSelect-Target').locator('.ant-select').click();
await page.locator('.ant-select-dropdown').getByText(pageTitle2).click();

View File

@ -20,14 +20,14 @@ test.describe('router', () => {
await expect(page.getByText('This is tab2.')).toBeVisible();
// 2. 点击 tab1 应该跳转到 tab1并使用新版 URL
await page.getByText('tab1').click();
await page.getByText('tab1', { exact: true }).click();
await expect(page.getByText('This is tab1.')).toBeVisible();
expect(page.url()).toMatch(new RegExp(`${pageUrl}/tabs/u4earq3d9go`));
expect(page.url()).toMatch(new RegExp(`${pageUrl}/tabs/xbx6zg90ij2`));
// 3. 点击 tab2 应该跳转到 tab2并使用新版 URL
await page.getByText('tab2').click();
await page.getByText('tab2', { exact: true }).click();
await expect(page.getByText('This is tab2.')).toBeVisible();
expect(page.url()).toMatch(new RegExp(`${pageUrl}/tabs/bbch3c9b5jl`));
expect(page.url()).toMatch(new RegExp(`${pageUrl}/tabs/qhjdmy9nk6q`));
// 4. 使用不带 tab 参数的 URL应该默认显示第一个 tab
await nocoPage.goto();

View File

@ -13,12 +13,11 @@ test.describe('deleted popups', () => {
test('should display error info when deleted popups', async ({ page, mockPage }) => {
const nocoPage = await mockPage().waitForInit();
const url = await nocoPage.getUrl();
await page.goto(
const path =
url +
'/popups/vygn5ile3xz/filterbytk/1/popups/n24hos465bj/filterbytk/admin/sourceid/1/popups/s32h1ed5g9i/filterbytk/admin/sourceid/1',
);
'/popups/vygn5ile3xz/filterbytk/1/popups/n24hos465bj/filterbytk/admin/sourceid/1/popups/s32h1ed5g9i/filterbytk/admin/sourceid/1';
await page.goto(path);
await expect(page.getByText('Sorry, the page you visited does not exist.')).toHaveCount(3);
// close the popups

View File

@ -18,7 +18,7 @@ test.describe('popup router', () => {
}).waitForInit();
const url = await nocoPage.getUrl();
// 直接跳转到子页面,然后点击返回按钮,查看是否能返回到上一级页面
// Directly navigate to the subpage, then click the back button to check if it can return to the parent page
await page.goto(
url +
'/popups/56tsj7l3k35/filterbytk/1/popups/bd3nizznkdw/filterbytk/member/sourceid/1/popups/1ct9qd9jlbm/filterbytk/member/sourceid/1',

View File

@ -11,6 +11,7 @@ import { DisconnectOutlined, LoadingOutlined } from '@ant-design/icons';
import { css } from '@emotion/css';
import { observer } from '@formily/reactive-react';
import { getSubAppName } from '@nocobase/sdk';
import { tval } from '@nocobase/utils/client';
import { Button, Modal, Result, Spin } from 'antd';
import React, { FC } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
@ -32,7 +33,6 @@ import { BlockTemplateDetails, BlockTemplatePage } from '../schema-templates';
import { SystemSettingsPlugin } from '../system-settings';
import { CurrentUserProvider, CurrentUserSettingsMenuProvider } from '../user';
import { LocalePlugin } from './plugins/LocalePlugin';
import { tval } from '@nocobase/utils/client';
const AppSpin = () => {
return (
@ -251,7 +251,7 @@ const AppMaintainingDialog: FC<{ app: Application; error: Error }> = observer(
{ displayName: 'AppMaintainingDialog' },
);
const AppNotFound = () => {
export const AppNotFound = () => {
const navigate = useNavigate();
return (
<Result

View File

@ -9,9 +9,12 @@
import { css } from '@emotion/css';
import { SchemaOptionsContext } from '@formily/react';
import { ConfigProvider, Divider } from 'antd';
import { get } from 'lodash';
import React, { useContext } from 'react';
import { useACLRoleContext } from '../acl/ACLProvider';
import { UserCenter } from '../route-switch/antd/admin-layout/UserCenterButton';
import { Help } from '../user/Help';
import { PinnedPluginListContext } from './context';
export const PinnedPluginListProvider: React.FC<{ items: any }> = (props) => {
@ -25,7 +28,8 @@ export const PinnedPluginListProvider: React.FC<{ items: any }> = (props) => {
};
const pinnedPluginListClassName = css`
display: inline-block;
display: inline-flex;
align-items: center;
.ant-btn {
border: 0;
@ -44,6 +48,12 @@ const pinnedPluginListClassName = css`
}
`;
const dividerTheme = {
token: {
colorSplit: 'rgba(255, 255, 255, 0.1)',
},
};
export const PinnedPluginList = React.memo(() => {
const { allowAll, snippets } = useACLRoleContext();
const getSnippetsAllow = (aclKey) => {
@ -61,6 +71,11 @@ export const PinnedPluginList = React.memo(() => {
const Action = get(components, ctx.items[key].component);
return Action ? <Action key={key} /> : null;
})}
<ConfigProvider theme={dividerTheme}>
<Divider type="vertical" />
</ConfigProvider>
<Help key="help" />
<UserCenter />
</div>
);
});

View File

@ -1,141 +0,0 @@
/**
* 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 { describe, expect, it } from 'vitest';
import { convertRoutesToSchema, NocoBaseDesktopRouteType } from '../convertRoutesToSchema';
describe('convertRoutesToSchema', () => {
it('should convert empty routes array to basic menu schema', () => {
const result = convertRoutesToSchema([]);
expect(result).toMatchObject({
type: 'void',
'x-component': 'Menu',
'x-designer': 'Menu.Designer',
'x-initializer': 'MenuItemInitializers',
properties: {},
});
});
it('should convert single page route to menu schema', () => {
const routes = [
{
id: 1,
title: 'Test Page',
type: NocoBaseDesktopRouteType.page,
icon: 'HomeOutlined',
menuSchemaUid: 'test-uid',
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
},
];
const result = convertRoutesToSchema(routes);
expect(result.properties).toMatchObject({
[Object.keys(result.properties)[0]]: {
type: 'void',
title: 'Test Page',
'x-component': 'Menu.Item',
'x-component-props': {
icon: 'HomeOutlined',
},
'x-uid': 'test-uid',
},
});
});
it('should convert nested group route to menu schema', () => {
const routes = [
{
id: 1,
title: 'Group',
type: NocoBaseDesktopRouteType.group,
icon: 'GroupOutlined',
schemaUid: 'group-uid',
children: [
{
id: 2,
title: 'Child Page',
type: NocoBaseDesktopRouteType.page,
icon: 'FileOutlined',
menuSchemaUid: 'child-uid',
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
},
],
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
},
];
const result = convertRoutesToSchema(routes);
const groupSchema = result.properties[Object.keys(result.properties)[0]];
expect(groupSchema).toMatchObject({
type: 'void',
title: 'Group',
'x-component': 'Menu.SubMenu',
'x-component-props': {
icon: 'GroupOutlined',
},
'x-uid': 'group-uid',
});
const childSchema = groupSchema.properties[Object.keys(groupSchema.properties)[0]];
expect(childSchema).toMatchObject({
type: 'void',
title: 'Child Page',
'x-component': 'Menu.Item',
'x-component-props': {
icon: 'FileOutlined',
},
'x-uid': 'child-uid',
});
});
it('should skip tabs type routes', () => {
const routes = [
{
id: 1,
title: 'Tabs',
type: NocoBaseDesktopRouteType.tabs,
schemaUid: 'tabs-uid',
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
},
];
const result = convertRoutesToSchema(routes);
expect(Object.keys(result.properties)).toHaveLength(0);
});
it('should convert link type route to menu URL schema', () => {
const routes = [
{
id: 1,
title: 'External Link',
type: NocoBaseDesktopRouteType.link,
icon: 'LinkOutlined',
schemaUid: 'link-uid',
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
},
];
const result = convertRoutesToSchema(routes);
expect(result.properties[Object.keys(result.properties)[0]]).toMatchObject({
type: 'void',
title: 'External Link',
'x-component': 'Menu.URL',
'x-component-props': {
icon: 'LinkOutlined',
},
'x-uid': 'link-uid',
});
});
});

View File

@ -7,10 +7,6 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { ISchema } from '@formily/json-schema';
import { uid } from '@formily/shared';
import _ from 'lodash';
export enum NocoBaseDesktopRouteType {
group = 'group',
page = 'page',
@ -56,71 +52,3 @@ export interface NocoBaseDesktopRoute {
createdBy?: any;
updatedBy?: any;
}
/**
* Schema
* Schema desktopRoutes
* @param routes
*/
export function convertRoutesToSchema(routes: NocoBaseDesktopRoute[]) {
const routesSchemaList = routes.map((route) => convertRouteToSchema(route)).filter(Boolean);
return {
type: 'void',
'x-component': 'Menu',
'x-designer': 'Menu.Designer',
'x-initializer': 'MenuItemInitializers',
'x-component-props': {
mode: 'mix',
theme: 'dark',
onSelect: '{{ onSelect }}',
sideMenuRefScopeKey: 'sideMenuRef',
},
properties: _.fromPairs(routesSchemaList.map((schema) => [uid(), schema])),
name: 'wecmvuxtid7',
'x-uid': 'nocobase-admin-menu',
'x-async': false,
} as ISchema;
}
const routeTypeToComponent = {
[NocoBaseDesktopRouteType.page]: 'Menu.Item',
[NocoBaseDesktopRouteType.group]: 'Menu.SubMenu',
[NocoBaseDesktopRouteType.link]: 'Menu.URL',
};
function convertRouteToSchema(route: NocoBaseDesktopRoute) {
// tabs 需要在页面 Schema 中处理
if (route.type === NocoBaseDesktopRouteType.tabs) {
return null;
}
const children = route.children?.map((child) => convertRouteToSchema(child)).filter(Boolean);
return {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title: route.title,
'x-component': routeTypeToComponent[route.type],
'x-decorator': 'ACLMenuItemProvider',
'x-component-props': {
icon: route.icon,
href: route.options?.href,
params: route.options?.params,
hidden: route.hideInMenu,
},
properties: children
? _.fromPairs(
children.map((child) => [
uid(), // 生成唯一的 key
child,
]),
)
: {},
'x-app-version': '1.5.0-beta.12',
'x-uid': route.type === NocoBaseDesktopRouteType.page ? route.menuSchemaUid : route.schemaUid,
'x-async': false,
__route__: route,
};
}

View File

@ -0,0 +1,623 @@
/**
* 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 { ExclamationCircleFilled } from '@ant-design/icons';
import { TreeSelect } from '@formily/antd-v5';
import { Field, onFieldChange } from '@formily/core';
import { ISchema } from '@formily/react';
import { uid } from '@formily/shared';
import { App, ConfigProvider } from 'antd';
import { SiderContext } from 'antd/es/layout/Sider';
import React, { FC, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
css,
findRouteBySchemaUid,
isVariable,
NocoBaseDesktopRouteType,
useAllAccessDesktopRoutes,
useCompile,
useCurrentPageUid,
useCurrentRoute,
useGlobalTheme,
useNavigateNoUpdate,
useNocoBaseRoutes,
useToken,
useURLAndHTMLSchema,
} from '../../..';
import { getPageMenuSchema } from '../../../';
import { SchemaSettings } from '../../../application/schema-settings/SchemaSettings';
import { useInsertPageSchema } from '../../../modules/menu/PageMenuItem';
import { SchemaToolbar } from '../../../schema-settings/GeneralSchemaDesigner';
import {
SchemaSettingsItem,
SchemaSettingsModalItem,
SchemaSettingsSubMenu,
SchemaSettingsSwitchItem,
} from '../../../schema-settings/SchemaSettings';
import { NocoBaseDesktopRoute } from './convertRoutesToSchema';
const components = { TreeSelect };
const toItems = (routes: NocoBaseDesktopRoute[], { t, compile }) => {
const items = [];
for (const route of routes) {
const item = {
label: isVariable(route.title) ? compile(route.title) : t(route.title),
value: `${route.id}||${route.type}`,
};
if (route.children?.length > 0) {
item['children'] = toItems(route.children, { t, compile });
}
items.push(item);
}
return items;
};
const insertPositionToMethod = {
beforeBegin: 'prepend',
afterEnd: 'insertAfter',
};
const findPrevSibling = (routes: NocoBaseDesktopRoute[], currentRoute: NocoBaseDesktopRoute | undefined) => {
if (!currentRoute) {
return;
}
for (let i = 0; i < routes.length; i++) {
const route = routes[i];
if (route.id === currentRoute.id) {
return routes[i - 1];
}
if (route.children) {
const prevSibling = findPrevSibling(route.children, currentRoute);
if (prevSibling) {
return prevSibling;
}
}
}
};
const findNextSibling = (routes: NocoBaseDesktopRoute[], currentRoute: NocoBaseDesktopRoute | undefined) => {
if (!currentRoute) {
return;
}
for (let i = 0; i < routes.length; i++) {
const route = routes[i];
if (route.id === currentRoute.id) {
return routes[i + 1];
}
if (route.children) {
const nextSibling = findNextSibling(route.children, currentRoute);
if (nextSibling) {
return nextSibling;
}
}
}
};
export const RemoveRoute: FC = () => {
const { t } = useTranslation();
const { modal } = App.useApp();
const { deleteRoute } = useNocoBaseRoutes();
const currentRoute = useCurrentRoute();
const { allAccessRoutes } = useAllAccessDesktopRoutes();
const navigate = useNavigateNoUpdate();
const currentPageUid = useCurrentPageUid();
return (
<SchemaSettingsItem
title="Delete"
eventKey="remove"
onClick={() => {
modal.confirm({
title: t('Delete menu item'),
content: t('Are you sure you want to delete it?'),
onOk: async () => {
// 删除对应菜单的路由
currentRoute?.id != null && (await deleteRoute(currentRoute.id));
if (
currentPageUid !== currentRoute?.schemaUid &&
!findRouteBySchemaUid(currentPageUid, currentRoute?.children)
) {
return;
}
// 上一个兄弟节点
const prevSibling = findPrevSibling(allAccessRoutes, currentRoute);
// 下一个兄弟节点
const nextSibling = findNextSibling(allAccessRoutes, currentRoute);
if (prevSibling || nextSibling) {
// 如果删除的是当前打开的页面或分组,需要跳转到上一个页面或分组
navigate(`/admin/${prevSibling?.schemaUid || nextSibling?.schemaUid}`);
} else {
navigate(`/`);
}
},
});
}}
>
{t('Delete')}
</SchemaSettingsItem>
);
};
const InsertMenuItems = (props) => {
const { eventKey, title, insertPosition } = props;
const { t } = useTranslation();
const { urlSchema, paramsSchema } = useURLAndHTMLSchema();
const currentRoute = useCurrentRoute();
const isSubMenu = currentRoute?.type === NocoBaseDesktopRouteType.group;
const { createRoute, moveRoute } = useNocoBaseRoutes();
const insertPageSchema = useInsertPageSchema();
if (!isSubMenu && insertPosition === 'beforeEnd') {
return null;
}
return (
<SchemaSettingsSubMenu eventKey={eventKey} title={title}>
<SchemaSettingsModalItem
eventKey={`${insertPosition}group`}
title={t('Group')}
schema={
{
type: 'object',
title: t('Add group'),
properties: {
title: {
'x-decorator': 'FormItem',
'x-component': 'Input',
title: t('Menu item title'),
required: true,
'x-component-props': {},
},
icon: {
title: t('Icon'),
'x-component': 'IconPicker',
'x-decorator': 'FormItem',
},
},
} as ISchema
}
onSubmit={async ({ title, icon }) => {
const schemaUid = uid();
const parentId = insertPosition === 'beforeEnd' ? currentRoute?.id : currentRoute?.parentId;
// 1. 先创建一个路由
const { data } = await createRoute({
type: NocoBaseDesktopRouteType.group,
title,
icon,
// 'beforeEnd' 表示的是 Insert inner此时需要把路由插入到当前路由的内部
parentId: parentId || undefined,
schemaUid,
});
if (insertPositionToMethod[insertPosition]) {
// 2. 然后再把路由移动到对应的位置
await moveRoute({
sourceId: data?.data?.id,
targetId: currentRoute?.id as any,
sortField: 'sort',
method: insertPositionToMethod[insertPosition],
});
}
}}
/>
<SchemaSettingsModalItem
eventKey={`${insertPosition}page`}
title={t('Page')}
schema={
{
type: 'object',
title: t('Add page'),
properties: {
title: {
'x-decorator': 'FormItem',
'x-component': 'Input',
title: t('Menu item title'),
required: true,
'x-component-props': {},
},
icon: {
title: t('Icon'),
'x-component': 'IconPicker',
'x-decorator': 'FormItem',
},
},
} as ISchema
}
onSubmit={async ({ title, icon }) => {
const menuSchemaUid = uid();
const pageSchemaUid = uid();
const tabSchemaUid = uid();
const tabSchemaName = uid();
const parentId = insertPosition === 'beforeEnd' ? currentRoute?.id : currentRoute?.parentId;
// 1. 先创建一个路由
const { data } = await createRoute({
type: NocoBaseDesktopRouteType.page,
title,
icon,
// 'beforeEnd' 表示的是 Insert inner此时需要把路由插入到当前路由的内部
parentId: parentId || undefined,
schemaUid: pageSchemaUid,
menuSchemaUid,
enableTabs: false,
children: [
{
type: NocoBaseDesktopRouteType.tabs,
schemaUid: tabSchemaUid,
tabSchemaName,
hidden: true,
},
],
});
if (insertPositionToMethod[insertPosition]) {
// 2. 然后再把路由移动到对应的位置
await moveRoute({
sourceId: data?.data?.id,
targetId: currentRoute?.id,
sortField: 'sort',
method: insertPositionToMethod[insertPosition],
});
}
// 3. 插入一个对应的 Schema
insertPageSchema(getPageMenuSchema({ pageSchemaUid, tabSchemaUid, tabSchemaName }));
}}
/>
<SchemaSettingsModalItem
eventKey={`${insertPosition}link`}
title={t('Link')}
schema={
{
type: 'object',
title: t('Add link'),
properties: {
title: {
title: t('Menu item title'),
required: true,
'x-component': 'Input',
'x-decorator': 'FormItem',
},
icon: {
title: t('Icon'),
'x-component': 'IconPicker',
'x-decorator': 'FormItem',
},
href: urlSchema,
params: paramsSchema,
},
} as ISchema
}
onSubmit={async ({ title, icon, href, params }) => {
const schemaUid = uid();
const parentId = insertPosition === 'beforeEnd' ? currentRoute?.id : currentRoute?.parentId;
// 1. 先创建一个路由
const { data } = await createRoute({
type: NocoBaseDesktopRouteType.link,
title,
icon,
// 'beforeEnd' 表示的是 Insert inner此时需要把路由插入到当前路由的内部
parentId: parentId || undefined,
schemaUid,
options: {
href,
params,
},
});
if (insertPositionToMethod[insertPosition]) {
// 2. 然后再把路由移动到对应的位置
await moveRoute({
sourceId: data?.data?.id,
targetId: currentRoute?.id,
sortField: 'sort',
method: insertPositionToMethod[insertPosition],
});
}
}}
/>
</SchemaSettingsSubMenu>
);
};
const EditMenuItem = () => {
const { t } = useTranslation();
const schema = useMemo(() => {
return {
type: 'object',
title: t('Edit menu item'),
properties: {
title: {
title: t('Menu item title'),
required: true,
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {},
},
icon: {
title: t('Menu item icon'),
'x-component': 'IconPicker',
'x-decorator': 'FormItem',
},
},
};
}, [t]);
const currentRoute = useCurrentRoute();
const { urlSchema, paramsSchema } = useURLAndHTMLSchema();
const initialValues = useMemo(() => {
return {
title: currentRoute.title,
icon: currentRoute.icon,
};
}, [currentRoute.title, currentRoute.icon]);
if (currentRoute.type === NocoBaseDesktopRouteType.link) {
schema.properties['href'] = urlSchema;
schema.properties['params'] = paramsSchema;
initialValues['href'] = currentRoute.options.href;
initialValues['params'] = currentRoute.options.params;
}
const { updateRoute } = useNocoBaseRoutes();
const onEditSubmit: (values: any) => void = useCallback(({ title, icon, href, params }) => {
// 更新菜单对应的路由
if (currentRoute.id !== undefined) {
updateRoute(currentRoute.id, {
title,
icon,
options:
href || params
? {
href,
params,
}
: undefined,
});
}
}, []);
return (
<SchemaSettingsModalItem
title={t('Edit')}
eventKey="edit"
schema={schema as ISchema}
initialValues={initialValues}
onSubmit={onEditSubmit}
/>
);
};
const HiddenMenuItem = () => {
const { t } = useTranslation();
const currentRoute = useCurrentRoute();
const { updateRoute } = useNocoBaseRoutes();
const { modal } = App.useApp();
return (
<SchemaSettingsSwitchItem
title={t('Hidden')}
checked={currentRoute.hideInMenu}
onChange={(value) => {
modal.confirm({
title: t('Are you sure you want to hide this menu?'),
icon: <ExclamationCircleFilled />,
content: t(
'After hiding, this menu will no longer appear in the menu bar. To show it again, you need to go to the route management page to configure it.',
),
async onOk() {
if (currentRoute.id !== undefined) {
await updateRoute(currentRoute.id, {
hideInMenu: !!value,
});
}
},
});
}}
/>
);
};
const MoveToMenuItem = () => {
const { t } = useTranslation();
const effects = useCallback(
(form) => {
onFieldChange('target', (field: Field) => {
const [, type] = field?.value?.split?.('||') || [];
field.query('position').take((f: Field) => {
f.dataSource =
type === NocoBaseDesktopRouteType.group
? [
{ label: t('Before'), value: 'beforeBegin' },
{ label: t('After'), value: 'afterEnd' },
{ label: t('Inner'), value: 'beforeEnd' },
]
: [
{ label: t('Before'), value: 'beforeBegin' },
{ label: t('After'), value: 'afterEnd' },
];
});
});
},
[t],
);
const compile = useCompile();
const { allAccessRoutes } = useAllAccessDesktopRoutes();
const items = useMemo(() => toItems(allAccessRoutes, { t, compile }), []);
const modalSchema = useMemo(() => {
return {
type: 'object',
title: t('Move to'),
properties: {
target: {
title: t('Target'),
enum: items,
required: true,
'x-decorator': 'FormItem',
'x-component': 'TreeSelect',
'x-component-props': {},
},
position: {
title: t('Position'),
required: true,
enum: [
{ label: t('Before'), value: 'beforeBegin' },
{ label: t('After'), value: 'afterEnd' },
],
default: 'afterEnd',
'x-component': 'Radio.Group',
'x-decorator': 'FormItem',
},
},
} as ISchema;
}, [items, t]);
const { moveRoute } = useNocoBaseRoutes();
const currentRoute = useCurrentRoute();
const onMoveToSubmit: (values: any) => void = useCallback(async ({ target, position }) => {
const [targetId] = target?.split?.('||') || [];
if (!targetId) {
return;
}
if (targetId === undefined || !currentRoute) {
return;
}
const positionToMethod = {
beforeBegin: 'prepend',
afterEnd: 'insertAfter',
};
await moveRoute({
sourceId: currentRoute.id as any,
targetId: targetId,
sortField: 'sort',
method: positionToMethod[position],
});
}, []);
return (
<SchemaSettingsModalItem
title={t('Move to')}
eventKey="move-to"
components={components}
effects={effects}
schema={modalSchema}
onSubmit={onMoveToSubmit}
/>
);
};
export const menuItemSettings = new SchemaSettings({
name: 'menuSettings:menuItem',
items: [
{
name: 'edit',
Component: EditMenuItem,
},
{
name: 'hidden',
Component: HiddenMenuItem,
},
{
name: 'moveTo',
Component: MoveToMenuItem,
},
{
name: 'divider',
type: 'divider',
},
{
name: 'insertbeforeBegin',
Component: () => {
const { t } = useTranslation();
return (
<InsertMenuItems eventKey={'insertbeforeBegin'} title={t('Insert before')} insertPosition={'beforeBegin'} />
);
},
},
{
name: 'insertafterEnd',
Component: () => {
const { t } = useTranslation();
return <InsertMenuItems eventKey={'insertafterEnd'} title={t('Insert after')} insertPosition={'afterEnd'} />;
},
},
{
name: 'insertbeforeEnd',
Component: () => {
const { t } = useTranslation();
return <InsertMenuItems eventKey={'insertbeforeEnd'} title={t('Insert inner')} insertPosition={'beforeEnd'} />;
},
},
{
name: 'divider',
type: 'divider',
},
{
name: 'delete',
sort: 100,
Component: RemoveRoute,
},
],
});
const iconStyle = css`
.anticon {
line-height: 16px !important;
}
`;
const siderContextValue = { siderCollapsed: false };
export const MenuSchemaToolbar: FC<{ container?: HTMLElement }> = (props) => {
return (
<ResetThemeTokenAndKeepAlgorithm>
{/* 避免 Sider 的状态影响到 SchemaToolbar。否则会导致在折叠状态下SchemaToolbar 的样式异常 */}
<SiderContext.Provider value={siderContextValue}>
<SchemaToolbar
spaceClassName={iconStyle}
settings={menuItemSettings}
showBorder={false}
container={props.container}
/>
</SiderContext.Provider>
</ResetThemeTokenAndKeepAlgorithm>
);
};
/**
* ProLayout
* @param props
* @returns
*/
export const ResetThemeTokenAndKeepAlgorithm: FC = (props) => {
const { theme } = useGlobalTheme();
return (
<ConfigProvider
theme={{
...theme,
inherit: false,
}}
>
{props.children}
</ConfigProvider>
);
};

View File

@ -21,7 +21,7 @@ import { ActionContextNoRerender } from './context';
import { useActionContext } from './hooks';
import { useSetAriaLabelForDrawer } from './hooks/useSetAriaLabelForDrawer';
import { ActionDrawerProps, ComposedActionDrawer, OpenSize } from './types';
import { useZIndexContext, zIndexContext } from './zIndexContext';
import { getZIndex, useZIndexContext, zIndexContext } from './zIndexContext';
const MemoizeRecursionField = React.memo(RecursionField);
MemoizeRecursionField.displayName = 'MemoizeRecursionField';
@ -103,7 +103,7 @@ export const InternalActionDrawer: React.FC<ActionDrawerProps> = observer(
useSetAriaLabelForDrawer(visible);
}
const zIndex = _zIndex || parentZIndex + (props.level || 0);
const zIndex = getZIndex('drawer', _zIndex || parentZIndex, props.level || 0);
const onClose = useCallback(
(e) => {

View File

@ -22,7 +22,7 @@ import { ActionContextNoRerender } from './context';
import { useActionContext } from './hooks';
import { useSetAriaLabelForModal } from './hooks/useSetAriaLabelForModal';
import { ActionDrawerProps, ComposedActionDrawer, OpenSize } from './types';
import { useZIndexContext, zIndexContext } from './zIndexContext';
import { getZIndex, useZIndexContext, zIndexContext } from './zIndexContext';
const ModalErrorFallback: React.FC<FallbackProps> = (props) => {
const { visible, setVisible } = useActionContext();
@ -89,7 +89,7 @@ export const InternalActionModal: React.FC<ActionDrawerProps<ModalProps>> = obse
useSetAriaLabelForModal(visible);
}
const zIndex = _zIndex || parentZIndex + (props.level || 0);
const zIndex = getZIndex('modal', _zIndex || parentZIndex, props.level || 0);
return (
<ActionContextNoRerender>

View File

@ -15,7 +15,7 @@ export const useActionPageStyle = genStyleHook('nb-action-page', (token) => {
return {
[componentCls]: {
position: 'absolute !important' as any,
top: 'var(--nb-header-height)',
top: 0,
left: 0,
right: 0,
bottom: 0,

View File

@ -16,7 +16,7 @@ import { BackButtonUsedInSubPage } from '../page/BackButtonUsedInSubPage';
import { TabsContextProvider, useTabsContext } from '../tabs/context';
import { useActionPageStyle } from './Action.Page.style';
import { usePopupOrSubpagesContainerDOM } from './hooks/usePopupSlotDOM';
import { useZIndexContext, zIndexContext } from './zIndexContext';
import { getZIndex, useZIndexContext, zIndexContext } from './zIndexContext';
const ActionPageContent: FC<{ schema: any }> = React.memo(({ schema }) => {
// Improve the speed of opening the page
@ -45,7 +45,7 @@ export function ActionPage({ level }) {
const style = useMemo(() => {
return {
zIndex: parentZIndex + (level || 0),
zIndex: getZIndex('page', parentZIndex, level || 0),
};
}, [parentZIndex, level]);

View File

@ -12,6 +12,7 @@ import { observer, Schema, useField, useFieldSchema, useForm } from '@formily/re
import { isPortalInBody } from '@nocobase/utils/client';
import { App, Button } from 'antd';
import classnames from 'classnames';
import debounce from 'lodash/debounce';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { useTranslation } from 'react-i18next';
@ -526,7 +527,7 @@ const RenderButtonInner = observer(
buttonStyle: React.CSSProperties;
handleMouseEnter: (e: React.MouseEvent) => void;
getAriaLabel: (postfix?: string) => string;
handleButtonClick: (e: React.MouseEvent) => void;
handleButtonClick: (e: React.MouseEvent, checkPortal?: boolean) => void;
tarComponent: React.ElementType;
componentCls: string;
hashId: string;
@ -562,6 +563,23 @@ const RenderButtonInner = observer(
return null;
}
const debouncedClick = useCallback(
debounce(
(e: React.MouseEvent, checkPortal = true) => {
handleButtonClick(e, checkPortal);
},
300,
{ leading: true, trailing: false },
),
[handleButtonClick],
);
useEffect(() => {
return () => {
debouncedClick.cancel();
};
}, []);
const actionTitle = title || field?.title;
return (
@ -575,7 +593,7 @@ const RenderButtonInner = observer(
icon={typeof icon === 'string' ? <Icon type={icon} /> : icon}
disabled={disabled}
style={buttonStyle}
onClick={handleButtonClick}
onClick={process.env.__E2E__ ? handleButtonClick : debouncedClick} // E2E 中的点击操作都是很快的,如果加上 debounce 会导致 E2E 测试失败
component={tarComponent || Button}
className={classnames(componentCls, hashId, className, 'nb-action')}
type={type === 'danger' ? undefined : type}

View File

@ -14,3 +14,18 @@ export const zIndexContext = React.createContext(100);
export const useZIndexContext = () => {
return React.useContext(zIndexContext);
};
export const getZIndex = (type: 'page' | 'drawer' | 'modal', basicZIndex: number, level: number) => {
let result = basicZIndex;
// 子页面的 z-index 不能超过 200不然会遮挡折叠展开菜单的按钮
// 注意:嵌入页面时需要跳过,因为嵌入页面中的弹窗不是通过 URL 打开的,会导致子页面被弹窗盖住
if (type === 'page' && !window.location.pathname.includes('/embed/')) {
result = basicZIndex + level;
return result > 200 ? result - 200 : result;
}
// 弹窗的 z-index 需要高一点,不然会被折叠展开按钮遮挡
result = basicZIndex + level;
return result < 200 ? result + 200 : result;
};

View File

@ -30,6 +30,7 @@ import {
useDesignable,
useURLAndHTMLSchema,
} from '../../../';
import { useInsertPageSchema } from '../../../modules/menu/PageMenuItem';
import { NocoBaseDesktopRouteType } from '../../../route-switch/antd/admin-layout/convertRoutesToSchema';
const insertPositionToMethod = {
@ -71,11 +72,11 @@ const findMenuSchema = (fieldSchema: Schema) => {
const InsertMenuItems = (props) => {
const { eventKey, title, insertPosition } = props;
const { t } = useTranslation();
const { dn } = useDesignable();
const fieldSchema = useFieldSchema();
const { urlSchema, paramsSchema } = useURLAndHTMLSchema();
const isSubMenu = fieldSchema['x-component'] === 'Menu.SubMenu';
const { createRoute, moveRoute } = useNocoBaseRoutes();
const insertPageSchema = useInsertPageSchema();
if (!isSubMenu && insertPosition === 'beforeEnd') {
return null;
@ -131,18 +132,6 @@ const InsertMenuItems = (props) => {
method: insertPositionToMethod[insertPosition],
});
}
// 3. 插入一个对应的 Schema
dn.insertAdjacent(insertPosition, {
type: 'void',
title,
'x-component': 'Menu.SubMenu',
'x-decorator': 'ACLMenuItemProvider',
'x-component-props': {
icon,
},
'x-uid': schemaUid,
});
}}
/>
@ -208,17 +197,7 @@ const InsertMenuItems = (props) => {
}
// 3. 插入一个对应的 Schema
dn.insertAdjacent(
insertPosition,
getPageMenuSchema({
title,
icon,
pageSchemaUid,
menuSchemaUid,
tabSchemaUid,
tabSchemaName,
}),
);
insertPageSchema(getPageMenuSchema({ pageSchemaUid, tabSchemaUid, tabSchemaName }));
}}
/>
<SchemaSettingsModalItem
@ -276,20 +255,6 @@ const InsertMenuItems = (props) => {
method: insertPositionToMethod[insertPosition],
});
}
// 3. 插入一个对应的 Schema
dn.insertAdjacent(insertPosition, {
type: 'void',
title,
'x-component': 'Menu.URL',
'x-decorator': 'ACLMenuItemProvider',
'x-component-props': {
icon,
href,
params,
},
'x-uid': schemaUid,
});
}}
/>
</SchemaSettingsSubMenu>

View File

@ -203,7 +203,7 @@ type ComposedMenu = React.FC<any> & {
Designer?: React.FC<any>;
};
const ParentRouteContext = createContext<NocoBaseDesktopRoute>(null);
export const ParentRouteContext = createContext<NocoBaseDesktopRoute>(null);
ParentRouteContext.displayName = 'ParentRouteContext';
export const useParentRoute = () => {
@ -264,8 +264,8 @@ export const useNocoBaseRoutes = (collectionName = 'desktopRoutes') => {
method,
refreshAfterMove = true,
}: {
sourceId: string;
targetId?: string;
sourceId: string | number;
targetId?: string | number;
targetScope?: any;
sortField?: string;
sticky?: boolean;

View File

@ -143,6 +143,16 @@ export const pageSettings = new SchemaSettings({
await updateRoute(currentRoute.id, {
enableTabs: v,
});
// enableTabs 已经保存在 route 中了,按说这里不需要加了。但 E2E 中需要这个参数。
fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {};
fieldSchema['x-component-props']['enablePageTabs'] = v;
dn.emit('patch', {
schema: {
['x-uid']: fieldSchema['x-uid'],
['x-component-props']: fieldSchema['x-component-props'],
},
});
},
};
},

View File

@ -11,7 +11,7 @@ import { PlusOutlined } from '@ant-design/icons';
import { PageHeader as AntdPageHeader } from '@ant-design/pro-layout';
import { css } from '@emotion/css';
import { FormLayout } from '@formily/antd-v5';
import { Schema, SchemaOptionsContext, useFieldSchema } from '@formily/react';
import { SchemaOptionsContext, useFieldSchema } from '@formily/react';
import { uid } from '@formily/shared';
import { Button, Tabs } from 'antd';
import classNames from 'classnames';
@ -25,18 +25,24 @@ import {
CurrentTabUidContext,
useCurrentSearchParams,
useCurrentTabUid,
useLocationNoUpdate,
useNavigateNoUpdate,
useRouterBasename,
} from '../../../application/CustomRouterContextProvider';
import { useDocumentTitle } from '../../../document-title';
import { useGlobalTheme } from '../../../global-theme';
import { Icon } from '../../../icon';
import { NocoBaseDesktopRouteType, useCurrentRoute } from '../../../route-switch/antd/admin-layout';
import { AppNotFound } from '../../../nocobase-buildin-plugin';
import {
NocoBaseDesktopRouteType,
NocoBaseRouteContext,
useCurrentRoute,
} from '../../../route-switch/antd/admin-layout';
import { KeepAliveProvider, useKeepAlive } from '../../../route-switch/antd/admin-layout/KeepAlive';
import { useGetAriaLabelOfSchemaInitializer } from '../../../schema-initializer/hooks/useGetAriaLabelOfSchemaInitializer';
import { DndContext } from '../../common';
import { SortableItem } from '../../common/sortable-item';
import { SchemaComponent, SchemaComponentOptions } from '../../core';
import { RemoteSchemaComponent, SchemaComponent, SchemaComponentOptions } from '../../core';
import { useCompile, useDesignable } from '../../hooks';
import { useToken } from '../__builtins__';
import { ErrorFallback } from '../error-fallback';
@ -62,10 +68,7 @@ const InternalPage = React.memo((props: PageProps) => {
const loading = false;
const currentRoute = useCurrentRoute();
const enablePageTabs = currentRoute.enableTabs;
const defaultActiveKey = useMemo(
() => getDefaultActiveKey(currentRoute?.children?.[0]?.schemaUid, fieldSchema),
[currentRoute?.children, fieldSchema],
);
const defaultActiveKey = currentRoute?.children?.[0]?.schemaUid;
const activeKey = useMemo(
// 处理 searchParams 是为了兼容旧版的 tab 参数
@ -74,8 +77,8 @@ const InternalPage = React.memo((props: PageProps) => {
);
const outletContext = useMemo(
() => ({ loading, disablePageHeader, enablePageTabs, fieldSchema, tabUid: currentTabUid }),
[currentTabUid, disablePageHeader, enablePageTabs, fieldSchema, loading],
() => ({ loading, disablePageHeader, enablePageTabs, tabUid: currentTabUid }),
[currentTabUid, disablePageHeader, enablePageTabs, loading],
);
return (
@ -92,7 +95,6 @@ const InternalPage = React.memo((props: PageProps) => {
loading={loading}
disablePageHeader={disablePageHeader}
enablePageTabs={enablePageTabs}
fieldSchema={fieldSchema}
activeKey={activeKey}
/>
{/* Used to match the route with name "admin.page.popup" */}
@ -135,14 +137,13 @@ export const Page = React.memo((props: PageProps) => {
Page.displayName = 'NocoBasePage';
export const PageTabs = () => {
const { loading, disablePageHeader, enablePageTabs, fieldSchema, tabUid } = useOutletContext<any>();
const { loading, disablePageHeader, enablePageTabs, tabUid } = useOutletContext<any>();
return (
<CurrentTabUidContext.Provider value={tabUid}>
<PageContent
loading={loading}
disablePageHeader={disablePageHeader}
enablePageTabs={enablePageTabs}
fieldSchema={fieldSchema}
activeKey={tabUid}
/>
{/* used to match the route with name "admin.page.tab.popup" */}
@ -170,7 +171,7 @@ const displayNone = {
};
// Add a TabPane component to manage caching, implementing an effect similar to Vue's keep-alive
const TabPane = React.memo(({ schema, active: tabActive }: { schema: Schema; active: boolean }) => {
const TabPane = React.memo(({ active: tabActive, uid }: { active: boolean; uid: string }) => {
const mountedRef = useRef(false);
const { active: pageActive } = useKeepAlive();
@ -178,16 +179,6 @@ const TabPane = React.memo(({ schema, active: tabActive }: { schema: Schema; act
mountedRef.current = true;
}
const newSchema = useMemo(
() =>
new Schema({
properties: {
[schema.name]: schema,
},
}),
[schema],
);
if (!mountedRef.current) {
return null;
}
@ -195,7 +186,7 @@ const TabPane = React.memo(({ schema, active: tabActive }: { schema: Schema; act
return (
<div style={tabActive ? displayBlock : displayNone}>
<KeepAliveProvider active={pageActive && tabActive}>
<SchemaComponent distributed schema={newSchema} />
<RemoteSchemaComponent uid={uid} />
</KeepAliveProvider>
</div>
);
@ -205,26 +196,48 @@ interface PageContentProps {
loading: boolean;
disablePageHeader: any;
enablePageTabs: any;
fieldSchema: Schema<any, any, any, any, any, any, any, any, any>;
activeKey: string;
}
const InternalPageContent = (props: PageContentProps) => {
const { loading, disablePageHeader, enablePageTabs, fieldSchema, activeKey } = props;
const { loading, disablePageHeader, enablePageTabs, activeKey } = props;
const currentRoute = useCurrentRoute();
const navigate = useNavigateNoUpdate();
const location = useLocationNoUpdate();
const children = currentRoute?.children || [];
const noTabs = children.every((tabRoute) => tabRoute.schemaUid !== activeKey && tabRoute.tabSchemaName !== activeKey);
if (activeKey && noTabs) {
return <AppNotFound />;
}
// 兼容旧版本的 tab 路径
const oldTab = currentRoute?.children?.find((tabRoute) => tabRoute.tabSchemaName === activeKey);
if (oldTab) {
navigate(location.pathname.replace(activeKey, oldTab.schemaUid));
return null;
}
if (!disablePageHeader && enablePageTabs) {
return (
<>
{fieldSchema.mapProperties((schema) => (
<TabPane key={schema.name} schema={schema} active={schema.name === activeKey} />
))}
{currentRoute.children?.map((tabRoute) => {
return (
<NocoBaseRouteContext.Provider value={tabRoute} key={tabRoute.schemaUid}>
<TabPane active={tabRoute.schemaUid === activeKey} uid={tabRoute.schemaUid} />
</NocoBaseRouteContext.Provider>
);
})}
</>
);
}
return (
<div className={className1}>
<SchemaComponent schema={fieldSchema} distributed />
<NocoBaseRouteContext.Provider value={currentRoute?.children?.[0]}>
<RemoteSchemaComponent uid={currentRoute?.children?.[0].schemaUid} />
</NocoBaseRouteContext.Provider>
</div>
);
};
@ -334,29 +347,30 @@ const NocoBasePageHeaderTabs: FC<{ className: string; activeKey: string }> = ({
);
const items = useMemo(() => {
return fieldSchema
.mapProperties((schema) => {
const tabRoute = currentRoute?.children?.find((route) => route.schemaUid === schema['x-uid']);
return currentRoute?.children
?.map((tabRoute) => {
if (!tabRoute || tabRoute.hideInMenu) {
return null;
}
// 将 tabRoute 挂载到 schema 上,以方便获取
(schema as any).__route__ = tabRoute;
// fake schema used to pass routing information to SortableItem
const fakeSchema: any = { __route__: tabRoute };
return {
label: (
<SortableItem
id={schema.name as string}
schema={schema}
className={classNames('nb-action-link', 'designerCss', className)}
>
{schema['x-icon'] && <Icon style={{ marginRight: 8 }} type={schema['x-icon']} />}
<span>{(tabRoute.title && routeT(compile(tabRoute.title))) || t('Unnamed')}</span>
<PageTabDesigner schema={schema} />
</SortableItem>
<NocoBaseRouteContext.Provider value={tabRoute}>
<SortableItem
id={String(tabRoute.id)}
className={classNames('nb-action-link', 'designerCss', className)}
schema={fakeSchema}
>
{tabRoute.icon && <Icon style={{ marginRight: 8 }} type={tabRoute.icon} />}
<span>{(tabRoute.title && routeT(compile(tabRoute.title))) || t('Unnamed')}</span>
<PageTabDesigner />
</SortableItem>
</NocoBaseRouteContext.Provider>
),
key: schema.name as string,
key: tabRoute.schemaUid,
};
})
.filter(Boolean);
@ -396,6 +410,8 @@ const NocoBasePageHeader = React.memo(({ activeKey, className }: { activeKey: st
const enablePageTabs = currentRoute.enableTabs;
const hidePageTitle = fieldSchema['x-component-props']?.hidePageTitle;
const { token } = useToken();
useEffect(() => {
const title = t(fieldSchema.title) || t(currentRoute?.title);
if (title) {
@ -410,6 +426,9 @@ const NocoBasePageHeader = React.memo(({ activeKey, className }: { activeKey: st
{!disablePageHeader && (
<AntdPageHeader
className={classNames('pageHeaderCss', pageTitle || enablePageTabs ? '' : 'height0')}
style={{
paddingBottom: currentRoute.enableTabs || hidePageTitle ? 0 : token.paddingSM,
}}
ghost={false}
// 如果标题为空的时候会导致 PageHeader 不渲染,所以这里设置一个空白字符,然后再设置高度为 0
title={hidePageTitle ? ' ' : (!fieldSchema.title && pageTitle ? routeT(pageTitle) : pageTitle) || ' '}
@ -480,19 +499,6 @@ export function getTabSchema({
'x-initializer': 'page:addBlock',
properties: {},
'x-uid': schemaUid,
'x-async': true,
};
}
function getDefaultActiveKey(defaultTabSchemaUid: string, fieldSchema: Schema) {
if (!fieldSchema.properties) {
return '';
}
const tabSchemaList = Object.values(fieldSchema.properties);
for (const tabSchema of tabSchemaList) {
if (tabSchema['x-uid'] === defaultTabSchemaUid) {
return tabSchema.name as string;
}
}
}

View File

@ -9,12 +9,13 @@
import { ExclamationCircleFilled } from '@ant-design/icons';
import { ISchema } from '@formily/json-schema';
import { useFieldSchema } from '@formily/react';
import { App, Modal } from 'antd';
import _ from 'lodash';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigateNoUpdate } from '../../../application/CustomRouterContextProvider';
import { SchemaSettings } from '../../../application/schema-settings/SchemaSettings';
import { useSchemaToolbar } from '../../../application/schema-toolbar';
import { useCurrentRoute } from '../../../route-switch';
import { useDesignable } from '../../hooks';
import { useNocoBaseRoutes } from '../menu/Menu';
@ -29,9 +30,8 @@ export const pageTabSettings = new SchemaSettings({
type: 'modal',
useComponentProps() {
const { t } = useTranslation();
const { schema } = useSchemaToolbar<{ schema: ISchema }>();
const { dn } = useDesignable();
const { updateRoute } = useNocoBaseRoutes();
const currentRoute = useCurrentRoute();
return {
title: t('Edit'),
@ -54,20 +54,10 @@ export const pageTabSettings = new SchemaSettings({
},
},
} as ISchema,
initialValues: { title: schema.title, icon: schema['x-icon'] },
initialValues: { title: currentRoute.title, icon: currentRoute.icon },
onSubmit: ({ title, icon }) => {
schema.title = title;
schema['x-icon'] = icon;
dn.emit('patch', {
schema: {
['x-uid']: schema['x-uid'],
title,
'x-icon': icon,
},
});
// 更新路由
updateRoute(schema['__route__'].id, {
updateRoute(currentRoute.id, {
title,
icon,
});
@ -80,33 +70,23 @@ export const pageTabSettings = new SchemaSettings({
type: 'switch',
useComponentProps() {
const { t } = useTranslation();
const { schema } = useSchemaToolbar<{ schema: ISchema }>();
const { updateRoute } = useNocoBaseRoutes();
const { dn } = useDesignable();
const currentRoute = useCurrentRoute();
return {
title: t('Hidden'),
checked: schema['x-component-props']?.hidden,
checked: currentRoute.hideInMenu,
onChange: (v) => {
Modal.confirm({
title: '确定要隐藏该菜单吗?',
title: t('Are you sure you want to hide this tab?'),
icon: <ExclamationCircleFilled />,
content: '隐藏后,该菜单将不再显示在菜单栏中。如需再次显示,需要去路由管理页面设置。',
content: t(
'After hiding, this tab will no longer appear in the tab bar. To show it again, you need to go to the route management page to set it.',
),
async onOk() {
_.set(schema, 'x-component-props.hidden', !!v);
// 更新菜单对应的路由
if (schema['__route__']?.id) {
await updateRoute(schema['__route__'].id, {
hideInMenu: !!v,
});
}
dn.emit('patch', {
schema: {
'x-uid': schema['x-uid'],
'x-component-props': schema['x-component-props'],
},
// Update the route corresponding to the menu
await updateRoute(currentRoute.id, {
hideInMenu: !!v,
});
},
});
@ -125,7 +105,11 @@ export const pageTabSettings = new SchemaSettings({
const { modal } = App.useApp();
const { dn } = useDesignable();
const { t } = useTranslation();
const { schema } = useSchemaToolbar();
const { deleteRoute } = useNocoBaseRoutes();
const currentRoute = useCurrentRoute();
const navigate = useNavigateNoUpdate();
const schema = useFieldSchema();
return {
title: t('Delete'),
eventKey: 'remove',
@ -134,8 +118,18 @@ export const pageTabSettings = new SchemaSettings({
title: t('Delete block'),
content: t('Are you sure you want to delete it?'),
...confirm,
onOk() {
dn.remove(schema);
async onOk() {
await deleteRoute(currentRoute.id);
dn.emit('remove', {
removed: {
'x-uid': currentRoute.schemaUid,
},
});
// 如果删除的是当前打开的 tab需要跳转到其他 tab
if (window.location.pathname.includes(currentRoute.schemaUid)) {
navigate(`/admin/${schema['x-uid']}`);
}
},
});
},

View File

@ -8,7 +8,6 @@
*/
import { DragOutlined } from '@ant-design/icons';
import { useFieldSchema } from '@formily/react';
import { Space } from 'antd';
import React from 'react';
import { DragHandler, useDesignable } from '../..';
@ -31,29 +30,25 @@ export const PageDesigner = ({ title }) => {
);
};
export const PageTabDesigner = ({ schema }) => {
export const PageTabDesigner = () => {
const { designable } = useDesignable();
const { getAriaLabel } = useGetAriaLabelOfDesigner();
const fieldSchema = useFieldSchema();
const { render } = useSchemaSettingsRender(
fieldSchema['x-settings'] || 'PageTabSettings',
fieldSchema['x-settings-props'],
);
const { render } = useSchemaSettingsRender('PageTabSettings');
if (!designable) {
return null;
}
return (
<SchemaToolbarProvider schema={schema}>
<div className={'general-schema-designer'}>
<div className={'general-schema-designer-icons'}>
<Space size={3} align={'center'}>
<DragHandler>
<DragOutlined style={{ marginRight: 0 }} role="button" aria-label={getAriaLabel('drag-handler', 'tab')} />
</DragHandler>
{render()}
</Space>
</div>
<div className={'general-schema-designer'}>
<div className={'general-schema-designer-icons'}>
<Space size={3} align={'center'}>
<DragHandler>
<DragOutlined style={{ marginRight: 0 }} role="button" aria-label={getAriaLabel('drag-handler', 'tab')} />
</DragHandler>
{render()}
</Space>
</div>
</SchemaToolbarProvider>
</div>
);
};

View File

@ -8,26 +8,10 @@
*/
import { DocumentTitleProvider, Form, FormItem, Grid, IconPicker, Input } from '@nocobase/client';
import { render, renderAppOptions, screen, userEvent, waitFor } from '@nocobase/test/client';
import React from 'react';
import App1 from '../demos/demo1';
import { renderAppOptions, screen, userEvent, waitFor } from '@nocobase/test/client';
import { isTabPage, navigateToTab, Page } from '../Page';
describe('Page', () => {
it('should render correctly', async () => {
render(<App1 />);
await waitFor(() => {
expect(screen.getByText(/page title/i)).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText(/page content/i)).toBeInTheDocument();
});
await waitFor(() => {
expect(document.title).toBe('Page Title - NocoBase');
});
});
describe('Page Component', () => {
const title = 'Test Title';

View File

@ -1,39 +0,0 @@
import { ISchema } from '@formily/react';
import { DocumentTitleProvider, Page, SchemaComponent, SchemaComponentProvider, Application } from '@nocobase/client';
import React from 'react';
const schema: ISchema = {
type: 'object',
properties: {
page1: {
type: 'void',
'x-component': 'Page',
title: 'Page Title',
properties: {
content: {
type: 'void',
'x-component': 'div',
'x-content': 'Page Content',
},
},
},
},
};
const Root = () => {
return (
<SchemaComponentProvider components={{ Page }}>
<DocumentTitleProvider addonAfter={'NocoBase'}>
<SchemaComponent schema={schema} />
</DocumentTitleProvider>
</SchemaComponentProvider>
);
};
const app = new Application({
providers: [Root],
});
export default app.getRootComponent();

View File

@ -72,7 +72,6 @@ const InternalRemoteSelect = withDynamicSchemaProps(
} = props;
const dataSource = useDataSourceKey();
const headers = useDataSourceHeaders(propsDataSource || dataSource);
const [open, setOpen] = useState(false);
const firstRun = useRef(false);
const fieldSchema = useFieldSchema();
const isQuickAdd = fieldSchema['x-component-props']?.addMode === 'quickAdd';
@ -197,7 +196,6 @@ const InternalRemoteSelect = withDynamicSchemaProps(
search={searchData.current}
callBack={() => {
searchData.current = null;
setOpen(false);
}}
/>
);
@ -244,7 +242,6 @@ const InternalRemoteSelect = withDynamicSchemaProps(
}, [value, defaultValue, data?.data, fieldNames.value, optionFilter]);
const onDropdownVisibleChange = (visible) => {
setOpen(visible);
searchData.current = null;
if (visible) {
run();
@ -254,7 +251,6 @@ const InternalRemoteSelect = withDynamicSchemaProps(
return (
<Select
open={open}
popupMatchSelectWidth={popupMatchSelectWidth}
autoClearSearchValue
filterOption={false}

View File

@ -84,4 +84,5 @@ const RequestSchemaComponent: React.FC<RemoteSchemaComponentProps> = (props) =>
export const RemoteSchemaComponent: React.FC<RemoteSchemaComponentProps> = memo((props) => {
return props.uid ? <RequestSchemaComponent {...props} /> : null;
});
RemoteSchemaComponent.displayName = 'RemoteSchemaComponent';

View File

@ -17,13 +17,14 @@ import React, {
FC,
startTransition,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
useContext,
} from 'react';
import { useTranslation } from 'react-i18next';
import { SchemaComponentContext } from '../';
import { SchemaInitializer, SchemaSettings, SchemaToolbarProvider, useSchemaInitializerRender } from '../application';
import { useSchemaSettingsRender } from '../application/schema-settings/hooks/useSchemaSettingsRender';
import { useDataSourceManager } from '../data-source/data-source/DataSourceManagerProvider';
@ -33,7 +34,6 @@ import { DragHandler, useCompile, useDesignable, useGridContext, useGridRowConte
import { gridRowColWrap } from '../schema-initializer/utils';
import { SchemaSettingsDropdown } from './SchemaSettings';
import { useGetAriaLabelOfDesigner } from './hooks/useGetAriaLabelOfDesigner';
import { SchemaComponentContext } from '../';
import { useStyles } from './styles';
const titleCss = css`
@ -214,6 +214,11 @@ export interface SchemaToolbarProps {
spaceWrapperStyle?: React.CSSProperties;
spaceClassName?: string;
spaceStyle?: React.CSSProperties;
/**
* The HTML element that listens for mouse enter/leave events.
* Parent element is used by default.
*/
container?: HTMLElement;
onVisibleChange?: (nextVisible: boolean) => void;
}
@ -232,6 +237,7 @@ const InternalSchemaToolbar: FC<SchemaToolbarProps> = React.memo((props) => {
spaceStyle,
toolbarClassName,
toolbarStyle = {},
container,
} = {
...props,
...(fieldSchema?.['x-toolbar-props'] || {}),
@ -325,7 +331,10 @@ const InternalSchemaToolbar: FC<SchemaToolbarProps> = React.memo((props) => {
while (parentElement && parentElement.clientHeight === 0) {
parentElement = parentElement.parentElement;
}
if (!parentElement) {
const el = container || parentElement;
if (!el) {
return;
}
@ -343,18 +352,18 @@ const InternalSchemaToolbar: FC<SchemaToolbarProps> = React.memo((props) => {
}
}
const style = window.getComputedStyle(parentElement);
if (style.position === 'static') {
parentElement.style.position = 'relative';
}
// const style = window.getComputedStyle(parentElement);
// if (style.position === 'static') {
// parentElement.style.position = 'relative';
// }
parentElement.addEventListener('mouseenter', show);
parentElement.addEventListener('mouseleave', hide);
el.addEventListener('mouseenter', show);
el.addEventListener('mouseleave', hide);
return () => {
parentElement.removeEventListener('mouseenter', show);
parentElement.removeEventListener('mouseleave', hide);
el.removeEventListener('mouseenter', show);
el.removeEventListener('mouseleave', hide);
};
}, [props.onVisibleChange]);
}, [props.onVisibleChange, container]);
const containerStyle = useMemo(
() => ({

View File

@ -20,7 +20,7 @@ export const useGetAriaLabelOfDesigner = () => {
const { name: _collectionName } = useCollection_deprecated();
const getAriaLabel = useCallback(
(name: string, postfix?: string) => {
if (!fieldSchema) return '';
if (!fieldSchema) return `designer-${name}-${postfix}`;
const component = fieldSchema['x-component'];
const componentName = typeof component === 'string' ? component : component?.displayName || component?.name;

View File

@ -0,0 +1,43 @@
/**
* 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 { MockDatabase, mockDatabase } from '../../mock-database';
describe('relation repository', () => {
let db: MockDatabase;
beforeEach(() => {
db = mockDatabase();
});
afterEach(async () => {
await db.clean({ drop: true });
await db.close();
});
it('should not convert string source id to number', async () => {
db.collection({
name: 'tags',
fields: [{ type: 'string', name: 'name' }],
});
const User = db.collection({
name: 'users',
autoGenId: false,
fields: [
{ type: 'string', name: 'name' },
{ type: 'hasMany', name: 'tags', sourceKey: 'name', target: 'tags', foreignKey: 'userId' },
],
});
await db.sync();
await User.repository.create({
values: { name: '123', tags: [{ name: 'tag1' }] },
});
const repo = db.getRepository('users.tags', '123');
await expect(repo.find()).resolves.not.toThrow();
});
});

View File

@ -57,7 +57,8 @@ export abstract class RelationRepository {
decodeMultiTargetKey(str: string) {
try {
const decoded = decodeURIComponent(str);
return JSON.parse(decoded);
const parsed = JSON.parse(decoded);
return typeof parsed === 'object' && parsed !== null ? parsed : decoded;
} catch (e) {
return false;
}

View File

@ -13,6 +13,24 @@ import { Browser, Page, test as base, expect, request } from '@playwright/test';
import _ from 'lodash';
import { defineConfig } from './defineConfig';
function getPageMenuSchema({ pageSchemaUid, tabSchemaUid, tabSchemaName }) {
return {
type: 'void',
'x-component': 'Page',
properties: {
[tabSchemaName]: {
type: 'void',
'x-component': 'Grid',
'x-initializer': 'page:addBlock',
properties: {},
'x-uid': tabSchemaUid,
'x-async': true,
},
},
'x-uid': pageSchemaUid,
};
}
export * from '@playwright/test';
export { defineConfig };
@ -360,7 +378,7 @@ export class NocoPage {
this.uid = schemaUid;
this.desktopRouteId = routeId;
this.url = `${this.options?.basePath || '/admin/'}${this.uid}`;
this.url = `${this.options?.basePath || '/admin/'}${this.uid || this.desktopRouteId}`;
}
async goto() {
@ -393,7 +411,7 @@ export class NocoPage {
async destroy() {
const waitList: any[] = [];
if (this.uid) {
if (this.uid || this.desktopRouteId !== undefined) {
waitList.push(deletePage(this.uid, this.desktopRouteId));
this.uid = undefined;
this.desktopRouteId = undefined;
@ -723,30 +741,15 @@ const createPage = async (options?: CreatePageOptions) => {
const api = await request.newContext({
storageState: process.env.PLAYWRIGHT_AUTH_FILE,
});
const typeToSchema = {
group: {
'x-component': 'Menu.SubMenu',
'x-component-props': {},
},
page: {
'x-component': 'Menu.Item',
'x-component-props': {},
},
link: {
'x-component': 'Menu.URL',
'x-component-props': {
href: url,
},
},
};
const state = await api.storageState();
const headers = getHeaders(state);
const menuSchemaUid = pageUidFromOptions || uid();
const pageSchemaUid = uid();
const tabSchemaUid = uid();
const tabSchemaName = uid();
const title = name || menuSchemaUid;
const newPageSchema = keepUid ? pageSchema : updateUidOfPageSchema(pageSchema);
const pageSchemaUid = newPageSchema?.['x-uid'] || uid();
const newTabSchemaUid = uid();
const newTabSchemaName = uid();
const title = name || pageSchemaUid;
let routeId;
let schemaUid;
@ -756,7 +759,6 @@ const createPage = async (options?: CreatePageOptions) => {
data: {
type: 'group',
title,
schemaUid: menuSchemaUid,
hideInMenu: false,
},
});
@ -767,17 +769,15 @@ const createPage = async (options?: CreatePageOptions) => {
const data = await result.json();
routeId = data.data?.id;
schemaUid = menuSchemaUid;
}
if (type === 'page') {
const result = await api.post('/api/desktopRoutes:create', {
const routeResult = await api.post('/api/desktopRoutes:create', {
headers,
data: {
type: 'page',
title,
schemaUid: newPageSchema?.['x-uid'] || pageSchemaUid,
menuSchemaUid,
schemaUid: pageSchemaUid,
hideInMenu: false,
enableTabs: !!newPageSchema?.['x-component-props']?.enablePageTabs,
children: newPageSchema
@ -786,21 +786,36 @@ const createPage = async (options?: CreatePageOptions) => {
{
type: 'tabs',
title: '{{t("Unnamed")}}',
schemaUid: tabSchemaUid,
tabSchemaName,
schemaUid: newTabSchemaUid,
tabSchemaName: newTabSchemaName,
hideInMenu: false,
},
],
},
});
if (!result.ok()) {
throw new Error(await result.text());
if (!routeResult.ok()) {
throw new Error(await routeResult.text());
}
const data = await result.json();
const schemaResult = await api.post(`/api/uiSchemas:insert`, {
headers,
data:
newPageSchema ||
getPageMenuSchema({
pageSchemaUid,
tabSchemaUid: newTabSchemaUid,
tabSchemaName: newTabSchemaName,
}),
});
if (!schemaResult.ok()) {
throw new Error(await routeResult.text());
}
const data = await routeResult.json();
routeId = data.data?.id;
schemaUid = menuSchemaUid;
schemaUid = pageSchemaUid;
}
if (type === 'link') {
@ -809,7 +824,6 @@ const createPage = async (options?: CreatePageOptions) => {
data: {
type: 'link',
title,
schemaUid: menuSchemaUid,
hideInMenu: false,
options: {
href: url,
@ -823,50 +837,6 @@ const createPage = async (options?: CreatePageOptions) => {
const data = await result.json();
routeId = data.data?.id;
schemaUid = menuSchemaUid;
}
const result = await api.post(`/api/uiSchemas:insertAdjacent/nocobase-admin-menu?position=beforeEnd`, {
headers,
data: {
schema: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
title,
...typeToSchema[type],
'x-decorator': 'ACLMenuItemProvider',
properties: {
page: newPageSchema || {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Page',
'x-async': true,
properties: {
[tabSchemaName]: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer': 'page:addBlock',
'x-uid': tabSchemaUid,
name: tabSchemaName,
},
},
'x-uid': pageSchemaUid,
name: 'page',
},
},
name: uid(),
'x-uid': menuSchemaUid,
},
wrap: null,
},
});
if (!result.ok()) {
throw new Error(await result.text());
}
return { schemaUid, routeId };
@ -1063,7 +1033,7 @@ const deleteMobileRoutes = async (mobileRouteId: number) => {
};
/**
* uid NocoBase
* uid schema id
*/
const deletePage = async (pageUid: string, routeId: number) => {
const api = await request.newContext({
@ -1083,12 +1053,14 @@ const deletePage = async (pageUid: string, routeId: number) => {
}
}
const result = await api.post(`/api/uiSchemas:remove/${pageUid}`, {
headers,
});
if (pageUid) {
const result = await api.post(`/api/uiSchemas:remove/${pageUid}`, {
headers,
});
if (!result.ok()) {
throw new Error(await result.text());
if (!result.ok()) {
throw new Error(await result.text());
}
}
};

View File

@ -8,7 +8,6 @@
*/
import { expect, test } from '@nocobase/test/e2e';
import { oneTableBlock } from './utils';
test('allows to configure interface', async ({ page, mockPage, mockRole, updateRole }) => {
await mockPage().goto();
@ -121,13 +120,13 @@ test('new menu items allow to be asscessed by default ', async ({ page, mockPage
window.localStorage.setItem('NOCOBASE_ROLE', roleData.name);
}, roleData);
await page.reload();
await mockPage({ ...oneTableBlock, name: 'new page' }).goto();
await mockPage({ name: 'new page' }).goto();
await expect(page.getByLabel('new page')).not.toBeVisible();
await updateRole({
name: roleData.name,
allowNewMenu: true,
});
await mockPage({ ...oneTableBlock, name: 'new page' }).goto();
await mockPage({ name: 'new page' }).goto();
await expect(page.getByLabel('new page')).toBeVisible();
});

View File

@ -9,7 +9,14 @@
import { createForm, Form, onFormValuesChange } from '@formily/core';
import { uid } from '@formily/shared';
import { css, SchemaComponent, useAPIClient, useCompile, useRequest } from '@nocobase/client';
import {
css,
SchemaComponent,
useAllAccessDesktopRoutes,
useAPIClient,
useCompile,
useRequest,
} from '@nocobase/client';
import { useMemoizedFn } from 'ahooks';
import { Checkbox, message, Table } from 'antd';
import { uniq } from 'lodash';
@ -68,7 +75,8 @@ const style = css`
const translateTitle = (menus: any[], t, compile) => {
return menus.map((menu) => {
const title = menu.title?.match(/^\s*\{\{\s*.+?\s*\}\}\s*$/) ? compile(menu.title) : t(menu.title);
const title =
(menu.title?.match(/^\s*\{\{\s*.+?\s*\}\}\s*$/) ? compile(menu.title) : t(menu.title)) || t('Unnamed');
if (menu.children) {
return {
...menu,
@ -166,6 +174,7 @@ export const MenuPermissions: React.FC<{
);
const resource = api.resource('roles.desktopRoutes', role.name);
const allChecked = allIDList.length === IDList.length;
const { refresh: refreshDesktopRoutes } = useAllAccessDesktopRoutes();
const handleChange = async (checked, menuItem) => {
// 处理取消选中
@ -214,6 +223,7 @@ export const MenuPermissions: React.FC<{
values: shouldAdd,
});
}
refreshDesktopRoutes();
message.success(t('Saved successfully'));
};
@ -288,6 +298,7 @@ export const MenuPermissions: React.FC<{
});
}
refresh();
refreshDesktopRoutes();
message.success(t('Saved successfully'));
}}
/>{' '}

View File

@ -382,8 +382,11 @@ export const oneTableWithViewAction: PageConfig = {
'x-index': 1,
},
},
'x-uid': 'j0k2m5r9z3b',
'x-async': false,
},
},
'x-uid': 'l6ioayfnq6c',
},
};

View File

@ -20,6 +20,7 @@ import {
handleDateChangeOnForm,
useACLRoleContext,
useActionContext,
useApp,
useCollection,
useCollectionParentRecordData,
useDesignable,
@ -27,10 +28,8 @@ import {
useLazy,
usePopupUtils,
useProps,
useToken,
withDynamicSchemaProps,
withSkeletonComponent,
useApp,
} from '@nocobase/client';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';

View File

@ -6,8 +6,8 @@
* 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 { Plugin, useToken } from '@nocobase/client';
import React from 'react';
import { generateNTemplate } from '../locale';
import { CalendarV2 } from './calendar';
import { calendarBlockSettings } from './calendar/Calender.Settings';

View File

@ -13,8 +13,6 @@ import { useField, useForm } from '@formily/react';
import {
CollectionField,
css,
getGroupMenuSchema,
getLinkMenuSchema,
getPageMenuSchema,
getTabSchema,
getVariableComponentWithScope,
@ -26,6 +24,7 @@ import {
useCollectionRecordData,
useDataBlockRequestData,
useDataBlockRequestGetter,
useInsertPageSchema,
useNocoBaseRoutes,
useRequest,
useRouterBasename,
@ -1244,22 +1243,11 @@ function useCreateRouteSchema(isMobile: boolean) {
const collectionName = 'uiSchemas';
const api = useAPIClient();
const resource = useMemo(() => api.resource(collectionName), [api, collectionName]);
const insertPageSchema = useInsertPageSchema();
const createRouteSchema = useCallback(
async ({
title,
icon,
type,
href,
params,
}: {
title: string;
icon: string;
type: NocoBaseDesktopRouteType;
href?: string;
params?: Record<string, any>;
}) => {
const menuSchemaUid = uid();
async ({ type }: { type: NocoBaseDesktopRouteType }) => {
const menuSchemaUid = isMobile ? undefined : uid();
const pageSchemaUid = uid();
const tabSchemaName = uid();
const tabSchemaUid = type === NocoBaseDesktopRouteType.page ? uid() : undefined;
@ -1268,17 +1256,16 @@ function useCreateRouteSchema(isMobile: boolean) {
[NocoBaseDesktopRouteType.page]: isMobile
? getMobilePageSchema(pageSchemaUid, tabSchemaUid).schema
: getPageMenuSchema({
title,
icon,
pageSchemaUid,
tabSchemaUid,
menuSchemaUid,
tabSchemaName,
}),
[NocoBaseDesktopRouteType.group]: getGroupMenuSchema({ title, icon, schemaUid: menuSchemaUid }),
[NocoBaseDesktopRouteType.link]: getLinkMenuSchema({ title, icon, schemaUid: menuSchemaUid, href, params }),
};
if (!typeToSchema[type]) {
return {};
}
if (isMobile) {
await resource['insertAdjacent']({
resourceIndex: 'mobile',
@ -1288,17 +1275,12 @@ function useCreateRouteSchema(isMobile: boolean) {
},
});
} else {
await resource['insertAdjacent/nocobase-admin-menu']({
position: 'beforeEnd',
values: {
schema: typeToSchema[type],
},
});
await insertPageSchema(typeToSchema[type]);
}
return { menuSchemaUid, pageSchemaUid, tabSchemaUid, tabSchemaName };
},
[isMobile, resource],
[isMobile, resource, insertPageSchema],
);
/**

View File

@ -0,0 +1,99 @@
/**
* 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 { MockServer, createMockServer } from '@nocobase/test';
import Migration, { getIds } from '../migrations/202502071837-fix-permissions';
import { vi, describe, beforeEach, afterEach, test, expect } from 'vitest';
describe('202502071837-fix-permissions', () => {
let app: MockServer;
beforeEach(async () => {
app = await createMockServer({
plugins: ['nocobase'],
});
await app.version.update('1.5.0');
});
afterEach(async () => {
await app.destroy();
});
async function createTestData() {
const desktopRoutes = app.db.getRepository('desktopRoutes');
const roles = app.db.getRepository('roles');
// 创建测试路由
const routes = await desktopRoutes.create({
values: [
{
type: 'page',
title: 'Page 1',
menuSchemaUid: 'page1',
children: [{ type: 'tabs', title: 'Tabs 1', parentId: 1, schemaUid: 'tabs1' }],
},
{
type: 'page',
title: 'Page 2',
menuSchemaUid: 'page2',
},
],
});
// 创建测试角色
const role = await roles.create({
values: {
name: 'test',
menuUiSchemas: [
{ 'x-uid': 'page1' }, // 已有 page1 的权限
{ 'x-uid': 'page3' }, // 不存在的权限
],
},
});
return { routes, role };
}
test('should add missing permissions', async () => {
const { role } = await createTestData();
const migration = new Migration({ db: app.db, app } as any);
await migration.up();
// 获取更新后的角色权限
const updatedRole = await app.db.getRepository('roles').findOne({
filter: { name: 'test' },
appends: ['desktopRoutes'],
});
// 验证应该添加的权限
const routeIds = updatedRole.desktopRoutes.map((r) => r.id);
expect(routeIds).toContain(1); // page1 已存在
expect(routeIds).toContain(2); // tabs1 应该被添加
expect(routeIds).not.toContain(3); // page2 不应该被添加
});
test('should handle empty desktop routes', async () => {
const migration = new Migration({ db: app.db, app } as any);
await expect(migration.up()).resolves.not.toThrow();
});
test('getIds should return correct needAddIds', () => {
const desktopRoutes = [
{ id: 1, type: 'page', menuSchemaUid: 'page1' },
{ id: 2, type: 'tabs', parentId: 1, schemaUid: 'tabs1' },
{ id: 3, type: 'page', menuSchemaUid: 'page2' },
];
const menuUiSchemas = [{ 'x-uid': 'page1' }];
const { needAddIds } = getIds(desktopRoutes, menuUiSchemas);
expect(needAddIds).toEqual([1, 2]); // page1 已存在但 tabs1/page2 需要添加
});
});

View File

@ -0,0 +1,170 @@
/**
* 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 Database from '@nocobase/database';
import { createMockServer, MockServer } from '@nocobase/test';
describe('desktopRoutes:listAccessible', () => {
let app: MockServer;
let db: Database;
beforeEach(async () => {
app = await createMockServer({
registerActions: true,
acl: true,
plugins: ['nocobase'],
});
db = app.db;
// 创建测试页面和tab路由
await db.getRepository('desktopRoutes').create({
values: [
{
type: 'page',
title: 'page1',
children: [{ type: 'tab', title: 'tab1' }],
},
{
type: 'page',
title: 'page2',
children: [{ type: 'tab', title: 'tab2' }],
},
{
type: 'page',
title: 'page3',
children: [{ type: 'tab', title: 'tab3' }],
},
],
});
});
afterEach(async () => {
await app.destroy();
});
it('should return all routes for root role', async () => {
const rootUser = await db.getRepository('users').create({
values: { roles: ['root'] },
});
const agent = await app.agent().login(rootUser);
const response = await agent.resource('desktopRoutes').listAccessible();
expect(response.status).toBe(200);
expect(response.body.data.length).toBe(3);
expect(response.body.data[0].children.length).toBe(1);
});
it('should return all routes by default for admin/member', async () => {
// 测试 admin 角色
const adminUser = await db.getRepository('users').create({
values: { roles: ['admin'] },
});
const adminAgent = await app.agent().login(adminUser);
let response = await adminAgent.resource('desktopRoutes').listAccessible();
expect(response.body.data.length).toBe(3);
// 测试 member 角色
const memberUser = await db.getRepository('users').create({
values: { roles: ['member'] },
});
const memberAgent = await app.agent().login(memberUser);
response = await memberAgent.resource('desktopRoutes').listAccessible();
expect(response.body.data.length).toBe(3);
});
it('should return filtered routes with children', async () => {
// 使用 root 角色配置 member 的可访问路由
const rootUser = await db.getRepository('users').create({
values: { roles: ['root'] },
});
const rootAgent = await app.agent().login(rootUser);
// 更新 member 角色的可访问路由
await rootAgent.resource('roles.desktopRoutes', 'member').remove({
values: [1, 2, 3, 4, 5, 6], // 移除所有路由的访问权限
});
await rootAgent.resource('roles.desktopRoutes', 'member').add({
values: [1, 2], // 再加上 page1 和 tab1 的访问权限
});
// 使用 member 用户测试
const memberUser = await db.getRepository('users').create({
values: { roles: ['member'] },
});
const memberAgent = await app.agent().login(memberUser);
const response = await memberAgent.resource('desktopRoutes').listAccessible();
expect(response.body.data.length).toBe(1);
expect(response.body.data[0].title).toBe('page1');
expect(response.body.data[0].children.length).toBe(1);
expect(response.body.data[0].children[0].title).toBe('tab1');
});
it('should return an empty response when there are no accessible routes', async () => {
// 使用 root 角色配置 member 的可访问路由
const rootUser = await db.getRepository('users').create({
values: { roles: ['root'] },
});
const rootAgent = await app.agent().login(rootUser);
// 更新 member 角色的可访问路由
await rootAgent.resource('roles.desktopRoutes', 'member').remove({
values: [1, 2, 3, 4, 5, 6], // 移除所有路由的访问权限
});
// 使用 member 用户测试
const memberUser = await db.getRepository('users').create({
values: { roles: ['member'] },
});
const memberAgent = await app.agent().login(memberUser);
const response = await memberAgent.resource('desktopRoutes').listAccessible();
expect(response.body.data.length).toBe(0);
});
it('should auto include children when page has no children', async () => {
// 创建一个没有子路由的页面
const page4 = await db.getRepository('desktopRoutes').create({
values: {
type: 'page',
title: 'page4',
},
});
// 创建两个子路由
await db.getRepository('desktopRoutes').create({
values: [
{ type: 'tab', title: 'tab4-1', parentId: page4.id },
{ type: 'tab', title: 'tab4-2', parentId: page4.id },
],
});
// 配置 member 角色只能访问 page4
const rootUser = await db.getRepository('users').create({
values: { roles: ['root'] },
});
const rootAgent = await app.agent().login(rootUser);
await rootAgent.resource('roles.desktopRoutes', 'member').remove({
values: [1, 2, 3, 4, 5, 6, 8, 9], // 只保留 page4 的访问权限
});
// 验证返回结果包含子路由
const memberUser = await db.getRepository('users').create({
values: { roles: ['member'] },
});
const memberAgent = await app.agent().login(memberUser);
const response = await memberAgent.resource('desktopRoutes').listAccessible();
expect(response.body.data.length).toBe(1);
expect(response.body.data[0].title).toBe('page4');
expect(response.body.data[0].children.length).toBe(2);
});
});

View File

@ -8,14 +8,14 @@
*/
import { Model } from '@nocobase/database';
import PluginLocalizationServer from '@nocobase/plugin-localization';
import { Plugin } from '@nocobase/server';
import { tval } from '@nocobase/utils';
import * as process from 'node:process';
import { resolve } from 'path';
import { getAntdLocale } from './antd';
import { getCronLocale } from './cron';
import { getCronstrueLocale } from './cronstrue';
import PluginLocalizationServer from '@nocobase/plugin-localization';
import { tval } from '@nocobase/utils';
async function getLang(ctx) {
const SystemSetting = ctx.db.getRepository('systemSettings');
@ -216,20 +216,35 @@ export class PluginClientServer extends Plugin {
appends: ['desktopRoutes'],
});
const desktopRoutesId = role
.get('desktopRoutes')
// hidden 为 true 的节点不会显示在权限配置表格中,所以无法被配置,需要被过滤掉
.filter((item) => !item.hidden)
.map((item) => item.id);
// 1. 如果 page 的 children 为空,那么需要把 page 的 children 全部找出来,然后返回。否则前端会因为缺少 tab 路由的数据而导致页面空白
// 2. 如果 page 的 children 不为空,不需要做特殊处理
const desktopRoutesId = role.get('desktopRoutes').map(async (item, index, items) => {
if (item.type === 'page' && !items.some((tab) => tab.parentId === item.id)) {
const children = await desktopRoutesRepository.find({
filter: {
parentId: item.id,
},
});
ctx.body = await desktopRoutesRepository.find({
tree: true,
...ctx.query,
filter: {
id: desktopRoutesId,
},
return [item.id, ...(children || []).map((child) => child.id)];
}
return item.id;
});
if (desktopRoutesId) {
const ids = (await Promise.all(desktopRoutesId)).flat();
const result = await desktopRoutesRepository.find({
tree: true,
...ctx.query,
filter: {
id: ids,
},
});
ctx.body = result;
}
await next();
});
}

View File

@ -29,25 +29,15 @@ export const useStyles = genStyleHook('nb-mobile-navigation-bar-action', (token)
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'.schema-toolbar': {
inset: '-15px -8px',
},
},
'.nb-navigation-bar-action-title': {
fontSize: 17,
padding: 0,
'.schema-toolbar': {
inset: '-15px -8px',
},
},
'.nb-navigation-bar-action-icon-and-title': {
height: '32px !important',
fontSize: '17px !important',
padding: '0 6px !important',
'.schema-toolbar': {
inset: '-15px',
},
},
},
};

View File

@ -7,20 +7,20 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { FC } from 'react';
import { App } from 'antd';
import { useNavigate } from 'react-router-dom';
import {
SchemaSettings,
SchemaToolbar,
useSchemaToolbar,
SchemaToolbarProvider,
createTextSettingsItem,
SchemaSettings,
SchemaSettingsItemType,
SchemaToolbar,
SchemaToolbarProvider,
useSchemaToolbar,
} from '@nocobase/client';
import { App } from 'antd';
import React, { FC } from 'react';
import { useNavigate } from 'react-router-dom';
import { MobileRouteItem, useMobileRoutes } from '../../../../mobile-providers';
import { generatePluginTranslationTemplate, usePluginTranslation } from '../../../../locale';
import { MobileRouteItem, useMobileRoutes } from '../../../../mobile-providers';
const remove = createTextSettingsItem({
name: 'remove',
@ -118,7 +118,7 @@ export const MobilePageTabsSettings: FC<MobilePageTabsSettingsProps> = ({ tab })
settings={mobilePageTabsSettings}
showBackground
showBorder={false}
toolbarStyle={{ inset: '-15px -12px' }}
toolbarStyle={{ inset: '0 -12px' }}
spaceWrapperStyle={{ top: 3 }}
/>
</SchemaToolbarProvider>

View File

@ -9,7 +9,7 @@
export const editActionSchema = {
type: 'void',
title: 'Edit',
title: "{{t('Edit')}}",
'x-component': 'Action.Link',
'x-component-props': {
openMode: 'drawer',

View File

@ -238,7 +238,7 @@ export const publicFormsSchema: ISchema = {
editActionSchema,
delete: {
type: 'void',
title: 'Delete',
title: "{{t('Delete')}}",
'x-component': 'Action.Link',
'x-use-component-props': 'useDeleteActionProps',
},

View File

@ -18,22 +18,22 @@ const { ExecutionPage } = lazy(() => import('./ExecutionPage'), 'ExecutionPage')
const { WorkflowPage } = lazy(() => import('./WorkflowPage'), 'WorkflowPage');
const { WorkflowPane } = lazy(() => import('./WorkflowPane'), 'WorkflowPane');
import { Trigger } from './triggers';
import CollectionTrigger from './triggers/collection';
import ScheduleTrigger from './triggers/schedule';
import { NAMESPACE } from './locale';
import { Instruction } from './nodes';
import CalculationInstruction from './nodes/calculation';
import ConditionInstruction from './nodes/condition';
import CreateInstruction from './nodes/create';
import DestroyInstruction from './nodes/destroy';
import EndInstruction from './nodes/end';
import QueryInstruction from './nodes/query';
import CreateInstruction from './nodes/create';
import UpdateInstruction from './nodes/update';
import DestroyInstruction from './nodes/destroy';
import { getWorkflowDetailPath, getWorkflowExecutionsPath } from './utils';
import { lang, NAMESPACE } from './locale';
import { VariableOption } from './variable';
import { WorkflowTasks, TasksProvider, TaskTypeOptions } from './WorkflowTasks';
import { BindWorkflowConfig } from './settings/BindWorkflowConfig';
import { Trigger } from './triggers';
import CollectionTrigger from './triggers/collection';
import ScheduleTrigger from './triggers/schedule';
import { getWorkflowDetailPath, getWorkflowExecutionsPath } from './utils';
import { VariableOption } from './variable';
import { TasksProvider, TaskTypeOptions, WorkflowTasks } from './WorkflowTasks';
const workflowConfigSettings = {
Component: BindWorkflowConfig,
@ -182,15 +182,14 @@ export default class PluginWorkflowClient extends Plugin {
}
export * from './Branch';
export * from './FlowContext';
export * from './constants';
export * from './nodes';
export { Trigger, useTrigger } from './triggers';
export * from './variable';
export * from './components';
export * from './utils';
export * from './hooks';
export { default as useStyles } from './style';
export * from './variable';
export * from './constants';
export * from './ExecutionContextProvider';
export * from './FlowContext';
export * from './hooks';
export * from './nodes';
export * from './settings/BindWorkflowConfig';
export { default as useStyles } from './style';
export { Trigger, useTrigger } from './triggers';
export * from './utils';
export * from './variable';