From 538b7ee616dff012abea98df569d2d22526d27ee Mon Sep 17 00:00:00 2001 From: James Mikrut Date: Wed, 28 Aug 2024 21:56:17 -0400 Subject: [PATCH] feat!: auto-removes localized property from localized fields within other localized fields (#7933) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Payload localization works on a field-by-field basis. As you can nest fields within other fields, you could potentially nest a localized field within a localized field—but this would be redundant and unnecessary. There would be no reason to define a localized field within a localized parent field, given that the entire data structure from the parent field onward would be localized. Up until this point, Payload would _allow_ you to nest a localized field within another localized field, and this might have worked in MongoDB but it will throw errors in Postgres. Now, Payload will automatically remove the `localized: true` property from sub-fields within `sanitizeFields` if a parent field is localized. This could potentially be a breaking change if you have a configuration with MongoDB that nests localized fields within localized fields. ## Migrating You probably only need to migrate if you are using MongoDB, as there, you may not have noticed any problems. But in Postgres or SQLite, this would have caused issues so it's unlikely that you've made it too far without experiencing issues due to a nested localized fields config. In the event you would like to keep existing data in this fashion, we have added a `compatibility.allowLocalizedWithinLocalized` flag to the Payload config, which you can set to `true`, and Payload will then disable this new sanitization step. Set this compatibility flag to `true` only if you have an existing Payload MongoDB database from pre-3.0, and you have nested localized fields that you would like to maintain without migrating. --- docs/configuration/overview.mdx | 11 ++++ packages/payload/src/admin/RichText.ts | 2 + .../src/collections/config/sanitize.ts | 1 + packages/payload/src/config/sanitize.ts | 1 + packages/payload/src/config/types.ts | 13 ++++ .../payload/src/fields/config/sanitize.ts | 20 +++++- .../payload/src/globals/config/sanitize.ts | 1 + .../src/features/blocks/server/index.ts | 3 +- .../experimental_table/server/index.ts | 3 +- .../src/features/link/server/index.ts | 3 +- .../src/features/typesServer.ts | 1 + .../features/upload/server/feature.server.ts | 3 +- packages/richtext-lexical/src/index.ts | 4 +- .../src/lexical/config/server/loader.ts | 3 + .../src/lexical/config/server/sanitize.ts | 2 + packages/richtext-lexical/src/types.ts | 2 + .../src/utilities/createServerFeature.ts | 3 + packages/richtext-slate/src/index.tsx | 2 + .../LocalizedWithinLocalized/index.ts | 65 +++++++++++++++++++ test/localization/config.ts | 2 + test/localization/int.spec.ts | 13 +++- 21 files changed, 150 insertions(+), 8 deletions(-) create mode 100644 test/localization/collections/LocalizedWithinLocalized/index.ts diff --git a/docs/configuration/overview.mdx b/docs/configuration/overview.mdx index 1c50bff5a1..575b3d4192 100644 --- a/docs/configuration/overview.mdx +++ b/docs/configuration/overview.mdx @@ -71,6 +71,7 @@ The following options are available: | **`db`** \* | The Database Adapter which will be used by Payload. [More details](../database/overview). | | **`serverURL`** | A string used to define the absolute URL of your app. This includes the protocol, for example `https://example.com`. No paths allowed, only protocol, domain and (optionally) port. | | **`collections`** | An array of Collections for Payload to manage. [More details](./collections). | +| **`compatibility`** | Compatibility flags for earlier versions of Payload. [More details](#compatibility-flags). | | **`globals`** | An array of Globals for Payload to manage. [More details](./globals). | | **`cors`** | Cross-origin resource sharing (CORS) is a mechanism that accept incoming requests from given domains. You can also customize the `Access-Control-Allow-Headers` header. [More details](#cors). | | **`localization`** | Opt-in to translate your content into multiple locales. [More details](./localization). | @@ -253,3 +254,13 @@ import type { Config, SanitizedConfig } from 'payload' The Payload Config only lives on the server and is not allowed to contain any client-side code. That way, you can load up the Payload Config in any server environment or standalone script, without having to use Bundlers or Node.js loaders to handle importing client-only modules (e.g. scss files or React Components) without any errors. Behind the curtains, the Next.js-based Admin Panel generates a ClientConfig, which strips away any server-only code and enriches the config with React Components. + +## Compatibility flags + +The Payload Config can accept compatibility flags for running the newest versions but with older databases. You should only use these flags if you need to, and should confirm that you need to prior to enabling these flags. + +`allowLocalizedWithinLocalized` + +Payload localization works on a field-by-field basis. As you can nest fields within other fields, you could potentially nest a localized field within a localized field—but this would be redundant and unnecessary. There would be no reason to define a localized field within a localized parent field, given that the entire data structure from the parent field onward would be localized. + +By default, Payload will remove the `localized: true` property from sub-fields if a parent field is localized. Set this compatibility flag to `true` only if you have an existing Payload MongoDB database from pre-3.0, and you have nested localized fields that you would like to maintain without migrating. diff --git a/packages/payload/src/admin/RichText.ts b/packages/payload/src/admin/RichText.ts index d8d7961896..0fb1f3cca0 100644 --- a/packages/payload/src/admin/RichText.ts +++ b/packages/payload/src/admin/RichText.ts @@ -271,6 +271,7 @@ export type RichTextAdapterProvider< > = ({ config, isRoot, + parentIsLocalized, }: { config: SanitizedConfig /** @@ -279,6 +280,7 @@ export type RichTextAdapterProvider< * @default false */ isRoot?: boolean + parentIsLocalized: boolean }) => | Promise> | RichTextAdapter diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index 362bd690f9..355c5a52b5 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -39,6 +39,7 @@ export const sanitizeCollection = async ( collectionConfig: sanitized, config, fields: sanitized.fields, + parentIsLocalized: false, richTextSanitizationPromises, validRelationships, }) diff --git a/packages/payload/src/config/sanitize.ts b/packages/payload/src/config/sanitize.ts index 49485daf25..5727fa6bc4 100644 --- a/packages/payload/src/config/sanitize.ts +++ b/packages/payload/src/config/sanitize.ts @@ -188,6 +188,7 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise= 0) { config.i18n.translations = deepMergeSimple(config.i18n.translations, config.editor.i18n) diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index e481da7108..6858f5284f 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -811,6 +811,19 @@ export type Config = { * @see https://payloadcms.com/docs/configuration/collections#collection-configs */ collections?: CollectionConfig[] + /** + * Compatibility flags for prior Payload versions + */ + compatibility?: { + /** + * By default, Payload will remove the `localized: true` property + * from fields if a parent field is localized. Set this property + * to `true` only if you have an existing Payload database from pre-3.0 + * that you would like to maintain without migrating. This is only + * relevant for MongoDB databases. + */ + allowLocalizedWithinLocalized: true + } /** * Prefix a string to all cookies that Payload sets. * diff --git a/packages/payload/src/fields/config/sanitize.ts b/packages/payload/src/fields/config/sanitize.ts index dbbb41bea7..9b58044380 100644 --- a/packages/payload/src/fields/config/sanitize.ts +++ b/packages/payload/src/fields/config/sanitize.ts @@ -23,18 +23,19 @@ type Args = { config: Config existingFieldNames?: Set fields: Field[] + parentIsLocalized: boolean /** * If true, a richText field will require an editor property to be set, as the sanitizeFields function will not add it from the payload config if not present. * * @default false */ requireFieldLevelRichTextEditor?: boolean + /** * If this property is set, RichText fields won't be sanitized immediately. Instead, they will be added to this array as promises * so that you can sanitize them together, after the config has been sanitized. */ richTextSanitizationPromises?: Array<(config: SanitizedConfig) => Promise> - /** * If not null, will validate that upload and relationship fields do not relate to a collection that is not in this array. * This validation will be skipped if validRelationships is null. @@ -47,6 +48,7 @@ export const sanitizeFields = async ({ config, existingFieldNames = new Set(), fields, + parentIsLocalized, requireFieldLevelRichTextEditor = false, richTextSanitizationPromises, validRelationships, @@ -137,7 +139,17 @@ export const sanitizeFields = async ({ existingFieldNames.add(field.name) } - if (field.localized && !config.localization) delete field.localized + if (typeof field.localized !== 'undefined') { + let shouldDisableLocalized = !config.localization + + if (!config.compatibility?.allowLocalizedWithinLocalized && parentIsLocalized) { + shouldDisableLocalized = true + } + + if (shouldDisableLocalized) { + delete field.localized + } + } if (typeof field.validate === 'undefined') { const defaultValidate = validations[field.type] @@ -174,6 +186,7 @@ export const sanitizeFields = async ({ field.editor = await field.editor({ config: _config, isRoot: requireFieldLevelRichTextEditor, + parentIsLocalized: parentIsLocalized || field.localized, }) } @@ -201,6 +214,7 @@ export const sanitizeFields = async ({ config, existingFieldNames: new Set(), fields: block.fields, + parentIsLocalized: parentIsLocalized || field.localized, requireFieldLevelRichTextEditor, richTextSanitizationPromises, validRelationships, @@ -213,6 +227,7 @@ export const sanitizeFields = async ({ config, existingFieldNames: fieldAffectsData(field) ? new Set() : existingFieldNames, fields: field.fields, + parentIsLocalized: parentIsLocalized || field.localized, requireFieldLevelRichTextEditor, richTextSanitizationPromises, validRelationships, @@ -230,6 +245,7 @@ export const sanitizeFields = async ({ config, existingFieldNames: tabHasName(tab) ? new Set() : existingFieldNames, fields: tab.fields, + parentIsLocalized: parentIsLocalized || (tabHasName(tab) && tab.localized), requireFieldLevelRichTextEditor, richTextSanitizationPromises, validRelationships, diff --git a/packages/payload/src/globals/config/sanitize.ts b/packages/payload/src/globals/config/sanitize.ts index 0140a2adf5..9536204f63 100644 --- a/packages/payload/src/globals/config/sanitize.ts +++ b/packages/payload/src/globals/config/sanitize.ts @@ -46,6 +46,7 @@ export const sanitizeGlobals = async ( global.fields = await sanitizeFields({ config, fields: global.fields, + parentIsLocalized: false, richTextSanitizationPromises, validRelationships, }) diff --git a/packages/richtext-lexical/src/features/blocks/server/index.ts b/packages/richtext-lexical/src/features/blocks/server/index.ts index fab8f85386..4192d29047 100644 --- a/packages/richtext-lexical/src/features/blocks/server/index.ts +++ b/packages/richtext-lexical/src/features/blocks/server/index.ts @@ -22,7 +22,7 @@ export const BlocksFeature = createServerFeature< BlocksFeatureProps, BlocksFeatureClientProps >({ - feature: async ({ config: _config, isRoot, props }) => { + feature: async ({ config: _config, isRoot, parentIsLocalized, props }) => { // Build clientProps const clientProps: BlocksFeatureClientProps = { clientBlockSlugs: [], @@ -44,6 +44,7 @@ export const BlocksFeature = createServerFeature< blocks: props.inlineBlocks ?? [], }, ], + parentIsLocalized, requireFieldLevelRichTextEditor: isRoot, validRelationships, }) diff --git a/packages/richtext-lexical/src/features/experimental_table/server/index.ts b/packages/richtext-lexical/src/features/experimental_table/server/index.ts index 59e3239438..014c6dc4a0 100644 --- a/packages/richtext-lexical/src/features/experimental_table/server/index.ts +++ b/packages/richtext-lexical/src/features/experimental_table/server/index.ts @@ -49,12 +49,13 @@ export type SerializedTableRowNode = Spread< _SerializedTableRowNode > export const EXPERIMENTAL_TableFeature = createServerFeature({ - feature: async ({ config, isRoot }) => { + feature: async ({ config, isRoot, parentIsLocalized }) => { const validRelationships = config.collections.map((c) => c.slug) || [] const sanitizedFields = await sanitizeFields({ config: config as unknown as Config, fields, + parentIsLocalized, requireFieldLevelRichTextEditor: isRoot, validRelationships, }) diff --git a/packages/richtext-lexical/src/features/link/server/index.ts b/packages/richtext-lexical/src/features/link/server/index.ts index b81defc012..942f6320ca 100644 --- a/packages/richtext-lexical/src/features/link/server/index.ts +++ b/packages/richtext-lexical/src/features/link/server/index.ts @@ -64,7 +64,7 @@ export const LinkFeature = createServerFeature< LinkFeatureServerProps, ClientProps >({ - feature: async ({ config: _config, isRoot, props }) => { + feature: async ({ config: _config, isRoot, parentIsLocalized, props }) => { if (!props) { props = {} } @@ -81,6 +81,7 @@ export const LinkFeature = createServerFeature< const sanitizedFields = await sanitizeFields({ config: _config as unknown as Config, fields: _transformedFields, + parentIsLocalized, requireFieldLevelRichTextEditor: isRoot, validRelationships, }) diff --git a/packages/richtext-lexical/src/features/typesServer.ts b/packages/richtext-lexical/src/features/typesServer.ts index 058c26fab2..b9c94d22f0 100644 --- a/packages/richtext-lexical/src/features/typesServer.ts +++ b/packages/richtext-lexical/src/features/typesServer.ts @@ -110,6 +110,7 @@ export type FeatureProviderServer< /** unSanitizedEditorConfig.features, but mapped */ featureProviderMap: ServerFeatureProviderMap isRoot?: boolean + parentIsLocalized: boolean // other resolved features, which have been loaded before this one. All features declared in 'dependencies' should be available here resolvedFeatures: ResolvedServerFeatureMap // unSanitized EditorConfig, diff --git a/packages/richtext-lexical/src/features/upload/server/feature.server.ts b/packages/richtext-lexical/src/features/upload/server/feature.server.ts index 7be285128d..09c1435d36 100644 --- a/packages/richtext-lexical/src/features/upload/server/feature.server.ts +++ b/packages/richtext-lexical/src/features/upload/server/feature.server.ts @@ -47,7 +47,7 @@ export const UploadFeature = createServerFeature< UploadFeatureProps, UploadFeaturePropsClient >({ - feature: async ({ config: _config, isRoot, props }) => { + feature: async ({ config: _config, isRoot, parentIsLocalized, props }) => { if (!props) { props = { collections: {} } } @@ -70,6 +70,7 @@ export const UploadFeature = createServerFeature< props.collections[collection].fields = await sanitizeFields({ config: _config as unknown as Config, fields: props.collections[collection].fields, + parentIsLocalized, requireFieldLevelRichTextEditor: isRoot, validRelationships, }) diff --git a/packages/richtext-lexical/src/index.ts b/packages/richtext-lexical/src/index.ts index dd2e623c8e..836ed431c1 100644 --- a/packages/richtext-lexical/src/index.ts +++ b/packages/richtext-lexical/src/index.ts @@ -41,7 +41,7 @@ import { richTextValidateHOC } from './validate/index.js' let defaultSanitizedServerEditorConfig: SanitizedServerEditorConfig = null export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapterProvider { - return async ({ config, isRoot }) => { + return async ({ config, isRoot, parentIsLocalized }) => { if ( process.env.NODE_ENV !== 'production' && process.env.PAYLOAD_DISABLE_DEPENDENCY_CHECKER !== 'true' @@ -77,6 +77,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte defaultSanitizedServerEditorConfig = await sanitizeServerEditorConfig( defaultEditorConfig, config, + parentIsLocalized, ) features = deepCopyObject(defaultEditorFeatures) } @@ -108,6 +109,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte resolvedFeatureMap = await loadFeatures({ config, isRoot, + parentIsLocalized, unSanitizedEditorConfig: { features, lexical, diff --git a/packages/richtext-lexical/src/lexical/config/server/loader.ts b/packages/richtext-lexical/src/lexical/config/server/loader.ts index a468533c92..de159d3dc3 100644 --- a/packages/richtext-lexical/src/lexical/config/server/loader.ts +++ b/packages/richtext-lexical/src/lexical/config/server/loader.ts @@ -108,10 +108,12 @@ export function sortFeaturesForOptimalLoading( export async function loadFeatures({ config, isRoot, + parentIsLocalized, unSanitizedEditorConfig, }: { config: SanitizedConfig isRoot?: boolean + parentIsLocalized: boolean unSanitizedEditorConfig: ServerEditorConfig }): Promise { // First remove all duplicate features. The LAST feature with a given key wins. @@ -179,6 +181,7 @@ export async function loadFeatures({ config, featureProviderMap, isRoot, + parentIsLocalized, resolvedFeatures, unSanitizedEditorConfig, }) diff --git a/packages/richtext-lexical/src/lexical/config/server/sanitize.ts b/packages/richtext-lexical/src/lexical/config/server/sanitize.ts index 20db525a52..a57c694a66 100644 --- a/packages/richtext-lexical/src/lexical/config/server/sanitize.ts +++ b/packages/richtext-lexical/src/lexical/config/server/sanitize.ts @@ -125,9 +125,11 @@ export const sanitizeServerFeatures = ( export async function sanitizeServerEditorConfig( editorConfig: ServerEditorConfig, config: SanitizedConfig, + parentIsLocalized?: boolean, ): Promise { const resolvedFeatureMap = await loadFeatures({ config, + parentIsLocalized, unSanitizedEditorConfig: editorConfig, }) diff --git a/packages/richtext-lexical/src/types.ts b/packages/richtext-lexical/src/types.ts index 2b0df59c01..e83389cccb 100644 --- a/packages/richtext-lexical/src/types.ts +++ b/packages/richtext-lexical/src/types.ts @@ -67,9 +67,11 @@ export type LexicalRichTextAdapterProvider = ({ config, isRoot, + parentIsLocalized, }: { config: SanitizedConfig isRoot?: boolean + parentIsLocalized: boolean }) => Promise export type LexicalRichTextFieldProps = { diff --git a/packages/richtext-lexical/src/utilities/createServerFeature.ts b/packages/richtext-lexical/src/utilities/createServerFeature.ts index e526067f3a..50cf028158 100644 --- a/packages/richtext-lexical/src/utilities/createServerFeature.ts +++ b/packages/richtext-lexical/src/utilities/createServerFeature.ts @@ -16,6 +16,7 @@ export type CreateServerFeatureArgs { @@ -64,6 +66,7 @@ export const createServerFeature: < config, featureProviderMap, isRoot, + parentIsLocalized, props, resolvedFeatures, unSanitizedEditorConfig, diff --git a/packages/richtext-slate/src/index.tsx b/packages/richtext-slate/src/index.tsx index b3881d86fb..053196bc46 100644 --- a/packages/richtext-slate/src/index.tsx +++ b/packages/richtext-slate/src/index.tsx @@ -29,6 +29,7 @@ export function slateEditor( args.admin.link.fields = await sanitizeFields({ config: config as unknown as Config, fields: transformExtraFields(args.admin?.link?.fields, config), + parentIsLocalized: false, validRelationships, }) @@ -38,6 +39,7 @@ export function slateEditor( args.admin.upload.collections[collection].fields = await sanitizeFields({ config: config as unknown as Config, fields: args.admin?.upload?.collections[collection]?.fields, + parentIsLocalized: false, validRelationships, }) } diff --git a/test/localization/collections/LocalizedWithinLocalized/index.ts b/test/localization/collections/LocalizedWithinLocalized/index.ts new file mode 100644 index 0000000000..2861450610 --- /dev/null +++ b/test/localization/collections/LocalizedWithinLocalized/index.ts @@ -0,0 +1,65 @@ +import type { CollectionConfig } from 'payload' + +export const LocalizedWithinLocalized: CollectionConfig = { + slug: 'localized-within-localized', + fields: [ + { + type: 'tabs', + tabs: [ + { + name: 'myTab', + label: 'My Tab', + localized: true, + fields: [ + { + name: 'shouldNotBeLocalized', + type: 'text', + localized: true, + }, + ], + }, + ], + }, + { + name: 'myArray', + type: 'array', + localized: true, + fields: [ + { + name: 'shouldNotBeLocalized', + type: 'text', + localized: true, + }, + ], + }, + { + name: 'myBlocks', + type: 'blocks', + localized: true, + blocks: [ + { + slug: 'myBlock', + fields: [ + { + name: 'shouldNotBeLocalized', + type: 'text', + localized: true, + }, + ], + }, + ], + }, + { + name: 'myGroup', + type: 'group', + localized: true, + fields: [ + { + name: 'shouldNotBeLocalized', + type: 'text', + localized: true, + }, + ], + }, + ], +} diff --git a/test/localization/config.ts b/test/localization/config.ts index 55ab6c2ebe..a9f2324bc5 100644 --- a/test/localization/config.ts +++ b/test/localization/config.ts @@ -9,6 +9,7 @@ import { devUser } from '../credentials.js' import { ArrayCollection } from './collections/Array/index.js' import { BlocksCollection } from './collections/Blocks/index.js' import { Group } from './collections/Group/index.js' +import { LocalizedWithinLocalized } from './collections/LocalizedWithinLocalized/index.js' import { NestedArray } from './collections/NestedArray/index.js' import { NestedFields } from './collections/NestedFields/index.js' import { NestedToArrayAndBlock } from './collections/NestedToArrayAndBlock/index.js' @@ -288,6 +289,7 @@ export default buildConfigWithDefaults({ }, ], }, + LocalizedWithinLocalized, ], globals: [ { diff --git a/test/localization/int.spec.ts b/test/localization/int.spec.ts index 55ba1b39e1..f7855f938f 100644 --- a/test/localization/int.spec.ts +++ b/test/localization/int.spec.ts @@ -6,7 +6,6 @@ import { fileURLToPath } from 'url' import type { NextRESTClient } from '../helpers/NextRESTClient.js' import type { LocalizedPost, WithLocalizedRelationship } from './payload-types.js' -import { englishLocale } from '../globals/config.js' import { idToString } from '../helpers/idToString.js' import { initPayloadInt } from '../helpers/initPayloadInt.js' import { arrayCollectionSlug } from './collections/Array/index.js' @@ -15,6 +14,7 @@ import { nestedToArrayAndBlockCollectionSlug } from './collections/NestedToArray import { tabSlug } from './collections/Tab/index.js' import { defaultLocale, + defaultLocale as englishLocale, englishTitle, hungarianLocale, localizedPostsSlug, @@ -1396,6 +1396,17 @@ describe('Localization', () => { }) }) + 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 () => {