decap-cms-core 3.8.1 → 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.
- package/dist/decap-cms-core.js +23 -23
- package/dist/decap-cms-core.js.LICENSE.txt +10 -0
- package/dist/decap-cms-core.js.map +1 -1
- package/dist/esm/actions/config.js +19 -0
- package/dist/esm/actions/entries.js +10 -1
- package/dist/esm/bootstrap.js +2 -2
- package/dist/esm/components/App/StatusBar.js +41 -0
- package/dist/esm/components/Collection/Entries/Pagination.js +132 -0
- package/dist/esm/components/Editor/EditorNotesPane/AddNoteForm.js +111 -0
- package/dist/esm/components/Editor/EditorNotesPane/EditorNotesPane.js +138 -0
- package/dist/esm/components/Editor/EditorNotesPane/NoteItem.js +247 -0
- package/dist/esm/components/Editor/EditorNotesPane/NotesList.js +54 -0
- package/dist/esm/components/UI/ErrorBoundary.js +6 -9
- package/dist/esm/constants/configSchema.js +41 -23
- package/dist/esm/lib/entryCache.js +145 -0
- package/dist/esm/lib/entryHelpers.js +102 -0
- package/dist/esm/lib/formatters.js +2 -1
- package/dist/esm/lib/immutableHelpers.js +21 -0
- package/dist/esm/lib/indexFileHelper.js +36 -0
- package/dist/esm/lib/pagination.js +68 -0
- package/dist/esm/reducers/collections.js +54 -5
- package/dist/esm/reducers/entries.js +9 -4
- package/index.d.ts +10 -2
- package/package.json +2 -3
- package/src/actions/__tests__/config.spec.js +4 -4
- package/src/actions/config.ts +22 -0
- package/src/actions/entries.ts +11 -1
- package/src/components/UI/ErrorBoundary.js +1 -2
- package/src/constants/__tests__/configSchema.spec.js +84 -0
- package/src/constants/configSchema.js +34 -1
- package/src/lib/__tests__/formatters.spec.js +30 -2
- package/src/lib/formatters.ts +6 -1
- package/src/reducers/__tests__/collections.spec.js +39 -0
- package/src/reducers/__tests__/entries.spec.js +3 -3
- package/src/reducers/collections.ts +52 -5
- package/src/reducers/entries.ts +13 -5
- package/src/types/redux.ts +11 -3
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pagination Utilities
|
|
3
|
+
*
|
|
4
|
+
* This module provides helpers for determining if pagination is enabled for a collection,
|
|
5
|
+
* and for retrieving the effective pagination configuration (page size, enabled flag).
|
|
6
|
+
*
|
|
7
|
+
* Principles:
|
|
8
|
+
* - Pagination can be enabled/disabled per collection or globally via config.
|
|
9
|
+
* - If sorting, filtering, or grouping is active, pagination is handled client-side (all entries loaded).
|
|
10
|
+
* - If none are active, server-side pagination is used (only a page of entries loaded at a time).
|
|
11
|
+
* - The effective page size is determined by collection config, then global config, then a default.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* - Use isPaginationEnabled(collection, config) to check if pagination should be active.
|
|
15
|
+
* - Use getPaginationConfig(collection, config) to get the effective page size and enabled flag.
|
|
16
|
+
*/
|
|
17
|
+
import { getValue, isImmutableMap } from './immutableHelpers';
|
|
18
|
+
const DEFAULT_PER_PAGE = 100;
|
|
19
|
+
export function isPaginationEnabled(collection, globalConfig) {
|
|
20
|
+
const pagination = isImmutableMap(collection) ? collection.get('pagination') : collection.pagination;
|
|
21
|
+
if (typeof pagination !== 'undefined') {
|
|
22
|
+
if (typeof pagination === 'boolean') return pagination;
|
|
23
|
+
if (pagination && typeof pagination === 'object') {
|
|
24
|
+
const enabled = getValue(pagination, 'enabled');
|
|
25
|
+
return enabled !== false;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
if (globalConfig?.pagination) {
|
|
30
|
+
if (typeof globalConfig.pagination === 'boolean') return globalConfig.pagination;
|
|
31
|
+
if (typeof globalConfig.pagination === 'object') {
|
|
32
|
+
const enabled = getValue(globalConfig.pagination, 'enabled');
|
|
33
|
+
return enabled !== false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
export function getPaginationConfig(collection, globalConfig) {
|
|
39
|
+
const defaults = {
|
|
40
|
+
enabled: false,
|
|
41
|
+
per_page: DEFAULT_PER_PAGE
|
|
42
|
+
};
|
|
43
|
+
if (globalConfig?.pagination) {
|
|
44
|
+
if (typeof globalConfig.pagination === 'boolean') {
|
|
45
|
+
defaults.enabled = globalConfig.pagination;
|
|
46
|
+
} else if (typeof globalConfig.pagination === 'object') {
|
|
47
|
+
const enabled = getValue(globalConfig.pagination, 'enabled');
|
|
48
|
+
defaults.enabled = enabled !== false;
|
|
49
|
+
const perPage = getValue(globalConfig.pagination, 'per_page');
|
|
50
|
+
defaults.per_page = typeof perPage === 'number' ? perPage : defaults.per_page;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const pagination = isImmutableMap(collection) ? collection.get('pagination') : collection.pagination;
|
|
54
|
+
if (pagination === true) {
|
|
55
|
+
return {
|
|
56
|
+
enabled: true,
|
|
57
|
+
per_page: defaults.per_page
|
|
58
|
+
};
|
|
59
|
+
} else if (typeof pagination === 'object' && pagination !== null) {
|
|
60
|
+
const perPage = getValue(pagination, 'per_page');
|
|
61
|
+
const enabled = getValue(pagination, 'enabled');
|
|
62
|
+
return {
|
|
63
|
+
enabled: enabled !== false,
|
|
64
|
+
per_page: typeof perPage === 'number' ? perPage : defaults.per_page
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return defaults;
|
|
68
|
+
}
|
|
@@ -305,32 +305,58 @@ export function selectDefaultSortableFields(collection, backend, hasIntegration)
|
|
|
305
305
|
// always have commit date by default
|
|
306
306
|
defaultSortable = [COMMIT_DATE, ...defaultSortable];
|
|
307
307
|
}
|
|
308
|
-
|
|
308
|
+
|
|
309
|
+
// Return as objects with field property
|
|
310
|
+
return defaultSortable.map(field => ({
|
|
311
|
+
field
|
|
312
|
+
}));
|
|
309
313
|
}
|
|
310
314
|
export function selectSortableFields(collection, t) {
|
|
311
|
-
const fields = collection.get('sortable_fields').toArray().map(
|
|
315
|
+
const fields = collection.get('sortable_fields').toArray().map(sortableField => {
|
|
316
|
+
// Extract the field name and custom label from the sortable field object
|
|
317
|
+
const key = sortableField.get('field');
|
|
318
|
+
const customLabel = sortableField.get('label');
|
|
312
319
|
if (key === COMMIT_DATE) {
|
|
320
|
+
const label = customLabel || t('collection.defaultFields.updatedOn.label');
|
|
313
321
|
return {
|
|
314
322
|
key,
|
|
315
323
|
field: {
|
|
316
324
|
name: key,
|
|
317
|
-
label
|
|
325
|
+
label
|
|
318
326
|
}
|
|
319
327
|
};
|
|
320
328
|
}
|
|
321
329
|
const field = selectField(collection, key);
|
|
322
330
|
if (key === COMMIT_AUTHOR && !field) {
|
|
331
|
+
const label = customLabel || t('collection.defaultFields.author.label');
|
|
323
332
|
return {
|
|
324
333
|
key,
|
|
325
334
|
field: {
|
|
326
335
|
name: key,
|
|
327
|
-
label
|
|
336
|
+
label
|
|
328
337
|
}
|
|
329
338
|
};
|
|
330
339
|
}
|
|
340
|
+
let fieldObj = field?.toJS();
|
|
341
|
+
|
|
342
|
+
// If custom label is provided, override the field's label
|
|
343
|
+
if (fieldObj && customLabel) {
|
|
344
|
+
fieldObj = {
|
|
345
|
+
...fieldObj,
|
|
346
|
+
label: customLabel
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// If no label exists at all, use the field name
|
|
351
|
+
if (fieldObj && !fieldObj.label) {
|
|
352
|
+
fieldObj = {
|
|
353
|
+
...fieldObj,
|
|
354
|
+
label: fieldObj.name || key
|
|
355
|
+
};
|
|
356
|
+
}
|
|
331
357
|
return {
|
|
332
358
|
key,
|
|
333
|
-
field:
|
|
359
|
+
field: fieldObj
|
|
334
360
|
};
|
|
335
361
|
}).filter(item => !!item.field).map(item => ({
|
|
336
362
|
...item.field,
|
|
@@ -338,6 +364,29 @@ export function selectSortableFields(collection, t) {
|
|
|
338
364
|
}));
|
|
339
365
|
return fields;
|
|
340
366
|
}
|
|
367
|
+
export function selectDefaultSortField(collection) {
|
|
368
|
+
const sortableFields = collection.get('sortable_fields').toArray();
|
|
369
|
+
const defaultField = sortableFields.find(field => field.get('default_sort') !== undefined);
|
|
370
|
+
if (!defaultField) {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
const fieldName = defaultField.get('field');
|
|
374
|
+
const defaultSortValue = defaultField.get('default_sort');
|
|
375
|
+
|
|
376
|
+
// Determine direction based on default_sort value
|
|
377
|
+
let direction;
|
|
378
|
+
if (defaultSortValue === true || defaultSortValue === 'asc') {
|
|
379
|
+
direction = 'asc';
|
|
380
|
+
} else if (defaultSortValue === 'desc') {
|
|
381
|
+
direction = 'desc';
|
|
382
|
+
} else {
|
|
383
|
+
direction = 'asc'; // fallback
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
field: fieldName,
|
|
387
|
+
direction
|
|
388
|
+
};
|
|
389
|
+
}
|
|
341
390
|
export function selectSortDataPath(collection, key) {
|
|
342
391
|
if (key === COMMIT_DATE) {
|
|
343
392
|
return 'updatedOn';
|
|
@@ -25,6 +25,12 @@ let page;
|
|
|
25
25
|
let slug;
|
|
26
26
|
const storageSortKey = 'decap-cms.entries.sort';
|
|
27
27
|
const viewStyleKey = 'decap-cms.entries.viewStyle';
|
|
28
|
+
function normalizeDoubleSlashes(path) {
|
|
29
|
+
if (!path) {
|
|
30
|
+
return path;
|
|
31
|
+
}
|
|
32
|
+
return path.replace(/([^:]\/)\/+/g, '$1');
|
|
33
|
+
}
|
|
28
34
|
const loadSort = once(() => {
|
|
29
35
|
const sortString = localStorage.getItem(storageSortKey);
|
|
30
36
|
if (sortString) {
|
|
@@ -429,7 +435,6 @@ export function selectEntriesLoaded(state, collection) {
|
|
|
429
435
|
export function selectIsFetching(state, collection) {
|
|
430
436
|
return state.getIn(['pages', collection, 'isFetching'], false);
|
|
431
437
|
}
|
|
432
|
-
const DRAFT_MEDIA_FILES = 'DRAFT_MEDIA_FILES';
|
|
433
438
|
function getFileField(collectionFiles, slug) {
|
|
434
439
|
const file = collectionFiles.find(f => f?.get('name') === slug);
|
|
435
440
|
return file;
|
|
@@ -528,7 +533,7 @@ export function selectMediaFolder(config, collection, entryMap, field) {
|
|
|
528
533
|
mediaFolder = join(folder);
|
|
529
534
|
} else {
|
|
530
535
|
const entryPath = entryMap?.get('path');
|
|
531
|
-
mediaFolder = entryPath ? join(dirname(entryPath), folder) :
|
|
536
|
+
mediaFolder = entryPath ? join(dirname(entryPath), folder) : collection.get('folder');
|
|
532
537
|
}
|
|
533
538
|
}
|
|
534
539
|
return trim(mediaFolder, '/');
|
|
@@ -545,10 +550,10 @@ export function selectMediaFilePublicPath(config, collection, mediaPath, entryMa
|
|
|
545
550
|
return mediaPath;
|
|
546
551
|
}
|
|
547
552
|
const name = 'public_folder';
|
|
548
|
-
let publicFolder = config[name];
|
|
553
|
+
let publicFolder = normalizeDoubleSlashes(config[name]);
|
|
549
554
|
const customFolder = hasCustomFolder(name, collection, entryMap?.get('slug'), field);
|
|
550
555
|
if (customFolder) {
|
|
551
|
-
publicFolder = evaluateFolder(name, config, collection, entryMap, field);
|
|
556
|
+
publicFolder = normalizeDoubleSlashes(evaluateFolder(name, config, collection, entryMap, field));
|
|
552
557
|
}
|
|
553
558
|
if (isAbsolutePath(publicFolder)) {
|
|
554
559
|
return joinUrlPath(publicFolder, basename(mediaPath));
|
package/index.d.ts
CHANGED
|
@@ -133,6 +133,8 @@ declare module 'decap-cms-core' {
|
|
|
133
133
|
default?: any;
|
|
134
134
|
|
|
135
135
|
allow_add?: boolean;
|
|
136
|
+
allow_remove?: boolean;
|
|
137
|
+
allow_reorder?: boolean;
|
|
136
138
|
collapsed?: boolean;
|
|
137
139
|
summary?: string;
|
|
138
140
|
minimize_collapsed?: boolean;
|
|
@@ -290,6 +292,12 @@ declare module 'decap-cms-core' {
|
|
|
290
292
|
pattern?: string;
|
|
291
293
|
}
|
|
292
294
|
|
|
295
|
+
export interface SortableField {
|
|
296
|
+
field: string;
|
|
297
|
+
label?: string;
|
|
298
|
+
default_sort?: boolean | 'asc' | 'desc';
|
|
299
|
+
}
|
|
300
|
+
|
|
293
301
|
export interface CmsCollection {
|
|
294
302
|
name: string;
|
|
295
303
|
label: string;
|
|
@@ -330,7 +338,7 @@ declare module 'decap-cms-core' {
|
|
|
330
338
|
path?: string;
|
|
331
339
|
media_folder?: string;
|
|
332
340
|
public_folder?: string;
|
|
333
|
-
sortable_fields?: string[];
|
|
341
|
+
sortable_fields?: (string | SortableField)[];
|
|
334
342
|
view_filters?: ViewFilter[];
|
|
335
343
|
view_groups?: ViewGroup[];
|
|
336
344
|
i18n?: boolean | CmsI18nConfig;
|
|
@@ -338,7 +346,7 @@ declare module 'decap-cms-core' {
|
|
|
338
346
|
/**
|
|
339
347
|
* @deprecated Use sortable_fields instead
|
|
340
348
|
*/
|
|
341
|
-
sortableFields?: string[];
|
|
349
|
+
sortableFields?: (string | SortableField)[];
|
|
342
350
|
}
|
|
343
351
|
|
|
344
352
|
export interface CmsBackend {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "decap-cms-core",
|
|
3
3
|
"description": "Decap CMS core application, see decap-cms package for the main distribution.",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.10.0",
|
|
5
5
|
"repository": "https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-core",
|
|
6
6
|
"bugs": "https://github.com/decaporg/decap-cms/issues",
|
|
7
7
|
"module": "dist/esm/index.js",
|
|
@@ -28,7 +28,6 @@
|
|
|
28
28
|
"@reduxjs/toolkit": "^1.9.1",
|
|
29
29
|
"@vercel/stega": "^0.1.2",
|
|
30
30
|
"buffer": "^6.0.3",
|
|
31
|
-
"clean-stack": "^5.2.0",
|
|
32
31
|
"copy-text-to-clipboard": "^3.0.0",
|
|
33
32
|
"dayjs": "^1.11.10",
|
|
34
33
|
"deepmerge": "^4.2.2",
|
|
@@ -100,5 +99,5 @@
|
|
|
100
99
|
"browser": {
|
|
101
100
|
"path": "path-browserify"
|
|
102
101
|
},
|
|
103
|
-
"gitHead": "
|
|
102
|
+
"gitHead": "0c1b7c7d63ba6ddb3de3692fbb6b16ac6ebb44d5"
|
|
104
103
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { stripIndent } from 'common-tags';
|
|
2
|
-
import {
|
|
2
|
+
import { stringify } from 'yaml';
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
loadConfig,
|
|
@@ -480,7 +480,7 @@ describe('config', () => {
|
|
|
480
480
|
).toEqual({
|
|
481
481
|
collections: [
|
|
482
482
|
{
|
|
483
|
-
sortable_fields: ['title'],
|
|
483
|
+
sortable_fields: [{ field: 'title', default_sort: undefined }],
|
|
484
484
|
folder: 'src',
|
|
485
485
|
type: 'folder_based_collection',
|
|
486
486
|
view_filters: [],
|
|
@@ -934,7 +934,7 @@ describe('config', () => {
|
|
|
934
934
|
|
|
935
935
|
global.fetch.mockResolvedValue({
|
|
936
936
|
status: 200,
|
|
937
|
-
text: () => Promise.resolve(
|
|
937
|
+
text: () => Promise.resolve(stringify({ backend: { repo: 'test-repo' } })),
|
|
938
938
|
headers: new Headers(),
|
|
939
939
|
});
|
|
940
940
|
await loadConfig()(dispatch);
|
|
@@ -962,7 +962,7 @@ describe('config', () => {
|
|
|
962
962
|
document.querySelector.mockReturnValue({ type: 'text/yaml', href: 'custom-config.yml' });
|
|
963
963
|
global.fetch.mockResolvedValue({
|
|
964
964
|
status: 200,
|
|
965
|
-
text: () => Promise.resolve(
|
|
965
|
+
text: () => Promise.resolve(stringify({ backend: { repo: 'github' } })),
|
|
966
966
|
headers: new Headers(),
|
|
967
967
|
});
|
|
968
968
|
await loadConfig()(dispatch);
|
package/src/actions/config.ts
CHANGED
|
@@ -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
|
|
package/src/actions/entries.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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(
|
|
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
|
|
package/src/lib/formatters.ts
CHANGED
|
@@ -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 =
|
|
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
|
});
|
|
@@ -85,7 +85,7 @@ describe('entries', () => {
|
|
|
85
85
|
).toEqual('static/media');
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
-
it('should return
|
|
88
|
+
it('should return collection folder when collection specifies media_folder and entry is undefined', () => {
|
|
89
89
|
expect(
|
|
90
90
|
selectMediaFolder(
|
|
91
91
|
{ media_folder: 'static/media' },
|
|
@@ -93,7 +93,7 @@ describe('entries', () => {
|
|
|
93
93
|
undefined,
|
|
94
94
|
undefined,
|
|
95
95
|
),
|
|
96
|
-
).toEqual('posts
|
|
96
|
+
).toEqual('posts');
|
|
97
97
|
});
|
|
98
98
|
|
|
99
99
|
it('should return relative media folder when collection specifies media_folder and entry path is not null', () => {
|
|
@@ -364,7 +364,7 @@ describe('entries', () => {
|
|
|
364
364
|
'image.png',
|
|
365
365
|
undefined,
|
|
366
366
|
),
|
|
367
|
-
).toBe('posts/
|
|
367
|
+
).toBe('posts/image.png');
|
|
368
368
|
});
|
|
369
369
|
|
|
370
370
|
it('should handle relative media_folder', () => {
|