diff --git a/playground/samples/index.js b/playground/samples/index.js index 68fa8d22d8..56c356f1c1 100644 --- a/playground/samples/index.js +++ b/playground/samples/index.js @@ -14,6 +14,8 @@ import files from "./files"; import single from "./single"; import customArray from "./customArray"; import alternatives from "./alternatives"; +import propertyDependencies from "./propertyDependencies"; +import schemaDependencies from "./schemaDependencies"; export const samples = { Simple: simple, @@ -32,4 +34,6 @@ export const samples = { Single: single, "Custom Array": customArray, Alternatives: alternatives, + "Property dependencies": propertyDependencies, + "Schema dependencies": schemaDependencies, }; diff --git a/playground/samples/propertyDependencies.js b/playground/samples/propertyDependencies.js new file mode 100644 index 0000000000..6fc4efc371 --- /dev/null +++ b/playground/samples/propertyDependencies.js @@ -0,0 +1,86 @@ +module.exports = { + schema: { + title: "Property dependencies", + description: "These samples are best viewed without live validation.", + type: "object", + properties: { + unidirectional: { + title: "Unidirectional", + src: + "https://spacetelescope.github.io/understanding-json-schema/reference/object.html#dependencies", + description: + "In the following example, whenever a `credit_card` property is provided, a `billing_address` property must also be present.", + type: "object", + properties: { + name: { + type: "string", + }, + credit_card: { + type: "number", + }, + billing_address: { + type: "string", + }, + }, + required: ["name"], + dependencies: { + credit_card: ["billing_address"], + }, + }, + bidirectional: { + title: "Bidirectional", + src: + "https://spacetelescope.github.io/understanding-json-schema/reference/object.html#dependencies", + description: + "Dependencies are not bidirectional, you can, of course, define the bidirectional dependencies explicitly.", + type: "object", + properties: { + name: { + type: "string", + }, + credit_card: { + type: "number", + }, + billing_address: { + type: "string", + }, + }, + required: ["name"], + dependencies: { + credit_card: ["billing_address"], + billing_address: ["credit_card"], + }, + }, + }, + }, + uiSchema: { + unidirectional: { + credit_card: { + "ui:help": + "If you enter anything here then `billing_address` will become required.", + }, + billing_address: { + "ui:help": + "It’s okay to have a billing address without a credit card number.", + }, + }, + bidirectional: { + credit_card: { + "ui:help": + "If you enter anything here then `billing_address` will become required.", + }, + billing_address: { + "ui:help": + "If you enter anything here then `credit_card` will become required.", + }, + }, + }, + formData: { + unidirectional: { + name: "Tim", + }, + bidirectional: { + name: "Jill", + }, + }, +}; diff --git a/playground/samples/schemaDependencies.js b/playground/samples/schemaDependencies.js new file mode 100644 index 0000000000..54d42fc455 --- /dev/null +++ b/playground/samples/schemaDependencies.js @@ -0,0 +1,171 @@ +module.exports = { + schema: { + title: "Schema dependencies", + description: "These samples are best viewed without live validation.", + type: "object", + properties: { + simple: { + src: + "https://spacetelescope.github.io/understanding-json-schema/reference/object.html#dependencies", + title: "Simple", + type: "object", + properties: { + name: { + type: "string", + }, + credit_card: { + type: "number", + }, + }, + required: ["name"], + dependencies: { + credit_card: { + properties: { + billing_address: { + type: "string", + }, + }, + required: ["billing_address"], + }, + }, + }, + conditional: { + title: "Conditional", + $ref: "#/definitions/person", + }, + arrayOfConditionals: { + title: "Array of conditionals", + type: "array", + items: { + $ref: "#/definitions/person", + }, + }, + fixedArrayOfConditionals: { + title: "Fixed array of conditionals", + type: "array", + items: [ + { + title: "Primary person", + $ref: "#/definitions/person", + }, + ], + additionalItems: { + title: "Additional person", + $ref: "#/definitions/person", + }, + }, + }, + definitions: { + person: { + title: "Person", + type: "object", + properties: { + "Do you have any pets?": { + type: "string", + enum: ["No", "Yes: One", "Yes: More than one"], + default: "No", + }, + }, + required: ["Do you have any pets?"], + dependencies: { + "Do you have any pets?": { + oneOf: [ + { + properties: { + "Do you have any pets?": { + enum: ["No"], + }, + }, + }, + { + properties: { + "Do you have any pets?": { + enum: ["Yes: One"], + }, + "How old is your pet?": { + type: "number", + }, + }, + required: ["How old is your pet?"], + }, + { + properties: { + "Do you have any pets?": { + enum: ["Yes: More than one"], + }, + "Do you want to get rid of any?": { + type: "boolean", + }, + }, + required: ["Do you want to get rid of any?"], + }, + ], + }, + }, + }, + }, + }, + uiSchema: { + simple: { + credit_card: { + "ui:help": + "If you enter anything here then `billing_address` will be dynamically added to the form.", + }, + }, + conditional: { + "Do you want to get rid of any?": { + "ui:widget": "radio", + }, + }, + arrayOfConditionals: { + items: { + "Do you want to get rid of any?": { + "ui:widget": "radio", + }, + }, + }, + fixedArrayOfConditionals: { + items: { + "Do you want to get rid of any?": { + "ui:widget": "radio", + }, + }, + additionalItems: { + "Do you want to get rid of any?": { + "ui:widget": "radio", + }, + }, + }, + }, + formData: { + simple: { + name: "Randy", + }, + conditional: { + "Do you have any pets?": "No", + }, + arrayOfConditionals: [ + { + "Do you have any pets?": "Yes: One", + "How old is your pet?": 6, + }, + { + "Do you have any pets?": "Yes: More than one", + "Do you want to get rid of any?": false, + }, + ], + fixedArrayOfConditionals: [ + { + "Do you have any pets?": "No", + }, + { + "Do you have any pets?": "Yes: One", + "How old is your pet?": 6, + }, + { + "Do you have any pets?": "Yes: More than one", + "Do you want to get rid of any?": true, + }, + ], + }, +}; diff --git a/src/components/Form.js b/src/components/Form.js index 1455d4f21c..70cc45da24 100644 --- a/src/components/Form.js +++ b/src/components/Form.js @@ -48,7 +48,8 @@ export default class Form extends Component { const idSchema = toIdSchema( schema, uiSchema["ui:rootFieldId"], - definitions + definitions, + formData ); return { status: "initial", diff --git a/src/components/fields/ArrayField.js b/src/components/fields/ArrayField.js index 4aa51bf861..bbda555d0a 100644 --- a/src/components/fields/ArrayField.js +++ b/src/components/fields/ArrayField.js @@ -312,14 +312,24 @@ class ArrayField extends Component { const arrayProps = { canAdd: this.canAddItem(formData), items: formData.map((item, index) => { + const itemSchema = retrieveSchema( + schema.items, + definitions, + formData[index] + ); const itemErrorSchema = errorSchema ? errorSchema[index] : undefined; const itemIdPrefix = idSchema.$id + "_" + index; - const itemIdSchema = toIdSchema(itemsSchema, itemIdPrefix, definitions); + const itemIdSchema = toIdSchema( + itemSchema, + itemIdPrefix, + definitions, + formData[index] + ); return this.renderArrayFieldItem({ index, canMoveUp: index > 0, canMoveDown: index < formData.length - 1, - itemSchema: itemsSchema, + itemSchema: itemSchema, itemIdSchema, itemErrorSchema, itemData: formData[index], @@ -352,6 +362,7 @@ class ArrayField extends Component { schema, idSchema, uiSchema, + formData, disabled, readonly, autofocus, @@ -360,7 +371,7 @@ class ArrayField extends Component { } = this.props; const items = this.props.formData; const { widgets, definitions, formContext } = registry; - const itemsSchema = retrieveSchema(schema.items, definitions); + const itemsSchema = retrieveSchema(schema.items, definitions, formData); const enumOptions = optionsList(itemsSchema); const { widget = "select", ...options } = { ...getUiOptions(uiSchema), @@ -423,6 +434,7 @@ class ArrayField extends Component { const { schema, uiSchema, + formData, errorSchema, idSchema, name, @@ -437,11 +449,11 @@ class ArrayField extends Component { let items = this.props.formData; const { ArrayFieldTemplate, definitions, fields } = registry; const { TitleField } = fields; - const itemSchemas = schema.items.map(item => - retrieveSchema(item, definitions) + const itemSchemas = schema.items.map((item, index) => + retrieveSchema(item, definitions, formData[index]) ); const additionalSchema = allowAdditionalItems(schema) - ? retrieveSchema(schema.additionalItems, definitions) + ? retrieveSchema(schema.additionalItems, definitions, formData) : null; if (!items || items.length < itemSchemas.length) { @@ -456,11 +468,19 @@ class ArrayField extends Component { className: "field field-array field-array-fixed-items", disabled, idSchema, + formData, items: items.map((item, index) => { const additional = index >= itemSchemas.length; - const itemSchema = additional ? additionalSchema : itemSchemas[index]; + const itemSchema = additional + ? retrieveSchema(schema.additionalItems, definitions, formData[index]) + : itemSchemas[index]; const itemIdPrefix = idSchema.$id + "_" + index; - const itemIdSchema = toIdSchema(itemSchema, itemIdPrefix, definitions); + const itemIdSchema = toIdSchema( + itemSchema, + itemIdPrefix, + definitions, + formData[index] + ); const itemUiSchema = additional ? uiSchema.additionalItems || {} : Array.isArray(uiSchema.items) diff --git a/src/components/fields/ObjectField.js b/src/components/fields/ObjectField.js index 9f1efee253..44bcae4367 100644 --- a/src/components/fields/ObjectField.js +++ b/src/components/fields/ObjectField.js @@ -47,7 +47,7 @@ class ObjectField extends Component { } = this.props; const { definitions, fields, formContext } = registry; const { SchemaField, TitleField, DescriptionField } = fields; - const schema = retrieveSchema(this.props.schema, definitions); + const schema = retrieveSchema(this.props.schema, definitions, formData); const title = schema.title === undefined ? name : schema.title; let orderedProperties; try { diff --git a/src/components/fields/SchemaField.js b/src/components/fields/SchemaField.js index 1b20d54ffc..238895ff08 100644 --- a/src/components/fields/SchemaField.js +++ b/src/components/fields/SchemaField.js @@ -146,6 +146,7 @@ DefaultTemplate.defaultProps = { function SchemaFieldRender(props) { const { uiSchema, + formData, errorSchema, idSchema, name, @@ -158,7 +159,7 @@ function SchemaFieldRender(props) { formContext, FieldTemplate = DefaultTemplate, } = registry; - const schema = retrieveSchema(props.schema, definitions); + const schema = retrieveSchema(props.schema, definitions, formData); const FieldComponent = getFieldComponent(schema, uiSchema, fields); const { DescriptionField } = fields; const disabled = Boolean(props.disabled || uiSchema["ui:disabled"]); diff --git a/src/utils.js b/src/utils.js index a14299d57a..ef3fa8121f 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,5 +1,6 @@ import React from "react"; import "setimmediate"; +import { validate as jsonValidate } from "jsonschema"; const widgetMap = { boolean: { @@ -152,7 +153,7 @@ export function getDefaultFormState(_schema, formData, definitions = {}) { if (!isObject(_schema)) { throw new Error("Invalid schema: " + _schema); } - const schema = retrieveSchema(_schema, definitions); + const schema = retrieveSchema(_schema, definitions, formData); const defaults = computeDefaults(schema, _schema.default, definitions); if (typeof formData === "undefined") { // No form data? Use schema defaults. @@ -379,17 +380,82 @@ function findSchemaDefinition($ref, definitions = {}) { throw new Error(`Could not find a definition for ${$ref}.`); } -export function retrieveSchema(schema, definitions = {}) { - // No $ref attribute found, returning the original schema. - if (!schema.hasOwnProperty("$ref")) { +export function retrieveSchema(schema, definitions = {}, formData = {}) { + if (schema.hasOwnProperty("$ref")) { + // Retrieve the referenced schema definition. + const $refSchema = findSchemaDefinition(schema.$ref, definitions); + // Drop the $ref property of the source schema. + const { $ref, ...localSchema } = schema; + // Update referenced schema definition with local schema properties. + return retrieveSchema( + { ...$refSchema, ...localSchema }, + definitions, + formData + ); + } else if (schema.hasOwnProperty("dependencies")) { + // Drop the dependencies from the source schema. + let { dependencies, ...localSchema } = schema; + // Process dependencies updating the local schema properties as appropriate. + for (const key of Object.keys(dependencies)) { + // Skip this dependency if its trigger property is not present. + if (!formData[key]) { + // fixme: a falsey check may not be appropriate here, I'm not sure + continue; + } + const value = dependencies[key]; + if (Array.isArray(value)) { + for (const property of value) { + if (!Array.isArray(localSchema.required)) { + localSchema.required = []; + } + if (localSchema.required.includes(property)) { + continue; + } + localSchema.required = localSchema.required.concat(property); + } + } else if (isObject(value)) { + localSchema = retrieveSchema( + mergeObjects( + localSchema, + retrieveSchema(value, definitions, formData), + true + ) + ); + if (Array.isArray(value.oneOf)) { + for (const subschema of value.oneOf) { + if (!subschema.properties) { + continue; + } + const { + [key]: conditionProperty, + ...dependentProperties + } = subschema.properties; + if (conditionProperty) { + const conditionSchema = { + type: "object", + properties: { + [key]: conditionProperty, + }, + }; + const { errors } = jsonValidate(formData, conditionSchema); + if (errors.length === 0) { + const dependentSchema = { ...subschema }; + dependentSchema.properties = dependentProperties; + localSchema = mergeObjects( + localSchema, + retrieveSchema(dependentSchema, definitions, formData) + ); + } + } + } + } + } + } + return retrieveSchema(localSchema, definitions, formData); + } else { + // No $ref or dependencies attribute found, returning the original schema. return schema; } - // Retrieve the referenced schema definition. - const $refSchema = findSchemaDefinition(schema.$ref, definitions); - // Drop the $ref property of the source schema. - const { $ref, ...localSchema } = schema; - // Update referenced schema definition with local schema properties. - return { ...$refSchema, ...localSchema }; } function isArguments(object) { @@ -478,16 +544,16 @@ export function shouldRender(comp, nextProps, nextState) { return !deepEquals(props, nextProps) || !deepEquals(state, nextState); } -export function toIdSchema(schema, id, definitions) { +export function toIdSchema(schema, id, definitions, formData = {}) { const idSchema = { $id: id || "root", }; if ("$ref" in schema) { - const _schema = retrieveSchema(schema, definitions); - return toIdSchema(_schema, id, definitions); + const _schema = retrieveSchema(schema, definitions, formData); + return toIdSchema(_schema, id, definitions, formData); } if ("items" in schema && !schema.items.$ref) { - return toIdSchema(schema.items, id, definitions); + return toIdSchema(schema.items, id, definitions, formData); } if (schema.type !== "object") { return idSchema; @@ -495,7 +561,7 @@ export function toIdSchema(schema, id, definitions) { for (const name in schema.properties || {}) { const field = schema.properties[name]; const fieldId = idSchema.$id + "_" + name; - idSchema[name] = toIdSchema(field, fieldId, definitions); + idSchema[name] = toIdSchema(field, fieldId, definitions, formData[name]); } return idSchema; } diff --git a/test/utils_test.js b/test/utils_test.js index 619b4fe863..66574d6b2a 100644 --- a/test/utils_test.js +++ b/test/utils_test.js @@ -496,6 +496,139 @@ describe("utils", () => { title: "foo", }); }); + + describe("property dependencies", () => { + describe("false condition", () => { + it("should not add required properties", () => { + const schema = { + type: "object", + properties: { + a: { type: "string" }, + b: { type: "integer" }, + }, + required: ["a"], + dependencies: { + a: ["b"], + }, + }; + const definitions = {}; + const formData = {}; + expect(retrieveSchema(schema, definitions, formData)).eql({ + type: "object", + properties: { + a: { type: "string" }, + b: { type: "integer" }, + }, + required: ["a"], + }); + }); + }); + describe("true condition", () => { + describe("when required is not defined", () => { + it("should define required properties", () => { + const schema = { + type: "object", + properties: { + a: { type: "string" }, + b: { type: "integer" }, + }, + dependencies: { + a: ["b"], + }, + }; + const definitions = {}; + const formData = { a: "1" }; + expect(retrieveSchema(schema, definitions, formData)).eql({ + type: "object", + properties: { + a: { type: "string" }, + b: { type: "integer" }, + }, + required: ["b"], + }); + }); + }); + describe("when required is defined", () => { + it("should concat required properties", () => { + const schema = { + type: "object", + properties: { + a: { type: "string" }, + b: { type: "integer" }, + }, + required: ["a"], + dependencies: { + a: ["b"], + }, + }; + const definitions = {}; + const formData = { a: "1" }; + expect(retrieveSchema(schema, definitions, formData)).eql({ + type: "object", + properties: { + a: { type: "string" }, + b: { type: "integer" }, + }, + required: ["a", "b"], + }); + }); + }); + }); + }); + + describe("schema dependencies", () => { + describe("false condition", () => { + it("should not modify schema", () => { + const schema = { + type: "object", + properties: { + a: { type: "string" }, + }, + dependencies: { + a: { + properties: { + b: { type: "integer" }, + }, + }, + }, + }; + const definitions = {}; + const formData = {}; + expect(retrieveSchema(schema, definitions, formData)).eql({ + type: "object", + properties: { + a: { type: "string" }, + }, + }); + }); + }); + describe("true condition", () => { + it("should add additional properties in object", () => { + const schema = { + type: "object", + properties: { + a: { type: "string" }, + }, + dependencies: { + a: { + properties: { + b: { type: "integer" }, + }, + }, + }, + }; + const definitions = {}; + const formData = { a: "1" }; + expect(retrieveSchema(schema, definitions, formData)).eql({ + type: "object", + properties: { + a: { type: "string" }, + b: { type: "integer" }, + }, + }); + }); + }); + }); }); describe("shouldRender", () => {