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..147697874d61 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,21 @@ 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 +25,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..09e3c8f604c7 100644 --- a/packages/decap-cms-core/src/lib/serializeEntryValues.js +++ b/packages/decap-cms-core/src/lib/serializeEntryValues.js @@ -3,6 +3,8 @@ import { Map, List } from 'immutable'; import { getWidgetValueSerializer } from './registry'; +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/ @@ -21,7 +23,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, 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 @@ -33,18 +35,21 @@ function runSerializer(values, fields, method) { 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)), + 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)); + return acc.set(fieldName, runSerializer(value, nestedFields, method, config, true, newPath)); } // Run serialization method on value if not null or undefined @@ -52,6 +57,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') { + _pathsToRemove.add(newPath); + } + // 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 +70,43 @@ 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 = removeEntriesByPaths(serializedData, _pathsToRemove); + _pathsToRemove.clear(); + } + return serializedData; } -export function serializeValues(values, fields) { - return runSerializer(values, fields, 'serialize'); +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]); + } + + 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) { + 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; };