Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/decap-cms-core/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/decap-cms-core/src/actions/editorialWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')));
Expand Down
7 changes: 4 additions & 3 deletions packages/decap-cms-core/src/actions/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import type {
ViewFilter,
ViewGroup,
Entry,
CmsConfig,
} from '../types/redux';
import type { EntryValue } from '../valueObjects/Entry';
import type { Backend } from '../backend';
Expand Down Expand Up @@ -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.
Expand All @@ -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'));
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' }] }));
Expand Down
4 changes: 4 additions & 0 deletions packages/decap-cms-core/src/constants/configSchema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'] },
Expand Down
Original file line number Diff line number Diff line change
@@ -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: '' }),
);
});
});
71 changes: 61 additions & 10 deletions packages/decap-cms-core/src/lib/serializeEntryValues.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions packages/decap-cms-core/src/types/redux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading