Skip to content

Commit 5e782ee

Browse files
feat: add default_sort option to sortable fields (#7665)
1 parent 977d99c commit 5e782ee

File tree

10 files changed

+232
-14
lines changed

10 files changed

+232
-14
lines changed

dev-test/config.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ collections: # A list of collections the CMS should be able to edit
1919
create: true # Allow users to create new documents in this collection
2020
editor:
2121
visualEditing: true
22+
sortable_fields:
23+
- title
24+
- { field: date, default_sort: desc }
25+
- draft
2226
view_filters:
2327
- label: Posts With Index
2428
field: title

dev-test/index.html

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,17 @@
5050
}
5151

5252
var ONE_DAY = 60 * 60 * 24 * 1000;
53+
var ONE_WEEK = ONE_DAY * 7;
5354

5455
for (var i=1; i<=20; i++) {
5556
var date = new Date();
5657

57-
date.setTime(date.getTime() + ONE_DAY);
58+
if (i % 2 === 0) {
59+
date.setTime(date.getTime() + ONE_DAY);
60+
} else {
61+
date.setTime(date.getTime() - ONE_WEEK);
62+
}
63+
5864
var month = ('0' + (date.getMonth()+1)).slice(-2)
5965
var day = ('0' + (date.getDate())).slice(-2)
6066
var dateString = '' + date.getFullYear() + '-' + month + '-' + day;

packages/decap-cms-core/index.d.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,12 @@ declare module 'decap-cms-core' {
292292
pattern?: string;
293293
}
294294

295+
export interface SortableField {
296+
field: string;
297+
label?: string;
298+
default_sort?: boolean | 'asc' | 'desc';
299+
}
300+
295301
export interface CmsCollection {
296302
name: string;
297303
label: string;
@@ -332,15 +338,15 @@ declare module 'decap-cms-core' {
332338
path?: string;
333339
media_folder?: string;
334340
public_folder?: string;
335-
sortable_fields?: string[];
341+
sortable_fields?: (string | SortableField)[];
336342
view_filters?: ViewFilter[];
337343
view_groups?: ViewGroup[];
338344
i18n?: boolean | CmsI18nConfig;
339345

340346
/**
341347
* @deprecated Use sortable_fields instead
342348
*/
343-
sortableFields?: string[];
349+
sortableFields?: (string | SortableField)[];
344350
}
345351

346352
export interface CmsBackend {

packages/decap-cms-core/src/actions/__tests__/config.spec.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -480,7 +480,7 @@ describe('config', () => {
480480
).toEqual({
481481
collections: [
482482
{
483-
sortable_fields: ['title'],
483+
sortable_fields: [{ field: 'title', default_sort: undefined }],
484484
folder: 'src',
485485
type: 'folder_based_collection',
486486
view_filters: [],

packages/decap-cms-core/src/actions/config.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,20 @@ function hasIntegration(config: CmsConfig, collection: CmsCollection) {
171171
return !!integration;
172172
}
173173

174+
function normalizeSortableFields(
175+
sortableFields: (
176+
| string
177+
| { field: string; label?: string; default_sort?: boolean | 'asc' | 'desc' }
178+
)[],
179+
) {
180+
return sortableFields.map(field => {
181+
if (typeof field === 'string') {
182+
return { field, default_sort: undefined };
183+
}
184+
return field;
185+
});
186+
}
187+
174188
export function normalizeConfig(config: CmsConfig) {
175189
const { collections = [] } = config;
176190

@@ -200,6 +214,14 @@ export function normalizeConfig(config: CmsConfig) {
200214
);
201215
}
202216

217+
// Normalize sortable_fields to consistent object format
218+
if (normalizedCollection.sortable_fields) {
219+
normalizedCollection = {
220+
...normalizedCollection,
221+
sortable_fields: normalizeSortableFields(normalizedCollection.sortable_fields),
222+
};
223+
}
224+
203225
return normalizedCollection;
204226
});
205227

packages/decap-cms-core/src/actions/entries.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import isEqual from 'lodash/isEqual';
33
import { Cursor } from 'decap-cms-lib-util';
44

55
import { selectCollectionEntriesCursor } from '../reducers/cursors';
6-
import { selectFields, updateFieldByKey } from '../reducers/collections';
6+
import { selectFields, updateFieldByKey, selectDefaultSortField } from '../reducers/collections';
77
import { selectIntegration, selectPublishedSlugs } from '../reducers';
88
import { getIntegrationProvider } from '../integrations';
99
import { currentBackend } from '../backend';
@@ -579,11 +579,21 @@ export function loadEntries(collection: Collection, page = 0) {
579579
}
580580
const state = getState();
581581
const sortFields = selectEntriesSortFields(state.entries, collection.get('name'));
582+
583+
// If user has already set a sort, use it
582584
if (sortFields && sortFields.length > 0) {
583585
const field = sortFields[0];
584586
return dispatch(sortByField(collection, field.get('key'), field.get('direction')));
585587
}
586588

589+
// Otherwise, check for a default sort field in the collection configuration
590+
const defaultSort = selectDefaultSortField(collection);
591+
if (defaultSort) {
592+
const direction =
593+
defaultSort.direction === 'desc' ? SortDirection.Descending : SortDirection.Ascending;
594+
return dispatch(sortByField(collection, defaultSort.field, direction));
595+
}
596+
587597
const backend = currentBackend(state.config);
588598
const integration = selectIntegration(state, collection.get('name'), 'listEntries');
589599
const provider = integration

packages/decap-cms-core/src/constants/__tests__/configSchema.spec.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,90 @@ describe('config', () => {
216216
}).toThrowError("'collections[0]' must NOT be valid");
217217
});
218218

219+
it('should allow sortable_fields to have object format with field property', () => {
220+
expect(() => {
221+
validateConfig(
222+
merge({}, validConfig, {
223+
collections: [{ sortable_fields: [{ field: 'title' }] }],
224+
}),
225+
);
226+
}).not.toThrow();
227+
});
228+
229+
it('should allow sortable_fields with default_sort as boolean', () => {
230+
expect(() => {
231+
validateConfig(
232+
merge({}, validConfig, {
233+
collections: [{ sortable_fields: [{ field: 'title', default_sort: true }] }],
234+
}),
235+
);
236+
}).not.toThrow();
237+
});
238+
239+
it('should allow sortable_fields with default_sort as asc/desc', () => {
240+
expect(() => {
241+
validateConfig(
242+
merge({}, validConfig, {
243+
collections: [{ sortable_fields: ['title', { field: 'date', default_sort: 'desc' }] }],
244+
}),
245+
);
246+
}).not.toThrow();
247+
});
248+
249+
it('should allow sortable_fields with custom label', () => {
250+
expect(() => {
251+
validateConfig(
252+
merge({}, validConfig, {
253+
collections: [{ sortable_fields: [{ field: 'date', label: 'Publish Date' }] }],
254+
}),
255+
);
256+
}).not.toThrow();
257+
});
258+
259+
it('should allow sortable_fields with label and default_sort', () => {
260+
expect(() => {
261+
validateConfig(
262+
merge({}, validConfig, {
263+
collections: [
264+
{
265+
sortable_fields: [
266+
'title',
267+
{ field: 'date', label: 'Publish Date', default_sort: 'desc' },
268+
],
269+
},
270+
],
271+
}),
272+
);
273+
}).not.toThrow();
274+
});
275+
276+
it('should allow mixed string and object format in sortable_fields', () => {
277+
expect(() => {
278+
validateConfig(
279+
merge({}, validConfig, {
280+
collections: [{ sortable_fields: ['title', { field: 'date', default_sort: true }] }],
281+
}),
282+
);
283+
}).not.toThrow();
284+
});
285+
286+
it('should throw if more than one sortable field has default_sort property', () => {
287+
expect(() => {
288+
validateConfig(
289+
merge({}, validConfig, {
290+
collections: [
291+
{
292+
sortable_fields: [
293+
{ field: 'title', default_sort: true },
294+
{ field: 'date', default_sort: true },
295+
],
296+
},
297+
],
298+
}),
299+
);
300+
}).toThrowError('only one sortable field can have the default_sort property');
301+
});
302+
219303
it('should throw if collection names are not unique', () => {
220304
expect(() => {
221305
validateConfig(

packages/decap-cms-core/src/constants/configSchema.js

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,21 @@ function getConfigSchema() {
253253
sortable_fields: {
254254
type: 'array',
255255
items: {
256-
type: 'string',
256+
oneOf: [
257+
{ type: 'string' },
258+
{
259+
type: 'object',
260+
properties: {
261+
field: { type: 'string' },
262+
label: { type: 'string' },
263+
default_sort: {
264+
oneOf: [{ type: 'boolean' }, { type: 'string', enum: ['asc', 'desc'] }],
265+
},
266+
},
267+
required: ['field'],
268+
additionalProperties: false,
269+
},
270+
],
257271
},
258272
},
259273
sortableFields: {
@@ -405,4 +419,23 @@ export function validateConfig(config) {
405419
console.error('Config Errors', errors);
406420
throw new ConfigError(errors);
407421
}
422+
423+
// Custom validation: only one sortable field can have default_sort property
424+
if (config.collections) {
425+
config.collections.forEach((collection, index) => {
426+
if (collection.sortable_fields) {
427+
const defaultFields = collection.sortable_fields.filter(
428+
field => typeof field === 'object' && field.default_sort !== undefined,
429+
);
430+
if (defaultFields.length > 1) {
431+
const error = {
432+
instancePath: `/collections/${index}/sortable_fields`,
433+
message: 'only one sortable field can have the default_sort property',
434+
};
435+
console.error('Config Errors', [error]);
436+
throw new ConfigError([error]);
437+
}
438+
}
439+
});
440+
}
408441
}

packages/decap-cms-core/src/reducers/collections.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -412,30 +412,77 @@ export function selectDefaultSortableFields(
412412
defaultSortable = [COMMIT_DATE, ...defaultSortable];
413413
}
414414

415-
return defaultSortable as string[];
415+
// Return as objects with field property
416+
return defaultSortable.map(field => ({ field })) as {
417+
field: string;
418+
label?: string;
419+
default_sort?: boolean | 'asc' | 'desc';
420+
}[];
416421
}
417422

418423
export function selectSortableFields(collection: Collection, t: (key: string) => string) {
419424
const fields = collection
420425
.get('sortable_fields')
421426
.toArray()
422-
.map(key => {
427+
.map(sortableField => {
428+
// Extract the field name and custom label from the sortable field object
429+
const key = sortableField.get('field');
430+
const customLabel = sortableField.get('label');
431+
423432
if (key === COMMIT_DATE) {
424-
return { key, field: { name: key, label: t('collection.defaultFields.updatedOn.label') } };
433+
const label = customLabel || t('collection.defaultFields.updatedOn.label');
434+
return { key, field: { name: key, label } };
425435
}
426436
const field = selectField(collection, key);
427437
if (key === COMMIT_AUTHOR && !field) {
428-
return { key, field: { name: key, label: t('collection.defaultFields.author.label') } };
438+
const label = customLabel || t('collection.defaultFields.author.label');
439+
return { key, field: { name: key, label } };
440+
}
441+
442+
let fieldObj: Record<string, unknown> | undefined = field?.toJS();
443+
444+
// If custom label is provided, override the field's label
445+
if (fieldObj && customLabel) {
446+
fieldObj = { ...fieldObj, label: customLabel };
429447
}
430448

431-
return { key, field: field?.toJS() };
449+
// If no label exists at all, use the field name
450+
if (fieldObj && !fieldObj.label) {
451+
fieldObj = { ...fieldObj, label: (fieldObj.name as string) || key };
452+
}
453+
454+
return { key, field: fieldObj };
432455
})
433456
.filter(item => !!item.field)
434457
.map(item => ({ ...item.field, key: item.key }));
435458

436459
return fields;
437460
}
438461

462+
export function selectDefaultSortField(collection: Collection) {
463+
const sortableFields = collection.get('sortable_fields').toArray();
464+
const defaultField = sortableFields.find(field => field.get('default_sort') !== undefined);
465+
466+
if (!defaultField) {
467+
return null;
468+
}
469+
470+
const fieldName = defaultField.get('field');
471+
const defaultSortValue = defaultField.get('default_sort');
472+
473+
// Determine direction based on default_sort value
474+
let direction;
475+
if (defaultSortValue === true || defaultSortValue === 'asc') {
476+
direction = 'asc';
477+
} else if (defaultSortValue === 'desc') {
478+
direction = 'desc';
479+
} else {
480+
direction = 'asc'; // fallback
481+
}
482+
483+
return { field: fieldName, direction };
484+
}
485+
439486
export function selectSortDataPath(collection: Collection, key: string) {
440487
if (key === COMMIT_DATE) {
441488
return 'updatedOn';

packages/decap-cms-core/src/types/redux.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,12 @@ export interface ViewGroup {
309309
id: string;
310310
}
311311

312+
export interface SortableField {
313+
field: string;
314+
label?: string;
315+
default_sort?: boolean | 'asc' | 'desc';
316+
}
317+
312318
export interface CmsCollection {
313319
name: string;
314320
label: string;
@@ -348,15 +354,15 @@ export interface CmsCollection {
348354
path?: string;
349355
media_folder?: string;
350356
public_folder?: string;
351-
sortable_fields?: string[];
357+
sortable_fields?: (string | SortableField)[];
352358
view_filters?: ViewFilter[];
353359
view_groups?: ViewGroup[];
354360
i18n?: boolean | CmsI18nConfig;
355361

356362
/**
357363
* @deprecated Use sortable_fields instead
358364
*/
359-
sortableFields?: string[];
365+
sortableFields?: (string | SortableField)[];
360366
}
361367

362368
export interface CmsBackend {
@@ -635,7 +641,7 @@ type CollectionObject = {
635641
slug?: string;
636642
label_singular?: string;
637643
label: string;
638-
sortable_fields: List<string>;
644+
sortable_fields: List<StaticallyTypedRecord<SortableField>>;
639645
view_filters: List<StaticallyTypedRecord<ViewFilter>>;
640646
view_groups: List<StaticallyTypedRecord<ViewGroup>>;
641647
nested?: Nested;

0 commit comments

Comments
 (0)