Skip to content

Commit

Permalink
feat(app): make branding icons and primary color configurable (janus-…
Browse files Browse the repository at this point in the history
…idp#555)

* feat(app): make branding icons and primary color configurable

* Update .changeset/shy-cobras-tease.md

Co-authored-by: Frank Kong <50030060+Zaperex@users.noreply.github.com>
  • Loading branch information
invincibleJai and Zaperex committed Sep 26, 2023
1 parent 20eba1d commit fb319ee
Show file tree
Hide file tree
Showing 12 changed files with 314 additions and 64 deletions.
5 changes: 5 additions & 0 deletions .changeset/shy-cobras-tease.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'app': minor
---

Adds ability to configure branding icons and primary color
24 changes: 24 additions & 0 deletions packages/app/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,28 @@ export interface Config {
*/
proxyPath?: string;
};
app: {
branding?: {
/**
* Base64 URI for the full logo
* @visibility frontend
*/
fullLogo?: string;
/**
* Base64 URI for the icon logo
* @visibility frontend
*/
iconLogo?: string;
theme?: {
[key: string]: {
/**
* primaryColor Configuration for the instance
* The following formats are supported: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color()
* @visibility frontend
*/
primaryColor: string;
};
};
};
};
}
1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
"@testing-library/dom": "8.20.1",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "12.1.5",
"@testing-library/react-hooks": "8.0.1",
"@testing-library/user-event": "14.4.3",
"@types/node": "18.17.17",
"@types/react": "17.0.65",
Expand Down
25 changes: 19 additions & 6 deletions packages/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
} from '@backstage/core-plugin-api';
import { customLightTheme } from './themes/lightTheme';
import { customDarkTheme } from './themes/darkTheme';
import { useUpdateTheme } from './hooks/useUpdateTheme';

const app = createApp({
apis,
Expand All @@ -78,18 +79,30 @@ const app = createApp({
title: 'Light Theme',
variant: 'light',
icon: <LightIcon />,
Provider: ({ children }) => (
<UnifiedThemeProvider theme={customLightTheme} children={children} />
),
Provider: ({ children }) => {
const { primaryColor } = useUpdateTheme('light');
return (
<UnifiedThemeProvider
theme={customLightTheme(primaryColor)}
children={children}
/>
);
},
},
{
id: 'dark',
title: 'Dark Theme',
variant: 'dark',
icon: <DarkIcon />,
Provider: ({ children }) => (
<UnifiedThemeProvider theme={customDarkTheme} children={children} />
),
Provider: ({ children }) => {
const { primaryColor } = useUpdateTheme('dark');
return (
<UnifiedThemeProvider
theme={customDarkTheme(primaryColor)}
children={children}
/>
);
},
},
],
components: {
Expand Down
25 changes: 1 addition & 24 deletions packages/app/src/components/Root/Root.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import {
Link,
Sidebar,
SidebarDivider,
SidebarGroup,
SidebarItem,
SidebarPage,
SidebarScrollWrapper,
SidebarSpace,
useSidebarOpenState,
} from '@backstage/core-components';
import { SidebarSearchModal } from '@backstage/plugin-search';
import {
Expand All @@ -26,28 +24,7 @@ import SearchIcon from '@mui/icons-material/Search';
import StorageIcon from '@mui/icons-material/Storage';
import AssessmentIcon from '@mui/icons-material/Assessment';
import React, { PropsWithChildren } from 'react';
import { makeStyles } from 'tss-react/mui';
import LogoFull from './LogoFull';
import LogoIcon from './LogoIcon';

const useStyles = makeStyles()({
sidebarLogo: {
margin: '24px 0px 6px 24px',
},
});

const SidebarLogo = () => {
const { classes } = useStyles();
const { isOpen } = useSidebarOpenState();

return (
<div className={classes.sidebarLogo}>
<Link to="/" underline="none" aria-label="Home">
{isOpen ? <LogoFull /> : <LogoIcon />}
</Link>
</div>
);
};
import { SidebarLogo } from './SidebarLogo';

export const Root = ({ children }: PropsWithChildren<{}>) => (
<SidebarPage>
Expand Down
89 changes: 89 additions & 0 deletions packages/app/src/components/Root/SidebarLogo.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React from 'react';
import { render } from '@testing-library/react';
import { useSidebarOpenState } from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import { BrowserRouter } from 'react-router-dom';
import { SidebarLogo } from './SidebarLogo';

jest.mock('@backstage/core-components', () => ({
...jest.requireActual('@backstage/core-components'),
useSidebarOpenState: jest.fn(),
}));

jest.mock('@backstage/core-plugin-api', () => ({
...jest.requireActual('@backstage/core-plugin-api'),
useApi: jest.fn(),
}));

jest.mock('./LogoFull.tsx', () => () => (
<svg data-testid="default-full-logo" />
));
jest.mock('./LogoIcon.tsx', () => () => (
<svg data-testid="default-icon-logo" />
));

describe('SidebarLogo', () => {
it('when sidebar is open renders the component with full logo base64 provided by config', () => {
(useApi as any).mockReturnValue({
getOptionalString: jest.fn().mockReturnValue('fullLogoBase64URI'),
});

(useSidebarOpenState as any).mockReturnValue({ isOpen: true });
const { getByTestId } = render(
<BrowserRouter>
<SidebarLogo />
</BrowserRouter>,
);

const fullLogo = getByTestId('home-logo');
expect(fullLogo).toBeInTheDocument();
expect(fullLogo).toHaveAttribute('src', 'fullLogoBase64URI'); // Check the expected attribute value
});

it('when sidebar is open renders the component with default full logo if config is undefined', () => {
(useApi as any).mockReturnValue({
getOptionalString: jest.fn().mockReturnValue(undefined),
});

(useSidebarOpenState as any).mockReturnValue({ isOpen: true });
const { getByTestId } = render(
<BrowserRouter>
<SidebarLogo />
</BrowserRouter>,
);

expect(getByTestId('default-full-logo')).toBeInTheDocument();
});

it('when sidebar is closed renders the component with icon logo base64 provided by config', () => {
(useApi as any).mockReturnValue({
getOptionalString: jest.fn().mockReturnValue('iconLogoBase64URI'),
});

(useSidebarOpenState as any).mockReturnValue({ isOpen: false });
const { getByTestId } = render(
<BrowserRouter>
<SidebarLogo />
</BrowserRouter>,
);

const fullLogo = getByTestId('home-logo');
expect(fullLogo).toBeInTheDocument();
expect(fullLogo).toHaveAttribute('src', 'iconLogoBase64URI');
});

it('when sidebar is closed renders the component with icon logo from default if not provided with config', () => {
(useApi as any).mockReturnValue({
getOptionalString: jest.fn().mockReturnValue(undefined),
});

(useSidebarOpenState as any).mockReturnValue({ isOpen: false });
const { getByTestId } = render(
<BrowserRouter>
<SidebarLogo />
</BrowserRouter>,
);

expect(getByTestId('default-icon-logo')).toBeInTheDocument();
});
});
65 changes: 65 additions & 0 deletions packages/app/src/components/Root/SidebarLogo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Link, useSidebarOpenState } from '@backstage/core-components';
import React from 'react';
import { makeStyles } from 'tss-react/mui';
import LogoFull from './LogoFull';
import LogoIcon from './LogoIcon';
import { configApiRef, useApi } from '@backstage/core-plugin-api';

const useStyles = makeStyles()({
sidebarLogo: {
margin: '24px 0px 6px 24px',
},
});

const LogoRender = ({
base64Logo,
defaultLogo,
width,
}: {
base64Logo: string | undefined;
defaultLogo: React.JSX.Element;
width: number;
}) => {
return base64Logo ? (
<img
data-testid="home-logo"
src={base64Logo}
alt="Home logo"
width={width}
/>
) : (
defaultLogo
);
};

export const SidebarLogo = () => {
const { classes } = useStyles();
const { isOpen } = useSidebarOpenState();
const configApi = useApi(configApiRef);
const logoFullBase64URI = configApi.getOptionalString(
'app.branding.fullLogo',
);
const logoIconBase64URI = configApi.getOptionalString(
'app.branding.iconLogo',
);

return (
<div className={classes.sidebarLogo}>
<Link to="/" underline="none" aria-label="Home">
{isOpen ? (
<LogoRender
base64Logo={logoFullBase64URI}
defaultLogo={<LogoFull />}
width={110}
/>
) : (
<LogoRender
base64Logo={logoIconBase64URI}
defaultLogo={<LogoIcon />}
width={28}
/>
)}
</Link>
</div>
);
};
31 changes: 31 additions & 0 deletions packages/app/src/hooks/useUpdateTheme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { renderHook } from '@testing-library/react-hooks';
import { useApi } from '@backstage/core-plugin-api';
import { useUpdateTheme } from './useUpdateTheme';

jest.mock('@backstage/core-plugin-api', () => ({
...jest.requireActual('@backstage/core-plugin-api'),
useApi: jest.fn(),
}));

describe('useUpdateTheme', () => {
it('returns the primaryColor when config is available', () => {
(useApi as any).mockReturnValue({
getOptionalString: jest.fn().mockReturnValue('blue'),
});

const { result } = renderHook(() => useUpdateTheme('someTheme'));
expect(result.current.primaryColor).toBe('blue');
});

it('returns undefined when config is unavailable', () => {
// Mock the useApi function to throw an error (simulate unavailable config)
(useApi as any).mockImplementation(
jest.fn(() => {
throw new Error('Custom hook error');
}),
);

const { result } = renderHook(() => useUpdateTheme('someTheme'));
expect(result.current.primaryColor).toBeUndefined();
});
});
16 changes: 16 additions & 0 deletions packages/app/src/hooks/useUpdateTheme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { configApiRef, useApi } from '@backstage/core-plugin-api';

export const useUpdateTheme = (
selTheme: string,
): { primaryColor: string | undefined } => {
let primaryColor: string | undefined;
try {
const configApi = useApi(configApiRef);
primaryColor = configApi.getOptionalString(
`app.branding.theme.${selTheme}.primaryColor`,
);
} catch (err) {
// useApi won't be initialized initally in createApp theme provider, and will get updated later
}
return { primaryColor };
};
41 changes: 24 additions & 17 deletions packages/app/src/themes/darkTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,29 @@ import { components } from './componentOverrides';
import { pageFontFamily, typography } from './consts';
import { pageTheme } from './pageTheme';

export const customDarkTheme = createUnifiedTheme({
fontFamily: pageFontFamily,
palette: {
...themes.dark.getTheme('v5')?.palette,
navigation: {
background: '#0f1214',
indicator: '#009596',
color: '#ffffff',
selectedColor: '#ffffff',
navItem: {
hoverBackground: '#030303',
export const customDarkTheme = (primaryColor?: string | undefined) =>
createUnifiedTheme({
fontFamily: pageFontFamily,
palette: {
...themes.dark.getTheme('v5')?.palette,
...(primaryColor && {
primary: {
...themes.light.getTheme('v5')?.palette.primary,
main: primaryColor,
},
}),
navigation: {
background: '#0f1214',
indicator: '#009596',
color: '#ffffff',
selectedColor: '#ffffff',
navItem: {
hoverBackground: '#030303',
},
},
},
},
defaultPageTheme: 'home',
pageTheme,
components,
typography,
});
defaultPageTheme: 'home',
pageTheme,
components,
typography,
});
Loading

0 comments on commit fb319ee

Please sign in to comment.