feat(workflow): add deploy preview links (#2028)
This commit is contained in:
@ -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);
|
||||
|
||||
|
Reference in New Issue
Block a user