From f4d526d6e51e496218e1353311617fccb1107201 Mon Sep 17 00:00:00 2001 From: Paul Date: Wed, 13 Nov 2024 12:13:31 -0600 Subject: [PATCH] fix: fallbackLocale not respecting default settings, locale specific fallbacks and not respecting 'none' or false (#8591) This PR fixes and improves a few things around localisation and fallbackLocale: - For the REST API `fallbackLocale` and `fallback-locale` are treated the same for consistency with the Local API - `fallback: false` in config is now respected, by default results will not fallback to `defaultLocale` unless this config is true, can also be overridden by providing an explicit `fallbackLocale` in the request - locale specific fallbacks will now take priority over `defaultLocale` unless an explicit fallback is provided - Fixes types on operations to allow `'none'` as a value for fallbackLocale - `fallback` is now true by default if unspecified Closes https://github.com/payloadcms/payload/issues/8443 --- docs/configuration/localization.mdx | 4 +- .../src/app/components/Login/client.page.tsx | 4 +- .../Pages/hooks/ensureUniqueSlug.ts | 4 +- .../src/collections/Pages/index.ts | 4 +- .../src/collections/Tenants/access/read.ts | 4 +- .../src/collections/Users/access/create.ts | 12 +- .../Users/access/isAccessingSelf.ts | 4 +- .../Users/access/updateAndDelete.ts | 8 +- .../Users/hooks/ensureUniqueUsername.ts | 4 +- .../TenantField/hooks/autofillTenant.ts | 4 +- .../src/fields/TenantField/index.ts | 4 +- .../src/utilities/getTenantAccessIDs.ts | 8 +- .../next/src/utilities/addLocalesToRequest.ts | 29 +- .../src/utilities/createPayloadRequest.ts | 32 +- .../next/src/utilities/getRequestLocales.ts | 17 +- packages/next/src/utilities/initPage/index.ts | 2 +- packages/next/src/utilities/initReq.ts | 1 - .../src/views/Document/getDocumentData.ts | 4 +- packages/next/src/views/Document/index.tsx | 3 +- packages/next/src/views/List/index.tsx | 2 +- packages/next/src/views/LivePreview/index.tsx | 4 +- packages/payload/src/admin/forms/Form.ts | 2 + .../collections/operations/local/create.ts | 2 +- .../collections/operations/local/delete.ts | 2 +- .../collections/operations/local/duplicate.ts | 2 +- .../src/collections/operations/local/find.ts | 2 +- .../collections/operations/local/findByID.ts | 2 +- .../operations/local/findVersionByID.ts | 2 +- .../operations/local/findVersions.ts | 2 +- .../operations/local/restoreVersion.ts | 2 +- .../collections/operations/local/update.ts | 2 +- packages/payload/src/config/sanitize.ts | 3 + packages/payload/src/config/types.ts | 6 +- .../src/globals/operations/local/findOne.ts | 2 +- .../operations/local/findVersionByID.ts | 2 +- .../globals/operations/local/findVersions.ts | 2 +- .../operations/local/restoreVersion.ts | 2 +- .../src/globals/operations/local/update.ts | 4 +- packages/payload/src/index.ts | 3 +- .../payload/src/utilities/createLocalReq.ts | 24 +- .../src/utilities/sanitizeFallbackLocale.ts | 53 + .../src/providers/ServerFunctions/index.tsx | 6 +- test/localization/int.spec.ts | 4363 +++++++++-------- 43 files changed, 2451 insertions(+), 2197 deletions(-) create mode 100644 packages/payload/src/utilities/sanitizeFallbackLocale.ts diff --git a/docs/configuration/localization.mdx b/docs/configuration/localization.mdx index fc011ea5f..0d40be664 100644 --- a/docs/configuration/localization.mdx +++ b/docs/configuration/localization.mdx @@ -65,7 +65,7 @@ export default buildConfig({ }, ], defaultLocale: 'en', // required - fallback: true, + fallback: true, // defaults to true }, }) ``` @@ -81,7 +81,7 @@ The following options are available: | -------------- | ------------------------------------------------------------------------------------------------------------------------------ | | **`locales`** | Array of all the languages that you would like to support. [More details](#locales) | | **`defaultLocale`** | Required string that matches one of the locale codes from the array provided. By default, if no locale is specified, documents will be returned in this locale. | -| **`fallback`** | Boolean enabling "fallback" locale functionality. If a document is requested in a locale, but a field does not have a localized value corresponding to the requested locale, then if this property is enabled, the document will automatically fall back to the fallback locale value. If this property is not enabled, the value will not be populated. | +| **`fallback`** | Boolean enabling "fallback" locale functionality. If a document is requested in a locale, but a field does not have a localized value corresponding to the requested locale, then if this property is enabled, the document will automatically fall back to the fallback locale value. If this property is not enabled, the value will not be populated unless a fallback is explicitly provided in the request. True by default. | ### Locales diff --git a/examples/multi-tenant/src/app/components/Login/client.page.tsx b/examples/multi-tenant/src/app/components/Login/client.page.tsx index 9fdf9d3e3..e12e1c1a6 100644 --- a/examples/multi-tenant/src/app/components/Login/client.page.tsx +++ b/examples/multi-tenant/src/app/components/Login/client.page.tsx @@ -20,7 +20,9 @@ export const Login = ({ tenantSlug }: Props) => { const handleSubmit = async (e: FormEvent) => { e.preventDefault() - if (!usernameRef?.current?.value || !passwordRef?.current?.value) {return} + if (!usernameRef?.current?.value || !passwordRef?.current?.value) { + return + } const actionRes = await fetch( `${process.env.NEXT_PUBLIC_SERVER_URL}/api/users/external-users/login`, { diff --git a/examples/multi-tenant/src/collections/Pages/hooks/ensureUniqueSlug.ts b/examples/multi-tenant/src/collections/Pages/hooks/ensureUniqueSlug.ts index ae2f97a73..4508cf219 100644 --- a/examples/multi-tenant/src/collections/Pages/hooks/ensureUniqueSlug.ts +++ b/examples/multi-tenant/src/collections/Pages/hooks/ensureUniqueSlug.ts @@ -6,7 +6,9 @@ import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs' export const ensureUniqueSlug: FieldHook = async ({ data, originalDoc, req, value }) => { // if value is unchanged, skip validation - if (originalDoc.slug === value) {return value} + if (originalDoc.slug === value) { + return value + } const incomingTenantID = typeof data?.tenant === 'object' ? data.tenant.id : data?.tenant const currentTenantID = diff --git a/examples/multi-tenant/src/collections/Pages/index.ts b/examples/multi-tenant/src/collections/Pages/index.ts index 7fbf9231b..fe3f9d239 100644 --- a/examples/multi-tenant/src/collections/Pages/index.ts +++ b/examples/multi-tenant/src/collections/Pages/index.ts @@ -14,7 +14,9 @@ export const Pages: CollectionConfig = { read: (args) => { // when viewing pages inside the admin panel // restrict access to the ones your user has access to - if (isPayloadAdminPanel(args.req)) {return filterByTenantRead(args)} + if (isPayloadAdminPanel(args.req)) { + return filterByTenantRead(args) + } // when viewing pages from outside the admin panel // you should be able to see your tenants and public tenants diff --git a/examples/multi-tenant/src/collections/Tenants/access/read.ts b/examples/multi-tenant/src/collections/Tenants/access/read.ts index f3dddd800..9900c3897 100644 --- a/examples/multi-tenant/src/collections/Tenants/access/read.ts +++ b/examples/multi-tenant/src/collections/Tenants/access/read.ts @@ -7,7 +7,9 @@ export const tenantRead: Access = (args) => { const req = args.req // Super admin can read all - if (isSuperAdmin(args)) {return true} + if (isSuperAdmin(args)) { + return true + } const tenantIDs = getTenantAccessIDs(req.user) diff --git a/examples/multi-tenant/src/collections/Users/access/create.ts b/examples/multi-tenant/src/collections/Users/access/create.ts index f00cc66c9..d7fcd286d 100644 --- a/examples/multi-tenant/src/collections/Users/access/create.ts +++ b/examples/multi-tenant/src/collections/Users/access/create.ts @@ -7,13 +7,19 @@ import { getTenantAdminTenantAccessIDs } from '../../../utilities/getTenantAcces export const createAccess: Access = (args) => { const { req } = args - if (!req.user) {return false} + if (!req.user) { + return false + } - if (isSuperAdmin(args)) {return true} + if (isSuperAdmin(args)) { + return true + } const adminTenantAccessIDs = getTenantAdminTenantAccessIDs(req.user) - if (adminTenantAccessIDs.length > 0) {return true} + if (adminTenantAccessIDs.length > 0) { + return true + } return false } diff --git a/examples/multi-tenant/src/collections/Users/access/isAccessingSelf.ts b/examples/multi-tenant/src/collections/Users/access/isAccessingSelf.ts index bccd36a33..e6a15710f 100644 --- a/examples/multi-tenant/src/collections/Users/access/isAccessingSelf.ts +++ b/examples/multi-tenant/src/collections/Users/access/isAccessingSelf.ts @@ -1,6 +1,8 @@ import type { Access } from 'payload' export const isAccessingSelf: Access = ({ id, req }) => { - if (!req?.user) {return false} + if (!req?.user) { + return false + } return req.user.id === id } diff --git a/examples/multi-tenant/src/collections/Users/access/updateAndDelete.ts b/examples/multi-tenant/src/collections/Users/access/updateAndDelete.ts index a615bf9fc..6f6ef74b1 100644 --- a/examples/multi-tenant/src/collections/Users/access/updateAndDelete.ts +++ b/examples/multi-tenant/src/collections/Users/access/updateAndDelete.ts @@ -5,9 +5,13 @@ import { getTenantAdminTenantAccessIDs } from '../../../utilities/getTenantAcces export const updateAndDeleteAccess: Access = (args) => { const { req } = args - if (!req.user) {return false} + if (!req.user) { + return false + } - if (isSuperAdmin(args)) {return true} + if (isSuperAdmin(args)) { + return true + } const adminTenantAccessIDs = getTenantAdminTenantAccessIDs(req.user) diff --git a/examples/multi-tenant/src/collections/Users/hooks/ensureUniqueUsername.ts b/examples/multi-tenant/src/collections/Users/hooks/ensureUniqueUsername.ts index 23ca8e52b..e52ed9a27 100644 --- a/examples/multi-tenant/src/collections/Users/hooks/ensureUniqueUsername.ts +++ b/examples/multi-tenant/src/collections/Users/hooks/ensureUniqueUsername.ts @@ -6,7 +6,9 @@ import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs' export const ensureUniqueUsername: FieldHook = async ({ data, originalDoc, req, value }) => { // if value is unchanged, skip validation - if (originalDoc.username === value) {return value} + if (originalDoc.username === value) { + return value + } const incomingTenantID = typeof data?.tenant === 'object' ? data.tenant.id : data?.tenant const currentTenantID = diff --git a/examples/multi-tenant/src/fields/TenantField/hooks/autofillTenant.ts b/examples/multi-tenant/src/fields/TenantField/hooks/autofillTenant.ts index d04eaf868..1bc0814fb 100644 --- a/examples/multi-tenant/src/fields/TenantField/hooks/autofillTenant.ts +++ b/examples/multi-tenant/src/fields/TenantField/hooks/autofillTenant.ts @@ -8,7 +8,9 @@ export const autofillTenant: FieldHook = ({ req, value }) => { // return that tenant ID as the value if (!value) { const tenantIDs = getTenantAccessIDs(req.user) - if (tenantIDs.length === 1) {return tenantIDs[0]} + if (tenantIDs.length === 1) { + return tenantIDs[0] + } } return value diff --git a/examples/multi-tenant/src/fields/TenantField/index.ts b/examples/multi-tenant/src/fields/TenantField/index.ts index 8782ab2a3..0716f980a 100644 --- a/examples/multi-tenant/src/fields/TenantField/index.ts +++ b/examples/multi-tenant/src/fields/TenantField/index.ts @@ -10,7 +10,9 @@ export const tenantField: Field = { access: { read: () => true, update: (args) => { - if (isSuperAdmin(args)) {return true} + if (isSuperAdmin(args)) { + return true + } return tenantFieldUpdate(args) }, }, diff --git a/examples/multi-tenant/src/utilities/getTenantAccessIDs.ts b/examples/multi-tenant/src/utilities/getTenantAccessIDs.ts index b827ac536..3cf628112 100644 --- a/examples/multi-tenant/src/utilities/getTenantAccessIDs.ts +++ b/examples/multi-tenant/src/utilities/getTenantAccessIDs.ts @@ -1,7 +1,9 @@ import type { User } from '../payload-types' export const getTenantAccessIDs = (user: null | User): string[] => { - if (!user) {return []} + if (!user) { + return [] + } return ( user?.tenants?.reduce((acc: string[], { tenant }) => { if (tenant) { @@ -13,7 +15,9 @@ export const getTenantAccessIDs = (user: null | User): string[] => { } export const getTenantAdminTenantAccessIDs = (user: null | User): string[] => { - if (!user) {return []} + if (!user) { + return [] + } return ( user?.tenants?.reduce((acc: string[], { roles, tenant }) => { diff --git a/packages/next/src/utilities/addLocalesToRequest.ts b/packages/next/src/utilities/addLocalesToRequest.ts index 11e22644c..0c281cecd 100644 --- a/packages/next/src/utilities/addLocalesToRequest.ts +++ b/packages/next/src/utilities/addLocalesToRequest.ts @@ -1,5 +1,6 @@ import type { PayloadRequest, SanitizedConfig } from 'payload' +import { sanitizeFallbackLocale } from 'payload' /** * Mutates the Request to contain 'locale' and 'fallbackLocale' based on data or searchParams */ @@ -17,12 +18,14 @@ export function addLocalesToRequestFromData(req: PayloadRequest): void { localeOnReq = data.locale } - if ( - !fallbackLocaleOnReq && - data?.['fallback-locale'] && - typeof data?.['fallback-locale'] === 'string' - ) { - fallbackLocaleOnReq = data['fallback-locale'] + if (!fallbackLocaleOnReq) { + if (data?.['fallback-locale'] && typeof data?.['fallback-locale'] === 'string') { + fallbackLocaleOnReq = data['fallback-locale'] + } + + if (data?.['fallbackLocale'] && typeof data?.['fallbackLocale'] === 'string') { + fallbackLocaleOnReq = data['fallbackLocale'] + } } const { fallbackLocale, locale } = sanitizeLocales({ @@ -54,15 +57,19 @@ export const sanitizeLocales = ({ locale, localization, }: SanitizeLocalesArgs): SanitizeLocalesReturn => { - if (['none', 'null'].includes(fallbackLocale)) { - fallbackLocale = 'null' - } else if (localization && !localization.localeCodes.includes(fallbackLocale)) { - fallbackLocale = localization.defaultLocale + // Check if localization has fallback enabled or if a fallback locale is provided + + if (localization) { + fallbackLocale = sanitizeFallbackLocale({ + fallbackLocale, + locale, + localization, + }) } if (locale === '*') { locale = 'all' - } else if (localization && !localization.localeCodes.includes(locale)) { + } else if (localization && !localization.localeCodes.includes(locale) && localization.fallback) { locale = localization.defaultLocale } diff --git a/packages/next/src/utilities/createPayloadRequest.ts b/packages/next/src/utilities/createPayloadRequest.ts index 5cddbad50..2faa143f3 100644 --- a/packages/next/src/utilities/createPayloadRequest.ts +++ b/packages/next/src/utilities/createPayloadRequest.ts @@ -1,7 +1,7 @@ import type { CustomPayloadRequestProperties, PayloadRequest, SanitizedConfig } from 'payload' import { initI18n } from '@payloadcms/translations' -import { executeAuthStrategies, getDataLoader, parseCookies } from 'payload' +import { executeAuthStrategies, getDataLoader, parseCookies, sanitizeFallbackLocale } from 'payload' import * as qs from 'qs-esm' import { URL } from 'url' @@ -26,6 +26,7 @@ export const createPayloadRequest = async ({ const payload = await getPayloadHMR({ config: configPromise }) const { config } = payload + const localization = config.localization const urlProperties = new URL(request.url) const { pathname, searchParams } = urlProperties @@ -45,8 +46,10 @@ export const createPayloadRequest = async ({ language, }) - let locale - let fallbackLocale + const fallbackFromRequest = + searchParams.get('fallback-locale') || searchParams.get('fallbackLocale') + let locale = searchParams.get('locale') + let fallbackLocale = fallbackFromRequest const overrideHttpMethod = request.headers.get('X-HTTP-Method-Override') const queryToParse = overrideHttpMethod === 'GET' ? await request.text() : urlProperties.search @@ -59,21 +62,24 @@ export const createPayloadRequest = async ({ }) : {} - if (config.localization) { - const locales = sanitizeLocales({ - fallbackLocale: searchParams.get('fallback-locale'), - locale: searchParams.get('locale'), - localization: payload.config.localization, + if (localization) { + fallbackLocale = sanitizeFallbackLocale({ + fallbackLocale, + locale, + localization, }) + + const locales = sanitizeLocales({ + fallbackLocale, + locale, + localization, + }) + locale = locales.locale - fallbackLocale = locales.fallbackLocale // Override if query params are present, in order to respect HTTP method override if (query.locale) { - locale = query.locale - } - if (query?.['fallback-locale']) { - fallbackLocale = query['fallback-locale'] + locale = query.locale as string } } diff --git a/packages/next/src/utilities/getRequestLocales.ts b/packages/next/src/utilities/getRequestLocales.ts index 434389d91..35ed73f80 100644 --- a/packages/next/src/utilities/getRequestLocales.ts +++ b/packages/next/src/utilities/getRequestLocales.ts @@ -1,5 +1,7 @@ import type { Payload } from 'payload' +import { sanitizeFallbackLocale } from 'payload' + type GetRequestLocalesArgs = { data?: Record localization: Exclude @@ -10,7 +12,7 @@ export function getRequestLocales({ data, localization, searchParams }: GetReque locale: string } { let locale = searchParams.get('locale') - let fallbackLocale = searchParams.get('fallback-locale') + let fallbackLocale = searchParams.get('fallback-locale') || searchParams.get('fallbackLocale') if (data) { if (data?.locale) { @@ -19,13 +21,16 @@ export function getRequestLocales({ data, localization, searchParams }: GetReque if (data?.['fallback-locale']) { fallbackLocale = data['fallback-locale'] } + if (data?.['fallbackLocale']) { + fallbackLocale = data['fallbackLocale'] + } } - if (fallbackLocale === 'none') { - fallbackLocale = 'null' - } else if (!localization.localeCodes.includes(fallbackLocale)) { - fallbackLocale = localization.defaultLocale - } + fallbackLocale = sanitizeFallbackLocale({ + fallbackLocale, + locale, + localization, + }) if (locale === '*') { locale = 'all' diff --git a/packages/next/src/utilities/initPage/index.ts b/packages/next/src/utilities/initPage/index.ts index 742b507c8..1daecbe0c 100644 --- a/packages/next/src/utilities/initPage/index.ts +++ b/packages/next/src/utilities/initPage/index.ts @@ -44,7 +44,7 @@ export const initPage = async ({ // we get above. Clone the req? We'll look into that eventually. const req = await createLocalReq( { - fallbackLocale: null, + fallbackLocale: false, req: { headers, host: headers.get('host'), diff --git a/packages/next/src/utilities/initReq.ts b/packages/next/src/utilities/initReq.ts index e7e1ff2bd..e907ee728 100644 --- a/packages/next/src/utilities/initReq.ts +++ b/packages/next/src/utilities/initReq.ts @@ -39,7 +39,6 @@ export const initReq = cache(async function ( const req = await createLocalReq( { - fallbackLocale: 'null', req: { headers, host: headers.get('host'), diff --git a/packages/next/src/views/Document/getDocumentData.ts b/packages/next/src/views/Document/getDocumentData.ts index 8280532d0..03f61170d 100644 --- a/packages/next/src/views/Document/getDocumentData.ts +++ b/packages/next/src/views/Document/getDocumentData.ts @@ -26,7 +26,7 @@ export const getDocumentData = async ({ collection: collectionSlug, depth: 0, draft: true, - fallbackLocale: null, + fallbackLocale: false, locale: locale?.code, overrideAccess: false, user, @@ -38,7 +38,7 @@ export const getDocumentData = async ({ slug: globalSlug, depth: 0, draft: true, - fallbackLocale: null, + fallbackLocale: false, locale: locale?.code, overrideAccess: false, user, diff --git a/packages/next/src/views/Document/index.tsx b/packages/next/src/views/Document/index.tsx index ba381d72f..fe52fceda 100644 --- a/packages/next/src/views/Document/index.tsx +++ b/packages/next/src/views/Document/index.tsx @@ -149,6 +149,7 @@ export const renderDocument = async ({ data: doc, docPermissions, docPreferences, + fallbackLocale: false, globalSlug, locale: locale?.code, operation: (collectionSlug && id) || globalSlug ? 'update' : 'create', @@ -278,7 +279,7 @@ export const renderDocument = async ({ data: initialData || {}, depth: 0, draft: true, - fallbackLocale: null, + fallbackLocale: false, locale: locale?.code, req, user, diff --git a/packages/next/src/views/List/index.tsx b/packages/next/src/views/List/index.tsx index e844a54c7..9e2219c60 100644 --- a/packages/next/src/views/List/index.tsx +++ b/packages/next/src/views/List/index.tsx @@ -139,7 +139,7 @@ export const renderListView = async ( collection: collectionSlug, depth: 0, draft: true, - fallbackLocale: null, + fallbackLocale: false, includeLockStatus: true, limit, locale, diff --git a/packages/next/src/views/LivePreview/index.tsx b/packages/next/src/views/LivePreview/index.tsx index 8e68b4fa1..30f97ece1 100644 --- a/packages/next/src/views/LivePreview/index.tsx +++ b/packages/next/src/views/LivePreview/index.tsx @@ -37,7 +37,7 @@ export const LivePreviewView: PayloadServerReactComponent = a collection: collectionConfig.slug, depth: 0, draft: true, - fallbackLocale: null, + fallbackLocale: false, }) } @@ -46,7 +46,7 @@ export const LivePreviewView: PayloadServerReactComponent = a slug: globalConfig.slug, depth: 0, draft: true, - fallbackLocale: null, + fallbackLocale: false, }) } } catch (error) { diff --git a/packages/payload/src/admin/forms/Form.ts b/packages/payload/src/admin/forms/Form.ts index ec1d2601f..978e40d52 100644 --- a/packages/payload/src/admin/forms/Form.ts +++ b/packages/payload/src/admin/forms/Form.ts @@ -2,6 +2,7 @@ import { type SupportedLanguages } from '@payloadcms/translations' import type { DocumentPermissions } from '../../auth/types.js' import type { Field, Validate } from '../../fields/config/types.js' +import type { TypedLocale } from '../../index.js' import type { DocumentPreferences } from '../../preferences/types.js' import type { PayloadRequest, Where } from '../../types/index.js' @@ -62,6 +63,7 @@ export type BuildFormStateArgs = { data?: Data docPermissions: DocumentPermissions | undefined docPreferences: DocumentPreferences + fallbackLocale?: false | TypedLocale formState?: FormState id?: number | string /* diff --git a/packages/payload/src/collections/operations/local/create.ts b/packages/payload/src/collections/operations/local/create.ts index 6043928f8..36c171a1d 100644 --- a/packages/payload/src/collections/operations/local/create.ts +++ b/packages/payload/src/collections/operations/local/create.ts @@ -28,7 +28,7 @@ export type Options = disableTransaction?: boolean disableVerificationEmail?: boolean draft?: boolean - fallbackLocale?: TypedLocale + fallbackLocale?: false | TypedLocale file?: File filePath?: string locale?: TypedLocale diff --git a/packages/payload/src/collections/operations/local/delete.ts b/packages/payload/src/collections/operations/local/delete.ts index 729dcde0c..d9e6a0147 100644 --- a/packages/payload/src/collections/operations/local/delete.ts +++ b/packages/payload/src/collections/operations/local/delete.ts @@ -22,7 +22,7 @@ export type BaseOptions = depth?: number disableTransaction?: boolean draft?: boolean - fallbackLocale?: TypedLocale + fallbackLocale?: false | TypedLocale id: number | string locale?: TypedLocale overrideAccess?: boolean diff --git a/packages/payload/src/collections/operations/local/find.ts b/packages/payload/src/collections/operations/local/find.ts index c0e676bdd..2b16dea9e 100644 --- a/packages/payload/src/collections/operations/local/find.ts +++ b/packages/payload/src/collections/operations/local/find.ts @@ -31,7 +31,7 @@ export type Options = depth?: number disableErrors?: boolean draft?: boolean - fallbackLocale?: TypedLocale + fallbackLocale?: false | TypedLocale includeLockStatus?: boolean joins?: JoinQuery limit?: number diff --git a/packages/payload/src/collections/operations/local/findByID.ts b/packages/payload/src/collections/operations/local/findByID.ts index 183ac1c55..334e1f05d 100644 --- a/packages/payload/src/collections/operations/local/findByID.ts +++ b/packages/payload/src/collections/operations/local/findByID.ts @@ -34,7 +34,7 @@ export type Options< depth?: number disableErrors?: TDisableErrors draft?: boolean - fallbackLocale?: TypedLocale + fallbackLocale?: false | TypedLocale id: number | string includeLockStatus?: boolean joins?: JoinQuery diff --git a/packages/payload/src/collections/operations/local/findVersionByID.ts b/packages/payload/src/collections/operations/local/findVersionByID.ts index 61057ad15..a606e52d8 100644 --- a/packages/payload/src/collections/operations/local/findVersionByID.ts +++ b/packages/payload/src/collections/operations/local/findVersionByID.ts @@ -16,7 +16,7 @@ export type Options = { depth?: number disableErrors?: boolean draft?: boolean - fallbackLocale?: TypedLocale + fallbackLocale?: false | TypedLocale id: string locale?: 'all' | TypedLocale overrideAccess?: boolean diff --git a/packages/payload/src/collections/operations/local/findVersions.ts b/packages/payload/src/collections/operations/local/findVersions.ts index 1b5e1d71d..842cade19 100644 --- a/packages/payload/src/collections/operations/local/findVersions.ts +++ b/packages/payload/src/collections/operations/local/findVersions.ts @@ -23,7 +23,7 @@ export type Options = { context?: RequestContext depth?: number draft?: boolean - fallbackLocale?: TypedLocale + fallbackLocale?: false | TypedLocale limit?: number locale?: 'all' | TypedLocale overrideAccess?: boolean diff --git a/packages/payload/src/collections/operations/local/restoreVersion.ts b/packages/payload/src/collections/operations/local/restoreVersion.ts index 83a47d2f0..e961b53c8 100644 --- a/packages/payload/src/collections/operations/local/restoreVersion.ts +++ b/packages/payload/src/collections/operations/local/restoreVersion.ts @@ -14,7 +14,7 @@ export type Options = { context?: RequestContext depth?: number draft?: boolean - fallbackLocale?: TypedLocale + fallbackLocale?: false | TypedLocale id: string locale?: TypedLocale overrideAccess?: boolean diff --git a/packages/payload/src/collections/operations/local/update.ts b/packages/payload/src/collections/operations/local/update.ts index 8909543cd..f99cadf36 100644 --- a/packages/payload/src/collections/operations/local/update.ts +++ b/packages/payload/src/collections/operations/local/update.ts @@ -33,7 +33,7 @@ export type BaseOptions locale.code, })) } + + // Default fallback to true if not provided + config.localization.fallback = config.localization?.fallback ?? true } const i18nConfig: SanitizedConfig['i18n'] = { diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index b10133690..be1c53b71 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -444,7 +444,11 @@ export type BaseLocalizationConfig = { * @example `"en"` */ defaultLocale: string - /** Set to `true` to let missing values in localised fields fall back to the values in `defaultLocale` */ + /** Set to `true` to let missing values in localised fields fall back to the values in `defaultLocale` + * + * If false, then no requests will fallback unless a fallbackLocale is specified in the request. + * @default true + */ fallback?: boolean } diff --git a/packages/payload/src/globals/operations/local/findOne.ts b/packages/payload/src/globals/operations/local/findOne.ts index 0676385a6..69f04541d 100644 --- a/packages/payload/src/globals/operations/local/findOne.ts +++ b/packages/payload/src/globals/operations/local/findOne.ts @@ -16,7 +16,7 @@ export type Options = { context?: RequestContext depth?: number draft?: boolean - fallbackLocale?: TypedLocale + fallbackLocale?: false | TypedLocale includeLockStatus?: boolean locale?: 'all' | TypedLocale overrideAccess?: boolean diff --git a/packages/payload/src/globals/operations/local/findVersionByID.ts b/packages/payload/src/globals/operations/local/findVersionByID.ts index df76db573..1b5fe298b 100644 --- a/packages/payload/src/globals/operations/local/findVersionByID.ts +++ b/packages/payload/src/globals/operations/local/findVersionByID.ts @@ -11,7 +11,7 @@ export type Options = { context?: RequestContext depth?: number disableErrors?: boolean - fallbackLocale?: TypedLocale + fallbackLocale?: false | TypedLocale id: string locale?: 'all' | TypedLocale overrideAccess?: boolean diff --git a/packages/payload/src/globals/operations/local/findVersions.ts b/packages/payload/src/globals/operations/local/findVersions.ts index 042158fbb..0393209c9 100644 --- a/packages/payload/src/globals/operations/local/findVersions.ts +++ b/packages/payload/src/globals/operations/local/findVersions.ts @@ -19,7 +19,7 @@ import { findVersionsOperation } from '../findVersions.js' export type Options = { context?: RequestContext depth?: number - fallbackLocale?: TypedLocale + fallbackLocale?: false | TypedLocale limit?: number locale?: 'all' | TypedLocale overrideAccess?: boolean diff --git a/packages/payload/src/globals/operations/local/restoreVersion.ts b/packages/payload/src/globals/operations/local/restoreVersion.ts index e2cf93e9b..fb9f91ba8 100644 --- a/packages/payload/src/globals/operations/local/restoreVersion.ts +++ b/packages/payload/src/globals/operations/local/restoreVersion.ts @@ -10,7 +10,7 @@ import { restoreVersionOperation } from '../restoreVersion.js' export type Options = { context?: RequestContext depth?: number - fallbackLocale?: TypedLocale + fallbackLocale?: false | TypedLocale id: string locale?: TypedLocale overrideAccess?: boolean diff --git a/packages/payload/src/globals/operations/local/update.ts b/packages/payload/src/globals/operations/local/update.ts index 6a4378a07..d89a3d8ec 100644 --- a/packages/payload/src/globals/operations/local/update.ts +++ b/packages/payload/src/globals/operations/local/update.ts @@ -19,8 +19,8 @@ export type Options = { data: DeepPartial, 'id'>> depth?: number draft?: boolean - fallbackLocale?: TypedLocale - locale?: TypedLocale + fallbackLocale?: false | TypedLocale + locale?: 'all' | TypedLocale overrideAccess?: boolean overrideLock?: boolean populate?: PopulateType diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index f3e51445d..dfeed2003 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1202,6 +1202,7 @@ export { isPlainObject } from './utilities/isPlainObject.js' export { isValidID } from './utilities/isValidID.js' export { killTransaction } from './utilities/killTransaction.js' export { mapAsync } from './utilities/mapAsync.js' +export { sanitizeFallbackLocale } from './utilities/sanitizeFallbackLocale.js' export { traverseFields } from './utilities/traverseFields.js' export type { TraverseFieldsCallback } from './utilities/traverseFields.js' export { buildVersionCollectionFields } from './versions/buildCollectionFields.js' @@ -1211,7 +1212,7 @@ export { deleteCollectionVersions } from './versions/deleteCollectionVersions.js export { enforceMaxVersions } from './versions/enforceMaxVersions.js' export { getLatestCollectionVersion } from './versions/getLatestCollectionVersion.js' export { getLatestGlobalVersion } from './versions/getLatestGlobalVersion.js' -export { saveVersion } from './versions/saveVersion.js' +export { saveVersion } from './versions/saveVersion.js' export type { TypeWithVersion } from './versions/types.js' export { deepMergeSimple } from '@payloadcms/translations/utilities' diff --git a/packages/payload/src/utilities/createLocalReq.ts b/packages/payload/src/utilities/createLocalReq.ts index 4d119235c..c4b6c577c 100644 --- a/packages/payload/src/utilities/createLocalReq.ts +++ b/packages/payload/src/utilities/createLocalReq.ts @@ -1,9 +1,10 @@ import type { User } from '../auth/types.js' -import type { Payload, RequestContext } from '../index.js' +import type { Payload, RequestContext, TypedLocale } from '../index.js' import type { PayloadRequest } from '../types/index.js' import { getDataLoader } from '../collections/dataloader.js' import { getLocalI18n } from '../translations/getLocalI18n.js' +import { sanitizeFallbackLocale } from '../utilities/sanitizeFallbackLocale.js' function getRequestContext( req: Partial = { context: null } as PayloadRequest, @@ -71,7 +72,7 @@ const attachFakeURLProperties = (req: Partial) => { type CreateLocalReq = ( options: { context?: RequestContext - fallbackLocale?: string + fallbackLocale?: false | TypedLocale locale?: string req?: Partial user?: User @@ -83,23 +84,22 @@ export const createLocalReq: CreateLocalReq = async ( { context, fallbackLocale, locale: localeArg, req = {} as PayloadRequest, user }, payload, ) => { - if (payload.config?.localization) { + const localization = payload.config?.localization + if (localization) { const locale = localeArg === '*' ? 'all' : localeArg - const defaultLocale = payload.config.localization.defaultLocale + const defaultLocale = localization.defaultLocale const localeCandidate = locale || req?.locale || req?.query?.locale req.locale = localeCandidate && typeof localeCandidate === 'string' ? localeCandidate : defaultLocale - const fallbackLocaleFromConfig = payload.config.localization.locales.find( - ({ code }) => req.locale === code, - )?.fallbackLocale + const sanitizedFallback = sanitizeFallbackLocale({ + fallbackLocale, + locale: req.locale, + localization, + }) - if (typeof fallbackLocale !== 'undefined') { - req.fallbackLocale = fallbackLocale - } else if (typeof req?.fallbackLocale === 'undefined') { - req.fallbackLocale = fallbackLocaleFromConfig || defaultLocale - } + req.fallbackLocale = sanitizedFallback } const i18n = diff --git a/packages/payload/src/utilities/sanitizeFallbackLocale.ts b/packages/payload/src/utilities/sanitizeFallbackLocale.ts new file mode 100644 index 000000000..154734d34 --- /dev/null +++ b/packages/payload/src/utilities/sanitizeFallbackLocale.ts @@ -0,0 +1,53 @@ +import type { SanitizedLocalizationConfig } from '../config/types.js' +import type { TypedLocale } from '../index.js' + +interface Args { + fallbackLocale: false | TypedLocale + locale: string + localization: SanitizedLocalizationConfig +} + +/** + * Sanitizes fallbackLocale based on a provided fallbackLocale, locale and localization config + * + * Handles the following scenarios: + * - determines if a fallback locale should be used + * - determines if a locale specific fallback should be used in place of the default locale + * - sets the fallbackLocale to 'null' if no fallback locale should be used + */ +export const sanitizeFallbackLocale = ({ + fallbackLocale, + locale, + localization, +}: Args): null | string => { + const hasFallbackLocale = + fallbackLocale === undefined || fallbackLocale === null + ? localization && localization.fallback + : fallbackLocale + ? !['false', 'none', 'null'].includes(fallbackLocale) + : false + + if (hasFallbackLocale) { + if (!fallbackLocale) { + // Check for locale specific fallback + const localeSpecificFallback = + localization && localization?.locales?.length + ? localization.locales.find((localeConfig) => localeConfig.code === locale) + ?.fallbackLocale + : undefined + + if (localeSpecificFallback) { + fallbackLocale = localeSpecificFallback + } else { + // Use defaultLocale as fallback otherwise + if (localization && 'fallback' in localization && localization.fallback) { + fallbackLocale = localization.defaultLocale + } + } + } + } else { + fallbackLocale = null + } + + return fallbackLocale as null | string +} diff --git a/packages/ui/src/providers/ServerFunctions/index.tsx b/packages/ui/src/providers/ServerFunctions/index.tsx index 2a8afdce2..144c363a2 100644 --- a/packages/ui/src/providers/ServerFunctions/index.tsx +++ b/packages/ui/src/providers/ServerFunctions/index.tsx @@ -84,7 +84,7 @@ export const ServerFunctionsProvider: React.FC<{ if (!remoteSignal?.aborted) { const result = (await serverFunction({ name: 'form-state', - args: rest, + args: { fallbackLocale: false, ...rest }, })) as ReturnType // TODO: infer this type when `strictNullChecks` is enabled if (!remoteSignal?.aborted) { @@ -108,7 +108,7 @@ export const ServerFunctionsProvider: React.FC<{ if (!remoteSignal?.aborted) { const result = (await serverFunction({ name: 'table-state', - args: rest, + args: { fallbackLocale: false, ...rest }, })) as ReturnType // TODO: infer this type when `strictNullChecks` is enabled if (!remoteSignal?.aborted) { @@ -132,7 +132,7 @@ export const ServerFunctionsProvider: React.FC<{ if (!remoteSignal?.aborted) { const result = (await serverFunction({ name: 'render-document', - args: rest, + args: { fallbackLocale: false, ...rest }, })) as { docID: string; Document: React.ReactNode } if (!remoteSignal?.aborted) { diff --git a/test/localization/int.spec.ts b/test/localization/int.spec.ts index 3fa5437de..e260cae98 100644 --- a/test/localization/int.spec.ts +++ b/test/localization/int.spec.ts @@ -38,34 +38,8 @@ const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) describe('Localization', () => { - let post1: LocalizedPost - let postWithLocalizedData: LocalizedPost - beforeAll(async () => { ;({ payload, restClient } = await initPayloadInt(dirname)) - - post1 = await payload.create({ - collection, - data: { - title: englishTitle, - }, - }) - - postWithLocalizedData = await payload.create({ - collection, - data: { - title: englishTitle, - }, - }) - - await payload.update({ - id: postWithLocalizedData.id, - collection, - data: { - title: spanishTitle, - }, - locale: spanishLocale, - }) }) afterAll(async () => { @@ -74,164 +48,60 @@ describe('Localization', () => { } }) - describe('Localized text', () => { - it('create english', async () => { - const allDocs = await payload.find({ + describe('Localization with fallback true', () => { + let post1: LocalizedPost + let postWithLocalizedData: LocalizedPost + + beforeAll(async () => { + post1 = await payload.create({ collection, - where: { - title: { equals: post1.title }, + data: { + title: englishTitle, }, }) - expect(allDocs.docs).toContainEqual(expect.objectContaining(post1)) - }) - it('add spanish translation', async () => { - const updated = await payload.update({ - id: post1.id, + postWithLocalizedData = await payload.create({ + collection, + data: { + title: englishTitle, + }, + }) + + await payload.update({ + id: postWithLocalizedData.id, collection, data: { title: spanishTitle, }, locale: spanishLocale, }) - - expect(updated.title).toEqual(spanishTitle) - - const localized: any = await payload.findByID({ - id: post1.id, - collection, - locale: 'all', - }) - - expect(localized.title.en).toEqual(englishTitle) - expect(localized.title.es).toEqual(spanishTitle) }) - it('should fallback to english translation when empty', async () => { - await payload.update({ - id: post1.id, - collection, - data: { - title: '', - }, - locale: spanishLocale, - }) - - const retrievedInEnglish = await payload.findByID({ - id: post1.id, - collection, - }) - - expect(retrievedInEnglish.title).toEqual(englishTitle) - - const localizedFallback: any = await payload.findByID({ - id: post1.id, - collection, - locale: 'all', - }) - - expect(localizedFallback.title.en).toEqual(englishTitle) - expect(localizedFallback.title.es).toEqual('') - }) - - describe('fallback locales', () => { - let englishData - let spanishData - let localizedDoc - - beforeAll(async () => { - englishData = { - localizedCheckbox: false, - } - spanishData = { - localizedCheckbox: true, - title: 'spanish title', - } - - localizedDoc = await payload.create({ - collection: localizedPostsSlug, - data: englishData, - locale: englishLocale, - }) - - await payload.update({ - id: localizedDoc.id, - collection: localizedPostsSlug, - data: spanishData, - locale: spanishLocale, - }) - await payload.update({ - id: localizedDoc.id, - collection: localizedPostsSlug, - data: { localizedCheckbox: true }, - locale: portugueseLocale, - }) - }) - - it('should return localized fields using fallbackLocale specified in the requested locale config', async () => { - const portugueseDoc = await payload.findByID({ - id: localizedDoc.id, - collection: localizedPostsSlug, - locale: portugueseLocale, - }) - - expect(portugueseDoc.title).toStrictEqual(spanishData.title) - expect(portugueseDoc.localizedCheckbox).toStrictEqual(true) - }) - }) - - describe('querying', () => { - let localizedPost: LocalizedPost - beforeEach(async () => { - const { id } = await payload.create({ + describe('Localized text', () => { + it('create english', async () => { + const allDocs = await payload.find({ collection, - data: { - title: englishTitle, + where: { + title: { equals: post1.title }, }, }) + expect(allDocs.docs).toContainEqual(expect.objectContaining(post1)) + }) - localizedPost = await payload.update({ - id, + it('add spanish translation', async () => { + const updated = await payload.update({ + id: post1.id, collection, data: { title: spanishTitle, }, locale: spanishLocale, }) - }) - it('unspecified locale returns default', async () => { - const localized = await payload.findByID({ - id: localizedPost.id, - collection, - }) + expect(updated.title).toEqual(spanishTitle) - expect(localized.title).toEqual(englishTitle) - }) - - it('specific locale - same as default', async () => { - const localized = await payload.findByID({ - id: localizedPost.id, - collection, - locale: defaultLocale, - }) - - expect(localized.title).toEqual(englishTitle) - }) - - it('specific locale - not default', async () => { - const localized = await payload.findByID({ - id: localizedPost.id, - collection, - locale: spanishLocale, - }) - - expect(localized.title).toEqual(spanishTitle) - }) - - it('all locales', async () => { const localized: any = await payload.findByID({ - id: localizedPost.id, + id: post1.id, collection, locale: 'all', }) @@ -240,667 +110,829 @@ describe('Localization', () => { expect(localized.title.es).toEqual(spanishTitle) }) - it('by localized field value - default locale', async () => { - const result = await payload.find({ + it('should fallback to english translation when empty', async () => { + await payload.update({ + id: post1.id, collection, - where: { - title: { - equals: englishTitle, - }, + data: { + title: '', }, - }) - - expect(result.docs.map(({ id }) => id)).toContain(localizedPost.id) - }) - - it('by localized field value - alternate locale', async () => { - const result = await payload.find({ - collection, locale: spanishLocale, - where: { - title: { - equals: spanishTitle, - }, - }, }) - expect(result.docs.map(({ id }) => id)).toContain(localizedPost.id) - }) + const retrievedInEnglish = await payload.findByID({ + id: post1.id, + collection, + }) - it('by localized field value - opposite locale???', async () => { - const result = await payload.find({ + expect(retrievedInEnglish.title).toEqual(englishTitle) + + const localizedFallback: any = await payload.findByID({ + id: post1.id, collection, locale: 'all', - where: { - 'title.es': { - equals: spanishTitle, - }, - }, }) - expect(result.docs.map(({ id }) => id)).toContain(localizedPost.id) + expect(localizedFallback.title.en).toEqual(englishTitle) + expect(localizedFallback.title.es).toEqual('') }) - it('by localized field value with sorting', async () => { - const doc_1 = await payload.create({ collection, data: { title: 'word_b' } }) - const doc_2 = await payload.create({ collection, data: { title: 'word_a' } }) - const doc_3 = await payload.create({ collection, data: { title: 'word_c' } }) - - await payload.create({ collection, data: { title: 'others_c' } }) - - const { docs } = await payload.find({ + it('should fallback to spanish translation when empty and locale-specific fallback is provided', async () => { + const localizedFallback: any = await payload.findByID({ + id: postWithLocalizedData.id, collection, - sort: 'title', - where: { - title: { - like: 'word', + locale: portugueseLocale, + }) + + expect(localizedFallback.title).toEqual(spanishTitle) + }) + + it('should respect fallback none', async () => { + const localizedFallback: any = await payload.findByID({ + id: postWithLocalizedData.id, + collection, + locale: portugueseLocale, + // @ts-expect-error - testing fallbackLocale 'none' for backwards compatibility though the correct type here is `false` + fallbackLocale: 'none', + }) + + expect(localizedFallback.title).not.toBeDefined() + }) + + describe('fallback locales', () => { + let englishData + let spanishData + let localizedDoc + + beforeAll(async () => { + englishData = { + localizedCheckbox: false, + } + spanishData = { + localizedCheckbox: true, + title: 'spanish title', + } + + localizedDoc = await payload.create({ + collection: localizedPostsSlug, + data: englishData, + locale: englishLocale, + }) + + await payload.update({ + id: localizedDoc.id, + collection: localizedPostsSlug, + data: spanishData, + locale: spanishLocale, + }) + await payload.update({ + id: localizedDoc.id, + collection: localizedPostsSlug, + data: { localizedCheckbox: true }, + locale: portugueseLocale, + }) + }) + + it('should return localized fields using fallbackLocale specified in the requested locale config', async () => { + const portugueseDoc = await payload.findByID({ + id: localizedDoc.id, + collection: localizedPostsSlug, + locale: portugueseLocale, + }) + + expect(portugueseDoc.title).toStrictEqual(spanishData.title) + expect(portugueseDoc.localizedCheckbox).toStrictEqual(true) + }) + }) + + describe('querying', () => { + let localizedPost: LocalizedPost + beforeEach(async () => { + const { id } = await payload.create({ + collection, + data: { + title: englishTitle, }, - }, - }) - - expect(docs).toHaveLength(3) - expect(docs[0].id).toBe(doc_2.id) - expect(docs[1].id).toBe(doc_1.id) - expect(docs[2].id).toBe(doc_3.id) - }) - - if (['mongodb'].includes(process.env.PAYLOAD_DATABASE)) { - describe('Localized sorting', () => { - let localizedAccentPostOne: LocalizedPost - let localizedAccentPostTwo: LocalizedPost - beforeEach(async () => { - localizedAccentPostOne = await payload.create({ - collection, - data: { - title: 'non accent post', - localizedDescription: 'something', - }, - locale: englishLocale, - }) - - localizedAccentPostTwo = await payload.create({ - collection, - data: { - title: 'accent post', - localizedDescription: 'veterinarian', - }, - locale: englishLocale, - }) - - await payload.update({ - id: localizedAccentPostOne.id, - collection, - data: { - title: 'non accent post', - localizedDescription: 'valami', - }, - locale: hungarianLocale, - }) - - await payload.update({ - id: localizedAccentPostTwo.id, - collection, - data: { - title: 'accent post', - localizedDescription: 'állatorvos', - }, - locale: hungarianLocale, - }) }) - it('should sort alphabetically even with accented letters', async () => { - const sortByDescriptionQuery = await payload.find({ - collection, - sort: 'description', - where: { - title: { - like: 'accent', + localizedPost = await payload.update({ + id, + collection, + data: { + title: spanishTitle, + }, + locale: spanishLocale, + }) + }) + + it('unspecified locale returns default', async () => { + const localized = await payload.findByID({ + id: localizedPost.id, + collection, + }) + + expect(localized.title).toEqual(englishTitle) + }) + + it('specific locale - same as default', async () => { + const localized = await payload.findByID({ + id: localizedPost.id, + collection, + locale: defaultLocale, + }) + + expect(localized.title).toEqual(englishTitle) + }) + + it('specific locale - not default', async () => { + const localized = await payload.findByID({ + id: localizedPost.id, + collection, + locale: spanishLocale, + }) + + expect(localized.title).toEqual(spanishTitle) + }) + + it('all locales', async () => { + const localized: any = await payload.findByID({ + id: localizedPost.id, + collection, + locale: 'all', + }) + + expect(localized.title.en).toEqual(englishTitle) + expect(localized.title.es).toEqual(spanishTitle) + }) + + it('by localized field value - default locale', async () => { + const result = await payload.find({ + collection, + where: { + title: { + equals: englishTitle, + }, + }, + }) + + expect(result.docs.map(({ id }) => id)).toContain(localizedPost.id) + }) + + it('by localized field value - alternate locale', async () => { + const result = await payload.find({ + collection, + locale: spanishLocale, + where: { + title: { + equals: spanishTitle, + }, + }, + }) + + expect(result.docs.map(({ id }) => id)).toContain(localizedPost.id) + }) + + it('by localized field value - opposite locale???', async () => { + const result = await payload.find({ + collection, + locale: 'all', + where: { + 'title.es': { + equals: spanishTitle, + }, + }, + }) + + expect(result.docs.map(({ id }) => id)).toContain(localizedPost.id) + }) + + it('by localized field value with sorting', async () => { + const doc_1 = await payload.create({ collection, data: { title: 'word_b' } }) + const doc_2 = await payload.create({ collection, data: { title: 'word_a' } }) + const doc_3 = await payload.create({ collection, data: { title: 'word_c' } }) + + await payload.create({ collection, data: { title: 'others_c' } }) + + const { docs } = await payload.find({ + collection, + sort: 'title', + where: { + title: { + like: 'word', + }, + }, + }) + + expect(docs).toHaveLength(3) + expect(docs[0].id).toBe(doc_2.id) + expect(docs[1].id).toBe(doc_1.id) + expect(docs[2].id).toBe(doc_3.id) + }) + + if (['mongodb'].includes(process.env.PAYLOAD_DATABASE)) { + describe('Localized sorting', () => { + let localizedAccentPostOne: LocalizedPost + let localizedAccentPostTwo: LocalizedPost + beforeEach(async () => { + localizedAccentPostOne = await payload.create({ + collection, + data: { + title: 'non accent post', + localizedDescription: 'something', }, - }, - locale: hungarianLocale, + locale: englishLocale, + }) + + localizedAccentPostTwo = await payload.create({ + collection, + data: { + title: 'accent post', + localizedDescription: 'veterinarian', + }, + locale: englishLocale, + }) + + await payload.update({ + id: localizedAccentPostOne.id, + collection, + data: { + title: 'non accent post', + localizedDescription: 'valami', + }, + locale: hungarianLocale, + }) + + await payload.update({ + id: localizedAccentPostTwo.id, + collection, + data: { + title: 'accent post', + localizedDescription: 'állatorvos', + }, + locale: hungarianLocale, + }) }) - expect(sortByDescriptionQuery.docs[0].id).toEqual(localizedAccentPostTwo.id) + it('should sort alphabetically even with accented letters', async () => { + const sortByDescriptionQuery = await payload.find({ + collection, + sort: 'description', + where: { + title: { + like: 'accent', + }, + }, + locale: hungarianLocale, + }) + + expect(sortByDescriptionQuery.docs[0].id).toEqual(localizedAccentPostTwo.id) + }) }) - }) - } + } + }) }) - }) - describe('Localized Sort Count', () => { - const expectedTotalDocs = 5 - const posts: LocalizedSort[] = [] - beforeAll(async () => { - for (let i = 1; i <= expectedTotalDocs; i++) { - const post = await payload.create({ + describe('Localized Sort Count', () => { + const expectedTotalDocs = 5 + const posts: LocalizedSort[] = [] + beforeAll(async () => { + for (let i = 1; i <= expectedTotalDocs; i++) { + const post = await payload.create({ + collection: localizedSortSlug, + data: { + date: new Date().toISOString(), + title: `EN ${i}`, + }, + locale: englishLocale, + }) + + posts.push(post) + + await payload.update({ + id: post.id, + collection: localizedSortSlug, + data: { + date: new Date().toISOString(), + title: `ES ${i}`, + }, + locale: spanishLocale, + }) + } + }) + + it('should have correct totalDocs when unsorted', async () => { + const simpleQuery = await payload.find({ collection: localizedSortSlug, - data: { - date: new Date().toISOString(), - title: `EN ${i}`, - }, - locale: englishLocale, }) - - posts.push(post) - - await payload.update({ - id: post.id, + const sortByIdQuery = await payload.find({ collection: localizedSortSlug, - data: { - date: new Date().toISOString(), - title: `ES ${i}`, - }, - locale: spanishLocale, + sort: 'id', }) - } - }) - it('should have correct totalDocs when unsorted', async () => { - const simpleQuery = await payload.find({ - collection: localizedSortSlug, - }) - const sortByIdQuery = await payload.find({ - collection: localizedSortSlug, - sort: 'id', + expect(simpleQuery.totalDocs).toEqual(expectedTotalDocs) + expect(sortByIdQuery.totalDocs).toEqual(expectedTotalDocs) }) - expect(simpleQuery.totalDocs).toEqual(expectedTotalDocs) - expect(sortByIdQuery.totalDocs).toEqual(expectedTotalDocs) - }) + // https://github.com/payloadcms/payload/issues/4889 + it('should have correct totalDocs when sorted by localized fields', async () => { + const sortByTitleQuery = await payload.find({ + collection: localizedSortSlug, + sort: 'title', + }) + const sortByDateQuery = await payload.find({ + collection: localizedSortSlug, + sort: 'date', + }) - // https://github.com/payloadcms/payload/issues/4889 - it('should have correct totalDocs when sorted by localized fields', async () => { - const sortByTitleQuery = await payload.find({ - collection: localizedSortSlug, - sort: 'title', - }) - const sortByDateQuery = await payload.find({ - collection: localizedSortSlug, - sort: 'date', + console.log({ sortByTitleQuery }) + + expect(sortByTitleQuery.totalDocs).toEqual(expectedTotalDocs) + expect(sortByDateQuery.totalDocs).toEqual(expectedTotalDocs) }) - expect(sortByTitleQuery.totalDocs).toEqual(expectedTotalDocs) - expect(sortByDateQuery.totalDocs).toEqual(expectedTotalDocs) - }) + it('should return correct order when sorted by localized fields', async () => { + const { docs: docsAsc } = await payload.find({ + collection: localizedSortSlug, + sort: 'title', + }) + docsAsc.forEach((doc, i) => { + expect(posts[i].id).toBe(doc.id) + }) - it('should return correct order when sorted by localized fields', async () => { - const { docs: docsAsc } = await payload.find({ collection: localizedSortSlug, sort: 'title' }) - docsAsc.forEach((doc, i) => { - expect(posts[i].id).toBe(doc.id) - }) + const { docs: docsDesc } = await payload.find({ + collection: localizedSortSlug, + sort: '-title', + }) + docsDesc.forEach((doc, i) => { + expect(posts.at(posts.length - i - 1).id).toBe(doc.id) + }) - const { docs: docsDesc } = await payload.find({ - collection: localizedSortSlug, - sort: '-title', - }) - docsDesc.forEach((doc, i) => { - expect(posts.at(posts.length - i - 1).id).toBe(doc.id) - }) + // Test with words + const randomWords = [ + 'sunset', + 'whisper', + 'lighthouse', + 'harmony', + 'crystal', + 'thunder', + 'meadow', + 'voyage', + 'echo', + 'quicksand', + ] - // Test with words - const randomWords = [ - 'sunset', - 'whisper', - 'lighthouse', - 'harmony', - 'crystal', - 'thunder', - 'meadow', - 'voyage', - 'echo', - 'quicksand', - ] + const randomWordsSpanish = [ + 'atardecer', + 'susurro', + 'faro', + 'armonía', + 'cristal', + 'trueno', + 'pradera', + 'viaje', + 'eco', + 'arenas movedizas', + ] - const randomWordsSpanish = [ - 'atardecer', - 'susurro', - 'faro', - 'armonía', - 'cristal', - 'trueno', - 'pradera', - 'viaje', - 'eco', - 'arenas movedizas', - ] + expect(randomWords).toHaveLength(randomWordsSpanish.length) - expect(randomWords).toHaveLength(randomWordsSpanish.length) + const randomWordsPosts: (number | string)[] = [] - const randomWordsPosts: (number | string)[] = [] + for (let i = 0; i < randomWords.length; i++) { + const en = randomWords[i] + const post = await payload.create({ collection: 'localized-sort', data: { title: en } }) + const es = randomWordsSpanish[i] + await payload.update({ + collection: 'localized-sort', + data: { title: es }, + id: post.id, + locale: 'es', + }) - for (let i = 0; i < randomWords.length; i++) { - const en = randomWords[i] - const post = await payload.create({ collection: 'localized-sort', data: { title: en } }) - const es = randomWordsSpanish[i] - await payload.update({ - collection: 'localized-sort', - data: { title: es }, - id: post.id, + randomWordsPosts.push(post.id) + } + + const ascSortedWordsEn = randomWords.toSorted((a, b) => a.localeCompare(b)) + const descSortedWordsEn = randomWords.toSorted((a, b) => b.localeCompare(a)) + + const q = { id: { in: randomWordsPosts } } + + const { docs: randomWordsEnAsc } = await payload.find({ + collection: localizedSortSlug, + sort: 'title', + where: q, + }) + randomWordsEnAsc.forEach((doc, i) => { + expect(ascSortedWordsEn[i]).toBe(doc.title) + }) + + const { docs: randomWordsEnDesc } = await payload.find({ + collection: localizedSortSlug, + sort: '-title', + where: q, + }) + + randomWordsEnDesc.forEach((doc, i) => { + expect(descSortedWordsEn[i]).toBe(doc.title) + }) + + // Test sorting for Spanish locale + const ascSortedWordsEs = randomWordsSpanish.toSorted((a, b) => a.localeCompare(b)) + const descSortedWordsEs = randomWordsSpanish.toSorted((a, b) => b.localeCompare(a)) + + // Fetch sorted words in Spanish (ascending) + const { docs: randomWordsEsAsc } = await payload.find({ + collection: localizedSortSlug, + sort: 'title', + where: q, locale: 'es', }) - randomWordsPosts.push(post.id) - } + randomWordsEsAsc.forEach((doc, i) => { + expect(ascSortedWordsEs[i]).toBe(doc.title) + }) - const ascSortedWordsEn = randomWords.toSorted((a, b) => a.localeCompare(b)) - const descSortedWordsEn = randomWords.toSorted((a, b) => b.localeCompare(a)) + // Fetch sorted words in Spanish (descending) + const { docs: randomWordsEsDesc } = await payload.find({ + collection: localizedSortSlug, + sort: '-title', + where: q, + locale: 'es', + }) - const q = { id: { in: randomWordsPosts } } - - const { docs: randomWordsEnAsc } = await payload.find({ - collection: localizedSortSlug, - sort: 'title', - where: q, - }) - randomWordsEnAsc.forEach((doc, i) => { - expect(ascSortedWordsEn[i]).toBe(doc.title) - }) - - const { docs: randomWordsEnDesc } = await payload.find({ - collection: localizedSortSlug, - sort: '-title', - where: q, - }) - - randomWordsEnDesc.forEach((doc, i) => { - expect(descSortedWordsEn[i]).toBe(doc.title) - }) - - // Test sorting for Spanish locale - const ascSortedWordsEs = randomWordsSpanish.toSorted((a, b) => a.localeCompare(b)) - const descSortedWordsEs = randomWordsSpanish.toSorted((a, b) => b.localeCompare(a)) - - // Fetch sorted words in Spanish (ascending) - const { docs: randomWordsEsAsc } = await payload.find({ - collection: localizedSortSlug, - sort: 'title', - where: q, - locale: 'es', - }) - - randomWordsEsAsc.forEach((doc, i) => { - expect(ascSortedWordsEs[i]).toBe(doc.title) - }) - - // Fetch sorted words in Spanish (descending) - const { docs: randomWordsEsDesc } = await payload.find({ - collection: localizedSortSlug, - sort: '-title', - where: q, - locale: 'es', - }) - - randomWordsEsDesc.forEach((doc, i) => { - expect(descSortedWordsEs[i]).toBe(doc.title) - }) - }) - }) - - describe('Localized Relationship', () => { - let localizedRelation: LocalizedPost - let localizedRelation2: LocalizedPost - let withRelationship: WithLocalizedRelationship - - beforeAll(async () => { - localizedRelation = await createLocalizedPost({ - title: { - [defaultLocale]: relationEnglishTitle, - [spanishLocale]: relationSpanishTitle, - }, - }) - localizedRelation2 = await createLocalizedPost({ - title: { - [defaultLocale]: relationEnglishTitle2, - [spanishLocale]: relationSpanishTitle2, - }, - }) - - withRelationship = await payload.create({ - collection: withLocalizedRelSlug, - data: { - localizedRelationHasManyField: [localizedRelation.id, localizedRelation2.id], - localizedRelationMultiRelationTo: { - relationTo: localizedPostsSlug, - value: localizedRelation.id, - }, - localizedRelationMultiRelationToHasMany: [ - { relationTo: localizedPostsSlug, value: localizedRelation.id }, - { relationTo: localizedPostsSlug, value: localizedRelation2.id }, - ], - localizedRelationship: localizedRelation.id, - }, + randomWordsEsDesc.forEach((doc, i) => { + expect(descSortedWordsEs[i]).toBe(doc.title) + }) }) }) - describe('regular relationship', () => { - it('can query localized relationship', async () => { - const result = await payload.find({ - collection: withLocalizedRelSlug, - where: { - 'localizedRelationship.title': { - equals: localizedRelation.title, - }, + describe('Localized Relationship', () => { + let localizedRelation: LocalizedPost + let localizedRelation2: LocalizedPost + let withRelationship: WithLocalizedRelationship + + beforeAll(async () => { + localizedRelation = await createLocalizedPost({ + title: { + [defaultLocale]: relationEnglishTitle, + [spanishLocale]: relationSpanishTitle, + }, + }) + localizedRelation2 = await createLocalizedPost({ + title: { + [defaultLocale]: relationEnglishTitle2, + [spanishLocale]: relationSpanishTitle2, }, }) - expect(result.docs[0].id).toEqual(withRelationship.id) - }) - - it('specific locale', async () => { - const result = await payload.find({ + withRelationship = await payload.create({ collection: withLocalizedRelSlug, - locale: spanishLocale, - where: { - 'localizedRelationship.title': { - equals: relationSpanishTitle, + data: { + localizedRelationHasManyField: [localizedRelation.id, localizedRelation2.id], + localizedRelationMultiRelationTo: { + relationTo: localizedPostsSlug, + value: localizedRelation.id, }, + localizedRelationMultiRelationToHasMany: [ + { relationTo: localizedPostsSlug, value: localizedRelation.id }, + { relationTo: localizedPostsSlug, value: localizedRelation2.id }, + ], + localizedRelationship: localizedRelation.id, }, }) - - expect(result.docs[0].id).toEqual(withRelationship.id) }) - it('all locales', async () => { - const result = await payload.find({ - collection: withLocalizedRelSlug, - locale: 'all', - where: { - 'localizedRelationship.title.es': { - equals: relationSpanishTitle, + describe('regular relationship', () => { + it('can query localized relationship', async () => { + const result = await payload.find({ + collection: withLocalizedRelSlug, + where: { + 'localizedRelationship.title': { + equals: localizedRelation.title, + }, }, - }, + }) + + expect(result.docs[0].id).toEqual(withRelationship.id) }) - expect(result.docs[0].id).toEqual(withRelationship.id) - }) - - it('populates relationships with all locales', async () => { - // the relationship fields themselves are localized on this collection - const result: any = await payload.find({ - collection: relationshipLocalizedSlug, - depth: 1, - locale: 'all', - }) - - expect(result.docs[0].relationship.en.id).toBeDefined() - expect(result.docs[0].relationshipHasMany.en[0].id).toBeDefined() - expect(result.docs[0].relationMultiRelationTo.en.value.id).toBeDefined() - expect(result.docs[0].relationMultiRelationToHasMany.en[0].value.id).toBeDefined() - expect(result.docs[0].arrayField.en[0].nestedRelation.id).toBeDefined() - }) - }) - - describe('relationship - hasMany', () => { - it('default locale', async () => { - const result = await payload.find({ - collection: withLocalizedRelSlug, - where: { - 'localizedRelationHasManyField.title': { - equals: localizedRelation.title, + it('specific locale', async () => { + const result = await payload.find({ + collection: withLocalizedRelSlug, + locale: spanishLocale, + where: { + 'localizedRelationship.title': { + equals: relationSpanishTitle, + }, }, - }, + }) + + expect(result.docs[0].id).toEqual(withRelationship.id) }) - expect(result.docs.map(({ id }) => id)).toContain(withRelationship.id) - - // Second relationship - const result2 = await payload.find({ - collection: withLocalizedRelSlug, - where: { - 'localizedRelationHasManyField.title': { - equals: localizedRelation2.title, - }, - }, - }) - - expect(result2.docs.map(({ id }) => id)).toContain(withRelationship.id) - }) - - it('specific locale', async () => { - const result = await payload.find({ - collection: withLocalizedRelSlug, - locale: spanishLocale, - where: { - 'localizedRelationHasManyField.title': { - equals: relationSpanishTitle, - }, - }, - }) - - expect(result.docs[0].id).toEqual(withRelationship.id) - - // Second relationship - const result2 = await payload.find({ - collection: withLocalizedRelSlug, - locale: spanishLocale, - where: { - 'localizedRelationHasManyField.title': { - equals: relationSpanishTitle2, - }, - }, - }) - - expect(result2.docs[0].id).toEqual(withRelationship.id) - }) - - it('relationship population uses locale', async () => { - const result = await payload.findByID({ - id: withRelationship.id, - collection: withLocalizedRelSlug, - depth: 1, - locale: spanishLocale, - }) - expect((result.localizedRelationship as LocalizedPost).title).toEqual(relationSpanishTitle) - }) - - it('all locales', async () => { - const queryRelation = (where: Where) => { - return payload.find({ + it('all locales', async () => { + const result = await payload.find({ collection: withLocalizedRelSlug, locale: 'all', - where, + where: { + 'localizedRelationship.title.es': { + equals: relationSpanishTitle, + }, + }, }) - } - const result = await queryRelation({ - 'localizedRelationHasManyField.title.en': { - equals: relationEnglishTitle, - }, + expect(result.docs[0].id).toEqual(withRelationship.id) }) - expect(result.docs.map(({ id }) => id)).toContain(withRelationship.id) + it('populates relationships with all locales', async () => { + // the relationship fields themselves are localized on this collection + const result: any = await payload.find({ + collection: relationshipLocalizedSlug, + depth: 1, + locale: 'all', + }) - // First relationship - spanish - const result2 = await queryRelation({ - 'localizedRelationHasManyField.title.es': { - equals: relationSpanishTitle, - }, + expect(result.docs[0].relationship.en.id).toBeDefined() + expect(result.docs[0].relationshipHasMany.en[0].id).toBeDefined() + expect(result.docs[0].relationMultiRelationTo.en.value.id).toBeDefined() + expect(result.docs[0].relationMultiRelationToHasMany.en[0].value.id).toBeDefined() + expect(result.docs[0].arrayField.en[0].nestedRelation.id).toBeDefined() }) - - expect(result2.docs.map(({ id }) => id)).toContain(withRelationship.id) - - // Second relationship - english - const result3 = await queryRelation({ - 'localizedRelationHasManyField.title.en': { - equals: relationEnglishTitle2, - }, - }) - - expect(result3.docs.map(({ id }) => id)).toContain(withRelationship.id) - - // Second relationship - spanish - const result4 = await queryRelation({ - 'localizedRelationHasManyField.title.es': { - equals: relationSpanishTitle2, - }, - }) - - expect(result4.docs[0].id).toEqual(withRelationship.id) - }) - }) - - describe('relationTo multi', () => { - it('by id', async () => { - const result = await payload.find({ - collection: withLocalizedRelSlug, - where: { - 'localizedRelationMultiRelationTo.value': { - equals: localizedRelation.id, - }, - }, - }) - - expect(result.docs[0].id).toEqual(withRelationship.id) - - // Second relationship - const result2 = await payload.find({ - collection: withLocalizedRelSlug, - locale: spanishLocale, - where: { - 'localizedRelationMultiRelationTo.value': { - equals: localizedRelation.id, - }, - }, - }) - - expect(result2.docs[0].id).toEqual(withRelationship.id) - }) - }) - - describe('relationTo multi hasMany', () => { - it('by id', async () => { - const result = await payload.find({ - collection: withLocalizedRelSlug, - where: { - 'localizedRelationMultiRelationToHasMany.value': { - equals: localizedRelation.id, - }, - }, - }) - - expect(result.docs[0].id).toEqual(withRelationship.id) - - // First relationship - spanish locale - const result2 = await payload.find({ - collection: withLocalizedRelSlug, - locale: spanishLocale, - where: { - 'localizedRelationMultiRelationToHasMany.value': { - equals: localizedRelation.id, - }, - }, - }) - - expect(result2.docs[0].id).toEqual(withRelationship.id) - - // Second relationship - const result3 = await payload.find({ - collection: withLocalizedRelSlug, - where: { - 'localizedRelationMultiRelationToHasMany.value': { - equals: localizedRelation2.id, - }, - }, - }) - - expect(result3.docs[0].id).toEqual(withRelationship.id) - - // Second relationship - spanish locale - const result4 = await payload.find({ - collection: withLocalizedRelSlug, - where: { - 'localizedRelationMultiRelationToHasMany.value': { - equals: localizedRelation2.id, - }, - }, - }) - - expect(result4.docs[0].id).toEqual(withRelationship.id) - }) - }) - }) - - describe('Localized - arrays with nested localized fields', () => { - it('should allow moving rows and retain existing row locale data', async () => { - const globalArray: any = await payload.findGlobal({ - slug: 'global-array', }) - const reversedArrayRows = [...globalArray.array].reverse() - - const updatedGlobal = await payload.updateGlobal({ - slug: 'global-array', - data: { - array: reversedArrayRows, - }, - locale: 'all', - }) - - expect(updatedGlobal.array[0].text.en).toStrictEqual('test en 2') - expect(updatedGlobal.array[0].text.es).toStrictEqual('test es 2') - }) - }) - - describe('Localized - required', () => { - it('should update without passing all required fields', async () => { - const newDoc = await payload.create({ - collection: withRequiredLocalizedFields, - data: { - nav: { - layout: [ - { - blockType: 'text', - text: 'laiwejfilwaje', + describe('relationship - hasMany', () => { + it('default locale', async () => { + const result = await payload.find({ + collection: withLocalizedRelSlug, + where: { + 'localizedRelationHasManyField.title': { + equals: localizedRelation.title, }, - ], - }, - title: 'hello', - }, - }) + }, + }) - await payload.update({ - id: newDoc.id, - collection: withRequiredLocalizedFields, - data: { - nav: { - layout: [ - { - blockType: 'number', - number: 12, + expect(result.docs.map(({ id }) => id)).toContain(withRelationship.id) + + // Second relationship + const result2 = await payload.find({ + collection: withLocalizedRelSlug, + where: { + 'localizedRelationHasManyField.title': { + equals: localizedRelation2.title, }, - ], - }, - title: 'en espanol, big bird', - }, - locale: spanishLocale, + }, + }) + + expect(result2.docs.map(({ id }) => id)).toContain(withRelationship.id) + }) + + it('specific locale', async () => { + const result = await payload.find({ + collection: withLocalizedRelSlug, + locale: spanishLocale, + where: { + 'localizedRelationHasManyField.title': { + equals: relationSpanishTitle, + }, + }, + }) + + expect(result.docs[0].id).toEqual(withRelationship.id) + + // Second relationship + const result2 = await payload.find({ + collection: withLocalizedRelSlug, + locale: spanishLocale, + where: { + 'localizedRelationHasManyField.title': { + equals: relationSpanishTitle2, + }, + }, + }) + + expect(result2.docs[0].id).toEqual(withRelationship.id) + }) + + it('relationship population uses locale', async () => { + const result = await payload.findByID({ + id: withRelationship.id, + collection: withLocalizedRelSlug, + depth: 1, + locale: spanishLocale, + }) + expect((result.localizedRelationship as LocalizedPost).title).toEqual( + relationSpanishTitle, + ) + }) + + it('all locales', async () => { + const queryRelation = (where: Where) => { + return payload.find({ + collection: withLocalizedRelSlug, + locale: 'all', + where, + }) + } + + const result = await queryRelation({ + 'localizedRelationHasManyField.title.en': { + equals: relationEnglishTitle, + }, + }) + + expect(result.docs.map(({ id }) => id)).toContain(withRelationship.id) + + // First relationship - spanish + const result2 = await queryRelation({ + 'localizedRelationHasManyField.title.es': { + equals: relationSpanishTitle, + }, + }) + + expect(result2.docs.map(({ id }) => id)).toContain(withRelationship.id) + + // Second relationship - english + const result3 = await queryRelation({ + 'localizedRelationHasManyField.title.en': { + equals: relationEnglishTitle2, + }, + }) + + expect(result3.docs.map(({ id }) => id)).toContain(withRelationship.id) + + // Second relationship - spanish + const result4 = await queryRelation({ + 'localizedRelationHasManyField.title.es': { + equals: relationSpanishTitle2, + }, + }) + + expect(result4.docs[0].id).toEqual(withRelationship.id) + }) }) - const updatedDoc = await payload.update({ - id: newDoc.id, - collection: withRequiredLocalizedFields, - data: { - title: 'hello x2', - }, + describe('relationTo multi', () => { + it('by id', async () => { + const result = await payload.find({ + collection: withLocalizedRelSlug, + where: { + 'localizedRelationMultiRelationTo.value': { + equals: localizedRelation.id, + }, + }, + }) + + expect(result.docs[0].id).toEqual(withRelationship.id) + + // Second relationship + const result2 = await payload.find({ + collection: withLocalizedRelSlug, + locale: spanishLocale, + where: { + 'localizedRelationMultiRelationTo.value': { + equals: localizedRelation.id, + }, + }, + }) + + expect(result2.docs[0].id).toEqual(withRelationship.id) + }) }) - expect(updatedDoc.nav.layout[0].blockType).toStrictEqual('text') + describe('relationTo multi hasMany', () => { + it('by id', async () => { + const result = await payload.find({ + collection: withLocalizedRelSlug, + where: { + 'localizedRelationMultiRelationToHasMany.value': { + equals: localizedRelation.id, + }, + }, + }) - const spanishDoc = await payload.findByID({ - id: newDoc.id, - collection: withRequiredLocalizedFields, - locale: spanishLocale, + expect(result.docs[0].id).toEqual(withRelationship.id) + + // First relationship - spanish locale + const result2 = await payload.find({ + collection: withLocalizedRelSlug, + locale: spanishLocale, + where: { + 'localizedRelationMultiRelationToHasMany.value': { + equals: localizedRelation.id, + }, + }, + }) + + expect(result2.docs[0].id).toEqual(withRelationship.id) + + // Second relationship + const result3 = await payload.find({ + collection: withLocalizedRelSlug, + where: { + 'localizedRelationMultiRelationToHasMany.value': { + equals: localizedRelation2.id, + }, + }, + }) + + expect(result3.docs[0].id).toEqual(withRelationship.id) + + // Second relationship - spanish locale + const result4 = await payload.find({ + collection: withLocalizedRelSlug, + where: { + 'localizedRelationMultiRelationToHasMany.value': { + equals: localizedRelation2.id, + }, + }, + }) + + expect(result4.docs[0].id).toEqual(withRelationship.id) + }) }) - - expect(spanishDoc.nav.layout[0].blockType).toStrictEqual('number') }) - }) - describe('Localized - GraphQL', () => { - let token - let client + describe('Localized - arrays with nested localized fields', () => { + it('should allow moving rows and retain existing row locale data', async () => { + const globalArray: any = await payload.findGlobal({ + slug: 'global-array', + }) - it('should allow user to login and retrieve populated localized field', async () => { - const query = `mutation { + const reversedArrayRows = [...globalArray.array].reverse() + + const updatedGlobal = await payload.updateGlobal({ + slug: 'global-array', + data: { + array: reversedArrayRows, + }, + locale: 'all', + }) + + expect(updatedGlobal.array[0].text.en).toStrictEqual('test en 2') + expect(updatedGlobal.array[0].text.es).toStrictEqual('test es 2') + }) + }) + + describe('Localized - required', () => { + it('should update without passing all required fields', async () => { + const newDoc = await payload.create({ + collection: withRequiredLocalizedFields, + data: { + nav: { + layout: [ + { + blockType: 'text', + text: 'laiwejfilwaje', + }, + ], + }, + title: 'hello', + }, + }) + + await payload.update({ + id: newDoc.id, + collection: withRequiredLocalizedFields, + data: { + nav: { + layout: [ + { + blockType: 'number', + number: 12, + }, + ], + }, + title: 'en espanol, big bird', + }, + locale: spanishLocale, + }) + + const updatedDoc = await payload.update({ + id: newDoc.id, + collection: withRequiredLocalizedFields, + data: { + title: 'hello x2', + }, + }) + + expect(updatedDoc.nav.layout[0].blockType).toStrictEqual('text') + + const spanishDoc = await payload.findByID({ + id: newDoc.id, + collection: withRequiredLocalizedFields, + locale: spanishLocale, + }) + + expect(spanishDoc.nav.layout[0].blockType).toStrictEqual('number') + }) + }) + + describe('Localized - GraphQL', () => { + let token + let client + + it('should allow user to login and retrieve populated localized field', async () => { + const query = `mutation { loginUser(email: "dev@payloadcms.com", password: "test") { token user { @@ -911,22 +943,22 @@ describe('Localization', () => { } }` - const { data } = await restClient - .GRAPHQL_POST({ - body: JSON.stringify({ query }), - query: { locale: 'en' }, - }) - .then((res) => res.json()) - const result = data.loginUser + const { data } = await restClient + .GRAPHQL_POST({ + body: JSON.stringify({ query }), + query: { locale: 'en' }, + }) + .then((res) => res.json()) + const result = data.loginUser - expect(typeof result.token).toStrictEqual('string') - expect(typeof result.user.relation.title).toStrictEqual('string') + expect(typeof result.token).toStrictEqual('string') + expect(typeof result.user.relation.title).toStrictEqual('string') - token = result.token - }) + token = result.token + }) - it('should allow retrieval of populated localized fields within meUser', async () => { - const query = `query { + it('should allow retrieval of populated localized fields within meUser', async () => { + const query = `query { meUser { user { id @@ -937,22 +969,22 @@ describe('Localization', () => { } }` - const { data } = await restClient - .GRAPHQL_POST({ - body: JSON.stringify({ query }), - headers: { - Authorization: `JWT ${token}`, - }, - query: { locale: 'en' }, - }) - .then((res) => res.json()) - const result = data.meUser + const { data } = await restClient + .GRAPHQL_POST({ + body: JSON.stringify({ query }), + headers: { + Authorization: `JWT ${token}`, + }, + query: { locale: 'en' }, + }) + .then((res) => res.json()) + const result = data.meUser - expect(typeof result.user.relation.title).toStrictEqual('string') - }) + expect(typeof result.user.relation.title).toStrictEqual('string') + }) - it('should create and update collections', async () => { - const create = `mutation { + it('should create and update collections', async () => { + const create = `mutation { createLocalizedPost( data: { title: "${englishTitle}" @@ -964,18 +996,18 @@ describe('Localization', () => { } }` - const { data } = await restClient - .GRAPHQL_POST({ - body: JSON.stringify({ query: create }), - headers: { - Authorization: `JWT ${token}`, - }, - query: { locale: 'en' }, - }) - .then((res) => res.json()) - const createResult = data.createLocalizedPost + const { data } = await restClient + .GRAPHQL_POST({ + body: JSON.stringify({ query: create }), + headers: { + Authorization: `JWT ${token}`, + }, + query: { locale: 'en' }, + }) + .then((res) => res.json()) + const createResult = data.createLocalizedPost - const update = `mutation { + const update = `mutation { updateLocalizedPost( id: ${payload.db.defaultIDType === 'number' ? createResult.id : `"${createResult.id}"`}, data: { @@ -987,45 +1019,45 @@ describe('Localization', () => { } }` - const { data: updateData } = await restClient - .GRAPHQL_POST({ - body: JSON.stringify({ query: update }), - headers: { - Authorization: `JWT ${token}`, - }, - query: { locale: 'en' }, + const { data: updateData } = await restClient + .GRAPHQL_POST({ + body: JSON.stringify({ query: update }), + headers: { + Authorization: `JWT ${token}`, + }, + query: { locale: 'en' }, + }) + .then((res) => res.json()) + const updateResult = updateData.updateLocalizedPost + + const result = await payload.findByID({ + id: createResult.id, + collection: localizedPostsSlug, + locale: 'all', }) - .then((res) => res.json()) - const updateResult = updateData.updateLocalizedPost - const result = await payload.findByID({ - id: createResult.id, - collection: localizedPostsSlug, - locale: 'all', + expect(createResult.title).toStrictEqual(englishTitle) + expect(updateResult.title).toStrictEqual(spanishTitle) + expect(result.title[defaultLocale]).toStrictEqual(englishTitle) + expect(result.title[spanishLocale]).toStrictEqual(spanishTitle) }) - expect(createResult.title).toStrictEqual(englishTitle) - expect(updateResult.title).toStrictEqual(spanishTitle) - expect(result.title[defaultLocale]).toStrictEqual(englishTitle) - expect(result.title[spanishLocale]).toStrictEqual(spanishTitle) - }) - - it('should query multiple locales', async () => { - const englishDoc = await payload.create({ - collection: localizedPostsSlug, - data: { - title: englishTitle, - }, - locale: defaultLocale, - }) - const spanishDoc = await payload.create({ - collection: localizedPostsSlug, - data: { - title: spanishTitle, - }, - locale: spanishLocale, - }) - const query = ` + it('should query multiple locales', async () => { + const englishDoc = await payload.create({ + collection: localizedPostsSlug, + data: { + title: englishTitle, + }, + locale: defaultLocale, + }) + const spanishDoc = await payload.create({ + collection: localizedPostsSlug, + data: { + title: spanishTitle, + }, + locale: spanishLocale, + }) + const query = ` { es: LocalizedPost(id: ${idToString(spanishDoc.id, payload)}, locale: es) { title @@ -1036,1387 +1068,1498 @@ describe('Localization', () => { } ` - const { data: multipleLocaleData } = await restClient - .GRAPHQL_POST({ - body: JSON.stringify({ query }), - headers: { - Authorization: `JWT ${token}`, - }, - query: { locale: 'en' }, - }) - .then((res) => res.json()) - - const { en, es } = multipleLocaleData - - expect(en.title).toStrictEqual(englishTitle) - expect(es.title).toStrictEqual(spanishTitle) - }) - }) - - describe('Localized - Arrays', () => { - let docID - - beforeAll(async () => { - const englishDoc = await payload.create({ - collection: arrayCollectionSlug, - data: { - items: [ - { - text: englishTitle, + const { data: multipleLocaleData } = await restClient + .GRAPHQL_POST({ + body: JSON.stringify({ query }), + headers: { + Authorization: `JWT ${token}`, }, - ], - }, - }) + query: { locale: 'en' }, + }) + .then((res) => res.json()) - docID = englishDoc.id + const { en, es } = multipleLocaleData + + expect(en.title).toStrictEqual(englishTitle) + expect(es.title).toStrictEqual(spanishTitle) + }) }) - it('should use default locale as fallback', async () => { - const spanishDoc = await payload.findByID({ - id: docID, - collection: arrayCollectionSlug, - locale: spanishLocale, - }) + describe('Localized - Arrays', () => { + let docID - expect(spanishDoc.items[0].text).toStrictEqual(englishTitle) - }) - - it('should use empty array as value', async () => { - const updatedSpanishDoc = await payload.update({ - id: docID, - collection: arrayCollectionSlug, - data: { - items: [], - }, - fallbackLocale: null, - locale: spanishLocale, - }) - - expect(updatedSpanishDoc.items).toStrictEqual([]) - }) - - it('should use fallback value if setting null', async () => { - await payload.update({ - id: docID, - collection: arrayCollectionSlug, - data: { - items: [], - }, - locale: spanishLocale, - }) - - const updatedSpanishDoc = await payload.update({ - id: docID, - collection: arrayCollectionSlug, - data: { - items: null, - }, - locale: spanishLocale, - }) - - // should return the value of the fallback locale - expect(updatedSpanishDoc.items[0].text).toStrictEqual(englishTitle) - }) - }) - - describe('Localized - Field Paths', () => { - it('should allow querying by non-localized field names ending in a locale', async () => { - await payload.update({ - id: post1.id, - collection, - data: { - children: post1.id, - group: { - children: 'something', + beforeAll(async () => { + const englishDoc = await payload.create({ + collection: arrayCollectionSlug, + data: { + items: [ + { + text: englishTitle, + }, + ], }, - }, + }) + + docID = englishDoc.id }) - const { docs: relationshipDocs } = await restClient - .GET(`/${collection}`, { - query: { - where: { - children: { - in: post1.id, + it('should use default locale as fallback', async () => { + const spanishDoc = await payload.findByID({ + id: docID, + collection: arrayCollectionSlug, + locale: spanishLocale, + }) + + expect(spanishDoc.items[0].text).toStrictEqual(englishTitle) + }) + + it('should use empty array as value', async () => { + const updatedSpanishDoc = await payload.update({ + id: docID, + collection: arrayCollectionSlug, + data: { + items: [], + }, + fallbackLocale: 'none', + locale: spanishLocale, + }) + + expect(updatedSpanishDoc.items).toStrictEqual([]) + }) + + it('should use fallback value if setting null', async () => { + await payload.update({ + id: docID, + collection: arrayCollectionSlug, + data: { + items: [], + }, + locale: spanishLocale, + }) + + const updatedSpanishDoc = await payload.update({ + id: docID, + collection: arrayCollectionSlug, + data: { + items: null, + }, + locale: spanishLocale, + }) + + // should return the value of the fallback locale + expect(updatedSpanishDoc.items[0].text).toStrictEqual(englishTitle) + }) + }) + + describe('Localized - Field Paths', () => { + it('should allow querying by non-localized field names ending in a locale', async () => { + await payload.update({ + id: post1.id, + collection, + data: { + children: post1.id, + group: { + children: 'something', + }, + }, + }) + + const { docs: relationshipDocs } = await restClient + .GET(`/${collection}`, { + query: { + where: { + children: { + in: post1.id, + }, }, }, - }, - }) - .then((res) => res.json()) + }) + .then((res) => res.json()) - expect(relationshipDocs.map(({ id }) => id)).toContain(post1.id) + expect(relationshipDocs.map(({ id }) => id)).toContain(post1.id) - const { docs: nestedFieldDocs } = await restClient - .GET(`/${collection}`, { - query: { - where: { - 'group.children': { - contains: 'some', + const { docs: nestedFieldDocs } = await restClient + .GET(`/${collection}`, { + query: { + where: { + 'group.children': { + contains: 'some', + }, }, }, - }, - }) - .then((res) => res.json()) + }) + .then((res) => res.json()) - expect(nestedFieldDocs.map(({ id }) => id)).toContain(post1.id) + expect(nestedFieldDocs.map(({ id }) => id)).toContain(post1.id) + }) }) - }) - describe('Nested To Array And Block', () => { - it('should be equal to the created document', async () => { - const { id, blocks } = await payload.create({ - collection: nestedToArrayAndBlockCollectionSlug, - data: { - blocks: [ - { - array: [ + describe('Nested To Array And Block', () => { + it('should be equal to the created document', async () => { + const { id, blocks } = await payload.create({ + collection: nestedToArrayAndBlockCollectionSlug, + data: { + blocks: [ + { + array: [ + { + text: 'english', + textNotLocalized: 'test', + }, + ], + blockType: 'block', + }, + ], + }, + locale: defaultLocale, + }) + + await payload.update({ + id, + collection: nestedToArrayAndBlockCollectionSlug, + data: { + blocks: (blocks as { array: { text: string }[] }[]).map((block) => ({ + ...block, + array: block.array.map((item) => ({ ...item, text: 'spanish' })), + })), + }, + locale: spanishLocale, + }) + + const docDefaultLocale = await payload.findByID({ + id, + collection: nestedToArrayAndBlockCollectionSlug, + locale: defaultLocale, + }) + + const docSpanishLocale = await payload.findByID({ + id, + collection: nestedToArrayAndBlockCollectionSlug, + locale: spanishLocale, + }) + + const rowDefault = docDefaultLocale.blocks[0].array[0] + const rowSpanish = docSpanishLocale.blocks[0].array[0] + + expect(rowDefault.text).toEqual('english') + expect(rowDefault.textNotLocalized).toEqual('test') + expect(rowSpanish.text).toEqual('spanish') + expect(rowSpanish.textNotLocalized).toEqual('test') + }) + }) + + describe('Duplicate Collection', () => { + it('should duplicate localized document', async () => { + const localizedPost = await payload.create({ + collection: localizedPostsSlug, + data: { + localizedCheckbox: true, + title: englishTitle, + }, + locale: defaultLocale, + }) + + const id = localizedPost.id.toString() + + await payload.update({ + id, + collection: localizedPostsSlug, + data: { + localizedCheckbox: false, + title: spanishTitle, + }, + locale: spanishLocale, + }) + + const result = await payload.duplicate({ + id, + collection: localizedPostsSlug, + locale: defaultLocale, + }) + + const allLocales = await payload.findByID({ + id: result.id, + collection: localizedPostsSlug, + locale: 'all', + }) + + // check fields + expect(result.title).toStrictEqual(englishTitle) + + expect(allLocales.title.es).toStrictEqual(spanishTitle) + + expect(allLocales.localizedCheckbox.en).toBeTruthy() + expect(allLocales.localizedCheckbox.es).toBeFalsy() + }) + + it('should duplicate with localized blocks', async () => { + // This test covers a few things: + // 1. make sure we can duplicate localized blocks + // - in relational DBs, we need to create new block / array IDs + // - and this needs to be done recursively for all block / array fields + // 2. make sure localized arrays / blocks work inside of localized groups / tabs + // - this is covered with myTab.group.nestedArray2 + // 3. the field schema for `nav` is within an unnamed tab, which tests that we + // properly recursively loop through all field structures / types + + const englishText = 'english' + const spanishText = 'spanish' + const doc = await payload.create({ + collection: withRequiredLocalizedFields, + data: { + nav: { + layout: [ { - text: 'english', - textNotLocalized: 'test', + blockType: 'text', + text: englishText, + nestedArray: [ + { + text: 'hello', + l2: [ + { + l3: [ + { + l4: [ + { + superNestedText: 'hello', + }, + ], + }, + ], + }, + ], + }, + { + text: 'goodbye', + l2: [ + { + l3: [ + { + l4: [ + { + superNestedText: 'goodbye', + }, + ], + }, + ], + }, + ], + }, + ], }, ], - blockType: 'block', }, - ], - }, - locale: defaultLocale, - }) - - await payload.update({ - id, - collection: nestedToArrayAndBlockCollectionSlug, - data: { - blocks: (blocks as { array: { text: string }[] }[]).map((block) => ({ - ...block, - array: block.array.map((item) => ({ ...item, text: 'spanish' })), - })), - }, - locale: spanishLocale, - }) - - const docDefaultLocale = await payload.findByID({ - id, - collection: nestedToArrayAndBlockCollectionSlug, - locale: defaultLocale, - }) - - const docSpanishLocale = await payload.findByID({ - id, - collection: nestedToArrayAndBlockCollectionSlug, - locale: spanishLocale, - }) - - const rowDefault = docDefaultLocale.blocks[0].array[0] - const rowSpanish = docSpanishLocale.blocks[0].array[0] - - expect(rowDefault.text).toEqual('english') - expect(rowDefault.textNotLocalized).toEqual('test') - expect(rowSpanish.text).toEqual('spanish') - expect(rowSpanish.textNotLocalized).toEqual('test') - }) - }) - - describe('Duplicate Collection', () => { - it('should duplicate localized document', async () => { - const localizedPost = await payload.create({ - collection: localizedPostsSlug, - data: { - localizedCheckbox: true, - title: englishTitle, - }, - locale: defaultLocale, - }) - - const id = localizedPost.id.toString() - - await payload.update({ - id, - collection: localizedPostsSlug, - data: { - localizedCheckbox: false, - title: spanishTitle, - }, - locale: spanishLocale, - }) - - const result = await payload.duplicate({ - id, - collection: localizedPostsSlug, - locale: defaultLocale, - }) - - const allLocales = await payload.findByID({ - id: result.id, - collection: localizedPostsSlug, - locale: 'all', - }) - - // check fields - expect(result.title).toStrictEqual(englishTitle) - - expect(allLocales.title.es).toStrictEqual(spanishTitle) - - expect(allLocales.localizedCheckbox.en).toBeTruthy() - expect(allLocales.localizedCheckbox.es).toBeFalsy() - }) - - it('should duplicate with localized blocks', async () => { - // This test covers a few things: - // 1. make sure we can duplicate localized blocks - // - in relational DBs, we need to create new block / array IDs - // - and this needs to be done recursively for all block / array fields - // 2. make sure localized arrays / blocks work inside of localized groups / tabs - // - this is covered with myTab.group.nestedArray2 - // 3. the field schema for `nav` is within an unnamed tab, which tests that we - // properly recursively loop through all field structures / types - - const englishText = 'english' - const spanishText = 'spanish' - const doc = await payload.create({ - collection: withRequiredLocalizedFields, - data: { - nav: { - layout: [ - { - blockType: 'text', - text: englishText, - nestedArray: [ + myTab: { + text: 'hello', + group: { + nestedText: 'hello', + nestedArray2: [ { + nestedText: 'hello', + }, + { + nestedText: 'goodbye', + }, + ], + }, + }, + title: 'hello', + }, + locale: defaultLocale, + }) + + await payload.update({ + id: doc.id, + collection: withRequiredLocalizedFields, + data: { + nav: { + layout: [ + { + blockType: 'text', + text: spanishText, + nestedArray: [ + { + text: 'hola', + l2: [ + { + l3: [ + { + l4: [ + { + superNestedText: 'hola', + }, + ], + }, + ], + }, + ], + }, + { + text: 'adios', + l2: [ + { + l3: [ + { + l4: [ + { + superNestedText: 'adios', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + title: 'hello', + myTab: { + text: 'hola', + group: { + nestedText: 'hola', + nestedArray2: [ + { + nestedText: 'hola', + }, + { + nestedText: 'adios', + }, + ], + }, + }, + }, + locale: spanishLocale, + }) + + const result = await payload.duplicate({ + id: doc.id, + collection: withRequiredLocalizedFields, + locale: defaultLocale, + }) + + const allLocales = await payload.findByID({ + id: result.id, + collection: withRequiredLocalizedFields, + locale: 'all', + }) + + // check fields + expect(result.nav.layout[0].text).toStrictEqual(englishText) + + expect(allLocales.nav.layout.en[0].text).toStrictEqual(englishText) + expect(allLocales.nav.layout.es[0].text).toStrictEqual(spanishText) + + expect(allLocales.myTab.group.en.nestedText).toStrictEqual('hello') + expect(allLocales.myTab.group.en.nestedArray2[0].nestedText).toStrictEqual('hello') + expect(allLocales.myTab.group.en.nestedArray2[1].nestedText).toStrictEqual('goodbye') + + expect(allLocales.myTab.group.es.nestedText).toStrictEqual('hola') + expect(allLocales.myTab.group.es.nestedArray2[0].nestedText).toStrictEqual('hola') + expect(allLocales.myTab.group.es.nestedArray2[1].nestedText).toStrictEqual('adios') + }) + }) + + describe('Localized group and tabs', () => { + it('should properly create/update/read localized group field', async () => { + const result = await payload.create({ + collection: groupSlug, + data: { + groupLocalized: { + title: 'hello en', + }, + }, + locale: englishLocale, + }) + + expect(result.groupLocalized?.title).toBe('hello en') + + await payload.update({ + collection: groupSlug, + locale: spanishLocale, + id: result.id, + data: { + groupLocalized: { + title: 'hello es', + }, + }, + }) + + const docEn = await payload.findByID({ + collection: groupSlug, + locale: englishLocale, + id: result.id, + }) + const docEs = await payload.findByID({ + collection: groupSlug, + locale: spanishLocale, + id: result.id, + }) + + expect(docEn.groupLocalized.title).toBe('hello en') + expect(docEs.groupLocalized.title).toBe('hello es') + }) + + it('should properly create/update/read localized field inside of group', async () => { + const result = await payload.create({ + collection: groupSlug, + locale: englishLocale, + data: { + group: { + title: 'hello en', + }, + }, + }) + + expect(result.group.title).toBe('hello en') + + await payload.update({ + collection: groupSlug, + locale: spanishLocale, + id: result.id, + data: { + group: { + title: 'hello es', + }, + }, + }) + + const docEn = await payload.findByID({ + collection: groupSlug, + locale: englishLocale, + id: result.id, + }) + const docEs = await payload.findByID({ + collection: groupSlug, + locale: spanishLocale, + id: result.id, + }) + + expect(docEn.group.title).toBe('hello en') + expect(docEs.group.title).toBe('hello es') + }) + + it('should properly create/update/read deep localized field inside of group', async () => { + const result = await payload.create({ + collection: groupSlug, + locale: englishLocale, + data: { + deep: { + blocks: [ + { + blockType: 'first', + title: 'hello en', + }, + ], + array: [{ title: 'hello en' }], + }, + }, + }) + + expect(result.deep.array[0].title).toBe('hello en') + + await payload.update({ + collection: groupSlug, + locale: spanishLocale, + id: result.id, + data: { + deep: { + blocks: [ + { + blockType: 'first', + title: 'hello es', + id: result.deep.blocks[0].id, + }, + ], + array: [ + { + id: result.deep.array[0].id, + title: 'hello es', + }, + ], + }, + }, + }) + + const docEn = await payload.findByID({ + collection: groupSlug, + locale: englishLocale, + id: result.id, + }) + const docEs = await payload.findByID({ + collection: groupSlug, + locale: spanishLocale, + id: result.id, + }) + + expect(docEn.deep.array[0].title).toBe('hello en') + expect(docEn.deep.blocks[0].title).toBe('hello en') + expect(docEs.deep.array[0].title).toBe('hello es') + expect(docEs.deep.blocks[0].title).toBe('hello es') + }) + + it('should create/updated/read localized group with row field', async () => { + const doc = await payload.create({ + collection: 'groups', + data: { + groupLocalizedRow: { + text: 'hello world', + }, + }, + locale: 'en', + }) + + expect(doc.groupLocalizedRow.text).toBe('hello world') + + const docES = await payload.update({ + collection: 'groups', + data: { + groupLocalizedRow: { + text: 'hola world or something', + }, + }, + locale: 'es', + id: doc.id, + }) + + expect(docES.groupLocalizedRow.text).toBe('hola world or something') + + // check if docES didnt break EN + const docEN = await payload.findByID({ collection: 'groups', id: doc.id, locale: 'en' }) + expect(docEN.groupLocalizedRow.text).toBe('hello world') + + const all = await payload.findByID({ collection: 'groups', id: doc.id, locale: 'all' }) + + expect(all.groupLocalizedRow.en.text).toBe('hello world') + expect(all.groupLocalizedRow.es.text).toBe('hola world or something') + }) + + it('should properly create/update/read localized tab field', async () => { + const result = await payload.create({ + collection: tabSlug, + locale: englishLocale, + data: { + tabLocalized: { + title: 'hello en', + }, + }, + }) + + expect(result.tabLocalized?.title).toBe('hello en') + + await payload.update({ + collection: tabSlug, + locale: spanishLocale, + id: result.id, + data: { + tabLocalized: { + title: 'hello es', + }, + }, + }) + + const docEn = await payload.findByID({ + collection: tabSlug, + locale: englishLocale, + id: result.id, + }) + + const docEs = await payload.findByID({ + collection: tabSlug, + locale: spanishLocale, + id: result.id, + }) + + expect(docEn.tabLocalized.title).toBe('hello en') + expect(docEs.tabLocalized.title).toBe('hello es') + }) + + it('should properly create/update/read localized field inside of tab', async () => { + const result = await payload.create({ + collection: tabSlug, + locale: englishLocale, + data: { + tab: { + title: 'hello en', + }, + }, + }) + + expect(result.tab.title).toBe('hello en') + + await payload.update({ + collection: tabSlug, + locale: spanishLocale, + id: result.id, + data: { + tab: { + title: 'hello es', + }, + }, + }) + + const docEn = await payload.findByID({ + collection: tabSlug, + locale: englishLocale, + id: result.id, + }) + const docEs = await payload.findByID({ + collection: tabSlug, + locale: spanishLocale, + id: result.id, + }) + + expect(docEn.tab.title).toBe('hello en') + expect(docEs.tab.title).toBe('hello es') + }) + + it('should properly create/update/read deep localized field inside of tab', async () => { + const result = await payload.create({ + collection: tabSlug, + locale: englishLocale, + data: { + deep: { + blocks: [ + { + blockType: 'first', + title: 'hello en', + }, + ], + array: [{ title: 'hello en' }], + }, + }, + }) + + expect(result.deep.array[0].title).toBe('hello en') + + await payload.update({ + collection: tabSlug, + locale: spanishLocale, + id: result.id, + data: { + deep: { + blocks: [ + { + blockType: 'first', + title: 'hello es', + id: result.deep.blocks[0].id, + }, + ], + array: [ + { + id: result.deep.array[0].id, + title: 'hello es', + }, + ], + }, + }, + }) + + const docEn = await payload.findByID({ + collection: tabSlug, + locale: englishLocale, + id: result.id, + }) + const docEs = await payload.findByID({ + collection: tabSlug, + locale: spanishLocale, + id: result.id, + }) + + expect(docEn.deep.array[0].title).toBe('hello en') + expect(docEn.deep.blocks[0].title).toBe('hello en') + expect(docEs.deep.array[0].title).toBe('hello es') + expect(docEs.deep.blocks[0].title).toBe('hello es') + }) + }) + + describe('nested localized field sanitization', () => { + it('should sanitize nested localized fields', () => { + const collection = payload.collections['localized-within-localized'].config + + expect(collection.fields[0].tabs[0].fields[0].localized).toBeUndefined() + expect(collection.fields[1].fields[0].localized).toBeUndefined() + expect(collection.fields[2].blocks[0].fields[0].localized).toBeUndefined() + expect(collection.fields[3].fields[0].localized).toBeUndefined() + }) + }) + + describe('nested blocks', () => { + let id + it('should allow creating nested blocks per locale', async () => { + const doc = await payload.create({ + collection: 'blocks-fields', + data: { + content: [ + { + blockType: 'blockInsideBlock', + array: [ + { + link: { + label: 'English 1', + }, + }, + { + link: { + label: 'English 2', + }, + }, + ], + content: [ + { + blockType: 'textBlock', text: 'hello', - l2: [ - { - l3: [ - { - l4: [ - { - superNestedText: 'hello', - }, - ], - }, - ], - }, - ], - }, - { - text: 'goodbye', - l2: [ - { - l3: [ - { - l4: [ - { - superNestedText: 'goodbye', - }, - ], - }, - ], - }, - ], }, ], }, ], }, - myTab: { - text: 'hello', - group: { - nestedText: 'hello', - nestedArray2: [ - { - nestedText: 'hello', - }, - { - nestedText: 'goodbye', - }, - ], - }, - }, - title: 'hello', - }, - locale: defaultLocale, - }) + }) - await payload.update({ - id: doc.id, - collection: withRequiredLocalizedFields, - data: { - nav: { - layout: [ + id = doc.id + + const retrievedInEN = await payload.findByID({ + collection: 'blocks-fields', + id, + }) + + await payload.update({ + collection: 'blocks-fields', + id, + locale: 'es', + data: { + content: [ { - blockType: 'text', - text: spanishText, - nestedArray: [ + blockType: 'blockInsideBlock', + array: [ { + link: { + label: 'Spanish 1', + }, + }, + { + link: { + label: 'Spanish 2', + }, + }, + ], + content: [ + { + blockType: 'textBlock', text: 'hola', - l2: [ - { - l3: [ - { - l4: [ - { - superNestedText: 'hola', - }, - ], - }, - ], - }, - ], - }, - { - text: 'adios', - l2: [ - { - l3: [ - { - l4: [ - { - superNestedText: 'adios', - }, - ], - }, - ], - }, - ], }, ], }, ], }, - title: 'hello', - myTab: { - text: 'hola', - group: { - nestedText: 'hola', - nestedArray2: [ - { - nestedText: 'hola', - }, - { - nestedText: 'adios', - }, - ], - }, - }, - }, - locale: spanishLocale, - }) - - const result = await payload.duplicate({ - id: doc.id, - collection: withRequiredLocalizedFields, - locale: defaultLocale, - }) - - const allLocales = await payload.findByID({ - id: result.id, - collection: withRequiredLocalizedFields, - locale: 'all', - }) - - // check fields - expect(result.nav.layout[0].text).toStrictEqual(englishText) - - expect(allLocales.nav.layout.en[0].text).toStrictEqual(englishText) - expect(allLocales.nav.layout.es[0].text).toStrictEqual(spanishText) - - expect(allLocales.myTab.group.en.nestedText).toStrictEqual('hello') - expect(allLocales.myTab.group.en.nestedArray2[0].nestedText).toStrictEqual('hello') - expect(allLocales.myTab.group.en.nestedArray2[1].nestedText).toStrictEqual('goodbye') - - expect(allLocales.myTab.group.es.nestedText).toStrictEqual('hola') - expect(allLocales.myTab.group.es.nestedArray2[0].nestedText).toStrictEqual('hola') - expect(allLocales.myTab.group.es.nestedArray2[1].nestedText).toStrictEqual('adios') - }) - }) - - describe('Localized group and tabs', () => { - it('should properly create/update/read localized group field', async () => { - const result = await payload.create({ - collection: groupSlug, - data: { - groupLocalized: { - title: 'hello en', - }, - }, - locale: englishLocale, - }) - - expect(result.groupLocalized?.title).toBe('hello en') - - await payload.update({ - collection: groupSlug, - locale: spanishLocale, - id: result.id, - data: { - groupLocalized: { - title: 'hello es', - }, - }, - }) - - const docEn = await payload.findByID({ - collection: groupSlug, - locale: englishLocale, - id: result.id, - }) - const docEs = await payload.findByID({ - collection: groupSlug, - locale: spanishLocale, - id: result.id, - }) - - expect(docEn.groupLocalized.title).toBe('hello en') - expect(docEs.groupLocalized.title).toBe('hello es') - }) - - it('should properly create/update/read localized field inside of group', async () => { - const result = await payload.create({ - collection: groupSlug, - locale: englishLocale, - data: { - group: { - title: 'hello en', - }, - }, - }) - - expect(result.group.title).toBe('hello en') - - await payload.update({ - collection: groupSlug, - locale: spanishLocale, - id: result.id, - data: { - group: { - title: 'hello es', - }, - }, - }) - - const docEn = await payload.findByID({ - collection: groupSlug, - locale: englishLocale, - id: result.id, - }) - const docEs = await payload.findByID({ - collection: groupSlug, - locale: spanishLocale, - id: result.id, - }) - - expect(docEn.group.title).toBe('hello en') - expect(docEs.group.title).toBe('hello es') - }) - - it('should properly create/update/read deep localized field inside of group', async () => { - const result = await payload.create({ - collection: groupSlug, - locale: englishLocale, - data: { - deep: { - blocks: [ - { - blockType: 'first', - title: 'hello en', - }, - ], - array: [{ title: 'hello en' }], - }, - }, - }) - - expect(result.deep.array[0].title).toBe('hello en') - - await payload.update({ - collection: groupSlug, - locale: spanishLocale, - id: result.id, - data: { - deep: { - blocks: [ - { - blockType: 'first', - title: 'hello es', - id: result.deep.blocks[0].id, - }, - ], - array: [ - { - id: result.deep.array[0].id, - title: 'hello es', - }, - ], - }, - }, - }) - - const docEn = await payload.findByID({ - collection: groupSlug, - locale: englishLocale, - id: result.id, - }) - const docEs = await payload.findByID({ - collection: groupSlug, - locale: spanishLocale, - id: result.id, - }) - - expect(docEn.deep.array[0].title).toBe('hello en') - expect(docEn.deep.blocks[0].title).toBe('hello en') - expect(docEs.deep.array[0].title).toBe('hello es') - expect(docEs.deep.blocks[0].title).toBe('hello es') - }) - - it('should create/updated/read localized group with row field', async () => { - const doc = await payload.create({ - collection: 'groups', - data: { - groupLocalizedRow: { - text: 'hello world', - }, - }, - locale: 'en', - }) - - expect(doc.groupLocalizedRow.text).toBe('hello world') - - const docES = await payload.update({ - collection: 'groups', - data: { - groupLocalizedRow: { - text: 'hola world or something', - }, - }, - locale: 'es', - id: doc.id, - }) - - expect(docES.groupLocalizedRow.text).toBe('hola world or something') - - // check if docES didnt break EN - const docEN = await payload.findByID({ collection: 'groups', id: doc.id, locale: 'en' }) - expect(docEN.groupLocalizedRow.text).toBe('hello world') - - const all = await payload.findByID({ collection: 'groups', id: doc.id, locale: 'all' }) - - expect(all.groupLocalizedRow.en.text).toBe('hello world') - expect(all.groupLocalizedRow.es.text).toBe('hola world or something') - }) - - it('should properly create/update/read localized tab field', async () => { - const result = await payload.create({ - collection: tabSlug, - locale: englishLocale, - data: { - tabLocalized: { - title: 'hello en', - }, - }, - }) - - expect(result.tabLocalized?.title).toBe('hello en') - - await payload.update({ - collection: tabSlug, - locale: spanishLocale, - id: result.id, - data: { - tabLocalized: { - title: 'hello es', - }, - }, - }) - - const docEn = await payload.findByID({ - collection: tabSlug, - locale: englishLocale, - id: result.id, - }) - - const docEs = await payload.findByID({ - collection: tabSlug, - locale: spanishLocale, - id: result.id, - }) - - expect(docEn.tabLocalized.title).toBe('hello en') - expect(docEs.tabLocalized.title).toBe('hello es') - }) - - it('should properly create/update/read localized field inside of tab', async () => { - const result = await payload.create({ - collection: tabSlug, - locale: englishLocale, - data: { - tab: { - title: 'hello en', - }, - }, - }) - - expect(result.tab.title).toBe('hello en') - - await payload.update({ - collection: tabSlug, - locale: spanishLocale, - id: result.id, - data: { - tab: { - title: 'hello es', - }, - }, - }) - - const docEn = await payload.findByID({ - collection: tabSlug, - locale: englishLocale, - id: result.id, - }) - const docEs = await payload.findByID({ - collection: tabSlug, - locale: spanishLocale, - id: result.id, - }) - - expect(docEn.tab.title).toBe('hello en') - expect(docEs.tab.title).toBe('hello es') - }) - - it('should properly create/update/read deep localized field inside of tab', async () => { - const result = await payload.create({ - collection: tabSlug, - locale: englishLocale, - data: { - deep: { - blocks: [ - { - blockType: 'first', - title: 'hello en', - }, - ], - array: [{ title: 'hello en' }], - }, - }, - }) - - expect(result.deep.array[0].title).toBe('hello en') - - await payload.update({ - collection: tabSlug, - locale: spanishLocale, - id: result.id, - data: { - deep: { - blocks: [ - { - blockType: 'first', - title: 'hello es', - id: result.deep.blocks[0].id, - }, - ], - array: [ - { - id: result.deep.array[0].id, - title: 'hello es', - }, - ], - }, - }, - }) - - const docEn = await payload.findByID({ - collection: tabSlug, - locale: englishLocale, - id: result.id, - }) - const docEs = await payload.findByID({ - collection: tabSlug, - locale: spanishLocale, - id: result.id, - }) - - expect(docEn.deep.array[0].title).toBe('hello en') - expect(docEn.deep.blocks[0].title).toBe('hello en') - expect(docEs.deep.array[0].title).toBe('hello es') - expect(docEs.deep.blocks[0].title).toBe('hello es') - }) - }) - - describe('nested localized field sanitization', () => { - it('should sanitize nested localized fields', () => { - const collection = payload.collections['localized-within-localized'].config - - expect(collection.fields[0].tabs[0].fields[0].localized).toBeUndefined() - expect(collection.fields[1].fields[0].localized).toBeUndefined() - expect(collection.fields[2].blocks[0].fields[0].localized).toBeUndefined() - expect(collection.fields[3].fields[0].localized).toBeUndefined() - }) - }) - - describe('nested blocks', () => { - let id - it('should allow creating nested blocks per locale', async () => { - const doc = await payload.create({ - collection: 'blocks-fields', - data: { - content: [ - { - blockType: 'blockInsideBlock', - array: [ - { - link: { - label: 'English 1', - }, - }, - { - link: { - label: 'English 2', - }, - }, - ], - content: [ - { - blockType: 'textBlock', - text: 'hello', - }, - ], - }, - ], - }, - }) - - id = doc.id - - const retrievedInEN = await payload.findByID({ - collection: 'blocks-fields', - id, - }) - - await payload.update({ - collection: 'blocks-fields', - id, - locale: 'es', - data: { - content: [ - { - blockType: 'blockInsideBlock', - array: [ - { - link: { - label: 'Spanish 1', - }, - }, - { - link: { - label: 'Spanish 2', - }, - }, - ], - content: [ - { - blockType: 'textBlock', - text: 'hola', - }, - ], - }, - ], - }, - }) - - const retrieved = await payload.findByID({ - collection: 'blocks-fields', - id, - locale: 'all', - }) - - expect(retrieved.content.en[0].content).toHaveLength(1) - expect(retrieved.content.es[0].content).toHaveLength(1) - - expect(retrieved.content.en[0].array[0].link.label).toStrictEqual('English 1') - expect(retrieved.content.en[0].array[1].link.label).toStrictEqual('English 2') - - expect(retrieved.content.es[0].array[0].link.label).toStrictEqual('Spanish 1') - expect(retrieved.content.es[0].array[1].link.label).toStrictEqual('Spanish 2') - }) - }) - - describe('nested arrays', () => { - it('should not duplicate block rows for blocks within localized array fields', async () => { - const randomDoc = ( - await payload.find({ - collection: 'localized-posts', - depth: 0, }) - ).docs[0] - const randomDoc2 = ( - await payload.find({ - collection: 'localized-posts', - depth: 0, + const retrieved = await payload.findByID({ + collection: 'blocks-fields', + id, + locale: 'all', }) - ).docs[1] - const blocksWithinArrayEN = [ - { - blockName: '1', - blockType: 'someBlock', - relationWithinBlock: randomDoc.id, - myGroup: { - text: 'hello in english 1', - }, - }, - { - blockName: '2', - blockType: 'someBlock', - relationWithinBlock: randomDoc.id, - myGroup: { - text: 'hello in english 2', - }, - }, - { - blockName: '3', - blockType: 'someBlock', - relationWithinBlock: randomDoc.id, - myGroup: { - text: 'hello in english 3', - }, - }, - ] + expect(retrieved.content.en[0].content).toHaveLength(1) + expect(retrieved.content.es[0].content).toHaveLength(1) - const blocksWithinArrayES = [ - { - blockName: '1', - blockType: 'someBlock', - relationWithinBlock: randomDoc2.id, - myGroup: { - text: 'hello in spanish 1', - }, - }, - { - blockName: '2', - blockType: 'someBlock', - relationWithinBlock: randomDoc2.id, - myGroup: { - text: 'hello in spanish 2', - }, - }, - { - blockName: '3', - blockType: 'someBlock', - relationWithinBlock: randomDoc2.id, - myGroup: { - text: 'hello in spanish 3', - }, - }, - ] + expect(retrieved.content.en[0].array[0].link.label).toStrictEqual('English 1') + expect(retrieved.content.en[0].array[1].link.label).toStrictEqual('English 2') - const createdEnDoc = await payload.create({ - collection: 'nested-arrays', - locale: 'en', - depth: 0, - data: { - arrayWithBlocks: [ - { - blocksWithinArray: blocksWithinArrayEN as any, - }, - ], - }, + expect(retrieved.content.es[0].array[0].link.label).toStrictEqual('Spanish 1') + expect(retrieved.content.es[0].array[1].link.label).toStrictEqual('Spanish 2') }) + }) - const updatedEsDoc = await payload.update({ - collection: 'nested-arrays', - id: createdEnDoc.id, - depth: 0, - locale: 'es', - data: { - arrayWithBlocks: [ - { - blocksWithinArray: blocksWithinArrayES as any, + describe('nested arrays', () => { + it('should not duplicate block rows for blocks within localized array fields', async () => { + const randomDoc = ( + await payload.find({ + collection: 'localized-posts', + depth: 0, + }) + ).docs[0] + + const randomDoc2 = ( + await payload.find({ + collection: 'localized-posts', + depth: 0, + }) + ).docs[1] + + const blocksWithinArrayEN = [ + { + blockName: '1', + blockType: 'someBlock', + relationWithinBlock: randomDoc.id, + myGroup: { + text: 'hello in english 1', }, - ], - }, - }) + }, + { + blockName: '2', + blockType: 'someBlock', + relationWithinBlock: randomDoc.id, + myGroup: { + text: 'hello in english 2', + }, + }, + { + blockName: '3', + blockType: 'someBlock', + relationWithinBlock: randomDoc.id, + myGroup: { + text: 'hello in english 3', + }, + }, + ] - const esArrayBlocks = updatedEsDoc.arrayWithBlocks[0].blocksWithinArray - // recursively remove any id field within esArrayRow - const removeId = (obj) => { - if (obj instanceof Object) { - delete obj.id - Object.values(obj).forEach(removeId) + const blocksWithinArrayES = [ + { + blockName: '1', + blockType: 'someBlock', + relationWithinBlock: randomDoc2.id, + myGroup: { + text: 'hello in spanish 1', + }, + }, + { + blockName: '2', + blockType: 'someBlock', + relationWithinBlock: randomDoc2.id, + myGroup: { + text: 'hello in spanish 2', + }, + }, + { + blockName: '3', + blockType: 'someBlock', + relationWithinBlock: randomDoc2.id, + myGroup: { + text: 'hello in spanish 3', + }, + }, + ] + + const createdEnDoc = await payload.create({ + collection: 'nested-arrays', + locale: 'en', + depth: 0, + data: { + arrayWithBlocks: [ + { + blocksWithinArray: blocksWithinArrayEN as any, + }, + ], + }, + }) + + const updatedEsDoc = await payload.update({ + collection: 'nested-arrays', + id: createdEnDoc.id, + depth: 0, + locale: 'es', + data: { + arrayWithBlocks: [ + { + blocksWithinArray: blocksWithinArrayES as any, + }, + ], + }, + }) + + const esArrayBlocks = updatedEsDoc.arrayWithBlocks[0].blocksWithinArray + // recursively remove any id field within esArrayRow + const removeId = (obj) => { + if (obj instanceof Object) { + delete obj.id + Object.values(obj).forEach(removeId) + } } - } - removeId(esArrayBlocks) - removeId(createdEnDoc.arrayWithBlocks[0].blocksWithinArray) + removeId(esArrayBlocks) + removeId(createdEnDoc.arrayWithBlocks[0].blocksWithinArray) - expect(esArrayBlocks).toEqual(blocksWithinArrayES) - expect(createdEnDoc.arrayWithBlocks[0].blocksWithinArray).toEqual(blocksWithinArrayEN) + expect(esArrayBlocks).toEqual(blocksWithinArrayES) + expect(createdEnDoc.arrayWithBlocks[0].blocksWithinArray).toEqual(blocksWithinArrayEN) - // pull enDoc again and make sure the update of esDoc did not mess with the data of enDoc - const enDoc2 = await payload.findByID({ - id: createdEnDoc.id, - collection: 'nested-arrays', - locale: 'en', - depth: 0, - }) - removeId(enDoc2.arrayWithBlocks[0].blocksWithinArray) - expect(enDoc2.arrayWithBlocks[0].blocksWithinArray).toEqual(blocksWithinArrayEN) - }) - - it('should update localized relation within unLocalized array', async () => { - const randomTextDoc = ( - await payload.find({ - collection: 'localized-posts', + // pull enDoc again and make sure the update of esDoc did not mess with the data of enDoc + const enDoc2 = await payload.findByID({ + id: createdEnDoc.id, + collection: 'nested-arrays', + locale: 'en', depth: 0, }) - ).docs[0] - const randomTextDoc2 = ( - await payload.find({ - collection: 'localized-posts', - depth: 0, - }) - ).docs[1] - - const createdEnDoc = await payload.create({ - collection: 'nested-arrays', - locale: 'en', - depth: 0, - data: { - arrayWithLocalizedRelation: [ - { - localizedRelation: randomTextDoc.id, - }, - ], - }, + removeId(enDoc2.arrayWithBlocks[0].blocksWithinArray) + expect(enDoc2.arrayWithBlocks[0].blocksWithinArray).toEqual(blocksWithinArrayEN) }) - const updatedEsDoc = await payload.update({ - collection: 'nested-arrays', - id: createdEnDoc.id, - depth: 0, - locale: 'es', - data: { - arrayWithLocalizedRelation: [ - { - id: createdEnDoc.arrayWithLocalizedRelation[0].id, - localizedRelation: randomTextDoc2.id, - }, - ], - }, - }) + it('should update localized relation within unLocalized array', async () => { + const randomTextDoc = ( + await payload.find({ + collection: 'localized-posts', + depth: 0, + }) + ).docs[0] + const randomTextDoc2 = ( + await payload.find({ + collection: 'localized-posts', + depth: 0, + }) + ).docs[1] - expect(updatedEsDoc.arrayWithLocalizedRelation).toHaveLength(1) - expect(updatedEsDoc.arrayWithLocalizedRelation[0].localizedRelation).toBe(randomTextDoc2.id) - - expect(createdEnDoc.arrayWithLocalizedRelation).toHaveLength(1) - expect(createdEnDoc.arrayWithLocalizedRelation[0].localizedRelation).toBe(randomTextDoc.id) - - // pull enDoc again and make sure the update of esDoc did not mess with the data of enDoc - const enDoc2 = await payload.findByID({ - id: createdEnDoc.id, - collection: 'nested-arrays', - locale: 'en', - depth: 0, - }) - expect(enDoc2.arrayWithLocalizedRelation).toHaveLength(1) - expect(enDoc2.arrayWithLocalizedRelation[0].localizedRelation).toBe(randomTextDoc.id) - }) - }) - - describe('nested fields', () => { - it('should allow for fields which could contain new tables within localized arrays to be stored', async () => { - const randomDoc = ( - await payload.find({ - collection: 'localized-posts', + const createdEnDoc = await payload.create({ + collection: 'nested-arrays', + locale: 'en', depth: 0, - }) - ).docs[0] - const randomDoc2 = ( - await payload.find({ - collection: 'localized-posts', - depth: 0, - }) - ).docs[1] - - const newDoc = await payload.create({ - collection: 'nested-field-tables', - data: { - array: [ - { - relation: { - value: randomDoc.id, - relationTo: 'localized-posts', + data: { + arrayWithLocalizedRelation: [ + { + localizedRelation: randomTextDoc.id, }, - hasManyRelation: [randomDoc.id, randomDoc2.id], - hasManyPolyRelation: [ - { - relationTo: 'localized-posts', - value: randomDoc.id, - }, - { - relationTo: 'localized-posts', - value: randomDoc2.id, - }, - ], - number: [1, 2], - text: ['hello', 'goodbye'], - select: ['one'], - }, - ], - }, - }) + ], + }, + }) - await payload.update({ - collection: 'nested-field-tables', - id: newDoc.id, - locale: 'es', - data: { - array: [ - { - relation: { - value: randomDoc2.id, - relationTo: 'localized-posts', + const updatedEsDoc = await payload.update({ + collection: 'nested-arrays', + id: createdEnDoc.id, + depth: 0, + locale: 'es', + data: { + arrayWithLocalizedRelation: [ + { + id: createdEnDoc.arrayWithLocalizedRelation[0].id, + localizedRelation: randomTextDoc2.id, }, - hasManyRelation: [randomDoc2.id, randomDoc.id], - hasManyPolyRelation: [ - { - relationTo: 'localized-posts', - value: randomDoc2.id, - }, - { - relationTo: 'localized-posts', + ], + }, + }) + + expect(updatedEsDoc.arrayWithLocalizedRelation).toHaveLength(1) + expect(updatedEsDoc.arrayWithLocalizedRelation[0].localizedRelation).toBe(randomTextDoc2.id) + + expect(createdEnDoc.arrayWithLocalizedRelation).toHaveLength(1) + expect(createdEnDoc.arrayWithLocalizedRelation[0].localizedRelation).toBe(randomTextDoc.id) + + // pull enDoc again and make sure the update of esDoc did not mess with the data of enDoc + const enDoc2 = await payload.findByID({ + id: createdEnDoc.id, + collection: 'nested-arrays', + locale: 'en', + depth: 0, + }) + expect(enDoc2.arrayWithLocalizedRelation).toHaveLength(1) + expect(enDoc2.arrayWithLocalizedRelation[0].localizedRelation).toBe(randomTextDoc.id) + }) + }) + + describe('nested fields', () => { + it('should allow for fields which could contain new tables within localized arrays to be stored', async () => { + const randomDoc = ( + await payload.find({ + collection: 'localized-posts', + depth: 0, + }) + ).docs[0] + const randomDoc2 = ( + await payload.find({ + collection: 'localized-posts', + depth: 0, + }) + ).docs[1] + + const newDoc = await payload.create({ + collection: 'nested-field-tables', + data: { + array: [ + { + relation: { value: randomDoc.id, + relationTo: 'localized-posts', }, - ], - select: ['two', 'three'], - text: ['hola', 'adios'], - number: [3, 4], - }, - ], - }, + hasManyRelation: [randomDoc.id, randomDoc2.id], + hasManyPolyRelation: [ + { + relationTo: 'localized-posts', + value: randomDoc.id, + }, + { + relationTo: 'localized-posts', + value: randomDoc2.id, + }, + ], + number: [1, 2], + text: ['hello', 'goodbye'], + select: ['one'], + }, + ], + }, + }) + + await payload.update({ + collection: 'nested-field-tables', + id: newDoc.id, + locale: 'es', + data: { + array: [ + { + relation: { + value: randomDoc2.id, + relationTo: 'localized-posts', + }, + hasManyRelation: [randomDoc2.id, randomDoc.id], + hasManyPolyRelation: [ + { + relationTo: 'localized-posts', + value: randomDoc2.id, + }, + { + relationTo: 'localized-posts', + value: randomDoc.id, + }, + ], + select: ['two', 'three'], + text: ['hola', 'adios'], + number: [3, 4], + }, + ], + }, + }) + + const retrieved = await payload.findByID({ + collection: 'nested-field-tables', + id: newDoc.id, + depth: 0, + locale: 'all', + }) + + expect(retrieved.array.en[0].relation.value).toStrictEqual(randomDoc.id) + expect(retrieved.array.es[0].relation.value).toStrictEqual(randomDoc2.id) + + expect(retrieved.array.en[0].hasManyRelation).toEqual([randomDoc.id, randomDoc2.id]) + expect(retrieved.array.es[0].hasManyRelation).toEqual([randomDoc2.id, randomDoc.id]) + + expect(retrieved.array.en[0].hasManyPolyRelation).toEqual([ + { value: randomDoc.id, relationTo: 'localized-posts' }, + { value: randomDoc2.id, relationTo: 'localized-posts' }, + ]) + expect(retrieved.array.es[0].hasManyPolyRelation).toEqual([ + { value: randomDoc2.id, relationTo: 'localized-posts' }, + { value: randomDoc.id, relationTo: 'localized-posts' }, + ]) + + expect(retrieved.array.en[0].number).toEqual([1, 2]) + expect(retrieved.array.es[0].number).toEqual([3, 4]) + + expect(retrieved.array.en[0].select).toEqual(['one']) + expect(retrieved.array.es[0].select).toEqual(['two', 'three']) + + expect(retrieved.array.en[0].text).toEqual(['hello', 'goodbye']) + expect(retrieved.array.es[0].text).toEqual(['hola', 'adios']) }) - const retrieved = await payload.findByID({ - collection: 'nested-field-tables', - id: newDoc.id, - depth: 0, - locale: 'all', + it('should allow for relationship in new tables within blocks inside of localized blocks to be stored', async () => { + const randomDoc = ( + await payload.find({ + collection: 'localized-posts', + depth: 0, + }) + ).docs[0] + const randomDoc2 = ( + await payload.find({ + collection: 'localized-posts', + depth: 0, + }) + ).docs[1] + + const docEn = await payload.create({ + collection: 'nested-field-tables', + depth: 0, + data: { + blocks: [ + { + blockType: 'block', + nestedBlocks: [ + { + blockType: 'content', + relation: { + relationTo: 'localized-posts', + value: randomDoc.id, + }, + }, + ], + }, + { + blockType: 'block', + nestedBlocks: [ + { + blockType: 'content', + relation: { + relationTo: 'localized-posts', + value: randomDoc.id, + }, + }, + ], + }, + { + blockType: 'block', + nestedBlocks: [ + { + blockType: 'content', + relation: { + relationTo: 'localized-posts', + value: randomDoc.id, + }, + }, + ], + }, + ], + }, + }) + + expect(docEn.blocks[0].nestedBlocks[0].relation.value).toBe(randomDoc.id) + expect(docEn.blocks[1].nestedBlocks[0].relation.value).toBe(randomDoc.id) + expect(docEn.blocks[2].nestedBlocks[0].relation.value).toBe(randomDoc.id) + + const docEs = await payload.update({ + id: docEn.id, + depth: 0, + locale: 'es', + collection: 'nested-field-tables', + data: { + blocks: [ + { + blockType: 'block', + nestedBlocks: [ + { + blockType: 'content', + relation: { + relationTo: 'localized-posts', + value: randomDoc2.id, + }, + }, + ], + }, + { + blockType: 'block', + nestedBlocks: [ + { + blockType: 'content', + relation: { + relationTo: 'localized-posts', + value: randomDoc2.id, + }, + }, + ], + }, + { + blockType: 'block', + nestedBlocks: [ + { + blockType: 'content', + relation: { + relationTo: 'localized-posts', + value: randomDoc2.id, + }, + }, + ], + }, + ], + }, + }) + + expect(docEs.blocks[0].nestedBlocks[0].relation.value).toBe(randomDoc2.id) + expect(docEs.blocks[1].nestedBlocks[0].relation.value).toBe(randomDoc2.id) + expect(docEs.blocks[2].nestedBlocks[0].relation.value).toBe(randomDoc2.id) + + const docAll = await payload.findByID({ + collection: 'nested-field-tables', + id: docEn.id, + locale: 'all', + depth: 0, + }) + + expect(docAll.blocks.en[0].nestedBlocks[0].relation.value).toBe(randomDoc.id) + expect(docAll.blocks.en[1].nestedBlocks[0].relation.value).toBe(randomDoc.id) + expect(docAll.blocks.en[2].nestedBlocks[0].relation.value).toBe(randomDoc.id) + + expect(docAll.blocks.es[0].nestedBlocks[0].relation.value).toBe(randomDoc2.id) + expect(docAll.blocks.es[1].nestedBlocks[0].relation.value).toBe(randomDoc2.id) + expect(docAll.blocks.es[2].nestedBlocks[0].relation.value).toBe(randomDoc2.id) }) - expect(retrieved.array.en[0].relation.value).toStrictEqual(randomDoc.id) - expect(retrieved.array.es[0].relation.value).toStrictEqual(randomDoc2.id) + it('should allow for relationship in new tables within arrays inside of localized blocks to be stored', async () => { + const randomDoc = ( + await payload.find({ + collection: 'localized-posts', + depth: 0, + }) + ).docs[0] + const randomDoc2 = ( + await payload.find({ + collection: 'localized-posts', + depth: 0, + }) + ).docs[1] - expect(retrieved.array.en[0].hasManyRelation).toEqual([randomDoc.id, randomDoc2.id]) - expect(retrieved.array.es[0].hasManyRelation).toEqual([randomDoc2.id, randomDoc.id]) + const docEn = await payload.create({ + collection: 'nested-field-tables', + depth: 0, + data: { + blocks: [ + { + blockType: 'block', + array: [ + { + relation: { + relationTo: 'localized-posts', + value: randomDoc.id, + }, + }, + ], + }, + { + blockType: 'block', + array: [ + { + relation: { + relationTo: 'localized-posts', + value: randomDoc.id, + }, + }, + ], + }, + { + blockType: 'block', + array: [ + { + relation: { + relationTo: 'localized-posts', + value: randomDoc.id, + }, + }, + ], + }, + ], + }, + }) - expect(retrieved.array.en[0].hasManyPolyRelation).toEqual([ - { value: randomDoc.id, relationTo: 'localized-posts' }, - { value: randomDoc2.id, relationTo: 'localized-posts' }, - ]) - expect(retrieved.array.es[0].hasManyPolyRelation).toEqual([ - { value: randomDoc2.id, relationTo: 'localized-posts' }, - { value: randomDoc.id, relationTo: 'localized-posts' }, - ]) + expect(docEn.blocks[0].array[0].relation.value).toBe(randomDoc.id) + expect(docEn.blocks[1].array[0].relation.value).toBe(randomDoc.id) + expect(docEn.blocks[2].array[0].relation.value).toBe(randomDoc.id) - expect(retrieved.array.en[0].number).toEqual([1, 2]) - expect(retrieved.array.es[0].number).toEqual([3, 4]) + const docEs = await payload.update({ + id: docEn.id, + depth: 0, + locale: 'es', + collection: 'nested-field-tables', + data: { + blocks: [ + { + blockType: 'block', + array: [ + { + relation: { + relationTo: 'localized-posts', + value: randomDoc2.id, + }, + }, + ], + }, + { + blockType: 'block', + array: [ + { + relation: { + relationTo: 'localized-posts', + value: randomDoc2.id, + }, + }, + ], + }, + { + blockType: 'block', + array: [ + { + relation: { + relationTo: 'localized-posts', + value: randomDoc2.id, + }, + }, + ], + }, + ], + }, + }) - expect(retrieved.array.en[0].select).toEqual(['one']) - expect(retrieved.array.es[0].select).toEqual(['two', 'three']) + expect(docEs.blocks[0].array[0].relation.value).toBe(randomDoc2.id) + expect(docEs.blocks[1].array[0].relation.value).toBe(randomDoc2.id) + expect(docEs.blocks[2].array[0].relation.value).toBe(randomDoc2.id) - expect(retrieved.array.en[0].text).toEqual(['hello', 'goodbye']) - expect(retrieved.array.es[0].text).toEqual(['hola', 'adios']) + const docAll = await payload.findByID({ + collection: 'nested-field-tables', + id: docEn.id, + locale: 'all', + depth: 0, + }) + + expect(docAll.blocks.en[0].array[0].relation.value).toBe(randomDoc.id) + expect(docAll.blocks.en[1].array[0].relation.value).toBe(randomDoc.id) + expect(docAll.blocks.en[2].array[0].relation.value).toBe(randomDoc.id) + + expect(docAll.blocks.es[0].array[0].relation.value).toBe(randomDoc2.id) + expect(docAll.blocks.es[1].array[0].relation.value).toBe(randomDoc2.id) + expect(docAll.blocks.es[2].array[0].relation.value).toBe(randomDoc2.id) + }) }) - it('should allow for relationship in new tables within blocks inside of localized blocks to be stored', async () => { - const randomDoc = ( - await payload.find({ + describe('localized with unique', () => { + it('localized with unique should work for each locale', async () => { + await payload.create({ collection: 'localized-posts', - depth: 0, + locale: 'ar', + data: { + unique: 'text', + }, }) - ).docs[0] - const randomDoc2 = ( - await payload.find({ - collection: 'localized-posts', - depth: 0, - }) - ).docs[1] - const docEn = await payload.create({ - collection: 'nested-field-tables', - depth: 0, - data: { - blocks: [ - { - blockType: 'block', - nestedBlocks: [ - { - blockType: 'content', - relation: { - relationTo: 'localized-posts', - value: randomDoc.id, - }, - }, - ], - }, - { - blockType: 'block', - nestedBlocks: [ - { - blockType: 'content', - relation: { - relationTo: 'localized-posts', - value: randomDoc.id, - }, - }, - ], - }, - { - blockType: 'block', - nestedBlocks: [ - { - blockType: 'content', - relation: { - relationTo: 'localized-posts', - value: randomDoc.id, - }, - }, - ], - }, - ], - }, - }) - - expect(docEn.blocks[0].nestedBlocks[0].relation.value).toBe(randomDoc.id) - expect(docEn.blocks[1].nestedBlocks[0].relation.value).toBe(randomDoc.id) - expect(docEn.blocks[2].nestedBlocks[0].relation.value).toBe(randomDoc.id) - - const docEs = await payload.update({ - id: docEn.id, - depth: 0, - locale: 'es', - collection: 'nested-field-tables', - data: { - blocks: [ - { - blockType: 'block', - nestedBlocks: [ - { - blockType: 'content', - relation: { - relationTo: 'localized-posts', - value: randomDoc2.id, - }, - }, - ], - }, - { - blockType: 'block', - nestedBlocks: [ - { - blockType: 'content', - relation: { - relationTo: 'localized-posts', - value: randomDoc2.id, - }, - }, - ], - }, - { - blockType: 'block', - nestedBlocks: [ - { - blockType: 'content', - relation: { - relationTo: 'localized-posts', - value: randomDoc2.id, - }, - }, - ], - }, - ], - }, - }) - - expect(docEs.blocks[0].nestedBlocks[0].relation.value).toBe(randomDoc2.id) - expect(docEs.blocks[1].nestedBlocks[0].relation.value).toBe(randomDoc2.id) - expect(docEs.blocks[2].nestedBlocks[0].relation.value).toBe(randomDoc2.id) - - const docAll = await payload.findByID({ - collection: 'nested-field-tables', - id: docEn.id, - locale: 'all', - depth: 0, - }) - - expect(docAll.blocks.en[0].nestedBlocks[0].relation.value).toBe(randomDoc.id) - expect(docAll.blocks.en[1].nestedBlocks[0].relation.value).toBe(randomDoc.id) - expect(docAll.blocks.en[2].nestedBlocks[0].relation.value).toBe(randomDoc.id) - - expect(docAll.blocks.es[0].nestedBlocks[0].relation.value).toBe(randomDoc2.id) - expect(docAll.blocks.es[1].nestedBlocks[0].relation.value).toBe(randomDoc2.id) - expect(docAll.blocks.es[2].nestedBlocks[0].relation.value).toBe(randomDoc2.id) - }) - - it('should allow for relationship in new tables within arrays inside of localized blocks to be stored', async () => { - const randomDoc = ( - await payload.find({ - collection: 'localized-posts', - depth: 0, - }) - ).docs[0] - const randomDoc2 = ( - await payload.find({ - collection: 'localized-posts', - depth: 0, - }) - ).docs[1] - - const docEn = await payload.create({ - collection: 'nested-field-tables', - depth: 0, - data: { - blocks: [ - { - blockType: 'block', - array: [ - { - relation: { - relationTo: 'localized-posts', - value: randomDoc.id, - }, - }, - ], - }, - { - blockType: 'block', - array: [ - { - relation: { - relationTo: 'localized-posts', - value: randomDoc.id, - }, - }, - ], - }, - { - blockType: 'block', - array: [ - { - relation: { - relationTo: 'localized-posts', - value: randomDoc.id, - }, - }, - ], - }, - ], - }, - }) - - expect(docEn.blocks[0].array[0].relation.value).toBe(randomDoc.id) - expect(docEn.blocks[1].array[0].relation.value).toBe(randomDoc.id) - expect(docEn.blocks[2].array[0].relation.value).toBe(randomDoc.id) - - const docEs = await payload.update({ - id: docEn.id, - depth: 0, - locale: 'es', - collection: 'nested-field-tables', - data: { - blocks: [ - { - blockType: 'block', - array: [ - { - relation: { - relationTo: 'localized-posts', - value: randomDoc2.id, - }, - }, - ], - }, - { - blockType: 'block', - array: [ - { - relation: { - relationTo: 'localized-posts', - value: randomDoc2.id, - }, - }, - ], - }, - { - blockType: 'block', - array: [ - { - relation: { - relationTo: 'localized-posts', - value: randomDoc2.id, - }, - }, - ], - }, - ], - }, - }) - - expect(docEs.blocks[0].array[0].relation.value).toBe(randomDoc2.id) - expect(docEs.blocks[1].array[0].relation.value).toBe(randomDoc2.id) - expect(docEs.blocks[2].array[0].relation.value).toBe(randomDoc2.id) - - const docAll = await payload.findByID({ - collection: 'nested-field-tables', - id: docEn.id, - locale: 'all', - depth: 0, - }) - - expect(docAll.blocks.en[0].array[0].relation.value).toBe(randomDoc.id) - expect(docAll.blocks.en[1].array[0].relation.value).toBe(randomDoc.id) - expect(docAll.blocks.en[2].array[0].relation.value).toBe(randomDoc.id) - - expect(docAll.blocks.es[0].array[0].relation.value).toBe(randomDoc2.id) - expect(docAll.blocks.es[1].array[0].relation.value).toBe(randomDoc2.id) - expect(docAll.blocks.es[2].array[0].relation.value).toBe(randomDoc2.id) - }) - }) - - describe('localized with unique', () => { - it('localized with unique should work for each locale', async () => { - await payload.create({ - collection: 'localized-posts', - locale: 'ar', - data: { - unique: 'text', - }, - }) - - await payload.create({ - collection: 'localized-posts', - locale: 'en', - data: { - unique: 'text', - }, - }) - - await payload.create({ - collection: 'localized-posts', - locale: 'es', - data: { - unique: 'text', - }, - }) - - await expect( - payload.create({ + await payload.create({ collection: 'localized-posts', locale: 'en', data: { unique: 'text', }, - }), - ).rejects.toBeTruthy() + }) + + await payload.create({ + collection: 'localized-posts', + locale: 'es', + data: { + unique: 'text', + }, + }) + + await expect( + payload.create({ + collection: 'localized-posts', + locale: 'en', + data: { + unique: 'text', + }, + }), + ).rejects.toBeTruthy() + }) + }) + }) + + describe('Localization with fallback false', () => { + let post1: LocalizedPost + let postWithLocalizedData: LocalizedPost + + beforeAll(async () => { + if (payload.config.localization) { + payload.config.localization.fallback = false + } + + post1 = await payload.create({ + collection, + data: { + title: englishTitle, + }, + }) + + postWithLocalizedData = await payload.create({ + collection, + data: { + title: englishTitle, + }, + }) + + await payload.update({ + id: postWithLocalizedData.id, + collection, + data: { + title: spanishTitle, + }, + locale: spanishLocale, + }) + }) + + describe('fallback locale', () => { + it('create english', async () => { + const allDocs = await payload.find({ + collection, + where: { + title: { equals: post1.title }, + }, + }) + expect(allDocs.docs).toContainEqual(expect.objectContaining(post1)) + }) + + it('add spanish translation', async () => { + const updated = await payload.update({ + id: post1.id, + collection, + data: { + title: spanishTitle, + }, + locale: spanishLocale, + }) + + expect(updated.title).toEqual(spanishTitle) + + const localized: any = await payload.findByID({ + id: post1.id, + collection, + locale: 'all', + }) + + expect(localized.title.en).toEqual(englishTitle) + expect(localized.title.es).toEqual(spanishTitle) + }) + + it('should not fallback to english', async () => { + const retrievedDoc = await payload.findByID({ + id: post1.id, + collection, + locale: portugueseLocale, + }) + + expect(retrievedDoc.title).not.toBeDefined() + }) + + it('should fallback to english with explicit fallbackLocale', async () => { + const fallbackDoc = await payload.findByID({ + id: post1.id, + collection, + locale: portugueseLocale, + fallbackLocale: englishLocale, + }) + + expect(fallbackDoc.title).toBe(englishTitle) + }) + + it('should not fallback to spanish translation and no explicit fallback is provided', async () => { + const localizedFallback: any = await payload.findByID({ + id: postWithLocalizedData.id, + collection, + locale: portugueseLocale, + }) + + expect(localizedFallback.title).not.toBeDefined() + }) + + it('should respect fallback none', async () => { + const localizedFallback: any = await payload.findByID({ + id: postWithLocalizedData.id, + collection, + locale: portugueseLocale, + fallbackLocale: false, + }) + + expect(localizedFallback.title).not.toBeDefined() + }) }) }) })