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.
- package/dist/decap-cms-core.js +18 -18
- package/dist/decap-cms-core.js.map +1 -1
- package/dist/esm/actions/deploys.js +3 -2
- package/dist/esm/backend.js +13 -5
- package/dist/esm/bootstrap.js +2 -2
- package/dist/esm/components/App/StatusBar.js +1 -1
- package/dist/esm/components/Editor/EditorPreviewPane/EditorPreviewContent.js +1 -0
- package/dist/esm/components/Editor/EditorPreviewPane/EditorPreviewPane.js +6 -4
- package/dist/esm/components/Editor/EditorToolbar.js +52 -34
- package/dist/esm/components/MediaLibrary/MediaLibraryButtons.js +7 -8
- package/dist/esm/components/UI/ErrorBoundary.js +10 -10
- package/dist/esm/constants/configSchema.js +9 -0
- package/dist/esm/formats/yaml.js +11 -2
- package/dist/esm/lib/formatters.js +14 -3
- package/dist/esm/lib/i18n.js +8 -3
- package/dist/esm/reducers/deploys.js +8 -3
- package/index.d.ts +6 -0
- package/package.json +2 -3
- package/src/actions/deploys.ts +4 -3
- package/src/backend.ts +12 -1
- package/src/components/Editor/EditorPreviewPane/EditorPreviewContent.js +1 -0
- package/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js +3 -0
- package/src/components/Editor/EditorToolbar.js +40 -3
- package/src/components/Editor/__tests__/EditorToolbar.spec.js +46 -0
- package/src/components/MediaLibrary/MediaLibraryButtons.js +2 -3
- package/src/components/UI/ErrorBoundary.js +5 -5
- package/src/constants/configSchema.js +6 -0
- package/src/formats/__tests__/frontmatter.spec.js +21 -0
- package/src/formats/__tests__/yaml.spec.js +27 -0
- package/src/formats/yaml.ts +16 -1
- package/src/lib/__tests__/formatters.spec.js +21 -0
- package/src/lib/formatters.ts +15 -3
- package/src/lib/i18n.ts +14 -6
- package/src/reducers/__tests__/deploys.spec.ts +111 -0
- package/src/reducers/deploys.ts +5 -3
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 `${
|
|
63
|
+
return `${issueUrl}?${params.toString()}`;
|
|
64
64
|
} catch (e) {
|
|
65
65
|
console.log(e);
|
|
66
|
-
return `${
|
|
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={() =>
|
|
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', () => {
|
package/src/formats/yaml.ts
CHANGED
|
@@ -41,7 +41,22 @@ export default {
|
|
|
41
41
|
if (content && content.trim().endsWith('---')) {
|
|
42
42
|
content = content.trim().slice(0, -3);
|
|
43
43
|
}
|
|
44
|
-
|
|
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', () => {
|
package/src/lib/formatters.ts
CHANGED
|
@@ -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
|
|
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)
|
|
314
|
+
const value = await getEntryValue(entryPath);
|
|
315
315
|
return { value, locale };
|
|
316
316
|
}),
|
|
317
317
|
);
|
|
318
318
|
|
|
319
|
-
const nonNullValues =
|
|
320
|
-
value:
|
|
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
|
+
});
|
package/src/reducers/deploys.ts
CHANGED
|
@@ -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] =
|
|
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
|
-
|
|
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
|
}
|
package/src/types/redux.ts
CHANGED
|
@@ -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;
|