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.
Files changed (86) hide show
  1. package/dist/decap-cms-core.js +9 -9
  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 +15 -8
  8. package/dist/esm/components/App/Header.js +18 -18
  9. package/dist/esm/components/Collection/Collection.js +7 -7
  10. package/dist/esm/components/Collection/CollectionControls.js +1 -1
  11. package/dist/esm/components/Collection/CollectionTop.js +8 -8
  12. package/dist/esm/components/Collection/ControlButton.js +2 -2
  13. package/dist/esm/components/Collection/Entries/Entries.js +2 -2
  14. package/dist/esm/components/Collection/Entries/EntryCard.js +30 -15
  15. package/dist/esm/components/Collection/NestedCollection.js +20 -11
  16. package/dist/esm/components/Collection/Sidebar.js +6 -6
  17. package/dist/esm/components/Editor/EditorPreviewPane/EditorPreviewPane.js +22 -6
  18. package/dist/esm/components/MediaLibrary/MediaLibraryButtons.js +48 -9
  19. package/dist/esm/components/MediaLibrary/MediaLibraryCard.js +7 -9
  20. package/dist/esm/components/MediaLibrary/MediaLibraryCardGrid.js +5 -5
  21. package/dist/esm/components/MediaLibrary/MediaLibraryHeader.js +15 -3
  22. package/dist/esm/components/MediaLibrary/MediaLibraryModal.js +4 -1
  23. package/dist/esm/components/MediaLibrary/MediaLibrarySearch.js +6 -6
  24. package/dist/esm/components/MediaLibrary/MediaLibraryTop.js +14 -24
  25. package/dist/esm/components/UI/ErrorBoundary.js +2 -2
  26. package/dist/esm/components/UI/Modal.js +11 -4
  27. package/dist/esm/components/UI/SettingsDropdown.js +25 -27
  28. package/dist/esm/components/Workflow/Workflow.js +5 -5
  29. package/dist/esm/components/Workflow/WorkflowCard.js +14 -14
  30. package/dist/esm/components/Workflow/WorkflowList.js +18 -42
  31. package/dist/esm/constants/configSchema.js +2 -3
  32. package/dist/esm/lib/registry.js +4 -1
  33. package/dist/esm/reducers/entries.js +9 -7
  34. package/dist/esm/reducers/entryDraft.js +39 -7
  35. package/dist/esm/reducers/mediaLibrary.js +2 -3
  36. package/dist/esm/reducers/notifications.js +1 -2
  37. package/index.d.ts +19 -3
  38. package/package.json +7 -8
  39. package/src/__tests__/backend.spec.js +214 -0
  40. package/src/actions/__tests__/config.spec.js +14 -0
  41. package/src/actions/__tests__/editorialWorkflow.spec.js +1 -3
  42. package/src/actions/__tests__/entries.spec.js +36 -1
  43. package/src/actions/config.ts +13 -1
  44. package/src/actions/entries.ts +22 -7
  45. package/src/backend.ts +2 -0
  46. package/src/components/App/App.js +22 -14
  47. package/src/components/App/Header.js +36 -11
  48. package/src/components/Collection/Collection.js +10 -3
  49. package/src/components/Collection/CollectionControls.js +4 -3
  50. package/src/components/Collection/CollectionTop.js +3 -2
  51. package/src/components/Collection/ControlButton.js +1 -0
  52. package/src/components/Collection/Entries/Entries.js +1 -0
  53. package/src/components/Collection/Entries/EntryCard.js +13 -3
  54. package/src/components/Collection/NestedCollection.js +14 -7
  55. package/src/components/Collection/Sidebar.js +8 -7
  56. package/src/components/Collection/__tests__/NestedCollection.spec.js +1 -1
  57. package/src/components/Collection/__tests__/__snapshots__/Collection.spec.js.snap +76 -12
  58. package/src/components/Collection/__tests__/__snapshots__/NestedCollection.spec.js.snap +0 -68
  59. package/src/components/Collection/__tests__/__snapshots__/Sidebar.spec.js.snap +76 -16
  60. package/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js +6 -5
  61. package/src/components/Editor/__tests__/__snapshots__/EditorToolbar.spec.js.snap +104 -72
  62. package/src/components/MediaLibrary/MediaLibraryButtons.js +39 -7
  63. package/src/components/MediaLibrary/MediaLibraryCard.js +1 -3
  64. package/src/components/MediaLibrary/MediaLibraryCardGrid.js +2 -2
  65. package/src/components/MediaLibrary/MediaLibraryHeader.js +32 -17
  66. package/src/components/MediaLibrary/MediaLibraryModal.js +8 -4
  67. package/src/components/MediaLibrary/MediaLibrarySearch.js +3 -1
  68. package/src/components/MediaLibrary/MediaLibraryTop.js +19 -1
  69. package/src/components/MediaLibrary/__tests__/__snapshots__/MediaLibraryCard.spec.js.snap +6 -6
  70. package/src/components/UI/Modal.js +10 -3
  71. package/src/components/UI/SettingsDropdown.js +36 -9
  72. package/src/components/Workflow/Workflow.js +7 -1
  73. package/src/components/Workflow/WorkflowCard.js +52 -14
  74. package/src/components/Workflow/WorkflowList.js +50 -31
  75. package/src/constants/__tests__/configSchema.spec.js +9 -6
  76. package/src/constants/configSchema.js +2 -3
  77. package/src/lib/__tests__/formatters.spec.js +16 -4
  78. package/src/lib/__tests__/registry.spec.js +3 -3
  79. package/src/lib/registry.js +4 -1
  80. package/src/reducers/__tests__/entryDraft.spec.js +118 -1
  81. package/src/reducers/__tests__/mediaLibrary.spec.js +0 -1
  82. package/src/reducers/entries.ts +9 -7
  83. package/src/reducers/entryDraft.js +46 -7
  84. package/src/reducers/mediaLibrary.ts +2 -3
  85. package/src/reducers/notifications.ts +1 -2
  86. package/src/types/redux.ts +22 -5
@@ -15,47 +15,57 @@ import { selectEntryCollectionTitle } from '../../reducers/collections';
15
15
  const WorkflowListContainer = styled.div`
16
16
  min-height: 60%;
17
17
  display: grid;
18
- grid-template-columns: 33.3% 33.3% 33.3%;
18
+ grid-template-columns: repeat(3, minmax(0, 1fr));
19
+ gap: 8px;
20
+ @media (min-width: 500px) {
21
+ gap: 14px;
22
+ }
23
+ @media (min-width: 800px) {
24
+ gap: 40px;
25
+ }
19
26
  `;
20
27
 
21
28
  const WorkflowListContainerOpenAuthoring = styled.div`
22
29
  min-height: 60%;
23
30
  display: grid;
24
- grid-template-columns: 50% 50% 0%;
31
+ grid-template-columns: repeat(2, minmax(0, 1fr));
32
+ gap: 8px;
33
+ @media (min-width: 500px) {
34
+ gap: 14px;
35
+ }
36
+ @media (min-width: 800px) {
37
+ gap: 40px;
38
+ }
25
39
  `;
26
40
 
27
41
  const styles = {
28
42
  columnPosition: idx =>
29
- (idx === 0 &&
30
- css`
31
- margin-left: 0;
32
- `) ||
33
- (idx === 2 &&
34
- css`
35
- margin-right: 0;
36
- `) ||
43
+ idx > 0 &&
37
44
  css`
38
- &:before,
39
- &:after {
45
+ &:before {
40
46
  content: '';
41
47
  display: block;
42
48
  position: absolute;
43
49
  width: 2px;
44
50
  height: 80%;
45
- top: 76px;
51
+ top: 36px;
46
52
  background-color: ${colors.textFieldBorder};
53
+ @media (min-width: 800px) {
54
+ top: 76px;
55
+ }
47
56
  }
48
57
 
49
58
  &:before {
50
- left: -23px;
51
- }
52
-
53
- &:after {
54
- right: -23px;
59
+ left: -7px;
60
+ @media (min-width: 500px) {
61
+ left: -10px;
62
+ }
63
+ @media (min-width: 800px) {
64
+ left: -23px;
65
+ }
55
66
  }
56
67
  `,
57
68
  column: css`
58
- margin: 0 20px;
59
69
  transition: background-color 0.5s ease;
60
70
  border: 2px dashed transparent;
61
71
  border-radius: 4px;
@@ -68,21 +78,21 @@ const styles = {
68
78
  hiddenColumn: css`
69
79
  display: none;
70
80
  `,
71
- hiddenRightBorder: css`
72
- &:not(:first-child):not(:last-child) {
73
- &:after {
74
- display: none;
75
- }
76
- }
77
- `,
78
81
  };
79
82
 
80
83
  const ColumnHeader = styled.h2`
81
- font-size: 20px;
84
+ font-size: 16px;
82
85
  font-weight: normal;
83
- padding: 4px 14px;
86
+ padding: 2px 6px;
84
87
  border-radius: ${lengths.borderRadius};
85
- margin-bottom: 28px;
88
+ white-space: nowrap;
89
+ margin-bottom: 6px;
90
+
91
+ @media (min-width: 800px) {
92
+ font-size: 20px;
93
+ padding: 4px 12px;
94
+ margin-bottom: 28px;
95
+ }
86
96
 
87
97
  ${props =>
88
98
  props.name === 'draft' &&
@@ -107,11 +117,21 @@ const ColumnHeader = styled.h2`
107
117
  `;
108
118
 
109
119
  const ColumnCount = styled.p`
110
- font-size: 13px;
120
+ font-size: 11px;
111
121
  font-weight: 500;
112
122
  color: ${colors.text};
113
123
  text-transform: uppercase;
114
124
  margin-bottom: 6px;
125
+ padding-inline: 6px;
126
+ white-space: nowrap;
127
+ text-overflow: ellipsis;
128
+ overflow: hidden;
129
+ min-width: 0;
130
+
131
+ @media (min-width: 800px) {
132
+ font-size: 13px;
133
+ padding-inline: 12px;
134
+ }
115
135
  `;
116
136
 
117
137
  // This is a namespace so that we can only drop these elements on a DropTarget with the same
@@ -188,7 +208,6 @@ class WorkflowList extends React.Component {
188
208
  styles.columnPosition(idx),
189
209
  isHovered && styles.columnHovered,
190
210
  isOpenAuthoring && currColumn === 'pending_publish' && styles.hiddenColumn,
191
- isOpenAuthoring && currColumn === 'pending_review' && styles.hiddenRightBorder,
192
211
  ]}
193
212
  >
194
213
  <ColumnHeader name={currColumn}>
@@ -477,22 +477,25 @@ describe('config', () => {
477
477
  merge({}, validConfig, { collections: [{ meta: { path: { label: 'Label' } } }] }),
478
478
  );
479
479
  }).toThrowError("'collections[0].meta.path' must have required property 'widget'");
480
+ });
481
+
482
+ it('should allow collection meta to have a path configuration with index_file', () => {
480
483
  expect(() => {
481
484
  validateConfig(
482
485
  merge({}, validConfig, {
483
- collections: [{ meta: { path: { label: 'Label', widget: 'widget' } } }],
486
+ collections: [
487
+ { meta: { path: { label: 'Path', widget: 'string', index_file: 'index' } } },
488
+ ],
484
489
  }),
485
490
  );
486
- }).toThrowError("'collections[0].meta.path' must have required property 'index_file'");
491
+ }).not.toThrow();
487
492
  });
488
493
 
489
- it('should allow collection meta to have a path configuration', () => {
494
+ it('should allow collection meta to have a path configuration without index_file', () => {
490
495
  expect(() => {
491
496
  validateConfig(
492
497
  merge({}, validConfig, {
493
- collections: [
494
- { meta: { path: { label: 'Path', widget: 'string', index_file: 'index' } } },
495
- ],
498
+ collections: [{ meta: { path: { label: 'Path', widget: 'string' } } }],
496
499
  }),
497
500
  );
498
501
  }).not.toThrow();
@@ -6,7 +6,6 @@ import {
6
6
  prohibited,
7
7
  } from 'ajv-keywords/dist/keywords';
8
8
  import ajvErrors from 'ajv-errors';
9
- import { v4 as uuid } from 'uuid';
10
9
 
11
10
  import { frontmatterFormats, extensionFormatters } from '../formats/formats';
12
11
  import { getWidgets } from '../lib/registry';
@@ -45,7 +44,7 @@ const i18nField = {
45
44
  * Config for fields in both file and folder collections.
46
45
  */
47
46
  function fieldsConfig() {
48
- const id = uuid();
47
+ const id = crypto.randomUUID();
49
48
  return {
50
49
  $id: `fields_${id}`,
51
50
  type: 'array',
@@ -305,7 +304,7 @@ function getConfigSchema() {
305
304
  widget: { type: 'string' },
306
305
  index_file: { type: 'string' },
307
306
  },
308
- required: ['label', 'widget', 'index_file'],
307
+ required: ['label', 'widget'],
309
308
  },
310
309
  },
311
310
  additionalProperties: false,
@@ -295,8 +295,8 @@ describe('formatters', () => {
295
295
  };
296
296
 
297
297
  describe('slugFormatter', () => {
298
- const date = new Date('2020-01-01').valueOf();
299
- Date.now = jest.spyOn(Date, 'now').mockImplementation(() => date);
298
+ const date = new Date('2020-01-01T13:28:27.679Z').valueOf();
299
+ jest.spyOn(Date, 'now').mockImplementation(() => date);
300
300
 
301
301
  const { selectIdentifier } = require('../../reducers/collections');
302
302
 
@@ -333,10 +333,22 @@ describe('formatters', () => {
333
333
  ).toBe('entry-slug');
334
334
  });
335
335
 
336
+ it('should allow filters in slug templates', () => {
337
+ selectIdentifier.mockReturnValueOnce('published');
338
+
339
+ expect(
340
+ slugFormatter(
341
+ Map({ slug: "{{published | date('MM-DD')}}" }),
342
+ Map({ title: 'Post Title', published: new Date(date) }),
343
+ slugConfig,
344
+ ),
345
+ ).toBe('01-01');
346
+ });
347
+
336
348
  it('should see date filters applied to date from entry if it exists', () => {
337
349
  const { selectInferredField } = require('../../reducers/collections');
338
350
  selectInferredField.mockReturnValue('date');
339
- const entryDate = new Date('2026-10-20');
351
+ const entryDate = new Date('2026-10-20T13:28:27.679Z');
340
352
 
341
353
  expect(
342
354
  slugFormatter(
@@ -350,7 +362,7 @@ describe('formatters', () => {
350
362
  it('should see date filters applied to publishDate from entry if it exists', () => {
351
363
  const { selectInferredField } = require('../../reducers/collections');
352
364
  selectInferredField.mockReturnValue('publishDate');
353
- const entryDate = new Date('2026-10-20');
365
+ const entryDate = new Date('2026-10-20T13:28:27.679Z');
354
366
 
355
367
  expect(
356
368
  slugFormatter(
@@ -200,7 +200,7 @@ describe('registry', () => {
200
200
  });
201
201
  });
202
202
 
203
- it(`should return an updated entry's DataMap`, async () => {
203
+ it(`should return the complete updated entry object`, async () => {
204
204
  const { registerEventListener, invokeEvent } = require('../registry');
205
205
 
206
206
  const event = 'preSave';
@@ -233,7 +233,7 @@ describe('registry', () => {
233
233
  expect(handler1).toHaveBeenCalledWith(data, options);
234
234
  expect(handler2).toHaveBeenCalledWith(dataAfterFirstHandlerExecution, options);
235
235
 
236
- expect(result).toEqual(dataAfterSecondHandlerExecution.entry.get('data'));
236
+ expect(result).toEqual(dataAfterSecondHandlerExecution.entry);
237
237
  });
238
238
 
239
239
  it('should allow multiple events to not return a value', async () => {
@@ -254,7 +254,7 @@ describe('registry', () => {
254
254
 
255
255
  expect(handler1).toHaveBeenCalledWith(data, options);
256
256
  expect(handler2).toHaveBeenCalledWith(data, options);
257
- expect(result).toEqual(data.entry.get('data'));
257
+ expect(result).toEqual(data.entry);
258
258
  });
259
259
  });
260
260
  });
@@ -257,7 +257,10 @@ export async function invokeEvent({ name, data }) {
257
257
  _data = { ...data, entry };
258
258
  }
259
259
  }
260
- return _data.entry.get('data');
260
+ // Return the full entry object with all metadata (slug, path, meta, etc.)
261
+ // rather than just the data payload. Callers like invokePreSaveEvent expect
262
+ // the complete entry object to be preserved through the event handler chain.
263
+ return _data.entry;
261
264
  }
262
265
 
263
266
  export function removeEventListener({ name, handler }) {
@@ -3,7 +3,7 @@ import { Map, fromJS } from 'immutable';
3
3
  import * as actions from '../../actions/entries';
4
4
  import reducer from '../entryDraft';
5
5
 
6
- jest.mock('uuid', () => ({ v4: jest.fn(() => '1') }));
6
+ global.crypto.randomUUID = jest.fn(() => '1');
7
7
 
8
8
  const initialState = Map({
9
9
  entry: Map(),
@@ -195,4 +195,121 @@ describe('entryDraft reducer', () => {
195
195
  });
196
196
  });
197
197
  });
198
+
199
+ describe('selectCustomPath', () => {
200
+ let selectCustomPath;
201
+ let selectHasMetaPath;
202
+ let selectFolderEntryExtension;
203
+
204
+ beforeEach(() => {
205
+ jest.resetModules();
206
+ selectHasMetaPath = jest.fn(
207
+ collection => collection.has('meta') && collection.get('meta').has('path'),
208
+ );
209
+ selectFolderEntryExtension = jest.fn(collection => collection.get('extension') || 'md');
210
+
211
+ jest.doMock('../collections', () => ({
212
+ selectHasMetaPath,
213
+ selectFolderEntryExtension,
214
+ }));
215
+
216
+ const entryDraftModule = require('../entryDraft');
217
+ selectCustomPath = entryDraftModule.selectCustomPath;
218
+ });
219
+
220
+ afterEach(() => {
221
+ jest.unmock('../collections');
222
+ });
223
+
224
+ it('should generate dynamic filename for new entries without index_file', () => {
225
+ const collection = fromJS({
226
+ folder: '_pages',
227
+ extension: 'md',
228
+ meta: { path: { label: 'Path', widget: 'string' } },
229
+ });
230
+ const entryDraft = fromJS({
231
+ entry: {
232
+ newRecord: true,
233
+ data: { title: 'My Great Article' },
234
+ meta: { path: 'blog' },
235
+ },
236
+ });
237
+
238
+ const result = selectCustomPath(collection, entryDraft);
239
+ expect(result).toBe('_pages/blog/my-great-article.md');
240
+ });
241
+
242
+ it('should preserve filename for existing entries without index_file', () => {
243
+ const collection = fromJS({
244
+ folder: '_pages',
245
+ extension: 'md',
246
+ meta: { path: { label: 'Path', widget: 'string' } },
247
+ });
248
+ const entryDraft = fromJS({
249
+ entry: {
250
+ newRecord: false,
251
+ path: '_pages/old-folder/existing-file.md',
252
+ data: { title: 'Updated Title' },
253
+ meta: { path: 'new-folder' },
254
+ },
255
+ });
256
+
257
+ const result = selectCustomPath(collection, entryDraft);
258
+ expect(result).toBe('_pages/new-folder/existing-file.md');
259
+ });
260
+
261
+ it('should use index_file when specified (backward compatibility)', () => {
262
+ const collection = fromJS({
263
+ folder: '_pages',
264
+ extension: 'md',
265
+ meta: { path: { label: 'Path', widget: 'string', index_file: 'index' } },
266
+ });
267
+ const entryDraft = fromJS({
268
+ entry: {
269
+ newRecord: true,
270
+ data: { title: 'My Article' },
271
+ meta: { path: 'blog' },
272
+ },
273
+ });
274
+
275
+ const result = selectCustomPath(collection, entryDraft);
276
+ expect(result).toBe('_pages/blog/index.md');
277
+ });
278
+
279
+ it('should return undefined when path is not set', () => {
280
+ const collection = fromJS({
281
+ folder: '_pages',
282
+ extension: 'md',
283
+ meta: { path: { label: 'Path', widget: 'string' } },
284
+ });
285
+ const entryDraft = fromJS({
286
+ entry: {
287
+ newRecord: true,
288
+ data: { title: 'My Article' },
289
+ meta: {},
290
+ },
291
+ });
292
+
293
+ const result = selectCustomPath(collection, entryDraft);
294
+ expect(result).toBeUndefined();
295
+ });
296
+
297
+ it('should preserve non-latin characters in generated filename', () => {
298
+ const collection = fromJS({
299
+ folder: '_pages',
300
+ extension: 'md',
301
+ meta: { path: { label: 'Path', widget: 'string' } },
302
+ });
303
+ const entryDraft = fromJS({
304
+ entry: {
305
+ newRecord: true,
306
+ data: { title: '日本語のタイトル' },
307
+ meta: { path: 'blog' },
308
+ },
309
+ });
310
+
311
+ const result = selectCustomPath(collection, entryDraft);
312
+ expect(result).toBe('_pages/blog/日本語のタイトル.md');
313
+ });
314
+ });
198
315
  });
@@ -7,7 +7,6 @@ import mediaLibrary, {
7
7
  selectMediaDisplayURL,
8
8
  } from '../mediaLibrary';
9
9
 
10
- jest.mock('uuid');
11
10
  jest.mock('../entries');
12
11
  jest.mock('../');
13
12
 
@@ -474,14 +474,16 @@ function getGroup(entry: EntryMap, selectedGroup: GroupMap) {
474
474
  if (selectedGroup.has('pattern')) {
475
475
  const pattern = selectedGroup.get('pattern');
476
476
  let value = '';
477
- try {
478
- const regex = new RegExp(pattern);
479
- const matched = dataAsString.match(regex);
480
- if (matched) {
481
- value = matched[0];
477
+ if (pattern !== undefined) {
478
+ try {
479
+ const regex = new RegExp(pattern);
480
+ const matched = dataAsString.match(regex);
481
+ if (matched) {
482
+ value = matched[0];
483
+ }
484
+ } catch (e) {
485
+ console.warn(`Invalid view group pattern '${pattern}' for field '${field}'`, e);
482
486
  }
483
- } catch (e) {
484
- console.warn(`Invalid view group pattern '${pattern}' for field '${field}'`, e);
485
487
  }
486
488
  return {
487
489
  id: `${label}${value}`,
@@ -1,8 +1,8 @@
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';
5
4
 
5
+ import { sanitizeSlug } from '../lib/urlHelper';
6
6
  import {
7
7
  DRAFT_CREATE_FROM_ENTRY,
8
8
  DRAFT_CREATE_EMPTY,
@@ -52,7 +52,7 @@ function entryDraftReducer(state = Map(), action) {
52
52
  state.set('fieldsMetaData', Map());
53
53
  state.set('fieldsErrors', Map());
54
54
  state.set('hasChanged', false);
55
- state.set('key', uuid());
55
+ state.set('key', crypto.randomUUID());
56
56
  });
57
57
  case DRAFT_CREATE_EMPTY:
58
58
  // New Entry
@@ -62,7 +62,7 @@ function entryDraftReducer(state = Map(), action) {
62
62
  state.set('fieldsMetaData', Map());
63
63
  state.set('fieldsErrors', Map());
64
64
  state.set('hasChanged', false);
65
- state.set('key', uuid());
65
+ state.set('key', crypto.randomUUID());
66
66
  });
67
67
  case DRAFT_CREATE_FROM_LOCAL_BACKUP:
68
68
  // Local Backup
@@ -75,7 +75,7 @@ function entryDraftReducer(state = Map(), action) {
75
75
  state.set('fieldsMetaData', Map());
76
76
  state.set('fieldsErrors', Map());
77
77
  state.set('hasChanged', true);
78
- state.set('key', uuid());
78
+ state.set('key', crypto.randomUUID());
79
79
  });
80
80
  case DRAFT_CREATE_DUPLICATE_FROM_ENTRY:
81
81
  // Duplicate Entry
@@ -204,15 +204,54 @@ function entryDraftReducer(state = Map(), action) {
204
204
  }
205
205
  }
206
206
 
207
+ function cleanTitleForFilename(title) {
208
+ if (!title) return 'untitled';
209
+
210
+ const cleanedTitle = sanitizeSlug(title.toString().toLowerCase().trim(), {
211
+ sanitize_replacement: '-',
212
+ encoding: 'unicode',
213
+ });
214
+
215
+ return cleanedTitle || 'untitled';
216
+ }
217
+
207
218
  export function selectCustomPath(collection, entryDraft) {
208
219
  if (!selectHasMetaPath(collection)) {
209
220
  return;
210
221
  }
211
222
  const meta = entryDraft.getIn(['entry', 'meta']);
212
223
  const path = meta && meta.get('path');
213
- const indexFile = get(collection.toJS(), ['meta', 'path', 'index_file']);
224
+
225
+ if (!path) {
226
+ return;
227
+ }
228
+
214
229
  const extension = selectFolderEntryExtension(collection);
215
- const customPath = path && join(collection.get('folder'), path, `${indexFile}.${extension}`);
230
+ const indexFile = get(collection.toJS(), ['meta', 'path', 'index_file']);
231
+
232
+ // If index_file is specified, use the old behavior for backward compatibility
233
+ if (indexFile) {
234
+ const customPath = join(collection.get('folder'), path, `${indexFile}.${extension}`);
235
+ return customPath;
236
+ }
237
+
238
+ // New behavior: generate filename from entry title
239
+ const isNewEntry = entryDraft.getIn(['entry', 'newRecord']);
240
+ const currentPath = entryDraft.getIn(['entry', 'path']);
241
+
242
+ let filename;
243
+ if (isNewEntry || !currentPath) {
244
+ // For new entries, generate filename from title
245
+ const entryData = entryDraft.getIn(['entry', 'data']);
246
+ const title = entryData && entryData.get('title');
247
+ filename = cleanTitleForFilename(title);
248
+ } else {
249
+ // For existing entries, preserve the current filename
250
+ const currentFilename = basename(currentPath, `.${extension}`);
251
+ filename = currentFilename;
252
+ }
253
+
254
+ const customPath = join(collection.get('folder'), path, `${filename}.${extension}`);
216
255
  return customPath;
217
256
  }
218
257
 
@@ -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
 
5
4
  import {
@@ -150,7 +149,7 @@ function mediaLibrary(state = Map(defaultState), action: MediaLibraryAction) {
150
149
  return state;
151
150
  }
152
151
 
153
- const filesWithKeys = files.map(file => ({ ...file, key: uuid() }));
152
+ const filesWithKeys = files.map(file => ({ ...file, key: crypto.randomUUID() }));
154
153
  return state.withMutations(map => {
155
154
  map.set('isLoading', false);
156
155
  map.set('isPaginating', false);
@@ -186,7 +185,7 @@ function mediaLibrary(state = Map(defaultState), action: MediaLibraryAction) {
186
185
  return state;
187
186
  }
188
187
  return state.withMutations(map => {
189
- const fileWithKey = { ...file, key: uuid() };
188
+ const fileWithKey = { ...file, key: crypto.randomUUID() };
190
189
  const files = map.get('files') as MediaFile[];
191
190
  const updatedFiles = [fileWithKey, ...files];
192
191
  map.set('files', updatedFiles);
@@ -1,5 +1,4 @@
1
1
  import { produce } from 'immer';
2
- import { v4 as uuid } from 'uuid';
3
2
 
4
3
  import {
5
4
  NOTIFICATION_SEND,
@@ -41,7 +40,7 @@ const notifications = produce((state: NotificationsState, action: NotificationsA
41
40
  state.notifications = [
42
41
  ...state.notifications,
43
42
  {
44
- id: uuid(),
43
+ id: crypto.randomUUID(),
45
44
  ...(action.payload as NotificationPayload),
46
45
  },
47
46
  ];
@@ -72,7 +72,7 @@ export interface CmsFieldBase {
72
72
  label?: string;
73
73
  required?: boolean;
74
74
  hint?: string;
75
- pattern?: [string, string];
75
+ pattern?: [string | RegExp, string];
76
76
  i18n?: boolean | 'translate' | 'duplicate' | 'none';
77
77
  media_folder?: string;
78
78
  public_folder?: string;
@@ -185,6 +185,21 @@ export interface CmsFieldMarkdown {
185
185
  editorComponents?: string[];
186
186
  }
187
187
 
188
+ export interface CmsFieldRichText {
189
+ widget: 'richtext';
190
+ default?: string;
191
+
192
+ minimal?: boolean;
193
+ buttons?: CmsMarkdownWidgetButton[];
194
+ editor_components?: string[];
195
+ modes?: ('raw' | 'rich_text')[];
196
+
197
+ /**
198
+ * @deprecated Use editor_components instead
199
+ */
200
+ editorComponents?: string[];
201
+ }
202
+
188
203
  export interface CmsFieldNumber {
189
204
  widget: 'number';
190
205
  default?: string | number;
@@ -258,7 +273,7 @@ export interface CmsFieldMeta {
258
273
  label: string;
259
274
  widget: string;
260
275
  required: boolean;
261
- index_file: string;
276
+ index_file?: string;
262
277
  meta: boolean;
263
278
  }
264
279
 
@@ -272,6 +287,7 @@ export type CmsField = CmsFieldBase &
272
287
  | CmsFieldList
273
288
  | CmsFieldMap
274
289
  | CmsFieldMarkdown
290
+ | CmsFieldRichText
275
291
  | CmsFieldNumber
276
292
  | CmsFieldObject
277
293
  | CmsFieldRelation
@@ -299,14 +315,14 @@ export interface CmsCollectionFile {
299
315
  export interface ViewFilter {
300
316
  label: string;
301
317
  field: string;
302
- pattern: string;
318
+ pattern: string | boolean;
303
319
  id: string;
304
320
  }
305
321
 
306
322
  export interface ViewGroup {
307
323
  label: string;
308
324
  field: string;
309
- pattern: string;
325
+ pattern?: string;
310
326
  id: string;
311
327
  }
312
328
 
@@ -340,7 +356,7 @@ export interface CmsCollection {
340
356
  depth: number;
341
357
  };
342
358
  type: typeof FOLDER | typeof FILES;
343
- meta?: { path?: { label: string; widget: string; index_file: string } };
359
+ meta?: { path?: { label: string; widget: string; index_file?: string } };
344
360
 
345
361
  /**
346
362
  * It accepts the following values: yml, yaml, toml, json, md, markdown, html
@@ -580,6 +596,7 @@ export type EntryField = StaticallyTypedRecord<{
580
596
  name: string;
581
597
  default: string | null | boolean | List<unknown>;
582
598
  media_folder?: string;
599
+ multiple?: boolean;
583
600
  public_folder?: string;
584
601
  comment?: string;
585
602
  meta?: boolean;