diff --git a/packages/decap-cms-core/index.d.ts b/packages/decap-cms-core/index.d.ts index 6531f251ea28..6501dc3b54ad 100644 --- a/packages/decap-cms-core/index.d.ts +++ b/packages/decap-cms-core/index.d.ts @@ -438,6 +438,7 @@ declare module 'decap-cms-core' { slug?: CmsSlug; i18n?: CmsI18nConfig; local_backend?: boolean | CmsLocalBackend; + remove_empty_fields?: string[]; editor?: { notes?: boolean; preview?: boolean; diff --git a/packages/decap-cms-core/src/actions/editorialWorkflow.ts b/packages/decap-cms-core/src/actions/editorialWorkflow.ts index ee5b9d817fdd..ec394100ed2d 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 c9aa04b3f40c..aacdf550f9e8 100644 --- a/packages/decap-cms-core/src/actions/entries.ts +++ b/packages/decap-cms-core/src/actions/entries.ts @@ -41,6 +41,7 @@ import type { ViewFilter, ViewGroup, Entry, + CmsConfig, } from '../types/redux'; import type { EntryValue } from '../valueObjects/Entry'; import type { Backend } from '../backend'; @@ -931,7 +932,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. @@ -940,7 +941,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')); @@ -985,7 +986,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 7a377761f0fc..38b0bd69a706 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,24 @@ describe('config', () => { }).not.toThrowError(); }); + it('should throw if remove_empty_fields is not an array', () => { + expect(() => { + validateConfig(merge({}, validConfig, { remove_empty_fields: 'image' })); + }).toThrowError("'remove_empty_fields' must be array"); + }); + + it('should throw if remove_empty_fields contains a non-string value', () => { + expect(() => { + validateConfig(merge({}, validConfig, { remove_empty_fields: ['image', false] })); + }).toThrowError("'remove_empty_fields[1]' must be string"); + }); + + it('should not throw if remove_empty_fields is a string array', () => { + expect(() => { + validateConfig(merge({}, validConfig, { remove_empty_fields: ['image', 'string'] })); + }).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 1bcddcc274f8..413152061262 100644 --- a/packages/decap-cms-core/src/constants/configSchema.js +++ b/packages/decap-cms-core/src/constants/configSchema.js @@ -155,6 +155,10 @@ function getConfigSchema() { }, ], }, + remove_empty_fields: { + type: 'array', + items: { type: 'string' }, + }, 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..791d9b34ae5f 100644 --- a/packages/decap-cms-core/src/lib/__tests__/serializeEntryValues.spec.js +++ b/packages/decap-cms-core/src/lib/__tests__/serializeEntryValues.spec.js @@ -1,22 +1,118 @@ import { fromJS } from 'immutable'; import { serializeValues, deserializeValues } from '../serializeEntryValues'; +import { registerWidgetValueSerializer } from '../registry'; -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' }), + fromJS({ title: 'New Post', unknown: 'Unknown Field', removed_image: '' }), ); }); + + it('should remove empty fields for configured widgets', () => { + const configuredValues = values.merge(fromJS({ removed_title: '', count: 0, featured: false })); + const configuredFields = fields.concat( + fromJS([ + { name: 'removed_title', widget: 'string' }, + { name: 'count', widget: 'number' }, + { name: 'featured', widget: 'boolean' }, + ]), + ); + + expect( + serializeValues(configuredValues, configuredFields, { + remove_empty_fields: ['image', 'string', 'number', 'boolean'], + }), + ).toEqual( + fromJS({ + title: 'New Post', + unknown: 'Unknown Field', + count: 0, + featured: false, + }), + ); + }); + + it('should remove nested fields and preserve dots in field names', () => { + const nestedValues = fromJS({ + sections: [{ image: '', 'hero.image': '' }, { image: 'image.jpg' }], + }); + const nestedFields = fromJS([ + { + name: 'sections', + widget: 'list', + fields: [ + { name: 'image', widget: 'image' }, + { name: 'hero.image', widget: 'image' }, + ], + }, + ]); + + expect(serializeValues(nestedValues, nestedFields, { remove_empty_fields: ['image'] })).toEqual( + fromJS({ sections: [{}, { image: 'image.jpg' }] }), + ); + }); + + it('should not retain removal paths after a serializer throws', () => { + registerWidgetValueSerializer('throwing-widget', { + serialize: () => { + throw new Error('Serialization failed'); + }, + }); + const failingFields = fromJS([ + { name: 'removed_image', widget: 'image' }, + { name: 'content', widget: 'throwing-widget' }, + ]); + + expect(() => + serializeValues(fromJS({ removed_image: '', content: 'value' }), failingFields, { + remove_empty_fields: ['image'], + }), + ).toThrow('Serialization failed'); + + expect( + serializeValues( + fromJS({ removed_image: 'image.jpg' }), + fromJS([{ name: 'removed_image', widget: 'image' }]), + { remove_empty_fields: ['image'] }, + ), + ).toEqual(fromJS({ removed_image: 'image.jpg' })); + }); + + it('should retain empty fields for widgets that are not configured', () => { + expect(serializeValues(values, fields, { remove_empty_fields: ['string'] })).toEqual( + fromJS({ title: 'New Post', unknown: 'Unknown Field', removed_image: '' }), + ); + }); + + it('should treat fields without a widget as string fields', () => { + expect( + serializeValues(fromJS({ subtitle: '' }), fromJS([{ name: 'subtitle' }]), { + remove_empty_fields: ['string'], + }), + ).toEqual(fromJS({})); + }); + + it('should remove null fields for configured widgets', () => { + expect( + serializeValues(fromJS({ image: null }), fromJS([{ name: 'image', widget: 'image' }]), { + remove_empty_fields: ['image'], + }), + ).toEqual(fromJS({})); + }); }); 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 84013a534cd2..c1f2ef8aa890 100644 --- a/packages/decap-cms-core/src/lib/serializeEntryValues.js +++ b/packages/decap-cms-core/src/lib/serializeEntryValues.js @@ -21,30 +21,52 @@ 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, + removeEmptyFields = [], + pathsToRemove = [], + 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 * registered serializers. If the field is a list or object, call recursively * for nested fields. */ - let serializedData = fields.reduce((acc, field) => { + const serializedData = fields.reduce((acc, field) => { const fieldName = field.get('name'); const value = values.get(fieldName); - const serializer = getWidgetValueSerializer(field.get('widget')); + const widget = field.get('widget', 'string'); + const serializer = getWidgetValueSerializer(widget); const nestedFields = field.get('fields'); + const newPath = [...currentPath, 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, removeEmptyFields, pathsToRemove, [ + ...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, removeEmptyFields, pathsToRemove, newPath), + ); + } + + if (removeEmptyFields.includes(widget) && (isNil(value) || value === '')) { + pathsToRemove.push(newPath); + return acc; } // Run serialization method on value if not null or undefined @@ -60,14 +82,43 @@ function runSerializer(values, fields, method) { return acc; }, Map()); - //preserve unknown fields value - serializedData = values.mergeDeep(serializedData); + // preserve unknown fields value + return values.mergeDeep(serializedData); +} + +function removeEntriesByPaths(data, paths) { + paths.forEach(path => { + data = removeEntryByPath(data, path); + }); + 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 serializedData; + return data; } -export function serializeValues(values, fields) { - return runSerializer(values, fields, 'serialize'); +export function serializeValues(values, fields, config) { + const pathsToRemove = []; + const serializedValues = runSerializer( + values, + fields, + 'serialize', + config?.remove_empty_fields, + pathsToRemove, + ); + return removeEntriesByPaths(serializedValues, pathsToRemove); } 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 670d6525a6e5..8aac4cdbfa3f 100644 --- a/packages/decap-cms-core/src/types/redux.ts +++ b/packages/decap-cms-core/src/types/redux.ts @@ -475,6 +475,7 @@ export interface CmsConfig { i18n?: CmsI18nConfig; issue_reports?: CmsIssueReports; local_backend?: boolean | CmsLocalBackend; + remove_empty_fields?: string[]; editor?: { preview?: boolean; notes?: boolean;