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.
|
||||
folder: '_posts'
|
||||
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
|
||||
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
|
||||
create: true # Allow users to create new documents in this collection
|
||||
fields: # The fields each document in this collection have
|
||||
- { 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 { stripIndent } from 'common-tags';
|
||||
import moment from 'moment';
|
||||
import fuzzy from 'fuzzy';
|
||||
import { resolveFormat } from 'Formats/formats';
|
||||
import { selectIntegration } from 'Reducers/integrations';
|
||||
@ -20,6 +19,13 @@ import { sanitizeSlug } from 'Lib/urlHelper';
|
||||
import { getBackend } from 'Lib/registry';
|
||||
import { localForage, Cursor, CURSOR_COMPATIBILITY_SYMBOL } from 'netlify-cms-lib-util';
|
||||
import { EDITORIAL_WORKFLOW, status } from 'Constants/publishModes';
|
||||
import {
|
||||
SLUG_MISSING_REQUIRED_DATE,
|
||||
compileStringTemplate,
|
||||
extractTemplateVars,
|
||||
parseDateFromEntry,
|
||||
dateParsers,
|
||||
} from 'Lib/stringTemplate';
|
||||
|
||||
class LocalStorageAuthStore {
|
||||
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) {
|
||||
const baseKey = 'backup';
|
||||
if (!collectionName) {
|
||||
return baseKey;
|
||||
}
|
||||
const suffix = slug ? `.${slug}` : '';
|
||||
return `backup.${collectionName}${suffix}`;
|
||||
return `${baseKey}.${collectionName}${suffix}`;
|
||||
}
|
||||
|
||||
function getLabelForFileCollectionEntry(collection, path) {
|
||||
@ -89,42 +73,6 @@ function getLabelForFileCollectionEntry(collection, path) {
|
||||
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) {
|
||||
const template = collection.get('slug') || '{{slug}}';
|
||||
|
||||
@ -138,7 +86,11 @@ function slugFormatter(collection, entryData, slugConfig) {
|
||||
// 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)]);
|
||||
const processSlug = flow([
|
||||
compileStringTemplate,
|
||||
prepareSlug,
|
||||
partialRight(sanitizeSlug, slugConfig),
|
||||
]);
|
||||
|
||||
return processSlug(template, new Date(), identifier, entryData);
|
||||
}
|
||||
@ -183,20 +135,6 @@ 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
|
||||
@ -221,7 +159,7 @@ function createPreviewUrl(baseUrl, collection, slug, slugConfig, entry) {
|
||||
const basePath = trimEnd(baseUrl, '/');
|
||||
const pathTemplate = collection.get('preview_path');
|
||||
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
|
||||
// `preview_path` template as is.
|
||||
@ -233,7 +171,7 @@ function createPreviewUrl(baseUrl, collection, slug, slugConfig, entry) {
|
||||
let compiledPath;
|
||||
|
||||
try {
|
||||
compiledPath = compileSlug(pathTemplate, date, slug, fields, processSegment);
|
||||
compiledPath = compileStringTemplate(pathTemplate, date, slug, fields, processSegment);
|
||||
} catch (err) {
|
||||
// Print an error and ignore `preview_path` if both:
|
||||
// 1. Date is invalid (according to Moment), and
|
||||
@ -384,15 +322,24 @@ class Backend {
|
||||
const errors = [];
|
||||
const collectionEntriesRequests = collections
|
||||
.map(async collection => {
|
||||
const summary = collection.get('summary', '');
|
||||
const summaryFields = extractTemplateVars(summary);
|
||||
|
||||
// TODO: pass search fields in as an argument
|
||||
const searchFields = [
|
||||
selectInferedField(collection, 'title'),
|
||||
selectInferedField(collection, 'shortTitle'),
|
||||
selectInferedField(collection, 'author'),
|
||||
...summaryFields.map(elem => {
|
||||
if (dateParsers[elem]) {
|
||||
return selectInferedField(collection, 'date');
|
||||
}
|
||||
return elem;
|
||||
}),
|
||||
];
|
||||
const collectionEntries = await this.listAllEntries(collection);
|
||||
return fuzzy.filter(searchTerm, collectionEntries, {
|
||||
extract: extractSearchFields(searchFields),
|
||||
extract: extractSearchFields(uniq(searchFields)),
|
||||
});
|
||||
})
|
||||
.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 { colors, colorsRaw, components, lengths } from 'netlify-cms-ui-default';
|
||||
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`
|
||||
${components.card};
|
||||
@ -89,9 +91,17 @@ const EntryCard = ({
|
||||
viewStyle = VIEW_STYLE_LIST,
|
||||
}) => {
|
||||
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')}`;
|
||||
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);
|
||||
if (image) {
|
||||
image = encodeURI(image);
|
||||
|
@ -89,6 +89,7 @@ const getConfigSchema = () => ({
|
||||
},
|
||||
},
|
||||
identifier_field: { type: 'string' },
|
||||
summary: { type: 'string' },
|
||||
slug: { type: 'string' },
|
||||
preview_path: { 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 => {
|
||||
const identifier = collection.get('identifier_field');
|
||||
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 =>
|
||||
fieldNames.find(name => name.toLowerCase().trim() === id.toLowerCase().trim()),
|
||||
);
|
||||
@ -128,7 +128,7 @@ export const selectInferedField = (collection, fieldName) => {
|
||||
const fields = collection.get('fields');
|
||||
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;
|
||||
// Try to return a field of the specified type with one of the synonyms
|
||||
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
|
||||
* `fields` (required): see detailed description below
|
||||
* `editor`: see detailed description below
|
||||
* `summary`: see detailed description below
|
||||
|
||||
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:
|
||||
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