feat(core): allow custom summary on entry cards (#2140)

This commit is contained in:
Austin Devine 2019-03-29 18:30:38 +00:00 committed by Shawn Erquhart
parent 228271194b
commit 573ad8816d
7 changed files with 146 additions and 83 deletions

View File

@ -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' }

View File

@ -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) && []));

View File

@ -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);

View File

@ -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' },

View 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]);
}

View File

@ -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

View File

@ -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}}"
```