fix(ui): disables form during locale change (#8705)
Editing fields during a locale change on slow networks can lead to changes being reset when the new form state is returned. This is because the fields receive new values from context when the new locale loads in, which may have occurred _after_ changes were made to the fields. The fix is to subscribe to a new `localeIsLoading` context which is set immediately after changing locales, and then reset once the new locale loads in. This also removes the misleading `@deprecated` flag from the `useLocale` hook itself.
This commit is contained in:
@@ -54,7 +54,7 @@ export const RootLayout = async ({
|
||||
|
||||
const payload = await getPayload({ config, importMap })
|
||||
|
||||
const { i18n, permissions, user } = await initReq(config)
|
||||
const { i18n, permissions, req, user } = await initReq(config)
|
||||
|
||||
const dir = (rtlLanguages as unknown as AcceptedLanguages[]).includes(languageCode)
|
||||
? 'RTL'
|
||||
@@ -92,9 +92,10 @@ export const RootLayout = async ({
|
||||
importMap,
|
||||
})
|
||||
|
||||
req.user = user
|
||||
|
||||
const locale = await getRequestLocale({
|
||||
payload,
|
||||
user,
|
||||
req,
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Payload, User } from 'payload'
|
||||
|
||||
import { cache } from 'react'
|
||||
|
||||
export const getPreference = cache(
|
||||
export const getPreferences = cache(
|
||||
async <T>(key: string, payload: Payload, user: User): Promise<T> => {
|
||||
let result: T = null
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
import type { Locale, Payload, User } from 'payload'
|
||||
import type { Locale, PayloadRequest } from 'payload'
|
||||
|
||||
import { upsertPreferences } from '@payloadcms/ui/rsc'
|
||||
import { findLocaleFromCode } from '@payloadcms/ui/shared'
|
||||
|
||||
import { getPreference } from './getPreference.js'
|
||||
import { getPreferences } from './getPreferences.js'
|
||||
|
||||
type GetRequestLocalesArgs = {
|
||||
localeFromParams?: string
|
||||
payload: Payload
|
||||
user: User
|
||||
req: PayloadRequest
|
||||
}
|
||||
|
||||
export async function getRequestLocale({
|
||||
localeFromParams,
|
||||
payload,
|
||||
user,
|
||||
}: GetRequestLocalesArgs): Promise<Locale> {
|
||||
if (payload.config.localization) {
|
||||
export async function getRequestLocale({ req }: GetRequestLocalesArgs): Promise<Locale> {
|
||||
if (req.payload.config.localization) {
|
||||
const localeFromParams = req.query.locale as string | undefined
|
||||
|
||||
if (localeFromParams) {
|
||||
await upsertPreferences<Locale['code']>({ key: 'locale', req, value: localeFromParams })
|
||||
}
|
||||
|
||||
return (
|
||||
findLocaleFromCode(
|
||||
payload.config.localization,
|
||||
localeFromParams || (await getPreference<Locale['code']>('locale', payload, user)),
|
||||
req.payload.config.localization,
|
||||
localeFromParams || (await getPreferences<Locale['code']>('locale', req.payload, req.user)),
|
||||
) ||
|
||||
findLocaleFromCode(
|
||||
payload.config.localization,
|
||||
payload.config.localization.defaultLocale || 'en',
|
||||
req.payload.config.localization,
|
||||
req.payload.config.localization.defaultLocale || 'en',
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,9 +74,7 @@ export const initPage = async ({
|
||||
req.user = user
|
||||
|
||||
const locale = await getRequestLocale({
|
||||
localeFromParams: req.query.locale as string,
|
||||
payload,
|
||||
user,
|
||||
req,
|
||||
})
|
||||
|
||||
req.locale = locale?.code
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import { getTranslation } from '@payloadcms/translations'
|
||||
import { useSearchParams } from 'next/navigation.js'
|
||||
import * as qs from 'qs-esm'
|
||||
import React from 'react'
|
||||
import React, { Fragment } from 'react'
|
||||
|
||||
import { useConfig } from '../../providers/Config/index.js'
|
||||
import { useLocale } from '../../providers/Locale/index.js'
|
||||
import { useLocale, useLocaleLoading } from '../../providers/Locale/index.js'
|
||||
import { useTranslation } from '../../providers/Translation/index.js'
|
||||
import { parseSearchParams } from '../../utilities/parseSearchParams.js'
|
||||
import { Popup, PopupList } from '../Popup/index.js'
|
||||
@@ -21,6 +21,7 @@ export const Localizer: React.FC<{
|
||||
const { config } = useConfig()
|
||||
const { localization } = config
|
||||
const searchParams = useSearchParams()
|
||||
const { setLocaleIsLoading } = useLocaleLoading()
|
||||
|
||||
const { i18n } = useTranslation()
|
||||
const locale = useLocale()
|
||||
@@ -41,6 +42,7 @@ export const Localizer: React.FC<{
|
||||
return (
|
||||
<PopupList.Button
|
||||
active={locale.code === localeOption.code}
|
||||
disabled={locale.code === localeOption.code}
|
||||
href={qs.stringify(
|
||||
{
|
||||
...parseSearchParams(searchParams),
|
||||
@@ -49,10 +51,22 @@ export const Localizer: React.FC<{
|
||||
{ addQueryPrefix: true },
|
||||
)}
|
||||
key={localeOption.code}
|
||||
onClick={close}
|
||||
onClick={() => {
|
||||
setLocaleIsLoading(true)
|
||||
close()
|
||||
}}
|
||||
>
|
||||
{localeOptionLabel !== localeOption.code ? (
|
||||
<Fragment>
|
||||
{localeOptionLabel}
|
||||
{localeOptionLabel !== localeOption.code && ` (${localeOption.code})`}
|
||||
|
||||
<span className={`${baseClass}__locale-code`}>
|
||||
{`(${localeOption.code})`}
|
||||
</span>
|
||||
</Fragment>
|
||||
) : (
|
||||
<span className={`${baseClass}__locale-code`}>{localeOptionLabel}</span>
|
||||
)}
|
||||
</PopupList.Button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
'use client'
|
||||
// TODO: abstract the `next/link` dependency out from this component
|
||||
import type { LinkProps } from 'next/link.js'
|
||||
|
||||
import LinkImport from 'next/link.js'
|
||||
import * as React from 'react' // TODO: abstract this out to support all routers
|
||||
import * as React from 'react'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
@@ -32,6 +31,7 @@ type MenuButtonProps = {
|
||||
active?: boolean
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
href?: LinkProps['href']
|
||||
id?: string
|
||||
onClick?: (e?: React.MouseEvent) => void
|
||||
@@ -42,6 +42,7 @@ export const Button: React.FC<MenuButtonProps> = ({
|
||||
active,
|
||||
children,
|
||||
className,
|
||||
disabled,
|
||||
href,
|
||||
onClick,
|
||||
}) => {
|
||||
@@ -49,6 +50,7 @@ export const Button: React.FC<MenuButtonProps> = ({
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
if (!disabled) {
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
@@ -83,6 +85,7 @@ export const Button: React.FC<MenuButtonProps> = ({
|
||||
</button>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes} id={id}>
|
||||
|
||||
@@ -8,7 +8,15 @@ import type {
|
||||
} from 'payload'
|
||||
|
||||
import * as qs from 'qs-esm'
|
||||
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
import type { DocumentInfoContext, DocumentInfoProps } from './types.js'
|
||||
|
||||
@@ -16,7 +24,7 @@ import { useAuth } from '../../providers/Auth/index.js'
|
||||
import { requests } from '../../utilities/api.js'
|
||||
import { formatDocTitle } from '../../utilities/formatDocTitle.js'
|
||||
import { useConfig } from '../Config/index.js'
|
||||
import { useLocale } from '../Locale/index.js'
|
||||
import { useLocale, useLocaleLoading } from '../Locale/index.js'
|
||||
import { usePreferences } from '../Preferences/index.js'
|
||||
import { useTranslation } from '../Translation/index.js'
|
||||
import { UploadEditsProvider, useUploadEdits } from '../UploadEdits/index.js'
|
||||
@@ -113,10 +121,14 @@ const DocumentInfo: React.FC<
|
||||
setUploadStatus(status)
|
||||
}, [])
|
||||
|
||||
const isInitializing = initialState === undefined || initialData === undefined
|
||||
|
||||
const { getPreference, setPreference } = usePreferences()
|
||||
const { code: locale } = useLocale()
|
||||
const { localeIsLoading } = useLocaleLoading()
|
||||
|
||||
const isInitializing = useMemo(
|
||||
() => initialState === undefined || initialData === undefined || localeIsLoading,
|
||||
[initialData, initialState, localeIsLoading],
|
||||
)
|
||||
|
||||
const baseURL = `${serverURL}${api}`
|
||||
let slug: string
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { Locale } from 'payload'
|
||||
|
||||
import { useSearchParams } from 'next/navigation.js'
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react'
|
||||
import React, { createContext, useContext, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { findLocaleFromCode } from '../../utilities/findLocaleFromCode.js'
|
||||
import { useAuth } from '../Auth/index.js'
|
||||
@@ -12,9 +12,30 @@ import { usePreferences } from '../Preferences/index.js'
|
||||
|
||||
const LocaleContext = createContext({} as Locale)
|
||||
|
||||
export const LocaleLoadingContext = createContext({
|
||||
localeIsLoading: false,
|
||||
setLocaleIsLoading: (_: boolean) => undefined,
|
||||
})
|
||||
|
||||
const fetchPreferences = async <T extends Record<string, unknown> | string>(
|
||||
key: string,
|
||||
): Promise<{ id: string; value: T }> =>
|
||||
await fetch(`/api/payload-preferences/${key}`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'GET',
|
||||
})?.then((res) => res.json() as Promise<{ id: string; value: T }>)
|
||||
|
||||
export const LocaleProvider: React.FC<{ children?: React.ReactNode; locale?: Locale['code'] }> = ({
|
||||
children,
|
||||
locale: localeFromProps,
|
||||
/**
|
||||
The `locale` prop originates from the root layout, which does not have access to search params
|
||||
This component uses the `useSearchParams` hook to get the locale from the URL as precedence over this prop
|
||||
This prop does not update as the user navigates the site, because the root layout does not re-render
|
||||
*/
|
||||
locale: initialLocaleFromPrefs,
|
||||
}) => {
|
||||
const {
|
||||
config: { localization = false },
|
||||
@@ -28,61 +49,74 @@ export const LocaleProvider: React.FC<{ children?: React.ReactNode; locale?: Loc
|
||||
const { getPreference, setPreference } = usePreferences()
|
||||
const localeFromParams = useSearchParams().get('locale')
|
||||
|
||||
// `localeFromProps` originates from the root layout, which does not have access to search params
|
||||
const [localeCode, setLocaleCode] = useState<string>(localeFromProps || localeFromParams)
|
||||
|
||||
const locale: Locale = React.useMemo(() => {
|
||||
const [locale, setLocale] = React.useState<Locale>(() => {
|
||||
if (!localization) {
|
||||
// TODO: return null V4
|
||||
return {} as Locale
|
||||
}
|
||||
|
||||
return (
|
||||
findLocaleFromCode(localization, localeFromParams || localeCode) ||
|
||||
findLocaleFromCode(localization, localeFromParams) ||
|
||||
findLocaleFromCode(localization, initialLocaleFromPrefs) ||
|
||||
findLocaleFromCode(localization, defaultLocale)
|
||||
)
|
||||
}, [localeCode, localeFromParams, localization, defaultLocale])
|
||||
})
|
||||
|
||||
const [isLoading, setLocaleIsLoading] = useState(false)
|
||||
|
||||
const prevLocale = useRef<Locale>(locale)
|
||||
|
||||
useEffect(() => {
|
||||
async function setInitialLocale() {
|
||||
if (localization && user) {
|
||||
if (typeof localeFromParams !== 'string') {
|
||||
try {
|
||||
const localeToSet = await getPreference<string>('locale')
|
||||
setLocaleCode(localeToSet)
|
||||
} catch (_) {
|
||||
setLocaleCode(defaultLocale)
|
||||
/**
|
||||
* We need to set `isLoading` back to false once the locale is detected to be different
|
||||
* This happens when the user clicks an anchor link which appends the `?locale=` query param
|
||||
* This triggers a client-side navigation, which re-renders the page with the new locale
|
||||
* In Next.js, local state is persisted during this type of navigation because components are not unmounted
|
||||
*/
|
||||
if (locale.code !== prevLocale.current.code) {
|
||||
setLocaleIsLoading(false)
|
||||
}
|
||||
} else {
|
||||
void setPreference(
|
||||
'locale',
|
||||
findLocaleFromCode(localization, localeFromParams)?.code || defaultLocale,
|
||||
|
||||
prevLocale.current = locale
|
||||
}, [locale])
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* This effect should only run when `localeFromParams` changes, i.e. when the user clicks an anchor link
|
||||
* The root layout, which sends the initial locale from prefs, will not re-render as the user navigates the site
|
||||
* For this reason, we need to fetch the locale from prefs if the search params clears the `locale` query param
|
||||
*/
|
||||
async function resetLocale() {
|
||||
if (localization && user?.id) {
|
||||
const localeToUse =
|
||||
localeFromParams ||
|
||||
(await fetchPreferences<Locale['code']>('locale')?.then((res) => res.value)) ||
|
||||
defaultLocale
|
||||
|
||||
const newLocale =
|
||||
findLocaleFromCode(localization, localeToUse) ||
|
||||
findLocaleFromCode(localization, defaultLocale)
|
||||
|
||||
setLocale(newLocale)
|
||||
}
|
||||
}
|
||||
|
||||
void resetLocale()
|
||||
}, [defaultLocale, getPreference, localization, localeFromParams, user?.id])
|
||||
|
||||
return (
|
||||
<LocaleContext.Provider value={locale}>
|
||||
<LocaleLoadingContext.Provider value={{ localeIsLoading: isLoading, setLocaleIsLoading }}>
|
||||
{children}
|
||||
</LocaleLoadingContext.Provider>
|
||||
</LocaleContext.Provider>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void setInitialLocale()
|
||||
}, [defaultLocale, getPreference, localization, localeFromParams, setPreference, user])
|
||||
|
||||
return <LocaleContext.Provider value={locale}>{children}</LocaleContext.Provider>
|
||||
}
|
||||
|
||||
export const useLocaleLoading = () => useContext(LocaleLoadingContext)
|
||||
|
||||
/**
|
||||
* @deprecated A hook that returns the current locale object.
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* #### 🚨 V4 Breaking Change
|
||||
* The `useLocale` return type now reflects `null | Locale` instead of `false | Locale`.
|
||||
*
|
||||
* **Old (V3):**
|
||||
* ```ts
|
||||
* const { code } = useLocale();
|
||||
* ```
|
||||
* **New (V4):**
|
||||
* ```ts
|
||||
* const locale = useLocale();
|
||||
* ```
|
||||
* TODO: V4
|
||||
* The return type of the `useLocale` hook will change in v4. It will return `null | Locale` instead of `false | {} | Locale`.
|
||||
*/
|
||||
export const useLocale = (): Locale => useContext(LocaleContext)
|
||||
|
||||
@@ -142,7 +142,7 @@ export const buildTableState = async (
|
||||
value: {
|
||||
columns,
|
||||
limit: isNumber(query?.limit) ? Number(query.limit) : undefined,
|
||||
sort: query?.sort,
|
||||
sort: query?.sort as string,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
0
packages/ui/src/utilities/fetchPreferences.ts
Normal file
0
packages/ui/src/utilities/fetchPreferences.ts
Normal file
@@ -1,27 +1,17 @@
|
||||
import type { PayloadRequest } from 'payload'
|
||||
import type { DefaultDocumentIDType, PayloadRequest } from 'payload'
|
||||
|
||||
import { dequal } from 'dequal/lite'
|
||||
|
||||
import { removeUndefined } from './removeUndefined.js'
|
||||
|
||||
/**
|
||||
* Will update the given preferences by key, creating a new record if it doesn't already exist, or merging existing preferences with the new value.
|
||||
* This is not possible to do with the existing `db.upsert` operation because it stores on the `value` key and does not perform a deep merge beyond the first level.
|
||||
* I.e. if you have a preferences record with a `value` key, `db.upsert` will overwrite the existing value. In the future if this supported we should use that instead.
|
||||
* @param req - The PayloadRequest object
|
||||
* @param key - The key of the preferences to update
|
||||
* @param value - The new value to merge with the existing preferences
|
||||
*/
|
||||
export const upsertPreferences = async <T extends Record<string, any>>({
|
||||
const getPreferences = async <T>({
|
||||
key,
|
||||
req,
|
||||
value: incomingValue,
|
||||
}: {
|
||||
key: string
|
||||
req: PayloadRequest
|
||||
value: Record<string, any>
|
||||
}): Promise<T> => {
|
||||
const preferencesResult = await req.payload
|
||||
}): Promise<{ id: DefaultDocumentIDType; value: T }> => {
|
||||
const result = (await req.payload
|
||||
.find({
|
||||
collection: 'payload-preferences',
|
||||
depth: 0,
|
||||
@@ -47,11 +37,36 @@ export const upsertPreferences = async <T extends Record<string, any>>({
|
||||
],
|
||||
},
|
||||
})
|
||||
.then((res) => res.docs[0] ?? { id: null, value: {} })
|
||||
.then((res) => res.docs?.[0])) as { id: DefaultDocumentIDType; value: T }
|
||||
|
||||
let newPrefs = preferencesResult.value
|
||||
return result
|
||||
}
|
||||
|
||||
if (!preferencesResult.id) {
|
||||
/**
|
||||
* Will update the given preferences by key, creating a new record if it doesn't already exist, or merging existing preferences with the new value.
|
||||
* This is not possible to do with the existing `db.upsert` operation because it stores on the `value` key and does not perform a deep merge beyond the first level.
|
||||
* I.e. if you have a preferences record with a `value` key, `db.upsert` will overwrite the existing value. In the future if this supported we should use that instead.
|
||||
* @param req - The PayloadRequest object
|
||||
* @param key - The key of the preferences to update
|
||||
* @param value - The new value to merge with the existing preferences
|
||||
*/
|
||||
export const upsertPreferences = async <T extends Record<string, unknown> | string>({
|
||||
key,
|
||||
req,
|
||||
value: incomingValue,
|
||||
}: {
|
||||
key: string
|
||||
req: PayloadRequest
|
||||
value: T
|
||||
}): Promise<T> => {
|
||||
const existingPrefs = await getPreferences<T>({
|
||||
key,
|
||||
req,
|
||||
})
|
||||
|
||||
let newPrefs = existingPrefs?.value
|
||||
|
||||
if (!existingPrefs?.id) {
|
||||
await req.payload.create({
|
||||
collection: 'payload-preferences',
|
||||
data: {
|
||||
@@ -66,15 +81,19 @@ export const upsertPreferences = async <T extends Record<string, any>>({
|
||||
req,
|
||||
})
|
||||
} else {
|
||||
const mergedPrefs = {
|
||||
...(preferencesResult?.value || {}), // Shallow merge existing prefs to acquire any missing keys from incoming value
|
||||
// Strings are valid JSON, i.e. `locale` saved as a string to the locale preferences
|
||||
const mergedPrefs =
|
||||
typeof incomingValue === 'object'
|
||||
? {
|
||||
...(typeof existingPrefs.value === 'object' ? existingPrefs?.value : {}), // Shallow merge existing prefs to acquire any missing keys from incoming value
|
||||
...removeUndefined(incomingValue || {}),
|
||||
}
|
||||
: incomingValue
|
||||
|
||||
if (!dequal(mergedPrefs, preferencesResult.value)) {
|
||||
if (!dequal(mergedPrefs, existingPrefs.value)) {
|
||||
newPrefs = await req.payload
|
||||
.update({
|
||||
id: preferencesResult.id,
|
||||
id: existingPrefs.id,
|
||||
collection: 'payload-preferences',
|
||||
data: {
|
||||
key,
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { BrowserContext, ChromiumBrowserContext, Locator, Page } from '@playwright/test'
|
||||
import type {
|
||||
BrowserContext,
|
||||
CDPSession,
|
||||
ChromiumBrowserContext,
|
||||
Locator,
|
||||
Page,
|
||||
} from '@playwright/test'
|
||||
import type { Config } from 'payload'
|
||||
|
||||
import { formatAdminURL } from '@payloadcms/ui/shared'
|
||||
@@ -52,6 +58,11 @@ const networkConditions = {
|
||||
latency: 1000,
|
||||
upload: ((10 * 1000 * 1000) / 8) * 0.8,
|
||||
},
|
||||
None: {
|
||||
download: 0,
|
||||
latency: -1,
|
||||
upload: -1,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,7 +145,7 @@ export async function throttleTest({
|
||||
context: BrowserContext
|
||||
delay: keyof typeof networkConditions
|
||||
page: Page
|
||||
}) {
|
||||
}): Promise<CDPSession> {
|
||||
const cdpSession = await context.newCDPSession(page)
|
||||
|
||||
await cdpSession.send('Network.emulateNetworkConditions', {
|
||||
@@ -151,6 +162,8 @@ export async function throttleTest({
|
||||
|
||||
const client = await (page.context() as ChromiumBrowserContext).newCDPSession(page)
|
||||
await client.send('Emulation.setCPUThrottlingRate', { rate: 8 }) // 8x slowdown
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
export async function firstRegister(args: FirstRegisterArgs): Promise<void> {
|
||||
@@ -278,18 +291,50 @@ export async function closeNav(page: Page): Promise<void> {
|
||||
await expect(page.locator('.template-default.template-default--nav-open')).toBeHidden()
|
||||
}
|
||||
|
||||
export async function openLocaleSelector(page: Page): Promise<void> {
|
||||
const button = page.locator('.localizer button.popup-button')
|
||||
const popup = page.locator('.localizer .popup.popup--active')
|
||||
|
||||
if (!(await popup.isVisible())) {
|
||||
await button.click()
|
||||
await expect(popup).toBeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeLocaleSelector(page: Page): Promise<void> {
|
||||
const popup = page.locator('.localizer .popup.popup--active')
|
||||
|
||||
if (await popup.isVisible()) {
|
||||
await page.click('body', { position: { x: 0, y: 0 } })
|
||||
await expect(popup).toBeHidden()
|
||||
}
|
||||
}
|
||||
|
||||
export async function changeLocale(page: Page, newLocale: string) {
|
||||
await page.locator('.localizer >> button').first().click()
|
||||
await page
|
||||
.locator(`.localizer .popup.popup--active .popup-button-list__button`, {
|
||||
hasText: newLocale,
|
||||
await openLocaleSelector(page)
|
||||
|
||||
const currentlySelectedLocale = await page
|
||||
.locator(
|
||||
`.localizer .popup.popup--active .popup-button-list__button--selected .localizer__locale-code`,
|
||||
)
|
||||
.textContent()
|
||||
|
||||
if (currentlySelectedLocale !== `(${newLocale})`) {
|
||||
const localeToSelect = page
|
||||
.locator('.localizer .popup.popup--active .popup-button-list__button')
|
||||
.locator('.localizer__locale-code', {
|
||||
hasText: `(${newLocale})`,
|
||||
})
|
||||
.first()
|
||||
.click()
|
||||
|
||||
await expect(localeToSelect).toBeEnabled()
|
||||
await localeToSelect.click()
|
||||
|
||||
const regexPattern = new RegExp(`locale=${newLocale}`)
|
||||
|
||||
await expect(page).toHaveURL(regexPattern)
|
||||
}
|
||||
|
||||
await closeLocaleSelector(page)
|
||||
}
|
||||
|
||||
export function exactText(text: string) {
|
||||
|
||||
@@ -10,9 +10,11 @@ import type { Config, LocalizedPost } from './payload-types.js'
|
||||
|
||||
import {
|
||||
changeLocale,
|
||||
closeLocaleSelector,
|
||||
ensureCompilationIsDone,
|
||||
initPageConsoleErrorCatch,
|
||||
openDocDrawer,
|
||||
openLocaleSelector,
|
||||
saveDocAndAssert,
|
||||
throttleTest,
|
||||
} from '../helpers.js'
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
withRequiredLocalizedFields,
|
||||
} from './shared.js'
|
||||
import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js'
|
||||
|
||||
import { upsertPrefs } from 'helpers/e2e/upsertPrefs.js'
|
||||
import { RESTClient } from 'helpers/rest.js'
|
||||
import { GeneratedTypes } from 'helpers/sdk/types.js'
|
||||
@@ -45,7 +48,7 @@ const dirname = path.dirname(filename)
|
||||
* Repeat above for Globals
|
||||
*/
|
||||
|
||||
const { beforeAll, beforeEach, describe } = test
|
||||
const { beforeAll, beforeEach, describe, afterEach } = test
|
||||
let url: AdminUrlUtil
|
||||
let urlWithRequiredLocalizedFields: AdminUrlUtil
|
||||
let urlRelationshipLocalized: AdminUrlUtil
|
||||
@@ -92,35 +95,40 @@ describe('Localization', () => {
|
||||
})
|
||||
|
||||
describe('localizer', async () => {
|
||||
test('should not render default locale in locale selector when prefs are not default', async () => {
|
||||
// change prefs to spanish, then load page and check that the localizer label does not say English
|
||||
await upsertPrefs<Config, GeneratedTypes<any>>({
|
||||
payload,
|
||||
user: client.user,
|
||||
key: 'locale',
|
||||
value: 'es',
|
||||
test('should show localizer controls', async () => {
|
||||
await page.goto(url.create)
|
||||
await expect(page.locator('.localizer.app-header__localizer')).toBeVisible()
|
||||
await page.locator('.localizer >> button').first().click()
|
||||
await expect(page.locator('.localizer .popup.popup--active')).toBeVisible()
|
||||
})
|
||||
|
||||
await page.goto(url.list)
|
||||
test('should disable control for active locale', async () => {
|
||||
await page.goto(url.create)
|
||||
|
||||
const localeLabel = await page
|
||||
.locator('.localizer.app-header__localizer .localizer-button__current-label')
|
||||
.innerText()
|
||||
await page.locator('.localizer button.popup-button').first().click()
|
||||
|
||||
expect(localeLabel).not.toEqual('English')
|
||||
await expect(page.locator('.localizer .popup')).toHaveClass(/popup--active/)
|
||||
|
||||
const activeOption = await page.locator(
|
||||
`.localizer .popup.popup--active .popup-button-list__button--selected`,
|
||||
)
|
||||
|
||||
await expect(activeOption).toBeVisible()
|
||||
const tagName = await activeOption.evaluate((node) => node.tagName)
|
||||
await expect(tagName).not.toBe('A')
|
||||
await expect(activeOption).not.toHaveAttribute('href')
|
||||
await expect(tagName).not.toBe('BUTTON')
|
||||
await expect(tagName).toBe('DIV')
|
||||
})
|
||||
})
|
||||
|
||||
describe('localized text', () => {
|
||||
test('create english post, switch to spanish', async () => {
|
||||
await changeLocale(page, defaultLocale)
|
||||
|
||||
await page.goto(url.create)
|
||||
|
||||
await fillValues({ description, title })
|
||||
await saveDocAndAssert(page)
|
||||
|
||||
// Change back to English
|
||||
await changeLocale(page, 'es')
|
||||
|
||||
// Localized field should not be populated
|
||||
@@ -131,12 +139,10 @@ describe('Localization', () => {
|
||||
.not.toBe(title)
|
||||
|
||||
await expect(page.locator('#field-description')).toHaveValue(description)
|
||||
|
||||
await fillValues({ description, title: spanishTitle })
|
||||
await saveDocAndAssert(page)
|
||||
await changeLocale(page, defaultLocale)
|
||||
|
||||
// Expect english title
|
||||
await changeLocale(page, defaultLocale)
|
||||
await expect(page.locator('#field-title')).toHaveValue(title)
|
||||
await expect(page.locator('#field-description')).toHaveValue(description)
|
||||
})
|
||||
@@ -227,7 +233,6 @@ describe('Localization', () => {
|
||||
await page.waitForURL(url.create)
|
||||
await changeLocale(page, defaultLocale)
|
||||
await fillValues({ description, title: englishTitle })
|
||||
await expect(page.locator('#field-localizedCheckbox')).toBeEnabled()
|
||||
await page.locator('#field-localizedCheckbox').click()
|
||||
await page.locator('#action-save').click()
|
||||
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).not.toContain('create')
|
||||
@@ -260,34 +265,51 @@ describe('Localization', () => {
|
||||
const originalID = await page.locator('.id-label').innerText()
|
||||
await openDocControls(page)
|
||||
await page.locator('#action-duplicate').click()
|
||||
await expect(page.locator('.id-label')).not.toContainText(originalID)
|
||||
await expect(page.locator('#field-title')).toHaveValue(englishTitle)
|
||||
|
||||
await expect(page.locator('.payload-toast-container')).toContainText(
|
||||
'successfully duplicated',
|
||||
)
|
||||
|
||||
await expect(page.locator('.id-label')).not.toContainText(originalID)
|
||||
await expect(page.locator('#field-title')).toHaveValue(englishTitle)
|
||||
await expect(page.locator('.id-label')).not.toContainText(originalID)
|
||||
})
|
||||
})
|
||||
|
||||
describe('locale preference', () => {
|
||||
test('ensure preference is used when query param is not', async () => {
|
||||
test('should set preference on locale change and use preference no locale param is present', async () => {
|
||||
await page.goto(url.create)
|
||||
await changeLocale(page, spanishLocale)
|
||||
await expect(page.locator('#field-title')).toBeEmpty()
|
||||
await fillValues({ title: spanishTitle })
|
||||
await saveDocAndAssert(page)
|
||||
await page.goto(url.admin)
|
||||
await page.goto(url.list)
|
||||
await expect(page.locator('.row-1 .cell-title')).toContainText(spanishTitle)
|
||||
})
|
||||
|
||||
test('should not render default locale in locale selector when prefs are not default', async () => {
|
||||
await upsertPrefs<Config, GeneratedTypes<any>>({
|
||||
payload,
|
||||
user: client.user,
|
||||
key: 'locale',
|
||||
value: 'es',
|
||||
})
|
||||
|
||||
await page.goto(url.list)
|
||||
|
||||
const localeLabel = await page
|
||||
.locator('.localizer.app-header__localizer .localizer-button__current-label')
|
||||
.innerText()
|
||||
|
||||
expect(localeLabel).not.toEqual('English')
|
||||
})
|
||||
})
|
||||
|
||||
describe('localized relationships', () => {
|
||||
test('ensure relationship field fetches are localized as well', async () => {
|
||||
await changeLocale(page, spanishLocale)
|
||||
await navigateToDoc(page, url)
|
||||
const selectField = page.locator('#field-children .rs__control')
|
||||
await selectField.click()
|
||||
await page.locator('#field-children .rs__control').click()
|
||||
await expect(page.locator('#field-children .rs__menu')).toContainText('spanish-relation2')
|
||||
})
|
||||
|
||||
@@ -318,6 +340,7 @@ describe('Localization', () => {
|
||||
await setToLocale(page, 'Spanish')
|
||||
await runCopy(page)
|
||||
await expect(page.locator('#field-title')).toHaveValue(title)
|
||||
await changeLocale(page, defaultLocale)
|
||||
})
|
||||
|
||||
test('should copy rich text data to correct locale', async () => {
|
||||
@@ -359,7 +382,6 @@ describe('Localization', () => {
|
||||
await changeLocale(page, spanishLocale)
|
||||
await createAndSaveDoc(page, url, { title })
|
||||
await openCopyToLocaleDrawer(page)
|
||||
|
||||
const fromLocaleField = page.locator('#field-fromLocale')
|
||||
await expect(fromLocaleField).toContainText('Spanish')
|
||||
await page.locator('.drawer-close-button').click()
|
||||
@@ -391,14 +413,13 @@ describe('Localization', () => {
|
||||
await fillValues({ title: spanishTitle })
|
||||
await saveDocAndAssert(page)
|
||||
await changeLocale(page, defaultLocale)
|
||||
|
||||
await openCopyToLocaleDrawer(page)
|
||||
await setToLocale(page, 'Spanish')
|
||||
const overwriteCheckbox = page.locator('#field-overwriteExisting')
|
||||
await overwriteCheckbox.click()
|
||||
await runCopy(page)
|
||||
|
||||
await expect(page.locator('#field-title')).toHaveValue(englishTitle)
|
||||
await changeLocale(page, defaultLocale)
|
||||
})
|
||||
|
||||
test('should not include current locale in toLocale options', async () => {
|
||||
@@ -447,6 +468,46 @@ describe('Localization', () => {
|
||||
await expect(page.locator('.payload-toast-container')).toContainText('unsaved')
|
||||
})
|
||||
})
|
||||
|
||||
describe('locale change', () => {
|
||||
test('should disable fields during locale change', async () => {
|
||||
await page.goto(url.create)
|
||||
await changeLocale(page, defaultLocale)
|
||||
await expect(page.locator('#field-title')).toBeEnabled()
|
||||
|
||||
await openLocaleSelector(page)
|
||||
|
||||
const localeToSelect = page
|
||||
.locator('.localizer .popup.popup--active .popup-button-list__button')
|
||||
.locator('.localizer__locale-code', {
|
||||
hasText: `${spanishLocale}`,
|
||||
})
|
||||
|
||||
// only throttle test after initial load to avoid timeouts
|
||||
const cdpSession = await throttleTest({
|
||||
page: page,
|
||||
context,
|
||||
delay: 'Fast 4G',
|
||||
})
|
||||
|
||||
await localeToSelect.click()
|
||||
await expect(page.locator('#field-title')).toBeDisabled()
|
||||
|
||||
const regexPattern = new RegExp(`locale=${spanishLocale}`)
|
||||
await expect(page).toHaveURL(regexPattern)
|
||||
await expect(page.locator('#field-title')).toBeEnabled()
|
||||
await closeLocaleSelector(page)
|
||||
|
||||
await cdpSession.send('Network.emulateNetworkConditions', {
|
||||
offline: false,
|
||||
latency: 0,
|
||||
downloadThroughput: -1,
|
||||
uploadThroughput: -1,
|
||||
})
|
||||
|
||||
await cdpSession.detach()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
async function fillValues(data: Partial<LocalizedPost>) {
|
||||
@@ -461,9 +522,7 @@ async function fillValues(data: Partial<LocalizedPost>) {
|
||||
}
|
||||
|
||||
async function runCopy(page) {
|
||||
const copyDrawerClose = page.locator('.copy-locale-data__sub-header button')
|
||||
await expect(copyDrawerClose).toBeVisible()
|
||||
await copyDrawerClose.click()
|
||||
await page.locator('.copy-locale-data__sub-header button').click()
|
||||
}
|
||||
|
||||
async function createAndSaveDoc(page, url, values) {
|
||||
@@ -473,12 +532,8 @@ async function createAndSaveDoc(page, url, values) {
|
||||
}
|
||||
|
||||
async function openCopyToLocaleDrawer(page) {
|
||||
const docControls = page.locator('.doc-controls__popup button.popup-button')
|
||||
expect(docControls).toBeEnabled()
|
||||
await docControls.click()
|
||||
const copyButton = page.locator('#copy-locale-data__button')
|
||||
await expect(copyButton).toBeVisible()
|
||||
await copyButton.click()
|
||||
await page.locator('.doc-controls__popup button.popup-button').click()
|
||||
await page.locator('#copy-locale-data__button').click()
|
||||
await expect(page.locator('#copy-locale')).toBeVisible()
|
||||
await expect(page.locator('.copy-locale-data__content')).toBeVisible()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user