feat(widget-relation): string templates support (#3659)
This commit is contained in:
parent
02f3cdd102
commit
213ae86b54
@ -5,6 +5,7 @@ module.exports = {
|
||||
'netlify-cms-lib-util': '<rootDir>/packages/netlify-cms-lib-util/src/index.ts',
|
||||
'netlify-cms-ui-default': '<rootDir>/packages/netlify-cms-ui-default/src/index.js',
|
||||
'netlify-cms-backend-github': '<rootDir>/packages/netlify-cms-backend-github/src/index.ts',
|
||||
'netlify-cms-lib-widgets': '<rootDir>/packages/netlify-cms-lib-widgets/src/index.ts',
|
||||
},
|
||||
testURL: 'http://localhost:8080',
|
||||
};
|
||||
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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>) => {
|
||||
|
@ -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', () => {
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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: {
|
||||
|
11
packages/netlify-cms-lib-widgets/README.md
Normal file
11
packages/netlify-cms-lib-widgets/README.md
Normal file
@ -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)!
|
24
packages/netlify-cms-lib-widgets/package.json
Normal file
24
packages/netlify-cms-lib-widgets/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
6
packages/netlify-cms-lib-widgets/src/index.ts
Normal file
6
packages/netlify-cms-lib-widgets/src/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import * as stringTemplate from './stringTemplate';
|
||||
|
||||
export const NetlifyCmsLibWidgets = {
|
||||
stringTemplate,
|
||||
};
|
||||
export { stringTemplate };
|
@ -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, (date: Date) => string> = {
|
||||
second: (date: Date) => formatDate(date.getUTCSeconds()),
|
||||
};
|
||||
|
||||
export const SLUG_MISSING_REQUIRED_DATE = 'SLUG_MISSING_REQUIRED_DATE';
|
||||
export function parseDateFromEntry(entry: Map<string, unknown>, 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<string, unknown>) {
|
||||
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<string, string>) => {
|
||||
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;
|
||||
};
|
3
packages/netlify-cms-lib-widgets/webpack.config.js
Normal file
3
packages/netlify-cms-lib-widgets/webpack.config.js
Normal file
@ -0,0 +1,3 @@
|
||||
const { getConfig } = require('../../scripts/webpack.js');
|
||||
|
||||
module.exports = getConfig();
|
@ -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"
|
||||
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
@ -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 });
|
||||
|
@ -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/**/*"],
|
||||
|
@ -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.
|
||||
|
Loading…
x
Reference in New Issue
Block a user