decap-cms-core 3.9.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/decap-cms-core.js +23 -23
  2. package/dist/decap-cms-core.js.LICENSE.txt +10 -0
  3. package/dist/decap-cms-core.js.map +1 -1
  4. package/dist/esm/actions/config.js +19 -0
  5. package/dist/esm/actions/entries.js +10 -1
  6. package/dist/esm/bootstrap.js +2 -2
  7. package/dist/esm/components/App/StatusBar.js +41 -0
  8. package/dist/esm/components/Collection/Entries/Pagination.js +132 -0
  9. package/dist/esm/components/Editor/EditorNotesPane/AddNoteForm.js +17 -14
  10. package/dist/esm/components/UI/ErrorBoundary.js +6 -9
  11. package/dist/esm/constants/configSchema.js +41 -23
  12. package/dist/esm/lib/entryCache.js +145 -0
  13. package/dist/esm/lib/entryHelpers.js +102 -0
  14. package/dist/esm/lib/formatters.js +2 -1
  15. package/dist/esm/lib/immutableHelpers.js +21 -0
  16. package/dist/esm/lib/indexFileHelper.js +36 -0
  17. package/dist/esm/lib/pagination.js +68 -0
  18. package/dist/esm/reducers/collections.js +54 -5
  19. package/dist/esm/reducers/entries.js +8 -2
  20. package/index.d.ts +8 -2
  21. package/package.json +2 -3
  22. package/src/actions/__tests__/config.spec.js +4 -4
  23. package/src/actions/config.ts +22 -0
  24. package/src/actions/entries.ts +11 -1
  25. package/src/components/UI/ErrorBoundary.js +1 -2
  26. package/src/constants/__tests__/configSchema.spec.js +84 -0
  27. package/src/constants/configSchema.js +34 -1
  28. package/src/lib/__tests__/formatters.spec.js +30 -2
  29. package/src/lib/formatters.ts +6 -1
  30. package/src/reducers/__tests__/collections.spec.js +39 -0
  31. package/src/reducers/collections.ts +52 -5
  32. package/src/reducers/entries.ts +12 -2
  33. package/src/types/redux.ts +9 -3
@@ -171,6 +171,20 @@ function hasIntegration(config: CmsConfig, collection: CmsCollection) {
171
171
  return !!integration;
172
172
  }
173
173
 
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
+
174
188
  export function normalizeConfig(config: CmsConfig) {
175
189
  const { collections = [] } = config;
176
190
 
@@ -200,6 +214,14 @@ export function normalizeConfig(config: CmsConfig) {
200
214
  );
201
215
  }
202
216
 
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
+
203
225
  return normalizedCollection;
204
226
  });
205
227
 
@@ -3,7 +3,7 @@ import isEqual from 'lodash/isEqual';
3
3
  import { Cursor } from 'decap-cms-lib-util';
4
4
 
5
5
  import { selectCollectionEntriesCursor } from '../reducers/cursors';
6
- import { selectFields, updateFieldByKey } from '../reducers/collections';
6
+ import { selectFields, updateFieldByKey, selectDefaultSortField } from '../reducers/collections';
7
7
  import { selectIntegration, selectPublishedSlugs } from '../reducers';
8
8
  import { getIntegrationProvider } from '../integrations';
9
9
  import { currentBackend } from '../backend';
@@ -579,11 +579,21 @@ export function loadEntries(collection: Collection, page = 0) {
579
579
  }
580
580
  const state = getState();
581
581
  const sortFields = selectEntriesSortFields(state.entries, collection.get('name'));
582
+
583
+ // If user has already set a sort, use it
582
584
  if (sortFields && sortFields.length > 0) {
583
585
  const field = sortFields[0];
584
586
  return dispatch(sortByField(collection, field.get('key'), field.get('direction')));
585
587
  }
586
588
 
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
+
587
597
  const backend = currentBackend(state.config);
588
598
  const integration = selectIntegration(state, collection.get('name'), 'listEntries');
589
599
  const provider = integration
@@ -7,7 +7,6 @@ import truncate from 'lodash/truncate';
7
7
  import copyToClipboard from 'copy-text-to-clipboard';
8
8
  import { localForage } from 'decap-cms-lib-util';
9
9
  import { buttons, colors } from 'decap-cms-ui-default';
10
- import cleanStack from 'clean-stack';
11
10
 
12
11
  const ISSUE_URL = 'https://github.com/decaporg/decap-cms/issues/new?';
13
12
 
@@ -145,7 +144,7 @@ export class ErrorBoundary extends React.Component {
145
144
  console.error(error);
146
145
  return {
147
146
  hasError: true,
148
- errorMessage: cleanStack(error.stack, { basePath: window.location.origin || '' }),
147
+ errorMessage: error.stack || error.toString(),
149
148
  errorTitle: error.toString(),
150
149
  };
151
150
  }
@@ -216,6 +216,90 @@ describe('config', () => {
216
216
  }).toThrowError("'collections[0]' must NOT be valid");
217
217
  });
218
218
 
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
+
219
303
  it('should throw if collection names are not unique', () => {
220
304
  expect(() => {
221
305
  validateConfig(
@@ -253,7 +253,21 @@ function getConfigSchema() {
253
253
  sortable_fields: {
254
254
  type: 'array',
255
255
  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
+ ],
257
271
  },
258
272
  },
259
273
  sortableFields: {
@@ -405,4 +419,23 @@ export function validateConfig(config) {
405
419
  console.error('Config Errors', errors);
406
420
  throw new ConfigError(errors);
407
421
  }
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
+ }
408
441
  }
@@ -274,8 +274,8 @@ describe('formatters', () => {
274
274
  };
275
275
 
276
276
  describe('slugFormatter', () => {
277
- const date = new Date('2020-01-01');
278
- jest.spyOn(global, 'Date').mockImplementation(() => date);
277
+ const date = new Date('2020-01-01').valueOf();
278
+ Date.now = jest.spyOn(Date, 'now').mockImplementation(() => date);
279
279
 
280
280
  const { selectIdentifier } = require('../../reducers/collections');
281
281
 
@@ -312,6 +312,34 @@ describe('formatters', () => {
312
312
  ).toBe('entry-slug');
313
313
  });
314
314
 
315
+ it('should see date filters applied to date from entry if it exists', () => {
316
+ const { selectInferredField } = require('../../reducers/collections');
317
+ selectInferredField.mockReturnValue('date');
318
+ const entryDate = new Date('2026-10-20');
319
+
320
+ expect(
321
+ slugFormatter(
322
+ Map({ slug: '{{year}}-{{month}}-{{day}}-{{title}}' }),
323
+ Map({ date: entryDate, title: 'post title' }),
324
+ slugConfig,
325
+ ),
326
+ ).toBe('2026-10-20-post-title');
327
+ });
328
+
329
+ it('should see date filters applied to publishDate from entry if it exists', () => {
330
+ const { selectInferredField } = require('../../reducers/collections');
331
+ selectInferredField.mockReturnValue('publishDate');
332
+ const entryDate = new Date('2026-10-20');
333
+
334
+ expect(
335
+ slugFormatter(
336
+ Map({ slug: '{{year}}-{{month}}-{{day}}-{{title}}' }),
337
+ Map({ publishDate: entryDate, title: 'post title' }),
338
+ slugConfig,
339
+ ),
340
+ ).toBe('2026-10-20-post-title');
341
+ });
342
+
315
343
  it('should return slug', () => {
316
344
  selectIdentifier.mockReturnValueOnce('title');
317
345
 
@@ -21,6 +21,7 @@ import type { Map } from 'immutable';
21
21
  const {
22
22
  compileStringTemplate,
23
23
  parseDateFromEntry,
24
+ parseDateFromEntryData,
24
25
  SLUG_MISSING_REQUIRED_DATE,
25
26
  keyToPathArray,
26
27
  addFileTemplateFields,
@@ -129,7 +130,11 @@ export function slugFormatter(
129
130
  }
130
131
 
131
132
  const processSegment = getProcessSegment(slugConfig);
132
- const date = new Date();
133
+ const date =
134
+ parseDateFromEntryData(
135
+ entryData as unknown as Map<string, unknown>,
136
+ selectInferredField(collection, 'date'),
137
+ ) || new Date(Date.now());
133
138
  const slug = compileStringTemplate(slugTemplate, date, identifier, entryData, processSegment);
134
139
 
135
140
  if (!collection.has('path')) {
@@ -11,6 +11,7 @@ import collections, {
11
11
  getFieldsNames,
12
12
  selectField,
13
13
  updateFieldByKey,
14
+ selectInferredField,
14
15
  } from '../collections';
15
16
  import { FILES, FOLDER } from '../../constants/collectionTypes';
16
17
 
@@ -568,4 +569,42 @@ describe('collections', () => {
568
569
  );
569
570
  });
570
571
  });
572
+
573
+ describe("selectInferredField(collection, 'date')", () => {
574
+ it('should return publishDate if set', () => {
575
+ const collection = fromJS({
576
+ fields: [{ name: 'title' }, { name: 'publishDate', widget: 'datetime' }],
577
+ });
578
+
579
+ expect(selectInferredField(collection, 'date')).toEqual('publishDate');
580
+ });
581
+
582
+ it('should return publish_date if set', () => {
583
+ const collection = fromJS({
584
+ fields: [{ name: 'title' }, { name: 'publish_date', widget: 'datetime' }],
585
+ });
586
+
587
+ expect(selectInferredField(collection, 'date')).toEqual('publish_date');
588
+ });
589
+
590
+ it('should return date if set', () => {
591
+ const collection = fromJS({
592
+ fields: [{ name: 'title' }, { name: 'date', widget: 'datetime' }],
593
+ });
594
+
595
+ expect(selectInferredField(collection, 'date')).toEqual('date');
596
+ });
597
+
598
+ it('should return first date field if multiple synonyms are present', () => {
599
+ const collection = fromJS({
600
+ fields: [
601
+ { name: 'title' },
602
+ { name: 'publishDate', widget: 'datetime' },
603
+ { name: 'date', widget: 'datetime' },
604
+ ],
605
+ });
606
+
607
+ expect(selectInferredField(collection, 'date')).toEqual('publishDate');
608
+ });
609
+ });
571
610
  });
@@ -412,23 +412,46 @@ export function selectDefaultSortableFields(
412
412
  defaultSortable = [COMMIT_DATE, ...defaultSortable];
413
413
  }
414
414
 
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
+ }[];
416
421
  }
417
422
 
418
423
  export function selectSortableFields(collection: Collection, t: (key: string) => string) {
419
424
  const fields = collection
420
425
  .get('sortable_fields')
421
426
  .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
+
423
432
  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 } };
425
435
  }
426
436
  const field = selectField(collection, key);
427
437
  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 };
429
447
  }
430
448
 
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 };
432
455
  })
433
456
  .filter(item => !!item.field)
434
457
  .map(item => ({ ...item.field, key: item.key }));
@@ -436,6 +459,30 @@ export function selectSortableFields(collection: Collection, t: (key: string) =>
436
459
  return fields;
437
460
  }
438
461
 
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
+
439
486
  export function selectSortDataPath(collection: Collection, key: string) {
440
487
  if (key === COMMIT_DATE) {
441
488
  return 'updatedOn';
@@ -78,6 +78,14 @@ let slug: string;
78
78
 
79
79
  const storageSortKey = 'decap-cms.entries.sort';
80
80
  const viewStyleKey = 'decap-cms.entries.viewStyle';
81
+
82
+ function normalizeDoubleSlashes(path: string) {
83
+ if (!path) {
84
+ return path;
85
+ }
86
+
87
+ return path.replace(/([^:]\/)\/+/g, '$1');
88
+ }
81
89
  type StorageSortObject = SortObject & { index: number };
82
90
  type StorageSort = { [collection: string]: { [key: string]: StorageSortObject } };
83
91
 
@@ -785,12 +793,14 @@ export function selectMediaFilePublicPath(
785
793
  }
786
794
 
787
795
  const name = 'public_folder';
788
- let publicFolder = config[name]!;
796
+ let publicFolder = normalizeDoubleSlashes(config[name]!);
789
797
 
790
798
  const customFolder = hasCustomFolder(name, collection, entryMap?.get('slug'), field);
791
799
 
792
800
  if (customFolder) {
793
- publicFolder = evaluateFolder(name, config, collection!, entryMap, field);
801
+ publicFolder = normalizeDoubleSlashes(
802
+ evaluateFolder(name, config, collection!, entryMap, field),
803
+ );
794
804
  }
795
805
 
796
806
  if (isAbsolutePath(publicFolder)) {
@@ -309,6 +309,12 @@ export interface ViewGroup {
309
309
  id: string;
310
310
  }
311
311
 
312
+ export interface SortableField {
313
+ field: string;
314
+ label?: string;
315
+ default_sort?: boolean | 'asc' | 'desc';
316
+ }
317
+
312
318
  export interface CmsCollection {
313
319
  name: string;
314
320
  label: string;
@@ -348,7 +354,7 @@ export interface CmsCollection {
348
354
  path?: string;
349
355
  media_folder?: string;
350
356
  public_folder?: string;
351
- sortable_fields?: string[];
357
+ sortable_fields?: (string | SortableField)[];
352
358
  view_filters?: ViewFilter[];
353
359
  view_groups?: ViewGroup[];
354
360
  i18n?: boolean | CmsI18nConfig;
@@ -356,7 +362,7 @@ export interface CmsCollection {
356
362
  /**
357
363
  * @deprecated Use sortable_fields instead
358
364
  */
359
- sortableFields?: string[];
365
+ sortableFields?: (string | SortableField)[];
360
366
  }
361
367
 
362
368
  export interface CmsBackend {
@@ -635,7 +641,7 @@ type CollectionObject = {
635
641
  slug?: string;
636
642
  label_singular?: string;
637
643
  label: string;
638
- sortable_fields: List<string>;
644
+ sortable_fields: List<StaticallyTypedRecord<SortableField>>;
639
645
  view_filters: List<StaticallyTypedRecord<ViewFilter>>;
640
646
  view_groups: List<StaticallyTypedRecord<ViewGroup>>;
641
647
  nested?: Nested;