feat(workflow): add deploy preview links (#2028)
This commit is contained in:
@ -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();
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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.
|
||||
*/
|
||||
|
86
packages/netlify-cms-core/src/actions/deploys.js
Normal file
86
packages/netlify-cms-core/src/actions/deploys.js
Normal 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));
|
||||
}
|
||||
};
|
||||
}
|
@ -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;
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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}',
|
||||
|
@ -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'],
|
||||
|
45
packages/netlify-cms-core/src/reducers/deploys.js
Normal file
45
packages/netlify-cms-core/src/reducers/deploys.js
Normal 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;
|
@ -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);
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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 |
@ -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 |
Reference in New Issue
Block a user