diff --git a/docs/configuration/localization.mdx b/docs/configuration/localization.mdx index 8a01635958b..610c96cd62b 100644 --- a/docs/configuration/localization.mdx +++ b/docs/configuration/localization.mdx @@ -93,12 +93,12 @@ The locale codes do not need to be in any specific format. It's up to you to def #### Locale Object -| Option | Description | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| **`code`** \* | Unique code to identify the language throughout the APIs for `locale` and `fallbackLocale` | -| **`label`** | A string to use for the selector when choosing a language, or an object keyed on the i18n keys for different languages in use. | -| **`rtl`** | A boolean that when true will make the admin UI display in Right-To-Left. | -| **`fallbackLocale`** | The code for this language to fallback to when properties of a document are not present. | +| Option | Description | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| **`code`** \* | Unique code to identify the language throughout the APIs for `locale` and `fallbackLocale` | +| **`label`** | A string to use for the selector when choosing a language, or an object keyed on the i18n keys for different languages in use. | +| **`rtl`** | A boolean that when true will make the admin UI display in Right-To-Left. | +| **`fallbackLocale`** | The code for this language to fallback to when properties of a document are not present. This can be a single locale or array of locales. | _\* An asterisk denotes that a property is required._ @@ -222,7 +222,7 @@ The `locale` arg will only accept valid locales, but locales will be formatted a values (dashes or special characters will be converted to underscores, spaces will be removed, etc.). If you are curious to see how locales are auto-formatted, you can use the [GraphQL playground](/docs/graphql/overview#graphql-playground). -The `fallbackLocale` arg will accept valid locales as well as `none` to disable falling back. +The `fallbackLocale` arg will accept valid locales, an array of locales, as well as `none` to disable falling back. **Example:** @@ -247,7 +247,7 @@ query { You can specify `locale` as well as `fallbackLocale` within the Local API as well as properties on the `options` argument. The `locale` property will accept any valid locale, and the `fallbackLocale` property will accept any valid -locale as well as `'null'`, `'false'`, `false`, and `'none'`. +locale, array of locales, as well as `'null'`, `'false'`, `false`, and `'none'`. **Example:** diff --git a/docs/local-api/overview.mdx b/docs/local-api/overview.mdx index a5308145974..98a00ef0ae1 100644 --- a/docs/local-api/overview.mdx +++ b/docs/local-api/overview.mdx @@ -71,7 +71,7 @@ You can specify more options within the Local API vs. REST or GraphQL due to the | `locale` | Specify [locale](/docs/configuration/localization) for any returned documents. | | `select` | Specify [select](../queries/select) to control which fields to include to the result. | | `populate` | Specify [populate](../queries/select#populate) to control which fields to include to the result from populated documents. | -| `fallbackLocale` | Specify a [fallback locale](/docs/configuration/localization) to use for any returned documents. | +| `fallbackLocale` | Specify a [fallback locale](/docs/configuration/localization) to use for any returned documents. This can be a single locale or array of locales. | | `overrideAccess` | Skip access control. By default, this property is set to true within all Local API operations. | | `overrideLock` | By default, document locks are ignored (`true`). Set to `false` to enforce locks and prevent operations when a document is locked by another user. [More details](../admin/locked-documents). | | `user` | If you set `overrideAccess` to `false`, you can pass a user to use against the access control checks. | diff --git a/packages/payload/src/collections/operations/local/find.ts b/packages/payload/src/collections/operations/local/find.ts index 5c450b4f905..65807c41b76 100644 --- a/packages/payload/src/collections/operations/local/find.ts +++ b/packages/payload/src/collections/operations/local/find.ts @@ -54,7 +54,7 @@ export type Options = /** * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. */ - fallbackLocale?: false | TypedLocale + fallbackLocale?: false | TypedLocale | TypedLocale[] /** * Include info about the lock status to the result into all documents with fields: `_isLocked` and `_userEditing` */ diff --git a/packages/payload/src/collections/operations/local/findByID.ts b/packages/payload/src/collections/operations/local/findByID.ts index a576f1fbd37..4eb3f8dc5e4 100644 --- a/packages/payload/src/collections/operations/local/findByID.ts +++ b/packages/payload/src/collections/operations/local/findByID.ts @@ -62,7 +62,7 @@ export type Options< /** * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. */ - fallbackLocale?: false | TypedLocale + fallbackLocale?: false | TypedLocale | TypedLocale[] /** * The ID of the document to find. */ diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 6f87610967e..4500616b779 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -462,7 +462,7 @@ export type Locale = { /** * Code of another locale to use when reading documents with fallback, if not specified defaultLocale is used */ - fallbackLocale?: string + fallbackLocale?: string | string[] /** * label of supported locale * @example "English" diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts index 02b3b32e413..17a8b28d0f5 100644 --- a/packages/payload/src/fields/hooks/afterRead/promise.ts +++ b/packages/payload/src/fields/hooks/afterRead/promise.ts @@ -158,9 +158,22 @@ export const promise = async ({ let hoistedValue = value if (fallbackLocale && fallbackLocale !== locale) { - const fallbackValue = siblingDoc[field.name!][fallbackLocale] + let fallbackValue const isNullOrUndefined = typeof value === 'undefined' || value === null + if (Array.isArray(fallbackLocale)) { + for (const locale of fallbackLocale) { + const val = siblingDoc[field.name!]?.[locale] + + if (val !== undefined && val !== null && val !== '') { + fallbackValue = val + break + } + } + } else { + fallbackValue = siblingDoc[field.name!][fallbackLocale] + } + if (fallbackValue) { switch (field.type) { case 'text': diff --git a/packages/payload/src/globals/operations/local/findOne.ts b/packages/payload/src/globals/operations/local/findOne.ts index f82b55466aa..4a6db18d39b 100644 --- a/packages/payload/src/globals/operations/local/findOne.ts +++ b/packages/payload/src/globals/operations/local/findOne.ts @@ -37,7 +37,7 @@ export type Options = { /** * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. */ - fallbackLocale?: false | TypedLocale + fallbackLocale?: false | TypedLocale | TypedLocale[] /** * Include info about the lock status to the result with fields: `_isLocked` and `_userEditing` */ diff --git a/packages/payload/src/utilities/sanitizeFallbackLocale.ts b/packages/payload/src/utilities/sanitizeFallbackLocale.ts index 170b74aae65..cad208c2311 100644 --- a/packages/payload/src/utilities/sanitizeFallbackLocale.ts +++ b/packages/payload/src/utilities/sanitizeFallbackLocale.ts @@ -2,7 +2,7 @@ import type { SanitizedLocalizationConfig } from '../config/types.js' import type { TypedLocale } from '../index.js' interface Args { - fallbackLocale: false | TypedLocale + fallbackLocale: false | TypedLocale | TypedLocale[] locale: string localization: SanitizedLocalizationConfig } @@ -26,7 +26,10 @@ export const sanitizeFallbackLocale = ({ hasFallbackLocale = Boolean(localization && localization.fallback) } - if (fallbackLocale && !['false', 'none', 'null'].includes(fallbackLocale)) { + if ( + fallbackLocale && + !['false', 'none', 'null'].includes(!Array.isArray(fallbackLocale) ? fallbackLocale : '') + ) { hasFallbackLocale = true } @@ -52,5 +55,16 @@ export const sanitizeFallbackLocale = ({ fallbackLocale = null } + if ( + typeof fallbackLocale === 'string' && + fallbackLocale.startsWith('[') && + fallbackLocale.endsWith(']') + ) { + fallbackLocale = fallbackLocale + .slice(1, -1) + .split(',') + .map((l) => l.trim()) + } + return fallbackLocale as null | string } diff --git a/test/helpers/NextRESTClient.ts b/test/helpers/NextRESTClient.ts index 9151df4716e..19fc7109512 100644 --- a/test/helpers/NextRESTClient.ts +++ b/test/helpers/NextRESTClient.ts @@ -19,7 +19,7 @@ type RequestOptions = { auth?: boolean query?: { [key: string]: unknown } & { depth?: number - fallbackLocale?: string + fallbackLocale?: string | string[] joins?: JoinQuery limit?: number locale?: string diff --git a/test/localization/int.spec.ts b/test/localization/int.spec.ts index 7535e7901b3..762cb190f50 100644 --- a/test/localization/int.spec.ts +++ b/test/localization/int.spec.ts @@ -44,6 +44,7 @@ import { } from './shared.js' const collection = localizedPostsSlug +const global = 'global-text' let payload: Payload let restClient: NextRESTClient @@ -86,6 +87,22 @@ describe('Localization', () => { }, locale: spanishLocale, }) + + await payload.updateGlobal({ + slug: global, + data: { + text: spanishTitle, + }, + locale: spanishLocale, + }) + + await payload.updateGlobal({ + slug: global, + data: { + text: englishTitle, + }, + locale: englishLocale, + }) }) describe('Localized text', () => { @@ -3016,6 +3033,268 @@ describe('Localization', () => { expect(refreshedDoc.topLevelArrayLocalized?.[0]?.text).toBe('some-text') }) }) + + describe('Multiple fallback locales', () => { + describe('Local API', () => { + describe('Collections', () => { + it('should allow fallback locale to be an array', async () => { + const result = await payload.findByID({ + id: postWithLocalizedData.id, + collection, + locale: portugueseLocale, + fallbackLocale: [spanishLocale, englishLocale], + }) + + expect(result).toBeDefined() + expect((result as any).title).toBe(spanishTitle) + }) + + it('should pass over fallback locales until it finds one that exists', async () => { + const result = await payload.findByID({ + id: postWithLocalizedData.id, + collection, + locale: portugueseLocale, + fallbackLocale: ['hu', 'ar', spanishLocale], + }) + + expect(result).toBeDefined() + expect((result as any).title).toBe(spanishTitle) + }) + + it('should return undefined if no fallback locales exist', async () => { + const result = await payload.findByID({ + id: postWithLocalizedData.id, + collection, + locale: portugueseLocale, + fallbackLocale: ['hu', 'ar'], + }) + + expect(result).toBeDefined() + expect((result as any).title).not.toBeDefined() + }) + }) + + describe('Globals', () => { + it('should allow fallback locale to be an array', async () => { + const result = await payload.findGlobal({ + slug: global, + locale: portugueseLocale, + fallbackLocale: [spanishLocale, englishLocale], + }) + + expect(result).toBeDefined() + expect(result.text).toBe(spanishTitle) + }) + + it('should pass over fallback locales until it finds one that exists', async () => { + const result = await payload.findGlobal({ + slug: global, + locale: portugueseLocale, + fallbackLocale: ['hu', spanishLocale], + }) + expect(result).toBeDefined() + expect(result.text).toBe(spanishTitle) + }) + + it('should return undefined if no fallback locales exist', async () => { + const result = await payload.findGlobal({ + slug: global, + locale: portugueseLocale, + fallbackLocale: ['hu', 'ar'], + }) + + expect(result).toBeDefined() + expect(result.text).not.toBeDefined() + }) + }) + }) + + describe('REST API', () => { + describe('Collections', () => { + it('should allow fallback locale to be an array', async () => { + const response = await restClient.GET( + `/${collection}/${postWithLocalizedData.id}?locale=pt&fallbackLocale=[es,en]`, + ) + + expect(response.status).toBe(200) + const result = await response.json() + + expect(result.title).toEqual(spanishTitle) + }) + + it('should pass over fallback locales until it finds one that exists', async () => { + const response = await restClient.GET( + `/${collection}/${postWithLocalizedData.id}?locale=pt&fallbackLocale=[hu,ar,es]`, + ) + + expect(response.status).toBe(200) + const result = await response.json() + + expect(result.title).toEqual(spanishTitle) + }) + + it('should return undefined if no fallback locales exist', async () => { + const response = await restClient.GET( + `/${collection}/${postWithLocalizedData.id}?locale=pt&fallbackLocale=[hu,ar]`, + ) + + expect(response.status).toBe(200) + const result = await response.json() + + expect(result.title).not.toBeDefined() + }) + }) + + describe('Globals', () => { + it('should allow fallback locale to be an array', async () => { + const response = await restClient.GET( + `/globals/${global}?locale=pt&fallbackLocale=[es,en]`, + ) + + expect(response.status).toBe(200) + const result = await response.json() + expect(result.text).toBe(spanishTitle) + }) + + it('should pass over fallback locales until it finds one that exists', async () => { + const response = await restClient.GET( + `/globals/${global}?locale=pt&fallbackLocale=[hu,ar,es]`, + ) + + expect(response.status).toBe(200) + const result = await response.json() + + expect(result.text).toBe(spanishTitle) + }) + + it('should return undefined if no fallback locales exist', async () => { + const response = await restClient.GET( + `/globals/${global}?locale=pt&fallbackLocale=[hu,ar]`, + ) + + expect(response.status).toBe(200) + const result = await response.json() + + expect(result.title).not.toBeDefined() + }) + }) + }) + + describe('GraphQL', () => { + describe('Collections', () => { + it('should allow fallback locale to be an array', async () => { + const query = ` + { + LocalizedPost(id: ${idToString(postWithLocalizedData.id, payload)}, locale: pt) { + title + } + } + ` + + const { data } = await restClient + .GRAPHQL_POST({ + body: JSON.stringify({ query }), + query: { locale: 'pt', fallbackLocale: '[es, en]' }, + }) + .then((res) => res.json()) + console.log(data) + + expect(data.LocalizedPost.title).toStrictEqual(spanishTitle) + }) + + it('should pass over fallback locales until it finds one that exists', async () => { + const query = ` + { + LocalizedPost(id: ${idToString(postWithLocalizedData.id, payload)}, locale: pt) { + title + } + } + ` + + const { data: queryResult } = await restClient + .GRAPHQL_POST({ + body: JSON.stringify({ query }), + query: { locale: 'pt', fallbackLocale: '[hu,ar,es]' }, + }) + .then((res) => res.json()) + + expect(queryResult.LocalizedPost.title).toBe(spanishTitle) + }) + + it('should return null if no fallback locales exist', async () => { + const query = ` + { + LocalizedPost(id: ${idToString(postWithLocalizedData.id, payload)}, locale: pt) { + title + } + } + ` + + const { data: queryResult } = await restClient + .GRAPHQL_POST({ + body: JSON.stringify({ query }), + query: { locale: 'pt', fallbackLocale: '[hu,ar]' }, + }) + .then((res) => res.json()) + + expect(queryResult.LocalizedPost.title).toBeNull() + }) + }) + + describe('Globals', () => { + it('should allow fallback locale to be an array', async () => { + const query = `query { + GlobalText { + text + } + }` + + const { data: queryResult } = await restClient + .GRAPHQL_POST({ + body: JSON.stringify({ query }), + query: { locale: 'pt', fallbackLocale: '[es, en]' }, + }) + .then((res) => res.json()) + + expect(queryResult.GlobalText.text).toBe(spanishTitle) + }) + + it('should pass over fallback locales until it finds one that exists', async () => { + const query = `query { + GlobalText { + text + } + }` + + const { data: queryResult } = await restClient + .GRAPHQL_POST({ + body: JSON.stringify({ query }), + query: { locale: 'pt', fallbackLocale: '[hu,ar,es]' }, + }) + .then((res) => res.json()) + + expect(queryResult.GlobalText.text).toBe(spanishTitle) + }) + + it('should return null if no fallback locales exist', async () => { + const query = `query { + GlobalText { + text + } + }` + + const { data: queryResult } = await restClient + .GRAPHQL_POST({ + body: JSON.stringify({ query }), + query: { locale: 'pt', fallbackLocale: '[hu,ar]' }, + }) + .then((res) => res.json()) + + expect(queryResult.GlobalText.text).toBeNull() + }) + }) + }) + }) }) describe('Localization with fallback false', () => {