feat(workflow): add deploy preview links (#2028)

This commit is contained in:
Shawn Erquhart
2019-02-08 12:26:59 -05:00
committed by GitHub
parent f0553c720a
commit 15d221d4a4
24 changed files with 861 additions and 42 deletions

View File

@ -30,6 +30,12 @@ export function applyDefaults(config) {
return Map(defaults)
.mergeDeep(config)
.withMutations(map => {
/**
* Use `site_url` as default `display_url`.
*/
if (!map.get('display_url') && map.get('site_url')) {
map.set('display_url', map.get('site_url'));
}
/**
* Use media_folder as default public_folder.
*/

View File

@ -0,0 +1,86 @@
import { actions as notifActions } from 'redux-notifications';
import { currentBackend } from 'src/backend';
import { selectDeployPreview } from 'Reducers';
const { notifSend } = notifActions;
export const DEPLOY_PREVIEW_REQUEST = 'DEPLOY_PREVIEW_REQUEST';
export const DEPLOY_PREVIEW_SUCCESS = 'DEPLOY_PREVIEW_SUCCESS';
export const DEPLOY_PREVIEW_FAILURE = 'DEPLOY_PREVIEW_FAILURE';
export function deployPreviewLoading(collection, slug) {
return {
type: DEPLOY_PREVIEW_REQUEST,
payload: {
collection: collection.get('name'),
slug,
},
};
}
export function deployPreviewLoaded(collection, slug, { url, status }) {
return {
type: DEPLOY_PREVIEW_SUCCESS,
payload: {
collection: collection.get('name'),
slug,
url,
status,
},
};
}
export function deployPreviewError(collection, slug) {
return {
type: DEPLOY_PREVIEW_FAILURE,
payload: {
collection: collection.get('name'),
slug,
},
};
}
/**
* Requests a deploy preview object from the registered backend.
*/
export function loadDeployPreview(collection, slug, entry, published, opts) {
return async (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
// Exit if currently fetching
const deployState = selectDeployPreview(state, collection, slug);
if (deployState && deployState.get('isFetching')) {
return;
}
dispatch(deployPreviewLoading(collection, slug));
try {
/**
* `getDeploy` is for published entries, while `getDeployPreview` is for
* unpublished entries.
*/
const deploy = published
? backend.getDeploy(collection, slug, entry)
: await backend.getDeployPreview(collection, slug, entry, opts);
if (deploy) {
return dispatch(deployPreviewLoaded(collection, slug, deploy));
}
return dispatch(deployPreviewError(collection, slug));
} catch (error) {
console.error(error);
dispatch(
notifSend({
message: {
details: error.message,
key: 'ui.toast.onFailToLoadDeployPreview',
},
kind: 'danger',
dismissAfter: 8000,
}),
);
dispatch(deployPreviewError(collection, slug));
}
};
}

View File

@ -1,5 +1,7 @@
import { attempt, flatten, isError } from 'lodash';
import { attempt, flatten, isError, trimStart, trimEnd, flow, partialRight } from 'lodash';
import { Map } from 'immutable';
import { stripIndent } from 'common-tags';
import moment from 'moment';
import fuzzy from 'fuzzy';
import { resolveFormat } from 'Formats/formats';
import { selectIntegration } from 'Reducers/integrations';
@ -36,9 +38,67 @@ class LocalStorageAuthStore {
}
}
const slugFormatter = (collection, entryData, slugConfig) => {
function prepareSlug(slug) {
return (
slug
// Convert slug to lower-case
.toLocaleLowerCase()
// Remove single quotes.
.replace(/[']/g, '')
// Replace periods with dashes.
.replace(/[.]/g, '-')
);
}
const dateParsers = {
year: date => date.getFullYear(),
month: date => `0${date.getMonth() + 1}`.slice(-2),
day: date => `0${date.getDate()}`.slice(-2),
hour: date => `0${date.getHours()}`.slice(-2),
minute: date => `0${date.getMinutes()}`.slice(-2),
second: date => `0${date.getSeconds()}`.slice(-2),
};
const SLUG_MISSING_REQUIRED_DATE = 'SLUG_MISSING_REQUIRED_DATE';
function compileSlug(template, date, identifier = '', data = Map(), processor) {
let missingRequiredDate;
const slug = template.replace(/\{\{([^}]+)\}\}/g, (_, key) => {
let replacement;
if (dateParsers[key] && !date) {
missingRequiredDate = true;
return '';
}
if (dateParsers[key]) {
replacement = dateParsers[key](date);
} else if (key === 'slug') {
replacement = identifier.trim();
} else {
replacement = data.get(key, '').trim();
}
if (processor) {
return processor(replacement);
}
return replacement;
});
if (missingRequiredDate) {
const err = new Error();
err.name = SLUG_MISSING_REQUIRED_DATE;
throw err;
} else {
return slug;
}
}
function slugFormatter(collection, entryData, slugConfig) {
const template = collection.get('slug') || '{{slug}}';
const date = new Date();
const identifier = entryData.get(selectIdentifier(collection));
if (!identifier) {
@ -47,38 +107,13 @@ const slugFormatter = (collection, entryData, slugConfig) => {
);
}
const slug = template
.replace(/\{\{([^}]+)\}\}/g, (_, field) => {
switch (field) {
case 'year':
return date.getFullYear();
case 'month':
return `0${date.getMonth() + 1}`.slice(-2);
case 'day':
return `0${date.getDate()}`.slice(-2);
case 'hour':
return `0${date.getHours()}`.slice(-2);
case 'minute':
return `0${date.getMinutes()}`.slice(-2);
case 'second':
return `0${date.getSeconds()}`.slice(-2);
case 'slug':
return identifier.trim();
default:
return entryData.get(field, '').trim();
}
})
// Convert slug to lower-case
.toLocaleLowerCase()
// Pass entire slug through `prepareSlug` and `sanitizeSlug`.
// TODO: only pass slug replacements through sanitizers, static portions of
// the slug template should not be sanitized. (breaking change)
const processSlug = flow([compileSlug, prepareSlug, partialRight(sanitizeSlug, slugConfig)]);
// Remove single quotes.
.replace(/[']/g, '')
// Replace periods with dashes.
.replace(/[.]/g, '-');
return sanitizeSlug(slug, slugConfig);
};
return processSlug(template, new Date(), identifier, entryData);
}
const commitMessageTemplates = Map({
create: 'Create {{collection}} “{{slug}}”',
@ -120,8 +155,78 @@ const sortByScore = (a, b) => {
return 0;
};
function parsePreviewPathDate(collection, entry) {
const dateField =
collection.get('preview_path_date_field') || selectInferedField(collection, 'date');
if (!dateField) {
return;
}
const dateValue = entry.getIn(['data', dateField]);
const dateMoment = dateValue && moment(dateValue);
if (dateMoment && dateMoment.isValid()) {
return dateMoment.toDate();
}
}
function createPreviewUrl(baseUrl, collection, slug, slugConfig, entry) {
/**
* Preview URL can't be created without `baseUrl`. This makes preview URLs
* optional for backends that don't support them.
*/
if (!baseUrl) {
return;
}
/**
* Without a `previewPath` for the collection (via config), the preview URL
* will be the URL provided by the backend.
*/
if (!collection.get('preview_path')) {
return baseUrl;
}
/**
* If a `previewPath` is provided for the collection, use it to construct the
* URL path.
*/
const basePath = trimEnd(baseUrl, '/');
const pathTemplate = collection.get('preview_path');
const fields = entry.get('data');
const date = parsePreviewPathDate(collection, entry);
// Prepare and sanitize slug variables only, leave the rest of the
// `preview_path` template as is.
const processSegment = flow([
value => String(value),
prepareSlug,
partialRight(sanitizeSlug, slugConfig),
]);
let compiledPath;
try {
compiledPath = compileSlug(pathTemplate, date, slug, fields, processSegment);
} catch (err) {
// Print an error and ignore `preview_path` if both:
// 1. Date is invalid (according to Moment), and
// 2. A date expression (eg. `{{year}}`) is used in `preview_path`
if (err.name === SLUG_MISSING_REQUIRED_DATE) {
console.error(stripIndent`
Collection "${collection.get('name')}" configuration error:
\`preview_path_date_field\` must be a field with a valid date. Ignoring \`preview_path\`.
`);
return basePath;
}
throw err;
}
const previewPath = trimStart(compiledPath, ' /');
return `${basePath}/${previewPath}`;
}
class Backend {
constructor(implementation, { backendName, authStore = null, config } = {}) {
this.config = config;
this.implementation = implementation.init(config, {
useWorkflow: config.getIn(['publish_mode']) === EDITORIAL_WORKFLOW,
updateUserCredentials: this.updateUserCredentials,
@ -374,6 +479,75 @@ class Backend {
.then(this.entryWithFormat(collection, slug));
}
/**
* Creates a URL using `site_url` from the config and `preview_path` from the
* entry's collection. Does not currently make a request through the backend,
* but likely will in the future.
*/
getDeploy(collection, slug, entry) {
/**
* If `site_url` is undefiend or `show_preview_links` in the config is set to false, do nothing.
*/
const baseUrl = this.config.get('site_url');
if (!baseUrl || this.config.get('show_preview_links') === false) {
return;
}
return {
url: createPreviewUrl(baseUrl, collection, slug, this.config.get('slug'), entry),
status: 'SUCCESS',
};
}
/**
* Requests a base URL from the backend for previewing a specific entry.
* Supports polling via `maxAttempts` and `interval` options, as there is
* often a delay before a preview URL is available.
*/
async getDeployPreview(collection, slug, entry, { maxAttempts = 1, interval = 5000 } = {}) {
/**
* If the registered backend does not provide a `getDeployPreview` method, or
* `show_preview_links` in the config is set to false, do nothing.
*/
if (!this.implementation.getDeployPreview || this.config.get('show_preview_links') === false) {
return;
}
/**
* Poll for the deploy preview URL (defaults to 1 attempt, so no polling by
* default).
*/
let deployPreview,
count = 0;
while (!deployPreview && count < maxAttempts) {
count++;
deployPreview = await this.implementation.getDeployPreview(collection, slug);
if (!deployPreview) {
await new Promise(resolve => setTimeout(() => resolve(), interval));
}
}
/**
* If there's no deploy preview, do nothing.
*/
if (!deployPreview) {
return;
}
return {
/**
* Create a URL using the collection `preview_path`, if provided.
*/
url: createPreviewUrl(deployPreview.url, collection, slug, this.config.get('slug'), entry),
/**
* Always capitalize the status for consistency.
*/
status: deployPreview.status ? deployPreview.status.toUpperCase() : '',
};
}
persistEntry(config, collection, entryDraft, MediaFiles, integrations, options = {}) {
const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false;

View File

@ -22,8 +22,9 @@ import {
publishUnpublishedEntry,
deleteUnpublishedEntry,
} from 'Actions/editorialWorkflow';
import { loadDeployPreview } from 'Actions/deploys';
import { deserializeValues } from 'Lib/serializeEntryValues';
import { selectEntry, selectUnpublishedEntry, getAsset } from 'Reducers';
import { selectEntry, selectUnpublishedEntry, selectDeployPreview, getAsset } from 'Reducers';
import { selectFields } from 'Reducers/collections';
import { status } from 'Constants/publishModes';
import { EDITORIAL_WORKFLOW } from 'Constants/publishModes';
@ -64,6 +65,8 @@ class Editor extends React.Component {
deleteUnpublishedEntry: PropTypes.func.isRequired,
logoutUser: PropTypes.func.isRequired,
loadEntries: PropTypes.func.isRequired,
deployPreview: ImmutablePropTypes.map,
loadDeployPreview: PropTypes.func.isRequired,
currentStatus: PropTypes.string,
user: ImmutablePropTypes.map.isRequired,
location: PropTypes.shape({
@ -309,9 +312,14 @@ class Editor extends React.Component {
isModification,
currentStatus,
logoutUser,
deployPreview,
loadDeployPreview,
slug,
t,
} = this.props;
const isPublished = !newEntry && !unpublishedEntry;
if (entry && entry.get('error')) {
return (
<div>
@ -351,6 +359,8 @@ class Editor extends React.Component {
isModification={isModification}
currentStatus={currentStatus}
onLogoutClick={logoutUser}
deployPreview={deployPreview}
loadDeployPreview={opts => loadDeployPreview(collection, slug, entry, isPublished, opts)}
/>
);
}
@ -373,6 +383,7 @@ function mapStateToProps(state, ownProps) {
const collectionEntriesLoaded = !!entries.getIn(['pages', collectionName]);
const unpublishedEntry = selectUnpublishedEntry(state, collectionName, slug);
const currentStatus = unpublishedEntry && unpublishedEntry.getIn(['metaData', 'status']);
const deployPreview = selectDeployPreview(state, collectionName, slug);
return {
collection,
collections,
@ -389,6 +400,7 @@ function mapStateToProps(state, ownProps) {
isModification,
collectionEntriesLoaded,
currentStatus,
deployPreview,
};
}
@ -399,6 +411,7 @@ export default connect(
changeDraftFieldValidation,
loadEntry,
loadEntries,
loadDeployPreview,
createDraftFromEntry,
createEmptyDraft,
discardDraft,

View File

@ -169,6 +169,8 @@ class EditorInterface extends Component {
isModification,
currentStatus,
onLogoutClick,
loadDeployPreview,
deployPreview,
} = this.props;
const { previewVisible, scrollSyncEnabled, showEventBlocker } = this.state;
@ -239,6 +241,8 @@ class EditorInterface extends Component {
isModification={isModification}
currentStatus={currentStatus}
onLogoutClick={onLogoutClick}
loadDeployPreview={loadDeployPreview}
deployPreview={deployPreview}
/>
<Editor>
<ViewControls>
@ -290,6 +294,8 @@ EditorInterface.propTypes = {
isModification: PropTypes.bool,
currentStatus: PropTypes.string,
onLogoutClick: PropTypes.func.isRequired,
deployPreview: ImmutablePropTypes.map,
loadDeployPreview: PropTypes.func.isRequired,
};
export default EditorInterface;

View File

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import styled, { css } from 'react-emotion';
import { translate } from 'react-polyglot';
import { Map } from 'immutable';
import { Link } from 'react-router-dom';
import {
Icon,
@ -55,6 +56,7 @@ const ToolbarSectionMain = styled.div`
const ToolbarSubSectionFirst = styled.div`
display: flex;
align-items: center;
`;
const ToolbarSubSectionLast = styled(ToolbarSubSectionFirst)`
@ -160,6 +162,36 @@ const StatusButton = styled(StyledDropdownButton)`
color: ${colorsRaw.teal};
`;
const PreviewButtonContainer = styled.div`
margin-right: 12px;
color: ${colorsRaw.blue};
display: flex;
align-items: center;
a,
${Icon} {
color: ${colorsRaw.blue};
}
${Icon} {
position: relative;
top: 1px;
}
`;
const RefreshPreviewButton = styled.button`
background: none;
border: 0;
cursor: pointer;
color: ${colorsRaw.blue};
span {
margin-right: 6px;
}
`;
const PreviewLink = RefreshPreviewButton.withComponent('a');
const StatusDropdownItem = styled(DropdownItem)`
${Icon} {
color: ${colors.infoText};
@ -190,9 +222,25 @@ class EditorToolbar extends React.Component {
isModification: PropTypes.bool,
currentStatus: PropTypes.string,
onLogoutClick: PropTypes.func.isRequired,
deployPreview: ImmutablePropTypes.map,
loadDeployPreview: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
};
componentDidMount() {
const { isNewEntry, loadDeployPreview } = this.props;
if (!isNewEntry) {
loadDeployPreview({ maxAttempts: 3 });
}
}
componentDidUpdate(prevProps) {
const { isNewEntry, isPersisting, loadDeployPreview } = this.props;
if (!isNewEntry && prevProps.isPersisting && !isPersisting) {
loadDeployPreview({ maxAttempts: 3 });
}
}
renderSimpleSaveControls = () => {
const { showDelete, onDelete, t } = this.props;
return (
@ -204,6 +252,34 @@ class EditorToolbar extends React.Component {
);
};
renderDeployPreviewControls = label => {
const { deployPreview = Map(), loadDeployPreview, t } = this.props;
const url = deployPreview.get('url');
const status = deployPreview.get('status');
if (!status) {
return;
}
const isFetching = deployPreview.get('isFetching');
const deployPreviewReady = status === 'SUCCESS' && !isFetching;
return (
<PreviewButtonContainer>
{deployPreviewReady ? (
<PreviewLink rel="noopener noreferrer" target="_blank" href={url}>
<span>{label}</span>
<Icon type="new-tab" size="xsmall" />
</PreviewLink>
) : (
<RefreshPreviewButton onClick={loadDeployPreview}>
<span>{t('editor.editorToolbar.deployPreviewPendingButtonLabel')}</span>
<Icon type="refresh" size="xsmall" />
</RefreshPreviewButton>
)}
</PreviewButtonContainer>
);
};
renderSimplePublishControls = () => {
const {
collection,
@ -215,7 +291,12 @@ class EditorToolbar extends React.Component {
t,
} = this.props;
if (!isNewEntry && !hasChanged) {
return <StatusPublished>{t('editor.editorToolbar.published')}</StatusPublished>;
return (
<>
{this.renderDeployPreviewControls(t('editor.editorToolbar.deployButtonLabel'))}
<StatusPublished>{t('editor.editorToolbar.published')}</StatusPublished>
</>
);
}
return (
<div>
@ -302,6 +383,7 @@ class EditorToolbar extends React.Component {
if (currentStatus) {
return (
<>
{this.renderDeployPreviewControls(t('editor.editorToolbar.deployPreviewButtonLabel'))}
<ToolbarDropdown
dropdownTopOverlap="40px"
dropdownWidth="120px"
@ -358,8 +440,16 @@ class EditorToolbar extends React.Component {
);
}
/**
* Publish control for published workflow entry.
*/
if (!isNewEntry) {
return <StatusPublished>{t('editor.editorToolbar.published')}</StatusPublished>;
return (
<>
{this.renderDeployPreviewControls(t('editor.editorToolbar.deployButtonLabel'))}
<StatusPublished>{t('editor.editorToolbar.published')}</StatusPublished>
</>
);
}
};

View File

@ -34,8 +34,10 @@ const getConfigSchema = () => ({
properties: { name: { type: 'string', examples: ['test-repo'] } },
required: ['name'],
},
site_url: { type: 'string', examples: ['https://example.com'] },
display_url: { type: 'string', examples: ['https://example.com'] },
logo_url: { type: 'string', examples: ['https://example.com/images/logo.svg'] },
show_preview_links: { type: 'boolean' },
media_folder: { type: 'string', examples: ['assets/uploads'] },
public_folder: { type: 'string', examples: ['/uploads'] },
media_library: {
@ -86,7 +88,10 @@ const getConfigSchema = () => ({
required: ['name', 'label', 'file', 'fields'],
},
},
identifier_field: { type: 'string' },
slug: { type: 'string' },
preview_path: { type: 'string' },
preview_path_date_field: { type: 'string' },
create: { type: 'boolean' },
editor: {
type: 'object',

View File

@ -78,6 +78,9 @@ export function getPhrases() {
inReview: 'In review',
ready: 'Ready',
publishNow: 'Publish now',
deployPreviewPendingButtonLabel: 'Check for Preview',
deployPreviewButtonLabel: 'View Preview',
deployButtonLabel: 'View Live',
},
editorWidgets: {
unknownControl: {
@ -119,6 +122,7 @@ export function getPhrases() {
},
toast: {
onFailToLoadEntries: 'Failed to load entry: %{details}',
onFailToLoadDeployPreview: 'Failed to load preview: %{details}',
onFailToPersist: 'Failed to persist entry: %{details}',
onFailToDelete: 'Failed to delete entry: %{details}',
onFailToUpdateStatus: 'Failed to update status: %{details}',

View File

@ -27,6 +27,14 @@ export const INFERABLE_FIELDS = {
fallbackToFirstField: false,
showError: false,
},
date: {
type: 'datetime',
secondaryTypes: ['date'],
synonyms: ['date', 'publishDate', 'publish_date'],
defaultPreview: value => value,
fallbackToFirstField: false,
showError: false,
},
description: {
type: 'string',
secondaryTypes: ['text', 'markdown'],

View File

@ -0,0 +1,45 @@
import { Map, fromJS } from 'immutable';
import {
DEPLOY_PREVIEW_REQUEST,
DEPLOY_PREVIEW_SUCCESS,
DEPLOY_PREVIEW_FAILURE,
} from 'Actions/deploys';
const deploys = (state = Map({ deploys: Map() }), action) => {
switch (action.type) {
case DEPLOY_PREVIEW_REQUEST: {
const { collection, slug } = action.payload;
return state.setIn(['deploys', `${collection}.${slug}`, 'isFetching'], true);
}
case DEPLOY_PREVIEW_SUCCESS: {
const { collection, slug, url, status } = action.payload;
return state.setIn(
['deploys', `${collection}.${slug}`],
fromJS({
isFetching: false,
url,
status,
}),
);
}
case DEPLOY_PREVIEW_FAILURE: {
const { collection, slug } = action.payload;
return state.setIn(
['deploys', `${collection}.${slug}`],
fromJS({
isFetching: false,
}),
);
}
default:
return state;
}
};
export const selectDeployPreview = (state, collection, slug) =>
state.getIn(['deploys', `${collection}.${slug}`]);
export default deploys;

View File

@ -9,6 +9,7 @@ import collections from './collections';
import search from './search';
import mediaLibrary from './mediaLibrary';
import medias, * as fromMedias from './medias';
import deploys, * as fromDeploys from './deploys';
import globalUI from './globalUI';
const reducers = {
@ -23,6 +24,7 @@ const reducers = {
entryDraft,
mediaLibrary,
medias,
deploys,
globalUI,
};
@ -47,6 +49,9 @@ export const selectSearchedEntries = state => {
);
};
export const selectDeployPreview = (state, collection, slug) =>
fromDeploys.selectDeployPreview(state.deploys, collection, slug);
export const selectUnpublishedEntry = (state, collection, slug) =>
fromEditorialWorkflow.selectUnpublishedEntry(state.editorialWorkflow, collection, slug);