From 2b46608f86d22c8ad34f75e396be7c34462d9e99 Mon Sep 17 00:00:00 2001 From: Bartholomew Date: Wed, 3 Jun 2020 14:43:34 +0100 Subject: [PATCH] feat: add widgets schema validation (#3841) --- .../constants/__tests__/configSchema.spec.js | 82 +++++++++++++++++++ .../src/constants/configSchema.js | 30 +++++-- packages/netlify-cms-core/src/lib/registry.js | 14 +++- packages/netlify-cms-widget-code/src/index.js | 2 + .../netlify-cms-widget-code/src/schema.js | 11 +++ .../netlify-cms-widget-datetime/src/index.js | 2 + .../netlify-cms-widget-datetime/src/schema.js | 8 ++ packages/netlify-cms-widget-file/src/index.js | 2 + .../netlify-cms-widget-file/src/schema.js | 5 ++ .../netlify-cms-widget-image/src/index.js | 2 + .../netlify-cms-widget-image/src/schema.js | 5 ++ packages/netlify-cms-widget-list/src/index.js | 2 + .../netlify-cms-widget-list/src/schema.js | 9 ++ packages/netlify-cms-widget-map/src/index.js | 2 + packages/netlify-cms-widget-map/src/schema.js | 6 ++ .../netlify-cms-widget-markdown/src/index.js | 2 + .../netlify-cms-widget-markdown/src/schema.js | 27 ++++++ .../netlify-cms-widget-number/src/index.js | 2 + .../netlify-cms-widget-number/src/schema.js | 8 ++ .../netlify-cms-widget-object/src/index.js | 2 + .../netlify-cms-widget-object/src/schema.js | 5 ++ .../netlify-cms-widget-relation/src/index.js | 2 + .../netlify-cms-widget-relation/src/schema.js | 12 +++ .../netlify-cms-widget-select/src/index.js | 2 + .../netlify-cms-widget-select/src/schema.js | 24 ++++++ website/content/docs/custom-widgets.md | 54 +++++++++--- website/content/docs/widgets/list.md | 7 +- 27 files changed, 304 insertions(+), 25 deletions(-) create mode 100644 packages/netlify-cms-widget-code/src/schema.js create mode 100644 packages/netlify-cms-widget-datetime/src/schema.js create mode 100644 packages/netlify-cms-widget-file/src/schema.js create mode 100644 packages/netlify-cms-widget-image/src/schema.js create mode 100644 packages/netlify-cms-widget-list/src/schema.js create mode 100644 packages/netlify-cms-widget-map/src/schema.js create mode 100644 packages/netlify-cms-widget-markdown/src/schema.js create mode 100644 packages/netlify-cms-widget-number/src/schema.js create mode 100644 packages/netlify-cms-widget-object/src/schema.js create mode 100644 packages/netlify-cms-widget-relation/src/schema.js create mode 100644 packages/netlify-cms-widget-select/src/schema.js diff --git a/packages/netlify-cms-core/src/constants/__tests__/configSchema.spec.js b/packages/netlify-cms-core/src/constants/__tests__/configSchema.spec.js index 0462f368..079bab2d 100644 --- a/packages/netlify-cms-core/src/constants/__tests__/configSchema.spec.js +++ b/packages/netlify-cms-core/src/constants/__tests__/configSchema.spec.js @@ -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(); + }); + }); }); }); diff --git a/packages/netlify-cms-core/src/constants/configSchema.js b/packages/netlify-cms-core/src/constants/configSchema.js index dd31a8c1..dbb34952 100644 --- a/packages/netlify-cms-core/src/constants/configSchema.js +++ b/packages/netlify-cms-core/src/constants/configSchema.js @@ -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); diff --git a/packages/netlify-cms-core/src/lib/registry.js b/packages/netlify-cms-core/src/lib/registry.js index 57df0adc..f561cf7c 100644 --- a/packages/netlify-cms-core/src/lib/registry.js +++ b/packages/netlify-cms-core/src/lib/registry.js @@ -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.'); } diff --git a/packages/netlify-cms-widget-code/src/index.js b/packages/netlify-cms-widget-code/src/index.js index 3772d23b..05529ec3 100644 --- a/packages/netlify-cms-widget-code/src/index.js +++ b/packages/netlify-cms-widget-code/src/index.js @@ -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, diff --git a/packages/netlify-cms-widget-code/src/schema.js b/packages/netlify-cms-widget-code/src/schema.js new file mode 100644 index 00000000..204c641e --- /dev/null +++ b/packages/netlify-cms-widget-code/src/schema.js @@ -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' } }, + }, + }, +}; diff --git a/packages/netlify-cms-widget-datetime/src/index.js b/packages/netlify-cms-widget-datetime/src/index.js index 25b71ada..612efd06 100644 --- a/packages/netlify-cms-widget-datetime/src/index.js +++ b/packages/netlify-cms-widget-datetime/src/index.js @@ -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, }); diff --git a/packages/netlify-cms-widget-datetime/src/schema.js b/packages/netlify-cms-widget-datetime/src/schema.js new file mode 100644 index 00000000..45bc1079 --- /dev/null +++ b/packages/netlify-cms-widget-datetime/src/schema.js @@ -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' }, + }, +}; diff --git a/packages/netlify-cms-widget-file/src/index.js b/packages/netlify-cms-widget-file/src/index.js index ee28c2de..7bbad786 100644 --- a/packages/netlify-cms-widget-file/src/index.js +++ b/packages/netlify-cms-widget-file/src/index.js @@ -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, }); diff --git a/packages/netlify-cms-widget-file/src/schema.js b/packages/netlify-cms-widget-file/src/schema.js new file mode 100644 index 00000000..15dbf814 --- /dev/null +++ b/packages/netlify-cms-widget-file/src/schema.js @@ -0,0 +1,5 @@ +export default { + properties: { + allow_multiple: { type: 'boolean' }, + }, +}; diff --git a/packages/netlify-cms-widget-image/src/index.js b/packages/netlify-cms-widget-image/src/index.js index 578051ee..df17b78d 100644 --- a/packages/netlify-cms-widget-image/src/index.js +++ b/packages/netlify-cms-widget-image/src/index.js @@ -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, }); diff --git a/packages/netlify-cms-widget-image/src/schema.js b/packages/netlify-cms-widget-image/src/schema.js new file mode 100644 index 00000000..15dbf814 --- /dev/null +++ b/packages/netlify-cms-widget-image/src/schema.js @@ -0,0 +1,5 @@ +export default { + properties: { + allow_multiple: { type: 'boolean' }, + }, +}; diff --git a/packages/netlify-cms-widget-list/src/index.js b/packages/netlify-cms-widget-list/src/index.js index 4737a746..f489f0e1 100644 --- a/packages/netlify-cms-widget-list/src/index.js +++ b/packages/netlify-cms-widget-list/src/index.js @@ -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, }); diff --git a/packages/netlify-cms-widget-list/src/schema.js b/packages/netlify-cms-widget-list/src/schema.js new file mode 100644 index 00000000..f5869fc0 --- /dev/null +++ b/packages/netlify-cms-widget-list/src/schema.js @@ -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' }, + }, +}; diff --git a/packages/netlify-cms-widget-map/src/index.js b/packages/netlify-cms-widget-map/src/index.js index bccf8501..05053711 100644 --- a/packages/netlify-cms-widget-map/src/index.js +++ b/packages/netlify-cms-widget-map/src/index.js @@ -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, }); diff --git a/packages/netlify-cms-widget-map/src/schema.js b/packages/netlify-cms-widget-map/src/schema.js new file mode 100644 index 00000000..389f8999 --- /dev/null +++ b/packages/netlify-cms-widget-map/src/schema.js @@ -0,0 +1,6 @@ +export default { + properties: { + decimals: { type: 'integer' }, + type: { type: 'string', enum: ['Point', 'LineString', 'Polygon'] }, + }, +}; diff --git a/packages/netlify-cms-widget-markdown/src/index.js b/packages/netlify-cms-widget-markdown/src/index.js index 8ea0f2bc..69539361 100644 --- a/packages/netlify-cms-widget-markdown/src/index.js +++ b/packages/netlify-cms-widget-markdown/src/index.js @@ -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, }); diff --git a/packages/netlify-cms-widget-markdown/src/schema.js b/packages/netlify-cms-widget-markdown/src/schema.js new file mode 100644 index 00000000..3342c358 --- /dev/null +++ b/packages/netlify-cms-widget-markdown/src/schema.js @@ -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' } }, + }, +}; diff --git a/packages/netlify-cms-widget-number/src/index.js b/packages/netlify-cms-widget-number/src/index.js index 7865314a..19c98909 100644 --- a/packages/netlify-cms-widget-number/src/index.js +++ b/packages/netlify-cms-widget-number/src/index.js @@ -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, }); diff --git a/packages/netlify-cms-widget-number/src/schema.js b/packages/netlify-cms-widget-number/src/schema.js new file mode 100644 index 00000000..1efef169 --- /dev/null +++ b/packages/netlify-cms-widget-number/src/schema.js @@ -0,0 +1,8 @@ +export default { + properties: { + step: { type: 'integer' }, + valueType: { type: 'string' }, + min: { type: 'integer' }, + max: { type: 'integer' }, + }, +}; diff --git a/packages/netlify-cms-widget-object/src/index.js b/packages/netlify-cms-widget-object/src/index.js index 9df9d227..5fbcbfdf 100644 --- a/packages/netlify-cms-widget-object/src/index.js +++ b/packages/netlify-cms-widget-object/src/index.js @@ -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, }); diff --git a/packages/netlify-cms-widget-object/src/schema.js b/packages/netlify-cms-widget-object/src/schema.js new file mode 100644 index 00000000..6c06179f --- /dev/null +++ b/packages/netlify-cms-widget-object/src/schema.js @@ -0,0 +1,5 @@ +export default { + properties: { + collapsed: { type: 'boolean' }, + }, +}; diff --git a/packages/netlify-cms-widget-relation/src/index.js b/packages/netlify-cms-widget-relation/src/index.js index 87c106b0..c03df9ff 100644 --- a/packages/netlify-cms-widget-relation/src/index.js +++ b/packages/netlify-cms-widget-relation/src/index.js @@ -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, }); diff --git a/packages/netlify-cms-widget-relation/src/schema.js b/packages/netlify-cms-widget-relation/src/schema.js new file mode 100644 index 00000000..c84dd013 --- /dev/null +++ b/packages/netlify-cms-widget-relation/src/schema.js @@ -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'], +}; diff --git a/packages/netlify-cms-widget-select/src/index.js b/packages/netlify-cms-widget-select/src/index.js index b9b74ce6..13bd330e 100644 --- a/packages/netlify-cms-widget-select/src/index.js +++ b/packages/netlify-cms-widget-select/src/index.js @@ -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, }); diff --git a/packages/netlify-cms-widget-select/src/schema.js b/packages/netlify-cms-widget-select/src/schema.js new file mode 100644 index 00000000..e5191f3d --- /dev/null +++ b/packages/netlify-cms-widget-select/src/schema.js @@ -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'], +}; diff --git a/website/content/docs/custom-widgets.md b/website/content/docs/custom-widgets.md index ac484bd5..0f1fbfd8 100644 --- a/website/content/docs/custom-widgets.md +++ b/website/content/docs/custom-widgets.md @@ -23,11 +23,11 @@ Register a custom widget. ```js // Using global window object -CMS.registerWidget(name, control, [preview]); +CMS.registerWidget(name, control, [preview], [schema]); // Using npm module import import CMS from 'netlify-cms'; -CMS.registerWidget(name, control, [preview]); +CMS.registerWidget(name, control, [preview], [schema]); ``` **Params:** @@ -35,29 +35,34 @@ CMS.registerWidget(name, control, [preview]); | Param | Type | Description | | ----------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | `string` | Widget name, allows this widget to be used via the field `widget` property in config | -| `control` | `React.Component` or `string`| | +| `control` | `React.Component` or `string`| | | [`preview`] | `React.Component`, optional | Renders the widget preview, receives the following props: | - -* **field:** The field type that this widget will be used for. -* **control:** A React component that renders the editing interface for this field. Two props will be passed: - * **value:** The current value for this field. - * **onChange:** Callback function to update the field value. -* **preview (optional):** A React component that renders the preview of how the content will look. A `value` prop will be passed to this component. +| [`schema`] | `JSON Schema object`, optional | Enforces a schema for the widget's field configuration **Example:** +`admin/index.html` + ```html ``` +`admin/config.yml` + +```yml +collections: + - name: posts + label: Posts + folder: content/posts + fields: + - name: title + label: Title + widget: string + - name: categories + label: Categories + widget: categories + separator: __ +``` + ## `registerEditorComponent` Register a block level component for the Markdown editor: diff --git a/website/content/docs/widgets/list.md b/website/content/docs/widgets/list.md index e8007ed9..b5c095ff 100644 --- a/website/content/docs/widgets/list.md +++ b/website/content/docs/widgets/list.md @@ -10,10 +10,11 @@ The list widget allows you to create a repeatable item in the UI which saves as - **Data type:** list of widget values - **Options:** - `default`: if `fields` is specified, declare defaults on the child widgets; if not, you may specify a list of strings to populate the text field - - `allow_add`: if added and labeled `false`, button to add additional widgets disappears - - `collapsed`: if added and labeled `false`, the list widget's content does not collapse by default + - `allow_add`: if added and set to `false`, hides the button to add additional items + - `collapsed`: if added and set to `false`, the list widget's content does not collapse by default - `summary`: allows customization of a collapsed list item object in a similar way to a [collection summary](/docs/configuration-options/?#summary) - - `minimize_collapsed`: if added and labeled `true`, the list widget's content will be completely hidden instead of only collapsed if the list widget itself is collapsed + - `minimize_collapsed`: if added and set to `true`, the list widget's content will be completely hidden instead of only collapsed if the list widget itself is collapsed + - `label_singular`: singular label to show as a part of the add button - `field`: a single widget field to be repeated - `fields`: a nested list of multiple widget fields to be included in each repeatable iteration - **Example** (`field`/`fields` not specified):