decap-cms-core 3.13.0 → 3.14.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 (41) hide show
  1. package/dist/decap-cms-core.js +18 -18
  2. package/dist/decap-cms-core.js.map +1 -1
  3. package/dist/esm/actions/config.js +14 -1
  4. package/dist/esm/actions/entries.js +15 -4
  5. package/dist/esm/backend.js +2 -0
  6. package/dist/esm/bootstrap.js +2 -2
  7. package/dist/esm/components/App/App.js +12 -5
  8. package/dist/esm/components/App/Header.js +18 -18
  9. package/dist/esm/components/Collection/Entries/EntryCard.js +30 -15
  10. package/dist/esm/components/Collection/NestedCollection.js +20 -11
  11. package/dist/esm/components/Editor/EditorPreviewPane/EditorPreviewPane.js +22 -6
  12. package/dist/esm/components/UI/ErrorBoundary.js +2 -2
  13. package/dist/esm/components/UI/SettingsDropdown.js +25 -27
  14. package/dist/esm/constants/configSchema.js +1 -1
  15. package/dist/esm/lib/registry.js +4 -1
  16. package/dist/esm/reducers/entryDraft.js +36 -3
  17. package/index.d.ts +17 -1
  18. package/package.json +2 -2
  19. package/src/__tests__/backend.spec.js +214 -0
  20. package/src/actions/__tests__/config.spec.js +14 -0
  21. package/src/actions/__tests__/entries.spec.js +36 -1
  22. package/src/actions/config.ts +13 -1
  23. package/src/actions/entries.ts +22 -7
  24. package/src/backend.ts +2 -0
  25. package/src/components/App/App.js +22 -13
  26. package/src/components/App/Header.js +36 -11
  27. package/src/components/Collection/Entries/EntryCard.js +13 -3
  28. package/src/components/Collection/NestedCollection.js +14 -7
  29. package/src/components/Collection/__tests__/NestedCollection.spec.js +1 -1
  30. package/src/components/Collection/__tests__/__snapshots__/NestedCollection.spec.js.snap +0 -68
  31. package/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js +6 -5
  32. package/src/components/Editor/__tests__/__snapshots__/EditorToolbar.spec.js.snap +104 -72
  33. package/src/components/UI/SettingsDropdown.js +36 -9
  34. package/src/constants/__tests__/configSchema.spec.js +9 -6
  35. package/src/constants/configSchema.js +1 -1
  36. package/src/lib/__tests__/formatters.spec.js +16 -4
  37. package/src/lib/__tests__/registry.spec.js +3 -3
  38. package/src/lib/registry.js +4 -1
  39. package/src/reducers/__tests__/entryDraft.spec.js +117 -0
  40. package/src/reducers/entryDraft.js +43 -3
  41. package/src/types/redux.ts +19 -2
@@ -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
 
@@ -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
- ...meta.path,
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}`, {
@@ -1,9 +1,14 @@
1
- import { fromJS, List, Map } from 'immutable';
1
+ import { fromJS, List, Map, Set } from 'immutable';
2
2
  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, selectDefaultSortField } from '../reducers/collections';
6
+ import {
7
+ selectFields,
8
+ selectField,
9
+ updateFieldByKey,
10
+ selectDefaultSortField,
11
+ } from '../reducers/collections';
7
12
  import { selectIntegration, selectPublishedSlugs } from '../reducers';
8
13
  import { getIntegrationProvider } from '../integrations';
9
14
  import { currentBackend } from '../backend';
@@ -38,7 +43,6 @@ import type {
38
43
  import type { EntryValue } from '../valueObjects/Entry';
39
44
  import type { Backend } from '../backend';
40
45
  import type AssetProxy from '../valueObjects/AssetProxy';
41
- import type { Set } from 'immutable';
42
46
 
43
47
  /*
44
48
  * Constant Declarations
@@ -740,10 +744,21 @@ function getMetaFields(fields: EntryFields) {
740
744
  export function createEmptyDraft(collection: Collection, search: string) {
741
745
  return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
742
746
  const params = new URLSearchParams(search);
743
- params.forEach((value, key) => {
744
- collection = updateFieldByKey(collection, key, field =>
745
- field.set('default', processValue(value)),
746
- );
747
+ const uniqueKeys = Set([...params.keys()]).toArray();
748
+
749
+ uniqueKeys.forEach(key => {
750
+ const field = selectField(collection, key);
751
+ const isMultiple = field?.get('multiple', false);
752
+ const values = params.getAll(key);
753
+
754
+ collection = updateFieldByKey(collection, key, field => {
755
+ if (isMultiple) {
756
+ const allValues = values.flatMap(v => v.split(',')).map(processValue);
757
+ return field.set('default', List(allValues));
758
+ } else {
759
+ return field.set('default', processValue(values[values.length - 1]));
760
+ }
761
+ });
747
762
  });
748
763
 
749
764
  const fields = collection.get('fields', List());
package/src/backend.ts CHANGED
@@ -1184,6 +1184,7 @@ export class Backend {
1184
1184
  );
1185
1185
 
1186
1186
  const collectionName = collection.get('name');
1187
+ const hasSubfolders = collection.get('nested')?.get('subfolders') !== false;
1187
1188
 
1188
1189
  const updatedOptions = { unpublished, status };
1189
1190
  const opts = {
@@ -1191,6 +1192,7 @@ export class Backend {
1191
1192
  commitMessage,
1192
1193
  collectionName,
1193
1194
  useWorkflow,
1195
+ hasSubfolders,
1194
1196
  ...updatedOptions,
1195
1197
  };
1196
1198
 
@@ -156,6 +156,7 @@ class App extends React.Component {
156
156
  openMediaLibrary,
157
157
  t,
158
158
  showMediaButton,
159
+ location,
159
160
  } = this.props;
160
161
 
161
162
  if (config === null) {
@@ -177,22 +178,30 @@ class App extends React.Component {
177
178
  const defaultPath = getDefaultPath(collections);
178
179
  const hasWorkflow = publishMode === EDITORIAL_WORKFLOW;
179
180
 
181
+ // Work out if this is an editor route, following the same URL matching as the router.
182
+ // - /collections/:name/entries/*
183
+ // - /collections/:name/new
184
+ const [, base, , view] = location.pathname.split('/');
185
+ const isEditorRoute = base === 'collections' && (view === 'entries' || view === 'new');
186
+
180
187
  return (
181
188
  <>
182
189
  <Notifications />
183
- <Header
184
- user={user}
185
- collections={collections}
186
- onCreateEntryClick={createNewEntry}
187
- onLogoutClick={logoutUser}
188
- openMediaLibrary={openMediaLibrary}
189
- hasWorkflow={hasWorkflow}
190
- displayUrl={config.display_url}
191
- logoUrl={config.logo_url} // Deprecated, replaced by `logo.src`
192
- logo={config.logo}
193
- isTestRepo={config.backend.name === 'test-repo'}
194
- showMediaButton={showMediaButton}
195
- />
190
+ {!isEditorRoute && (
191
+ <Header
192
+ user={user}
193
+ collections={collections}
194
+ onCreateEntryClick={createNewEntry}
195
+ onLogoutClick={logoutUser}
196
+ openMediaLibrary={openMediaLibrary}
197
+ hasWorkflow={hasWorkflow}
198
+ displayUrl={config.display_url}
199
+ logoUrl={config.logo_url} // Deprecated, replaced by `logo.src`
200
+ logo={config.logo}
201
+ isTestRepo={config.backend.name === 'test-repo'}
202
+ showMediaButton={showMediaButton}
203
+ />
204
+ )}
196
205
  <AppMainContainer>
197
206
  {isFetching && <TopBarProgress />}
198
207
  <Switch>
@@ -11,7 +11,6 @@ import {
11
11
  DropdownItem,
12
12
  StyledDropdownButton,
13
13
  colors,
14
- lengths,
15
14
  shadows,
16
15
  buttons,
17
16
  zIndex,
@@ -32,12 +31,14 @@ function AppHeader(props) {
32
31
  <header
33
32
  css={css`
34
33
  ${shadows.dropMain};
35
- position: sticky;
36
34
  width: 100%;
37
- top: 0;
38
35
  background-color: ${colors.foreground};
39
36
  z-index: ${zIndex.zIndex300};
40
- height: ${lengths.topBarHeight};
37
+
38
+ @media (min-height: 500px) {
39
+ position: sticky;
40
+ top: 0;
41
+ }
41
42
  `}
42
43
  {...props}
43
44
  />
@@ -46,11 +47,15 @@ function AppHeader(props) {
46
47
 
47
48
  const AppHeaderContent = styled.div`
48
49
  display: flex;
49
- justify-content: space-between;
50
- min-width: 800px;
51
- max-width: 1440px;
50
+ flex-direction: column-reverse;
52
51
  padding: 0 12px;
53
52
  margin: 0 auto;
53
+
54
+ @media (min-width: 800px) {
55
+ max-width: 1440px;
56
+ flex-direction: row;
57
+ justify-content: space-between;
58
+ }
54
59
  `;
55
60
 
56
61
  const AppHeaderButton = styled.button`
@@ -58,14 +63,27 @@ const AppHeaderButton = styled.button`
58
63
  background: none;
59
64
  color: #7b8290;
60
65
  font-family: inherit;
61
- font-size: 16px;
66
+ font-size: 13px;
67
+ line-height: 1;
62
68
  font-weight: 500;
63
69
  display: inline-flex;
64
- padding: 16px 20px;
70
+ flex-direction: column;
71
+ gap: 2px;
72
+ padding: 0 10px 10px;
65
73
  align-items: center;
74
+ text-align: center;
75
+
76
+ @media (min-width: 400px) {
77
+ flex-direction: row;
78
+ gap: 4px;
79
+ }
80
+
81
+ @media (min-width: 500px) {
82
+ font-size: 16px;
83
+ padding: 16px 20px;
84
+ }
66
85
 
67
86
  ${Icon} {
68
- margin-right: 4px;
69
87
  color: #b3b9c4;
70
88
  }
71
89
 
@@ -93,14 +111,16 @@ const AppHeaderButton = styled.button`
93
111
  const AppHeaderNavLink = AppHeaderButton.withComponent(NavLink);
94
112
 
95
113
  const AppHeaderActions = styled.div`
96
- display: inline-flex;
114
+ display: flex;
97
115
  align-items: center;
116
+ justify-content: space-between;
98
117
  `;
99
118
 
100
119
  const AppHeaderQuickNewButton = styled(StyledDropdownButton)`
101
120
  ${buttons.button};
102
121
  ${buttons.medium};
103
122
  ${buttons.gray};
123
+ white-space: nowrap;
104
124
  margin-right: 8px;
105
125
 
106
126
  &:after {
@@ -112,6 +132,11 @@ const AppHeaderNavList = styled.ul`
112
132
  display: flex;
113
133
  margin: 0;
114
134
  list-style: none;
135
+ justify-content: space-around;
136
+
137
+ @media (min-width: 800px) {
138
+ justify-content: flex-start;
139
+ }
115
140
  `;
116
141
 
117
142
  const AppHeaderLogo = styled.li`
@@ -1,4 +1,4 @@
1
- import React from 'react';
1
+ import React, { useEffect, useState } from 'react';
2
2
  import styled from '@emotion/styled';
3
3
  import { connect } from 'react-redux';
4
4
  import { Link } from 'react-router-dom';
@@ -86,7 +86,7 @@ const CardBody = styled.div`
86
86
  }
87
87
  `;
88
88
 
89
- const CardImage = styled.div`
89
+ const StyledImage = styled.div`
90
90
  background-image: url(${props => props.src});
91
91
  background-position: center center;
92
92
  background-size: cover;
@@ -131,6 +131,16 @@ const WorkflowBadge = styled.span`
131
131
  }};
132
132
  `;
133
133
 
134
+ function CardImage({ getAsset, value, field }) {
135
+ const [asset, setAsset] = useState(null);
136
+
137
+ useEffect(() => {
138
+ setAsset(value ? getAsset(value, field) : null);
139
+ }, [value, field, getAsset]);
140
+
141
+ return asset ? <StyledImage src={asset.toString()} /> : null;
142
+ }
143
+
134
144
  function EntryCard({
135
145
  path,
136
146
  summary,
@@ -192,7 +202,7 @@ function EntryCard({
192
202
  </TitleIcons>
193
203
  </CardHeading>
194
204
  </CardBody>
195
- {image ? <CardImage src={getAsset(image, imageField).toString()} /> : null}
205
+ {image ? <CardImage getAsset={getAsset} value={image} field={imageField} /> : null}
196
206
  </GridCardLink>
197
207
  </GridCard>
198
208
  );
@@ -67,18 +67,25 @@ const TreeNavLink = styled(NavLink)`
67
67
  `};
68
68
  `;
69
69
 
70
- function getNodeTitle(node) {
71
- const title = node.isRoot
72
- ? node.title
73
- : node.children.find(c => !c.isDir && c.title)?.title || node.title;
74
- return title;
70
+ function getNodeTitle(node, collection) {
71
+ // Backward compatibility: when `nested.subfolders` is true(default) or undefined,
72
+ // directory nodes should use the title of their index entry.
73
+ // Otherwise, use the folder name already stored in `node.title`.
74
+ const subfolders = collection.getIn(['nested', 'subfolders']) !== false;
75
+ if (!node.isRoot && node.isDir && subfolders) {
76
+ const indexChild = node.children.find(child => !child.isDir);
77
+ if (indexChild && indexChild.title) {
78
+ return indexChild.title;
79
+ }
80
+ }
81
+ return node.title;
75
82
  }
76
83
 
77
84
  function TreeNode(props) {
78
85
  const { collection, treeData, depth = 0, onToggle } = props;
79
86
  const collectionName = collection.get('name');
80
87
 
81
- const sortedData = sortBy(treeData, getNodeTitle);
88
+ const sortedData = sortBy(treeData, node => getNodeTitle(node, collection));
82
89
  const subfolders = collection.get('nested')?.get('subfolders') !== false;
83
90
  return sortedData.map(node => {
84
91
  const leaf =
@@ -93,7 +100,7 @@ function TreeNode(props) {
93
100
  if (depth > 0) {
94
101
  to = `${to}/filter${node.path}`;
95
102
  }
96
- const title = getNodeTitle(node);
103
+ const title = getNodeTitle(node, collection);
97
104
 
98
105
  const hasChildren =
99
106
  depth === 0 ||
@@ -38,7 +38,7 @@ describe('NestedCollection', () => {
38
38
  folder: 'src/pages',
39
39
  fields: [{ name: 'title', widget: 'string' }],
40
40
  nested: {
41
- subfolders: false,
41
+ subfolders: true,
42
42
  },
43
43
  });
44
44
 
@@ -138,20 +138,6 @@ exports[`NestedCollection should render connected component 1`] = `
138
138
  margin-right: 4px;
139
139
  }
140
140
 
141
- .emotion-6 {
142
- position: relative;
143
- top: 2px;
144
- color: #fff;
145
- width: 0;
146
- height: 0;
147
- border: 5px solid transparent;
148
- border-radius: 2px;
149
- border-left: 6px solid currentColor;
150
- border-right: 0;
151
- color: currentColor;
152
- left: 2px;
153
- }
154
-
155
141
  <a
156
142
  class="emotion-0 emotion-1"
157
143
  data-testid="/a"
@@ -169,9 +155,6 @@ exports[`NestedCollection should render connected component 1`] = `
169
155
  >
170
156
  File 1
171
157
  </div>
172
- <div
173
- class="emotion-6 emotion-7"
174
- />
175
158
  </div>
176
159
  </a>
177
160
  .emotion-0 {
@@ -224,20 +207,6 @@ exports[`NestedCollection should render connected component 1`] = `
224
207
  margin-right: 4px;
225
208
  }
226
209
 
227
- .emotion-6 {
228
- position: relative;
229
- top: 2px;
230
- color: #fff;
231
- width: 0;
232
- height: 0;
233
- border: 5px solid transparent;
234
- border-radius: 2px;
235
- border-left: 6px solid currentColor;
236
- border-right: 0;
237
- color: currentColor;
238
- left: 2px;
239
- }
240
-
241
210
  <a
242
211
  class="emotion-0 emotion-1"
243
212
  data-testid="/b"
@@ -255,9 +224,6 @@ exports[`NestedCollection should render connected component 1`] = `
255
224
  >
256
225
  File 2
257
226
  </div>
258
- <div
259
- class="emotion-6 emotion-7"
260
- />
261
227
  </div>
262
228
  </a>
263
229
  </DocumentFragment>
@@ -401,20 +367,6 @@ exports[`NestedCollection should render correctly with nested entries 1`] = `
401
367
  margin-right: 4px;
402
368
  }
403
369
 
404
- .emotion-6 {
405
- position: relative;
406
- top: 2px;
407
- color: #fff;
408
- width: 0;
409
- height: 0;
410
- border: 5px solid transparent;
411
- border-radius: 2px;
412
- border-left: 6px solid currentColor;
413
- border-right: 0;
414
- color: currentColor;
415
- left: 2px;
416
- }
417
-
418
370
  <a
419
371
  class="emotion-0 emotion-1"
420
372
  data-testid="/a"
@@ -432,9 +384,6 @@ exports[`NestedCollection should render correctly with nested entries 1`] = `
432
384
  >
433
385
  File 1
434
386
  </div>
435
- <div
436
- class="emotion-6 emotion-7"
437
- />
438
387
  </div>
439
388
  </a>
440
389
  .emotion-0 {
@@ -487,20 +436,6 @@ exports[`NestedCollection should render correctly with nested entries 1`] = `
487
436
  margin-right: 4px;
488
437
  }
489
438
 
490
- .emotion-6 {
491
- position: relative;
492
- top: 2px;
493
- color: #fff;
494
- width: 0;
495
- height: 0;
496
- border: 5px solid transparent;
497
- border-radius: 2px;
498
- border-left: 6px solid currentColor;
499
- border-right: 0;
500
- color: currentColor;
501
- left: 2px;
502
- }
503
-
504
439
  <a
505
440
  class="emotion-0 emotion-1"
506
441
  data-testid="/b"
@@ -518,9 +453,6 @@ exports[`NestedCollection should render correctly with nested entries 1`] = `
518
453
  >
519
454
  File 2
520
455
  </div>
521
- <div
522
- class="emotion-6 emotion-7"
523
- />
524
456
  </div>
525
457
  </a>
526
458
  </DocumentFragment>
@@ -224,17 +224,18 @@ export class PreviewPane extends React.Component {
224
224
  * This function exists entirely to expose collections from outside of this entry
225
225
  *
226
226
  */
227
- getCollection = async (collectionName, slug) => {
227
+ getCollection = async (collectionName, slugToLoad) => {
228
228
  const { state } = this.props;
229
229
  const selectedCollection = state.collections.get(collectionName);
230
230
 
231
- if (typeof slug === 'undefined') {
231
+ if (typeof slugToLoad === 'undefined') {
232
232
  const entries = await getAllEntries(state, selectedCollection);
233
- return entries.map(entry => Map().set('data', entry.data));
233
+
234
+ return entries.map(({ data, slug, path }) => Map({ data, slug, path }));
234
235
  }
235
236
 
236
- const entry = await tryLoadEntry(state, selectedCollection, slug);
237
- return Map().set('data', entry.data);
237
+ const { data, slug, path } = await tryLoadEntry(state, selectedCollection, slugToLoad);
238
+ return Map({ data, slug, path });
238
239
  };
239
240
 
240
241
  render() {