Skip to content

Commit 23bb3ca

Browse files
committed
feat(FR-1573): Add theme setting section in user settings page
1 parent 971d534 commit 23bb3ca

29 files changed

+711
-52
lines changed
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { Col, ColorPicker, ColorPickerProps, Row, theme } from 'antd';
2+
import { ComponentTokenMap } from 'antd/es/theme/interface';
3+
import { AliasToken } from 'antd/lib/theme/internal';
4+
import { BAIFlex } from 'backend.ai-ui';
5+
import _ from 'lodash';
6+
import { useTranslation } from 'react-i18next';
7+
import {
8+
CustomThemeConfig,
9+
useCustomThemeConfig,
10+
} from 'src/helper/customThemeConfig';
11+
import { useBAISettingUserState } from 'src/hooks/useBAISetting';
12+
13+
type TokenPath = `token.${keyof AliasToken & string}`;
14+
type ComponentPath = `components.${keyof ComponentTokenMap & string}.${string}`;
15+
type ThemeConfigPath = TokenPath | ComponentPath;
16+
17+
interface ThemeColorPickerSettingItemProps extends ColorPickerProps {
18+
tokenName?: ThemeConfigPath;
19+
afterChangeColor?: (config: CustomThemeConfig) => void;
20+
}
21+
const ThemeColorPicker: React.FC<ThemeColorPickerSettingItemProps> = ({
22+
tokenName,
23+
}) => {
24+
'use memo';
25+
26+
const { t } = useTranslation();
27+
const { token } = theme.useToken();
28+
const [userCustomThemeConfig, setUserCustomThemeConfig] =
29+
useBAISettingUserState('custom_theme_config');
30+
31+
const themeConfig = useCustomThemeConfig();
32+
33+
const lightModeColor = (
34+
_.get(userCustomThemeConfig, `light.${tokenName}`) ||
35+
_.get(themeConfig, `light.${tokenName}`)
36+
)?.toString();
37+
const darkModeColor = (
38+
_.get(userCustomThemeConfig, `dark.${tokenName}`) ||
39+
_.get(themeConfig, `dark.${tokenName}`)
40+
)?.toString();
41+
42+
return (
43+
<BAIFlex
44+
align="stretch"
45+
direction="column"
46+
style={{ alignSelf: 'stretch' }}
47+
>
48+
<Row>
49+
<Col span={12}>
50+
<BAIFlex gap="sm" style={{ color: token.colorTextTertiary }}>
51+
{t('userSettings.LightMode')}:
52+
<ColorPicker
53+
format="hex"
54+
showText
55+
value={lightModeColor}
56+
onChangeComplete={(value) => {
57+
const newColor = value.toHexString();
58+
const newCustomThemeConfig: CustomThemeConfig = _.cloneDeep({
59+
...themeConfig!,
60+
...userCustomThemeConfig,
61+
});
62+
_.set(newCustomThemeConfig, `light.${tokenName}`, newColor);
63+
setUserCustomThemeConfig(newCustomThemeConfig);
64+
}}
65+
/>
66+
</BAIFlex>
67+
</Col>
68+
<Col span={12}>
69+
<BAIFlex gap="sm" style={{ color: token.colorTextTertiary }}>
70+
{t('userSettings.DarkMode')}:
71+
<ColorPicker
72+
format="hex"
73+
showText
74+
value={darkModeColor}
75+
onChangeComplete={(value) => {
76+
const newColor = value.toHexString();
77+
const newCustomThemeConfig: CustomThemeConfig = _.cloneDeep({
78+
...themeConfig!,
79+
...userCustomThemeConfig,
80+
});
81+
_.set(newCustomThemeConfig, `dark.${tokenName}`, newColor);
82+
setUserCustomThemeConfig(newCustomThemeConfig);
83+
}}
84+
/>
85+
</BAIFlex>
86+
</Col>
87+
</Row>
88+
</BAIFlex>
89+
);
90+
};
91+
92+
export default ThemeColorPicker;
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import ThemeColorPicker from './BrandingSettingItems/ThemeColorPicker';
2+
import SettingList, { SettingGroup } from './SettingList';
3+
import { BAIButton } from 'backend.ai-ui';
4+
import { useTranslation } from 'react-i18next';
5+
import { useBAISettingUserState } from 'src/hooks/useBAISetting';
6+
7+
interface BrandingSettingListProps {}
8+
9+
const BrandingSettingList: React.FC<BrandingSettingListProps> = () => {
10+
const { t } = useTranslation();
11+
12+
const [, setUserCustomThemeConfig] = useBAISettingUserState(
13+
'custom_theme_config',
14+
);
15+
16+
const settingGroups: Array<SettingGroup> = [
17+
{
18+
'data-testid': 'group-theme-customization',
19+
title: t('userSettings.Theme'),
20+
titleExtra: (
21+
<BAIButton size="small">{t('userSettings.theme.Preview')}</BAIButton>
22+
),
23+
settingItems: [
24+
{
25+
type: 'custom',
26+
title: t('userSettings.theme.PrimaryColor'),
27+
description: t('userSettings.theme.PrimaryColorDesc'),
28+
children: <ThemeColorPicker tokenName="token.colorPrimary" />,
29+
// for reset feature
30+
defaultValue: {},
31+
setValue: setUserCustomThemeConfig,
32+
},
33+
{
34+
type: 'custom',
35+
title: t('userSettings.theme.HeaderBg'),
36+
description: t('userSettings.theme.HeaderBgDesc'),
37+
children: <ThemeColorPicker tokenName="components.Layout.headerBg" />,
38+
defaultValue: {},
39+
setValue: setUserCustomThemeConfig,
40+
},
41+
{
42+
type: 'custom',
43+
title: t('userSettings.theme.LinkColor'),
44+
description: t('userSettings.theme.LinkColorDesc'),
45+
children: <ThemeColorPicker tokenName="token.colorLink" />,
46+
defaultValue: {},
47+
setValue: setUserCustomThemeConfig,
48+
},
49+
{
50+
type: 'custom',
51+
title: t('userSettings.theme.InfoColor'),
52+
description: t('userSettings.theme.InfoColorDesc'),
53+
children: <ThemeColorPicker tokenName="token.colorInfo" />,
54+
defaultValue: {},
55+
setValue: setUserCustomThemeConfig,
56+
},
57+
{
58+
type: 'custom',
59+
title: t('userSettings.theme.ErrorColor'),
60+
description: t('userSettings.theme.ErrorColorDesc'),
61+
children: <ThemeColorPicker tokenName="token.colorError" />,
62+
defaultValue: {},
63+
setValue: setUserCustomThemeConfig,
64+
},
65+
{
66+
type: 'custom',
67+
title: t('userSettings.theme.SuccessColor'),
68+
description: t('userSettings.theme.SuccessColorDesc'),
69+
children: <ThemeColorPicker tokenName="token.colorSuccess" />,
70+
defaultValue: {},
71+
setValue: setUserCustomThemeConfig,
72+
},
73+
{
74+
type: 'custom',
75+
title: t('userSettings.theme.TextColor'),
76+
description: t('userSettings.theme.TextColorDesc'),
77+
children: <ThemeColorPicker tokenName="token.colorText" />,
78+
defaultValue: {},
79+
setValue: setUserCustomThemeConfig,
80+
},
81+
],
82+
},
83+
];
84+
85+
return <SettingList settingGroups={settingGroups} showSearchBar />;
86+
};
87+
88+
export default BrandingSettingList;

react/src/components/SettingItem.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,15 @@ const SettingItem: React.FC<SettingItemProps> = ({
6464
defaultValue !== value && <Badge dot status="warning" />}
6565
</BAIFlex>
6666
{type === 'custom' && (
67-
<>
67+
<BAIFlex
68+
direction="column"
69+
gap="xs"
70+
align="start"
71+
style={{ width: '100%' }}
72+
>
6873
{description}
69-
<div style={{ marginTop: token.marginXS }}>{children}</div>
70-
</>
74+
{children}
75+
</BAIFlex>
7176
)}
7277
{type === 'checkbox' && (
7378
<Checkbox

react/src/components/SettingList.tsx

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const useStyles = createStyles(({ css }) => ({
3232
export type SettingGroup = {
3333
'data-testid': string;
3434
title: string;
35+
titleExtra?: ReactNode;
3536
description?: ReactNode;
3637
settingItems: SettingItemProps[];
3738
};
@@ -60,9 +61,15 @@ const GroupSettingItems: React.FC<
6061
{
6162
group: SettingGroup;
6263
hideEmpty?: boolean;
64+
showResetButton?: boolean;
65+
onReset?: () => void;
6366
} & React.HTMLAttributes<HTMLDivElement>
64-
> = ({ group, hideEmpty, ...props }) => {
67+
> = ({ group, hideEmpty, showResetButton = true, onReset, ...props }) => {
68+
const { t } = useTranslation();
6569
const { token } = theme.useToken();
70+
71+
const [showGroupResetButton, setShowGroupResetButton] = useState(false);
72+
6673
if (hideEmpty && group.settingItems.length === 0) return false;
6774
return (
6875
<BAIFlex
@@ -71,6 +78,8 @@ const GroupSettingItems: React.FC<
7178
style={{
7279
marginBottom: token.marginMD,
7380
}}
81+
onMouseEnter={() => setShowGroupResetButton(true)}
82+
onMouseLeave={() => setShowGroupResetButton(false)}
7483
{...props}
7584
>
7685
<BAIFlex
@@ -82,14 +91,24 @@ const GroupSettingItems: React.FC<
8291
background: token.colorBgContainer,
8392
}}
8493
>
85-
<Typography.Title
86-
level={5}
87-
style={{
88-
marginTop: 0,
89-
}}
90-
>
91-
{group.title}
92-
</Typography.Title>
94+
<BAIFlex align="start" justify="between">
95+
<BAIFlex gap="sm" align="start">
96+
<Typography.Title
97+
level={5}
98+
style={{
99+
marginTop: 0,
100+
}}
101+
>
102+
{group.title}
103+
</Typography.Title>
104+
{group.titleExtra && <div>{group.titleExtra}</div>}
105+
</BAIFlex>
106+
{!!showResetButton && showGroupResetButton && (
107+
<Button icon={<RedoOutlined />} size="small" onClick={onReset}>
108+
{t('button.Reset')}
109+
</Button>
110+
)}
111+
</BAIFlex>
93112
<Divider style={{ marginTop: 0, marginBottom: 0 }} />
94113
{group.description && (
95114
<Typography.Text
@@ -100,7 +119,7 @@ const GroupSettingItems: React.FC<
100119
</Typography.Text>
101120
)}
102121
</BAIFlex>
103-
<BAIFlex direction="column" align="start" gap={'lg'}>
122+
<BAIFlex direction="column" align="stretch" gap={'lg'}>
104123
{group.settingItems.map((item, idx) => (
105124
<SettingItem key={item.title + idx} {...item} />
106125
))}
@@ -116,10 +135,14 @@ const SettingList: React.FC<SettingPageProps> = ({
116135
showResetButton,
117136
showSearchBar,
118137
}) => {
138+
'use memo';
139+
119140
const { t } = useTranslation();
120141
const { styles } = useStyles();
121142
const [searchValue, setSearchValue] = useState('');
122143
const [changedOptionFilter, setChangedOptionFilter] = useState(false);
144+
const [selectedGroupToReset, setSelectedGroupToReset] =
145+
useState<SettingGroup | null>(null);
123146
const [isOpenResetChangesModal, { toggle: setIsOpenResetChangesModal }] =
124147
useToggle(false);
125148
const [activeTabKey, setActiveTabKey] = useState('all');
@@ -210,6 +233,10 @@ const SettingList: React.FC<SettingPageProps> = ({
210233
key={group.title}
211234
group={group}
212235
hideEmpty
236+
onReset={() => {
237+
setSelectedGroupToReset(group);
238+
setIsOpenResetChangesModal();
239+
}}
213240
/>
214241
))
215242
) : (
@@ -231,7 +258,14 @@ const SettingList: React.FC<SettingPageProps> = ({
231258
),
232259
children:
233260
group.settingItems.length > 0 ? (
234-
<GroupSettingItems group={group} hideEmpty />
261+
<GroupSettingItems
262+
group={group}
263+
hideEmpty
264+
onReset={() => {
265+
setSelectedGroupToReset(group);
266+
setIsOpenResetChangesModal();
267+
}}
268+
/>
235269
) : (
236270
<Empty
237271
image={Empty.PRESENTED_IMAGE_SIMPLE}
@@ -248,14 +282,11 @@ const SettingList: React.FC<SettingPageProps> = ({
248282
okText={t('button.Reset')}
249283
okButtonProps={{ danger: true }}
250284
onOk={() => {
251-
_.flatMap(settingGroups, (item) => item.settingItems).forEach(
252-
(option) => {
253-
!option.disabled &&
254-
option?.setValue &&
255-
option.setValue(option.defaultValue);
256-
},
257-
);
285+
selectedGroupToReset
286+
? resetSettingItems([selectedGroupToReset])
287+
: resetSettingItems(settingGroups);
258288
setIsOpenResetChangesModal();
289+
setSelectedGroupToReset(null);
259290
}}
260291
cancelText={t('button.Cancel')}
261292
onCancel={() => setIsOpenResetChangesModal()}
@@ -271,3 +302,11 @@ const SettingList: React.FC<SettingPageProps> = ({
271302
};
272303

273304
export default SettingList;
305+
306+
const resetSettingItems = (settingGroups: SettingGroup[]) => {
307+
_.flatMap(settingGroups, (item) => item.settingItems).forEach((option) => {
308+
!option.disabled &&
309+
option?.setValue &&
310+
option.setValue(option.defaultValue);
311+
});
312+
};

react/src/helper/customThemeConfig.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ThemeConfig } from 'antd';
22
import _ from 'lodash';
3-
import { useEffect, useState } from 'react';
3+
import { useEffect, useEffectEvent, useState } from 'react';
44

55
type LogoConfig = {
66
src: string;
@@ -29,6 +29,13 @@ type BrandingConfig = {
2929
companyName?: string;
3030
brandName?: string;
3131
};
32+
export type CustomThemeConfig = {
33+
light: ThemeConfig;
34+
dark: ThemeConfig;
35+
logo: LogoConfig;
36+
sider?: SiderConfig;
37+
branding?: BrandingConfig;
38+
};
3239
let _customTheme:
3340
| {
3441
light: ThemeConfig;
@@ -70,8 +77,10 @@ export const loadCustomThemeConfig = () => {
7077
};
7178

7279
export const useCustomThemeConfig = () => {
73-
const [customThemeConfig, setCustomThemeConfig] = useState(_customTheme);
74-
useEffect(() => {
80+
const [customThemeConfig, setCustomThemeConfig] = useState<
81+
CustomThemeConfig | undefined
82+
>(_customTheme);
83+
const addEventListener = useEffectEvent(() => {
7584
if (!customThemeConfig) {
7685
const handler = () => {
7786
setCustomThemeConfig(_customTheme);
@@ -82,7 +91,10 @@ export const useCustomThemeConfig = () => {
8291
document.removeEventListener('custom-theme-loaded', handler);
8392
};
8493
}
85-
// eslint-disable-next-line react-hooks/exhaustive-deps
94+
});
95+
96+
useEffect(() => {
97+
addEventListener();
8698
}, []);
8799

88100
return customThemeConfig;

0 commit comments

Comments
 (0)