mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-01 14:57:36 +08:00
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:
@@ -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<script>alert(1)</script>&"</span>',
|
||||
);
|
||||
expect(getAppVersionHTML(undefined)).toBe('');
|
||||
expect(escapeHTML("NocoBase <v2> & 'beta'")).toBe('NocoBase <v2> & 'beta'');
|
||||
});
|
||||
|
||||
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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
|
||||
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,
|
||||
|
||||
+123
@@ -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 }}>
|
||||
|
||||
Reference in New Issue
Block a user