decap-cms-core 3.10.1 → 3.12.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 (36) 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/deploys.js +3 -2
  4. package/dist/esm/backend.js +13 -5
  5. package/dist/esm/bootstrap.js +2 -2
  6. package/dist/esm/components/App/StatusBar.js +1 -1
  7. package/dist/esm/components/Editor/EditorPreviewPane/EditorPreviewContent.js +1 -0
  8. package/dist/esm/components/Editor/EditorPreviewPane/EditorPreviewPane.js +6 -4
  9. package/dist/esm/components/Editor/EditorToolbar.js +52 -34
  10. package/dist/esm/components/MediaLibrary/MediaLibraryButtons.js +7 -8
  11. package/dist/esm/components/UI/ErrorBoundary.js +10 -10
  12. package/dist/esm/constants/configSchema.js +9 -0
  13. package/dist/esm/formats/yaml.js +11 -2
  14. package/dist/esm/lib/formatters.js +14 -3
  15. package/dist/esm/lib/i18n.js +8 -3
  16. package/dist/esm/reducers/deploys.js +8 -3
  17. package/index.d.ts +6 -0
  18. package/package.json +2 -3
  19. package/src/actions/deploys.ts +4 -3
  20. package/src/backend.ts +12 -1
  21. package/src/components/Editor/EditorPreviewPane/EditorPreviewContent.js +1 -0
  22. package/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js +3 -0
  23. package/src/components/Editor/EditorToolbar.js +40 -3
  24. package/src/components/Editor/__tests__/EditorToolbar.spec.js +46 -0
  25. package/src/components/MediaLibrary/MediaLibraryButtons.js +2 -3
  26. package/src/components/UI/ErrorBoundary.js +5 -5
  27. package/src/constants/configSchema.js +6 -0
  28. package/src/formats/__tests__/frontmatter.spec.js +21 -0
  29. package/src/formats/__tests__/yaml.spec.js +27 -0
  30. package/src/formats/yaml.ts +16 -1
  31. package/src/lib/__tests__/formatters.spec.js +21 -0
  32. package/src/lib/formatters.ts +15 -3
  33. package/src/lib/i18n.ts +14 -6
  34. package/src/reducers/__tests__/deploys.spec.ts +111 -0
  35. package/src/reducers/deploys.ts +5 -3
  36. package/src/types/redux.ts +6 -0
@@ -230,6 +230,24 @@ const RefreshPreviewButton = styled.button`
230
230
  span {
231
231
  margin-right: 6px;
232
232
  }
233
+
234
+ &:disabled {
235
+ cursor: default;
236
+ opacity: 0.6;
237
+ }
238
+
239
+ ${Icon} {
240
+ ${props => props.$spinning && `animation: spin 1s linear infinite;`}
241
+ }
242
+
243
+ @keyframes spin {
244
+ from {
245
+ transform: rotate(0deg);
246
+ }
247
+ to {
248
+ transform: rotate(360deg);
249
+ }
250
+ }
233
251
  `;
234
252
 
235
253
  const PreviewLink = RefreshPreviewButton.withComponent('a');
@@ -285,17 +303,32 @@ export class EditorToolbar extends React.Component {
285
303
 
286
304
  const { isNewEntry, loadDeployPreview } = this.props;
287
305
  if (!isNewEntry) {
288
- loadDeployPreview({ maxAttempts: 3 });
306
+ // 24 attempts × 5s interval = ~2 min polling window.
307
+ // With editorial workflow, saving remounts the component (navigates to
308
+ // the unpublished entry view), so componentDidMount is the primary
309
+ // polling trigger — not componentDidUpdate.
310
+ this._pollController = new AbortController();
311
+ loadDeployPreview({ maxAttempts: 24, signal: this._pollController.signal });
289
312
  }
290
313
  }
291
314
 
292
315
  componentDidUpdate(prevProps) {
293
316
  const { isNewEntry, isPersisting, loadDeployPreview } = this.props;
294
317
  if (!isNewEntry && prevProps.isPersisting && !isPersisting) {
295
- loadDeployPreview({ maxAttempts: 3 });
318
+ // Abort any in-flight poll before starting a new one.
319
+ this._pollController?.abort();
320
+ this._pollController = new AbortController();
321
+ // Fires on subsequent saves when the component survives (no remount).
322
+ // In editorial workflow the first save remounts, so this mainly
323
+ // covers the second-save-and-beyond case.
324
+ loadDeployPreview({ maxAttempts: 3, signal: this._pollController.signal });
296
325
  }
297
326
  }
298
327
 
328
+ componentWillUnmount() {
329
+ this._pollController?.abort();
330
+ }
331
+
299
332
  renderSimpleControls = () => {
300
333
  const { collection, hasChanged, isNewEntry, showDelete, onDelete, t } = this.props;
301
334
  const canCreate = collection.get('create');
@@ -331,7 +364,11 @@ export class EditorToolbar extends React.Component {
331
364
  <Icon type="new-tab" size="xsmall" />
332
365
  </PreviewLink>
333
366
  ) : (
334
- <RefreshPreviewButton onClick={loadDeployPreview}>
367
+ <RefreshPreviewButton
368
+ onClick={loadDeployPreview}
369
+ disabled={isFetching}
370
+ $spinning={isFetching}
371
+ >
335
372
  <span>{t('editor.editorToolbar.deployPreviewPendingButtonLabel')}</span>
336
373
  <Icon type="refresh" size="xsmall" />
337
374
  </RefreshPreviewButton>
@@ -117,4 +117,50 @@ describe('EditorToolbar', () => {
117
117
  expect(asFragment()).toMatchSnapshot();
118
118
  });
119
119
  });
120
+
121
+ describe('deploy preview polling', () => {
122
+ it('should poll with maxAttempts: 24 and an AbortSignal on mount for existing entries', () => {
123
+ render(<EditorToolbar {...props} isNewEntry={false} />);
124
+ expect(props.loadDeployPreview).toHaveBeenCalledTimes(1);
125
+ const opts = props.loadDeployPreview.mock.calls[0][0];
126
+ expect(opts.maxAttempts).toBe(24);
127
+ expect(opts.signal).toBeInstanceOf(AbortSignal);
128
+ });
129
+
130
+ it('should not poll on mount for new entries', () => {
131
+ render(<EditorToolbar {...props} isNewEntry={true} />);
132
+ expect(props.loadDeployPreview).not.toHaveBeenCalled();
133
+ });
134
+
135
+ it('should poll with maxAttempts: 3 after a save completes', () => {
136
+ const { rerender } = render(<EditorToolbar {...props} isPersisting={true} />);
137
+ props.loadDeployPreview.mockClear();
138
+ rerender(<EditorToolbar {...props} isPersisting={false} />);
139
+ expect(props.loadDeployPreview).toHaveBeenCalledTimes(1);
140
+ const opts = props.loadDeployPreview.mock.calls[0][0];
141
+ expect(opts.maxAttempts).toBe(3);
142
+ expect(opts.signal).toBeInstanceOf(AbortSignal);
143
+ });
144
+
145
+ it('should abort polling on unmount', () => {
146
+ const { unmount } = render(<EditorToolbar {...props} isNewEntry={false} />);
147
+ const signal = props.loadDeployPreview.mock.calls[0][0].signal;
148
+ expect(signal.aborted).toBe(false);
149
+ unmount();
150
+ expect(signal.aborted).toBe(true);
151
+ });
152
+
153
+ it('should abort previous poll when a new save triggers a new poll', () => {
154
+ const { rerender } = render(<EditorToolbar {...props} isPersisting={false} />);
155
+ const firstSignal = props.loadDeployPreview.mock.calls[0][0].signal;
156
+
157
+ // Simulate save completing
158
+ rerender(<EditorToolbar {...props} isPersisting={true} />);
159
+ rerender(<EditorToolbar {...props} isPersisting={false} />);
160
+
161
+ expect(firstSignal.aborted).toBe(true);
162
+ const secondSignal = props.loadDeployPreview.mock.calls[1][0].signal;
163
+ expect(secondSignal.aborted).toBe(false);
164
+ });
165
+ });
120
166
  });
@@ -2,7 +2,6 @@ import React from 'react';
2
2
  import PropTypes from 'prop-types';
3
3
  import { css } from '@emotion/react';
4
4
  import styled from '@emotion/styled';
5
- import copyToClipboard from 'copy-text-to-clipboard';
6
5
  import { isAbsolutePath } from 'decap-cms-lib-util';
7
6
  import { buttons, shadows, zIndex } from 'decap-cms-ui-default';
8
7
 
@@ -87,10 +86,10 @@ export class CopyToClipBoardButton extends React.Component {
87
86
  this.mounted = false;
88
87
  }
89
88
 
90
- handleCopy = () => {
89
+ handleCopy = async () => {
91
90
  clearTimeout(this.timeout);
92
91
  const { path, draft, name } = this.props;
93
- copyToClipboard(isAbsolutePath(path) || !draft ? path : name);
92
+ await navigator.clipboard.writeText(isAbsolutePath(path) || !draft ? path : name);
94
93
  this.setState({ copied: true });
95
94
  this.timeout = setTimeout(() => this.mounted && this.setState({ copied: false }), 1500);
96
95
  };
@@ -4,11 +4,10 @@ import { translate } from 'react-polyglot';
4
4
  import styled from '@emotion/styled';
5
5
  import yaml from 'yaml';
6
6
  import truncate from 'lodash/truncate';
7
- import copyToClipboard from 'copy-text-to-clipboard';
8
7
  import { localForage } from 'decap-cms-lib-util';
9
8
  import { buttons, colors } from 'decap-cms-ui-default';
10
9
 
11
- const ISSUE_URL = 'https://github.com/decaporg/decap-cms/issues/new?';
10
+ const ISSUE_URL = 'https://github.com/decaporg/decap-cms/issues/new';
12
11
 
13
12
  function getIssueTemplate({ version, provider, browser, config }) {
14
13
  return `
@@ -52,6 +51,7 @@ function buildIssueTemplate({ config }) {
52
51
  }
53
52
 
54
53
  function buildIssueUrl({ title, config }) {
54
+ const issueUrl = config?.issue_reports?.url ?? ISSUE_URL;
55
55
  try {
56
56
  const body = buildIssueTemplate({ config });
57
57
 
@@ -60,10 +60,10 @@ function buildIssueUrl({ title, config }) {
60
60
  params.append('body', truncate(body, { length: 4000, omission: '\n...' }));
61
61
  params.append('labels', 'type: bug');
62
62
 
63
- return `${ISSUE_URL}${params.toString()}`;
63
+ return `${issueUrl}?${params.toString()}`;
64
64
  } catch (e) {
65
65
  console.log(e);
66
- return `${ISSUE_URL}template=bug_report.md`;
66
+ return `${issueUrl}?template=bug_report.md`;
67
67
  }
68
68
  }
69
69
 
@@ -116,7 +116,7 @@ function RecoveredEntry({ entry, t }) {
116
116
  <hr />
117
117
  <h2>{t('ui.errorBoundary.recoveredEntry.heading')}</h2>
118
118
  <strong>{t('ui.errorBoundary.recoveredEntry.warning')}</strong>
119
- <CopyButton onClick={() => copyToClipboard(entry)}>
119
+ <CopyButton onClick={() => navigator.clipboard.writeText(entry)}>
120
120
  {t('ui.errorBoundary.recoveredEntry.copyButtonLabel')}
121
121
  </CopyButton>
122
122
  <pre>
@@ -193,6 +193,12 @@ function getConfigSchema() {
193
193
  clean_accents: { type: 'boolean' },
194
194
  },
195
195
  },
196
+ issue_reports: {
197
+ type: 'object',
198
+ properties: {
199
+ url: { type: 'string', examples: ['https://example.com/report-issue'] },
200
+ },
201
+ },
196
202
  collections: {
197
203
  type: 'array',
198
204
  minItems: 1,
@@ -72,6 +72,27 @@ describe('Frontmatter', () => {
72
72
  });
73
73
  });
74
74
 
75
+ it('should throw on duplicate frontmatter keys', () => {
76
+ expect(() =>
77
+ FrontmatterInfer.fromFile('---\ntitle: Hello\ntitle: World\n---\nContent'),
78
+ ).toThrow(/Map keys must be unique/);
79
+ });
80
+
81
+ it('should throw on duplicate frontmatter keys with explicit YAML format', () => {
82
+ expect(() =>
83
+ frontmatterYAML().fromFile('---\ntitle: Hello\ntitle: World\n---\nContent'),
84
+ ).toThrow(/Map keys must be unique/);
85
+ });
86
+
87
+ it('should not throw when body contains YAML-like patterns', () => {
88
+ expect(
89
+ FrontmatterInfer.fromFile('---\ntitle: Hello\n---\ntitle: this is not a duplicate'),
90
+ ).toEqual({
91
+ title: 'Hello',
92
+ body: 'title: this is not a duplicate',
93
+ });
94
+ });
95
+
75
96
  it('should stringify YAML with --- delimiters', () => {
76
97
  expect(
77
98
  FrontmatterInfer.toFile({
@@ -85,6 +85,33 @@ describe('yaml', () => {
85
85
  time: '10:05',
86
86
  });
87
87
  });
88
+
89
+ test('throws on duplicate keys', () => {
90
+ expect(() => yaml.fromFile('title: Hello\ntitle: World')).toThrow(
91
+ /Map keys must be unique; "title" is repeated/,
92
+ );
93
+ });
94
+
95
+ test('throws on duplicate nested keys', () => {
96
+ expect(() => yaml.fromFile('nested:\n a: 1\n a: 2')).toThrow(
97
+ /Map keys must be unique; "a" is repeated/,
98
+ );
99
+ });
100
+
101
+ test('does not throw when same key appears in different nested objects', () => {
102
+ expect(yaml.fromFile('obj1:\n name: foo\nobj2:\n name: bar')).toEqual({
103
+ obj1: { name: 'foo' },
104
+ obj2: { name: 'bar' },
105
+ });
106
+ });
107
+
108
+ test('logs warnings to console.warn', () => {
109
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
110
+ // Valid YAML should produce no warnings
111
+ yaml.fromFile('title: Hello');
112
+ expect(warnSpy).not.toHaveBeenCalled();
113
+ warnSpy.mockRestore();
114
+ });
88
115
  });
89
116
  describe('toFile', () => {
90
117
  test('outputs valid yaml', () => {
@@ -41,7 +41,22 @@ export default {
41
41
  if (content && content.trim().endsWith('---')) {
42
42
  content = content.trim().slice(0, -3);
43
43
  }
44
- return yaml.parse(content, { customTags: [timestampTag] });
44
+
45
+ const doc = yaml.parseDocument(content, {
46
+ customTags: [timestampTag],
47
+ prettyErrors: true,
48
+ });
49
+
50
+ for (const warn of doc.warnings) {
51
+ console.warn(`YAML warning: ${warn.message}`);
52
+ }
53
+
54
+ if (doc.errors.length > 0) {
55
+ const messages = doc.errors.map(e => e.message).join('\n');
56
+ throw new Error(`YAML parsing error:\n${messages}`);
57
+ }
58
+
59
+ return doc.toJSON();
45
60
  },
46
61
 
47
62
  toFile(data: object, sortedKeys: string[] = [], comments: Record<string, string> = {}) {
@@ -247,6 +247,27 @@ describe('formatters', () => {
247
247
  'Ignoring unknown variable “author-email” in open authoring message template.',
248
248
  );
249
249
  });
250
+
251
+ it('should return commit with trailer when signoff_commits is enabled', () => {
252
+ const collection = Map({ label_singular: 'Collection' });
253
+ const config = {
254
+ backend: {
255
+ signoff_commits: true,
256
+ },
257
+ };
258
+
259
+ expect(
260
+ commitMessageFormatter('create', config, {
261
+ slug: 'doc-slug',
262
+ path: 'file-path',
263
+ collection,
264
+ authorName: 'Test User',
265
+ authorEmail: 'test-user@example.org',
266
+ }),
267
+ ).toEqual(
268
+ 'Create Collection “doc-slug”\n\nSigned-off-by: Test User <test-user@example.org>\n',
269
+ );
270
+ });
250
271
  });
251
272
 
252
273
  describe('prepareSlug', () => {
@@ -44,16 +44,28 @@ type Options = {
44
44
  collection?: Collection;
45
45
  authorLogin?: string;
46
46
  authorName?: string;
47
+ authorEmail?: string;
47
48
  };
48
49
 
49
50
  export function commitMessageFormatter(
50
51
  type: keyof typeof commitMessageTemplates,
51
52
  config: CmsConfig,
52
- { slug, path, collection, authorLogin, authorName }: Options,
53
+ { slug, path, collection, authorLogin, authorName, authorEmail }: Options,
53
54
  isOpenAuthoring?: boolean,
54
55
  ) {
55
56
  const templates = { ...commitMessageTemplates, ...(config.backend.commit_messages || {}) };
56
57
 
58
+ let trailers = '';
59
+ if (config.backend.signoff_commits) {
60
+ if (!authorName) {
61
+ console.warn('Option signoff_commits is enabled, but author name is unknown');
62
+ } else if (!authorEmail) {
63
+ console.warn('Option signoff_commits is enabled, but author email is unknown');
64
+ } else {
65
+ trailers = `\n\nSigned-off-by: ${authorName} <${authorEmail}>\n`;
66
+ }
67
+ }
68
+
57
69
  const commitMessage = templates[type].replace(variableRegex, (_, variable) => {
58
70
  switch (variable) {
59
71
  case 'slug':
@@ -73,7 +85,7 @@ export function commitMessageFormatter(
73
85
  });
74
86
 
75
87
  if (!isOpenAuthoring) {
76
- return commitMessage;
88
+ return commitMessage + trailers;
77
89
  }
78
90
 
79
91
  const message = templates.openAuthoring.replace(variableRegex, (_, variable) => {
@@ -90,7 +102,7 @@ export function commitMessageFormatter(
90
102
  }
91
103
  });
92
104
 
93
- return message;
105
+ return message + trailers;
94
106
  }
95
107
 
96
108
  export function prepareSlug(slug: string) {
package/src/lib/i18n.ts CHANGED
@@ -308,18 +308,26 @@ export async function getI18nEntry(
308
308
  if (structure === I18N_STRUCTURE.SINGLE_FILE) {
309
309
  entryValue = mergeSingleFileValue(await getEntryValue(path), defaultLocale, locales);
310
310
  } else {
311
- const entryValues = await Promise.all(
311
+ const entryValuesResults = await Promise.allSettled(
312
312
  locales.map(async locale => {
313
313
  const entryPath = getFilePath(structure, extension, path, slug, locale);
314
- const value = await getEntryValue(entryPath).catch(() => null);
314
+ const value = await getEntryValue(entryPath);
315
315
  return { value, locale };
316
316
  }),
317
317
  );
318
318
 
319
- const nonNullValues = entryValues.filter(e => e.value !== null) as {
320
- value: EntryValue;
321
- locale: string;
322
- }[];
319
+ const nonNullValues = entryValuesResults
320
+ .map(e => (e.status === 'fulfilled' ? e.value : undefined))
321
+ .filter((e): e is { value: EntryValue; locale: string } => e !== undefined);
322
+
323
+ if (nonNullValues.length === 0) {
324
+ // mergeValues will throw on an empty list, and show the error messages.
325
+ const [error = new Error('No entry values found for any locale')] = entryValuesResults
326
+ .map(e => (e.status === 'rejected' ? e.reason : undefined))
327
+ .filter(e => e !== undefined);
328
+
329
+ throw error;
330
+ }
323
331
 
324
332
  entryValue = mergeValues(collection, structure, defaultLocale, nonNullValues);
325
333
  }
@@ -0,0 +1,111 @@
1
+ import deploys, { selectDeployPreview } from '../deploys';
2
+ import {
3
+ DEPLOY_PREVIEW_REQUEST,
4
+ DEPLOY_PREVIEW_SUCCESS,
5
+ DEPLOY_PREVIEW_FAILURE,
6
+ } from '../../actions/deploys';
7
+
8
+ describe('deploys reducer', () => {
9
+ it('should return the default state', () => {
10
+ const result = deploys(undefined, { type: 'UNKNOWN' });
11
+ expect(result).toEqual({});
12
+ });
13
+
14
+ describe('DEPLOY_PREVIEW_REQUEST', () => {
15
+ it('should set isFetching to true and status to PENDING', () => {
16
+ const result = deploys(undefined, {
17
+ type: DEPLOY_PREVIEW_REQUEST,
18
+ payload: { collection: 'posts', slug: 'my-post' },
19
+ });
20
+ expect(result['posts.my-post']).toEqual({ isFetching: true, status: 'PENDING' });
21
+ });
22
+
23
+ it('should clear stale url and replace status with PENDING', () => {
24
+ const staleState = {
25
+ 'posts.my-post': {
26
+ isFetching: false,
27
+ url: 'https://production.example.com/posts/my-post',
28
+ status: 'SUCCESS',
29
+ },
30
+ };
31
+ const result = deploys(staleState, {
32
+ type: DEPLOY_PREVIEW_REQUEST,
33
+ payload: { collection: 'posts', slug: 'my-post' },
34
+ });
35
+ expect(result['posts.my-post']).toEqual({ isFetching: true, status: 'PENDING' });
36
+ expect(result['posts.my-post'].url).toBeUndefined();
37
+ });
38
+ });
39
+
40
+ describe('DEPLOY_PREVIEW_SUCCESS', () => {
41
+ it('should store the deploy preview url and status', () => {
42
+ const initialState = {
43
+ 'posts.my-post': { isFetching: true },
44
+ };
45
+ const result = deploys(initialState, {
46
+ type: DEPLOY_PREVIEW_SUCCESS,
47
+ payload: {
48
+ collection: 'posts',
49
+ slug: 'my-post',
50
+ url: 'https://preview.example.com/posts/my-post',
51
+ status: 'SUCCESS',
52
+ },
53
+ });
54
+ expect(result['posts.my-post']).toEqual({
55
+ isFetching: false,
56
+ url: 'https://preview.example.com/posts/my-post',
57
+ status: 'SUCCESS',
58
+ });
59
+ });
60
+ });
61
+
62
+ describe('DEPLOY_PREVIEW_FAILURE', () => {
63
+ it('should set isFetching to false and status to PENDING', () => {
64
+ const initialState = {
65
+ 'posts.my-post': { isFetching: true, status: 'PENDING' },
66
+ };
67
+ const result = deploys(initialState, {
68
+ type: DEPLOY_PREVIEW_FAILURE,
69
+ payload: { collection: 'posts', slug: 'my-post' },
70
+ });
71
+ expect(result['posts.my-post'].isFetching).toBe(false);
72
+ expect(result['posts.my-post'].status).toBe('PENDING');
73
+ });
74
+
75
+ it('should clear url and replace status with PENDING to prevent stale data', () => {
76
+ const initialState = {
77
+ 'posts.my-post': {
78
+ isFetching: true,
79
+ url: 'https://production.example.com/posts/my-post',
80
+ status: 'SUCCESS',
81
+ },
82
+ };
83
+ const result = deploys(initialState, {
84
+ type: DEPLOY_PREVIEW_FAILURE,
85
+ payload: { collection: 'posts', slug: 'my-post' },
86
+ });
87
+ expect(result['posts.my-post']).toEqual({
88
+ isFetching: false,
89
+ url: undefined,
90
+ status: 'PENDING',
91
+ });
92
+ });
93
+ });
94
+
95
+ describe('selectDeployPreview', () => {
96
+ it('should return the deploy preview for a given collection and slug', () => {
97
+ const state = {
98
+ 'posts.my-post': {
99
+ isFetching: false,
100
+ url: 'https://preview.example.com/posts/my-post',
101
+ status: 'SUCCESS',
102
+ },
103
+ };
104
+ expect(selectDeployPreview(state, 'posts', 'my-post')).toBe(state['posts.my-post']);
105
+ });
106
+
107
+ it('should return undefined for unknown collection/slug', () => {
108
+ expect(selectDeployPreview({}, 'posts', 'unknown')).toBeUndefined();
109
+ });
110
+ });
111
+ });
@@ -23,8 +23,7 @@ const deploys = produce((state: Deploys, action: DeploysAction) => {
23
23
  case DEPLOY_PREVIEW_REQUEST: {
24
24
  const { collection, slug } = action.payload;
25
25
  const key = `${collection}.${slug}`;
26
- state[key] = state[key] || {};
27
- state[key].isFetching = true;
26
+ state[key] = { isFetching: true, status: 'PENDING' };
28
27
  break;
29
28
  }
30
29
 
@@ -39,7 +38,10 @@ const deploys = produce((state: Deploys, action: DeploysAction) => {
39
38
 
40
39
  case DEPLOY_PREVIEW_FAILURE: {
41
40
  const { collection, slug } = action.payload;
42
- state[`${collection}.${slug}`].isFetching = false;
41
+ const key = `${collection}.${slug}`;
42
+ state[key].isFetching = false;
43
+ state[key].url = undefined;
44
+ state[key].status = 'PENDING';
43
45
  break;
44
46
  }
45
47
  }
@@ -377,6 +377,7 @@ export interface CmsBackend {
377
377
  auth_endpoint?: string;
378
378
  cms_label_prefix?: string;
379
379
  squash_merges?: boolean;
380
+ signoff_commits?: boolean;
380
381
  proxy_url?: string;
381
382
  commit_messages?: {
382
383
  create?: string;
@@ -399,6 +400,10 @@ export interface CmsLocalBackend {
399
400
  allowed_hosts?: string[];
400
401
  }
401
402
 
403
+ export interface CmsIssueReports {
404
+ url?: string;
405
+ }
406
+
402
407
  export interface CmsConfig {
403
408
  backend: CmsBackend;
404
409
  collections: CmsCollection[];
@@ -427,6 +432,7 @@ export interface CmsConfig {
427
432
  }[];
428
433
  slug?: CmsSlug;
429
434
  i18n?: CmsI18nConfig;
435
+ issue_reports?: CmsIssueReports;
430
436
  local_backend?: boolean | CmsLocalBackend;
431
437
  editor?: {
432
438
  preview?: boolean;