feat(widget-relation): string templates support (#3659)

This commit is contained in:
Erez Rokah
2020-04-30 16:03:08 +03:00
committed by GitHub
parent 02f3cdd102
commit 213ae86b54
17 changed files with 406 additions and 95 deletions

View File

@ -1,4 +1,4 @@
import { resolveBackend, Backend } from '../backend';
import { resolveBackend, Backend, extractSearchFields } from '../backend';
import registry from 'Lib/registry';
import { FOLDER } from 'Constants/collectionTypes';
import { Map, List, fromJS } from 'immutable';
@ -466,4 +466,181 @@ describe('Backend', () => {
);
});
});
describe('extractSearchFields', () => {
it('should extract slug', () => {
expect(extractSearchFields(['slug'])({ slug: 'entry-slug', data: {} })).toEqual(
' entry-slug',
);
});
it('should extract path', () => {
expect(extractSearchFields(['path'])({ path: 'entry-path', data: {} })).toEqual(
' entry-path',
);
});
it('should extract fields', () => {
expect(
extractSearchFields(['title', 'order'])({ data: { title: 'Entry Title', order: 5 } }),
).toEqual(' Entry Title 5');
});
it('should extract nested fields', () => {
expect(
extractSearchFields(['nested.title'])({ data: { nested: { title: 'nested title' } } }),
).toEqual(' nested title');
});
});
describe('search/query', () => {
const collections = [
fromJS({
name: 'posts',
folder: 'posts',
fields: [
{ name: 'title', widget: 'string' },
{ name: 'short_title', widget: 'string' },
{ name: 'author', widget: 'string' },
{ name: 'description', widget: 'string' },
{ name: 'nested', widget: 'object', fields: { name: 'title', widget: 'string' } },
],
}),
fromJS({
name: 'pages',
folder: 'pages',
fields: [
{ name: 'title', widget: 'string' },
{ name: 'short_title', widget: 'string' },
{ name: 'author', widget: 'string' },
{ name: 'description', widget: 'string' },
{ name: 'nested', widget: 'object', fields: { name: 'title', widget: 'string' } },
],
}),
];
const posts = [
{
path: 'posts/find-me.md',
slug: 'find-me',
data: {
title: 'find me by title',
short_title: 'find me by short title',
author: 'find me by author',
description: 'find me by description',
nested: { title: 'find me by nested title' },
},
},
{ path: 'posts/not-me.md', slug: 'not-me', data: { title: 'not me' } },
];
const pages = [
{
path: 'pages/find-me.md',
slug: 'find-me',
data: {
title: 'find me by title',
short_title: 'find me by short title',
author: 'find me by author',
description: 'find me by description',
nested: { title: 'find me by nested title' },
},
},
{ path: 'pages/not-me.md', slug: 'not-me', data: { title: 'not me' } },
];
const implementation = {
init: jest.fn(() => implementation),
};
const config = Map({});
let backend;
beforeEach(() => {
backend = new Backend(implementation, { config, backendName: 'github' });
backend.listAllEntries = jest.fn(collection => {
if (collection.get('name') === 'posts') {
return Promise.resolve(posts);
}
if (collection.get('name') === 'pages') {
return Promise.resolve(pages);
}
return Promise.resolve([]);
});
});
it('should search collections by title', async () => {
const results = await backend.search(collections, 'find me by title');
expect(results).toEqual({
entries: [posts[0], pages[0]],
});
});
it('should search collections by short title', async () => {
const results = await backend.search(collections, 'find me by short title');
expect(results).toEqual({
entries: [posts[0], pages[0]],
});
});
it('should search collections by author', async () => {
const results = await backend.search(collections, 'find me by author');
expect(results).toEqual({
entries: [posts[0], pages[0]],
});
});
it('should search collections by summary description', async () => {
const results = await backend.search(
collections.map(c => c.set('summary', '{{description}}')),
'find me by description',
);
expect(results).toEqual({
entries: [posts[0], pages[0]],
});
});
it('should query collections by title', async () => {
const results = await backend.query(collections[0], ['title'], 'find me by title');
expect(results).toEqual({
hits: [posts[0]],
query: 'find me by title',
});
});
it('should query collections by slug', async () => {
const results = await backend.query(collections[0], ['slug'], 'find-me');
expect(results).toEqual({
hits: [posts[0]],
query: 'find-me',
});
});
it('should query collections by path', async () => {
const results = await backend.query(collections[0], ['path'], 'posts/find-me.md');
expect(results).toEqual({
hits: [posts[0]],
query: 'posts/find-me.md',
});
});
it('should query collections by nested field', async () => {
const results = await backend.query(
collections[0],
['nested.title'],
'find me by nested title',
);
expect(results).toEqual({
hits: [posts[0]],
query: 'find me by nested title',
});
});
});
});

View File

@ -35,7 +35,7 @@ import {
blobToFileObj,
} from 'netlify-cms-lib-util';
import { status } from './constants/publishModes';
import { extractTemplateVars, dateParsers } from './lib/stringTemplate';
import { stringTemplate } from 'netlify-cms-lib-widgets';
import {
Collection,
EntryMap,
@ -50,6 +50,8 @@ import {
import AssetProxy from './valueObjects/AssetProxy';
import { FOLDER, FILES } from './constants/collectionTypes';
const { extractTemplateVars, dateParsers } = stringTemplate;
export class LocalStorageAuthStore {
storageKey = 'netlify-cms-user';
@ -76,7 +78,7 @@ function getEntryBackupKey(collectionName?: string, slug?: string) {
return `${baseKey}.${collectionName}${suffix}`;
}
const extractSearchFields = (searchFields: string[]) => (entry: EntryValue) =>
export const extractSearchFields = (searchFields: string[]) => (entry: EntryValue) =>
searchFields.reduce((acc, field) => {
const nestedFields = field.split('.');
let f = entry.data;
@ -84,7 +86,15 @@ const extractSearchFields = (searchFields: string[]) => (entry: EntryValue) =>
f = f[nestedFields[i]];
if (!f) break;
}
return f ? `${acc} ${f}` : acc;
if (f) {
return `${acc} ${f}`;
} else if (entry[nestedFields[0] as keyof EntryValue]) {
// allows searching using entry.slug/entry.path etc.
return `${acc} ${entry[nestedFields[0] as keyof EntryValue]}`;
} else {
return acc;
}
}, '');
const sortByScore = (a: fuzzy.FilterResult<EntryValue>, b: fuzzy.FilterResult<EntryValue>) => {

View File

@ -309,20 +309,39 @@ describe('formatters', () => {
);
});
it('should return preview url based on preview_path', () => {
it('should return preview url based on preview_path and preview_path_date_field', () => {
const date = new Date('2020-01-02T13:28:27.679Z');
expect(
previewUrlFormatter(
'https://www.example.com',
Map({
preview_path: '{{year}}/{{slug}}/{{title}}/{{fields.slug}}',
preview_path_date_field: 'date',
preview_path_date_field: 'customDateField',
}),
'backendSlug',
slugConfig,
Map({ data: Map({ customDateField: date, slug: 'entrySlug', title: 'title' }) }),
),
).toBe('https://www.example.com/2020/backendslug/title/entryslug');
});
it('should infer date field when preview_path_date_field is not configured', () => {
const { selectInferedField } = require('../../reducers/collections');
selectInferedField.mockReturnValue('date');
const date = new Date('2020-01-02T13:28:27.679Z');
expect(
previewUrlFormatter(
'https://www.example.com',
fromJS({
name: 'posts',
preview_path: '{{year}}/{{month}}/{{slug}}/{{title}}/{{fields.slug}}',
}),
'backendSlug',
slugConfig,
Map({ data: Map({ date, slug: 'entrySlug', title: 'title' }) }),
),
).toBe('https://www.example.com/2020/backendslug/title/entryslug');
).toBe('https://www.example.com/2020/01/backendslug/title/entryslug');
});
it('should compile filename and extension template values', () => {

View File

@ -1,115 +0,0 @@
import { fromJS } from 'immutable';
import {
keyToPathArray,
compileStringTemplate,
parseDateFromEntry,
extractTemplateVars,
} from '../stringTemplate';
describe('stringTemplate', () => {
describe('keyToPathArray', () => {
it('should return array of length 1 with simple path', () => {
expect(keyToPathArray('category')).toEqual(['category']);
});
it('should return path array for complex path', () => {
expect(keyToPathArray('categories[0].title.subtitles[0].welcome[2]')).toEqual([
'categories',
'0',
'title',
'subtitles',
'0',
'welcome',
'2',
]);
});
});
describe('parseDateFromEntry', () => {
it('should infer date field and return entry date', () => {
const date = new Date().toISOString();
const entry = fromJS({ data: { date } });
const collection = fromJS({ fields: [{ name: 'date', widget: 'date' }] });
expect(parseDateFromEntry(entry, collection).toISOString()).toBe(date);
});
it('should use supplied date field and return entry date', () => {
const date = new Date().toISOString();
const entry = fromJS({ data: { preview_date: date } });
expect(parseDateFromEntry(entry, null, 'preview_date').toISOString()).toBe(date);
});
it('should return undefined on non existing date', () => {
const entry = fromJS({ data: {} });
const collection = fromJS({ fields: [{}] });
expect(parseDateFromEntry(entry, collection)).toBeUndefined();
});
it('should return undefined on invalid date', () => {
const entry = fromJS({ data: { date: '' } });
const collection = fromJS({ fields: [{ name: 'date', widget: 'date' }] });
expect(parseDateFromEntry(entry, collection)).toBeUndefined();
});
});
describe('extractTemplateVars', () => {
it('should extract template variables', () => {
expect(extractTemplateVars('{{slug}}-hello-{{date}}-world-{{fields.id}}')).toEqual([
'slug',
'date',
'fields.id',
]);
});
it('should return empty array on no matches', () => {
expect(extractTemplateVars('hello-world')).toEqual([]);
});
});
describe('compileStringTemplate', () => {
const date = new Date('2020-01-02T13:28:27.679Z');
it('should compile year variable', () => {
expect(compileStringTemplate('{{year}}', date)).toBe('2020');
});
it('should compile month variable', () => {
expect(compileStringTemplate('{{month}}', date)).toBe('01');
});
it('should compile day variable', () => {
expect(compileStringTemplate('{{day}}', date)).toBe('02');
});
it('should compile hour variable', () => {
expect(compileStringTemplate('{{hour}}', date)).toBe('13');
});
it('should compile minute variable', () => {
expect(compileStringTemplate('{{minute}}', date)).toBe('28');
});
it('should compile second variable', () => {
expect(compileStringTemplate('{{second}}', date)).toBe('27');
});
it('should error on missing date', () => {
expect(() => compileStringTemplate('{{year}}')).toThrowError();
});
it('return compiled template', () => {
expect(
compileStringTemplate(
'{{slug}}-{{year}}-{{fields.slug}}-{{title}}-{{date}}',
date,
'backendSlug',
fromJS({ slug: 'entrySlug', title: 'title', date }),
),
).toBe('backendSlug-2020-entrySlug-title-' + date.toString());
});
it('return apply processor to values', () => {
expect(
compileStringTemplate('{{slug}}', date, 'slug', fromJS({}), value => value.toUpperCase()),
).toBe('SLUG');
});
});
});

View File

@ -1,16 +1,24 @@
import { Map } from 'immutable';
import { flow, partialRight, trimEnd, trimStart } from 'lodash';
import { sanitizeSlug } from './urlHelper';
import { stringTemplate } from 'netlify-cms-lib-widgets';
import {
selectIdentifier,
selectField,
COMMIT_AUTHOR,
COMMIT_DATE,
selectInferedField,
} from '../reducers/collections';
import { Collection, SlugConfig, Config, EntryMap } from '../types/redux';
import { stripIndent } from 'common-tags';
const {
compileStringTemplate,
parseDateFromEntry,
SLUG_MISSING_REQUIRED_DATE,
keyToPathArray,
} from './stringTemplate';
import { selectIdentifier, selectField, COMMIT_AUTHOR, COMMIT_DATE } from '../reducers/collections';
import { Collection, SlugConfig, Config, EntryMap } from '../types/redux';
import { stripIndent } from 'common-tags';
import { basename, fileExtension } from 'netlify-cms-lib-util';
addFileTemplateFields,
} = stringTemplate;
const commitMessageTemplates = Map({
create: 'Create {{collection}} “{{slug}}”',
@ -122,21 +130,6 @@ export const slugFormatter = (
}
};
const addFileTemplateFields = (entryPath: string, fields: Map<string, string>) => {
if (!entryPath) {
return fields;
}
const extension = fileExtension(entryPath);
const filename = basename(entryPath, `.${extension}`);
fields = fields.withMutations(map => {
map.set('filename', filename);
map.set('extension', extension);
});
return fields;
};
export const previewUrlFormatter = (
baseUrl: string,
collection: Collection,
@ -168,7 +161,9 @@ export const previewUrlFormatter = (
const pathTemplate = collection.get('preview_path') as string;
let fields = entry.get('data') as Map<string, string>;
fields = addFileTemplateFields(entry.get('path'), fields);
const date = parseDateFromEntry(entry, collection, collection.get('preview_path_date_field'));
const dateFieldName =
collection.get('preview_path_date_field') || selectInferedField(collection, 'date');
const date = parseDateFromEntry((entry as unknown) as Map<string, unknown>, dateFieldName);
// Prepare and sanitize slug variables only, leave the rest of the
// `preview_path` template as is.
@ -201,7 +196,11 @@ export const summaryFormatter = (
collection: Collection,
) => {
let entryData = entry.get('data');
const date = parseDateFromEntry(entry, collection) || null;
const date =
parseDateFromEntry(
(entry as unknown) as Map<string, unknown>,
selectInferedField(collection, 'date'),
) || null;
const identifier = entryData.getIn(keyToPathArray(selectIdentifier(collection) as string));
entryData = addFileTemplateFields(entry.get('path'), entryData);
@ -231,7 +230,11 @@ export const folderFormatter = (
let fields = (entry.get('data') as Map<string, string>).set(folderKey, defaultFolder);
fields = addFileTemplateFields(entry.get('path'), fields);
const date = parseDateFromEntry(entry, collection) || null;
const date =
parseDateFromEntry(
(entry as unknown) as Map<string, unknown>,
selectInferedField(collection, 'date'),
) || null;
const identifier = fields.getIn(keyToPathArray(selectIdentifier(collection) as string));
const processSegment = getProcessSegment(slugConfig);

View File

@ -1,132 +0,0 @@
import moment from 'moment';
import { Map } from 'immutable';
import { selectInferedField } from '../reducers/collections';
import { EntryMap, Collection } from '../types/redux';
// prepends a Zero if the date has only 1 digit
function formatDate(date: number) {
return `0${date}`.slice(-2);
}
export const dateParsers: Record<string, (date: Date) => string> = {
year: (date: Date) => `${date.getUTCFullYear()}`,
month: (date: Date) => formatDate(date.getUTCMonth() + 1),
day: (date: Date) => formatDate(date.getUTCDate()),
hour: (date: Date) => formatDate(date.getUTCHours()),
minute: (date: Date) => formatDate(date.getUTCMinutes()),
second: (date: Date) => formatDate(date.getUTCSeconds()),
};
export const SLUG_MISSING_REQUIRED_DATE = 'SLUG_MISSING_REQUIRED_DATE';
const FIELD_PREFIX = 'fields.';
const templateContentPattern = '[^}{]+';
const templateVariablePattern = `{{(${templateContentPattern})}}`;
export const keyToPathArray = (key?: string) => {
if (!key) {
return [];
}
const parts = [];
const separator = '';
const chars = key.split(separator);
let currentChar;
let currentStr = [];
while ((currentChar = chars.shift())) {
if (['[', ']', '.'].includes(currentChar)) {
if (currentStr.length > 0) {
parts.push(currentStr.join(separator));
}
currentStr = [];
} else {
currentStr.push(currentChar);
}
}
if (currentStr.length > 0) {
parts.push(currentStr.join(separator));
}
return parts;
};
// 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: string, data: Map<string, unknown>) {
if (!key.startsWith(FIELD_PREFIX)) {
return;
}
const fieldName = key.substring(FIELD_PREFIX.length);
return data.getIn(keyToPathArray(fieldName), '') as string;
}
export function parseDateFromEntry(entry: EntryMap, collection: Collection, fieldName?: string) {
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: string,
date: Date | undefined | null,
identifier = '',
data = Map<string, unknown>(),
processor?: (value: string) => string,
) {
let missingRequiredDate;
// Turn off date processing (support for replacements like `{{year}}`), by passing in
// `null` as the date arg.
const useDate = date !== null;
const compiledString = template.replace(
RegExp(templateVariablePattern, 'g'),
(_, key: string) => {
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 as Date);
} else if (key === 'slug') {
replacement = identifier;
} else {
replacement = data.getIn(keyToPathArray(key), '') as string;
}
if (processor) {
return processor(replacement);
}
return replacement;
},
);
if (useDate && missingRequiredDate) {
const err = new Error();
err.name = SLUG_MISSING_REQUIRED_DATE;
throw err;
} else {
return compiledString;
}
}
export function extractTemplateVars(template: string) {
const regexp = RegExp(templateVariablePattern, 'g');
const contentRegexp = RegExp(templateContentPattern, 'g');
const matches = template.match(regexp) || [];
return matches.map(elem => {
const match = elem.match(contentRegexp);
return match ? match[0] : '';
});
}

View File

@ -14,10 +14,12 @@ import {
EntryMap,
} from '../types/redux';
import { selectMediaFolder } from './entries';
import { keyToPathArray } from '../lib/stringTemplate';
import { stringTemplate } from 'netlify-cms-lib-widgets';
import { summaryFormatter } from '../lib/formatters';
import { Backend } from '../backend';
const { keyToPathArray } = stringTemplate;
const collections = (state = null, action: CollectionsAction) => {
switch (action.type) {
case CONFIG_SUCCESS: {