refactor: make the menu responsive to screen width (#6331)

* chore: convert routes

* chore: stash

* chore: stash

* feat: support to add menu item

* feat: add MenuSchemaToolbar

* refactor: extract EditMenuItem component

* feat: add hidden option

* refactor: extract HiddenMenuItem

* feat: add 'Move to' option

* feat: add insert options

* feat: remove route

* fix: children

* fix: route

* feat: enhance menu item rendering and group handling in admin layout

* feat: add container support to MenuSchemaToolbar and fix display issue in Group

* fix: add conditional check before moving routes in menu item settings

* feat(navigation): add default page navigation for admin layout

* chore(versions): 😊 publish v1.6.0-alpha.24

* feat: export AppNotFound component and integrate 404 handling in AdminDynamicPage

* fix: update admin layout route path to use item options URL

* chore(versions): 😊 publish v1.6.0-alpha.25

* refactor: rename route node retrieval functions for clarity and add legacy route compatibility

* refactor: clean up layout component by removing unused styles and improving header rendering

* refactor: simplify menu item components by removing unused schema insertion logic and optimizing imports

* refactor: add 'x-async' property to tab schema and clean up server imports

* refactor: tabs

* feat: support extending frontend filter operators (#6085)

* feat: operator extension

* fix: bug

* refactor: code improve

* fix: jsonLogic

---------

Co-authored-by: chenos <chenlinxh@gmail.com>

* feat: add fake schema for routing in SortableItem and remove unused fieldSchema import

* feat: adjust content padding in InternalAdminLayout for improved layout

* refactor: remove registerOperators (#6224)

* refactor(plugin-workflow): trigger workflow action settings (#6143)

* refactor(plugin-workflow): move bind workflow settings to plugin

* refactor(plugin-block-workbench): move component to core

* refactor(plugin-block-workbench): adjust component api

* fix(plugin-workflow-action-trigger): fix test cases

* fix(plugin-workflow): fix component scope

* fix(plugin-workflow-action-trigger): fix test cases

* chore(versions): 😊 publish v1.6.0-alpha.26

* feat: support the extension of preset fields in collections (#6183)

* feat: support the extension of preset fields in collections

* fix: bug

* fix: bug

* fix: bug

* refactor: create collection

* fix: config

* fix: test case

* refactor: code improve

* refactor: code improve

* fix: bug

* fix: bug

---------

Co-authored-by: chenos <chenlinxh@gmail.com>

* feat: replace SchemaComponent with RemoteSchemaComponent and add AppNotFound for empty tabs

* refactor: rename useCurrentRoute to useCurrentRouteData for clarity

* fix: redirect to first tab by default

* feat: support for the extension of optional fields for Kanban, Calendar, and Formula Field plugins (#6076)

* feat: kanban field extention

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* feat: calender title fields

* feat: background color fields

* fix: bug

* fix: bug

* feat: formula field expression support field

* feat: preset fields

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* refactor: code improve

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* refactor: code improve

* revert: preset fields

* refactor: code improve

* refactor: code improve

* fix: bug

* fix: bug

* fix: bug

* refactor: code improve

* fix: bug

* refactor: code improve

* refactor: code improve

* fix: bug

* fix: locale

* refactor: code improve

* fix: bug

* refactor: code improve

* refactor: code improve

* refactor: code improve

* refactor: locale

* fix: test

* fix: bug

* fix: test

* fix: test

---------

Co-authored-by: chenos <chenlinxh@gmail.com>

* fix: enhance access permission check and clean up unused props in Page component

* fix: adjust Action.Page style to set top position to 0

* fix: update redirect logic to point to the first page route in admin layout

* chore(versions): 😊 publish v1.6.0-alpha.27

* fix: add link handling in MenuItem to open URLs in a new tab

* fix(data-source-main): update order

* fix: integrate drag-and-drop functionality in MenuItem and Page components

* fix: add drag-and-drop support in admin layout and improve loading behavior

* fix: header style

* fix: set sider width in admin layout

* fix: refactor InternalAdminLayout for improved readability and maintainability

* fix: optimize token management in InternalAdminLayout for better styling consistency

* fix: style

* fix: avoid error

* fix: update container reference in MenuSchemaToolbarWithContainer

* fix: add icon style to MenuSchemaToolbar

* fix: remove bottom border from header in admin layout

* fix: add collapsed button render function to InternalAdminLayout

* fix: update viewport meta tag for better responsiveness

* fix: add MenuItemIcon component for conditional icon rendering in admin layout

* fix: wrap SchemaToolbar in SiderContext.Provider to prevent style issues in collapsed state

* fix: update InternalAdminLayout styles for improved menu item appearance

* fix: adjust menu item spacing and height for compact mode in admin layout

* fix: add collapse handling and page change logic in InternalAdminLayout

* fix: add header context provider and update MenuItemIcon rendering logic in InternalAdminLayout

* fix: replace Modal with App.useApp().modal in HiddenMenuItem for improved modal handling

* fix: enhance click area for links in MenuItem and streamline group navigation logic

* fix: refresh routes after adding link menu

* fix: add mobile actions popover for improved user interaction in admin layout

* fix: adjust layout width and margin for improved alignment in admin layout

* style: fix the style of the top collapsed menu button dropdown

* fix: add active background color for selected menu items in collapsed menu

* fix: improve z-index management for modal, drawer, and page components to prevent overlap with collapsed menu button

* fix: adjust position of collapsed button to prevent overlap with subpages

* fix: update collapsed button rendering to handle mobile context and prevent overlap with subpages

* fix: prevent schema data request for group pages in admin layout

* fix: handle undefined menu titles by providing a default value

* fix: add refresh functionality for desktop routes in menu permissions

* fix: adjust page header padding based on route settings and token

* fix: center text

* fix: add tooltip support for menu items in collapsed state

* fix: tooltip

* fix: improve page tab routing and deletion handling

* fix: adjust admin layout height to account for header

* fix: improve route navigation and deletion handling in admin layout

* chore: update version

* fix(routing): Add Navigate import from react-router-dom

* fix(e2e): locator

* test: remove demo and useless test case for Page component

* fix: improve page creation and routing in e2e utils

* fix: improve z-index handling for embedded pages

* feat(admin-layout): Add aria-label to menu item links for improved accessibility

* fix(mobile-ui): Adjust toolbar and navigation bar styling

* test(acl): Simplify menu item visibility test setup

* test: update e2e test templates and route handling

* fix: fix compatibility issues

* fix(admin-layout): improve default page navigation handling

* fix(acl): add optional chaining to prevent potential null/undefined error in uiButtonSchemasBlacklist check

* fix: keep alive

* fix(desktop-routes): enhance route retrieval with automatic child route inclusion

* test: add test

* fix(page): update navigation logic to use location pathname for tab routing

* fix(route): export CurrentRouteProvider for better accessibility in routing context

* refactor(layout): remove unused styles for cleaner code

* fix(layout): integrate useGlobalTheme for consistent theming and clean up unused parameters

* fix(route): improve route redirection logic

---------

Co-authored-by: nocobase[bot] <179432756+nocobase[bot]@users.noreply.github.com>
Co-authored-by: Katherine <katherine_15995@163.com>
Co-authored-by: chenos <chenlinxh@gmail.com>
Co-authored-by: Junyi <mytharcher@users.noreply.github.com>
This commit is contained in:
Zeke Zhang 2025-03-05 22:52:24 +08:00 committed by GitHub
parent 7404e57d4c
commit 0d0c81cc90
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 2011 additions and 1174 deletions

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

@ -47,6 +47,7 @@ export const CSSVariableProvider = ({ children }) => {
document.body.style.setProperty('--colorSettings', token.colorSettings || defaultTheme.token.colorSettings);
document.body.style.setProperty('--colorBgSettingsHover', token.colorBgSettingsHover);
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';
@ -90,5 +88,6 @@ export {
IsInNocoBaseRecursionFieldContext,
NocoBaseRecursionField,
RefreshComponentProvider,
useRefreshFieldSchema,
useRefreshFieldSchema
} from './formily/NocoBaseRecursionField';

View File

@ -882,5 +882,7 @@
"Are you sure you want to hide this menu?": "Are you sure you want to hide this menu?",
"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."
"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."
}

View File

@ -799,5 +799,7 @@
"Are you sure you want to hide this menu?": "¿Estás seguro de que quieres ocultar este menú?",
"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ú."
"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."
}

View File

@ -819,5 +819,7 @@
"Are you sure you want to hide this menu?": "Êtes-vous sûr de vouloir masquer ce menu ?",
"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."
"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."
}

View File

@ -1037,5 +1037,7 @@
"Are you sure you want to hide this menu?": "このメニューを非表示にしますか?",
"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.": "選択されている場合、ルートはメニューに表示されます。"
"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.": "非表示にすると、このタブはタブバーに表示されなくなります。再表示するには、ルート管理ページで設定する必要があります。"
}

View File

@ -910,5 +910,7 @@
"Are you sure you want to hide this menu?": "이 메뉴를 숨기시겠습니까?",
"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.": "선택되면 라우트는 메뉴에 표시됩니다."
"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.": "숨기면 이 탭은 탭 바에 더 이상 표시되지 않습니다. 다시 표시하려면 라우트 관리 페이지에서 설정해야 합니다."
}

View File

@ -776,5 +776,7 @@
"Are you sure you want to hide this menu?": "Tem certeza de que deseja ocultar este menu?",
"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."
"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."
}

View File

@ -605,5 +605,7 @@
"Are you sure you want to hide this menu?": "Вы уверены, что хотите скрыть это меню?",
"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.": "Если выбран, маршрут будет отображаться в меню."
"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.": "После скрытия этой вкладки она больше не будет отображаться во вкладке. Чтобы снова отобразить ее, вам нужно будет перейти на страницу управления маршрутами, чтобы установить ее."
}

View File

@ -603,5 +603,7 @@
"Are you sure you want to hide this menu?": "Bu menüyü gizlemek istediğinizden emin misiniz?",
"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."
"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."
}

View File

@ -819,5 +819,7 @@
"Are you sure you want to hide this menu?": "Ви впевнені, що хочете приховати це меню?",
"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.": "Якщо вибрано, маршрут буде відображений в меню."
"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.": "Після приховування цієї вкладки вона більше не з'явиться в панелі вкладок. Щоб знову показати її, вам потрібно перейти на сторінку керування маршрутами, щоб налаштувати її."
}

View File

@ -1078,5 +1078,7 @@
"Are you sure you want to hide this menu?": "你确定要隐藏这个菜单吗?",
"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.": "如果选中,该路由将显示在菜单中。"
"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.": "隐藏后,该标签将不再显示在标签栏中。要想再次显示它,你需要到路由管理页面进行设置。"
}

View File

@ -910,6 +910,8 @@
"Are you sure you want to hide this menu?": "你確定要隱藏這個菜單嗎?",
"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.": "如果選中,該路由將顯示在菜單中。"
"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.": "隱藏後,這個標籤將不再出現在標籤欄中。要再次顯示它,你需要到路由管理頁面進行設置。"
}

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,33 +105,15 @@ 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,
},
properties: {
page: {
type: 'void',
'x-component': 'Page',
'x-async': true,
properties: {
[tabSchemaName]: {
type: 'void',
@ -127,12 +121,9 @@ export function getPageMenuSchema({
'x-initializer': 'page:addBlock',
properties: {},
'x-uid': tabSchemaUid,
'x-async': true,
},
},
'x-uid': pageSchemaUid,
},
},
'x-uid': menuSchemaUid,
__route__: route,
};
}

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,
useCurrentRouteData,
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 = useCurrentRouteData();
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 = useCurrentRouteData();
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 = useCurrentRouteData();
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 = useCurrentRouteData();
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 = useCurrentRouteData();
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

@ -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

@ -12,7 +12,7 @@ import { useTranslation } from 'react-i18next';
import { useDesignable, useNocoBaseRoutes } from '../..';
import { SchemaSettings } from '../../../application/schema-settings';
import { useSchemaToolbar } from '../../../application/schema-toolbar';
import { useCurrentRoute } from '../../../route-switch';
import { useCurrentRouteData } from '../../../route-switch';
function useNotDisableHeader() {
const fieldSchema = useFieldSchema();
@ -133,7 +133,7 @@ export const pageSettings = new SchemaSettings({
const { dn } = useDesignable();
const { t } = useTranslation();
const fieldSchema = useFieldSchema();
const currentRoute = useCurrentRoute();
const currentRoute = useCurrentRouteData();
const { updateRoute } = useNocoBaseRoutes();
return {
title: t('Enable page tabs'),
@ -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,
useCurrentRouteData,
} 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';
@ -60,12 +66,9 @@ const InternalPage = React.memo((props: PageProps) => {
const disablePageHeader = fieldSchema['x-component-props']?.disablePageHeader;
const searchParams = useCurrentSearchParams();
const loading = false;
const currentRoute = useCurrentRoute();
const currentRoute = useCurrentRouteData();
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 = useCurrentRouteData();
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>
);
};
@ -254,7 +267,7 @@ const NocoBasePageHeaderTabs: FC<{ className: string; activeKey: string }> = ({
const { getAriaLabel } = useGetAriaLabelOfSchemaInitializer();
const options = useContext(SchemaOptionsContext);
const { theme } = useGlobalTheme();
const currentRoute = useCurrentRoute();
const currentRoute = useCurrentRouteData();
const { createRoute } = useNocoBaseRoutes();
const compile = useCompile();
@ -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: (
<NocoBaseRouteContext.Provider value={tabRoute}>
<SortableItem
id={schema.name as string}
schema={schema}
id={String(tabRoute.id)}
className={classNames('nb-action-link', 'designerCss', className)}
schema={fakeSchema}
>
{schema['x-icon'] && <Icon style={{ marginRight: 8 }} type={schema['x-icon']} />}
{tabRoute.icon && <Icon style={{ marginRight: 8 }} type={tabRoute.icon} />}
<span>{(tabRoute.title && routeT(compile(tabRoute.title))) || t('Unnamed')}</span>
<PageTabDesigner schema={schema} />
<PageTabDesigner />
</SortableItem>
</NocoBaseRouteContext.Provider>
),
key: schema.name as string,
key: tabRoute.schemaUid,
};
})
.filter(Boolean);
@ -392,10 +406,12 @@ const NocoBasePageHeader = React.memo(({ activeKey, className }: { activeKey: st
const [pageTitle, setPageTitle] = useState(() => t(fieldSchema.title));
const disablePageHeader = fieldSchema['x-component-props']?.disablePageHeader;
const currentRoute = useCurrentRoute();
const currentRoute = useCurrentRouteData();
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 { useCurrentRouteData } 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 = useCurrentRouteData();
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,34 +70,22 @@ export const pageTabSettings = new SchemaSettings({
type: 'switch',
useComponentProps() {
const { t } = useTranslation();
const { schema } = useSchemaToolbar<{ schema: ISchema }>();
const { updateRoute } = useNocoBaseRoutes();
const { dn } = useDesignable();
const currentRoute = useCurrentRouteData();
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, {
// Update the route corresponding to the menu
await updateRoute(currentRoute.id, {
hideInMenu: !!v,
});
}
dn.emit('patch', {
schema: {
'x-uid': schema['x-uid'],
'x-component-props': schema['x-component-props'],
},
});
},
});
},
@ -125,7 +103,11 @@ export const pageTabSettings = new SchemaSettings({
const { modal } = App.useApp();
const { dn } = useDesignable();
const { t } = useTranslation();
const { schema } = useSchemaToolbar();
const { deleteRoute } = useNocoBaseRoutes();
const currentRoute = useCurrentRouteData();
const navigate = useNavigateNoUpdate();
const schema = useFieldSchema();
return {
title: t('Delete'),
eventKey: 'remove',
@ -134,8 +116,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,19 +30,16 @@ 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'}>
@ -54,6 +50,5 @@ export const PageTabDesigner = ({ schema }) => {
</Space>
</div>
</div>
</SchemaToolbarProvider>
);
};

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

@ -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`
@ -208,6 +208,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;
}
@ -226,6 +231,7 @@ const InternalSchemaToolbar: FC<SchemaToolbarProps> = React.memo((props) => {
spaceStyle,
toolbarClassName,
toolbarStyle = {},
container,
} = {
...props,
...(fieldSchema?.['x-toolbar-props'] || {}),
@ -312,7 +318,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;
}
@ -330,18 +339,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

@ -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,6 +1053,7 @@ const deletePage = async (pageUid: string, routeId: number) => {
}
}
if (pageUid) {
const result = await api.post(`/api/uiSchemas:remove/${pageUid}`, {
headers,
});
@ -1090,6 +1061,7 @@ const deletePage = async (pageUid: string, routeId: number) => {
if (!result.ok()) {
throw new Error(await result.text());
}
}
};
const deleteCollections = async (collectionNames: string[]) => {

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,7 @@
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 +68,7 @@ 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 +166,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 +215,7 @@ export const MenuPermissions: React.FC<{
values: shouldAdd,
});
}
refreshDesktopRoutes();
message.success(t('Saved successfully'));
};
@ -288,6 +290,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,
withSkeletonComponent
} 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,
@ -575,8 +574,7 @@ export const createRoutesTableSchema = (collectionName: string, basename: string
}
if (recordData.type === NocoBaseDesktopRouteType.page) {
const path = `${basenameOfCurrentRouter.slice(0, -1)}${basename}/${
isMobile ? recordData.schemaUid : recordData.menuSchemaUid
const path = `${basenameOfCurrentRouter.slice(0, -1)}${basename}/${isMobile ? recordData.schemaUid : recordData.menuSchemaUid
}`;
// 在点击 Access 按钮时,会用到
recordData._path = path;
@ -1244,22 +1242,15 @@ 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();
const menuSchemaUid = isMobile ? undefined : uid();
const pageSchemaUid = uid();
const tabSchemaName = uid();
const tabSchemaUid = type === NocoBaseDesktopRouteType.page ? uid() : undefined;
@ -1268,17 +1259,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 +1278,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({
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: desktopRoutesId,
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

@ -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,15 @@ 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';