feat: wires up server-action for language translation loading (#5346)

This commit is contained in:
Jarrod Flesch
2024-03-18 15:42:45 -04:00
committed by GitHub
parent 6c2faf68c4
commit 99f31bbc23
19 changed files with 112 additions and 54 deletions

View File

@@ -133,7 +133,7 @@ export const CollectionArchiveByCollection: React.FC<Props> = (props) => {
}
}
makeRequest()
void makeRequest()
return () => {
if (timer) clearTimeout(timer)

View File

@@ -3,7 +3,7 @@ import type { SanitizedConfig } from 'payload/types'
import { translations } from '@payloadcms/translations/client'
import { RootProvider, buildComponentMap } from '@payloadcms/ui'
import '@payloadcms/ui/scss/app.scss'
import { headers as getHeaders } from 'next/headers.js'
import { headers as getHeaders, cookies as nextCookies } from 'next/headers.js'
import { parseCookies } from 'payload/auth'
import { createClientConfig } from 'payload/config'
import { deepMerge } from 'payload/utilities'
@@ -37,6 +37,7 @@ export const RootLayout = async ({
const lang =
getRequestLanguage({
config,
cookies,
headers,
}) ?? clientConfig.i18n.fallbackLanguage
@@ -50,6 +51,16 @@ export const RootLayout = async ({
value: language,
}))
// eslint-disable-next-line @typescript-eslint/require-await
async function switchLanguageServerAction(lang: string): Promise<void> {
'use server'
nextCookies().set({
name: `${config.cookiePrefix || 'payload'}-lng'`,
path: '/',
value: lang,
})
}
const { componentMap, wrappedChildren } = buildComponentMap({
DefaultCell,
DefaultEditView,
@@ -67,6 +78,8 @@ export const RootLayout = async ({
fallbackLang={clientConfig.i18n.fallbackLanguage}
lang={lang}
languageOptions={languageOptions}
// eslint-disable-next-line react/jsx-no-bind
switchLanguageServerAction={switchLanguageServerAction}
translations={mergedTranslations[lang]}
>
{wrappedChildren}

View File

@@ -67,6 +67,7 @@ export const createPayloadRequest = async ({
}
const language = getRequestLanguage({
config,
cookies,
headers: request.headers,
})

View File

@@ -7,19 +7,16 @@ import { cookies, headers } from 'next/headers.js'
import { getRequestLanguage } from './getRequestLanguage.js'
export const getNextI18n = async ({
export const getNextI18n = ({
config,
language,
}: {
config: SanitizedConfig
language?: string
}): Promise<I18n> => {
const i18n = initI18n({
}): I18n =>
initI18n({
config: config.i18n,
context: 'client',
language: language || getRequestLanguage({ cookies: cookies(), headers: headers() }),
language: language || getRequestLanguage({ config, cookies: cookies(), headers: headers() }),
translations,
})
return i18n
}

View File

@@ -1,24 +1,27 @@
import type { ReadonlyRequestCookies } from 'next/dist/server/web/spec-extension/adapters/request-cookies.js'
import type { SanitizedConfig } from 'packages/payload/src/exports/types.js'
import { matchLanguage } from '@payloadcms/translations'
type GetRequestLanguageArgs = {
config: SanitizedConfig
cookies: Map<string, string> | ReadonlyRequestCookies
defaultLanguage?: string
headers: Request['headers']
}
export const getRequestLanguage = ({
config,
cookies,
defaultLanguage = 'en',
headers,
}: GetRequestLanguageArgs): string => {
const acceptLanguage = headers.get('Accept-Language')
const cookieLanguage = cookies.get('lng')
const cookieLanguage = cookies.get(`${config.cookiePrefix || 'payload'}-lng'`)
const reqLanguage =
acceptLanguage ||
(typeof cookieLanguage === 'string' ? cookieLanguage : cookieLanguage?.value) ||
acceptLanguage ||
defaultLanguage
return matchLanguage(reqLanguage)

View File

@@ -62,7 +62,7 @@ export const initPage = async ({
localization && localization.defaultLocale ? localization.defaultLocale : 'en'
const localeCode = localeParam || defaultLocale
const locale = localization && findLocaleFromCode(localization, localeCode)
const language = getRequestLanguage({ cookies, headers })
const language = getRequestLanguage({ config: payload.config, cookies, headers })
const i18n = initI18n({
config: payload.config.i18n,

View File

@@ -13,7 +13,7 @@ export const Settings: React.FC<{
}> = (props) => {
const { className } = props
const { i18n, languageOptions, t } = useTranslation()
const { i18n, languageOptions, switchLanguage, t } = useTranslation()
return (
<div className={[baseClass, className].filter(Boolean).join(' ')}>
@@ -22,8 +22,9 @@ export const Settings: React.FC<{
<Label htmlFor="language-select" label={t('general:language')} />
<ReactSelect
inputId="language-select"
// TODO(i18n): wire up onChange / changeLanguage fn
// onChange={({ value }) => i18n.changeLanguage(value)}
onChange={async ({ value }) => {
await switchLanguage(value)
}}
options={languageOptions}
value={languageOptions.find((language) => language.value === i18n.language)}
/>

View File

@@ -61,7 +61,7 @@ type GetCookieExpirationArgs = {
*/
seconds: number
}
const getCookieExpiration = ({ seconds = 7200 }: GetCookieExpirationArgs) => {
export const getCookieExpiration = ({ seconds = 7200 }: GetCookieExpirationArgs) => {
const currentTime = new Date()
currentTime.setSeconds(currentTime.getSeconds() + seconds)
return currentTime

View File

@@ -12,20 +12,37 @@
"prepublishOnly": "pnpm clean && pnpm turbo build"
},
"publishConfig": {
"access": "public"
"access": "public",
"exports": {
".": {
"import": "./dist/exports/index.ts",
"require": "./dist/exports/index.ts",
"types": "./dist/exports/*.d.ts"
},
"./api": {
"import": "./dist/_generatedFiles_/api/index.ts",
"require": "./dist/_generatedFiles_/api/index.ts",
"types": "./dist/_generatedFiles_/exports/*.d.ts"
},
"./client": {
"import": "./dist/_generatedFiles_/client/index.ts",
"require": "./dist/_generatedFiles_/client/index.ts",
"types": "./dist/_generatedFiles_/exports/*.d.ts"
}
}
},
"exports": {
".": {
"import": "./dist/exports/index.js",
"require": "./dist/exports/index.js"
"import": "./src/exports/index.ts",
"require": "./src/exports/index.ts"
},
"./api": {
"import": "./dist/_generatedFiles_/api/index.js",
"require": "./dist/_generatedFiles_/api/index.js"
"import": "./src/_generatedFiles_/api/index.ts",
"require": "./src/_generatedFiles_/api/index.ts"
},
"./client": {
"import": "./dist/_generatedFiles_/client/index.js",
"require": "./dist/_generatedFiles_/client/index.js"
"import": "./src/_generatedFiles_/client/index.ts",
"require": "./src/_generatedFiles_/client/index.ts"
}
},
"devDependencies": {

View File

@@ -5,11 +5,7 @@ export type LanguageTranslations = {
}
export type Translations = {
[language: string]:
| {
$schema: string
}
| LanguageTranslations
[language: string]: LanguageTranslations
}
export type TFunction = (key: string, options?: Record<string, any>) => string

View File

@@ -228,12 +228,7 @@ function memoize(fn: Function, keys: string[]) {
}
export const initI18n: InitI18n = memoize(
({
config,
context,
language = 'en',
translations: incomingTranslations,
}: Parameters<InitI18n>[0]) => {
({ config, language = 'en', translations: incomingTranslations }: Parameters<InitI18n>[0]) => {
const { t, translations } = initTFunction({
config,
language: language || config.fallbackLanguage,

View File

@@ -14,8 +14,7 @@ export const LocalizerLabel: React.FC<{
}> = (props) => {
const { ariaLabel, className } = props
const locale = useLocale()
const { t } = useTranslation()
const { i18n } = useTranslation()
const { i18n, t } = useTranslation()
return (
<div

View File

@@ -37,18 +37,18 @@ const Relationship: React.FC<RelationshipFieldProps> = (props) => {
Description,
Error,
Label,
allowCreate = true,
className,
hasMany,
isSortable = true,
path: pathFromProps,
readOnly,
relationTo,
required,
sortOptions,
style,
validate,
width,
relationTo,
hasMany,
sortOptions,
isSortable = true,
allowCreate = true,
} = props
const config = useConfig()

View File

@@ -36,18 +36,18 @@ export const Select: React.FC<SelectFieldProps> = (props) => {
Error,
Label: LabelFromProps,
className,
hasMany = false,
isClearable = true,
isSortable = true,
label,
onChange: onChangeFromProps,
options: optionsFromProps = [],
path: pathFromProps,
readOnly,
required,
style,
validate,
width,
options: optionsFromProps = [],
hasMany = false,
isClearable = true,
isSortable = true,
} = props
const Label = LabelFromProps || <LabelComp label={label} required={required} />

View File

@@ -33,11 +33,11 @@ const Textarea: React.FC<TextareaFieldProps> = (props) => {
path: pathFromProps,
placeholder,
required,
rows,
rtl,
style,
validate,
width,
rows,
} = props
const Label = LabelFromProps || <LabelComp label={label} required={required} />

View File

@@ -1,8 +1,9 @@
'use client'
import type { FieldTypes } from 'payload/config.js'
import React, { createContext, useContext } from 'react'
import { fieldComponents } from '../../forms/fields/index.js'
import { FieldTypes } from 'payload/config.js'
export type IFieldComponentsContext = FieldTypes

View File

@@ -20,6 +20,7 @@ import { ClientFunctionProvider } from '../ClientFunction/index.js'
import { ComponentMapProvider } from '../ComponentMapProvider/index.js'
import { ConfigProvider } from '../Config/index.js'
import { DocumentEventsProvider } from '../DocumentEvents/index.js'
import { FieldComponentsProvider } from '../FieldComponentsProvider/index.js'
import { LocaleProvider } from '../Locale/index.js'
import { ParamsProvider } from '../Params/index.js'
import { PreferencesProvider } from '../Preferences/index.js'
@@ -27,7 +28,6 @@ import { RouteCache } from '../RouteCache/index.js'
import { SearchParamsProvider } from '../SearchParams/index.js'
import { ThemeProvider } from '../Theme/index.js'
import { TranslationProvider } from '../Translation/index.js'
import { FieldComponentsProvider } from '../FieldComponentsProvider/index.js'
type Props = {
children: React.ReactNode
@@ -36,6 +36,7 @@ type Props = {
fallbackLang: ClientConfig['i18n']['fallbackLanguage']
lang: string
languageOptions: LanguageOptions
switchLanguageServerAction?: (lang: string) => Promise<void>
translations: LanguageTranslations
}
@@ -46,6 +47,7 @@ export const RootProvider: React.FC<Props> = ({
fallbackLang,
lang,
languageOptions,
switchLanguageServerAction,
translations,
}) => {
const { ModalContainer, ModalProvider } = facelessUIImport || {
@@ -66,6 +68,7 @@ export const RootProvider: React.FC<Props> = ({
fallbackLang={fallbackLang}
lang={lang}
languageOptions={languageOptions}
switchLanguageServerAction={switchLanguageServerAction}
translations={translations}
>
<WindowInfoProvider

View File

@@ -1,10 +1,12 @@
'use client'
import type { I18n, LanguageTranslations, Translations } from '@payloadcms/translations'
import type { I18n, LanguageTranslations } from '@payloadcms/translations'
import type { ClientConfig } from 'payload/types'
import { t } from '@payloadcms/translations'
import React, { createContext, useContext } from 'react'
import { useRouteCache } from '../RouteCache/index.js'
export type LanguageOptions = {
label: string
value: string
@@ -13,6 +15,7 @@ export type LanguageOptions = {
const Context = createContext<{
i18n: I18n
languageOptions: LanguageOptions
switchLanguage?: (lang: string) => Promise<void>
t: (key: string, vars?: Record<string, any>) => string
}>({
i18n: {
@@ -22,23 +25,49 @@ const Context = createContext<{
translations: {},
},
languageOptions: undefined,
switchLanguage: undefined,
t: (key) => key,
})
export const TranslationProvider: React.FC<{
type Props = {
children: React.ReactNode
fallbackLang: ClientConfig['i18n']['fallbackLanguage']
lang: string
languageOptions: LanguageOptions
switchLanguageServerAction: (lang: string) => Promise<void>
translations: LanguageTranslations
}> = ({ children, fallbackLang, lang, languageOptions, translations }) => {
const nextT = (key: string, vars?: Record<string, any>): string =>
}
export const TranslationProvider: React.FC<Props> = ({
children,
fallbackLang,
lang,
languageOptions,
switchLanguageServerAction,
translations,
}) => {
const { clearRouteCache } = useRouteCache()
const nextT = (key: string, vars?: Record<string, unknown>): string =>
t({
key,
translations,
vars,
})
const switchLanguage = React.useCallback(
async (lang: string) => {
try {
await switchLanguageServerAction(lang)
clearRouteCache()
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error loading language: "${lang}"`, error)
}
},
[switchLanguageServerAction, clearRouteCache],
)
return (
<Context.Provider
value={{
@@ -46,9 +75,12 @@ export const TranslationProvider: React.FC<{
fallbackLanguage: fallbackLang,
language: lang,
t: nextT,
translations: translations as Translations,
translations: {
[lang]: translations,
},
},
languageOptions,
switchLanguage,
t: nextT,
}}
>

View File

@@ -1,4 +1,4 @@
import React, { Fragment } from 'react'
import React from 'react'
import type { Props } from './types.js'