diff --git a/packages/core/client-v2/src/PluginSettingsManager.ts b/packages/core/client-v2/src/PluginSettingsManager.ts index 9f209b6c5b4..82b95c40937 100644 --- a/packages/core/client-v2/src/PluginSettingsManager.ts +++ b/packages/core/client-v2/src/PluginSettingsManager.ts @@ -432,7 +432,7 @@ export class PluginSettingsManager = BaseAppli return null; } - const { title, aclSnippet, key, menuKey, name, ...others } = page; + const { title, aclSnippet, key, menuKey, name, icon, ...others } = page; return { ...others, @@ -443,6 +443,7 @@ export class PluginSettingsManager = BaseAppli name, title, label: title, + icon: this.renderIcon(icon), path: this.getRoutePath(name), sort: page.sort, isAllow, diff --git a/packages/core/client-v2/src/__tests__/PluginSettingsManager.test.ts b/packages/core/client-v2/src/__tests__/PluginSettingsManager.test.ts index 9bc5edf51a4..e6abf3c4740 100644 --- a/packages/core/client-v2/src/__tests__/PluginSettingsManager.test.ts +++ b/packages/core/client-v2/src/__tests__/PluginSettingsManager.test.ts @@ -76,6 +76,25 @@ describe('PluginSettingsManager v2', () => { expect(app.router.get('admin.settings.demo.advanced')).toMatchObject({ path: 'advanced' }); }); + it('should render string icon on both menu and page tab via renderIcon', () => { + // Previously `renderPage` spread the raw `icon` string straight to the antd + // Menu item, which displayed "LockOutlinedTitle" as text. Both `renderMenuItem` + // and `renderPage` must coerce string icon names to React elements. + const app = createMockClient(); + + app.pluginSettingsManager.addMenuItem({ key: 'demo', title: 'Demo', icon: 'TeamOutlined' }); + app.pluginSettingsManager.addPageTabItem({ + menuKey: 'demo', + key: 'index', + title: 'Overview', + icon: 'LockOutlined', + }); + + const list = app.pluginSettingsManager.getList(); + expect(React.isValidElement(list[0].icon)).toBe(true); + expect(React.isValidElement(list[0].children?.[0].icon)).toBe(true); + }); + it('should support componentLoader on page item', () => { const app = createMockClient(); const componentLoader = async () => ({ diff --git a/packages/plugins/@nocobase/plugin-auth/src/client-v2/__tests__/PoweredByLite.test.tsx b/packages/core/client-v2/src/__tests__/PoweredBy.test.tsx similarity index 74% rename from packages/plugins/@nocobase/plugin-auth/src/client-v2/__tests__/PoweredByLite.test.tsx rename to packages/core/client-v2/src/__tests__/PoweredBy.test.tsx index 39cc94b4eba..12aa30775d2 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/client-v2/__tests__/PoweredByLite.test.tsx +++ b/packages/core/client-v2/src/__tests__/PoweredBy.test.tsx @@ -7,26 +7,26 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { createMockClient, Plugin } from '@nocobase/client-v2'; import { render, screen, waitFor } from '@testing-library/react'; import React from 'react'; +import { createMockClient } from '../MockApplication'; +import { Plugin } from '../Plugin'; +import PoweredBy from '../components/PoweredBy'; -import PoweredByLite from '../components/PoweredByLite'; - -class PoweredByLiteRoutePlugin extends Plugin { +class PoweredByRoutePlugin extends Plugin { async load() { this.router.add('root', { path: '/', - Component: PoweredByLite, + Component: PoweredBy, }); } } class MockCustomBrandPlugin extends Plugin {} -const renderPoweredByLite = async (plugins: any[] = [], appInfoData: Record = { version: '1.2.3' }) => { +const renderPoweredBy = async (plugins: any[] = [], appInfoData: Record = { version: '1.2.3' }) => { const app = createMockClient({ - plugins: [PoweredByLiteRoutePlugin as any, ...plugins], + plugins: [PoweredByRoutePlugin as any, ...plugins], }); app.apiMock.onGet('app:getInfo').reply(200, { @@ -43,21 +43,24 @@ const renderPoweredByLite = async (plugins: any[] = [], appInfoData: Record { +describe('PoweredBy', () => { afterEach(() => { vi.restoreAllMocks(); }); it('should render the default brand when custom-brand is not installed', async () => { - const { container } = await renderPoweredByLite(); + const { container } = await renderPoweredBy(); expect(screen.getByRole('link', { name: 'NocoBase' })).toHaveAttribute('href', 'https://www.nocobase.com'); expect(container).toHaveTextContent('Powered by NocoBase'); + // The `.nb-brand` className is reserved for the custom-brand HTML branch + // so downstream stylesheets can selectively target customised content + // without leaking onto the default footer. expect(container.querySelector('.nb-brand')).not.toBeInTheDocument(); }); it('should render custom-brand HTML and replace appVersion', async () => { - const { container } = await renderPoweredByLite([ + const { container } = await renderPoweredBy([ [ MockCustomBrandPlugin, { @@ -77,7 +80,7 @@ describe('PoweredByLite', () => { }); it('should not render undefined appVersion when app version is unavailable', async () => { - const { container } = await renderPoweredByLite( + const { container } = await renderPoweredBy( [ [ MockCustomBrandPlugin, @@ -98,7 +101,11 @@ describe('PoweredByLite', () => { }); it('should escape custom-brand appVersion placeholder', async () => { - const { container } = await renderPoweredByLite( + // Defence in depth: even if the back-end ever returns a tampered + // `app:getInfo` payload, the version string must be HTML-escaped + // before being interpolated into the custom-brand template — never + // produce a live `