Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): Allow customising the collection list based on multiple fields #2140

Merged
merged 18 commits into from
Mar 29, 2019
Merged
1 change: 1 addition & 0 deletions dev-test/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down
105 changes: 26 additions & 79 deletions packages/netlify-cms-core/src/backend.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -53,78 +59,20 @@ 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) {
const files = collection.get('files');
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}}';

Expand All @@ -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);
}
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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) && []));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/netlify-cms-core/src/constants/configSchema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
92 changes: 92 additions & 0 deletions packages/netlify-cms-core/src/lib/stringTemplate.js
Original file line number Diff line number Diff line change
@@ -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]);
}
4 changes: 2 additions & 2 deletions packages/netlify-cms-core/src/reducers/collections.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
);
Expand All @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I must have looked at this in annoyance a thousand times but never bothered to fix lol 👍👍

if (!fields || !inferableField) return null;
// Try to return a field of the specified type with one of the synonyms
const mainTypeFields = fields
Expand Down
12 changes: 12 additions & 0 deletions website/content/docs/configuration-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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