mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
feat: hide the themes the organisation turns off and keep a locked theme in place
This commit is contained in:
@@ -7,6 +7,8 @@ import { resolvePalette, useColors, useResolvedTheme } from '../../theme/colors'
|
|||||||
import { BUILTIN_THEMES } from '../../theme/builtin-themes';
|
import { BUILTIN_THEMES } from '../../theme/builtin-themes';
|
||||||
import { useSettingsStore } from '../../stores/settings-store';
|
import { useSettingsStore } from '../../stores/settings-store';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import { usePolicyStore, useSettingLocked } from '../../stores/policy-store';
|
||||||
|
import { visibleThemeCards } from './theme-cards';
|
||||||
|
|
||||||
interface ThemeCard {
|
interface ThemeCard {
|
||||||
id: string | null;
|
id: string | null;
|
||||||
@@ -28,19 +30,23 @@ export function ThemesSettings() {
|
|||||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||||
const active = useSettingsStore((s) => s.activeThemeId);
|
const active = useSettingsStore((s) => s.activeThemeId);
|
||||||
const update = useSettingsStore((s) => s.updateSetting);
|
const update = useSettingsStore((s) => s.updateSetting);
|
||||||
|
// The organisation can turn built-in themes off, and pin one (or the
|
||||||
|
// default, with themes turned off) for everyone (webmail themes-settings.tsx).
|
||||||
|
const policy = usePolicyStore((s) => s.effective);
|
||||||
|
const themeLocked = useSettingLocked('activeThemeId');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hydrated) void hydrate();
|
if (!hydrated) void hydrate();
|
||||||
}, [hydrated, hydrate]);
|
}, [hydrated, hydrate]);
|
||||||
|
|
||||||
const cards: ThemeCard[] = [
|
const cards = React.useMemo<ThemeCard[]>(() => visibleThemeCards([
|
||||||
{
|
{
|
||||||
id: null,
|
id: null,
|
||||||
name: t('settings.themes.default_name', 'Bulwark'),
|
name: t('settings.themes.default_name', 'Bulwark'),
|
||||||
description: t('settings.themes.default_description', 'The default light and dark palettes.'),
|
description: t('settings.themes.default_description', 'The default light and dark palettes.'),
|
||||||
},
|
},
|
||||||
...BUILTIN_THEMES.map((theme) => ({ id: theme.id, name: theme.name, description: theme.description })),
|
...BUILTIN_THEMES.map((theme) => ({ id: theme.id, name: theme.name, description: theme.description })),
|
||||||
];
|
], policy), [t, policy]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
@@ -50,6 +56,11 @@ export function ThemesSettings() {
|
|||||||
'Pick a colour theme. Themes follow the light/dark setting from Appearance; custom theme packages are managed in the webmail.',
|
'Pick a colour theme. Themes follow the light/dark setting from Appearance; custom theme packages are managed in the webmail.',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{themeLocked && (
|
||||||
|
<View style={styles.lockedNote}>
|
||||||
|
<Text style={styles.lockedNoteText}>{t('settings.themes.locked', 'Theme selection is locked by an administrator.')}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<View style={styles.grid}>
|
<View style={styles.grid}>
|
||||||
{cards.map((card) => {
|
{cards.map((card) => {
|
||||||
const isActive = (active ?? null) === card.id;
|
const isActive = (active ?? null) === card.id;
|
||||||
@@ -58,10 +69,11 @@ export function ThemesSettings() {
|
|||||||
<Pressable
|
<Pressable
|
||||||
key={card.id ?? 'default'}
|
key={card.id ?? 'default'}
|
||||||
onPress={() => update('activeThemeId', card.id)}
|
onPress={() => update('activeThemeId', card.id)}
|
||||||
|
disabled={themeLocked}
|
||||||
accessibilityRole="radio"
|
accessibilityRole="radio"
|
||||||
accessibilityState={{ selected: isActive, checked: isActive }}
|
accessibilityState={{ selected: isActive, checked: isActive, disabled: themeLocked }}
|
||||||
accessibilityLabel={card.name}
|
accessibilityLabel={card.name}
|
||||||
style={[styles.card, isActive && styles.cardActive]}
|
style={[styles.card, isActive && styles.cardActive, themeLocked && !isActive && styles.cardDisabled]}
|
||||||
>
|
>
|
||||||
<View style={[styles.preview, { backgroundColor: preview.background, borderColor: preview.border }]}>
|
<View style={[styles.preview, { backgroundColor: preview.background, borderColor: preview.border }]}>
|
||||||
<View style={[styles.previewRail, { backgroundColor: preview.surface, borderRightColor: preview.border }]} />
|
<View style={[styles.previewRail, { backgroundColor: preview.surface, borderRightColor: preview.border }]} />
|
||||||
@@ -112,6 +124,17 @@ function makeStyles(c: ThemePalette) {
|
|||||||
borderColor: c.primary,
|
borderColor: c.primary,
|
||||||
backgroundColor: c.primaryBg,
|
backgroundColor: c.primaryBg,
|
||||||
},
|
},
|
||||||
|
cardDisabled: { opacity: 0.6 },
|
||||||
|
lockedNote: {
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: c.warning,
|
||||||
|
backgroundColor: c.warningBg,
|
||||||
|
},
|
||||||
|
lockedNoteText: { ...typography.caption, color: c.text },
|
||||||
preview: {
|
preview: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
aspectRatio: 16 / 10,
|
aspectRatio: 16 / 10,
|
||||||
|
|||||||
@@ -16,4 +16,14 @@ describe('organisation policy on the settings pages', () => {
|
|||||||
expect(page).toContain("const themeLocked = useSettingLocked('theme');");
|
expect(page).toContain("const themeLocked = useSettingLocked('theme');");
|
||||||
expect(page).toMatch(/label=\{t\('settings\.appearance\.theme\.label', "Theme"\)\}[^>]*locked=\{themeLocked\}>/);
|
expect(page).toMatch(/label=\{t\('settings\.appearance\.theme\.label', "Theme"\)\}[^>]*locked=\{themeLocked\}>/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hides the themes the policy turns off and keeps the grid still while the theme is locked', () => {
|
||||||
|
const page = settings('ThemesSettings');
|
||||||
|
expect(page).toContain('const policy = usePolicyStore((s) => s.effective);');
|
||||||
|
expect(page).toMatch(/visibleThemeCards\(\[[\s\S]*\], policy\), \[t, policy\]\)/);
|
||||||
|
expect(page).toContain('{cards.map((card) => {');
|
||||||
|
expect(page).toContain("const themeLocked = useSettingLocked('activeThemeId');");
|
||||||
|
expect(page).toContain('disabled={themeLocked}');
|
||||||
|
expect(page).toContain("{themeLocked && (");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { visibleThemeCards } from '../theme-cards';
|
||||||
|
import { sanitizePolicy } from '../../../lib/org-policy';
|
||||||
|
|
||||||
|
const CARDS = [
|
||||||
|
{ id: null, name: 'Bulwark' },
|
||||||
|
{ id: 'nord', name: 'Nord' },
|
||||||
|
{ id: 'solarized', name: 'Solarized' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('visibleThemeCards', () => {
|
||||||
|
it('offers every card without a policy', () => {
|
||||||
|
expect(visibleThemeCards(CARDS, null).map((c) => c.id)).toEqual([null, 'nord', 'solarized']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves out the built-in themes the policy turns off', () => {
|
||||||
|
const policy = sanitizePolicy({ themePolicy: { disabledBuiltinThemes: ['nord'] } });
|
||||||
|
expect(visibleThemeCards(CARDS, policy).map((c) => c.id)).toEqual([null, 'solarized']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the default card whatever the policy lists', () => {
|
||||||
|
const policy = sanitizePolicy({ themePolicy: { disabledBuiltinThemes: ['nord', 'solarized', 'null', ''] } });
|
||||||
|
expect(visibleThemeCards(CARDS, policy).map((c) => c.id)).toEqual([null]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { OrgPolicy } from '../../lib/org-policy';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The theme cards the Themes page offers: every card but the built-in themes
|
||||||
|
* the organisation's policy turns off (webmail themes-settings.tsx filters
|
||||||
|
* them with isThemeDisabled). The default card (id null) always stays, so
|
||||||
|
* there is something to pick.
|
||||||
|
*/
|
||||||
|
export function visibleThemeCards<T extends { id: string | null }>(
|
||||||
|
cards: readonly T[],
|
||||||
|
policy: OrgPolicy | null | undefined,
|
||||||
|
): T[] {
|
||||||
|
const disabled = policy?.themePolicy.disabledBuiltinThemes ?? [];
|
||||||
|
if (disabled.length === 0) return [...cards];
|
||||||
|
return cards.filter((card) => card.id === null || !disabled.includes(card.id));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user