diff --git a/dev-test/config.yml b/dev-test/config.yml
index e20a7e02f47e..913682a831ed 100644
--- a/dev-test/config.yml
+++ b/dev-test/config.yml
@@ -39,9 +39,11 @@ collections: # A list of collections the CMS should be able to edit
- name: 'faq' # Used in routes, ie.: /admin/collections/:slug/edit
label: 'FAQ' # Used in the UI
folder: '_faqs'
+ slug_field: 'slug'
create: true # Allow users to create new documents in this collection
fields: # The fields each document in this collection have
- { label: 'Question', name: 'title', widget: 'string', tagname: 'h1' }
+ - { label: 'Slug', name: 'slug', widget: 'string', tagname: 'h1' }
- { label: 'Answer', name: 'body', widget: 'markdown' }
- name: 'settings'
diff --git a/packages/netlify-cms-backend-github/src/API.js b/packages/netlify-cms-backend-github/src/API.js
index e5bf90252f2c..44941b92a513 100644
--- a/packages/netlify-cms-backend-github/src/API.js
+++ b/packages/netlify-cms-backend-github/src/API.js
@@ -372,7 +372,8 @@ export default class API {
const contentKey = entry.slug;
const branchName = this.generateBranchName(contentKey);
const unpublished = options.unpublished || false;
- if (!unpublished) {
+ const slugChanged = entry.slugChanged;
+ if (!unpublished || slugChanged) {
// Open new editorial review workflow for this entry - Create new metadata and commit to new branch`
let prResponse;
@@ -385,7 +386,18 @@ export default class API {
prResponse = pr;
return this.user();
})
- .then(user => {
+ .then(async user => {
+ // Keep track of the entry initial parent slug
+ let parentSlug = entry.oldSlug || entry.slug;
+ let parentPath = entry.oldPath || entry.path;
+ if (slugChanged) {
+ await this.retrieveMetadata(entry.oldSlug).then(metaData => {
+ if (metaData) {
+ parentSlug = metaData.parentSlug;
+ parentPath = metaData.parentPath;
+ }
+ });
+ }
return this.storeMetadata(contentKey, {
type: 'PR',
pr: {
@@ -398,6 +410,8 @@ export default class API {
collection: options.collectionName,
title: options.parsedData && options.parsedData.title,
description: options.parsedData && options.parsedData.description,
+ parentSlug,
+ parentPath,
objects: {
entry: {
path: entry.path,
diff --git a/packages/netlify-cms-backend-gitlab/src/API.js b/packages/netlify-cms-backend-gitlab/src/API.js
index afd5cacd4441..159bdd9ea1a5 100644
--- a/packages/netlify-cms-backend-gitlab/src/API.js
+++ b/packages/netlify-cms-backend-gitlab/src/API.js
@@ -264,7 +264,10 @@ export default class API {
persistFiles = (files, { commitMessage, newEntry }) =>
Promise.all(
files.map(file =>
- this.uploadAndCommit(file, { commitMessage, updateFile: newEntry === false }),
+ this.uploadAndCommit(file, {
+ commitMessage,
+ updateFile: newEntry === false && file.slugChanged === false,
+ }),
),
);
diff --git a/packages/netlify-cms-backend-test/src/implementation.js b/packages/netlify-cms-backend-test/src/implementation.js
index 0e54a383ecb2..7e1d077ea777 100644
--- a/packages/netlify-cms-backend-test/src/implementation.js
+++ b/packages/netlify-cms-backend-test/src/implementation.js
@@ -141,18 +141,24 @@ export default class TestRepo {
const existingEntryIndex = unpubStore.findIndex(
e => e.metaData.collection === collection && e.slug === slug,
);
- unpubStore.splice(existingEntryIndex, 1);
+ if (existingEntryIndex >= 0) {
+ unpubStore.splice(existingEntryIndex, 1);
+ }
return Promise.resolve();
}
- persistEntry({ path, raw, slug }, mediaFiles, options = {}) {
+ persistEntry({ path, raw, slug, oldPath, oldSlug }, mediaFiles, options = {}) {
if (options.useWorkflow) {
const unpubStore = window.repoFilesUnpublished;
- const existingEntryIndex = unpubStore.findIndex(e => e.file.path === path);
+ const existingPath = oldPath || path;
+ const existingEntryIndex = unpubStore.findIndex(e => e.file.path === existingPath);
if (existingEntryIndex >= 0) {
const unpubEntry = { ...unpubStore[existingEntryIndex], data: raw };
- unpubEntry.title = options.parsedData && options.parsedData.title;
- unpubEntry.description = options.parsedData && options.parsedData.description;
+ if (oldPath) {
+ Object.assign(unpubEntry, { file: { path }, slug });
+ }
+ unpubEntry.metaData.title = options.parsedData && options.parsedData.title;
+ unpubEntry.metaData.description = options.parsedData && options.parsedData.description;
unpubStore.splice(existingEntryIndex, 1, unpubEntry);
} else {
const unpubEntry = {
@@ -165,6 +171,8 @@ export default class TestRepo {
status: this.options.initialWorkflowStatus,
title: options.parsedData && options.parsedData.title,
description: options.parsedData && options.parsedData.description,
+ parentSlug: oldSlug || slug,
+ parentPath: oldPath || path,
},
slug,
};
diff --git a/packages/netlify-cms-core/src/actions/editorialWorkflow.js b/packages/netlify-cms-core/src/actions/editorialWorkflow.js
index 8f264c62d4a6..96fe49817994 100644
--- a/packages/netlify-cms-core/src/actions/editorialWorkflow.js
+++ b/packages/netlify-cms-core/src/actions/editorialWorkflow.js
@@ -3,11 +3,11 @@ import { actions as notifActions } from 'redux-notifications';
import { BEGIN, COMMIT, REVERT } from 'redux-optimist';
import { serializeValues } from 'Lib/serializeEntryValues';
import { currentBackend } from 'src/backend';
-import { getAsset } from 'Reducers';
-import { selectFields } from 'Reducers/collections';
+import { getAsset, selectSlugs, selectUnpublishedEntry } from 'Reducers';
+import { selectFields, selectSlugField } from 'Reducers/collections';
import { EDITORIAL_WORKFLOW } from 'Constants/publishModes';
import { EDITORIAL_WORKFLOW_ERROR } from 'netlify-cms-lib-util';
-import { loadEntry } from './entries';
+import { loadEntry, deleteEntry } from './entries';
import ValidationErrorTypes from 'Constants/validationErrorTypes';
const { notifSend } = notifActions;
@@ -97,12 +97,13 @@ function unpublishedEntriesFailed(error) {
};
}
-function unpublishedEntryPersisting(collection, entry, transactionID) {
+function unpublishedEntryPersisting(collection, entry, slug, transactionID) {
return {
type: UNPUBLISHED_ENTRY_PERSIST_REQUEST,
payload: {
collection: collection.get('name'),
entry,
+ slug,
},
optimist: { type: BEGIN, id: transactionID },
};
@@ -261,6 +262,7 @@ export function loadUnpublishedEntries(collections) {
return (dispatch, getState) => {
const state = getState();
if (state.config.get('publish_mode') !== EDITORIAL_WORKFLOW) return;
+ if (state.editorialWorkflow.get('isFetched')) return;
const backend = currentBackend(state.config);
dispatch(unpublishedEntriesLoading());
backend
@@ -288,6 +290,7 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
const state = getState();
const entryDraft = state.entryDraft;
const fieldsErrors = entryDraft.get('fieldsErrors');
+ const unavailableSlugs = selectSlugs(state, collection.get('name'));
// Early return if draft contains validation errors
if (!fieldsErrors.isEmpty()) {
@@ -319,11 +322,16 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
* update the entry and entryDraft with the serialized values.
*/
const fields = selectFields(collection, entry.get('slug'));
+ const slugField = selectSlugField(collection);
const serializedData = serializeValues(entryDraft.getIn(['entry', 'data']), fields);
const serializedEntry = entry.set('data', serializedData);
const serializedEntryDraft = entryDraft.set('entry', serializedEntry);
+ const manualSlug = serializedData.get(slugField);
+ const entrySlug = serializedEntry.get('slug');
+ const slugChanged = manualSlug && entrySlug && manualSlug !== entrySlug;
+ const selectedSlug = manualSlug || entrySlug;
- dispatch(unpublishedEntryPersisting(collection, serializedEntry, transactionID));
+ dispatch(unpublishedEntryPersisting(collection, serializedEntry, selectedSlug, transactionID));
const persistAction = existingUnpublishedEntry
? backend.persistUnpublishedEntry
: backend.persistEntry;
@@ -334,6 +342,8 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
serializedEntryDraft,
assetProxies.toJS(),
state.integrations,
+ selectedSlug,
+ unavailableSlugs,
];
try {
@@ -348,6 +358,10 @@ export function persistUnpublishedEntry(collection, existingUnpublishedEntry) {
}),
);
dispatch(unpublishedEntryPersisted(collection, serializedEntry, transactionID, newSlug));
+ if (slugChanged && selectUnpublishedEntry(state, collection.get('name'), entrySlug)) {
+ // Delete previous entry
+ dispatch(deleteUnpublishedEntry(collection.get('name'), entrySlug));
+ }
} catch (error) {
dispatch(
notifSend({
@@ -448,6 +462,10 @@ export function publishUnpublishedEntry(collection, slug) {
const collections = state.collections;
const backend = currentBackend(state.config);
const transactionID = uuid();
+ const unpublishedEntry = selectUnpublishedEntry(state, collection, slug);
+ const parentSlug = unpublishedEntry.getIn(['metaData', 'parentSlug']);
+ const slugChanged = parentSlug && parentSlug !== slug;
+
dispatch(unpublishedEntryPublishRequest(collection, slug, transactionID));
return backend
.publishUnpublishedEntry(collection, slug)
@@ -461,6 +479,10 @@ export function publishUnpublishedEntry(collection, slug) {
);
dispatch(unpublishedEntryPublished(collection, slug, transactionID));
dispatch(loadEntry(collections.get(collection), slug));
+ if (slugChanged) {
+ // Delete parent entry
+ dispatch(deleteEntry(state.collections.get(collection), parentSlug));
+ }
})
.catch(error => {
dispatch(
diff --git a/packages/netlify-cms-core/src/actions/entries.js b/packages/netlify-cms-core/src/actions/entries.js
index 86d12ea1b6f9..9136b91a6113 100644
--- a/packages/netlify-cms-core/src/actions/entries.js
+++ b/packages/netlify-cms-core/src/actions/entries.js
@@ -3,8 +3,8 @@ import { actions as notifActions } from 'redux-notifications';
import { serializeValues } from 'Lib/serializeEntryValues';
import { currentBackend } from 'src/backend';
import { getIntegrationProvider } from 'Integrations';
-import { getAsset, selectIntegration } from 'Reducers';
-import { selectFields } from 'Reducers/collections';
+import { getAsset, selectSlugEntries, selectIntegration } from 'Reducers';
+import { selectFields, selectSlugField } from 'Reducers/collections';
import { selectCollectionEntriesCursor } from 'Reducers/cursors';
import { Cursor } from 'netlify-cms-lib-util';
import { createEntry } from 'ValueObjects/Entry';
@@ -121,7 +121,6 @@ export function entryPersisted(collection, entry, slug) {
payload: {
collectionName: collection.get('name'),
entrySlug: entry.get('slug'),
-
/**
* Pass slug from backend for newly created entries.
*/
@@ -202,10 +201,10 @@ export function changeDraft(entry) {
};
}
-export function changeDraftField(field, value, metadata) {
+export function changeDraftField(field, value, metadata, hasChanged = true) {
return {
type: DRAFT_CHANGE_FIELD,
- payload: { field, value, metadata },
+ payload: { field, value, metadata, hasChanged },
};
}
@@ -410,6 +409,7 @@ export function persistEntry(collection) {
const state = getState();
const entryDraft = state.entryDraft;
const fieldsErrors = entryDraft.get('fieldsErrors');
+ const unavailableSlugs = selectSlugEntries(state, collection.get('name'));
// Early return if draft contains validation errors
if (!fieldsErrors.isEmpty()) {
@@ -441,12 +441,26 @@ export function persistEntry(collection) {
* update the entry and entryDraft with the serialized values.
*/
const fields = selectFields(collection, entry.get('slug'));
+ const slugField = selectSlugField(collection);
const serializedData = serializeValues(entryDraft.getIn(['entry', 'data']), fields);
const serializedEntry = entry.set('data', serializedData);
const serializedEntryDraft = entryDraft.set('entry', serializedEntry);
+ const manualSlug = slugField && serializedData.get(slugField);
+ const entrySlug = serializedEntry.get('slug');
+ const selectedSlug = manualSlug || entrySlug;
+ const slugChanged = manualSlug && entrySlug && manualSlug !== entrySlug;
+
dispatch(entryPersisting(collection, serializedEntry));
return backend
- .persistEntry(state.config, collection, serializedEntryDraft, assetProxies.toJS())
+ .persistEntry(
+ state.config,
+ collection,
+ serializedEntryDraft,
+ assetProxies.toJS(),
+ state.integrations,
+ selectedSlug,
+ unavailableSlugs,
+ )
.then(slug => {
dispatch(
notifSend({
@@ -458,6 +472,10 @@ export function persistEntry(collection) {
}),
);
dispatch(entryPersisted(collection, serializedEntry, slug));
+ if (slugChanged) {
+ // Delete previous entry
+ dispatch(deleteEntry(collection, entrySlug));
+ }
})
.catch(error => {
console.error(error);
diff --git a/packages/netlify-cms-core/src/backend.js b/packages/netlify-cms-core/src/backend.js
index e3ca4fafd0c2..3a6d9798242f 100644
--- a/packages/netlify-cms-core/src/backend.js
+++ b/packages/netlify-cms-core/src/backend.js
@@ -13,12 +13,13 @@ import {
selectAllowDeletion,
selectFolderEntryExtension,
selectIdentifier,
+ selectSlugField,
selectInferedField,
} from 'Reducers/collections';
import { createEntry } from 'ValueObjects/Entry';
import { sanitizeSlug } from 'Lib/urlHelper';
import { getBackend } from 'Lib/registry';
-import { Cursor, CURSOR_COMPATIBILITY_SYMBOL } from 'netlify-cms-lib-util';
+import { Cursor, EditorialWorkflowError, CURSOR_COMPATIBILITY_SYMBOL } from 'netlify-cms-lib-util';
import { EDITORIAL_WORKFLOW, status } from 'Constants/publishModes';
class LocalStorageAuthStore {
@@ -301,6 +302,56 @@ class Backend {
getToken = () => this.implementation.getToken();
+ async entryExist(collection, path, slug) {
+ const unpublishedEntry =
+ this.implementation.unpublishedEntry &&
+ (await this.implementation.unpublishedEntry(collection, slug).catch(error => {
+ if (error instanceof EditorialWorkflowError && error.notUnderEditorialWorkflow) {
+ return Promise.resolve(false);
+ }
+ return Promise.reject(error);
+ }));
+
+ if (unpublishedEntry) return unpublishedEntry;
+
+ const publishedEntry = await this.implementation
+ .getEntry(collection, slug, path)
+ .then(({ data }) => data)
+ .catch(error => {
+ if (error.status === 404 || error.message.includes(404)) {
+ return Promise.resolve(false);
+ }
+ return Promise.reject(error);
+ });
+
+ return publishedEntry;
+ }
+
+ async getSlug(collection, entryData, slugConfig, unavailableSlugs) {
+ const slug = slugFormatter(collection, entryData, slugConfig);
+
+ return await this.generateUniqueSlug(collection, slug, slugConfig, unavailableSlugs);
+ }
+
+ async generateUniqueSlug(collection, slug, slugConfig, unavailableSlugs, availableSlugs = []) {
+ const sanitizeEntrySlug = flow([prepareSlug, partialRight(sanitizeSlug, slugConfig)]);
+ let i = 1;
+ let sanitizedSlug = sanitizeEntrySlug(slug);
+ let uniqueSlug = sanitizedSlug;
+
+ // Return if slug is the same as the current entry or parent slug.
+ if (availableSlugs && availableSlugs.includes(uniqueSlug)) return uniqueSlug;
+
+ // Check for duplicate slug in loaded entities store first before repo
+ while (
+ unavailableSlugs.includes(uniqueSlug) ||
+ (await this.entryExist(collection, selectEntryPath(collection, uniqueSlug), uniqueSlug))
+ ) {
+ uniqueSlug = sanitizeEntrySlug(`${sanitizedSlug} ${i++}`);
+ }
+ return uniqueSlug;
+ }
+
processEntries(loadedEntries, collection) {
const collectionFilter = collection.get('filter');
const entries = loadedEntries.map(loadedEntry =>
@@ -560,8 +611,18 @@ class Backend {
};
}
- persistEntry(config, collection, entryDraft, MediaFiles, integrations, options = {}) {
+ async persistEntry(
+ config,
+ collection,
+ entryDraft,
+ MediaFiles,
+ integrations,
+ selectedSlug,
+ unavailableSlugs,
+ options = {},
+ ) {
const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false;
+ const slugField = selectSlugField(collection);
const parsedData = {
title: entryDraft.getIn(['entry', 'data', 'title'], 'No Title'),
@@ -573,25 +634,35 @@ class Backend {
if (!selectAllowNewEntries(collection)) {
throw new Error('Not allowed to create new entries in this collection');
}
- const slug = slugFormatter(
- collection,
- entryDraft.getIn(['entry', 'data']),
- config.get('slug'),
- );
+
+ const slug =
+ selectedSlug ||
+ (await this.getSlug(
+ collection,
+ entryDraft.getIn(['entry', 'data']),
+ config.get('slug'),
+ unavailableSlugs,
+ ));
const path = selectEntryPath(collection, slug);
entryObj = {
path,
slug,
- raw: this.entryToRaw(collection, entryDraft.get('entry')),
+ slugChanged: false,
+ raw: this.entryToRaw(collection, entryDraft.get('entry'), slugField),
};
} else {
const path = entryDraft.getIn(['entry', 'path']);
const slug = entryDraft.getIn(['entry', 'slug']);
+ const selectedPath = selectEntryPath(collection, selectedSlug);
entryObj = {
- path,
- slug,
- raw: this.entryToRaw(collection, entryDraft.get('entry')),
+ path: selectedPath,
+ slug: selectedSlug,
+ slugChanged: false,
+ raw: this.entryToRaw(collection, entryDraft.get('entry'), slugField),
};
+ if (selectedSlug !== slug) {
+ Object.assign(entryObj, { slugChanged: true, oldSlug: slug, oldPath: path });
+ }
}
const commitMessage = commitMessageFormatter(newEntry ? 'create' : 'update', config, {
@@ -660,10 +731,12 @@ class Backend {
return this.implementation.deleteUnpublishedEntry(collection, slug);
}
- entryToRaw(collection, entry) {
+ entryToRaw(collection, entry, slugField) {
const format = resolveFormat(collection, entry.toJS());
const fieldsOrder = this.fieldsOrder(collection, entry);
- return format && format.toFile(entry.get('data').toJS(), fieldsOrder);
+ // Remove slug field and value
+ const data = entry.get('data').delete(slugField);
+ return format && format.toFile(data.toJS(), fieldsOrder);
}
fieldsOrder(collection, entry) {
diff --git a/packages/netlify-cms-core/src/components/Collection/Collection.js b/packages/netlify-cms-core/src/components/Collection/Collection.js
index 711ae21d973d..468ac2f1dfa3 100644
--- a/packages/netlify-cms-core/src/components/Collection/Collection.js
+++ b/packages/netlify-cms-core/src/components/Collection/Collection.js
@@ -10,6 +10,7 @@ import CollectionTop from './CollectionTop';
import EntriesCollection from './Entries/EntriesCollection';
import EntriesSearch from './Entries/EntriesSearch';
import { VIEW_STYLE_LIST } from 'Constants/collectionViews';
+import { selectUnpublishedEntryByParentSlug } from 'Reducers';
const CollectionContainer = styled.div`
margin: ${lengths.pageMargin};
@@ -33,13 +34,25 @@ class Collection extends React.Component {
};
renderEntriesCollection = () => {
- const { collection } = this.props;
- return ;
+ const { collection, unpublishedChildEntry } = this.props;
+ return (
+
+ );
};
renderEntriesSearch = () => {
- const { searchTerm, collections } = this.props;
- return ;
+ const { searchTerm, collections, unpublishedChildEntry } = this.props;
+ return (
+
+ );
};
handleChangeViewStyle = viewStyle => {
@@ -77,7 +90,15 @@ function mapStateToProps(state, ownProps) {
const { isSearchResults, match } = ownProps;
const { name, searchTerm } = match.params;
const collection = name ? collections.get(name) : collections.first();
- return { collection, collections, collectionName: name, isSearchResults, searchTerm };
+ const unpublishedChildEntry = selectUnpublishedEntryByParentSlug.bind(null, state);
+ return {
+ collection,
+ collections,
+ collectionName: name,
+ isSearchResults,
+ searchTerm,
+ unpublishedChildEntry,
+ };
}
export default connect(mapStateToProps)(Collection);
diff --git a/packages/netlify-cms-core/src/components/Collection/Entries/Entries.js b/packages/netlify-cms-core/src/components/Collection/Entries/Entries.js
index ece43f6735b5..b040101a3b63 100644
--- a/packages/netlify-cms-core/src/components/Collection/Entries/Entries.js
+++ b/packages/netlify-cms-core/src/components/Collection/Entries/Entries.js
@@ -12,6 +12,7 @@ const Entries = ({
isFetching,
viewStyle,
cursor,
+ unpublishedChildEntry,
handleCursorActions,
t,
}) => {
@@ -29,6 +30,7 @@ const Entries = ({
publicFolder={publicFolder}
viewStyle={viewStyle}
cursor={cursor}
+ unpublishedChildEntry={unpublishedChildEntry}
handleCursorActions={handleCursorActions}
/>
);
diff --git a/packages/netlify-cms-core/src/components/Collection/Entries/EntriesCollection.js b/packages/netlify-cms-core/src/components/Collection/Entries/EntriesCollection.js
index 277dda4224be..c8ab38a4eb6a 100644
--- a/packages/netlify-cms-core/src/components/Collection/Entries/EntriesCollection.js
+++ b/packages/netlify-cms-core/src/components/Collection/Entries/EntriesCollection.js
@@ -8,9 +8,11 @@ import {
loadEntries as actionLoadEntries,
traverseCollectionCursor as actionTraverseCollectionCursor,
} from 'Actions/entries';
+import { loadUnpublishedEntries as actionLoadUnpublishedEntries } from 'Actions/editorialWorkflow';
import { selectEntries } from 'Reducers';
import { selectCollectionEntriesCursor } from 'Reducers/cursors';
import Entries from './Entries';
+import { EDITORIAL_WORKFLOW } from 'Constants/publishModes';
class EntriesCollection extends React.Component {
static propTypes = {
@@ -25,10 +27,22 @@ class EntriesCollection extends React.Component {
};
componentDidMount() {
- const { collection, entriesLoaded, loadEntries } = this.props;
+ const {
+ collection,
+ entriesLoaded,
+ loadEntries,
+ collections,
+ isEditorialWorkflow,
+ loadUnpublishedEntries,
+ } = this.props;
+
if (collection && !entriesLoaded) {
loadEntries(collection);
}
+
+ if (isEditorialWorkflow) {
+ loadUnpublishedEntries(collections);
+ }
}
componentDidUpdate(prevProps) {
@@ -44,7 +58,15 @@ class EntriesCollection extends React.Component {
};
render() {
- const { collection, entries, publicFolder, isFetching, viewStyle, cursor } = this.props;
+ const {
+ collection,
+ entries,
+ publicFolder,
+ isFetching,
+ viewStyle,
+ cursor,
+ unpublishedChildEntry,
+ } = this.props;
return (
);
@@ -63,7 +86,7 @@ class EntriesCollection extends React.Component {
function mapStateToProps(state, ownProps) {
const { collection, viewStyle } = ownProps;
- const { config } = state;
+ const { config, collections } = state;
const publicFolder = config.get('public_folder');
const page = state.entries.getIn(['pages', collection.get('name'), 'page']);
@@ -73,13 +96,26 @@ function mapStateToProps(state, ownProps) {
const rawCursor = selectCollectionEntriesCursor(state.cursors, collection.get('name'));
const cursor = Cursor.create(rawCursor).clearData();
+ const isEditorialWorkflow = state.config.get('publish_mode') === EDITORIAL_WORKFLOW;
- return { publicFolder, collection, page, entries, entriesLoaded, isFetching, viewStyle, cursor };
+ return {
+ publicFolder,
+ collection,
+ page,
+ entries,
+ entriesLoaded,
+ isFetching,
+ viewStyle,
+ cursor,
+ collections,
+ isEditorialWorkflow,
+ };
}
const mapDispatchToProps = {
loadEntries: actionLoadEntries,
traverseCollectionCursor: actionTraverseCollectionCursor,
+ loadUnpublishedEntries: actionLoadUnpublishedEntries,
};
export default connect(
diff --git a/packages/netlify-cms-core/src/components/Collection/Entries/EntriesSearch.js b/packages/netlify-cms-core/src/components/Collection/Entries/EntriesSearch.js
index 674d29b0b3f5..25616f1f9c52 100644
--- a/packages/netlify-cms-core/src/components/Collection/Entries/EntriesSearch.js
+++ b/packages/netlify-cms-core/src/components/Collection/Entries/EntriesSearch.js
@@ -53,7 +53,7 @@ class EntriesSearch extends React.Component {
};
render() {
- const { collections, entries, publicFolder, isFetching } = this.props;
+ const { collections, entries, publicFolder, isFetching, unpublishedChildEntry } = this.props;
return (
);
}
diff --git a/packages/netlify-cms-core/src/components/Collection/Entries/EntryCard.js b/packages/netlify-cms-core/src/components/Collection/Entries/EntryCard.js
index cbe61d24be17..575c08a6fbb4 100644
--- a/packages/netlify-cms-core/src/components/Collection/Entries/EntryCard.js
+++ b/packages/netlify-cms-core/src/components/Collection/Entries/EntryCard.js
@@ -86,11 +86,15 @@ const EntryCard = ({
inferedFields,
publicFolder,
collectionLabel,
+ unpublishedChildEntry,
viewStyle = VIEW_STYLE_LIST,
}) => {
const label = entry.get('label');
+ const slug = entry.get('slug');
const title = label || entry.getIn(['data', inferedFields.titleField]);
- const path = `/collections/${collection.get('name')}/entries/${entry.get('slug')}`;
+ const childEntry = unpublishedChildEntry(collection.get('name'), slug);
+ const entrySlug = (childEntry && childEntry.get('slug')) || slug;
+ const path = `/collections/${collection.get('name')}/entries/${entrySlug}`;
let image = entry.getIn(['data', inferedFields.imageField]);
image = resolvePath(image, publicFolder);
if (image) {
diff --git a/packages/netlify-cms-core/src/components/Collection/Entries/EntryListing.js b/packages/netlify-cms-core/src/components/Collection/Entries/EntryListing.js
index cd76565db432..ef5d9d15024a 100644
--- a/packages/netlify-cms-core/src/components/Collection/Entries/EntryListing.js
+++ b/packages/netlify-cms-core/src/components/Collection/Entries/EntryListing.js
@@ -44,20 +44,33 @@ export default class EntryListing extends React.Component {
};
renderCardsForSingleCollection = () => {
- const { collections, entries, publicFolder, viewStyle } = this.props;
+ const { collections, entries, publicFolder, viewStyle, unpublishedChildEntry } = this.props;
const inferedFields = this.inferFields(collections);
- const entryCardProps = { collection: collections, inferedFields, publicFolder, viewStyle };
+ const entryCardProps = {
+ collection: collections,
+ inferedFields,
+ publicFolder,
+ viewStyle,
+ unpublishedChildEntry,
+ };
return entries.map((entry, idx) => );
};
renderCardsForMultipleCollections = () => {
- const { collections, entries, publicFolder } = this.props;
+ const { collections, entries, publicFolder, unpublishedChildEntry } = this.props;
return entries.map((entry, idx) => {
const collectionName = entry.get('collection');
const collection = collections.find(coll => coll.get('name') === collectionName);
const collectionLabel = collection.get('label');
const inferedFields = this.inferFields(collection);
- const entryCardProps = { collection, entry, inferedFields, publicFolder, collectionLabel };
+ const entryCardProps = {
+ collection,
+ entry,
+ inferedFields,
+ publicFolder,
+ collectionLabel,
+ unpublishedChildEntry,
+ };
return ;
});
};
diff --git a/packages/netlify-cms-core/src/components/Editor/Editor.js b/packages/netlify-cms-core/src/components/Editor/Editor.js
index f6b5f21a4159..5f5741f82b6f 100644
--- a/packages/netlify-cms-core/src/components/Editor/Editor.js
+++ b/packages/netlify-cms-core/src/components/Editor/Editor.js
@@ -154,13 +154,19 @@ class Editor extends React.Component {
componentDidUpdate(prevProps) {
/**
* If the old slug is empty and the new slug is not, a new entry was just
- * saved, and we need to update navigation to the correct url using the
- * slug.
+ * saved, or if slug entry was changed, we need to update navigation to the
+ * correct url using the slug.
*/
const newSlug = this.props.entryDraft && this.props.entryDraft.getIn(['entry', 'slug']);
- if (!prevProps.slug && newSlug && this.props.newEntry) {
+ const currentPath = history.location.pathname;
+ if (
+ (!prevProps.slug && newSlug && this.props.newEntry) ||
+ (prevProps.slug && newSlug && prevProps.slug != newSlug)
+ ) {
+ if (currentPath.split('/').pop() === newSlug) return;
+
navigateToEntry(prevProps.collection.get('name'), newSlug);
- this.props.loadEntry(this.props.collection, newSlug);
+ return this.props.loadEntry(this.props.collection, newSlug);
}
if (prevProps.entry === this.props.entry) return;
diff --git a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js
index 2cd855e7f2f8..46d85b8384df 100644
--- a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js
+++ b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControl.js
@@ -7,17 +7,18 @@ import { partial, uniqueId } from 'lodash';
import { connect } from 'react-redux';
import { colors, colorsRaw, transitions, lengths, borders } from 'netlify-cms-ui-default';
import { resolveWidget, getEditorComponents } from 'Lib/registry';
-import { clearFieldErrors } from 'Actions/entries';
+import { currentBackend } from 'src/backend';
import { addAsset } from 'Actions/media';
import { query, clearSearch } from 'Actions/search';
-import { loadEntry } from 'Actions/entries';
+import { clearFieldErrors, loadEntry } from 'Actions/entries';
import {
openMediaLibrary,
removeInsertedMedia,
clearMediaControl,
removeMediaControl,
} from 'Actions/mediaLibrary';
-import { getAsset } from 'Reducers';
+import { getAsset, selectSlugs } from 'Reducers';
+import { selectIdentifier, selectSlugField } from 'Reducers/collections';
import Widget from './Widget';
const styles = {
@@ -157,6 +158,55 @@ class EditorControl extends React.Component {
uniqueFieldId = uniqueId(`${this.props.field.get('name')}-field-`);
+ componentDidMount() {
+ this.handleSetSlugFieldValue();
+ }
+
+ componentDidUpdate() {
+ this.handleSetSlugFieldValue();
+ }
+
+ handleSetSlugFieldValue = () => {
+ const { field, value, onChange, collection, entry, isNewEntry } = this.props;
+ const fieldName = field.get('name');
+ const entrySlug = entry.get('slug');
+ const slugField = collection && selectSlugField(collection);
+ if (fieldName == slugField && !value && !isNewEntry && !this.state.styleActive) {
+ onChange(slugField, entrySlug, {}, false);
+ }
+ };
+
+ handleSetInactiveStyle = async () => {
+ const { field, value, config, entry, onChange, collection, boundSelectSlugs } = this.props;
+ const fieldName = field.get('name');
+ const entryData = entry.get('data');
+ const unavailableSlugs = boundSelectSlugs(collection.get('name'));
+ const availableSlugs = [entry.get('slug'), entry.getIn(['metaData', 'parentSlug'])];
+ const identifierField = collection && selectIdentifier(collection);
+ const slugField = collection && selectSlugField(collection);
+ const slugValue = entry.getIn(['data', slugField]);
+ const backend = currentBackend(config);
+
+ this.setState({ styleActive: false });
+
+ if (fieldName == identifierField && value && !slugValue && collection) {
+ onChange(slugField, await backend.getSlug(collection, entryData, config, unavailableSlugs));
+ }
+
+ if (fieldName == slugField && value && collection) {
+ onChange(
+ slugField,
+ await backend.generateUniqueSlug(
+ collection,
+ value,
+ config,
+ unavailableSlugs,
+ availableSlugs,
+ ),
+ );
+ }
+ };
+
render() {
const {
value,
@@ -238,7 +288,7 @@ class EditorControl extends React.Component {
getAsset={boundGetAsset}
hasActiveStyle={this.state.styleActive}
setActiveStyle={() => this.setState({ styleActive: true })}
- setInactiveStyle={() => this.setState({ styleActive: false })}
+ setInactiveStyle={this.handleSetInactiveStyle}
resolveWidget={resolveWidget}
getEditorComponents={getEditorComponents}
ref={processControlRef && partial(processControlRef, field)}
@@ -269,6 +319,9 @@ const mapStateToProps = state => ({
boundGetAsset: getAsset.bind(null, state),
isFetching: state.search.get('isFetching'),
queryHits: state.search.get('queryHits'),
+ entry: state.entryDraft.get('entry'),
+ config: state.config,
+ boundSelectSlugs: selectSlugs.bind(null, state),
});
const mapDispatchToProps = {
diff --git a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js
index 648e408d215d..ee230755460d 100644
--- a/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js
+++ b/packages/netlify-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js
@@ -44,6 +44,7 @@ export default class ControlPane extends React.Component {
fieldsErrors,
onChange,
onValidate,
+ isNewEntry,
} = this.props;
if (!collection || !fields) {
@@ -60,6 +61,7 @@ export default class ControlPane extends React.Component {
(field, i) =>
field.get('widget') === 'hidden' ? null : (
),
)}
diff --git a/packages/netlify-cms-core/src/components/Editor/EditorInterface.js b/packages/netlify-cms-core/src/components/Editor/EditorInterface.js
index 082d37d632ce..2ea2bdbc0128 100644
--- a/packages/netlify-cms-core/src/components/Editor/EditorInterface.js
+++ b/packages/netlify-cms-core/src/components/Editor/EditorInterface.js
@@ -187,6 +187,7 @@ class EditorInterface extends Component {
fieldsErrors={fieldsErrors}
onChange={onChange}
onValidate={onValidate}
+ isNewEntry={isNewEntry}
ref={c => (this.controlPaneRef = c)}
/>
diff --git a/packages/netlify-cms-core/src/reducers/collections.js b/packages/netlify-cms-core/src/reducers/collections.js
index 68f0f59ccb20..9ee37463bef4 100644
--- a/packages/netlify-cms-core/src/reducers/collections.js
+++ b/packages/netlify-cms-core/src/reducers/collections.js
@@ -120,6 +120,9 @@ export const selectIdentifier = collection => {
fieldNames.find(name => name.toLowerCase().trim() === id.toLowerCase().trim()),
);
};
+export const selectSlugField = collection => {
+ return collection.get('slug_field');
+};
export const selectInferedField = (collection, fieldName) => {
if (fieldName === 'title' && collection.get('identifier_field')) {
return selectIdentifier(collection);
diff --git a/packages/netlify-cms-core/src/reducers/editorialWorkflow.js b/packages/netlify-cms-core/src/reducers/editorialWorkflow.js
index 3788d5933af9..6c2d3cf4e143 100644
--- a/packages/netlify-cms-core/src/reducers/editorialWorkflow.js
+++ b/packages/netlify-cms-core/src/reducers/editorialWorkflow.js
@@ -1,4 +1,5 @@
import { Map, List, fromJS } from 'immutable';
+import { startsWith } from 'lodash';
import { EDITORIAL_WORKFLOW } from 'Constants/publishModes';
import {
UNPUBLISHED_ENTRY_REQUEST,
@@ -61,33 +62,32 @@ const unpublishedEntries = (state = Map(), action) => {
ids: List(action.payload.entries.map(entry => entry.slug)),
}),
);
+ map.set('isFetched', true);
});
case UNPUBLISHED_ENTRY_PERSIST_REQUEST:
// Update Optimistically
return state.withMutations(map => {
map.setIn(
- ['entities', `${action.payload.collection}.${action.payload.entry.get('slug')}`],
+ ['entities', `${action.payload.collection}.${action.payload.slug}`],
fromJS(action.payload.entry),
);
map.setIn(
- [
- 'entities',
- `${action.payload.collection}.${action.payload.entry.get('slug')}`,
- 'isPersisting',
- ],
+ ['entities', `${action.payload.collection}.${action.payload.slug}`, 'isPersisting'],
true,
);
- map.updateIn(['pages', 'ids'], List(), list => list.push(action.payload.entry.get('slug')));
+ map.updateIn(['pages', 'ids'], List(), list => list.push(action.payload.slug));
});
case UNPUBLISHED_ENTRY_PERSIST_SUCCESS:
// Update Optimistically
- return state.deleteIn([
- 'entities',
- `${action.payload.collection}.${action.payload.entry.get('slug')}`,
- 'isPersisting',
- ]);
+ return state.withMutations(map => {
+ map.deleteIn([
+ 'entities',
+ `${action.payload.collection}.${action.payload.slug}`,
+ 'isPersisting',
+ ]);
+ });
case UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST:
// Update Optimistically
@@ -140,4 +140,21 @@ export const selectUnpublishedEntriesByStatus = (state, status) => {
.valueSeq();
};
+export const selectUnpublishedEntryByParentSlug = (state, collection, slug) => {
+ if (!state.get('entities')) return null;
+ return state
+ .get('entities')
+ .filter((v, k) => startsWith(k, `${collection}.`))
+ .find(entry => entry.getIn(['metaData', 'parentSlug']) === slug);
+};
+
+export const selectUnpublishedSlugEntriesByCollection = (state, collection) => {
+ if (!state.get('entities')) return null;
+ return state
+ .get('entities')
+ .filter((v, k) => startsWith(k, `${collection}.`))
+ .map(entry => entry.get('slug'))
+ .valueSeq();
+};
+
export default unpublishedEntries;
diff --git a/packages/netlify-cms-core/src/reducers/entries.js b/packages/netlify-cms-core/src/reducers/entries.js
index 70bf2d2fdbf9..13d2a6ef4f19 100644
--- a/packages/netlify-cms-core/src/reducers/entries.js
+++ b/packages/netlify-cms-core/src/reducers/entries.js
@@ -104,8 +104,11 @@ const entries = (state = Map({ entities: Map(), pages: Map() }), action) => {
export const selectEntry = (state, collection, slug) =>
state.getIn(['entities', `${collection}.${slug}`]);
+export const selectSlugEntries = (state, collection) =>
+ state.getIn(['pages', collection, 'ids'], List());
+
export const selectEntries = (state, collection) => {
- const slugs = state.getIn(['pages', collection, 'ids']);
+ const slugs = selectSlugEntries(state, collection);
return slugs && slugs.map(slug => selectEntry(state, collection, slug));
};
diff --git a/packages/netlify-cms-core/src/reducers/entryDraft.js b/packages/netlify-cms-core/src/reducers/entryDraft.js
index 7e72b819e6a4..30c62de57b29 100644
--- a/packages/netlify-cms-core/src/reducers/entryDraft.js
+++ b/packages/netlify-cms-core/src/reducers/entryDraft.js
@@ -57,7 +57,9 @@ const entryDraftReducer = (state = Map(), action) => {
return state.withMutations(state => {
state.setIn(['entry', 'data', action.payload.field], action.payload.value);
state.mergeDeepIn(['fieldsMetaData'], fromJS(action.payload.metadata));
- state.set('hasChanged', true);
+ if (action.payload.hasChanged) {
+ state.set('hasChanged', true);
+ }
});
case DRAFT_VALIDATION_ERRORS:
@@ -86,7 +88,7 @@ const entryDraftReducer = (state = Map(), action) => {
return state.withMutations(state => {
state.deleteIn(['entry', 'isPersisting']);
state.set('hasChanged', false);
- if (!state.getIn(['entry', 'slug'])) {
+ if (state.getIn(['entry', 'slug']) !== action.payload.slug) {
state.setIn(['entry', 'slug'], action.payload.slug);
}
});
diff --git a/packages/netlify-cms-core/src/reducers/index.js b/packages/netlify-cms-core/src/reducers/index.js
index 50228f96d433..0185579cb8ec 100644
--- a/packages/netlify-cms-core/src/reducers/index.js
+++ b/packages/netlify-cms-core/src/reducers/index.js
@@ -39,6 +39,9 @@ export const selectEntry = (state, collection, slug) =>
export const selectEntries = (state, collection) =>
fromEntries.selectEntries(state.entries, collection);
+export const selectSlugEntries = (state, collection) =>
+ fromEntries.selectSlugEntries(state.entries, collection);
+
export const selectSearchedEntries = state => {
const searchItems = state.search.get('entryIds');
return (
@@ -55,9 +58,28 @@ export const selectDeployPreview = (state, collection, slug) =>
export const selectUnpublishedEntry = (state, collection, slug) =>
fromEditorialWorkflow.selectUnpublishedEntry(state.editorialWorkflow, collection, slug);
+export const selectUnpublishedEntryByParentSlug = (state, collection, slug) =>
+ fromEditorialWorkflow.selectUnpublishedEntryByParentSlug(
+ state.editorialWorkflow,
+ collection,
+ slug,
+ );
+
export const selectUnpublishedEntriesByStatus = (state, status) =>
fromEditorialWorkflow.selectUnpublishedEntriesByStatus(state.editorialWorkflow, status);
+export const selectUnpublishedSlugEntriesByCollection = (state, collection) =>
+ fromEditorialWorkflow.selectUnpublishedSlugEntriesByCollection(
+ state.editorialWorkflow,
+ collection,
+ );
+
+export const selectSlugs = (state, collection) => {
+ const unpublishedSlugs = selectUnpublishedSlugEntriesByCollection(state, collection);
+ const publishedSlugs = selectSlugEntries(state, collection);
+ return unpublishedSlugs ? publishedSlugs.concat(unpublishedSlugs) : publishedSlugs;
+};
+
export const selectIntegration = (state, collection, hook) =>
fromIntegrations.selectIntegration(state.integrations, collection, hook);
diff --git a/website/content/docs/beta-features.md b/website/content/docs/beta-features.md
index 44fa4acb998f..0e61d5fc783c 100644
--- a/website/content/docs/beta-features.md
+++ b/website/content/docs/beta-features.md
@@ -86,6 +86,27 @@ sections:
- images/image06.png
```
+## Slug Customization
+This feature makes it possible to edit slug entries. To enable this feature, add a config collection setting `slug_field` with the value pointing to the name of a field widget preferable a `string` widget, this widget will be used as the slug field.
+
+The value of the slug field is not saved in the frontmatter. The slug value is used internally by the CMS for entry navigation, to generate entry filename, etc.
+And If there is a slug change for any published or unpublished entry, a new slug entry is created, and the old slug entry is deleted.
+
+### Example Configuration
+```yaml
+collections:
+ - name: "blog"
+ label: "Blog"
+ folder: "content/blog"
+ create: true
+ slug_field: 'slug'
+ slug: "{{year}}-{{month}}-{{day}}-{{slug}}"
+ fields:
+ - { label: "Title", name: "title", widget: "string" }
+ - { label: "Slug", name: "slug", widget: "string" }
+ - { label: 'Body', name: 'body', widget: 'markdown' }
+```
+
## Custom Mount Element
Netlify CMS always creates its own DOM element for mounting the application, which means it always takes over the entire page, and is generally inflexible if you're trying to do something creative, like injecting it into a shared context.