feat: preselected theme (#8354)

This PR implements the ability to attempt to force the use of light/dark
theme in the admin panel. While I am a big advocate for the benefits
that dark mode can bring to UX, it does not always suit a clients
branding needs.

Open to discussion on whether we consider this a suitable feature for
the platform. Please feel free to add to this PR as needed.

TODO:

- [x] Implement tests (I'm open to guidance on this from the Payload
team as currently it doesn't look like it's possible to adjust the
payload config file on the fly - meaning it can't be easily placed in
the admin folder tests).

---------

Co-authored-by: Germán Jabloñski <43938777+GermanJablo@users.noreply.github.com>
This commit is contained in:
Riley Pearce
2024-09-26 23:39:29 +09:30
committed by GitHub
parent 4b0351fcca
commit 84d2026330
10 changed files with 43 additions and 10 deletions

View File

@@ -98,6 +98,7 @@ The following options are available:
| **`livePreview`** | Enable real-time editing for instant visual feedback of your front-end application. [More details](../live-preview/overview). | | **`livePreview`** | Enable real-time editing for instant visual feedback of your front-end application. [More details](../live-preview/overview). |
| **`meta`** | Base metadata to use for the Admin Panel. [More details](./metadata). | | **`meta`** | Base metadata to use for the Admin Panel. [More details](./metadata). |
| **`routes`** | Replace built-in Admin Panel routes with your own custom routes. [More details](#customizing-routes). | | **`routes`** | Replace built-in Admin Panel routes with your own custom routes. [More details](#customizing-routes). |
| **`theme`** | Restrict the Admin Panel theme to use only one of your choice. Default is `all`.
| **`user`** | The `slug` of the Collection that you want to allow to login to the Admin Panel. [More details](#the-admin-user-collection). | | **`user`** | The `slug` of the Collection that you want to allow to login to the Admin Panel. [More details](#the-admin-user-collection). |
<Banner type="success"> <Banner type="success">

View File

@@ -12,6 +12,10 @@ type GetRequestLanguageArgs = {
const acceptedThemes: Theme[] = ['dark', 'light'] const acceptedThemes: Theme[] = ['dark', 'light']
export const getRequestTheme = ({ config, cookies, headers }: GetRequestLanguageArgs): Theme => { export const getRequestTheme = ({ config, cookies, headers }: GetRequestLanguageArgs): Theme => {
if (config.admin.theme !== 'all' && acceptedThemes.includes(config.admin.theme)) {
return config.admin.theme
}
const themeCookie = cookies.get(`${config.cookiePrefix || 'payload'}-theme`) const themeCookie = cookies.get(`${config.cookiePrefix || 'payload'}-theme`)
const themeFromCookie: Theme = ( const themeFromCookie: Theme = (

View File

@@ -1,5 +1,5 @@
import type { I18n } from '@payloadcms/translations' import type { I18n } from '@payloadcms/translations'
import type { LanguageOptions } from 'payload' import type { Config, LanguageOptions } from 'payload'
import { FieldLabel } from '@payloadcms/ui' import { FieldLabel } from '@payloadcms/ui'
import React from 'react' import React from 'react'
@@ -14,8 +14,9 @@ export const Settings: React.FC<{
readonly className?: string readonly className?: string
readonly i18n: I18n readonly i18n: I18n
readonly languageOptions: LanguageOptions readonly languageOptions: LanguageOptions
readonly theme: Config['admin']['theme']
}> = (props) => { }> = (props) => {
const { className, i18n, languageOptions } = props const { className, i18n, languageOptions, theme } = props
return ( return (
<div className={[baseClass, className].filter(Boolean).join(' ')}> <div className={[baseClass, className].filter(Boolean).join(' ')}>
@@ -24,7 +25,7 @@ export const Settings: React.FC<{
<FieldLabel field={null} htmlFor="language-select" label={i18n.t('general:language')} /> <FieldLabel field={null} htmlFor="language-select" label={i18n.t('general:language')} />
<LanguageSelector languageOptions={languageOptions} /> <LanguageSelector languageOptions={languageOptions} />
</div> </div>
<ToggleTheme /> {theme === 'all' && <ToggleTheme />}
</div> </div>
) )
} }

View File

@@ -38,7 +38,11 @@ export const Account: React.FC<AdminViewProps> = async ({
} = initPageResult } = initPageResult
const { const {
admin: { components: { views: { Account: CustomAccountComponent } = {} } = {}, user: userSlug }, admin: {
components: { views: { Account: CustomAccountComponent } = {} } = {},
theme,
user: userSlug,
},
routes: { api }, routes: { api },
serverURL, serverURL,
} = config } = config
@@ -85,7 +89,7 @@ export const Account: React.FC<AdminViewProps> = async ({
return ( return (
<DocumentInfoProvider <DocumentInfoProvider
AfterFields={<Settings i18n={i18n} languageOptions={languageOptions} />} AfterFields={<Settings i18n={i18n} languageOptions={languageOptions} theme={theme} />}
apiURL={`${serverURL}${api}/${userSlug}${user?.id ? `/${user.id}` : ''}`} apiURL={`${serverURL}${api}/${userSlug}${user?.id ? `/${user.id}` : ''}`}
collectionSlug={userSlug} collectionSlug={userSlug}
docPermissions={docPermissions} docPermissions={docPermissions}

View File

@@ -25,6 +25,7 @@ export const defaults: Omit<Config, 'db' | 'editor' | 'secret'> = {
reset: '/reset', reset: '/reset',
unauthorized: '/unauthorized', unauthorized: '/unauthorized',
}, },
theme: 'all',
}, },
bin: [], bin: [],
collections: [], collections: [],

View File

@@ -818,6 +818,12 @@ export type Config = {
/** The route for the unauthorized page. */ /** The route for the unauthorized page. */
unauthorized?: string unauthorized?: string
} }
/**
* Restrict the Admin Panel theme to use only one of your choice
*
* @default 'all' // The theme can be configured by users
*/
theme?: 'all' | 'dark' | 'light'
/** The slug of a Collection that you want to be used to log in to the Admin dashboard. */ /** The slug of a Collection that you want to be used to log in to the Admin dashboard. */
user?: string user?: string
} }

View File

@@ -88,7 +88,7 @@ export const RootProvider: React.FC<Props> = ({
<ModalProvider classPrefix="payload" transTime={0} zIndex="var(--z-modal)"> <ModalProvider classPrefix="payload" transTime={0} zIndex="var(--z-modal)">
<AuthProvider permissions={permissions} user={user}> <AuthProvider permissions={permissions} user={user}>
<PreferencesProvider> <PreferencesProvider>
<ThemeProvider cookiePrefix={config.cookiePrefix} theme={theme}> <ThemeProvider theme={theme}>
<ParamsProvider> <ParamsProvider>
<LocaleProvider> <LocaleProvider>
<StepNavProvider> <StepNavProvider>

View File

@@ -1,6 +1,8 @@
'use client' 'use client'
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react' import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'
import { useConfig } from '../Config/index.js'
export type Theme = 'dark' | 'light' export type Theme = 'dark' | 'light'
export type ThemeContext = { export type ThemeContext = {
@@ -55,20 +57,26 @@ export const defaultTheme = 'light'
export const ThemeProvider: React.FC<{ export const ThemeProvider: React.FC<{
children?: React.ReactNode children?: React.ReactNode
cookiePrefix?: string
theme?: Theme theme?: Theme
}> = ({ children, cookiePrefix, theme: initialTheme }) => { }> = ({ children, theme: initialTheme }) => {
const cookieKey = `${cookiePrefix || 'payload'}-theme` const { config } = useConfig()
const preselectedTheme = config.admin.theme
const cookieKey = `${config.cookiePrefix || 'payload'}-theme`
const [theme, setThemeState] = useState<Theme>(initialTheme || defaultTheme) const [theme, setThemeState] = useState<Theme>(initialTheme || defaultTheme)
const [autoMode, setAutoMode] = useState<boolean>() const [autoMode, setAutoMode] = useState<boolean>()
useEffect(() => { useEffect(() => {
if (preselectedTheme !== 'all') {
return
}
const { theme, themeFromCookies } = getTheme(cookieKey) const { theme, themeFromCookies } = getTheme(cookieKey)
setThemeState(theme) setThemeState(theme)
setAutoMode(!themeFromCookies) setAutoMode(!themeFromCookies)
}, [cookieKey]) }, [preselectedTheme, cookieKey])
const setTheme = useCallback( const setTheme = useCallback(
(themeToSet: 'auto' | Theme) => { (themeToSet: 'auto' | Theme) => {

View File

@@ -21,6 +21,7 @@ export default buildConfigWithDefaults({
importMap: { importMap: {
baseDir: path.resolve(dirname), baseDir: path.resolve(dirname),
}, },
theme: 'dark',
}, },
cors: ['http://localhost:3000', 'http://localhost:3001'], cors: ['http://localhost:3000', 'http://localhost:3001'],
globals: [MenuGlobal], globals: [MenuGlobal],

View File

@@ -98,4 +98,11 @@ test.describe('Admin Panel (Root)', () => {
await expect(favicons.nth(1)).toHaveAttribute('media', '(prefers-color-scheme: dark)') await expect(favicons.nth(1)).toHaveAttribute('media', '(prefers-color-scheme: dark)')
await expect(favicons.nth(1)).toHaveAttribute('href', /\/payload-favicon-light\.[a-z\d]+\.png/) await expect(favicons.nth(1)).toHaveAttribute('href', /\/payload-favicon-light\.[a-z\d]+\.png/)
}) })
test('config.admin.theme should restrict the theme', async () => {
await page.goto(url.account)
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
await expect(page.locator('#field-theme')).toBeHidden()
await expect(page.locator('#field-theme-auto')).toBeHidden()
})
}) })