From 4de43fd8955bcb8ec2836d0436801d779b0381c6 Mon Sep 17 00:00:00 2001 From: hip3r Date: Tue, 22 Oct 2024 19:34:03 +0200 Subject: [PATCH 1/3] feat: option to remove empty image field from content #7120 #7186 --- packages/decap-cms-core/index.d.ts | 1 + .../src/actions/editorialWorkflow.ts | 2 +- .../decap-cms-core/src/actions/entries.ts | 7 ++-- .../constants/__tests__/configSchema.spec.js | 12 ++++++ .../src/constants/configSchema.js | 1 + .../__tests__/serializeEntryValues.spec.js | 12 ++++-- .../src/lib/serializeEntryValues.js | 41 ++++++++++++++++--- packages/decap-cms-core/src/types/redux.ts | 1 + 8 files changed, 64 insertions(+), 13 deletions(-) diff --git a/packages/decap-cms-core/index.d.ts b/packages/decap-cms-core/index.d.ts index 9eb5e7f9855e..f45f9418ad7c 100644 --- a/packages/decap-cms-core/index.d.ts +++ b/packages/decap-cms-core/index.d.ts @@ -400,6 +400,7 @@ declare module 'decap-cms-core' { slug?: CmsSlug; i18n?: CmsI18nConfig; local_backend?: boolean | CmsLocalBackend; + remove_empty_image_field?: boolean; editor?: { preview?: boolean; }; diff --git a/packages/decap-cms-core/src/actions/editorialWorkflow.ts b/packages/decap-cms-core/src/actions/editorialWorkflow.ts index 231caa75de76..10d0ab40a445 100644 --- a/packages/decap-cms-core/src/actions/editorialWorkflow.ts +++ b/packages/decap-cms-core/src/actions/editorialWorkflow.ts @@ -359,7 +359,7 @@ export function persistUnpublishedEntry(collection: Collection, existingUnpublis entry, }); - const serializedEntry = getSerializedEntry(collection, entry); + const serializedEntry = getSerializedEntry(collection, entry, state.config); const serializedEntryDraft = entryDraft.set('entry', serializedEntry); dispatch(unpublishedEntryPersisting(collection, entry.get('slug'))); diff --git a/packages/decap-cms-core/src/actions/entries.ts b/packages/decap-cms-core/src/actions/entries.ts index d7971b854d06..b04016649f49 100644 --- a/packages/decap-cms-core/src/actions/entries.ts +++ b/packages/decap-cms-core/src/actions/entries.ts @@ -34,6 +34,7 @@ import type { ViewFilter, ViewGroup, Entry, + CmsConfig, } from '../types/redux'; import type { EntryValue } from '../valueObjects/Entry'; import type { Backend } from '../backend'; @@ -856,7 +857,7 @@ export function getMediaAssets({ entry }: { entry: EntryMap }) { return assets; } -export function getSerializedEntry(collection: Collection, entry: Entry) { +export function getSerializedEntry(collection: Collection, entry: Entry, config: CmsConfig) { /** * Serialize the values of any fields with registered serializers, and * update the entry and entryDraft with the serialized values. @@ -865,7 +866,7 @@ export function getSerializedEntry(collection: Collection, entry: Entry) { // eslint-disable-next-line @typescript-eslint/no-explicit-any function serializeData(data: any) { - return serializeValues(data, fields); + return serializeValues(data, fields, config); } const serializedData = serializeData(entry.get('data')); @@ -910,7 +911,7 @@ export function persistEntry(collection: Collection) { entry, }); - const serializedEntry = getSerializedEntry(collection, entry); + const serializedEntry = getSerializedEntry(collection, entry, state.config); const serializedEntryDraft = entryDraft.set('entry', serializedEntry); dispatch(entryPersisting(collection, serializedEntry)); return backend diff --git a/packages/decap-cms-core/src/constants/__tests__/configSchema.spec.js b/packages/decap-cms-core/src/constants/__tests__/configSchema.spec.js index d0cae4d34e03..173427e19a6c 100644 --- a/packages/decap-cms-core/src/constants/__tests__/configSchema.spec.js +++ b/packages/decap-cms-core/src/constants/__tests__/configSchema.spec.js @@ -172,6 +172,18 @@ describe('config', () => { }).not.toThrowError(); }); + it('should throw if remove_empty_image_field is not a boolean', () => { + expect(() => { + validateConfig(merge({}, validConfig, { remove_empty_image_field: 'false' })); + }).toThrowError("'remove_empty_image_field' must be boolean"); + }); + + it('should not throw if remove_empty_image_field is a boolean', () => { + expect(() => { + validateConfig(merge({}, validConfig, { remove_empty_image_field: false })); + }).not.toThrowError(); + }); + it('should throw if collection publish is not a boolean', () => { expect(() => { validateConfig(merge({}, validConfig, { collections: [{ publish: 'false' }] })); diff --git a/packages/decap-cms-core/src/constants/configSchema.js b/packages/decap-cms-core/src/constants/configSchema.js index 5efd2cd4c172..233edf0cfd60 100644 --- a/packages/decap-cms-core/src/constants/configSchema.js +++ b/packages/decap-cms-core/src/constants/configSchema.js @@ -156,6 +156,7 @@ function getConfigSchema() { }, ], }, + remove_empty_image_field: { type: 'boolean' }, locale: { type: 'string', examples: ['en', 'fr', 'de'] }, i18n: i18nRoot, site_url: { type: 'string', examples: ['https://example.com'] }, diff --git a/packages/decap-cms-core/src/lib/__tests__/serializeEntryValues.spec.js b/packages/decap-cms-core/src/lib/__tests__/serializeEntryValues.spec.js index 755ce69894c1..4b2aa66be9c8 100644 --- a/packages/decap-cms-core/src/lib/__tests__/serializeEntryValues.spec.js +++ b/packages/decap-cms-core/src/lib/__tests__/serializeEntryValues.spec.js @@ -2,12 +2,18 @@ import { fromJS } from 'immutable'; import { serializeValues, deserializeValues } from '../serializeEntryValues'; -const values = fromJS({ title: 'New Post', unknown: 'Unknown Field' }); -const fields = fromJS([{ name: 'title', widget: 'string' }]); +const values = fromJS({ title: 'New Post', unknown: 'Unknown Field', removed_image: '' }); +const fields = fromJS([{ name: 'title', widget: 'string' }, { name: 'removed_image', widget: 'image' }]); describe('serializeValues', () => { it('should retain unknown fields', () => { expect(serializeValues(values, fields)).toEqual( + fromJS({ title: 'New Post', unknown: 'Unknown Field', removed_image: '' }), + ); + }); + + it('should remove image field', () => { + expect(serializeValues(values, fields, { remove_empty_image_field: true })).toEqual( fromJS({ title: 'New Post', unknown: 'Unknown Field' }), ); }); @@ -16,7 +22,7 @@ describe('serializeValues', () => { describe('deserializeValues', () => { it('should retain unknown fields', () => { expect(deserializeValues(values, fields)).toEqual( - fromJS({ title: 'New Post', unknown: 'Unknown Field' }), + fromJS({ title: 'New Post', unknown: 'Unknown Field', removed_image: '' }), ); }); }); diff --git a/packages/decap-cms-core/src/lib/serializeEntryValues.js b/packages/decap-cms-core/src/lib/serializeEntryValues.js index 578ffa38aa50..e763e070d08b 100644 --- a/packages/decap-cms-core/src/lib/serializeEntryValues.js +++ b/packages/decap-cms-core/src/lib/serializeEntryValues.js @@ -3,6 +3,7 @@ import { Map, List } from 'immutable'; import { getWidgetValueSerializer } from './registry'; +const FLAG_REMOVE_ENTRY = '!~FLAG_REMOVE_ENTRY~!'; /** * Methods for serializing/deserializing entry field values. Most widgets don't * require this for their values, and those that do can typically serialize/ @@ -21,7 +22,7 @@ import { getWidgetValueSerializer } from './registry'; * registered deserialization handlers run on entry load, and serialization * handlers run on persist. */ -function runSerializer(values, fields, method) { +function runSerializer(values, fields, method, config = {}, isRecursive = false) { /** * Reduce the list of fields to a map where keys are field names and values * are field values, serializing the values of fields whose widgets have @@ -38,13 +39,13 @@ function runSerializer(values, fields, method) { if (nestedFields && List.isList(value)) { return acc.set( fieldName, - value.map(val => runSerializer(val, nestedFields, method)), + value.map(val => runSerializer(val, nestedFields, method, config, true)), ); } // Call recursively for fields within objects if (nestedFields && Map.isMap(value)) { - return acc.set(fieldName, runSerializer(value, nestedFields, method)); + return acc.set(fieldName, runSerializer(value, nestedFields, method, config, true)); } // Run serialization method on value if not null or undefined @@ -52,6 +53,11 @@ function runSerializer(values, fields, method) { return acc.set(fieldName, serializer[method](value)); } + // If widget is image with no value set, flag field for removal + if (config.remove_empty_image_field && !value && field.get('widget') === 'image') { + return acc.set(fieldName, FLAG_REMOVE_ENTRY); + } + // If no serializer is registered for the field's widget, use the field as is if (!isNil(value)) { return acc.set(fieldName, value); @@ -60,14 +66,37 @@ function runSerializer(values, fields, method) { return acc; }, Map()); - //preserve unknown fields value + // preserve unknown fields value serializedData = values.mergeDeep(serializedData); + // Remove only on the top level, otherwise `mergeDeep` will reinsert them. + if (config.remove_empty_image_field && !isRecursive) { + serializedData = serializedData.map(v => removeEntriesRecursive(v)) + .filter(v => v !== FLAG_REMOVE_ENTRY); + } + return serializedData; } -export function serializeValues(values, fields) { - return runSerializer(values, fields, 'serialize'); +function removeEntriesRecursive(entry) { + if (List.isList(entry)) { + return entry.map(v => removeEntriesRecursive(v)).filter(v => v !== FLAG_REMOVE_ENTRY); + } else if (Map.isMap(entry)) { + let updatedEntry = entry; + entry.forEach((v, k) => { + if (Map.isMap(v) || List.isList(v)) { + updatedEntry = updatedEntry.set(k, removeEntriesRecursive(v)); + } else if (v === FLAG_REMOVE_ENTRY) { + updatedEntry = updatedEntry.delete(k); + } + }); + return updatedEntry; + } + return entry; +} + +export function serializeValues(values, fields, config) { + return runSerializer(values, fields, 'serialize', config); } export function deserializeValues(values, fields) { diff --git a/packages/decap-cms-core/src/types/redux.ts b/packages/decap-cms-core/src/types/redux.ts index b69a82311532..3a06535007a0 100644 --- a/packages/decap-cms-core/src/types/redux.ts +++ b/packages/decap-cms-core/src/types/redux.ts @@ -414,6 +414,7 @@ export interface CmsConfig { slug?: CmsSlug; i18n?: CmsI18nConfig; local_backend?: boolean | CmsLocalBackend; + remove_empty_image_field?: boolean; editor?: { preview?: boolean; }; From 9b70e0c2dc9f5c776223961fdd39b5d0b68b11d3 Mon Sep 17 00:00:00 2001 From: hip3r Date: Tue, 22 Oct 2024 19:46:46 +0200 Subject: [PATCH 2/3] fix: lint errors --- .../src/lib/__tests__/serializeEntryValues.spec.js | 5 ++++- packages/decap-cms-core/src/lib/serializeEntryValues.js | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/decap-cms-core/src/lib/__tests__/serializeEntryValues.spec.js b/packages/decap-cms-core/src/lib/__tests__/serializeEntryValues.spec.js index 4b2aa66be9c8..147697874d61 100644 --- a/packages/decap-cms-core/src/lib/__tests__/serializeEntryValues.spec.js +++ b/packages/decap-cms-core/src/lib/__tests__/serializeEntryValues.spec.js @@ -3,7 +3,10 @@ import { fromJS } from 'immutable'; import { serializeValues, deserializeValues } from '../serializeEntryValues'; const values = fromJS({ title: 'New Post', unknown: 'Unknown Field', removed_image: '' }); -const fields = fromJS([{ name: 'title', widget: 'string' }, { name: 'removed_image', widget: 'image' }]); +const fields = fromJS([ + { name: 'title', widget: 'string' }, + { name: 'removed_image', widget: 'image' }, +]); describe('serializeValues', () => { it('should retain unknown fields', () => { diff --git a/packages/decap-cms-core/src/lib/serializeEntryValues.js b/packages/decap-cms-core/src/lib/serializeEntryValues.js index e763e070d08b..706d39df1d69 100644 --- a/packages/decap-cms-core/src/lib/serializeEntryValues.js +++ b/packages/decap-cms-core/src/lib/serializeEntryValues.js @@ -71,7 +71,8 @@ function runSerializer(values, fields, method, config = {}, isRecursive = false) // Remove only on the top level, otherwise `mergeDeep` will reinsert them. if (config.remove_empty_image_field && !isRecursive) { - serializedData = serializedData.map(v => removeEntriesRecursive(v)) + serializedData = serializedData + .map(v => removeEntriesRecursive(v)) .filter(v => v !== FLAG_REMOVE_ENTRY); } From a085a380ada0488191a92f75692b28133bf7461e Mon Sep 17 00:00:00 2001 From: hip3r Date: Tue, 29 Oct 2024 17:40:30 +0100 Subject: [PATCH 3/3] refactor: remove images from content without changing values --- .../src/lib/serializeEntryValues.js | 53 +++++++++++-------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/packages/decap-cms-core/src/lib/serializeEntryValues.js b/packages/decap-cms-core/src/lib/serializeEntryValues.js index 706d39df1d69..09e3c8f604c7 100644 --- a/packages/decap-cms-core/src/lib/serializeEntryValues.js +++ b/packages/decap-cms-core/src/lib/serializeEntryValues.js @@ -3,7 +3,8 @@ import { Map, List } from 'immutable'; import { getWidgetValueSerializer } from './registry'; -const FLAG_REMOVE_ENTRY = '!~FLAG_REMOVE_ENTRY~!'; +const _pathsToRemove = new Set(); + /** * Methods for serializing/deserializing entry field values. Most widgets don't * require this for their values, and those that do can typically serialize/ @@ -22,7 +23,7 @@ const FLAG_REMOVE_ENTRY = '!~FLAG_REMOVE_ENTRY~!'; * registered deserialization handlers run on entry load, and serialization * handlers run on persist. */ -function runSerializer(values, fields, method, config = {}, isRecursive = false) { +function runSerializer(values, fields, method, config = {}, isRecursive = false, currentPath = '') { /** * Reduce the list of fields to a map where keys are field names and values * are field values, serializing the values of fields whose widgets have @@ -34,18 +35,21 @@ function runSerializer(values, fields, method, config = {}, isRecursive = false) const value = values.get(fieldName); const serializer = getWidgetValueSerializer(field.get('widget')); const nestedFields = field.get('fields'); + const newPath = currentPath ? `${currentPath}.${fieldName}` : fieldName; // Call recursively for fields within lists if (nestedFields && List.isList(value)) { return acc.set( fieldName, - value.map(val => runSerializer(val, nestedFields, method, config, true)), + value.map((val, index) => + runSerializer(val, nestedFields, method, config, true, `${newPath}.${index}`), + ), ); } // Call recursively for fields within objects if (nestedFields && Map.isMap(value)) { - return acc.set(fieldName, runSerializer(value, nestedFields, method, config, true)); + return acc.set(fieldName, runSerializer(value, nestedFields, method, config, true, newPath)); } // Run serialization method on value if not null or undefined @@ -55,7 +59,7 @@ function runSerializer(values, fields, method, config = {}, isRecursive = false) // If widget is image with no value set, flag field for removal if (config.remove_empty_image_field && !value && field.get('widget') === 'image') { - return acc.set(fieldName, FLAG_REMOVE_ENTRY); + _pathsToRemove.add(newPath); } // If no serializer is registered for the field's widget, use the field as is @@ -71,29 +75,34 @@ function runSerializer(values, fields, method, config = {}, isRecursive = false) // Remove only on the top level, otherwise `mergeDeep` will reinsert them. if (config.remove_empty_image_field && !isRecursive) { - serializedData = serializedData - .map(v => removeEntriesRecursive(v)) - .filter(v => v !== FLAG_REMOVE_ENTRY); + serializedData = removeEntriesByPaths(serializedData, _pathsToRemove); + _pathsToRemove.clear(); } return serializedData; } -function removeEntriesRecursive(entry) { - if (List.isList(entry)) { - return entry.map(v => removeEntriesRecursive(v)).filter(v => v !== FLAG_REMOVE_ENTRY); - } else if (Map.isMap(entry)) { - let updatedEntry = entry; - entry.forEach((v, k) => { - if (Map.isMap(v) || List.isList(v)) { - updatedEntry = updatedEntry.set(k, removeEntriesRecursive(v)); - } else if (v === FLAG_REMOVE_ENTRY) { - updatedEntry = updatedEntry.delete(k); - } - }); - return updatedEntry; +function removeEntriesByPaths(data, paths) { + paths.forEach(path => { + data = removeEntryByPath(data, path.split('.')); + }); + return data; +} + +function removeEntryByPath(data, keys) { + if (keys.length === 1) { + return data.delete(keys[0]); } - return entry; + + const [firstKey, ...restKeys] = keys; + const nestedData = data.get(firstKey); + + if (nestedData) { + const updatedNestedData = removeEntryByPath(nestedData, restKeys); + return data.set(firstKey, updatedNestedData); + } + + return data; } export function serializeValues(values, fields, config) {