fix(client-v2): support custom brand integration (#9543)

* fix(client-v2): support custom brand integration

* fix(client-v2): refine custom brand fallbacks

* fix(client-v2): address brand review feedback
This commit is contained in:
Zeke Zhang
2026-05-21 16:41:16 +08:00
committed by GitHub
parent 64a8bf2f1c
commit dc8a4113e2
11 changed files with 268 additions and 37 deletions
@@ -368,11 +368,11 @@ export abstract class BaseApplication<
});
}
updateFavicon(favicon?: string) {
updateFavicon(favicon?: string | null) {
let faviconLinkElement = document.querySelector('link[rel="shortcut icon"]') as HTMLLinkElement;
if (favicon) {
this.favicon = favicon;
if (arguments.length > 0) {
this.favicon = favicon || '';
}
const iconHref = this.favicon || '/favicon/favicon.ico';
@@ -12,6 +12,7 @@ import { useFlowEngineContext } from '@nocobase/flow-engine';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { Outlet } from 'react-router-dom';
import { escapeHTML, getAppVersionHTML } from '../utils';
const waitForAppReady = async () => {
await waitFor(() => {
@@ -66,6 +67,36 @@ describe('app', () => {
expect(favicon.getAttribute('href')).toBe('/custom-favicon.ico');
});
it('should reset favicon to default when favicon is cleared', () => {
const app = new Application({ router });
app.updateFavicon('/custom-favicon.ico');
app.updateFavicon(null);
const favicon = document.querySelector('link[rel="shortcut icon"]') as HTMLLinkElement;
expect(favicon).toBeInTheDocument();
expect(favicon.getAttribute('href')).toBe('/favicon/favicon.ico');
});
it('should reset favicon to default when favicon is explicitly undefined', () => {
const app = new Application({ router });
app.updateFavicon('/custom-favicon.ico');
app.updateFavicon(undefined);
const favicon = document.querySelector('link[rel="shortcut icon"]') as HTMLLinkElement;
expect(favicon).toBeInTheDocument();
expect(favicon.getAttribute('href')).toBe('/favicon/favicon.ico');
});
it('should escape app version html placeholder content', () => {
expect(getAppVersionHTML('<script>alert(1)</script>&"')).toBe(
'<span class="nb-app-version">v&lt;script&gt;alert(1)&lt;/script&gt;&amp;&quot;</span>',
);
expect(getAppVersionHTML(undefined)).toBe('');
expect(escapeHTML("NocoBase <v2> & 'beta'")).toBe('NocoBase &lt;v2&gt; &amp; &#39;beta&#39;');
});
it('should reject invalid component objects but keep valid exotic components', () => {
const app = new Application({ router });
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
@@ -10,7 +10,8 @@
import { TinyColor } from '@ctrl/tinycolor';
import { useEffect } from 'react';
import { theme } from 'antd';
import { type CustomToken, defaultTheme } from '../theme';
import { defaultTheme } from '../theme';
import type { CustomToken } from '../theme';
interface Result extends ReturnType<typeof theme.useToken> {
token: CustomToken;
@@ -42,6 +43,10 @@ export const CSSVariableProvider = ({ children }) => {
document.body.style.setProperty('--colorWarningBg', token.colorWarningBg);
document.body.style.setProperty('--colorWarningBorder', token.colorWarningBorder);
document.body.style.setProperty('--colorText', token.colorText);
document.body.style.setProperty('--colorTextDescription', token.colorTextDescription);
document.body.style.setProperty('--colorBgTextHover', token.colorBgTextHover);
document.body.style.setProperty('--colorSplit', token.colorSplit);
document.body.style.setProperty('--borderRadiusOuter', `${token.borderRadiusOuter}px`);
document.body.style.setProperty('--colorTextHeaderMenu', token.colorTextHeaderMenu);
document.body.style.setProperty('--colorPrimaryText', token.colorPrimaryText);
document.body.style.setProperty('--colorPrimaryTextActive', token.colorPrimaryTextActive);
@@ -81,9 +86,13 @@ export const CSSVariableProvider = ({ children }) => {
token.colorPrimaryTextActive,
token.colorPrimaryTextHover,
token.colorSettings,
token.colorBgTextHover,
token.colorSplit,
token.colorText,
token.colorTextDescription,
token.colorWarningBg,
token.colorWarningBorder,
token.borderRadiusOuter,
token.controlHeightLG,
token.marginLG,
token.marginSM,
@@ -9,45 +9,19 @@
import { QuestionCircleOutlined } from '@ant-design/icons';
import { css } from '@emotion/css';
import { observer, useFlowEngine } from '@nocobase/flow-engine';
import { observer } from '@nocobase/flow-engine';
import { parseHTML } from '@nocobase/utils/client';
import { Dropdown, Menu, Popover, theme as antdTheme } from 'antd';
import type { MenuItemType, MenuDividerType } from 'antd/es/menu/interface';
import React, { useEffect, useMemo, useState } from 'react';
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { usePlugin } from '../../../flow-compat';
import { useCurrentAppInfo } from '../../../hooks';
import type { CustomToken } from '../../../theme';
import { getAppVersionHTML } from '../../../utils';
type SettingsMenuItemType = MenuItemType | MenuDividerType;
/**
* 读取当前应用信息,避免继续依赖旧的 CurrentAppInfoProvider。
*/
function useCurrentAppInfoLite() {
const flowEngine = useFlowEngine();
const [data, setData] = useState<any>();
useEffect(() => {
let active = true;
Promise.resolve(flowEngine.context.appInfo)
.then((info) => {
if (active) {
setData(info);
}
})
.catch((error) => {
console.error(error);
});
return () => {
active = false;
};
}, [flowEngine]);
return data;
}
const helpClassName = css`
display: inline-block;
vertical-align: top;
@@ -60,7 +34,7 @@ const helpClassName = css`
const SettingsMenu: React.FC = () => {
const { t } = useTranslation();
const appInfo = useCurrentAppInfoLite();
const appInfo = useCurrentAppInfo();
const { token } = antdTheme.useToken();
const isSimplifiedChinese = appInfo?.lang === 'zh-CN';
@@ -136,7 +110,7 @@ export const HelpLite = observer(
const { token } = antdTheme.useToken();
const customToken = token as CustomToken;
const customBrandPlugin: any = usePlugin('@nocobase/plugin-custom-brand');
const appInfo = useCurrentAppInfoLite();
const appInfo = useCurrentAppInfo();
const icon = (
<span
@@ -156,7 +130,7 @@ export const HelpLite = observer(
);
if (customBrandPlugin?.options?.options?.about) {
const appVersion = `<span class="nb-app-version">v${appInfo?.version}</span>`;
const appVersion = getAppVersionHTML(appInfo?.version);
const content = parseHTML(customBrandPlugin.options.options.about, { appVersion });
return (
@@ -8,5 +8,7 @@
*/
export * from './useApp';
export * from './useCurrentAppInfo';
export * from './usePlugin';
export * from './useRouter';
export { escapeHTML, getAppVersionHTML } from '../utils/appVersionHTML';
@@ -0,0 +1,36 @@
/**
* 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 { useFlowEngineContext } from '@nocobase/flow-engine';
import { useEffect, useState } from 'react';
export function useCurrentAppInfo<TAppInfo extends Record<string, any> = Record<string, any>>() {
const ctx = useFlowEngineContext();
const [data, setData] = useState<TAppInfo>();
useEffect(() => {
let active = true;
Promise.resolve(ctx.appInfo)
.then((info) => {
if (active) {
setData((info || {}) as TAppInfo);
}
})
.catch((error) => {
console.error(error);
});
return () => {
active = false;
};
}, [ctx]);
return data;
}
@@ -0,0 +1,28 @@
/**
* 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.
*/
const htmlEscapeMap: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
export function escapeHTML(value: string) {
return value.replace(/[&<>"']/g, (matched) => htmlEscapeMap[matched]);
}
export function getAppVersionHTML(version: unknown) {
if (version === null || typeof version === 'undefined' || version === '') {
return '';
}
return `<span class="nb-app-version">v${escapeHTML(String(version))}</span>`;
}
@@ -10,6 +10,8 @@
import React, { ComponentType, FC } from 'react';
import { BlankComponent } from '../components';
export * from './appVersionHTML';
export function normalizeContainer(container: Element | ShadowRoot | string): Element | null {
if (!container) {
console.warn(`Failed to mount app: mount target should not be null or undefined.`);
@@ -42,6 +42,10 @@ export const CSSVariableProvider = ({ children }) => {
document.body.style.setProperty('--colorWarningBg', token.colorWarningBg);
document.body.style.setProperty('--colorWarningBorder', token.colorWarningBorder);
document.body.style.setProperty('--colorText', token.colorText);
document.body.style.setProperty('--colorTextDescription', token.colorTextDescription);
document.body.style.setProperty('--colorBgTextHover', token.colorBgTextHover);
document.body.style.setProperty('--colorSplit', token.colorSplit);
document.body.style.setProperty('--borderRadiusOuter', `${token.borderRadiusOuter}px`);
document.body.style.setProperty('--colorTextHeaderMenu', token.colorTextHeaderMenu);
document.body.style.setProperty('--colorPrimaryText', token.colorPrimaryText);
document.body.style.setProperty('--colorPrimaryTextActive', token.colorPrimaryTextActive);
@@ -81,9 +85,13 @@ export const CSSVariableProvider = ({ children }) => {
token.colorPrimaryTextActive,
token.colorPrimaryTextHover,
token.colorSettings,
token.colorBgTextHover,
token.colorSplit,
token.colorText,
token.colorTextDescription,
token.colorWarningBg,
token.colorWarningBorder,
token.borderRadiusOuter,
token.controlHeightLG,
token.marginLG,
token.marginSM,
@@ -0,0 +1,123 @@
/**
* 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 { createMockClient, Plugin } from '@nocobase/client-v2';
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import PoweredByLite from '../components/PoweredByLite';
class PoweredByLiteRoutePlugin extends Plugin {
async load() {
this.router.add('root', {
path: '/',
Component: PoweredByLite,
});
}
}
class MockCustomBrandPlugin extends Plugin {}
const renderPoweredByLite = async (plugins: any[] = [], appInfoData: Record<string, any> = { version: '1.2.3' }) => {
const app = createMockClient({
plugins: [PoweredByLiteRoutePlugin as any, ...plugins],
});
app.apiMock.onGet('app:getInfo').reply(200, {
data: appInfoData,
});
const Root = app.getRootComponent();
const result = render(<Root />);
await waitFor(() => {
expect(document.querySelector('.ant-spin-spinning')).not.toBeInTheDocument();
});
return result;
};
describe('PoweredByLite', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should render the default brand when custom-brand is not installed', async () => {
const { container } = await renderPoweredByLite();
expect(screen.getByRole('link', { name: 'NocoBase' })).toHaveAttribute('href', 'https://www.nocobase.com');
expect(container).toHaveTextContent('Powered by NocoBase');
expect(container.querySelector('.nb-brand')).not.toBeInTheDocument();
});
it('should render custom-brand HTML and replace appVersion', async () => {
const { container } = await renderPoweredByLite([
[
MockCustomBrandPlugin,
{
packageName: '@nocobase/plugin-custom-brand',
options: {
brand: '<span>Custom Brand</span>{{appVersion}}',
},
},
],
]);
await waitFor(() => {
expect(container.querySelector('.nb-brand')).toHaveTextContent('Custom Brandv1.2.3');
});
expect(container.querySelector('.nb-app-version')).toHaveTextContent('v1.2.3');
expect(screen.queryByText('Powered by')).not.toBeInTheDocument();
});
it('should not render undefined appVersion when app version is unavailable', async () => {
const { container } = await renderPoweredByLite(
[
[
MockCustomBrandPlugin,
{
packageName: '@nocobase/plugin-custom-brand',
options: {
brand: '<span>Custom Brand</span>{{appVersion}}',
},
},
],
],
{},
);
expect(container.querySelector('.nb-brand')).toHaveTextContent('Custom Brand');
expect(container.querySelector('.nb-brand')).not.toHaveTextContent('undefined');
expect(container.querySelector('.nb-app-version')).not.toBeInTheDocument();
});
it('should escape custom-brand appVersion placeholder', async () => {
const { container } = await renderPoweredByLite(
[
[
MockCustomBrandPlugin,
{
packageName: '@nocobase/plugin-custom-brand',
options: {
brand: '<span>Custom Brand</span>{{appVersion}}',
},
},
],
],
{
version: '<script>alert(1)</script>&"',
},
);
await waitFor(() => {
expect(container.querySelector('.nb-app-version')).toHaveTextContent('v<script>alert(1)</script>&"');
});
expect(container.querySelector('.nb-brand script')).not.toBeInTheDocument();
});
});
@@ -7,6 +7,8 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { getAppVersionHTML, useCurrentAppInfo, usePlugin } from '@nocobase/client-v2';
import { parseHTML } from '@nocobase/utils/client';
import React from 'react';
import { theme as antdTheme } from 'antd';
import { useTranslation } from 'react-i18next';
@@ -14,7 +16,23 @@ import { useTranslation } from 'react-i18next';
export default function PoweredByLite() {
const { token } = antdTheme.useToken();
const { i18n } = useTranslation();
const customBrandPlugin: any = usePlugin('@nocobase/plugin-custom-brand');
const appInfo = useCurrentAppInfo();
const homePage = i18n.language === 'zh-CN' ? 'https://www.nocobase.com/cn/' : 'https://www.nocobase.com';
const customBrand = customBrandPlugin?.options?.options?.brand;
if (customBrand) {
const appVersion = getAppVersionHTML(appInfo?.version);
return (
<div
className="nb-brand"
dangerouslySetInnerHTML={{
__html: parseHTML(customBrand, { appVersion }),
}}
/>
);
}
return (
<div style={{ color: token.colorTextDescription }}>