From 213ae86b54d02f5fc79fe11113507587ed062ff2 Mon Sep 17 00:00:00 2001 From: Erez Rokah Date: Thu, 30 Apr 2020 16:03:08 +0300 Subject: [PATCH] feat(widget-relation): string templates support (#3659) --- jest.config.js | 1 + .../src/__tests__/backend.spec.js | 179 +++++++++++++++++- packages/netlify-cms-core/src/backend.ts | 16 +- .../src/lib/__tests__/formatters.spec.js | 25 ++- .../netlify-cms-core/src/lib/formatters.ts | 49 ++--- .../src/reducers/collections.ts | 4 +- packages/netlify-cms-lib-widgets/README.md | 11 ++ packages/netlify-cms-lib-widgets/package.json | 24 +++ .../src}/__tests__/stringTemplate.spec.js | 25 +-- packages/netlify-cms-lib-widgets/src/index.ts | 6 + .../src}/stringTemplate.ts | 55 ++++-- .../netlify-cms-lib-widgets/webpack.config.js | 3 + .../netlify-cms-widget-relation/package.json | 1 + .../src/RelationControl.js | 27 ++- .../src/__tests__/relation.spec.js | 28 ++- tsconfig.json | 3 +- website/content/docs/widgets/relation.md | 44 +++-- 17 files changed, 406 insertions(+), 95 deletions(-) create mode 100644 packages/netlify-cms-lib-widgets/README.md create mode 100644 packages/netlify-cms-lib-widgets/package.json rename packages/{netlify-cms-core/src/lib => netlify-cms-lib-widgets/src}/__tests__/stringTemplate.spec.js (76%) create mode 100644 packages/netlify-cms-lib-widgets/src/index.ts rename packages/{netlify-cms-core/src/lib => netlify-cms-lib-widgets/src}/stringTemplate.ts (83%) create mode 100644 packages/netlify-cms-lib-widgets/webpack.config.js diff --git a/jest.config.js b/jest.config.js index 55a25850..9af8ba26 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,6 +5,7 @@ module.exports = { 'netlify-cms-lib-util': '/packages/netlify-cms-lib-util/src/index.ts', 'netlify-cms-ui-default': '/packages/netlify-cms-ui-default/src/index.js', 'netlify-cms-backend-github': '/packages/netlify-cms-backend-github/src/index.ts', + 'netlify-cms-lib-widgets': '/packages/netlify-cms-lib-widgets/src/index.ts', }, testURL: 'http://localhost:8080', }; diff --git a/packages/netlify-cms-core/src/__tests__/backend.spec.js b/packages/netlify-cms-core/src/__tests__/backend.spec.js index 1aed5f90..7729afe9 100644 --- a/packages/netlify-cms-core/src/__tests__/backend.spec.js +++ b/packages/netlify-cms-core/src/__tests__/backend.spec.js @@ -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', + }); + }); + }); }); diff --git a/packages/netlify-cms-core/src/backend.ts b/packages/netlify-cms-core/src/backend.ts index 8310b056..0986ad78 100644 --- a/packages/netlify-cms-core/src/backend.ts +++ b/packages/netlify-cms-core/src/backend.ts @@ -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, b: fuzzy.FilterResult) => { diff --git a/packages/netlify-cms-core/src/lib/__tests__/formatters.spec.js b/packages/netlify-cms-core/src/lib/__tests__/formatters.spec.js index 303564ae..ed2459a1 100644 --- a/packages/netlify-cms-core/src/lib/__tests__/formatters.spec.js +++ b/packages/netlify-cms-core/src/lib/__tests__/formatters.spec.js @@ -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', () => { diff --git a/packages/netlify-cms-core/src/lib/formatters.ts b/packages/netlify-cms-core/src/lib/formatters.ts index 04012f05..11aa1b66 100644 --- a/packages/netlify-cms-core/src/lib/formatters.ts +++ b/packages/netlify-cms-core/src/lib/formatters.ts @@ -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) => { - 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; 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, 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, + 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).set(folderKey, defaultFolder); fields = addFileTemplateFields(entry.get('path'), fields); - const date = parseDateFromEntry(entry, collection) || null; + const date = + parseDateFromEntry( + (entry as unknown) as Map, + selectInferedField(collection, 'date'), + ) || null; const identifier = fields.getIn(keyToPathArray(selectIdentifier(collection) as string)); const processSegment = getProcessSegment(slugConfig); diff --git a/packages/netlify-cms-core/src/reducers/collections.ts b/packages/netlify-cms-core/src/reducers/collections.ts index eb0c4e8c..94fc4025 100644 --- a/packages/netlify-cms-core/src/reducers/collections.ts +++ b/packages/netlify-cms-core/src/reducers/collections.ts @@ -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: { diff --git a/packages/netlify-cms-lib-widgets/README.md b/packages/netlify-cms-lib-widgets/README.md new file mode 100644 index 00000000..5daf1a56 --- /dev/null +++ b/packages/netlify-cms-lib-widgets/README.md @@ -0,0 +1,11 @@ +# Docs coming soon! + +Netlify CMS was recently converted from a single npm package to a "monorepo" of over 20 packages. +That's over 20 Readme's! We haven't created one for this package yet, but we will soon. + +In the meantime, you can: + +1. Check out the [main readme](https://github.com/netlify/netlify-cms/#readme) or the [documentation + site](https://www.netlifycms.org) for more info. +2. Reach out to the [community chat](https://netlifycms.org/chat/) if you need help. +3. Help out and [write the readme yourself](https://github.com/netlify/netlify-cms/edit/master/packages/netlify-cms-lib-widgets/README.md)! diff --git a/packages/netlify-cms-lib-widgets/package.json b/packages/netlify-cms-lib-widgets/package.json new file mode 100644 index 00000000..675fd82a --- /dev/null +++ b/packages/netlify-cms-lib-widgets/package.json @@ -0,0 +1,24 @@ +{ + "name": "netlify-cms-lib-widgets", + "description": "Shared utilities for Netlify CMS.", + "version": "1.0.0", + "repository": "https://github.com/netlify/netlify-cms/tree/master/packages/netlify-cms-lib-widgets", + "bugs": "https://github.com/netlify/netlify-cms/issues", + "module": "dist/esm/index.js", + "main": "dist/netlify-cms-lib-widgets.js", + "license": "MIT", + "keywords": [ + "netlify-cms" + ], + "sideEffects": false, + "scripts": { + "develop": "yarn build:esm --watch", + "build": "cross-env NODE_ENV=production webpack", + "build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward --extensions \".js,.jsx,.ts,.tsx\"" + }, + "dependencies": {}, + "peerDependencies": { + "immutable": "^3.7.6", + "moment": "^2.24.0" + } +} diff --git a/packages/netlify-cms-core/src/lib/__tests__/stringTemplate.spec.js b/packages/netlify-cms-lib-widgets/src/__tests__/stringTemplate.spec.js similarity index 76% rename from packages/netlify-cms-core/src/lib/__tests__/stringTemplate.spec.js rename to packages/netlify-cms-lib-widgets/src/__tests__/stringTemplate.spec.js index 55f271e4..52b42889 100644 --- a/packages/netlify-cms-core/src/lib/__tests__/stringTemplate.spec.js +++ b/packages/netlify-cms-lib-widgets/src/__tests__/stringTemplate.spec.js @@ -25,29 +25,24 @@ describe('stringTemplate', () => { }); describe('parseDateFromEntry', () => { - it('should infer date field and return entry date', () => { + it('should return date based on dateFieldName', () => { 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); + const dateFieldName = 'dateFieldName'; + const entry = fromJS({ data: { dateFieldName: date } }); + expect(parseDateFromEntry(entry, dateFieldName).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', () => { + it('should return undefined on empty dateFieldName', () => { const entry = fromJS({ data: {} }); - const collection = fromJS({ fields: [{}] }); - expect(parseDateFromEntry(entry, collection)).toBeUndefined(); + expect(parseDateFromEntry(entry, '')).toBeUndefined(); + expect(parseDateFromEntry(entry, null)).toBeUndefined(); + expect(parseDateFromEntry(entry, undefined)).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(); + const dateFieldName = 'date'; + expect(parseDateFromEntry(entry, dateFieldName)).toBeUndefined(); }); }); diff --git a/packages/netlify-cms-lib-widgets/src/index.ts b/packages/netlify-cms-lib-widgets/src/index.ts new file mode 100644 index 00000000..23526989 --- /dev/null +++ b/packages/netlify-cms-lib-widgets/src/index.ts @@ -0,0 +1,6 @@ +import * as stringTemplate from './stringTemplate'; + +export const NetlifyCmsLibWidgets = { + stringTemplate, +}; +export { stringTemplate }; diff --git a/packages/netlify-cms-core/src/lib/stringTemplate.ts b/packages/netlify-cms-lib-widgets/src/stringTemplate.ts similarity index 83% rename from packages/netlify-cms-core/src/lib/stringTemplate.ts rename to packages/netlify-cms-lib-widgets/src/stringTemplate.ts index 5f3caff9..d0dbbdf5 100644 --- a/packages/netlify-cms-core/src/lib/stringTemplate.ts +++ b/packages/netlify-cms-lib-widgets/src/stringTemplate.ts @@ -1,7 +1,10 @@ import moment from 'moment'; import { Map } from 'immutable'; -import { selectInferedField } from '../reducers/collections'; -import { EntryMap, Collection } from '../types/redux'; +import { basename, extname } from 'path'; + +const FIELD_PREFIX = 'fields.'; +const templateContentPattern = '[^}{]+'; +const templateVariablePattern = `{{(${templateContentPattern})}}`; // prepends a Zero if the date has only 1 digit function formatDate(date: number) { @@ -17,11 +20,19 @@ export const dateParsers: Record string> = { second: (date: Date) => formatDate(date.getUTCSeconds()), }; -export const SLUG_MISSING_REQUIRED_DATE = 'SLUG_MISSING_REQUIRED_DATE'; +export function parseDateFromEntry(entry: Map, dateFieldName?: string | null) { + if (!dateFieldName) { + return; + } -const FIELD_PREFIX = 'fields.'; -const templateContentPattern = '[^}{]+'; -const templateVariablePattern = `{{(${templateContentPattern})}}`; + const dateValue = entry.getIn(['data', dateFieldName]); + const dateMoment = dateValue && moment(dateValue); + if (dateMoment && dateMoment.isValid()) { + return dateMoment.toDate(); + } +} + +export const SLUG_MISSING_REQUIRED_DATE = 'SLUG_MISSING_REQUIRED_DATE'; export const keyToPathArray = (key?: string) => { if (!key) { @@ -56,20 +67,11 @@ function getExplicitFieldReplacement(key: string, data: Map) { 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(); + const value = data.getIn(keyToPathArray(fieldName)); + if (typeof value === 'object' && value !== null) { + return JSON.stringify(value); } + return value; } export function compileStringTemplate( @@ -130,3 +132,18 @@ export function extractTemplateVars(template: string) { return match ? match[0] : ''; }); } + +export const addFileTemplateFields = (entryPath: string, fields: Map) => { + if (!entryPath) { + return fields; + } + + const extension = extname(entryPath); + const filename = basename(entryPath, extension); + fields = fields.withMutations(map => { + map.set('filename', filename); + map.set('extension', extension === '' ? extension : extension.substr(1)); + }); + + return fields; +}; diff --git a/packages/netlify-cms-lib-widgets/webpack.config.js b/packages/netlify-cms-lib-widgets/webpack.config.js new file mode 100644 index 00000000..42edd361 --- /dev/null +++ b/packages/netlify-cms-lib-widgets/webpack.config.js @@ -0,0 +1,3 @@ +const { getConfig } = require('../../scripts/webpack.js'); + +module.exports = getConfig(); diff --git a/packages/netlify-cms-widget-relation/package.json b/packages/netlify-cms-widget-relation/package.json index a3636331..9a778a8f 100644 --- a/packages/netlify-cms-widget-relation/package.json +++ b/packages/netlify-cms-widget-relation/package.json @@ -30,6 +30,7 @@ "immutable": "^3.7.6", "lodash": "^4.17.11", "netlify-cms-ui-default": "^2.6.0", + "netlify-cms-lib-widgets": "^1.0.0", "prop-types": "^15.7.2", "react": "^16.8.4", "uuid": "^3.3.2" diff --git a/packages/netlify-cms-widget-relation/src/RelationControl.js b/packages/netlify-cms-widget-relation/src/RelationControl.js index b4cf4480..3d7ab4b9 100644 --- a/packages/netlify-cms-widget-relation/src/RelationControl.js +++ b/packages/netlify-cms-widget-relation/src/RelationControl.js @@ -5,6 +5,7 @@ import { Async as AsyncSelect } from 'react-select'; import { find, isEmpty, last, debounce } from 'lodash'; import { List, Map, fromJS } from 'immutable'; import { reactSelectStyles } from 'netlify-cms-ui-default'; +import { stringTemplate } from 'netlify-cms-lib-widgets'; function optionToString(option) { return option && option.value ? option.value : ''; @@ -72,7 +73,7 @@ export default class RelationControl extends React.Component { if (value) { const listValue = List.isList(value) ? value : List([value]); listValue.forEach(val => { - const hit = hits.find(i => this.parseNestedFields(i.data, valueField) === val); + const hit = hits.find(hit => this.parseNestedFields(hit, valueField) === val); if (hit) { onChange(value, { [field.get('name')]: { @@ -113,17 +114,15 @@ export default class RelationControl extends React.Component { } }; - parseNestedFields = (targetObject, field) => { - const nestedField = field.split('.'); - let f = targetObject; - for (let i = 0; i < nestedField.length; i++) { - f = f[nestedField[i]]; - if (!f) break; + parseNestedFields = (hit, field) => { + const templateVars = stringTemplate.extractTemplateVars(field); + // wrap non template fields with a template + if (templateVars.length <= 0) { + field = `{{fields.${field}}}`; } - if (typeof f === 'object' && f !== null) { - return JSON.stringify(f); - } - return f; + const data = stringTemplate.addFileTemplateFields(hit.path, fromJS(hit.data)); + const value = stringTemplate.compileStringTemplate(field, null, hit.slug, data); + return value; }; parseHitOptions = hits => { @@ -136,14 +135,14 @@ export default class RelationControl extends React.Component { if (List.isList(displayField)) { labelReturn = displayField .toJS() - .map(key => this.parseNestedFields(hit.data, key)) + .map(key => this.parseNestedFields(hit, key)) .join(' '); } else { - labelReturn = this.parseNestedFields(hit.data, displayField); + labelReturn = this.parseNestedFields(hit, displayField); } return { data: hit.data, - value: this.parseNestedFields(hit.data, valueField), + value: this.parseNestedFields(hit, valueField), label: labelReturn, }; }); diff --git a/packages/netlify-cms-widget-relation/src/__tests__/relation.spec.js b/packages/netlify-cms-widget-relation/src/__tests__/relation.spec.js index d139209e..5919dc4d 100644 --- a/packages/netlify-cms-widget-relation/src/__tests__/relation.spec.js +++ b/packages/netlify-cms-widget-relation/src/__tests__/relation.spec.js @@ -43,7 +43,8 @@ const generateHits = length => { const hits = Array.from({ length }, (val, idx) => { const title = `Post # ${idx + 1}`; const slug = `post-number-${idx + 1}`; - return { collection: 'posts', data: { title, slug } }; + const path = `posts/${slug}.md`; + return { collection: 'posts', data: { title, slug }, slug, path }; }); return [ @@ -247,6 +248,31 @@ describe('Relation widget', () => { }); }); + it('should handle string templates', async () => { + const stringTemplateConfig = { + name: 'post', + collection: 'posts', + displayFields: ['{{slug}}', '{{filename}}', '{{extension}}'], + searchFields: ['slug'], + valueField: '{{slug}}', + }; + + const field = fromJS(stringTemplateConfig); + const { getByText, input, onChangeSpy } = setup({ field }); + const value = 'post-number-1'; + const label = 'post-number-1 post-number-1 md'; + const metadata = { + post: { posts: { 'post-number-1': { title: 'Post # 1', slug: 'post-number-1' } } }, + }; + + await wait(() => { + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.click(getByText(label)); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeSpy).toHaveBeenCalledWith(value, metadata); + }); + }); + describe('with multiple', () => { it('should call onChange with correct selectedItem value and metadata', async () => { const field = fromJS({ ...fieldConfig, multiple: true }); diff --git a/tsconfig.json b/tsconfig.json index e17a076b..747a5c7c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,8 @@ "netlify-cms-backend-github": ["packages/netlify-cms-backend-github/src"], "netlify-cms-backend-gitlab": ["packages/netlify-cms-backend-gitlab/src"], "netlify-cms-backend-bitbucket": ["packages/netlify-cms-backend-bitbucket/src"], - "netlify-cms-lib-util": ["packages/netlify-cms-lib-util/src"] + "netlify-cms-lib-util": ["packages/netlify-cms-lib-util/src"], + "netlify-cms-lib-widgets": ["packages/netlify-cms-lib-widgets/src"] } }, "include": ["**/src/**/*"], diff --git a/website/content/docs/widgets/relation.md b/website/content/docs/widgets/relation.md index 5a9820c0..b1aba5dd 100644 --- a/website/content/docs/widgets/relation.md +++ b/website/content/docs/widgets/relation.md @@ -9,21 +9,37 @@ The relation widget allows you to reference items from another collection. It pr - **UI:** text input with search result dropdown - **Data type:** data type of the value pulled from the related collection item - **Options:** - - `default`: accepts any widget data type; defaults to an empty string - `collection`: (**required**) name of the collection being referenced (string) - - `displayFields`: list of one or more names of fields in the referenced collection that will render in the autocomplete menu of the control. Defaults to `valueField`. For nested fields, separate each subfield with a `.` (E.g. `name.first`). - - `searchFields`: (**required**) list of one or more names of fields in the referenced collection to search for the typed value. Syntax to reference nested fields is similar to that of *displayFields*. - - `valueField`: (**required**) name of the field from the referenced collection whose value will be stored for the relation. Syntax to reference nested fields is similar to that of *displayFields* and *searchFields*. As `valueField` only allows for a single field, this parameter only accepts a string. + - `valueField`: (**required**) name of the field from the referenced collection whose value will be stored for the relation. For nested fields, separate each subfield with a `.` (E.g. `name.first`). + - `searchFields`: (**required**) list of one or more names of fields in the referenced collection to search for the typed value. Syntax to reference nested fields is similar to that of *valueField*. + - `displayFields`: list of one or more names of fields in the referenced collection that will render in the autocomplete menu of the control. Defaults to `valueField`. Syntax to reference nested fields is similar to that of *valueField*. + - `default`: accepts any widget data type; defaults to an empty string - `multiple` : accepts a boolean, defaults to `false` - `optionsLength`: accepts integer to override number of options presented to user. Defaults to `20`. - **Example** (assuming a separate "authors" collection with "name" and "twitterHandle" fields with subfields "first" and "last" for the "name" field): - ```yaml - - label: "Post Author" - name: "author" - widget: "relation" - collection: "authors" - searchFields: ["name.first", "twitterHandle"] - valueField: "name.first" - displayFields: ["twitterHandle", "followerCount"] - ``` - The generated UI input will search the authors collection by name and twitterHandle, and display each author's handle and follower count. On selection, the author name will be saved for the field. + +```yaml +- label: "Post Author" + name: "author" + widget: "relation" + collection: "authors" + searchFields: ["name.first", "twitterHandle"] + valueField: "name.first" + displayFields: ["twitterHandle", "followerCount"] +``` + +The generated UI input will search the authors collection by name and twitterHandle, and display each author's handle and follower count. On selection, the author name will be saved for the field. + +- **Note:** `valueField` and `displayFields` support string templates. For example: + +```yaml +- label: "Post Author" + name: "author" + widget: "relation" + collection: "authors" + searchFields: ['name.first'] + valueField: "{{slug}}" + displayFields: ["{{twitterHandle}} - {{followerCount}}"] +``` + +The generated UI input will search the authors collection by name, and display each author's handle and follower count. On selection, the author entry slug will be saved for the field.