feat: add widgets schema validation (#3841)
This commit is contained in:
@ -1,6 +1,8 @@
|
||||
import { merge } from 'lodash';
|
||||
import { validateConfig } from '../configSchema';
|
||||
|
||||
jest.mock('../../lib/registry');
|
||||
|
||||
describe('config', () => {
|
||||
/**
|
||||
* Suppress error logging to reduce noise during testing. Jest will still
|
||||
@ -10,6 +12,9 @@ describe('config', () => {
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
const { getWidgets } = require('../../lib/registry');
|
||||
getWidgets.mockImplementation(() => [{}]);
|
||||
|
||||
describe('validateConfig', () => {
|
||||
const validConfig = {
|
||||
foo: 'bar',
|
||||
@ -234,5 +239,82 @@ describe('config', () => {
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
describe('nested validation', () => {
|
||||
const { getWidgets } = require('../../lib/registry');
|
||||
getWidgets.mockImplementation(() => [
|
||||
{
|
||||
name: 'relation',
|
||||
schema: {
|
||||
properties: {
|
||||
searchFields: { type: 'array', items: { type: 'string' } },
|
||||
displayFields: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
it('should throw if nested relation displayFields and searchFields are not arrays', () => {
|
||||
expect(() => {
|
||||
validateConfig(
|
||||
merge(validConfig, {
|
||||
collections: [
|
||||
{
|
||||
fields: [
|
||||
{ name: 'title', label: 'title', widget: 'string' },
|
||||
{
|
||||
name: 'object',
|
||||
label: 'Object',
|
||||
widget: 'object',
|
||||
fields: [
|
||||
{ name: 'title', label: 'title', widget: 'string' },
|
||||
{
|
||||
name: 'relation',
|
||||
label: 'relation',
|
||||
widget: 'relation',
|
||||
displayFields: 'title',
|
||||
searchFields: 'title',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}).toThrowError("'searchFields' should be array\n'displayFields' should be array");
|
||||
});
|
||||
|
||||
it('should not throw if nested relation displayFields and searchFields are arrays', () => {
|
||||
expect(() => {
|
||||
validateConfig(
|
||||
merge(validConfig, {
|
||||
collections: [
|
||||
{
|
||||
fields: [
|
||||
{ name: 'title', label: 'title', widget: 'string' },
|
||||
{
|
||||
name: 'object',
|
||||
label: 'Object',
|
||||
widget: 'object',
|
||||
fields: [
|
||||
{ name: 'title', label: 'title', widget: 'string' },
|
||||
{
|
||||
name: 'relation',
|
||||
label: 'relation',
|
||||
widget: 'relation',
|
||||
displayFields: ['title'],
|
||||
searchFields: ['title'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,27 +1,39 @@
|
||||
import AJV from 'ajv';
|
||||
import uniqueItemProperties from 'ajv-keywords/keywords/uniqueItemProperties';
|
||||
import { select, uniqueItemProperties } from 'ajv-keywords/keywords';
|
||||
import ajvErrors from 'ajv-errors';
|
||||
import { formatExtensions, frontmatterFormats, extensionFormatters } from 'Formats/formats';
|
||||
import { getWidgets } from 'Lib/registry';
|
||||
|
||||
/**
|
||||
* Config for fields in both file and folder collections.
|
||||
*/
|
||||
const fieldsConfig = {
|
||||
const fieldsConfig = () => ({
|
||||
$id: 'fields',
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
items: {
|
||||
// ------- Each field: -------
|
||||
$id: 'field',
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
label: { type: 'string' },
|
||||
widget: { type: 'string' },
|
||||
required: { type: 'boolean' },
|
||||
hint: { type: 'string' },
|
||||
pattern: { type: 'array', minItems: 2, items: { type: 'string' } },
|
||||
field: { $ref: 'field' },
|
||||
fields: { $ref: 'fields' },
|
||||
types: { $ref: 'fields' },
|
||||
},
|
||||
select: { $data: '0/widget' },
|
||||
selectCases: {
|
||||
...getWidgetSchemas(),
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
uniqueItemProperties: ['name'],
|
||||
};
|
||||
});
|
||||
|
||||
const viewFilters = {
|
||||
type: 'array',
|
||||
@ -133,7 +145,7 @@ const getConfigSchema = () => ({
|
||||
label_singular: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
file: { type: 'string' },
|
||||
fields: fieldsConfig,
|
||||
fields: fieldsConfig(),
|
||||
},
|
||||
required: ['name', 'label', 'file', 'fields'],
|
||||
},
|
||||
@ -163,7 +175,7 @@ const getConfigSchema = () => ({
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
fields: fieldsConfig,
|
||||
fields: fieldsConfig(),
|
||||
sortableFields: {
|
||||
type: 'array',
|
||||
items: {
|
||||
@ -199,6 +211,11 @@ const getConfigSchema = () => ({
|
||||
anyOf: [{ required: ['media_folder'] }, { required: ['media_library'] }],
|
||||
});
|
||||
|
||||
function getWidgetSchemas() {
|
||||
const schemas = getWidgets().map(widget => ({ [widget.name]: widget.schema }));
|
||||
return Object.assign(...schemas);
|
||||
}
|
||||
|
||||
class ConfigError extends Error {
|
||||
constructor(errors, ...args) {
|
||||
const message = errors
|
||||
@ -228,8 +245,9 @@ class ConfigError extends Error {
|
||||
* the config that is passed in.
|
||||
*/
|
||||
export function validateConfig(config) {
|
||||
const ajv = new AJV({ allErrors: true, jsonPointers: true });
|
||||
const ajv = new AJV({ allErrors: true, jsonPointers: true, $data: true });
|
||||
uniqueItemProperties(ajv);
|
||||
select(ajv);
|
||||
ajvErrors(ajv);
|
||||
|
||||
const valid = ajv.validate(getConfigSchema(), config);
|
||||
|
@ -82,7 +82,7 @@ export function getPreviewTemplate(name) {
|
||||
/**
|
||||
* Editor Widgets
|
||||
*/
|
||||
export function registerWidget(name, control, preview) {
|
||||
export function registerWidget(name, control, preview, schema = {}) {
|
||||
if (Array.isArray(name)) {
|
||||
name.forEach(widget => {
|
||||
if (typeof widget !== 'object') {
|
||||
@ -95,12 +95,13 @@ export function registerWidget(name, control, preview) {
|
||||
// A registered widget control can be reused by a new widget, allowing
|
||||
// multiple copies with different previews.
|
||||
const newControl = typeof control === 'string' ? registry.widgets[control].control : control;
|
||||
registry.widgets[name] = { control: newControl, preview };
|
||||
registry.widgets[name] = { control: newControl, preview, schema };
|
||||
} else if (typeof name === 'object') {
|
||||
const {
|
||||
name: widgetName,
|
||||
controlComponent: control,
|
||||
previewComponent: preview,
|
||||
schema = {},
|
||||
allowMapValue,
|
||||
globalStyles,
|
||||
...options
|
||||
@ -114,7 +115,14 @@ export function registerWidget(name, control, preview) {
|
||||
if (!control) {
|
||||
throw Error(`Widget "${widgetName}" registered without \`controlComponent\`.`);
|
||||
}
|
||||
registry.widgets[widgetName] = { control, preview, globalStyles, allowMapValue, ...options };
|
||||
registry.widgets[widgetName] = {
|
||||
control,
|
||||
preview,
|
||||
schema,
|
||||
globalStyles,
|
||||
allowMapValue,
|
||||
...options,
|
||||
};
|
||||
} else {
|
||||
console.error('`registerWidget` failed, called with incorrect arguments.');
|
||||
}
|
||||
|
@ -1,10 +1,12 @@
|
||||
import controlComponent from './CodeControl';
|
||||
import previewComponent from './CodePreview';
|
||||
import schema from './schema';
|
||||
|
||||
const Widget = (opts = {}) => ({
|
||||
name: 'code',
|
||||
controlComponent,
|
||||
previewComponent,
|
||||
schema,
|
||||
allowMapValue: true,
|
||||
codeMirrorConfig: {},
|
||||
...opts,
|
||||
|
11
packages/netlify-cms-widget-code/src/schema.js
Normal file
11
packages/netlify-cms-widget-code/src/schema.js
Normal file
@ -0,0 +1,11 @@
|
||||
export default {
|
||||
properties: {
|
||||
default_language: { type: 'string' },
|
||||
allow_language_selection: { type: 'boolean' },
|
||||
output_code_only: { type: 'boolean' },
|
||||
keys: {
|
||||
type: 'object',
|
||||
properties: { code: { type: 'string' }, lang: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
};
|
@ -1,10 +1,12 @@
|
||||
import controlComponent from './DateTimeControl';
|
||||
import previewComponent from './DateTimePreview';
|
||||
import schema from './schema';
|
||||
|
||||
const Widget = (opts = {}) => ({
|
||||
name: 'datetime',
|
||||
controlComponent,
|
||||
previewComponent,
|
||||
schema,
|
||||
...opts,
|
||||
});
|
||||
|
||||
|
8
packages/netlify-cms-widget-datetime/src/schema.js
Normal file
8
packages/netlify-cms-widget-datetime/src/schema.js
Normal file
@ -0,0 +1,8 @@
|
||||
export default {
|
||||
properties: {
|
||||
format: { type: 'string' },
|
||||
dateFormat: { oneOf: [{ type: 'string' }, { type: 'boolean' }] },
|
||||
timeFormat: { oneOf: [{ type: 'string' }, { type: 'boolean' }] },
|
||||
pickerUtc: { type: 'boolean' },
|
||||
},
|
||||
};
|
@ -1,11 +1,13 @@
|
||||
import withFileControl from './withFileControl';
|
||||
import previewComponent from './FilePreview';
|
||||
import schema from './schema';
|
||||
|
||||
const controlComponent = withFileControl();
|
||||
const Widget = (opts = {}) => ({
|
||||
name: 'file',
|
||||
controlComponent,
|
||||
previewComponent,
|
||||
schema,
|
||||
...opts,
|
||||
});
|
||||
|
||||
|
5
packages/netlify-cms-widget-file/src/schema.js
Normal file
5
packages/netlify-cms-widget-file/src/schema.js
Normal file
@ -0,0 +1,5 @@
|
||||
export default {
|
||||
properties: {
|
||||
allow_multiple: { type: 'boolean' },
|
||||
},
|
||||
};
|
@ -1,11 +1,13 @@
|
||||
import NetlifyCmsWidgetFile from 'netlify-cms-widget-file';
|
||||
import previewComponent from './ImagePreview';
|
||||
import schema from './schema';
|
||||
|
||||
const controlComponent = NetlifyCmsWidgetFile.withFileControl({ forImage: true });
|
||||
const Widget = (opts = {}) => ({
|
||||
name: 'image',
|
||||
controlComponent,
|
||||
previewComponent,
|
||||
schema,
|
||||
...opts,
|
||||
});
|
||||
|
||||
|
5
packages/netlify-cms-widget-image/src/schema.js
Normal file
5
packages/netlify-cms-widget-image/src/schema.js
Normal file
@ -0,0 +1,5 @@
|
||||
export default {
|
||||
properties: {
|
||||
allow_multiple: { type: 'boolean' },
|
||||
},
|
||||
};
|
@ -1,11 +1,13 @@
|
||||
import controlComponent from './ListControl';
|
||||
import NetlifyCmsWidgetObject from 'netlify-cms-widget-object';
|
||||
import schema from './schema';
|
||||
|
||||
const previewComponent = NetlifyCmsWidgetObject.previewComponent;
|
||||
const Widget = (opts = {}) => ({
|
||||
name: 'list',
|
||||
controlComponent,
|
||||
previewComponent,
|
||||
schema,
|
||||
...opts,
|
||||
});
|
||||
|
||||
|
9
packages/netlify-cms-widget-list/src/schema.js
Normal file
9
packages/netlify-cms-widget-list/src/schema.js
Normal file
@ -0,0 +1,9 @@
|
||||
export default {
|
||||
properties: {
|
||||
allow_add: { type: 'boolean' },
|
||||
collapsed: { type: 'boolean' },
|
||||
summary: { type: 'string' },
|
||||
minimize_collapsed: { type: 'boolean' },
|
||||
label_singular: { type: 'string' },
|
||||
},
|
||||
};
|
@ -1,11 +1,13 @@
|
||||
import withMapControl from './withMapControl';
|
||||
import previewComponent from './MapPreview';
|
||||
import schema from './schema';
|
||||
|
||||
const controlComponent = withMapControl();
|
||||
const Widget = (opts = {}) => ({
|
||||
name: 'map',
|
||||
controlComponent,
|
||||
previewComponent,
|
||||
schema,
|
||||
...opts,
|
||||
});
|
||||
|
||||
|
6
packages/netlify-cms-widget-map/src/schema.js
Normal file
6
packages/netlify-cms-widget-map/src/schema.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
properties: {
|
||||
decimals: { type: 'integer' },
|
||||
type: { type: 'string', enum: ['Point', 'LineString', 'Polygon'] },
|
||||
},
|
||||
};
|
@ -1,10 +1,12 @@
|
||||
import controlComponent from './MarkdownControl';
|
||||
import previewComponent from './MarkdownPreview';
|
||||
import schema from './schema';
|
||||
|
||||
const Widget = (opts = {}) => ({
|
||||
name: 'markdown',
|
||||
controlComponent,
|
||||
previewComponent,
|
||||
schema,
|
||||
...opts,
|
||||
});
|
||||
|
||||
|
27
packages/netlify-cms-widget-markdown/src/schema.js
Normal file
27
packages/netlify-cms-widget-markdown/src/schema.js
Normal file
@ -0,0 +1,27 @@
|
||||
export default {
|
||||
properties: {
|
||||
minimal: { type: 'boolean' },
|
||||
buttons: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'bold',
|
||||
'italic',
|
||||
'code',
|
||||
'link',
|
||||
'heading-one',
|
||||
'heading-two',
|
||||
'heading-three',
|
||||
'heading-four',
|
||||
'heading-five',
|
||||
'heading-six',
|
||||
'quote',
|
||||
'bulleted-list',
|
||||
'numbered-list',
|
||||
],
|
||||
},
|
||||
},
|
||||
editorComponents: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
};
|
@ -1,10 +1,12 @@
|
||||
import controlComponent from './NumberControl';
|
||||
import previewComponent from './NumberPreview';
|
||||
import schema from './schema';
|
||||
|
||||
const Widget = (opts = {}) => ({
|
||||
name: 'number',
|
||||
controlComponent,
|
||||
previewComponent,
|
||||
schema,
|
||||
...opts,
|
||||
});
|
||||
|
||||
|
8
packages/netlify-cms-widget-number/src/schema.js
Normal file
8
packages/netlify-cms-widget-number/src/schema.js
Normal file
@ -0,0 +1,8 @@
|
||||
export default {
|
||||
properties: {
|
||||
step: { type: 'integer' },
|
||||
valueType: { type: 'string' },
|
||||
min: { type: 'integer' },
|
||||
max: { type: 'integer' },
|
||||
},
|
||||
};
|
@ -1,10 +1,12 @@
|
||||
import controlComponent from './ObjectControl';
|
||||
import previewComponent from './ObjectPreview';
|
||||
import schema from './schema';
|
||||
|
||||
const Widget = (opts = {}) => ({
|
||||
name: 'object',
|
||||
controlComponent,
|
||||
previewComponent,
|
||||
schema,
|
||||
...opts,
|
||||
});
|
||||
|
||||
|
5
packages/netlify-cms-widget-object/src/schema.js
Normal file
5
packages/netlify-cms-widget-object/src/schema.js
Normal file
@ -0,0 +1,5 @@
|
||||
export default {
|
||||
properties: {
|
||||
collapsed: { type: 'boolean' },
|
||||
},
|
||||
};
|
@ -1,10 +1,12 @@
|
||||
import controlComponent from './RelationControl';
|
||||
import previewComponent from './RelationPreview';
|
||||
import schema from './schema';
|
||||
|
||||
const Widget = (opts = {}) => ({
|
||||
name: 'relation',
|
||||
controlComponent,
|
||||
previewComponent,
|
||||
schema,
|
||||
...opts,
|
||||
});
|
||||
|
||||
|
12
packages/netlify-cms-widget-relation/src/schema.js
Normal file
12
packages/netlify-cms-widget-relation/src/schema.js
Normal file
@ -0,0 +1,12 @@
|
||||
export default {
|
||||
properties: {
|
||||
collection: { type: 'string' },
|
||||
valueField: { type: 'string' },
|
||||
searchFields: { type: 'array', minItems: 1, items: { type: 'string' } },
|
||||
file: { type: 'string' },
|
||||
multiple: { type: 'boolean' },
|
||||
displayFields: { type: 'array', minItems: 1, items: { type: 'string' } },
|
||||
optionsLength: { type: 'integer' },
|
||||
},
|
||||
required: ['collection', 'valueField', 'searchFields'],
|
||||
};
|
@ -1,10 +1,12 @@
|
||||
import controlComponent from './SelectControl';
|
||||
import previewComponent from './SelectPreview';
|
||||
import schema from './schema';
|
||||
|
||||
const Widget = (opts = {}) => ({
|
||||
name: 'select',
|
||||
controlComponent,
|
||||
previewComponent,
|
||||
schema,
|
||||
...opts,
|
||||
});
|
||||
|
||||
|
24
packages/netlify-cms-widget-select/src/schema.js
Normal file
24
packages/netlify-cms-widget-select/src/schema.js
Normal file
@ -0,0 +1,24 @@
|
||||
export default {
|
||||
properties: {
|
||||
multiple: { type: 'boolean' },
|
||||
min: { type: 'integer' },
|
||||
max: { type: 'integer' },
|
||||
options: {
|
||||
type: 'array',
|
||||
items: {
|
||||
oneOf: [
|
||||
{ type: 'string' },
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
label: { type: 'string' },
|
||||
value: { type: 'string' },
|
||||
},
|
||||
required: ['label', 'value'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['options'],
|
||||
};
|
Reference in New Issue
Block a user