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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 861 additions and 42 deletions

View File

@ -1,7 +1,7 @@
backend:
name: test-repo
display_url: https://example.com
site_url: https://example.com
publish_mode: editorial_workflow
media_folder: assets/uploads

View File

@ -208,6 +208,11 @@ export default class GitGateway {
deleteFile(path, commitMessage, options) {
return this.backend.deleteFile(path, commitMessage, options);
}
getDeployPreview(collection, slug) {
if (this.backend.getDeployPreview) {
return this.backend.getDeployPreview(collection, slug);
}
}
unpublishedEntries() {
return this.backend.unpublishedEntries();
}

View File

@ -280,6 +280,15 @@ export default class API {
});
}
/**
* Retrieve statuses for a given SHA. Unrelated to the editorial workflow
* concept of entry "status". Useful for things like deploy preview links.
*/
async getStatuses(sha) {
const resp = await this.request(`${this.repoURL}/commits/${sha}/status`);
return resp.statuses;
}
composeFileTree(files) {
let filename;
let part;

View File

@ -6,6 +6,34 @@ import API from './API';
const MAX_CONCURRENT_DOWNLOADS = 10;
/**
* Keywords for inferring a status that will provide a deploy preview URL.
*/
const PREVIEW_CONTEXT_KEYWORDS = ['deploy'];
/**
* Check a given status context string to determine if it provides a link to a
* deploy preview. Checks for an exact match against `previewContext` if given,
* otherwise checks for inclusion of a value from `PREVIEW_CONTEXT_KEYWORDS`.
*/
function isPreviewContext(context, previewContext) {
if (previewContext) {
return context === previewContext;
}
return PREVIEW_CONTEXT_KEYWORDS.some(keyword => context.includes(keyword));
}
/**
* Retrieve a deploy preview URL from an array of statuses. By default, a
* matching status is inferred via `isPreviewContext`.
*/
function getPreviewStatus(statuses, config) {
const previewContext = config.getIn(['backend', 'preview_context']);
return statuses.find(({ context }) => {
return isPreviewContext(context, previewContext);
});
}
export default class GitHub {
constructor(config, options = {}) {
this.config = config;
@ -222,6 +250,28 @@ export default class GitHub {
});
}
/**
* Uses GitHub's Statuses API to retrieve statuses, infers which is for a
* deploy preview via `getPreviewStatus`. Returns the url provided by the
* status, as well as the status state, which should be one of 'success',
* 'pending', and 'failure'.
*/
async getDeployPreview(collection, slug) {
const data = await this.api.retrieveMetadata(slug);
if (!data) {
return null;
}
const statuses = await this.api.getStatuses(data.pr.head);
const deployStatus = getPreviewStatus(statuses, this.config);
if (deployStatus) {
const { target_url, state } = deployStatus;
return { url: target_url, status: state };
}
}
updateUnpublishedEntryStatus(collection, slug, newStatus) {
return this.api.updateUnpublishedEntryStatus(collection, slug, newStatus);
}

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);

View File

@ -30,10 +30,12 @@ import iconMedia from './media.svg';
import iconMediaAlt from './media-alt.svg';
import iconNetlify from './netlify.svg';
import iconNetlifyCms from './netlify-cms-logo.svg';
import iconNewTab from './new-tab.svg';
import iconPage from './page.svg';
import iconPages from './pages.svg';
import iconPagesAlt from './pages-alt.svg';
import iconQuote from './quote.svg';
import iconRefresh from './refresh.svg';
import iconScroll from './scroll.svg';
import iconSearch from './search.svg';
import iconSettings from './settings.svg';
@ -76,10 +78,12 @@ const images = {
'media-alt': iconMediaAlt,
netlify: iconNetlify,
'netlify-cms': iconNetlifyCms,
'new-tab': iconNewTab,
page: iconPage,
pages: iconPages,
'pages-alt': iconPagesAlt,
quote: iconQuote,
refresh: iconRefresh,
scroll: iconScroll,
search: iconSearch,
settings: iconSettings,

View File

@ -0,0 +1 @@
<svg width="21" height="21" viewBox="0 0 21 21" xmlns="http://www.w3.org/2000/svg"><g fill="#000" fill-rule="evenodd"><path d="M4.7 20.5H15c2.1 0 3.8-1.7 3.8-3.8v-6.4c0-.5-.4-.9-.8-.9-.5 0-1 .4-1 .9v6.4a2 2 0 0 1-2 2H4.7a2 2 0 0 1-2-2V6.2c0-1.1.8-2 2-2H11c.5 0 .8-.4.8-1 0-.4-.3-.8-.8-.8H4.7C2.6 2.4.9 4.1.9 6.2v10.5c0 2 1.7 3.8 3.8 3.8z"/><path d="M20.9 7.2V1.9v-.2h-.1v-.2l-.1-.1-.2-.2h-.1l-.2-.2H14.5c-.5 0-.8.3-.8.8s.3 1 .8 1H18l-8 8c-.4.3-.4.8 0 1.2.3.3.9.3 1.2 0l8-8v3.2c0 .5.4.9.9.9s.8-.4.9-1z"/></g></svg>

After

Width:  |  Height:  |  Size: 513 B

View File

@ -0,0 +1 @@
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M13.3 1A9.8 9.8 0 0 0 1.1 7a9.4 9.4 0 0 0 6.2 12c5 1.5 10.3-1 12.1-5.8.2-.6-.1-1.3-.7-1.5-.7-.2-1.4.1-1.6.7a7.3 7.3 0 0 1-9 4.3 7 7 0 0 1-4.7-8.9 7.3 7.3 0 0 1 12-2.8L13 7.4c-.5.5-.3.8.3.8h5.5c.7 0 1.2-.5 1.2-1.1V1.8c0-.7-.4-.8-.9-.4l-2 2c-1-1-2.3-1.9-3.8-2.4z" fill="#000" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 393 B

View File

@ -0,0 +1,65 @@
---
title: "Netlify CMS 2.4.0: Deploy Preview Links"
author: Shawn Erquhart
description: >-
Deploy preview links from your GitHub repository are now surfaced in Netlify
CMS for previewing builds of unpublished content.
date: 2019-01-28T19:30:00.000Z
twitter_image: /img/preview-link-unpublished.png
---
![Deploy preview link for unpublished content](/img/preview-link-unpublished.png)
## Seeing is believing
The editorial workflow allows editors to create draft content in Netlify CMS, and Netlify can
provide deploy previews of draft content, but there hasn't been a way to access links to these
preview builds from within Netlify CMS. The preview pane in the editor is a good tool for seeing how
content will look on the site, but in the words of Marvin Gaye, "ain't nothing like the real thing!"
As Mr. Gaye bemoaned the absence of his beloved, so content creators long for the warm embrace of an
actual production build. Their words, not ours.
## Solution: GitHub Statuses
![GitHub statuses](/img/github-statuses-deploy-previews.png)
For sites using the GitHub (or Git Gateway with GitHub) backend, we now have deploy preview links in
the CMS using the [GitHub Statuses
API](https://developer.github.com/v3/repos/statuses/#get-the-combined-status-for-a-specific-ref).
Many static sites already have continuous deployment and deploy previews configured on their repo,
and they often use statuses to provide a link to a deployment directly from a commit or pull
request. To retrieve a commit status that provides a deploy preview URL, we check for a status whose
"context" contains one of a list of keywords commonly associated with deploy previews.
If a status is not found, nothing happens in the UI. If a status is found, but the deploy preview
isn't ready, we provide a "Check for Preview" link, allowing the content editor to manually check
back until the preview is ready:
![Deploy preview link for unpublished content](/img/preview-link-check.png)
When the preview is ready, the "Check for Preview" button is replaced with a link to the content:
![Deploy preview link for unpublished content](/img/preview-link-unpublished.png)
## Deep links
Deploy preview links generally direct to the root of a site, but Netlify CMS can also link straight
to the piece of content being edited. By [providing a string template](/docs/deploy-preview-links)
for each collection, you can get links that go right where editors expect them to. More complex
URL's can be constructed [using date
information](/docs/deploy-preview-links/#dates-in-preview-paths) from your content files.
## Unpublished vs. published
If you're not using the editorial workflow, you may not feel you need this very much. Whenever you
save content, it's immediatlely published, so you can navigate to your live site to see the changes.
That said, it's at least convenient to have a link direct to your content from the CMS, so deploy
preview links can also work for CMS installs that do not use the editorial workflow. Instead of
retrieving a URL from a commit status, this functionality requires setting a `site_url` in your
config, and that URL is used in place of the deploy preview URL.
## GitLab and Bitbucket
Support is coming soon for these two awesome backends! Stay tuned.
## Try it out!
Deploy preview links are live in Netlify CMS 2.4.0. Please give them a try and let us know if you
have any problems by [opening an issue](https://github.com/netlify/netlify-cms/issues/new) or
reaching out in our [community chat on Gitter](https://gitter.im/netlify/netlifycms)!

View File

@ -6,6 +6,9 @@ group: start
Netlify CMS stores content in your GitHub, GitLab, or Bitbucket repository. In order for this to work, authenticate with your Git host. In most cases that requires a server. We have a few options for handling this.
**Note:** If you prefer to run your own authentication server, check out the section on [external OAuth clients](#external-oauth-clients).
**Note:** Some static site generators have plugins for optimized integration with Netlify CMS, and starter templates may utilize these plugins. If you're using a starter template, read the template documentation before proceeding, as their instructions may differ.
## Git Gateway with Netlify Identity
@ -54,7 +57,20 @@ To enable basic GitHub authentication:
repo: owner-name/repo-name # Path to your GitHub repository
```
If you prefer to run your own authentication server, check out the section on [external OAuth clients](#external-oauth-clients).
### Specifying a status for deploy previews
The GitHub backend supports [deploy preview links](../deploy-preview-links). Netlify CMS checks the
`context` of a commit's [statuses](https://help.github.com/articles/about-status-checks/) and infers
one that seems to represent a deploy preview. If you need to customize this behavior, you can
specify which context to look for using `preview_context`:
```yaml
backend:
name: github
repo: my/repo
preview_context: my-provider/deployment
```
The above configuration would look for the status who's `"context"` is `"my-provider/deployment"`.
## GitLab Backend

View File

@ -1,7 +1,7 @@
---
title: Cloudinary
group: media
weight: '10'
weight: 10
---
Cloudinary is a digital asset management platform with a broad feature set, including support for responsive image generation and url based image transformation. They also provide a powerful media library UI for managing assets, and tools for organizing your assets into a hierarchy.

View File

@ -85,10 +85,24 @@ media_library:
publicKey: demopublickey
```
## Site URL
The `site_url` setting should provide a URL to your published site. May be used by the CMS for
various functionality. Used together with a collection's `preview_path` to create links to live
content.
**Example:**
```yaml
display_url: https://your-site.com
```
## Display URL
When the `display_url` setting is specified, the CMS UI will include a link in the fixed area at the top of the page, allowing content authors to easily return to your main site. The text of the link consists of the URL less the protocol portion (e.g., `your-site.com`).
Defaults to `site_url`.
**Example:**
```yaml
@ -105,6 +119,16 @@ When the `logo_url` setting is specified, the CMS UI will change the logo displa
logo_url: https://your-site.com/images/logo.svg
```
## Show Preview Links
[Deploy preview links](../deploy-preview-links) can be disabled by setting `show_preview_links` to `false`.
**Example:**
```yaml
show_preview_links: false
```
## Slug Type
The `slug` option allows you to change how filenames for entries are created and sanitized. For modifying the actual data in a slug, see the per-collection option below.
@ -135,6 +159,7 @@ The `collections` setting is the heart of your Netlify CMS configuration, as it
`collections` accepts a list of collection objects, each with the following options:
- `name` (required): unique identifier for the collection, used as the key when referenced in other contexts (like the [relation widget](../widgets/#relation))
- `identifier_field`: see detailed description below
- `label`: label for the collection in the editor UI; defaults to the value of `name`
- `label_singular`: singular label for certain elements in the editor; defaults to the value of `label`
- `description`: optional text, displayed below the label when viewing a collection
@ -146,11 +171,27 @@ The `collections` setting is the heart of your Netlify CMS configuration, as it
- `format`: see detailed description below
- `frontmatter_delimiter`: see detailed description under `format`
- `slug`: see detailed description below
- `preview_path`: see detailed description below
- `fields` (required): see detailed description below
- `editor`: see detailed description below
The last few options require more detailed information.
### `identifier_field`
Netlify CMS expects every entry to provide a field named `"title"` that serves as an identifier for
the entry. The identifier field serves as an entry's title when viewing a list of entries, and is
used in [slug](#slug) creation. If you would like to use a field other than `"title"` as the
identifier, you can set `identifier_field` to the name of the other field.
**Example**
``` yaml
collections:
- name: posts
identifier_field: name
```
### `extension` and `format`
These settings determine how collection files are parsed and saved. Both are optional—Netlify CMS will attempt to infer your settings based on existing items in the collection. If your collection is empty, or you'd like more control, you can set these fields explicitly.
@ -174,11 +215,11 @@ You may also specify a custom `extension` not included in the list above, as lon
### `slug`
For folder collections where users can create new items, the `slug` option specifies a template for generating new filenames based on a file's creation date and `title` field. (This means that all collections with `create: true` must have a `title` field.)
For folder collections where users can create new items, the `slug` option specifies a template for generating new filenames based on a file's creation date and `title` field. (This means that all collections with `create: true` must have a `title` field (a different field can be used via [`identifier_field`](#identifier_field)).
**Available template tags:**
- `{{slug}}`: a url-safe version of the `title` field for the file
- `{{slug}}`: a url-safe version of the `title` field (or identifier field) for the file
- `{{year}}`: 4-digit year of the file creation date
- `{{month}}`: 2-digit month of the file creation date
- `{{day}}`: 2-digit day of the month of the file creation date
@ -192,6 +233,48 @@ For folder collections where users can create new items, the `slug` option speci
slug: "{{year}}-{{month}}-{{day}}_{{slug}}"
```
### `preview_path`
A string representing the path where content in this collection can be found on the live site. This
allows deploy preview links to direct to lead to a specific piece of content rather than the site
root of a deploy preview.
**Available template tags:**
- Any field can be referenced by wrapping the field name in double curly braces, eg. `{{author}}`
- `{{slug}}`: the entire slug for the current entry (not just the url-safe identifier, as is the
case with [`slug` configuration](#slug)
The following date based template tags are pulled from a date field in your entry, and may require additional configuration, see [`preview_path_date_field`](#preview_path_date_field) for details. If a date template tag is used and no date can be found, `preview_path` will be ignored.
- `{{year}}`: 4-digit year from entry data
- `{{month}}`: 2-digit month from entry data
- `{{day}}`: 2-digit day of the month from entry data
- `{{hour}}`: 2-digit hour from entry data
- `{{minute}}`: 2-digit minute from entry data
- `{{second}}`: 2-digit second from entry data
**Example:**
```yaml
collections:
- name: posts
preview_path: "blog/{{year}}/{{month}}/{{slug}}"
```
### `preview_path_date_field`
The name of a date field for parsing date-based template tags from `preview_path`. If this field is
not provided and `preview_path` contains date-based template tags (eg. `{{year}}`), Netlify CMS will
attempt to infer a usable date field by checking for common date field names, such as `date`. If you
find that you need to specify a date field, you can use `preview_path_date_field` to tell Netlify
CMS which field to use for preview path template tags.
**Example:**
```yaml
collections:
- name: posts
preview_path_date_field: "updated_on"
```
### `fields`
The `fields` option maps editor UI widgets to field-value pairs in the saved file. The order of the fields in your Netlify CMS `config.yml` file determines their order in the editor UI and in the saved file.

View File

@ -0,0 +1,139 @@
---
title: Deploy Preview Links
group: features
weight: 10
---
When using the editorial workflow, content editors can create and save content without publishing it
to a live site. Deploy preview links provide a way to view live content when it has not been
published, provided that you're using a continuous deployment platform to provide "deploy previews"
of your unmerged content.
**Note:** for the initial release, only the GitHub and Git Gateway (with GitHub) backends will
support deploy preview links. Others should follow shortly.
## Using deploy preview links
Deploy preview links will work without configuration when all of the following requirements are met:
- Netlify CMS version is 2.4.0+
- Using the GitHub backend (or Git Gateway with a GitHub repository)
- Using editorial workflow
- Have a continous deployment platform that builds every commit and provides statuses to your repo
Any site created using one of the Deploy to Netlify options on our [starters
page](../start-with-a-template) will automatically meet these criteria (barring any changes made to
your Netlify settings), but you may need to [update](../update-the-cms-version) your Netlify CMS version to get the
functionality.
**Note:** If you're using a custom backend (one that is not included with Netlify CMS), please check the
documentation for that backend for more information about enabling deploy preview links.
Deploy preview links are provided in the editor toolbar, near the publishing controls:
![Deploy preview link for unpublished content](/img/preview-link-unpublished.png)
### Waiting for builds
Deploy your site preview may take ten seconds or ten minutes, depending on many factors. For maximum
flexibility, Netlify CMS provides a "Check for Preview" refresh button when the deploy preview is
pending, which a content editor can use to manually check for a finished preview until it's ready:
![Deploy preview link for unpublished content](/img/preview-link-check.png)
## Configuring preview paths
Deploy preview links point to the site root by default, but you'll probably want them to point to
the specific piece of content that the content editor is viewing. You can do this by providing a
`preview_path` string template for each collection.
Let's say we have a `blog` collection that stores content in our repo under `content/blog`. The path
to a post in your repo may look like `content/blog/2018-01-new-post.md`, but the path to that post
on your site would look more like: `/blog/2018-01-new-post/`. Here's how you would use
`preview_path` in your configuration for this scenario:
```yml
collections:
- name: blog
folder: content/blog
slug: {{year}}-{{month}}-{{slug}}
preview_path: blog/{{slug}}
```
With the above configuration, the deploy preview URL from your backend will be combined with your
preview path to create a URL to a specific blog post.
**Note:** `{{slug}}` in `preview_path` is different than `{{slug}}` in `slug`. In the `slug`
template, `{{slug}}` is only the url-safe [identifier
field](../configuration-options/#identifier_field), while in the `preview_path` template, `{{slug}}`
is the entire slug for the entry. For example:
```yml
# for an entry created Jan 1, 2000 with identifier "My New Post!"
collections:
- name: posts
slug: {{year}}-{{month}}-{{slug}} # {{slug}} will compile to "my-new-post"
preview_path: blog/{{slug}} # {{slug}} will compile to "2000-01-my-new-post"
```
### Dates in preview paths
Some static site generators allow URL's to be customized with date parameters - for example, Hugo
can be configured to use values like `year` and `month` in a URL. These values are generally derived
by the static site generator from a date field in the content file. `preview_path` accepts these
parameters as well, similar to the `slug` configuration, except `preview_path` populates date values
based on a date value from the entry, just like static site generators do. Netlify CMS will attempt
to infer an obvious date field, but you can also specify which date field to use for `preview_path`
template tags by using
[`preview_path_date_field`](../configuration-options/#preview_path_date_field).
Together with your other field values, dates can be used to configure most URL schemes available
through static site generators.
**Example**
```yaml
# This collection's date field will be inferred because it has a field named `"date"`
collections:
- name: posts
preview_path: blog/{{year}}/{{month}}/{{title}}
fields:
- { name: title, label: Title }
{ name: date, label: Date, widget: date }
{ name: body, label: Body, widget: markdown }
# This collection requires `path_preview_date_field` because the no obvious date field is available
collections:
- name: posts
preview_path: blog/{{year}}/{{month}}/{{title}}
preview_path_date_field: published_at
fields:
- { name: title, label: Title }
{ name: published_at, label: Published At, widget: date }
{ name: body, label: Body, widget: markdown }
```
## Preview links for published content
You may also want preview links for published content as a convenience. You can do this by providing
a `site_url` in your configuration, which will be used in place of the deploy preview URL that a
backend would provide for an unpublished entry. Just as for deploy preview links to unpublished
content, inks to published content will use any `preview_path` values that are defined in the
collection configurations.
Preview links for published content will also work if you are not using the editorial workflow.
![Deploy preview link for unpublished content](/img/preview-link-unpublished.png)
## Disabling deploy preview links
To disable deploy preview links, set `show_preview_links` to false in your CMS configuration.
## How it works
Deploy preview links are provided through your CMS backend, and Netlify CMS is unopinionated about
where the links come from or how they're created. That said, the general approach for Git backends
like GitHub is powered by "commit statuses". Continuous deployment platforms like Netlify can deploy
a version of your site for every commit that is pushed to your remote Git repository, and then send
a commit status back to your repository host with the URL.
The deploy preview URL provided by a backend will lead to the root of the deployed site. Netlify CMS
will then use the `preview_path` template in an entry's collection configuration to build a path to
a specific piece of content. If a `preview_path` is not provided for an entry's collection, the URL
will be used as is.

View File

@ -11,6 +11,10 @@ module.exports = {
name: 'start',
title: 'Quick Start',
},
{
name: 'features',
title: 'Features',
},
{
name: 'reference',
title: 'Reference',