feat(core): allow custom summary on entry cards (#2140)
This commit is contained in:
parent
228271194b
commit
573ad8816d
@ -15,6 +15,7 @@ collections: # A list of collections the CMS should be able to edit
|
|||||||
guidelines that are specific to a collection.
|
guidelines that are specific to a collection.
|
||||||
folder: '_posts'
|
folder: '_posts'
|
||||||
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
|
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
|
||||||
|
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
|
||||||
create: true # Allow users to create new documents in this collection
|
create: true # Allow users to create new documents in this collection
|
||||||
fields: # The fields each document in this collection have
|
fields: # The fields each document in this collection have
|
||||||
- { label: 'Title', name: 'title', widget: 'string', tagname: 'h1' }
|
- { label: 'Title', name: 'title', widget: 'string', tagname: 'h1' }
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { attempt, flatten, isError, trimStart, trimEnd, flow, partialRight } from 'lodash';
|
import { attempt, flatten, isError, trimStart, trimEnd, flow, partialRight, uniq } from 'lodash';
|
||||||
import { Map } from 'immutable';
|
import { Map } from 'immutable';
|
||||||
import { stripIndent } from 'common-tags';
|
import { stripIndent } from 'common-tags';
|
||||||
import moment from 'moment';
|
|
||||||
import fuzzy from 'fuzzy';
|
import fuzzy from 'fuzzy';
|
||||||
import { resolveFormat } from 'Formats/formats';
|
import { resolveFormat } from 'Formats/formats';
|
||||||
import { selectIntegration } from 'Reducers/integrations';
|
import { selectIntegration } from 'Reducers/integrations';
|
||||||
@ -20,6 +19,13 @@ import { sanitizeSlug } from 'Lib/urlHelper';
|
|||||||
import { getBackend } from 'Lib/registry';
|
import { getBackend } from 'Lib/registry';
|
||||||
import { localForage, Cursor, CURSOR_COMPATIBILITY_SYMBOL } from 'netlify-cms-lib-util';
|
import { localForage, Cursor, CURSOR_COMPATIBILITY_SYMBOL } from 'netlify-cms-lib-util';
|
||||||
import { EDITORIAL_WORKFLOW, status } from 'Constants/publishModes';
|
import { EDITORIAL_WORKFLOW, status } from 'Constants/publishModes';
|
||||||
|
import {
|
||||||
|
SLUG_MISSING_REQUIRED_DATE,
|
||||||
|
compileStringTemplate,
|
||||||
|
extractTemplateVars,
|
||||||
|
parseDateFromEntry,
|
||||||
|
dateParsers,
|
||||||
|
} from 'Lib/stringTemplate';
|
||||||
|
|
||||||
class LocalStorageAuthStore {
|
class LocalStorageAuthStore {
|
||||||
storageKey = 'netlify-cms-user';
|
storageKey = 'netlify-cms-user';
|
||||||
@ -53,35 +59,13 @@ function prepareSlug(slug) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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';
|
|
||||||
const USE_FIELD_PREFIX = 'fields.';
|
|
||||||
|
|
||||||
// Allow `fields.` prefix in placeholder to override built in replacements
|
|
||||||
// like "slug" and "year" with values from fields of the same name.
|
|
||||||
function getExplicitFieldReplacement(key, data) {
|
|
||||||
if (!key.startsWith(USE_FIELD_PREFIX)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const fieldName = key.substring(USE_FIELD_PREFIX.length);
|
|
||||||
return data.get(fieldName, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEntryBackupKey(collectionName, slug) {
|
function getEntryBackupKey(collectionName, slug) {
|
||||||
const baseKey = 'backup';
|
const baseKey = 'backup';
|
||||||
if (!collectionName) {
|
if (!collectionName) {
|
||||||
return baseKey;
|
return baseKey;
|
||||||
}
|
}
|
||||||
const suffix = slug ? `.${slug}` : '';
|
const suffix = slug ? `.${slug}` : '';
|
||||||
return `backup.${collectionName}${suffix}`;
|
return `${baseKey}.${collectionName}${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLabelForFileCollectionEntry(collection, path) {
|
function getLabelForFileCollectionEntry(collection, path) {
|
||||||
@ -89,42 +73,6 @@ function getLabelForFileCollectionEntry(collection, path) {
|
|||||||
return files && files.find(f => f.get('file') === path).get('label');
|
return files && files.find(f => f.get('file') === path).get('label');
|
||||||
}
|
}
|
||||||
|
|
||||||
function compileSlug(template, date, identifier = '', data = Map(), processor) {
|
|
||||||
let missingRequiredDate;
|
|
||||||
|
|
||||||
const slug = template.replace(/\{\{([^}]+)\}\}/g, (_, key) => {
|
|
||||||
let replacement;
|
|
||||||
const explicitFieldReplacement = getExplicitFieldReplacement(key, data);
|
|
||||||
|
|
||||||
if (explicitFieldReplacement) {
|
|
||||||
replacement = explicitFieldReplacement;
|
|
||||||
} else if (dateParsers[key] && !date) {
|
|
||||||
missingRequiredDate = true;
|
|
||||||
return '';
|
|
||||||
} else if (dateParsers[key]) {
|
|
||||||
replacement = dateParsers[key](date);
|
|
||||||
} else if (key === 'slug') {
|
|
||||||
replacement = identifier;
|
|
||||||
} else {
|
|
||||||
replacement = data.get(key, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
function slugFormatter(collection, entryData, slugConfig) {
|
||||||
const template = collection.get('slug') || '{{slug}}';
|
const template = collection.get('slug') || '{{slug}}';
|
||||||
|
|
||||||
@ -138,7 +86,11 @@ function slugFormatter(collection, entryData, slugConfig) {
|
|||||||
// Pass entire slug through `prepareSlug` and `sanitizeSlug`.
|
// Pass entire slug through `prepareSlug` and `sanitizeSlug`.
|
||||||
// TODO: only pass slug replacements through sanitizers, static portions of
|
// TODO: only pass slug replacements through sanitizers, static portions of
|
||||||
// the slug template should not be sanitized. (breaking change)
|
// the slug template should not be sanitized. (breaking change)
|
||||||
const processSlug = flow([compileSlug, prepareSlug, partialRight(sanitizeSlug, slugConfig)]);
|
const processSlug = flow([
|
||||||
|
compileStringTemplate,
|
||||||
|
prepareSlug,
|
||||||
|
partialRight(sanitizeSlug, slugConfig),
|
||||||
|
]);
|
||||||
|
|
||||||
return processSlug(template, new Date(), identifier, entryData);
|
return processSlug(template, new Date(), identifier, entryData);
|
||||||
}
|
}
|
||||||
@ -183,20 +135,6 @@ const sortByScore = (a, b) => {
|
|||||||
return 0;
|
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) {
|
function createPreviewUrl(baseUrl, collection, slug, slugConfig, entry) {
|
||||||
/**
|
/**
|
||||||
* Preview URL can't be created without `baseUrl`. This makes preview URLs
|
* Preview URL can't be created without `baseUrl`. This makes preview URLs
|
||||||
@ -221,7 +159,7 @@ function createPreviewUrl(baseUrl, collection, slug, slugConfig, entry) {
|
|||||||
const basePath = trimEnd(baseUrl, '/');
|
const basePath = trimEnd(baseUrl, '/');
|
||||||
const pathTemplate = collection.get('preview_path');
|
const pathTemplate = collection.get('preview_path');
|
||||||
const fields = entry.get('data');
|
const fields = entry.get('data');
|
||||||
const date = parsePreviewPathDate(collection, entry);
|
const date = parseDateFromEntry(entry, collection, collection.get('preview_path_date_field'));
|
||||||
|
|
||||||
// Prepare and sanitize slug variables only, leave the rest of the
|
// Prepare and sanitize slug variables only, leave the rest of the
|
||||||
// `preview_path` template as is.
|
// `preview_path` template as is.
|
||||||
@ -233,7 +171,7 @@ function createPreviewUrl(baseUrl, collection, slug, slugConfig, entry) {
|
|||||||
let compiledPath;
|
let compiledPath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
compiledPath = compileSlug(pathTemplate, date, slug, fields, processSegment);
|
compiledPath = compileStringTemplate(pathTemplate, date, slug, fields, processSegment);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Print an error and ignore `preview_path` if both:
|
// Print an error and ignore `preview_path` if both:
|
||||||
// 1. Date is invalid (according to Moment), and
|
// 1. Date is invalid (according to Moment), and
|
||||||
@ -384,15 +322,24 @@ class Backend {
|
|||||||
const errors = [];
|
const errors = [];
|
||||||
const collectionEntriesRequests = collections
|
const collectionEntriesRequests = collections
|
||||||
.map(async collection => {
|
.map(async collection => {
|
||||||
|
const summary = collection.get('summary', '');
|
||||||
|
const summaryFields = extractTemplateVars(summary);
|
||||||
|
|
||||||
// TODO: pass search fields in as an argument
|
// TODO: pass search fields in as an argument
|
||||||
const searchFields = [
|
const searchFields = [
|
||||||
selectInferedField(collection, 'title'),
|
selectInferedField(collection, 'title'),
|
||||||
selectInferedField(collection, 'shortTitle'),
|
selectInferedField(collection, 'shortTitle'),
|
||||||
selectInferedField(collection, 'author'),
|
selectInferedField(collection, 'author'),
|
||||||
|
...summaryFields.map(elem => {
|
||||||
|
if (dateParsers[elem]) {
|
||||||
|
return selectInferedField(collection, 'date');
|
||||||
|
}
|
||||||
|
return elem;
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
const collectionEntries = await this.listAllEntries(collection);
|
const collectionEntries = await this.listAllEntries(collection);
|
||||||
return fuzzy.filter(searchTerm, collectionEntries, {
|
return fuzzy.filter(searchTerm, collectionEntries, {
|
||||||
extract: extractSearchFields(searchFields),
|
extract: extractSearchFields(uniq(searchFields)),
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.map(p => p.catch(err => errors.push(err) && []));
|
.map(p => p.catch(err => errors.push(err) && []));
|
||||||
|
@ -4,6 +4,8 @@ import { Link } from 'react-router-dom';
|
|||||||
import { resolvePath } from 'netlify-cms-lib-util';
|
import { resolvePath } from 'netlify-cms-lib-util';
|
||||||
import { colors, colorsRaw, components, lengths } from 'netlify-cms-ui-default';
|
import { colors, colorsRaw, components, lengths } from 'netlify-cms-ui-default';
|
||||||
import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from 'Constants/collectionViews';
|
import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from 'Constants/collectionViews';
|
||||||
|
import { compileStringTemplate, parseDateFromEntry } from 'Lib/stringTemplate';
|
||||||
|
import { selectIdentifier } from 'Reducers/collections';
|
||||||
|
|
||||||
const ListCard = styled.li`
|
const ListCard = styled.li`
|
||||||
${components.card};
|
${components.card};
|
||||||
@ -89,9 +91,17 @@ const EntryCard = ({
|
|||||||
viewStyle = VIEW_STYLE_LIST,
|
viewStyle = VIEW_STYLE_LIST,
|
||||||
}) => {
|
}) => {
|
||||||
const label = entry.get('label');
|
const label = entry.get('label');
|
||||||
const title = label || entry.getIn(['data', inferedFields.titleField]);
|
const entryData = entry.get('data');
|
||||||
|
const defaultTitle = label || entryData.get(inferedFields.titleField);
|
||||||
const path = `/collections/${collection.get('name')}/entries/${entry.get('slug')}`;
|
const path = `/collections/${collection.get('name')}/entries/${entry.get('slug')}`;
|
||||||
let image = entry.getIn(['data', inferedFields.imageField]);
|
const summary = collection.get('summary');
|
||||||
|
const date = parseDateFromEntry(entry, collection) || null;
|
||||||
|
const identifier = entryData.get(selectIdentifier(collection));
|
||||||
|
const title = summary
|
||||||
|
? compileStringTemplate(summary, date, identifier, entryData)
|
||||||
|
: defaultTitle;
|
||||||
|
|
||||||
|
let image = entryData.get(inferedFields.imageField);
|
||||||
image = resolvePath(image, publicFolder);
|
image = resolvePath(image, publicFolder);
|
||||||
if (image) {
|
if (image) {
|
||||||
image = encodeURI(image);
|
image = encodeURI(image);
|
||||||
|
@ -89,6 +89,7 @@ const getConfigSchema = () => ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
identifier_field: { type: 'string' },
|
identifier_field: { type: 'string' },
|
||||||
|
summary: { type: 'string' },
|
||||||
slug: { type: 'string' },
|
slug: { type: 'string' },
|
||||||
preview_path: { type: 'string' },
|
preview_path: { type: 'string' },
|
||||||
preview_path_date_field: { type: 'string' },
|
preview_path_date_field: { type: 'string' },
|
||||||
|
92
packages/netlify-cms-core/src/lib/stringTemplate.js
Normal file
92
packages/netlify-cms-core/src/lib/stringTemplate.js
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import moment from 'moment';
|
||||||
|
import { selectInferedField } from 'Reducers/collections';
|
||||||
|
|
||||||
|
// prepends a Zero if the date has only 1 digit
|
||||||
|
function formatDate(date) {
|
||||||
|
return `0${date}`.slice(-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dateParsers = {
|
||||||
|
year: date => date.getFullYear(),
|
||||||
|
month: date => formatDate(date.getMonth() + 1),
|
||||||
|
day: date => formatDate(date.getDate()),
|
||||||
|
hour: date => formatDate(date.getHours()),
|
||||||
|
minute: date => formatDate(date.getMinutes()),
|
||||||
|
second: date => formatDate(date.getSeconds()),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SLUG_MISSING_REQUIRED_DATE = 'SLUG_MISSING_REQUIRED_DATE';
|
||||||
|
|
||||||
|
const FIELD_PREFIX = 'fields.';
|
||||||
|
const templateContentPattern = '[^}{]+';
|
||||||
|
const templateVariablePattern = `{{(${templateContentPattern})}}`;
|
||||||
|
|
||||||
|
// Allow `fields.` prefix in placeholder to override built in replacements
|
||||||
|
// like "slug" and "year" with values from fields of the same name.
|
||||||
|
function getExplicitFieldReplacement(key, data) {
|
||||||
|
if (!key.startsWith(FIELD_PREFIX)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fieldName = key.substring(FIELD_PREFIX.length);
|
||||||
|
return data.get(fieldName, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDateFromEntry(entry, collection, fieldName) {
|
||||||
|
const dateFieldName = fieldName || selectInferedField(collection, 'date');
|
||||||
|
if (!dateFieldName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateValue = entry.getIn(['data', dateFieldName]);
|
||||||
|
const dateMoment = dateValue && moment(dateValue);
|
||||||
|
if (dateMoment && dateMoment.isValid()) {
|
||||||
|
return dateMoment.toDate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compileStringTemplate(template, date, identifier = '', data = Map(), processor) {
|
||||||
|
let missingRequiredDate;
|
||||||
|
|
||||||
|
// Turn off date processing (support for replacements like `{{year}}`), by passing in
|
||||||
|
// `null` as the date arg.
|
||||||
|
const useDate = date !== null;
|
||||||
|
|
||||||
|
const slug = template.replace(RegExp(templateVariablePattern, 'g'), (_, key) => {
|
||||||
|
let replacement;
|
||||||
|
const explicitFieldReplacement = getExplicitFieldReplacement(key, data);
|
||||||
|
|
||||||
|
if (explicitFieldReplacement) {
|
||||||
|
replacement = explicitFieldReplacement;
|
||||||
|
} else if (dateParsers[key] && !date) {
|
||||||
|
missingRequiredDate = true;
|
||||||
|
return '';
|
||||||
|
} else if (dateParsers[key]) {
|
||||||
|
replacement = dateParsers[key](date);
|
||||||
|
} else if (key === 'slug') {
|
||||||
|
replacement = identifier;
|
||||||
|
} else {
|
||||||
|
replacement = data.get(key, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processor) {
|
||||||
|
return processor(replacement);
|
||||||
|
}
|
||||||
|
|
||||||
|
return replacement;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (useDate && missingRequiredDate) {
|
||||||
|
const err = new Error();
|
||||||
|
err.name = SLUG_MISSING_REQUIRED_DATE;
|
||||||
|
throw err;
|
||||||
|
} else {
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractTemplateVars(template) {
|
||||||
|
const regexp = RegExp(templateVariablePattern, 'g');
|
||||||
|
const contentRegexp = RegExp(templateContentPattern, 'g');
|
||||||
|
const matches = template.match(regexp) || [];
|
||||||
|
return matches.map(elem => elem.match(contentRegexp)[0]);
|
||||||
|
}
|
@ -115,7 +115,7 @@ export const selectTemplateName = (collection, slug) =>
|
|||||||
export const selectIdentifier = collection => {
|
export const selectIdentifier = collection => {
|
||||||
const identifier = collection.get('identifier_field');
|
const identifier = collection.get('identifier_field');
|
||||||
const identifierFields = identifier ? [identifier, ...IDENTIFIER_FIELDS] : IDENTIFIER_FIELDS;
|
const identifierFields = identifier ? [identifier, ...IDENTIFIER_FIELDS] : IDENTIFIER_FIELDS;
|
||||||
const fieldNames = collection.get('fields').map(field => field.get('name'));
|
const fieldNames = collection.get('fields', []).map(field => field.get('name'));
|
||||||
return identifierFields.find(id =>
|
return identifierFields.find(id =>
|
||||||
fieldNames.find(name => name.toLowerCase().trim() === id.toLowerCase().trim()),
|
fieldNames.find(name => name.toLowerCase().trim() === id.toLowerCase().trim()),
|
||||||
);
|
);
|
||||||
@ -128,7 +128,7 @@ export const selectInferedField = (collection, fieldName) => {
|
|||||||
const fields = collection.get('fields');
|
const fields = collection.get('fields');
|
||||||
let field;
|
let field;
|
||||||
|
|
||||||
// If colllection has no fields or fieldName is not defined within inferables list, return null
|
// If collection has no fields or fieldName is not defined within inferables list, return null
|
||||||
if (!fields || !inferableField) return null;
|
if (!fields || !inferableField) return null;
|
||||||
// Try to return a field of the specified type with one of the synonyms
|
// Try to return a field of the specified type with one of the synonyms
|
||||||
const mainTypeFields = fields
|
const mainTypeFields = fields
|
||||||
|
@ -171,6 +171,7 @@ The `collections` setting is the heart of your Netlify CMS configuration, as it
|
|||||||
* `preview_path`: see detailed description below
|
* `preview_path`: see detailed description below
|
||||||
* `fields` (required): see detailed description below
|
* `fields` (required): see detailed description below
|
||||||
* `editor`: see detailed description below
|
* `editor`: see detailed description below
|
||||||
|
* `summary`: see detailed description below
|
||||||
|
|
||||||
The last few options require more detailed information.
|
The last few options require more detailed information.
|
||||||
|
|
||||||
@ -320,3 +321,14 @@ This setting changes options for the editor view of the collection. It has one o
|
|||||||
editor:
|
editor:
|
||||||
preview: false
|
preview: false
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### `summary`
|
||||||
|
|
||||||
|
This setting allows the customisation of the collection list view. Similar to the `slug` field, a string with templates can be used to include values of different fields, e.g. `{{title}}`.
|
||||||
|
This option over-rides the default of `title` field and `identifier_field`.
|
||||||
|
|
||||||
|
**Example**
|
||||||
|
```yaml
|
||||||
|
summary: "Version: {{version}} - {{title}}"
|
||||||
|
```
|
||||||
|
Loading…
x
Reference in New Issue
Block a user