feat: add tooltip configuration to menu item and table headers (#6346)

* feat: add tooltip configuration to menu item and table headers

* feat: add tooltip to link and group menus

* feat: menu tooltip icon style adjustment

* feat: menu tooltip icon style adjustment

* fix: e2e

* refactor: optimize tooltip component usage in table columns

* feat: add tooltip editing functionality and enhance tooltip display in menu items

---------

Co-authored-by: Zeke Zhang <958414905@qq.com>
This commit is contained in:
chenyongxin 2025-03-06 20:09:14 +08:00 committed by GitHub
parent a9000d16c1
commit c2928d38cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 297 additions and 78 deletions

View File

@ -0,0 +1,37 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { Tooltip } from 'antd';
import { QuestionCircleOutlined } from '@ant-design/icons';
const titleWrapperStyle = {
display: 'flex',
alignItems: 'center',
gap: 4,
};
export const withTooltipComponent = (Component: React.FC) => {
return (props) => {
const { tooltip } = props;
if (!tooltip) {
return <Component {...props} />;
}
return (
<div style={titleWrapperStyle}>
<Component {...props} />
<Tooltip title={tooltip}>
<QuestionCircleOutlined style={{ zIndex: 1 }} />
</Tooltip>
</div>
);
};
};

View File

@ -88,6 +88,5 @@ export {
IsInNocoBaseRecursionFieldContext,
NocoBaseRecursionField,
RefreshComponentProvider,
useRefreshFieldSchema
useRefreshFieldSchema,
} from './formily/NocoBaseRecursionField';

View File

@ -306,6 +306,9 @@ test.describe('configure actions column', () => {
await page.getByText('Actions', { exact: true }).hover();
await page.getByLabel('designer-schema-initializer-TableV2.Column-TableV2.ActionColumnDesigner-').hover();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByText('Actions', { exact: true }).hover();
await page.getByLabel('designer-schema-initializer-TableV2.Column-TableV2.ActionColumnDesigner-').hover();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await page.mouse.move(300, 0);

View File

@ -82,6 +82,43 @@ export const tableColumnSettings = new SchemaSettings({
};
},
},
{
name: 'editTooltip',
type: 'modal',
useComponentProps() {
const { t } = useTranslation();
const { dn } = useDesignable();
const field = useField();
const columnSchema = useFieldSchema();
return {
title: t('Edit tooltip'),
schema: {
type: 'object',
title: t('Edit tooltip'),
properties: {
tooltip: {
default: columnSchema?.['x-component-props']?.tooltip || '',
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
'x-component-props': {},
},
},
} as ISchema,
onSubmit({ tooltip }) {
field.componentProps.tooltip = tooltip;
columnSchema['x-component-props'] = columnSchema['x-component-props'] || {};
columnSchema['x-component-props']['tooltip'] = tooltip;
dn.emit('patch', {
schema: {
'x-uid': columnSchema['x-uid'],
'x-component-props': columnSchema['x-component-props'],
},
});
},
};
},
},
{
name: 'style',
Component: (props) => {

View File

@ -17,7 +17,7 @@ test.describe('group page side menus schema settings', () => {
await expectSettingsMenu({
page,
showMenu: () => showSettingsInSide(page, 'group page in side'),
supportedOptions: ['Edit', 'Move to', 'Insert before', 'Insert after', 'Insert inner', 'Delete'],
supportedOptions: ['Edit', 'Edit tooltip', 'Move to', 'Insert before', 'Insert after', 'Insert inner', 'Delete'],
});
});
@ -28,7 +28,7 @@ test.describe('group page side menus schema settings', () => {
await expectSettingsMenu({
page,
showMenu: () => showSettingsInSide(page, 'link page in side'),
supportedOptions: ['Edit', 'Move to', 'Insert before', 'Insert after', 'Delete'],
supportedOptions: ['Edit', 'Edit tooltip', 'Move to', 'Insert before', 'Insert after', 'Delete'],
});
});
@ -39,7 +39,7 @@ test.describe('group page side menus schema settings', () => {
await expectSettingsMenu({
page,
showMenu: () => showSettingsInSide(page, 'single page in side'),
supportedOptions: ['Edit', 'Move to', 'Insert before', 'Insert after', 'Delete'],
supportedOptions: ['Edit', 'Edit tooltip', 'Move to', 'Insert before', 'Insert after', 'Delete'],
});
});
});

View File

@ -13,7 +13,7 @@ test.describe('group page menus schema settings', () => {
test('edit', async ({ page, mockPage }) => {
await mockPage({ type: 'group', name: 'group page' }).goto();
await showSettings(page, 'group page');
await page.getByRole('menuitem', { name: 'Edit' }).click();
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click();
await page.mouse.move(300, 0);
// 设置一个新名称

View File

@ -23,6 +23,7 @@ export interface NocoBaseDesktopRoute {
children?: NocoBaseDesktopRoute[];
title?: string;
tooltip?: string;
icon?: string;
schemaUid?: string;
menuSchemaUid?: string;

View File

@ -50,6 +50,7 @@ import { KeepAlive } from './KeepAlive';
import { NocoBaseDesktopRoute, NocoBaseDesktopRouteType } from './convertRoutesToSchema';
import { MenuSchemaToolbar, ResetThemeTokenAndKeepAlgorithm } from './menuItemSettings';
import { userCenterSettings } from './userCenterSettings';
import { withTooltipComponent } from '../../../hoc/withTooltipComponent';
export { KeepAlive, NocoBaseDesktopRouteType };
@ -434,16 +435,26 @@ const actionsRender: any = (props) => {
return <PinnedPluginList />;
};
const MenuItemTitle: React.FC = (props) => {
return <>{props.children}</>;
};
const MenuItemTitleWithTooltip = withTooltipComponent(MenuItemTitle);
const menuItemRender = (item, dom, options) => {
return (
<MenuItem item={item} options={options}>
{dom}
<MenuItemTitleWithTooltip tooltip={item._route?.tooltip}>{dom}</MenuItemTitleWithTooltip>
</MenuItem>
);
};
const subMenuItemRender = (item, dom) => {
return <GroupItem item={item}>{dom}</GroupItem>;
return (
<GroupItem item={item}>
<MenuItemTitleWithTooltip tooltip={item._route?.tooltip}>{dom}</MenuItemTitleWithTooltip>
</GroupItem>
);
};
const CollapsedButton: FC<{ collapsed: boolean }> = (props) => {
@ -753,6 +764,8 @@ function convertRoutesToLayout(routes: NocoBaseDesktopRoute[], { designable, par
name: <MenuDesignerButton testId={testId} />,
path: '/',
disabled: true,
_route: {},
_parentRoute: {},
icon: <Icon type="setting" />,
};
};

View File

@ -525,6 +525,53 @@ const MoveToMenuItem = () => {
);
};
const EditTooltip = () => {
const { t } = useTranslation();
const currentRoute = useCurrentRoute();
const { updateRoute } = useNocoBaseRoutes();
const editTooltipSchema = useMemo(() => {
return {
type: 'object',
title: t('Edit tooltip'),
properties: {
tooltip: {
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
'x-component-props': {},
},
},
};
}, [t]);
const initialTooltipValues = useMemo(() => {
return {
tooltip: currentRoute.tooltip,
};
}, [currentRoute.tooltip]);
const onEditTooltipSubmit: (values: any) => void = useCallback(
({ tooltip }) => {
// 更新菜单对应的路由
if (currentRoute.id !== undefined) {
updateRoute(currentRoute.id, {
tooltip,
});
}
},
[currentRoute.id, updateRoute],
);
return (
<SchemaSettingsModalItem
title={t('Edit tooltip')}
eventKey="edit-tooltip"
schema={editTooltipSchema as ISchema}
initialValues={initialTooltipValues}
onSubmit={onEditTooltipSubmit}
/>
);
};
export const menuItemSettings = new SchemaSettings({
name: 'menuSettings:menuItem',
items: [
@ -532,6 +579,10 @@ export const menuItemSettings = new SchemaSettings({
name: 'edit',
Component: EditMenuItem,
},
{
name: 'editTooltip',
Component: EditTooltip,
},
{
name: 'hidden',
Component: HiddenMenuItem,

View File

@ -45,6 +45,7 @@ import { useToken } from '../__builtins__';
import { SubFormProvider, useAssociationFieldContext } from '../association-field/hooks';
import { ColumnFieldProvider } from '../table-v2/components/ColumnFieldProvider';
import { extractIndex, isCollectionFieldComponent, isColumnComponent } from '../table-v2/utils';
import { withTooltipComponent } from '../../../hoc/withTooltipComponent';
const InViewContext = React.createContext(false);
@ -85,6 +86,8 @@ export const useColumnsDeepMemoized = (columns: any[]) => {
return oldObj.value;
};
const TableColumnTitle = withTooltipComponent(RecursionField);
const useTableColumns = (props: { showDel?: any; isSubTable?: boolean }, paginationProps) => {
const { token } = useToken();
const field = useArrayField(props);
@ -112,7 +115,6 @@ const useTableColumns = (props: { showDel?: any; isSubTable?: boolean }, paginat
}, [token.paddingContentVerticalLG, token.marginSM]);
const collection = useCollection();
const columns = useMemo(
() =>
columnsSchema?.map((s: Schema) => {
@ -124,7 +126,7 @@ const useTableColumns = (props: { showDel?: any; isSubTable?: boolean }, paginat
const dataIndex = collectionFields?.length > 0 ? collectionFields[0].name : s.name;
const columnHidden = !!s['x-component-props']?.['columnHidden'];
return {
title: <RecursionField name={s.name} schema={s} onlyRenderSelf />,
title: <TableColumnTitle name={s.name} schema={s} onlyRenderSelf tooltip={s['x-component-props']?.tooltip} />,
dataIndex,
key: s.name,
sorter: s['x-component-props']?.['sorter'],

View File

@ -325,6 +325,24 @@ export const MenuDesigner = () => {
icon: field.componentProps.icon,
};
}, [field.title, field.componentProps.icon]);
const editTooltipSchema = useMemo(() => {
return {
type: 'object',
title: t('Edit tooltip'),
properties: {
tooltip: {
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
'x-component-props': {},
},
},
};
}, [t]);
const initialTooltipValues = useMemo(() => {
return {
tooltip: field.componentProps.tooltip,
};
}, [field.componentProps.tooltip]);
if (fieldSchema['x-component'] === 'Menu.URL') {
schema.properties['href'] = urlSchema;
schema.properties['params'] = paramsSchema;
@ -378,6 +396,17 @@ export const MenuDesigner = () => {
},
[fieldSchema, field, dn, refresh, onSelect],
);
const onEditTooltipSubmit: (values: any) => void = useCallback(
({ tooltip }) => {
// 更新菜单对应的路由
if (fieldSchema['__route__']?.id) {
updateRoute(fieldSchema['__route__'].id, {
tooltip,
});
}
},
[fieldSchema, field, dn, refresh, onSelect],
);
const modalSchema = useMemo(() => {
return {
@ -471,6 +500,13 @@ export const MenuDesigner = () => {
initialValues={initialValues}
onSubmit={onEditSubmit}
/>
<SchemaSettingsModalItem
title={t('Edit tooltip')}
eventKey="edit-tooltip"
schema={editTooltipSchema as ISchema}
initialValues={initialTooltipValues}
onSubmit={onEditTooltipSubmit}
/>
<SchemaSettingsSwitchItem
title={t('Hidden')}
checked={fieldSchema['x-component-props']?.hidden}

View File

@ -40,6 +40,7 @@ import { useUpdate } from 'ahooks';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useRefreshComponent, useRefreshFieldSchema } from '../../../formily/NocoBaseRecursionField';
import { NocoBaseDesktopRoute } from '../../../route-switch/antd/admin-layout/convertRoutesToSchema';
import { withTooltipComponent } from '../../../hoc/withTooltipComponent';
const subMenuDesignerCss = css`
position: relative;
@ -96,7 +97,7 @@ const subMenuDesignerCss = css`
const designerCss = css`
position: relative;
display: flex;
justify-content: center;
// justify-content: center;
align-items: center;
margin-left: -20px;
margin-right: -20px;
@ -203,6 +204,16 @@ type ComposedMenu = React.FC<any> & {
Designer?: React.FC<any>;
};
const MenuItemTitle: React.FC<{
schema: any;
style?: React.CSSProperties;
}> = ({ schema, style }) => {
const { t } = useMenuTranslation();
return <span style={style}>{t(schema.title)}</span>;
};
const MenuItemTitleWithTooltip = withTooltipComponent(MenuItemTitle);
export const ParentRouteContext = createContext<NocoBaseDesktopRoute>(null);
ParentRouteContext.displayName = 'ParentRouteContext';
@ -668,8 +679,9 @@ const menuItemTitleStyle = {
overflow: 'hidden',
textOverflow: 'ellipsis',
display: 'inline-block',
width: '100%',
// width: '100%',
verticalAlign: 'middle',
// marginInlineEnd: '4px',
};
Menu.Item = observer(
@ -698,7 +710,11 @@ Menu.Item = observer(
removeParentsIfNoChildren={false}
>
<Icon type={icon} />
<span style={menuItemTitleStyle}>{t(schema.title)}</span>
<MenuItemTitleWithTooltip
schema={schema}
style={menuItemTitleStyle}
tooltip={schema?.['x-component-props']?.tooltip}
/>
<Designer />
</SortableItem>
</FieldContext.Provider>
@ -720,6 +736,7 @@ Menu.Item = observer(
const MenuURLButton = ({ href, params, icon }) => {
const field = useField();
const schema = useFieldSchema();
const { t } = useMenuTranslation();
const Designer = useContext(MenuItemDesignerContext);
const { parseURLAndParams } = useParseURLAndParams();
@ -749,17 +766,11 @@ const MenuURLButton = ({ href, params, icon }) => {
aria-label={t(field.title)}
>
<Icon type={icon} />
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
display: 'inline-block',
width: '100%',
verticalAlign: 'middle',
}}
>
{t(field.title)}
</span>
<MenuItemTitleWithTooltip
schema={schema}
style={menuItemTitleStyle}
tooltip={schema?.['x-component-props']?.tooltip}
/>
<Designer />
</SortableItem>
);
@ -814,6 +825,7 @@ Menu.SubMenu = observer(
const field = useField();
const mode = useContext(MenuModeContext);
const Designer = useContext(MenuItemDesignerContext);
const submenu = useMemo(() => {
return {
...others,
@ -830,7 +842,11 @@ Menu.SubMenu = observer(
aria-label={t(field.title)}
>
<Icon type={icon} />
{t(field.title)}
<MenuItemTitleWithTooltip
schema={schema}
style={{ marginInlineStart: 0 }}
tooltip={schema?.['x-component-props']?.tooltip}
/>
<Designer />
</SortableItem>
</FieldContext.Provider>

View File

@ -66,6 +66,7 @@ import { useToken } from '../__builtins__';
import { useAssociationFieldContext } from '../association-field/hooks';
import { TableSkeleton } from './TableSkeleton';
import { extractIndex, isCollectionFieldComponent, isColumnComponent } from './utils';
import { withTooltipComponent } from '../../../hoc/withTooltipComponent';
type BodyRowComponentProps = {
rowIndex?: number;
@ -198,6 +199,9 @@ const useTableColumns = (
const collection = useCollection();
// 不能提取到外部,否则 NocoBaseRecursionField 的值在一开始会是 undefined。原因未知
const TableColumnTitle = useMemo(() => withTooltipComponent(NocoBaseRecursionField), []);
const columns = useMemo(
() =>
columnsSchemas?.map((columnSchema: Schema) => {
@ -217,11 +221,12 @@ const useTableColumns = (
return {
title: (
<RefreshComponentProvider refresh={refresh}>
<NocoBaseRecursionField
<TableColumnTitle
name={columnSchema.name}
schema={columnSchema}
onlyRenderSelf
isUseFormilyField={false}
tooltip={columnSchema?.['x-component-props']?.tooltip}
/>
</RefreshComponentProvider>
),

View File

@ -1396,11 +1396,11 @@ export async function expectSettingsMenu({
await page.waitForTimeout(100);
await showMenu();
for (const option of supportedOptions) {
await expect(page.getByRole('menuitem', { name: option })).toBeVisible();
await expect(page.getByRole('menuitem', { name: option, exact: option === 'Edit' })).toBeVisible();
}
if (unsupportedOptions) {
for (const option of unsupportedOptions) {
await expect(page.getByRole('menuitem', { name: option })).not.toBeVisible();
await expect(page.getByRole('menuitem', { name: option, exact: option === 'Edit' })).not.toBeVisible();
}
}
}

View File

@ -9,7 +9,14 @@
import { createForm, Form, onFormValuesChange } from '@formily/core';
import { uid } from '@formily/shared';
import { css, SchemaComponent, useAllAccessDesktopRoutes, useAPIClient, useCompile, useRequest } from '@nocobase/client';
import {
css,
SchemaComponent,
useAllAccessDesktopRoutes,
useAPIClient,
useCompile,
useRequest,
} from '@nocobase/client';
import { useMemoizedFn } from 'ahooks';
import { Checkbox, message, Table } from 'antd';
import { uniq } from 'lodash';
@ -68,7 +75,8 @@ const style = css`
const translateTitle = (menus: any[], t, compile) => {
return menus.map((menu) => {
const title = (menu.title?.match(/^\s*\{\{\s*.+?\s*\}\}\s*$/) ? compile(menu.title) : t(menu.title)) || t('Unnamed');
const title =
(menu.title?.match(/^\s*\{\{\s*.+?\s*\}\}\s*$/) ? compile(menu.title) : t(menu.title)) || t('Unnamed');
if (menu.children) {
return {
...menu,

View File

@ -29,7 +29,7 @@ import {
usePopupUtils,
useProps,
withDynamicSchemaProps,
withSkeletonComponent
withSkeletonComponent,
} from '@nocobase/client';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';

View File

@ -574,7 +574,8 @@ 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;
@ -1245,11 +1246,7 @@ function useCreateRouteSchema(isMobile: boolean) {
const insertPageSchema = useInsertPageSchema();
const createRouteSchema = useCallback(
async ({
type,
}: {
type: NocoBaseDesktopRouteType;
}) => {
async ({ type }: { type: NocoBaseDesktopRouteType }) => {
const menuSchemaUid = isMobile ? undefined : uid();
const pageSchemaUid = uid();
const tabSchemaName = uid();

View File

@ -205,6 +205,21 @@ export default {
title: '{{t("Title")}}',
},
},
{
key: 'brt6tz4hgin',
name: 'tooltip',
type: 'string',
interface: 'textarea',
description: null,
collectionName: 'desktopRoutes',
parentKey: null,
reverseKey: null,
uiSchema: {
type: 'string',
'x-component': 'Input.TextArea',
title: '{{t("Tooltip")}}',
},
},
{
key: 'ozl5d8t2d5e',
name: 'icon',

View File

@ -193,4 +193,3 @@ export { default as useStyles } from './style';
export { Trigger, useTrigger } from './triggers';
export * from './utils';
export * from './variable';