decap-cms-core 3.13.0 → 3.15.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 +9 -9
- package/dist/decap-cms-core.js.map +1 -1
- package/dist/esm/actions/config.js +14 -1
- package/dist/esm/actions/entries.js +15 -4
- package/dist/esm/backend.js +2 -0
- package/dist/esm/bootstrap.js +2 -2
- package/dist/esm/components/App/App.js +15 -8
- package/dist/esm/components/App/Header.js +18 -18
- package/dist/esm/components/Collection/Collection.js +7 -7
- package/dist/esm/components/Collection/CollectionControls.js +1 -1
- package/dist/esm/components/Collection/CollectionTop.js +8 -8
- package/dist/esm/components/Collection/ControlButton.js +2 -2
- package/dist/esm/components/Collection/Entries/Entries.js +2 -2
- package/dist/esm/components/Collection/Entries/EntryCard.js +30 -15
- package/dist/esm/components/Collection/NestedCollection.js +20 -11
- package/dist/esm/components/Collection/Sidebar.js +6 -6
- package/dist/esm/components/Editor/EditorPreviewPane/EditorPreviewPane.js +22 -6
- package/dist/esm/components/MediaLibrary/MediaLibraryButtons.js +48 -9
- package/dist/esm/components/MediaLibrary/MediaLibraryCard.js +7 -9
- package/dist/esm/components/MediaLibrary/MediaLibraryCardGrid.js +5 -5
- package/dist/esm/components/MediaLibrary/MediaLibraryHeader.js +15 -3
- package/dist/esm/components/MediaLibrary/MediaLibraryModal.js +4 -1
- package/dist/esm/components/MediaLibrary/MediaLibrarySearch.js +6 -6
- package/dist/esm/components/MediaLibrary/MediaLibraryTop.js +14 -24
- package/dist/esm/components/UI/ErrorBoundary.js +2 -2
- package/dist/esm/components/UI/Modal.js +11 -4
- package/dist/esm/components/UI/SettingsDropdown.js +25 -27
- package/dist/esm/components/Workflow/Workflow.js +5 -5
- package/dist/esm/components/Workflow/WorkflowCard.js +14 -14
- package/dist/esm/components/Workflow/WorkflowList.js +18 -42
- package/dist/esm/constants/configSchema.js +2 -3
- package/dist/esm/lib/registry.js +4 -1
- package/dist/esm/reducers/entries.js +9 -7
- package/dist/esm/reducers/entryDraft.js +39 -7
- package/dist/esm/reducers/mediaLibrary.js +2 -3
- package/dist/esm/reducers/notifications.js +1 -2
- package/index.d.ts +19 -3
- package/package.json +7 -8
- package/src/__tests__/backend.spec.js +214 -0
- package/src/actions/__tests__/config.spec.js +14 -0
- package/src/actions/__tests__/editorialWorkflow.spec.js +1 -3
- package/src/actions/__tests__/entries.spec.js +36 -1
- package/src/actions/config.ts +13 -1
- package/src/actions/entries.ts +22 -7
- package/src/backend.ts +2 -0
- package/src/components/App/App.js +22 -14
- package/src/components/App/Header.js +36 -11
- package/src/components/Collection/Collection.js +10 -3
- package/src/components/Collection/CollectionControls.js +4 -3
- package/src/components/Collection/CollectionTop.js +3 -2
- package/src/components/Collection/ControlButton.js +1 -0
- package/src/components/Collection/Entries/Entries.js +1 -0
- package/src/components/Collection/Entries/EntryCard.js +13 -3
- package/src/components/Collection/NestedCollection.js +14 -7
- package/src/components/Collection/Sidebar.js +8 -7
- package/src/components/Collection/__tests__/NestedCollection.spec.js +1 -1
- package/src/components/Collection/__tests__/__snapshots__/Collection.spec.js.snap +76 -12
- package/src/components/Collection/__tests__/__snapshots__/NestedCollection.spec.js.snap +0 -68
- package/src/components/Collection/__tests__/__snapshots__/Sidebar.spec.js.snap +76 -16
- package/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js +6 -5
- package/src/components/Editor/__tests__/__snapshots__/EditorToolbar.spec.js.snap +104 -72
- package/src/components/MediaLibrary/MediaLibraryButtons.js +39 -7
- package/src/components/MediaLibrary/MediaLibraryCard.js +1 -3
- package/src/components/MediaLibrary/MediaLibraryCardGrid.js +2 -2
- package/src/components/MediaLibrary/MediaLibraryHeader.js +32 -17
- package/src/components/MediaLibrary/MediaLibraryModal.js +8 -4
- package/src/components/MediaLibrary/MediaLibrarySearch.js +3 -1
- package/src/components/MediaLibrary/MediaLibraryTop.js +19 -1
- package/src/components/MediaLibrary/__tests__/__snapshots__/MediaLibraryCard.spec.js.snap +6 -6
- package/src/components/UI/Modal.js +10 -3
- package/src/components/UI/SettingsDropdown.js +36 -9
- package/src/components/Workflow/Workflow.js +7 -1
- package/src/components/Workflow/WorkflowCard.js +52 -14
- package/src/components/Workflow/WorkflowList.js +50 -31
- package/src/constants/__tests__/configSchema.spec.js +9 -6
- package/src/constants/configSchema.js +2 -3
- package/src/lib/__tests__/formatters.spec.js +16 -4
- package/src/lib/__tests__/registry.spec.js +3 -3
- package/src/lib/registry.js +4 -1
- package/src/reducers/__tests__/entryDraft.spec.js +118 -1
- package/src/reducers/__tests__/mediaLibrary.spec.js +0 -1
- package/src/reducers/entries.ts +9 -7
- package/src/reducers/entryDraft.js +46 -7
- package/src/reducers/mediaLibrary.ts +2 -3
- package/src/reducers/notifications.ts +1 -2
- package/src/types/redux.ts +22 -5
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import AJV from 'ajv';
|
|
2
2
|
import { select, uniqueItemProperties, instanceof as instanceOf, prohibited } from 'ajv-keywords/dist/keywords';
|
|
3
3
|
import ajvErrors from 'ajv-errors';
|
|
4
|
-
import { v4 as uuid } from 'uuid';
|
|
5
4
|
import { frontmatterFormats, extensionFormatters } from '../formats/formats';
|
|
6
5
|
import { getWidgets } from '../lib/registry';
|
|
7
6
|
import { I18N_STRUCTURE, I18N_FIELD } from '../lib/i18n';
|
|
@@ -49,7 +48,7 @@ const i18nField = {
|
|
|
49
48
|
* Config for fields in both file and folder collections.
|
|
50
49
|
*/
|
|
51
50
|
function fieldsConfig() {
|
|
52
|
-
const id =
|
|
51
|
+
const id = crypto.randomUUID();
|
|
53
52
|
return {
|
|
54
53
|
$id: `fields_${id}`,
|
|
55
54
|
type: 'array',
|
|
@@ -470,7 +469,7 @@ function getConfigSchema() {
|
|
|
470
469
|
type: 'string'
|
|
471
470
|
}
|
|
472
471
|
},
|
|
473
|
-
required: ['label', 'widget'
|
|
472
|
+
required: ['label', 'widget']
|
|
474
473
|
}
|
|
475
474
|
},
|
|
476
475
|
additionalProperties: false,
|
package/dist/esm/lib/registry.js
CHANGED
|
@@ -266,7 +266,10 @@ export async function invokeEvent({
|
|
|
266
266
|
};
|
|
267
267
|
}
|
|
268
268
|
}
|
|
269
|
-
|
|
269
|
+
// Return the full entry object with all metadata (slug, path, meta, etc.)
|
|
270
|
+
// rather than just the data payload. Callers like invokePreSaveEvent expect
|
|
271
|
+
// the complete entry object to be preserved through the event handler chain.
|
|
272
|
+
return _data.entry;
|
|
270
273
|
}
|
|
271
274
|
export function removeEventListener({
|
|
272
275
|
name,
|
|
@@ -379,14 +379,16 @@ function getGroup(entry, selectedGroup) {
|
|
|
379
379
|
if (selectedGroup.has('pattern')) {
|
|
380
380
|
const pattern = selectedGroup.get('pattern');
|
|
381
381
|
let value = '';
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
382
|
+
if (pattern !== undefined) {
|
|
383
|
+
try {
|
|
384
|
+
const regex = new RegExp(pattern);
|
|
385
|
+
const matched = dataAsString.match(regex);
|
|
386
|
+
if (matched) {
|
|
387
|
+
value = matched[0];
|
|
388
|
+
}
|
|
389
|
+
} catch (e) {
|
|
390
|
+
console.warn(`Invalid view group pattern '${pattern}' for field '${field}'`, e);
|
|
387
391
|
}
|
|
388
|
-
} catch (e) {
|
|
389
|
-
console.warn(`Invalid view group pattern '${pattern}' for field '${field}'`, e);
|
|
390
392
|
}
|
|
391
393
|
return {
|
|
392
394
|
id: `${label}${value}`,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Map, List, fromJS } from 'immutable';
|
|
2
|
-
import { v4 as uuid } from 'uuid';
|
|
3
2
|
import get from 'lodash/get';
|
|
4
|
-
import { join } from 'path';
|
|
3
|
+
import { join, basename } from 'path';
|
|
4
|
+
import { sanitizeSlug } from '../lib/urlHelper';
|
|
5
5
|
import { DRAFT_CREATE_FROM_ENTRY, DRAFT_CREATE_EMPTY, DRAFT_DISCARD, DRAFT_CHANGE_FIELD, DRAFT_VALIDATION_ERRORS, DRAFT_CLEAR_ERRORS, DRAFT_LOCAL_BACKUP_RETRIEVED, DRAFT_CREATE_FROM_LOCAL_BACKUP, DRAFT_CREATE_DUPLICATE_FROM_ENTRY, ENTRY_PERSIST_REQUEST, ENTRY_PERSIST_SUCCESS, ENTRY_PERSIST_FAILURE, ENTRY_DELETE_SUCCESS, ADD_DRAFT_ENTRY_MEDIA_FILE, REMOVE_DRAFT_ENTRY_MEDIA_FILE } from '../actions/entries';
|
|
6
6
|
import { UNPUBLISHED_ENTRY_PERSIST_REQUEST, UNPUBLISHED_ENTRY_PERSIST_SUCCESS, UNPUBLISHED_ENTRY_PERSIST_FAILURE, UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST, UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS, UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE, UNPUBLISHED_ENTRY_PUBLISH_REQUEST, UNPUBLISHED_ENTRY_PUBLISH_SUCCESS, UNPUBLISHED_ENTRY_PUBLISH_FAILURE } from '../actions/editorialWorkflow';
|
|
7
7
|
import { selectFolderEntryExtension, selectHasMetaPath } from './collections';
|
|
@@ -23,7 +23,7 @@ function entryDraftReducer(state = Map(), action) {
|
|
|
23
23
|
state.set('fieldsMetaData', Map());
|
|
24
24
|
state.set('fieldsErrors', Map());
|
|
25
25
|
state.set('hasChanged', false);
|
|
26
|
-
state.set('key',
|
|
26
|
+
state.set('key', crypto.randomUUID());
|
|
27
27
|
});
|
|
28
28
|
case DRAFT_CREATE_EMPTY:
|
|
29
29
|
// New Entry
|
|
@@ -33,7 +33,7 @@ function entryDraftReducer(state = Map(), action) {
|
|
|
33
33
|
state.set('fieldsMetaData', Map());
|
|
34
34
|
state.set('fieldsErrors', Map());
|
|
35
35
|
state.set('hasChanged', false);
|
|
36
|
-
state.set('key',
|
|
36
|
+
state.set('key', crypto.randomUUID());
|
|
37
37
|
});
|
|
38
38
|
case DRAFT_CREATE_FROM_LOCAL_BACKUP:
|
|
39
39
|
// Local Backup
|
|
@@ -46,7 +46,7 @@ function entryDraftReducer(state = Map(), action) {
|
|
|
46
46
|
state.set('fieldsMetaData', Map());
|
|
47
47
|
state.set('fieldsErrors', Map());
|
|
48
48
|
state.set('hasChanged', true);
|
|
49
|
-
state.set('key',
|
|
49
|
+
state.set('key', crypto.randomUUID());
|
|
50
50
|
});
|
|
51
51
|
case DRAFT_CREATE_DUPLICATE_FROM_ENTRY:
|
|
52
52
|
// Duplicate Entry
|
|
@@ -164,15 +164,47 @@ function entryDraftReducer(state = Map(), action) {
|
|
|
164
164
|
return state;
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
|
+
function cleanTitleForFilename(title) {
|
|
168
|
+
if (!title) return 'untitled';
|
|
169
|
+
const cleanedTitle = sanitizeSlug(title.toString().toLowerCase().trim(), {
|
|
170
|
+
sanitize_replacement: '-',
|
|
171
|
+
encoding: 'unicode'
|
|
172
|
+
});
|
|
173
|
+
return cleanedTitle || 'untitled';
|
|
174
|
+
}
|
|
167
175
|
export function selectCustomPath(collection, entryDraft) {
|
|
168
176
|
if (!selectHasMetaPath(collection)) {
|
|
169
177
|
return;
|
|
170
178
|
}
|
|
171
179
|
const meta = entryDraft.getIn(['entry', 'meta']);
|
|
172
180
|
const path = meta && meta.get('path');
|
|
173
|
-
|
|
181
|
+
if (!path) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
174
184
|
const extension = selectFolderEntryExtension(collection);
|
|
175
|
-
const
|
|
185
|
+
const indexFile = get(collection.toJS(), ['meta', 'path', 'index_file']);
|
|
186
|
+
|
|
187
|
+
// If index_file is specified, use the old behavior for backward compatibility
|
|
188
|
+
if (indexFile) {
|
|
189
|
+
const customPath = join(collection.get('folder'), path, `${indexFile}.${extension}`);
|
|
190
|
+
return customPath;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// New behavior: generate filename from entry title
|
|
194
|
+
const isNewEntry = entryDraft.getIn(['entry', 'newRecord']);
|
|
195
|
+
const currentPath = entryDraft.getIn(['entry', 'path']);
|
|
196
|
+
let filename;
|
|
197
|
+
if (isNewEntry || !currentPath) {
|
|
198
|
+
// For new entries, generate filename from title
|
|
199
|
+
const entryData = entryDraft.getIn(['entry', 'data']);
|
|
200
|
+
const title = entryData && entryData.get('title');
|
|
201
|
+
filename = cleanTitleForFilename(title);
|
|
202
|
+
} else {
|
|
203
|
+
// For existing entries, preserve the current filename
|
|
204
|
+
const currentFilename = basename(currentPath, `.${extension}`);
|
|
205
|
+
filename = currentFilename;
|
|
206
|
+
}
|
|
207
|
+
const customPath = join(collection.get('folder'), path, `${filename}.${extension}`);
|
|
176
208
|
return customPath;
|
|
177
209
|
}
|
|
178
210
|
export default entryDraftReducer;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Map, List } from 'immutable';
|
|
2
|
-
import { v4 as uuid } from 'uuid';
|
|
3
2
|
import { dirname } from 'path';
|
|
4
3
|
import { MEDIA_LIBRARY_OPEN, MEDIA_LIBRARY_CLOSE, MEDIA_LIBRARY_CREATE, MEDIA_INSERT, MEDIA_REMOVE_INSERTED, MEDIA_LOAD_REQUEST, MEDIA_LOAD_SUCCESS, MEDIA_LOAD_FAILURE, MEDIA_PERSIST_REQUEST, MEDIA_PERSIST_SUCCESS, MEDIA_PERSIST_FAILURE, MEDIA_DELETE_REQUEST, MEDIA_DELETE_SUCCESS, MEDIA_DELETE_FAILURE, MEDIA_DISPLAY_URL_REQUEST, MEDIA_DISPLAY_URL_SUCCESS, MEDIA_DISPLAY_URL_FAILURE } from '../actions/mediaLibrary';
|
|
5
4
|
import { selectEditingDraft, selectMediaFolder } from './entries';
|
|
@@ -110,7 +109,7 @@ function mediaLibrary(state = Map(defaultState), action) {
|
|
|
110
109
|
}
|
|
111
110
|
const filesWithKeys = files.map(file => ({
|
|
112
111
|
...file,
|
|
113
|
-
key:
|
|
112
|
+
key: crypto.randomUUID()
|
|
114
113
|
}));
|
|
115
114
|
return state.withMutations(map => {
|
|
116
115
|
map.set('isLoading', false);
|
|
@@ -151,7 +150,7 @@ function mediaLibrary(state = Map(defaultState), action) {
|
|
|
151
150
|
return state.withMutations(map => {
|
|
152
151
|
const fileWithKey = {
|
|
153
152
|
...file,
|
|
154
|
-
key:
|
|
153
|
+
key: crypto.randomUUID()
|
|
155
154
|
};
|
|
156
155
|
const files = map.get('files');
|
|
157
156
|
const updatedFiles = [fileWithKey, ...files];
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { produce } from 'immer';
|
|
2
|
-
import { v4 as uuid } from 'uuid';
|
|
3
2
|
import { NOTIFICATION_SEND, NOTIFICATION_DISMISS, NOTIFICATIONS_CLEAR } from '../actions/notifications';
|
|
4
3
|
const defaultState = {
|
|
5
4
|
notifications: []
|
|
@@ -14,7 +13,7 @@ const notifications = produce((state, action) => {
|
|
|
14
13
|
break;
|
|
15
14
|
case NOTIFICATION_SEND:
|
|
16
15
|
state.notifications = [...state.notifications, {
|
|
17
|
-
id:
|
|
16
|
+
id: crypto.randomUUID(),
|
|
18
17
|
...action.payload
|
|
19
18
|
}];
|
|
20
19
|
break;
|
package/index.d.ts
CHANGED
|
@@ -56,7 +56,7 @@ declare module 'decap-cms-core' {
|
|
|
56
56
|
label?: string;
|
|
57
57
|
required?: boolean;
|
|
58
58
|
hint?: string;
|
|
59
|
-
pattern?: [string, string];
|
|
59
|
+
pattern?: [string | RegExp, string];
|
|
60
60
|
i18n?: boolean | 'translate' | 'duplicate' | 'none';
|
|
61
61
|
media_folder?: string;
|
|
62
62
|
public_folder?: string;
|
|
@@ -170,6 +170,21 @@ declare module 'decap-cms-core' {
|
|
|
170
170
|
editorComponents?: string[];
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
export interface CmsFieldRichText {
|
|
174
|
+
widget: 'richtext';
|
|
175
|
+
default?: string;
|
|
176
|
+
|
|
177
|
+
minimal?: boolean;
|
|
178
|
+
buttons?: CmsMarkdownWidgetButton[];
|
|
179
|
+
editor_components?: string[];
|
|
180
|
+
modes?: ('raw' | 'rich_text')[];
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @deprecated Use editor_components instead
|
|
184
|
+
*/
|
|
185
|
+
editorComponents?: string[];
|
|
186
|
+
}
|
|
187
|
+
|
|
173
188
|
export interface CmsFieldNumber {
|
|
174
189
|
widget: 'number';
|
|
175
190
|
default?: string | number;
|
|
@@ -257,6 +272,7 @@ declare module 'decap-cms-core' {
|
|
|
257
272
|
| CmsFieldList
|
|
258
273
|
| CmsFieldMap
|
|
259
274
|
| CmsFieldMarkdown
|
|
275
|
+
| CmsFieldRichText
|
|
260
276
|
| CmsFieldNumber
|
|
261
277
|
| CmsFieldObject
|
|
262
278
|
| CmsFieldRelation
|
|
@@ -283,7 +299,7 @@ declare module 'decap-cms-core' {
|
|
|
283
299
|
export interface ViewFilter {
|
|
284
300
|
label: string;
|
|
285
301
|
field: string;
|
|
286
|
-
pattern: string;
|
|
302
|
+
pattern: string | boolean;
|
|
287
303
|
}
|
|
288
304
|
|
|
289
305
|
export interface ViewGroup {
|
|
@@ -323,7 +339,7 @@ declare module 'decap-cms-core' {
|
|
|
323
339
|
depth: number;
|
|
324
340
|
subfolders?: boolean;
|
|
325
341
|
};
|
|
326
|
-
meta?: { path?: { label: string; widget: string; index_file
|
|
342
|
+
meta?: { path?: { label: string; widget: string; index_file?: string } };
|
|
327
343
|
|
|
328
344
|
/**
|
|
329
345
|
* It accepts the following values: yml, yaml, toml, json, md, markdown, html
|
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.15.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",
|
|
@@ -25,19 +25,19 @@
|
|
|
25
25
|
"license": "MIT",
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@iarna/toml": "2.2.5",
|
|
28
|
-
"@reduxjs/toolkit": "^1.9.1",
|
|
29
28
|
"@vercel/stega": "^0.1.2",
|
|
29
|
+
"ajv": "^8.17.1",
|
|
30
|
+
"ajv-errors": "^3.0.0",
|
|
31
|
+
"ajv-keywords": "^5.1.0",
|
|
30
32
|
"buffer": "^6.0.3",
|
|
33
|
+
"common-tags": "^1.8.0",
|
|
31
34
|
"dayjs": "^1.11.10",
|
|
32
35
|
"deepmerge": "^4.2.2",
|
|
33
36
|
"diacritics": "^1.3.0",
|
|
34
37
|
"fuzzy": "^0.1.1",
|
|
35
|
-
"gotrue-js": "^0.9.24",
|
|
36
38
|
"gray-matter": "^4.0.2",
|
|
37
39
|
"history": "^4.7.2",
|
|
38
40
|
"immer": "^9.0.0",
|
|
39
|
-
"js-base64": "^3.0.0",
|
|
40
|
-
"jwt-decode": "^3.0.0",
|
|
41
41
|
"node-polyglot": "^2.3.0",
|
|
42
42
|
"path-browserify": "^1.0.1",
|
|
43
43
|
"prop-types": "^15.7.2",
|
|
@@ -62,11 +62,9 @@
|
|
|
62
62
|
"react-window": "^1.8.5",
|
|
63
63
|
"redux": "^4.0.5",
|
|
64
64
|
"redux-devtools-extension": "^2.13.8",
|
|
65
|
-
"redux-notifications": "^4.0.1",
|
|
66
65
|
"redux-thunk": "^2.3.0",
|
|
67
66
|
"remark-gfm": "1.0.0",
|
|
68
67
|
"sanitize-filename": "^1.6.1",
|
|
69
|
-
"semaphore": "^1.0.5",
|
|
70
68
|
"tomlify-j0.4": "^3.0.0-alpha.0",
|
|
71
69
|
"url": "^0.11.0",
|
|
72
70
|
"url-join": "^4.0.1",
|
|
@@ -93,10 +91,11 @@
|
|
|
93
91
|
"@types/iarna__toml": "^2.0.5",
|
|
94
92
|
"@types/redux-mock-store": "^1.0.2",
|
|
95
93
|
"@types/url-join": "^4.0.0",
|
|
94
|
+
"jest-mock": "^27.5.1",
|
|
96
95
|
"redux-mock-store": "^1.5.3"
|
|
97
96
|
},
|
|
98
97
|
"browser": {
|
|
99
98
|
"path": "path-browserify"
|
|
100
99
|
},
|
|
101
|
-
"gitHead": "
|
|
100
|
+
"gitHead": "567a80101f4846853701ad7d8abdc29b5e4fab56"
|
|
102
101
|
}
|
|
@@ -391,6 +391,68 @@ describe('Backend', () => {
|
|
|
391
391
|
expect(backend.entryToRaw).toHaveBeenCalledTimes(1);
|
|
392
392
|
expect(backend.entryToRaw).toHaveBeenCalledWith(collection, newEntry);
|
|
393
393
|
});
|
|
394
|
+
|
|
395
|
+
it('should preserve slug when preSave event handler modifies file collection entry', async () => {
|
|
396
|
+
const implementation = {
|
|
397
|
+
init: jest.fn(() => implementation),
|
|
398
|
+
persistEntry: jest.fn(() => implementation),
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const config = {
|
|
402
|
+
backend: {
|
|
403
|
+
commit_messages: 'commit-messages',
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
// File collection with a single file
|
|
408
|
+
const collection = Map({
|
|
409
|
+
name: 'settings',
|
|
410
|
+
type: FILES,
|
|
411
|
+
files: List([
|
|
412
|
+
Map({
|
|
413
|
+
name: 'config',
|
|
414
|
+
file: 'data/config.json',
|
|
415
|
+
fields: List([Map({ name: 'title', widget: 'string' })]),
|
|
416
|
+
}),
|
|
417
|
+
]),
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const originalEntry = Map({
|
|
421
|
+
slug: 'config',
|
|
422
|
+
path: 'data/config.json',
|
|
423
|
+
data: Map({ title: 'original' }),
|
|
424
|
+
meta: Map({ path: 'data/config.json' }),
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const entryDraft = Map({
|
|
428
|
+
entry: originalEntry,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const user = { login: 'login', name: 'name' };
|
|
432
|
+
const backend = new Backend(implementation, { config, backendName: 'github' });
|
|
433
|
+
|
|
434
|
+
backend.currentUser = jest.fn().mockResolvedValue(user);
|
|
435
|
+
backend.entryToRaw = jest.fn().mockReturnValue('content');
|
|
436
|
+
|
|
437
|
+
// Mock invokePreSaveEvent to simulate a preSave handler that modifies data
|
|
438
|
+
// This is what happens when custom widgets or event handlers modify entry data
|
|
439
|
+
// The key is that it returns the FULL entry with slug, not just the data
|
|
440
|
+
backend.invokePreSaveEvent = jest.fn().mockImplementation(async entry => {
|
|
441
|
+
// Simulate a preSave handler modifying the data field
|
|
442
|
+
return entry.setIn(['data', 'title'], 'modified');
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
await backend.persistEntry({ config, collection, entryDraft });
|
|
446
|
+
|
|
447
|
+
// Verify entryToRaw was called with an entry that has the slug
|
|
448
|
+
expect(backend.entryToRaw).toHaveBeenCalledTimes(1);
|
|
449
|
+
const entryPassedToRaw = backend.entryToRaw.mock.calls[0][1];
|
|
450
|
+
|
|
451
|
+
// Critical assertion: slug must be preserved
|
|
452
|
+
expect(entryPassedToRaw.get('slug')).toBe('config');
|
|
453
|
+
expect(entryPassedToRaw.get('path')).toBe('data/config.json');
|
|
454
|
+
expect(entryPassedToRaw.getIn(['data', 'title'])).toBe('modified');
|
|
455
|
+
});
|
|
394
456
|
});
|
|
395
457
|
|
|
396
458
|
describe('persistMedia', () => {
|
|
@@ -1006,4 +1068,156 @@ describe('Backend', () => {
|
|
|
1006
1068
|
]);
|
|
1007
1069
|
});
|
|
1008
1070
|
});
|
|
1071
|
+
|
|
1072
|
+
describe('persistEntry with nested collections', () => {
|
|
1073
|
+
it('should pass hasSubfolders=true when subfolders is true (default)', async () => {
|
|
1074
|
+
const implementation = {
|
|
1075
|
+
init: jest.fn(() => implementation),
|
|
1076
|
+
persistEntry: jest.fn(),
|
|
1077
|
+
};
|
|
1078
|
+
|
|
1079
|
+
const config = {
|
|
1080
|
+
backend: { commit_messages: {} },
|
|
1081
|
+
};
|
|
1082
|
+
const collection = Map({
|
|
1083
|
+
name: 'pages',
|
|
1084
|
+
type: FOLDER,
|
|
1085
|
+
folder: '_pages',
|
|
1086
|
+
create: true,
|
|
1087
|
+
fields: List([Map({ name: 'title', widget: 'string' })]),
|
|
1088
|
+
nested: Map({ depth: 10, subfolders: true }),
|
|
1089
|
+
meta: Map({ path: Map({ label: 'Path', widget: 'string' }) }),
|
|
1090
|
+
});
|
|
1091
|
+
const entryDraft = Map({
|
|
1092
|
+
entry: Map({
|
|
1093
|
+
data: Map({ title: 'Test' }),
|
|
1094
|
+
meta: Map({ path: 'blog' }),
|
|
1095
|
+
newRecord: true,
|
|
1096
|
+
}),
|
|
1097
|
+
});
|
|
1098
|
+
const user = { login: 'user', name: 'User' };
|
|
1099
|
+
const backend = new Backend(implementation, { config, backendName: 'test' });
|
|
1100
|
+
|
|
1101
|
+
backend.currentUser = jest.fn().mockResolvedValue(user);
|
|
1102
|
+
backend.entryToRaw = jest.fn().mockReturnValue('content');
|
|
1103
|
+
backend.generateUniqueSlug = jest.fn().mockResolvedValue('test-slug');
|
|
1104
|
+
backend.invokePreSaveEvent = jest.fn().mockResolvedValue(entryDraft.get('entry'));
|
|
1105
|
+
backend.invokePostSaveEvent = jest.fn().mockResolvedValue();
|
|
1106
|
+
|
|
1107
|
+
await backend.persistEntry({
|
|
1108
|
+
config,
|
|
1109
|
+
collection,
|
|
1110
|
+
entryDraft,
|
|
1111
|
+
assetProxies: [],
|
|
1112
|
+
usedSlugs: List(),
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
expect(implementation.persistEntry).toHaveBeenCalledWith(
|
|
1116
|
+
expect.anything(),
|
|
1117
|
+
expect.objectContaining({
|
|
1118
|
+
hasSubfolders: true,
|
|
1119
|
+
}),
|
|
1120
|
+
);
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
it('should pass hasSubfolders=false when subfolders is false', async () => {
|
|
1124
|
+
const implementation = {
|
|
1125
|
+
init: jest.fn(() => implementation),
|
|
1126
|
+
persistEntry: jest.fn(),
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1129
|
+
const config = {
|
|
1130
|
+
backend: { commit_messages: {} },
|
|
1131
|
+
};
|
|
1132
|
+
const collection = Map({
|
|
1133
|
+
name: 'pages',
|
|
1134
|
+
type: FOLDER,
|
|
1135
|
+
folder: '_pages',
|
|
1136
|
+
create: true,
|
|
1137
|
+
fields: List([Map({ name: 'title', widget: 'string' })]),
|
|
1138
|
+
nested: Map({ depth: 10, subfolders: false }),
|
|
1139
|
+
meta: Map({ path: Map({ label: 'Path', widget: 'string' }) }),
|
|
1140
|
+
});
|
|
1141
|
+
const entryDraft = Map({
|
|
1142
|
+
entry: Map({
|
|
1143
|
+
data: Map({ title: 'Test' }),
|
|
1144
|
+
meta: Map({ path: 'blog' }),
|
|
1145
|
+
newRecord: true,
|
|
1146
|
+
}),
|
|
1147
|
+
});
|
|
1148
|
+
const user = { login: 'user', name: 'User' };
|
|
1149
|
+
const backend = new Backend(implementation, { config, backendName: 'test' });
|
|
1150
|
+
|
|
1151
|
+
backend.currentUser = jest.fn().mockResolvedValue(user);
|
|
1152
|
+
backend.entryToRaw = jest.fn().mockReturnValue('content');
|
|
1153
|
+
backend.generateUniqueSlug = jest.fn().mockResolvedValue('test-slug');
|
|
1154
|
+
backend.invokePreSaveEvent = jest.fn().mockResolvedValue(entryDraft.get('entry'));
|
|
1155
|
+
backend.invokePostSaveEvent = jest.fn().mockResolvedValue();
|
|
1156
|
+
|
|
1157
|
+
await backend.persistEntry({
|
|
1158
|
+
config,
|
|
1159
|
+
collection,
|
|
1160
|
+
entryDraft,
|
|
1161
|
+
assetProxies: [],
|
|
1162
|
+
usedSlugs: List(),
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
expect(implementation.persistEntry).toHaveBeenCalledWith(
|
|
1166
|
+
expect.anything(),
|
|
1167
|
+
expect.objectContaining({
|
|
1168
|
+
hasSubfolders: false,
|
|
1169
|
+
}),
|
|
1170
|
+
);
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
it('should default to hasSubfolders=true when subfolders is not specified', async () => {
|
|
1174
|
+
const implementation = {
|
|
1175
|
+
init: jest.fn(() => implementation),
|
|
1176
|
+
persistEntry: jest.fn(),
|
|
1177
|
+
};
|
|
1178
|
+
|
|
1179
|
+
const config = {
|
|
1180
|
+
backend: { commit_messages: {} },
|
|
1181
|
+
};
|
|
1182
|
+
const collection = Map({
|
|
1183
|
+
name: 'pages',
|
|
1184
|
+
type: FOLDER,
|
|
1185
|
+
folder: '_pages',
|
|
1186
|
+
create: true,
|
|
1187
|
+
fields: List([Map({ name: 'title', widget: 'string' })]),
|
|
1188
|
+
nested: Map({ depth: 10 }),
|
|
1189
|
+
meta: Map({ path: Map({ label: 'Path', widget: 'string' }) }),
|
|
1190
|
+
});
|
|
1191
|
+
const entryDraft = Map({
|
|
1192
|
+
entry: Map({
|
|
1193
|
+
data: Map({ title: 'Test' }),
|
|
1194
|
+
meta: Map({ path: 'blog' }),
|
|
1195
|
+
newRecord: true,
|
|
1196
|
+
}),
|
|
1197
|
+
});
|
|
1198
|
+
const user = { login: 'user', name: 'User' };
|
|
1199
|
+
const backend = new Backend(implementation, { config, backendName: 'test' });
|
|
1200
|
+
|
|
1201
|
+
backend.currentUser = jest.fn().mockResolvedValue(user);
|
|
1202
|
+
backend.entryToRaw = jest.fn().mockReturnValue('content');
|
|
1203
|
+
backend.generateUniqueSlug = jest.fn().mockResolvedValue('test-slug');
|
|
1204
|
+
backend.invokePreSaveEvent = jest.fn().mockResolvedValue(entryDraft.get('entry'));
|
|
1205
|
+
backend.invokePostSaveEvent = jest.fn().mockResolvedValue();
|
|
1206
|
+
|
|
1207
|
+
await backend.persistEntry({
|
|
1208
|
+
config,
|
|
1209
|
+
collection,
|
|
1210
|
+
entryDraft,
|
|
1211
|
+
assetProxies: [],
|
|
1212
|
+
usedSlugs: List(),
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
expect(implementation.persistEntry).toHaveBeenCalledWith(
|
|
1216
|
+
expect.anything(),
|
|
1217
|
+
expect.objectContaining({
|
|
1218
|
+
hasSubfolders: true,
|
|
1219
|
+
}),
|
|
1220
|
+
);
|
|
1221
|
+
});
|
|
1222
|
+
});
|
|
1009
1223
|
});
|
|
@@ -862,6 +862,20 @@ describe('config', () => {
|
|
|
862
862
|
|
|
863
863
|
assetFetchCalled('http://192.168.0.1:8081/api/v1');
|
|
864
864
|
});
|
|
865
|
+
|
|
866
|
+
it('should return empty object when local_backend url has an unsafe scheme', async () => {
|
|
867
|
+
window.location = { hostname: 'localhost' };
|
|
868
|
+
global.fetch = jest.fn();
|
|
869
|
+
await expect(detectProxyServer({ url: 'file:///etc/passwd' })).resolves.toEqual({});
|
|
870
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
it('should return empty object when local_backend url is not a valid URL', async () => {
|
|
874
|
+
window.location = { hostname: 'localhost' };
|
|
875
|
+
global.fetch = jest.fn();
|
|
876
|
+
await expect(detectProxyServer({ url: 'not a url' })).resolves.toEqual({});
|
|
877
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
878
|
+
});
|
|
865
879
|
});
|
|
866
880
|
|
|
867
881
|
describe('handleLocalBackend', () => {
|
|
@@ -8,9 +8,7 @@ import * as actions from '../editorialWorkflow';
|
|
|
8
8
|
jest.mock('../../backend');
|
|
9
9
|
jest.mock('../../valueObjects/AssetProxy');
|
|
10
10
|
jest.mock('decap-cms-lib-util');
|
|
11
|
-
jest.
|
|
12
|
-
return { v4: jest.fn().mockReturnValue('000000000000000000000') };
|
|
13
|
-
});
|
|
11
|
+
global.crypto.randomUUID = jest.fn().mockReturnValue('000000000000000000000');
|
|
14
12
|
|
|
15
13
|
const middlewares = [thunk];
|
|
16
14
|
const mockStore = configureMockStore(middlewares);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fromJS, Map } from 'immutable';
|
|
1
|
+
import { fromJS, List, Map } from 'immutable';
|
|
2
2
|
import configureMockStore from 'redux-mock-store';
|
|
3
3
|
import thunk from 'redux-thunk';
|
|
4
4
|
|
|
@@ -99,6 +99,41 @@ describe('entries', () => {
|
|
|
99
99
|
});
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
+
it('should populate draft entry from repeated URL param', () => {
|
|
103
|
+
const store = mockStore({ mediaLibrary: fromJS({ files: [] }) });
|
|
104
|
+
|
|
105
|
+
const collection = fromJS({
|
|
106
|
+
fields: [{ name: 'post', multiple: true }],
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return store
|
|
110
|
+
.dispatch(createEmptyDraft(collection, '?post=2026-05-07-test&post=2026-05-08-test'))
|
|
111
|
+
.then(() => {
|
|
112
|
+
const actions = store.getActions();
|
|
113
|
+
expect(actions).toHaveLength(1);
|
|
114
|
+
|
|
115
|
+
expect(actions[0]).toEqual({
|
|
116
|
+
payload: {
|
|
117
|
+
author: '',
|
|
118
|
+
collection: undefined,
|
|
119
|
+
data: { post: List(['2026-05-07-test', '2026-05-08-test']) },
|
|
120
|
+
meta: {},
|
|
121
|
+
i18n: {},
|
|
122
|
+
isModification: null,
|
|
123
|
+
label: null,
|
|
124
|
+
mediaFiles: [],
|
|
125
|
+
partial: false,
|
|
126
|
+
path: '',
|
|
127
|
+
raw: '',
|
|
128
|
+
slug: '',
|
|
129
|
+
status: '',
|
|
130
|
+
updatedOn: '',
|
|
131
|
+
},
|
|
132
|
+
type: 'DRAFT_CREATE_EMPTY',
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
102
137
|
it('should html escape URL params', () => {
|
|
103
138
|
const store = mockStore({ mediaLibrary: fromJS({ files: [] }) });
|
|
104
139
|
|
package/src/actions/config.ts
CHANGED
|
@@ -308,11 +308,12 @@ export function applyDefaults(originalConfig: CmsConfig) {
|
|
|
308
308
|
collection.folder = trim(folder, '/');
|
|
309
309
|
|
|
310
310
|
if (meta && meta.path) {
|
|
311
|
+
const metaPath = meta.path;
|
|
311
312
|
const metaField = {
|
|
312
313
|
name: 'path',
|
|
313
314
|
meta: true,
|
|
314
315
|
required: true,
|
|
315
|
-
...
|
|
316
|
+
...metaPath,
|
|
316
317
|
};
|
|
317
318
|
collection.fields = [metaField, ...(collection.fields || [])];
|
|
318
319
|
}
|
|
@@ -458,6 +459,17 @@ export async function detectProxyServer(localBackend?: boolean | CmsLocalBackend
|
|
|
458
459
|
? defaultUrl
|
|
459
460
|
: localBackend.url || defaultUrl.replace('localhost', location.hostname);
|
|
460
461
|
|
|
462
|
+
try {
|
|
463
|
+
const { protocol } = new URL(proxyUrl);
|
|
464
|
+
if (protocol !== 'http:' && protocol !== 'https:') {
|
|
465
|
+
console.log(`Decap CMS local_backend url must use http or https, ignoring '${proxyUrl}'`);
|
|
466
|
+
return {};
|
|
467
|
+
}
|
|
468
|
+
} catch {
|
|
469
|
+
console.log(`Decap CMS local_backend url '${proxyUrl}' is not a valid URL`);
|
|
470
|
+
return {};
|
|
471
|
+
}
|
|
472
|
+
|
|
461
473
|
try {
|
|
462
474
|
console.log(`Looking for Decap CMS Proxy Server at '${proxyUrl}'`);
|
|
463
475
|
const res = await fetch(`${proxyUrl}`, {
|