From eef551d8c7861e9728ea8041ba44ad7a15921449 Mon Sep 17 00:00:00 2001 From: "Salisbury, Tom" Date: Thu, 27 Feb 2020 12:07:37 +0000 Subject: [PATCH 01/17] Implementation of If Then Else Fixed issue with arrays Removed only from tests better allOf support better allOf support mend If Then Else , Allof Fixed typo and included .lock.json added support for in if/then/else Added support for const as per #1627 Moved handling of if then / else from SchemaField to utils and tidied up things --- docs/index.md | 80 ++++- .../core/src/components/fields/SchemaField.js | 4 +- .../core/src/components/widgets/BaseInput.js | 8 +- .../src/components/widgets/CheckboxWidget.js | 8 +- packages/core/src/utils.js | 65 +++- packages/core/src/validate.js | 1 + packages/core/test/allOf_test.js | 37 ++- packages/core/test/const_test.js | 41 ++- packages/core/test/ifthenelse_test.js | 290 ++++++++++++++++++ packages/core/test/utils_test.js | 61 ---- packages/playground/package-lock.json | 32 -- packages/playground/src/samples/allOf.js | 16 +- .../playground/src/samples/allofifthenelse.js | 77 +++++ packages/playground/src/samples/ifthenelse.js | 75 +++++ .../playground/src/samples/ifthenelserefs.js | 41 +++ packages/playground/src/samples/index.js | 6 + packages/playground/src/samples/simple.js | 1 - 17 files changed, 719 insertions(+), 124 deletions(-) create mode 100644 packages/core/test/ifthenelse_test.js create mode 100644 packages/playground/src/samples/allofifthenelse.js create mode 100644 packages/playground/src/samples/ifthenelse.js create mode 100644 packages/playground/src/samples/ifthenelserefs.js diff --git a/docs/index.md b/docs/index.md index 23d433147b..d40cf8d54f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -81,7 +81,85 @@ For more information on what themes we support, see [Using Themes](usage/themes) disabled until https://github.com/rjsf-team/react-jsonschema-form/issues/1584 is resolved -## Useful samples +Sometimes you may want to trigger events or modify external state when a field has been focused, so you can pass an `onFocus` handler, which will receive the id of the input that is focused and the field value. + +### Submit form programmatically +You can use the reference to get your `Form` component and call the `submit` method to submit the form programmatically without a submit button. +This method will dispatch the `submit` event of the form, and the function, that is passed to `onSubmit` props, will be called. + +```js +const onSubmit = ({formData}) => console.log("Data submitted: ", formData); +let yourForm; + +render(( +
{yourForm = form;}}/> +), document.getElementById("app")); + +yourForm.submit(); +``` + +## Styling your forms + +This library renders form fields and widgets leveraging the [Bootstrap](http://getbootstrap.com/) semantics. That means your forms will be beautiful by default if you're loading its stylesheet in your page. + +You're not necessarily forced to use Bootstrap; while it uses its semantics, it also provides a bunch of other class names so you can bring new styles or override default ones quite easily in your own personalized stylesheet. That's just HTML after allĀ :) + +If you're okay with using styles from the Bootstrap ecosystem though, then the good news is that you have access to many themes for it, which are compatible with our generated forms! + +Here are some examples from the [playground](https://rjsf-team.github.io/react-jsonschema-form/), using some of the [Bootswatch](http://bootswatch.com/) free themes: + +![](https://i.imgur.com/1Z5oUK3.png) +![](https://i.imgur.com/IMFqMwK.png) +![](https://i.imgur.com/HOACwt5.png) + +Last, if you really really want to override the semantics generated by the lib, you can always create and use your own custom [widget](advanced-customization.md#custom-widget-components), [field](advanced-customization.md#custom-field-components) and/or [schema field](advanced-customization.md#custom-schemafield) components. + + +## JSON Schema supporting status + +This component follows [JSON Schema](http://json-schema.org/documentation.html) specs. We currently support JSON Schema-07 by default, but we also support other JSON schema versions through the [custom schema validation](https://react-jsonschema-form.readthedocs.io/en/latest/validation/#custom-schema-validation) feature. Due to the limitation of form widgets, there are some exceptions as follows: + +* `additionalItems` keyword for arrays + + This keyword works when `items` is an array. `additionalItems: true` is not supported because there's no widget to represent an item of any type. In this case it will be treated as no additional items allowed. `additionalItems` being a valid schema is supported. + +* `anyOf`, `allOf`, and `oneOf`, or multiple `types` (i.e. `"type": ["string", "array"]`) + + The `anyOf` and `oneOf` keywords are supported; however, properties declared inside the `anyOf/oneOf` should not overlap with properties "outside" of the `anyOf/oneOf`. + + You can also use `oneOf` with [schema dependencies](dependencies.md#schema-dependencies) to dynamically add schema properties based on input data. + + The `allOf` keyword is partially supported. + +* `"additionalProperties":false` produces incorrect schemas when used with [schema dependencies](#schema-dependencies). This library does not remove extra properties, which causes validation to fail. It is recommended to avoid setting `"additionalProperties":false` when you use schema dependencies. See [#848](https://github.com/mozilla-services/react-jsonschema-form/issues/848) [#902](https://github.com/mozilla-services/react-jsonschema-form/issues/902) [#992](https://github.com/mozilla-services/react-jsonschema-form/issues/992) + +## Handling of schema defaults + +This library automatically fills default values defined the [JSON Schema](http://json-schema.org/documentation.html) as initial values in your form. This also works for complex structures in the schema. If a field has a default defined, it should always appear as default value in form. This also works when using [schema dependencies](#schema-dependencies). + +Since there is a complex interaction between any supplied original form data and any injected defaults, this library tries to do the injection in a way which keeps the original intention of the original form data. + +Check out the defaults example on the [live playground](https://mozilla-services.github.io/react-jsonschema-form/) to see this in action. + +### Merging of defaults into the form data + +There are three different cases which need to be considered for the merging. Objects, arrays and scalar values. This library always deeply merges any defaults with the existing form data for objects. + +This are the rules which are used when injecting the defaults: + +- When the is a scalar in the form data, nothing is changed. +- When the value is `undefined` in the form data, the default is created in the form data. +- When the value is an object in the form data, the defaults are deeply merged into the form data, using the rules defined here for the deep merge. +- Then the value is an array in the form data, defaults are only injected in existing array items. No new array items will be created, even if the schema has minItems or additional items defined. + +### Merging of defaults within the schema + +In the schema itself, defaults of parent elements are propagated into children. So when you have a schema which defines a deeply nested object as default, these defaults will be applied to children of the current node. This also merges objects defined at different levels together with the "deeper" not having precedence. If the parent node defines properties, which are not defined in the child, they will be merged so that the default for the child will be the merged defaults of parent and child. + +For arrays this is not the case. Defining an array, when a parent also defines an array, will be overwritten. This is only true when arrays are used in the same level, for objects within these arrays, they will be deeply merged again. + +## Tips and tricks - Custom field template: - Multi-step wizard: diff --git a/packages/core/src/components/fields/SchemaField.js b/packages/core/src/components/fields/SchemaField.js index 2465b66912..522a3de9e5 100644 --- a/packages/core/src/components/fields/SchemaField.js +++ b/packages/core/src/components/fields/SchemaField.js @@ -249,7 +249,8 @@ function SchemaFieldRender(props) { const FieldTemplate = uiSchema["ui:FieldTemplate"] || registry.FieldTemplate || DefaultTemplate; let idSchema = props.idSchema; - const schema = retrieveSchema(props.schema, rootSchema, formData); + var schema = retrieveSchema(props.schema, rootSchema, formData); + idSchema = mergeObjects( toIdSchema(schema, null, rootSchema, formData, idPrefix), idSchema @@ -403,7 +404,6 @@ function SchemaFieldRender(props) { ); } - class SchemaField extends React.Component { shouldComponentUpdate(nextProps, nextState) { return !deepEquals(this.props, nextProps); diff --git a/packages/core/src/components/widgets/BaseInput.js b/packages/core/src/components/widgets/BaseInput.js index 27607787be..16b9b0600d 100644 --- a/packages/core/src/components/widgets/BaseInput.js +++ b/packages/core/src/components/widgets/BaseInput.js @@ -8,7 +8,7 @@ function BaseInput(props) { console.log("No id for", props); throw new Error(`no id for props ${JSON.stringify(props)}`); } - const { + var { value, readonly, disabled, @@ -66,6 +66,12 @@ function BaseInput(props) { return props.onChange(value === "" ? options.emptyValue : value); }; + // If its a constant value then we want to disable the control and set the default value + if (typeof schema.const !== "undefined") { + value = schema.const; + disabled = true; + } + return [ {schema.description && ( diff --git a/packages/core/src/utils.js b/packages/core/src/utils.js index 83c20cdec8..32ac1db55e 100644 --- a/packages/core/src/utils.js +++ b/packages/core/src/utils.js @@ -189,6 +189,8 @@ function computeDefaults( } else if ("default" in schema) { // Use schema defaults for this node. defaults = schema.default; + } else if ("const" in schema) { + defaults = schema.const; } else if ("$ref" in schema) { // Use referenced schema defaults for this node. const refSchema = findSchemaDefinition(schema.$ref, rootSchema); @@ -697,28 +699,63 @@ export function retrieveSchema(schema, rootSchema = {}, formData = {}) { return {}; } let resolvedSchema = resolveSchema(schema, rootSchema, formData); - if ("allOf" in schema) { - try { - resolvedSchema = mergeAllOf({ - ...resolvedSchema, - allOf: resolvedSchema.allOf, - }); - } catch (e) { - console.warn("could not merge subschemas in allOf:\n" + e); - const { allOf, ...resolvedSchemaWithoutAllOf } = resolvedSchema; - return resolvedSchemaWithoutAllOf; - } - } + const hasAdditionalProperties = resolvedSchema.hasOwnProperty("additionalProperties") && resolvedSchema.additionalProperties !== false; + if (hasAdditionalProperties) { - return stubExistingAdditionalProperties( + resolvedSchema = stubExistingAdditionalProperties( resolvedSchema, rootSchema, formData ); } + + if ("if" in resolvedSchema) { + // Note that if and else are key words in javascript so extract to variable names which are allowed + var { + if: expression, + then, + else: otherwise, + ...resolvedSchemaLessConditional + } = resolvedSchema; + var conditionalSchema = isValid(expression, formData) ? then : otherwise; + + if (conditionalSchema) { + conditionalSchema = resolveSchema( + conditionalSchema, + rootSchema, + formData + ); + resolvedSchema = mergeSchemas( + resolvedSchemaLessConditional, + conditionalSchema + ); + } + } + + let allOf = resolvedSchema.allOf; + + if (allOf) { + for (var i = 0; i < allOf.length; i++) { + let allOfSchema = allOf[i]; + + // if we see an if in our all of schema then evaluate the if schema and select the then / else, not sure if we should still merge without our if then else + if ("if" in allOfSchema) { + allOfSchema = isValid(allOfSchema.if, formData) + ? allOfSchema.then + : allOfSchema.else; + } + + if (allOfSchema) { + allOfSchema = resolveSchema(allOfSchema, rootSchema, formData); // resolve references etc. + resolvedSchema = mergeSchemas(resolvedSchema, allOfSchema); + delete resolvedSchema.allOf; + } + } + } + return resolvedSchema; } @@ -998,7 +1035,7 @@ export function toIdSchema( const idSchema = { $id: id || idPrefix, }; - if ("$ref" in schema || "dependencies" in schema || "allOf" in schema) { + if ("$ref" in schema || "dependencies" in schema) { const _schema = retrieveSchema(schema, rootSchema, formData); return toIdSchema(_schema, id, rootSchema, formData, idPrefix); } diff --git a/packages/core/src/validate.js b/packages/core/src/validate.js index f2bb6b9887..f561b1386a 100644 --- a/packages/core/src/validate.js +++ b/packages/core/src/validate.js @@ -16,6 +16,7 @@ function createAjvInstance() { multipleOfPrecision: 8, schemaId: "auto", unknownFormats: "ignore", + useDefaults: true, }); // add custom formats diff --git a/packages/core/test/allOf_test.js b/packages/core/test/allOf_test.js index f3644dbbac..709040406d 100644 --- a/packages/core/test/allOf_test.js +++ b/packages/core/test/allOf_test.js @@ -14,6 +14,7 @@ describe("allOf", () => { }); it("should render a regular input element with a single type, when multiple types specified", () => { + // I think that this is wrong const schema = { type: "object", properties: { @@ -30,12 +31,44 @@ describe("allOf", () => { expect(node.querySelectorAll("input")).to.have.length.of(1); }); + it("should properly merge multiple schemas as per https://json-schema.org/understanding-json-schema/reference/combining.html", () => { + const schema = { + definitions: { + address: { + type: "object", + properties: { + street_address: { type: "string" }, + city: { type: "string" }, + state: { type: "string" }, + }, + required: ["street_address", "city", "state"], + }, + }, + + allOf: [ + { $ref: "#/definitions/address" }, + { + properties: { + type: { enum: ["residential", "business"] }, + }, + }, + ], + }; + + const { node } = createFormComponent({ + schema, + }); + + expect(node.querySelectorAll("input")).to.have.length.of(3); // Schema 1 + expect(node.querySelectorAll("select")).to.have.length.of(1); // Schema 2 + }); + it("should be able to handle incompatible types and not crash", () => { const schema = { type: "object", properties: { foo: { - allOf: [{ type: "string" }, { type: "boolean" }], + allOf: [{ type: "string" }, { type: "boolean" }], // this will basically pick up the last entry }, }, }; @@ -44,6 +77,6 @@ describe("allOf", () => { schema, }); - expect(node.querySelectorAll("input")).to.have.length.of(0); + expect(node.querySelectorAll("input")).to.have.length.of(1); }); }); diff --git a/packages/core/test/const_test.js b/packages/core/test/const_test.js index d47e012bd2..ad9f9ec148 100644 --- a/packages/core/test/const_test.js +++ b/packages/core/test/const_test.js @@ -13,6 +13,23 @@ describe("const", () => { sandbox.restore(); }); + it("should not mutate disabled when field is not a const", () => { + const schema = { + type: "object", + properties: { + foo: { type: "string" }, + }, + }; + + const { node } = createFormComponent({ + schema, + }); + let input = node.querySelector("input#root_foo"); + expect(input).not.eql(null); + expect(input.value).to.eql(""); + expect(input.disabled).to.eql(false); + }); + it("should render a schema that uses const with a string value", () => { const schema = { type: "object", @@ -21,11 +38,15 @@ describe("const", () => { }, }; - const { node } = createFormComponent({ + const { node, comp } = createFormComponent({ schema, }); - expect(node.querySelector("input#root_foo")).not.eql(null); + let input = node.querySelector("input#root_foo"); + expect(input).not.eql(null); + expect(input.value).to.eql("bar"); + expect(input.disabled).to.eql(true); + expect(comp.state.formData.foo).to.eql("bar"); }); it("should render a schema that uses const with a number value", () => { @@ -36,11 +57,15 @@ describe("const", () => { }, }; - const { node } = createFormComponent({ + const { node, comp } = createFormComponent({ schema, }); - expect(node.querySelector("input#root_foo")).not.eql(null); + let input = node.querySelector("input#root_foo"); + expect(input).not.eql(null); + expect(input.value).to.eql("123"); + expect(input.disabled).to.eql(true); + expect(comp.state.formData.foo).to.eql(123); }); it("should render a schema that uses const with a boolean value", () => { @@ -51,10 +76,14 @@ describe("const", () => { }, }; - const { node } = createFormComponent({ + const { node, comp } = createFormComponent({ schema, }); - expect(node.querySelector("input#root_foo[type='checkbox']")).not.eql(null); + let input = node.querySelector("input#root_foo[type='checkbox']"); + expect(input).not.eql(null); + expect(input.checked).to.eql(true); + expect(input.disabled).to.eql(true); + expect(comp.state.formData.foo).to.eql(true); }); }); diff --git a/packages/core/test/ifthenelse_test.js b/packages/core/test/ifthenelse_test.js new file mode 100644 index 0000000000..3a0096a13a --- /dev/null +++ b/packages/core/test/ifthenelse_test.js @@ -0,0 +1,290 @@ +import { expect } from "chai"; + +import { createFormComponent, createSandbox } from "./test_utils"; + +describe("conditional items", () => { + let sandbox; + + beforeEach(() => { + sandbox = createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + const schema = { + type: "object", + properties: { + street_address: { + type: "string", + }, + country: { + enum: ["United States of America", "Canada"], + }, + }, + if: { + properties: { country: { const: "United States of America" } }, + }, + then: { + properties: { zipcode: { type: "string" } }, + }, + else: { + properties: { postal_code: { type: "string" } }, + }, + }; + + const schemaWithRef = { + type: "object", + properties: { + country: { + enum: ["United States of America", "Canada"], + }, + }, + if: { + properties: { + country: { + const: "United States of America", + }, + }, + }, + then: { + $ref: "#/definitions/us", + }, + else: { + $ref: "#/definitions/other", + }, + definitions: { + us: { + properties: { + zip_code: { + type: "string", + }, + }, + }, + other: { + properties: { + postal_code: { + type: "string", + }, + }, + }, + }, + }; + + it("should render then when condition is true", () => { + const formData = { + country: "United States of America", + }; + + const { node } = createFormComponent({ + schema, + formData, + }); + + expect(node.querySelector("input[label=zipcode]")).not.eql(null); + expect(node.querySelector("input[label=postal_code]")).to.eql(null); + }); + + it("should render else when condition is false", () => { + const formData = { + country: "France", + }; + + const { node } = createFormComponent({ + schema, + formData, + }); + + expect(node.querySelector("input[label=zipcode]")).to.eql(null); + expect(node.querySelector("input[label=postal_code]")).not.eql(null); + }); + + it("should should render control when data has not been filled in", () => { + const formData = {}; + + const { node } = createFormComponent({ + schema, + formData, + }); + + // This feels backwards but is basically because undefined equates to true when field is validated + // Please see https://github.com/epoberezkin/ajv/issues/913 + expect(node.querySelector("input[label=zipcode]")).not.eql(null); + expect(node.querySelector("input[label=postal_code]")).to.eql(null); + }); + + it("should render then when condition is true with reference", () => { + const formData = { + country: "United States of America", + }; + + const { node } = createFormComponent({ + schema: schemaWithRef, + formData, + }); + + expect(node.querySelector("input[label=zip_code]")).not.eql(null); + expect(node.querySelector("input[label=postal_code]")).to.eql(null); + }); + + it("should render else when condition is false with reference", () => { + const formData = { + country: "France", + }; + + const { node } = createFormComponent({ + schema: schemaWithRef, + formData, + }); + + expect(node.querySelector("input[label=zip_code]")).to.eql(null); + expect(node.querySelector("input[label=postal_code]")).not.eql(null); + }); + + describe("allOf if then else", () => { + const schemaWithAllOf = { + type: "object", + properties: { + street_address: { + type: "string", + }, + country: { + enum: [ + "United States of America", + "Canada", + "United Kingdom", + "France", + ], + }, + }, + allOf: [ + { + if: { + properties: { country: { const: "United States of America" } }, + }, + then: { + properties: { zipcode: { type: "string" } }, + }, + }, + { + if: { + properties: { country: { const: "United Kingdom" } }, + }, + then: { + properties: { postcode: { type: "string" } }, + }, + }, + { + if: { + properties: { country: { const: "France" } }, + }, + then: { + properties: { telephone: { type: "string" } }, + }, + }, + ], + }; + + it("should render correctly when condition is true in allOf (1)", () => { + const formData = { + country: "United States of America", + }; + + const { node } = createFormComponent({ + schema: schemaWithAllOf, + formData, + }); + + expect(node.querySelector("input[label=zipcode]")).not.eql(null); + }); + + it("should render correctly when condition is false in allOf (1)", () => { + const formData = { + country: "", + }; + + const { node } = createFormComponent({ + schema: schemaWithAllOf, + formData, + }); + + expect(node.querySelector("input[label=zipcode]")).to.eql(null); + }); + + it("should render correctly when condition is true in allof (2)", () => { + const formData = { + country: "United Kingdom", + }; + + const { node } = createFormComponent({ + schema: schemaWithAllOf, + formData, + }); + + expect(node.querySelector("input[label=postcode]")).not.eql(null); + expect(node.querySelector("input[label=zipcode]")).to.eql(null); + expect(node.querySelector("input[label=telephone]")).to.eql(null); + }); + + it("should render correctly when condition is true in allof (3)", () => { + const formData = { + country: "France", + }; + + const { node } = createFormComponent({ + schema: schemaWithAllOf, + formData, + }); + + expect(node.querySelector("input[label=postcode]")).to.eql(null); + expect(node.querySelector("input[label=zipcode]")).to.eql(null); + expect(node.querySelector("input[label=telephone]")).not.eql(null); + }); + + const schemaWithAllOfRef = { + type: "object", + properties: { + street_address: { + type: "string", + }, + country: { + enum: [ + "United States of America", + "Canada", + "United Kingdom", + "France", + ], + }, + }, + definitions: { + unitedkingdom: { + properties: { postcode: { type: "string" } }, + }, + }, + allOf: [ + { + if: { + properties: { country: { const: "United Kingdom" } }, + }, + then: { + $ref: "#/definitions/unitedkingdom", + }, + }, + ], + }; + + it("should render correctly when condition is true when then contains a reference", () => { + const formData = { + country: "United Kingdom", + }; + + const { node } = createFormComponent({ + schema: schemaWithAllOfRef, + formData, + }); + + expect(node.querySelector("input[label=postcode]")).not.eql(null); + }); + }); +}); diff --git a/packages/core/test/utils_test.js b/packages/core/test/utils_test.js index d7c554141c..372f995c9c 100644 --- a/packages/core/test/utils_test.js +++ b/packages/core/test/utils_test.js @@ -2327,67 +2327,6 @@ describe("utils", () => { }); }); }); - - describe("allOf", () => { - it("should merge types", () => { - const schema = { - allOf: [{ type: ["string", "number", "null"] }, { type: "string" }], - }; - const definitions = {}; - const formData = {}; - expect(retrieveSchema(schema, { definitions }, formData)).eql({ - type: "string", - }); - }); - it("should not merge incompatible types", () => { - sandbox.stub(console, "warn"); - const schema = { - allOf: [{ type: "string" }, { type: "boolean" }], - }; - const definitions = {}; - const formData = {}; - expect(retrieveSchema(schema, { definitions }, formData)).eql({}); - expect( - console.warn.calledWithMatch(/could not merge subschemas in allOf/) - ).to.be.true; - }); - it("should merge types with $ref in them", () => { - const schema = { - allOf: [{ $ref: "#/definitions/1" }, { $ref: "#/definitions/2" }], - }; - const definitions = { - "1": { type: "string" }, - "2": { minLength: 5 }, - }; - const formData = {}; - expect(retrieveSchema(schema, { definitions }, formData)).eql({ - type: "string", - minLength: 5, - }); - }); - it("should properly merge schemas with nested allOf's", () => { - const schema = { - allOf: [ - { - type: "string", - allOf: [{ minLength: 2 }, { maxLength: 5 }], - }, - { - type: "string", - allOf: [{ default: "hi" }, { minLength: 4 }], - }, - ], - }; - const definitions = {}; - const formData = {}; - expect(retrieveSchema(schema, { definitions }, formData)).eql({ - type: "string", - minLength: 4, - maxLength: 5, - default: "hi", - }); - }); - }); }); describe("shouldRender", () => { diff --git a/packages/playground/package-lock.json b/packages/playground/package-lock.json index 8c44e97e5b..7f75efae96 100644 --- a/packages/playground/package-lock.json +++ b/packages/playground/package-lock.json @@ -16178,38 +16178,6 @@ "spdx-expression-parse": "^3.0.0" } }, - "validate.io-array": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/validate.io-array/-/validate.io-array-1.0.6.tgz", - "integrity": "sha1-W1osr9j4uFq7L4hroVPy2Tond00=" - }, - "validate.io-function": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/validate.io-function/-/validate.io-function-1.0.2.tgz", - "integrity": "sha1-NDoZgC7TsZaCaceA5VjpNBHAutc=" - }, - "validate.io-integer": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/validate.io-integer/-/validate.io-integer-1.0.5.tgz", - "integrity": "sha1-FoSWSAuVviJH7EQ/IjPeT4mHgGg=", - "requires": { - "validate.io-number": "^1.0.3" - } - }, - "validate.io-integer-array": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/validate.io-integer-array/-/validate.io-integer-array-1.0.0.tgz", - "integrity": "sha1-LKveAzKTpry+Bj/q/pHq9GsToIk=", - "requires": { - "validate.io-array": "^1.0.3", - "validate.io-integer": "^1.0.4" - } - }, - "validate.io-number": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/validate.io-number/-/validate.io-number-1.0.3.tgz", - "integrity": "sha1-9j/+2iSL8opnqNSODjtGGhZluvg=" - }, "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/packages/playground/src/samples/allOf.js b/packages/playground/src/samples/allOf.js index 9509d09575..fee50db34e 100644 --- a/packages/playground/src/samples/allOf.js +++ b/packages/playground/src/samples/allOf.js @@ -1,15 +1,24 @@ module.exports = { + // https://json-schema.org/understanding-json-schema/reference/combining.html schema: { - type: "object", - allOf: [ - { + definitions: { + address: { + type: "object", properties: { lorem: { type: ["string", "boolean"], default: true, }, + street_address: { type: "string" }, + city: { type: "string" }, + state: { type: "string" }, }, + required: ["street_address", "city", "state"], }, + }, + + allOf: [ + { $ref: "#/definitions/address" }, { properties: { lorem: { @@ -18,6 +27,7 @@ module.exports = { ipsum: { type: "string", }, + type: { enum: ["residential", "business"] }, }, }, ], diff --git a/packages/playground/src/samples/allofifthenelse.js b/packages/playground/src/samples/allofifthenelse.js new file mode 100644 index 0000000000..14f96b07af --- /dev/null +++ b/packages/playground/src/samples/allofifthenelse.js @@ -0,0 +1,77 @@ +module.exports = { + schema: { + type: "object", + properties: { + country: { + enum: [ + "United States of America", + "United Kingdom", + "France", + "Germany", + ], + default: "United States of America", + }, + }, + required: ["country"], + definitions: { + uk: { + properties: { + post_code: { + type: "string", + }, + }, + }, + }, + allOf: [ + { + if: { + properties: { + country: { + const: "United States of America", + default: "", + }, + }, + }, + then: { + properties: { + zip_code: { + type: "string", + }, + }, + }, + }, + { + if: { + properties: { + country: { + const: "United Kingdom", + default: "", + }, + }, + }, + then: { + $ref: "#/definitions/uk", + }, + }, + { + if: { + properties: { + country: { + const: "France", + default: "", + }, + }, + }, + then: { + properties: { + phone_number: { + type: "string", + }, + }, + }, + }, + ], + }, + uiSchema: {}, + formData: {}, +}; diff --git a/packages/playground/src/samples/ifthenelse.js b/packages/playground/src/samples/ifthenelse.js new file mode 100644 index 0000000000..ad057e7cfb --- /dev/null +++ b/packages/playground/src/samples/ifthenelse.js @@ -0,0 +1,75 @@ +module.exports = { + schema: { + type: "object", + properties: { + country: { + enum: [ + "United States of America", + "United Kingdom", + "France", + "Germany", + ], + default: "United States of America", + }, + }, + required: ["country"], + definitions: { + uk: { + properties: { + post_code: { + type: "string", + }, + }, + }, + }, + allOf: [ + { + if: { + properties: { + country: { + const: "United States of America", + default: "", + }, + }, + }, + then: { + properties: { + zip_code: { + type: "string", + }, + }, + }, + }, + { + if: { + properties: { + country: { + const: "United Kingdom", + default: "", + }, + }, + }, + then: { + $ref: "#/definitions/uk", + }, + }, + { + if: { + properties: { + country: { + const: "France", + default: "", + }, + }, + }, + then: { + properties: { + phone_number: { + type: "string", + }, + }, + }, + }, + ], + }, +}; diff --git a/packages/playground/src/samples/ifthenelserefs.js b/packages/playground/src/samples/ifthenelserefs.js new file mode 100644 index 0000000000..0953ed1fa6 --- /dev/null +++ b/packages/playground/src/samples/ifthenelserefs.js @@ -0,0 +1,41 @@ +module.exports = { + schema: { + type: "object", + properties: { + country: { + enum: ["United States of America", "Canada"], + }, + }, + if: { + properties: { + country: { + const: "United States of America", + }, + }, + }, + then: { + $ref: "#/definitions/us", + }, + else: { + $ref: "#/definitions/other", + }, + definitions: { + us: { + properties: { + zip_code: { + type: "string", + }, + }, + }, + other: { + properties: { + postal_code: { + type: "string", + }, + }, + }, + }, + }, + uiSchema: {}, + formData: {}, +}; diff --git a/packages/playground/src/samples/index.js b/packages/playground/src/samples/index.js index 991f5215f8..b909b8030b 100644 --- a/packages/playground/src/samples/index.js +++ b/packages/playground/src/samples/index.js @@ -26,6 +26,9 @@ import nullable from "./nullable"; import nullField from "./null"; import errorSchema from "./errorSchema"; import defaults from "./defaults"; +import ifthenelse from "./ifthenelse"; +import ifthenelserefs from "./ifthenelserefs"; +import allofifthenelse from "./allofifthenelse"; export const samples = { Simple: simple, @@ -56,4 +59,7 @@ export const samples = { Nullable: nullable, ErrorSchema: errorSchema, Defaults: defaults, + "If Then Else": ifthenelse, + "If Then Else ($ref)": ifthenelserefs, + "All of If Then Else": allofifthenelse, }; diff --git a/packages/playground/src/samples/simple.js b/packages/playground/src/samples/simple.js index e356a9278e..59f8cc7a5f 100644 --- a/packages/playground/src/samples/simple.js +++ b/packages/playground/src/samples/simple.js @@ -68,7 +68,6 @@ module.exports = { formData: { lastName: "Norris", age: 75, - bio: "Roundhouse kicking asses since 1940", password: "noneed", }, }; From 51387a03e9d9bae055fac668a67a2907ee5e2195 Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Mon, 12 Jul 2021 14:02:34 -0400 Subject: [PATCH 02/17] Fix playground example --- packages/playground/src/samples/ifthenelse.js | 78 +++++-------------- 1 file changed, 18 insertions(+), 60 deletions(-) diff --git a/packages/playground/src/samples/ifthenelse.js b/packages/playground/src/samples/ifthenelse.js index ad057e7cfb..a47ecdcca5 100644 --- a/packages/playground/src/samples/ifthenelse.js +++ b/packages/playground/src/samples/ifthenelse.js @@ -2,74 +2,32 @@ module.exports = { schema: { type: "object", properties: { + street_address: { + type: "string", + }, country: { - enum: [ - "United States of America", - "United Kingdom", - "France", - "Germany", - ], default: "United States of America", + enum: ["United States of America", "Canada"], }, }, - required: ["country"], - definitions: { - uk: { - properties: { - post_code: { - type: "string", - }, - }, - }, + if: { + properties: { country: { const: "United States of America" } }, }, - allOf: [ - { - if: { - properties: { - country: { - const: "United States of America", - default: "", - }, - }, - }, - then: { - properties: { - zip_code: { - type: "string", - }, - }, - }, - }, - { - if: { - properties: { - country: { - const: "United Kingdom", - default: "", - }, - }, - }, - then: { - $ref: "#/definitions/uk", + then: { + properties: { + zip_code: { + type: "string", + pattern: "[0-9]{5}(-[0-9]{4})?", }, }, - { - if: { - properties: { - country: { - const: "France", - default: "", - }, - }, - }, - then: { - properties: { - phone_number: { - type: "string", - }, - }, + }, + else: { + properties: { + postal_code: { + type: "string", + pattern: "[A-Z][0-9][A-Z] [0-9][A-Z][0-9]", }, }, - ], + }, }, }; From fca3592bce07c18455e1c99b399237ff427c4cc7 Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Mon, 12 Jul 2021 14:04:23 -0400 Subject: [PATCH 03/17] Fix resolveSchema to handle more cases --- packages/core/src/utils.js | 53 +++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/packages/core/src/utils.js b/packages/core/src/utils.js index 32ac1db55e..8535adf520 100644 --- a/packages/core/src/utils.js +++ b/packages/core/src/utils.js @@ -1,6 +1,5 @@ import React from "react"; import * as ReactIs from "react-is"; -import mergeAllOf from "json-schema-merge-allof"; import fill from "core-js-pure/features/array/fill"; import union from "lodash/union"; import jsonpointer from "jsonpointer"; @@ -668,7 +667,7 @@ export function resolveSchema(schema, rootSchema = {}, formData = {}) { } else if (schema.hasOwnProperty("dependencies")) { const resolvedSchema = resolveDependencies(schema, rootSchema, formData); return retrieveSchema(resolvedSchema, rootSchema, formData); - } else if (schema.hasOwnProperty("allOf")) { + } else if (schema["allOf"]) { return { ...schema, allOf: schema.allOf.map(allOfSubschema => @@ -676,7 +675,8 @@ export function resolveSchema(schema, rootSchema = {}, formData = {}) { ), }; } else { - // No $ref or dependencies attribute found, returning the original schema. + // No $ref, dependencies, or allOf attribute found, so there's nothing to resolve. + // Returning the original schema. return schema; } } @@ -700,19 +700,7 @@ export function retrieveSchema(schema, rootSchema = {}, formData = {}) { } let resolvedSchema = resolveSchema(schema, rootSchema, formData); - const hasAdditionalProperties = - resolvedSchema.hasOwnProperty("additionalProperties") && - resolvedSchema.additionalProperties !== false; - - if (hasAdditionalProperties) { - resolvedSchema = stubExistingAdditionalProperties( - resolvedSchema, - rootSchema, - formData - ); - } - - if ("if" in resolvedSchema) { + while ("if" in resolvedSchema) { // Note that if and else are key words in javascript so extract to variable names which are allowed var { if: expression, @@ -720,7 +708,10 @@ export function retrieveSchema(schema, rootSchema = {}, formData = {}) { else: otherwise, ...resolvedSchemaLessConditional } = resolvedSchema; - var conditionalSchema = isValid(expression, formData) ? then : otherwise; + + var conditionalSchema = isValid(expression, formData, rootSchema) + ? then + : otherwise; if (conditionalSchema) { conditionalSchema = resolveSchema( @@ -728,11 +719,11 @@ export function retrieveSchema(schema, rootSchema = {}, formData = {}) { rootSchema, formData ); - resolvedSchema = mergeSchemas( - resolvedSchemaLessConditional, - conditionalSchema - ); } + resolvedSchema = mergeSchemas( + resolvedSchemaLessConditional, + conditionalSchema || {} + ); } let allOf = resolvedSchema.allOf; @@ -743,19 +734,33 @@ export function retrieveSchema(schema, rootSchema = {}, formData = {}) { // if we see an if in our all of schema then evaluate the if schema and select the then / else, not sure if we should still merge without our if then else if ("if" in allOfSchema) { - allOfSchema = isValid(allOfSchema.if, formData) + allOfSchema = isValid(allOfSchema.if, formData, rootSchema) ? allOfSchema.then : allOfSchema.else; } if (allOfSchema) { allOfSchema = resolveSchema(allOfSchema, rootSchema, formData); // resolve references etc. - resolvedSchema = mergeSchemas(resolvedSchema, allOfSchema); - delete resolvedSchema.allOf; + resolvedSchema = { + ...mergeSchemas(resolvedSchema, allOfSchema), + allOf: undefined, + }; } } } + const hasAdditionalProperties = + resolvedSchema.hasOwnProperty("additionalProperties") && + resolvedSchema.additionalProperties !== false; + + if (hasAdditionalProperties) { + resolvedSchema = stubExistingAdditionalProperties( + resolvedSchema, + rootSchema, + formData + ); + } + return resolvedSchema; } From ba15fd3adbc94c6ad75b6b12132fc745f7bd4f26 Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Mon, 12 Jul 2021 15:02:13 -0400 Subject: [PATCH 04/17] Don't remove the root schema after validating --- packages/core/src/validate.js | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/core/src/validate.js b/packages/core/src/validate.js index f561b1386a..7aed5b807c 100644 --- a/packages/core/src/validate.js +++ b/packages/core/src/validate.js @@ -301,17 +301,26 @@ export function withIdRefPrefix(schemaNode) { */ export function isValid(schema, data, rootSchema) { try { - // add the rootSchema ROOT_SCHEMA_PREFIX as id. + // add the rootSchema. if it has no $id, use ROOT_SCHEMA_PREFIX as the cache key. // then rewrite the schema ref's to point to the rootSchema // this accounts for the case where schema have references to models // that lives in the rootSchema but not in the schema in question. - return ajv - .addSchema(rootSchema, ROOT_SCHEMA_PREFIX) - .validate(withIdRefPrefix(schema), data); + let rootSchemaAdded; + if (rootSchema.$id) { + rootSchemaAdded = !!ajv.getSchema(rootSchema.$id); + } else { + rootSchemaAdded = !!ajv.getSchema(ROOT_SCHEMA_PREFIX); + } + + if (!rootSchemaAdded) { + if (rootSchema.$id) { + ajv.addSchema(rootSchema); + } else { + ajv.addSchema(rootSchema, ROOT_SCHEMA_PREFIX); + } + } + return ajv.validate(withIdRefPrefix(schema), data); } catch (e) { return false; - } finally { - // make sure we remove the rootSchema from the global ajv instance - ajv.removeSchema(ROOT_SCHEMA_PREFIX); } } From 75665caa00fa24e60bb0f176ff2a1f8400ef6002 Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Mon, 12 Jul 2021 15:05:31 -0400 Subject: [PATCH 05/17] Warn when encountering error in validation --- packages/core/src/validate.js | 1 + packages/core/test/allOf_test.js | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/validate.js b/packages/core/src/validate.js index 7aed5b807c..cf9ca5e4ac 100644 --- a/packages/core/src/validate.js +++ b/packages/core/src/validate.js @@ -321,6 +321,7 @@ export function isValid(schema, data, rootSchema) { } return ajv.validate(withIdRefPrefix(schema), data); } catch (e) { + console.warn("Encountered error while validating schema", e); return false; } } diff --git a/packages/core/test/allOf_test.js b/packages/core/test/allOf_test.js index 709040406d..c5d4af48e1 100644 --- a/packages/core/test/allOf_test.js +++ b/packages/core/test/allOf_test.js @@ -14,7 +14,6 @@ describe("allOf", () => { }); it("should render a regular input element with a single type, when multiple types specified", () => { - // I think that this is wrong const schema = { type: "object", properties: { @@ -31,7 +30,7 @@ describe("allOf", () => { expect(node.querySelectorAll("input")).to.have.length.of(1); }); - it("should properly merge multiple schemas as per https://json-schema.org/understanding-json-schema/reference/combining.html", () => { + it("should properly merge multiple schemas with refs", () => { const schema = { definitions: { address: { From 23288ae151c91fdba8d72489895f15d3f6b59755 Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Mon, 12 Jul 2021 15:05:59 -0400 Subject: [PATCH 06/17] Add tests to cover error cases --- packages/core/test/ifthenelse_test.js | 134 ++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/packages/core/test/ifthenelse_test.js b/packages/core/test/ifthenelse_test.js index 3a0096a13a..694011aad7 100644 --- a/packages/core/test/ifthenelse_test.js +++ b/packages/core/test/ifthenelse_test.js @@ -287,4 +287,138 @@ describe("conditional items", () => { expect(node.querySelector("input[label=postcode]")).not.eql(null); }); }); + + it("handles nested if then else", () => { + const schemaWithNested = { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + country: { + enum: ["USA"], + }, + }, + required: ["country"], + if: { + properties: { + country: { + const: "USA", + }, + }, + required: ["country"], + }, + then: { + properties: { + state: { + type: "string", + anyOf: [ + { + const: "California", + }, + { + const: "New York", + }, + ], + }, + }, + required: ["state"], + if: { + properties: { + state: { + const: "New York", + }, + }, + required: ["state"], + }, + then: { + properties: { + city: { + type: "string", + anyOf: [ + { + const: "New York City", + }, + { + const: "Buffalo", + }, + { + const: "Rochester", + }, + ], + }, + }, + }, + else: { + if: { + properties: { + state: { + const: "California", + }, + }, + required: ["state"], + }, + then: { + properties: { + city: { + type: "string", + anyOf: [ + { + const: "Los Angeles", + }, + { + const: "San Diego", + }, + { + const: "San Jose", + }, + ], + }, + }, + }, + }, + }, + }; + + const formData = { + country: "USA", + state: "California", + }; + const { node } = createFormComponent({ + schema: schemaWithNested, + formData, + }); + expect(node.querySelector("select[id=root_country]")).not.eql(null); + expect(node.querySelector("select[id=root_state]")).not.eql(null); + expect(node.querySelector("select[id=root_city]")).not.eql(null); + }); + + it("handles additionalProperties with if then else", () => { + /** + * Ensures that fields defined in "then" or "else" (e.g. zipcode) are handled + * with regular form fields, not as additional properties + */ + + const formData = { + country: "United States of America", + zipcode: "12345", + otherKey: "otherValue", + }; + const { node } = createFormComponent({ + schema: { + ...schema, + additionalProperties: true, + }, + formData, + }); + + // The zipcode field exists, but not as an additional property + expect(node.querySelector("input[label=zipcode]")).not.eql(null); + expect( + node.querySelector("div.form-additional input[label=zipcode]") + ).to.eql(null); + + // The "otherKey" field exists as an additional property + expect( + node.querySelector("div.form-additional input[label=otherKey]") + ).not.eql(null); + }); }); From 78fc62c971b8e5b19abcf86513ebc868ce39c614 Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Thu, 15 Jul 2021 14:09:07 -0400 Subject: [PATCH 07/17] Memoize withIdRefPrefix and prevent recompiling during form validation --- packages/core/src/validate.js | 93 ++++++++++++++++------------------- 1 file changed, 43 insertions(+), 50 deletions(-) diff --git a/packages/core/src/validate.js b/packages/core/src/validate.js index cf9ca5e4ac..58c5c97bea 100644 --- a/packages/core/src/validate.js +++ b/packages/core/src/validate.js @@ -1,13 +1,12 @@ import toPath from "lodash/toPath"; import Ajv from "ajv"; -let ajv = createAjvInstance(); -import { deepEquals, getDefaultFormState } from "./utils"; +let ajv; +import { getDefaultFormState } from "./utils"; -let formerCustomFormats = null; -let formerMetaSchema = null; const ROOT_SCHEMA_PREFIX = "__rjsf_rootSchema"; import { isObject, mergeObjects } from "./utils"; +import _ from "lodash"; function createAjvInstance() { const ajv = new Ajv({ @@ -161,6 +160,8 @@ function transformAjvErrors(errors = []) { }); } +let validate; + /** * This function processes the formData with a user `validate` contributed * function, which receives the form data and an `errorHandler` object that @@ -178,43 +179,30 @@ export default function validateFormData( const rootSchema = schema; formData = getDefaultFormState(schema, formData, rootSchema, true); - const newMetaSchemas = !deepEquals(formerMetaSchema, additionalMetaSchemas); - const newFormats = !deepEquals(formerCustomFormats, customFormats); - - if (newMetaSchemas || newFormats) { + // TODO: this will not work if root schema changes, or if additional metaschemas or formats are added after-the-fact + // Maybe we should map the (schema, metaschema, format) tuple to a cached ajv instance and/or compiled validator? + // It's also a bad idea to put off compiling the validator until validation-time, but that will require a larger re-write + if (validate == null) { ajv = createAjvInstance(); - } - // add more schemas to validate against - if ( - additionalMetaSchemas && - newMetaSchemas && - Array.isArray(additionalMetaSchemas) - ) { - ajv.addMetaSchema(additionalMetaSchemas); - formerMetaSchema = additionalMetaSchemas; - } - - // add more custom formats to validate against - if (customFormats && newFormats && isObject(customFormats)) { - Object.keys(customFormats).forEach(formatName => { - ajv.addFormat(formatName, customFormats[formatName]); - }); + // add more schemas to validate against + if (additionalMetaSchemas && Array.isArray(additionalMetaSchemas)) { + ajv.addMetaSchema(additionalMetaSchemas); + } - formerCustomFormats = customFormats; + // add more custom formats to validate against + if (customFormats && isObject(customFormats)) { + Object.keys(customFormats).forEach(formatName => { + ajv.addFormat(formatName, customFormats[formatName]); + }); + } + validate = ajv.compile(schema); } let validationError = null; - try { - ajv.validate(schema, formData); - } catch (err) { - validationError = err; - } - - let errors = transformAjvErrors(ajv.errors); - // Clear errors to prevent persistent errors, see #1104 + validate(formData); - ajv.errors = null; + let errors = transformAjvErrors(validate.errors); const noProperMetaSchema = validationError && @@ -269,7 +257,7 @@ export default function validateFormData( * Recursively prefixes all $ref's in a schema with `ROOT_SCHEMA_PREFIX` * This is used in isValid to make references to the rootSchema */ -export function withIdRefPrefix(schemaNode) { +function _withIdRefPrefix(schemaNode) { let obj = schemaNode; if (schemaNode.constructor === Object) { obj = { ...schemaNode }; @@ -294,6 +282,16 @@ export function withIdRefPrefix(schemaNode) { return obj; } +// Convert all arguments to one long string. This can be expensive with multiple, large objects, so use sparingly +const cacheKeyFn = (...args) => args.map(arg => JSON.stringify(arg)).join("_"); + +/** + * _withIdRefPrefix creates a new schema object every time it runs, which prevents us from utilizing AJV's cache, triggering a compile every run + * We can memoize the function to reuse schemas that we've already resolved, which lets us hit AJV's cache and avoid expensive recompiles + */ +export const withIdRefPrefix = _.memoize(_withIdRefPrefix, cacheKeyFn); + +let subschemaAjv; /** * Validates data against a schema, returning true if the data is valid, or * false otherwise. If the schema is invalid, then this function will return @@ -301,25 +299,20 @@ export function withIdRefPrefix(schemaNode) { */ export function isValid(schema, data, rootSchema) { try { + if (subschemaAjv == null) { + if (rootSchema.$id) { + delete rootSchema.$id; + } + subschemaAjv = createAjvInstance(); + subschemaAjv.addSchema(rootSchema, ROOT_SCHEMA_PREFIX); + } + + return subschemaAjv.validate(withIdRefPrefix(schema), data); + // add the rootSchema. if it has no $id, use ROOT_SCHEMA_PREFIX as the cache key. // then rewrite the schema ref's to point to the rootSchema // this accounts for the case where schema have references to models // that lives in the rootSchema but not in the schema in question. - let rootSchemaAdded; - if (rootSchema.$id) { - rootSchemaAdded = !!ajv.getSchema(rootSchema.$id); - } else { - rootSchemaAdded = !!ajv.getSchema(ROOT_SCHEMA_PREFIX); - } - - if (!rootSchemaAdded) { - if (rootSchema.$id) { - ajv.addSchema(rootSchema); - } else { - ajv.addSchema(rootSchema, ROOT_SCHEMA_PREFIX); - } - } - return ajv.validate(withIdRefPrefix(schema), data); } catch (e) { console.warn("Encountered error while validating schema", e); return false; From 58d333577f52c4cd33b9ef7e505c0d3e6b289aaa Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Thu, 15 Jul 2021 16:47:27 -0400 Subject: [PATCH 08/17] Fix tests --- packages/core/src/validate.js | 58 ++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/packages/core/src/validate.js b/packages/core/src/validate.js index 58c5c97bea..c451e39998 100644 --- a/packages/core/src/validate.js +++ b/packages/core/src/validate.js @@ -1,7 +1,10 @@ import toPath from "lodash/toPath"; import Ajv from "ajv"; let ajv; -import { getDefaultFormState } from "./utils"; +let previousSchema; +let previousMetaSchemas; +let previousFormats; +import { deepEquals, getDefaultFormState } from "./utils"; const ROOT_SCHEMA_PREFIX = "__rjsf_rootSchema"; @@ -161,7 +164,7 @@ function transformAjvErrors(errors = []) { } let validate; - +let compileError; /** * This function processes the formData with a user `validate` contributed * function, which receives the form data and an `errorHandler` object that @@ -179,10 +182,26 @@ export default function validateFormData( const rootSchema = schema; formData = getDefaultFormState(schema, formData, rootSchema, true); + const schemaChanged = !deepEquals(previousSchema, schema); + const metaSchemasChanged = !deepEquals( + previousMetaSchemas, + additionalMetaSchemas + ); + const formatsChanged = !deepEquals(previousFormats, customFormats); + // TODO: this will not work if root schema changes, or if additional metaschemas or formats are added after-the-fact // Maybe we should map the (schema, metaschema, format) tuple to a cached ajv instance and/or compiled validator? // It's also a bad idea to put off compiling the validator until validation-time, but that will require a larger re-write - if (validate == null) { + if ( + validate == null || + schemaChanged || + metaSchemasChanged || + formatsChanged + ) { + compileError = null; + previousSchema = schema; + previousMetaSchemas = additionalMetaSchemas; + previousFormats = customFormats; ajv = createAjvInstance(); // add more schemas to validate against @@ -196,25 +215,32 @@ export default function validateFormData( ajv.addFormat(formatName, customFormats[formatName]); }); } - validate = ajv.compile(schema); + try { + validate = ajv.compile(schema); + } catch (e) { + compileError = e; + } } - let validationError = null; - validate(formData); - - let errors = transformAjvErrors(validate.errors); + let errors; + if (!compileError) { + validate(formData); + errors = transformAjvErrors(validate.errors); + } else { + errors = transformAjvErrors(ajv.errors); + } const noProperMetaSchema = - validationError && - validationError.message && - typeof validationError.message === "string" && - validationError.message.includes("no schema with key or ref "); + compileError && + compileError.message && + typeof compileError.message === "string" && + compileError.message.includes("no schema with key or ref "); if (noProperMetaSchema) { errors = [ ...errors, { - stack: validationError.message, + stack: compileError.message, }, ]; } @@ -229,7 +255,7 @@ export default function validateFormData( ...errorSchema, ...{ $schema: { - __errors: [validationError.message], + __errors: [compileError.message], }, }, }; @@ -292,6 +318,7 @@ const cacheKeyFn = (...args) => args.map(arg => JSON.stringify(arg)).join("_"); export const withIdRefPrefix = _.memoize(_withIdRefPrefix, cacheKeyFn); let subschemaAjv; +let previousRootSchema = null; /** * Validates data against a schema, returning true if the data is valid, or * false otherwise. If the schema is invalid, then this function will return @@ -299,7 +326,8 @@ let subschemaAjv; */ export function isValid(schema, data, rootSchema) { try { - if (subschemaAjv == null) { + if (subschemaAjv == null || !deepEquals(previousRootSchema, rootSchema)) { + previousRootSchema = rootSchema; if (rootSchema.$id) { delete rootSchema.$id; } From 5e7a08dce80c0c1c85293ac3465c63d65693c6d4 Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Fri, 16 Jul 2021 15:50:21 -0400 Subject: [PATCH 09/17] Memoize all the things --- packages/core/src/utils.js | 56 ++++++++++++++++++++++++++--------- packages/core/src/validate.js | 34 ++++++++++++--------- 2 files changed, 62 insertions(+), 28 deletions(-) diff --git a/packages/core/src/utils.js b/packages/core/src/utils.js index 8535adf520..34e0f87145 100644 --- a/packages/core/src/utils.js +++ b/packages/core/src/utils.js @@ -6,7 +6,11 @@ import jsonpointer from "jsonpointer"; import fields from "./components/fields"; import widgets from "./components/widgets"; import validateFormData, { isValid } from "./validate"; +import _ from "lodash"; +// Use the same default object to optimize memoized functions that rely on referential equality +const DEFAULT_ROOT_SCHEMA = {}; +const DEFAULT_FORM_DATA = {}; export const ADDITIONAL_PROPERTY_FLAG = "__additional_property"; const widgetMap = { @@ -170,11 +174,14 @@ export function hasWidget(schema, widget, registeredWidgets = {}) { } } -function computeDefaults( +const cacheKeyFn = (...args) => args.map(arg => JSON.stringify(arg)).join("_"); +const computeDefaults = _.memoize(_computeDefaults, cacheKeyFn); + +function _computeDefaults( _schema, parentDefaults, rootSchema, - rawFormData = {}, + rawFormData = DEFAULT_FORM_DATA, includeUndefinedValues = false ) { let schema = isObject(_schema) ? _schema : {}; @@ -302,7 +309,7 @@ function computeDefaults( export function getDefaultFormState( _schema, formData, - rootSchema = {}, + rootSchema = DEFAULT_ROOT_SCHEMA, includeUndefinedValues = false ) { if (!isObject(_schema)) { @@ -520,7 +527,7 @@ export function toConstant(schema) { } } -export function isSelect(_schema, rootSchema = {}) { +export function isSelect(_schema, rootSchema = DEFAULT_ROOT_SCHEMA) { const schema = retrieveSchema(_schema, rootSchema); const altSchemas = schema.oneOf || schema.anyOf; if (Array.isArray(schema.enum)) { @@ -531,14 +538,18 @@ export function isSelect(_schema, rootSchema = {}) { return false; } -export function isMultiSelect(schema, rootSchema = {}) { +export function isMultiSelect(schema, rootSchema = DEFAULT_ROOT_SCHEMA) { if (!schema.uniqueItems || !schema.items) { return false; } return isSelect(schema.items, rootSchema); } -export function isFilesArray(schema, uiSchema, rootSchema = {}) { +export function isFilesArray( + schema, + uiSchema, + rootSchema = DEFAULT_ROOT_SCHEMA +) { if (uiSchema["ui:widget"] === "files") { return true; } else if (schema.items) { @@ -583,7 +594,7 @@ export function optionsList(schema) { } } -export function findSchemaDefinition($ref, rootSchema = {}) { +export function findSchemaDefinition($ref, rootSchema = DEFAULT_ROOT_SCHEMA) { const origRef = $ref; if ($ref.startsWith("#")) { // Decode URI fragment representation. @@ -624,8 +635,8 @@ export const guessType = function guessType(value) { // This function will create new "properties" items for each key in our formData export function stubExistingAdditionalProperties( schema, - rootSchema = {}, - formData = {} + rootSchema = DEFAULT_ROOT_SCHEMA, + formData = DEFAULT_FORM_DATA ) { // Clone the schema so we don't ruin the consumer's original schema = { @@ -661,7 +672,13 @@ export function stubExistingAdditionalProperties( return schema; } -export function resolveSchema(schema, rootSchema = {}, formData = {}) { +export const resolveSchema = _.memoize(_resolveSchema, cacheKeyFn); + +export function _resolveSchema( + schema, + rootSchema = DEFAULT_ROOT_SCHEMA, + formData = DEFAULT_FORM_DATA +) { if (schema.hasOwnProperty("$ref")) { return resolveReference(schema, rootSchema, formData); } else if (schema.hasOwnProperty("dependencies")) { @@ -694,9 +711,13 @@ function resolveReference(schema, rootSchema, formData) { ); } -export function retrieveSchema(schema, rootSchema = {}, formData = {}) { +export function _retrieveSchema( + schema, + rootSchema = DEFAULT_ROOT_SCHEMA, + formData = DEFAULT_FORM_DATA +) { if (!isObject(schema)) { - return {}; + return DEFAULT_ROOT_SCHEMA; } let resolvedSchema = resolveSchema(schema, rootSchema, formData); @@ -764,6 +785,8 @@ export function retrieveSchema(schema, rootSchema = {}, formData = {}) { return resolvedSchema; } +export const retrieveSchema = _.memoize(_retrieveSchema, cacheKeyFn); + function resolveDependencies(schema, rootSchema, formData) { // Drop the dependencies from the source schema. let { dependencies = {}, ...resolvedSchema } = schema; @@ -1034,7 +1057,7 @@ export function toIdSchema( schema, id, rootSchema, - formData = {}, + formData = DEFAULT_FORM_DATA, idPrefix = "root" ) { const idSchema = { @@ -1066,7 +1089,12 @@ export function toIdSchema( return idSchema; } -export function toPathSchema(schema, name = "", rootSchema, formData = {}) { +export function toPathSchema( + schema, + name = "", + rootSchema, + formData = DEFAULT_FORM_DATA +) { const pathSchema = { $name: name.replace(/^\./, ""), }; diff --git a/packages/core/src/validate.js b/packages/core/src/validate.js index c451e39998..1998aebd57 100644 --- a/packages/core/src/validate.js +++ b/packages/core/src/validate.js @@ -189,9 +189,6 @@ export default function validateFormData( ); const formatsChanged = !deepEquals(previousFormats, customFormats); - // TODO: this will not work if root schema changes, or if additional metaschemas or formats are added after-the-fact - // Maybe we should map the (schema, metaschema, format) tuple to a cached ajv instance and/or compiled validator? - // It's also a bad idea to put off compiling the validator until validation-time, but that will require a larger re-write if ( validate == null || schemaChanged || @@ -317,8 +314,8 @@ const cacheKeyFn = (...args) => args.map(arg => JSON.stringify(arg)).join("_"); */ export const withIdRefPrefix = _.memoize(_withIdRefPrefix, cacheKeyFn); -let subschemaAjv; -let previousRootSchema = null; +let compiledSubschemaMap = new WeakMap(); +let withIdRefPrefixMap = new WeakMap(); /** * Validates data against a schema, returning true if the data is valid, or * false otherwise. If the schema is invalid, then this function will return @@ -326,23 +323,32 @@ let previousRootSchema = null; */ export function isValid(schema, data, rootSchema) { try { - if (subschemaAjv == null || !deepEquals(previousRootSchema, rootSchema)) { - previousRootSchema = rootSchema; - if (rootSchema.$id) { - delete rootSchema.$id; - } + let subschemaAjv; + if (compiledSubschemaMap.has(rootSchema)) { + subschemaAjv = compiledSubschemaMap.get(rootSchema); + } else { + // add the rootSchema, using the ROOT_SCHEMA_PREFIX as the cache key. + subschemaAjv = createAjvInstance(); subschemaAjv.addSchema(rootSchema, ROOT_SCHEMA_PREFIX); + compiledSubschemaMap.set(rootSchema, subschemaAjv); } - return subschemaAjv.validate(withIdRefPrefix(schema), data); - - // add the rootSchema. if it has no $id, use ROOT_SCHEMA_PREFIX as the cache key. - // then rewrite the schema ref's to point to the rootSchema + // rewrite the schema ref's to point to the rootSchema // this accounts for the case where schema have references to models // that lives in the rootSchema but not in the schema in question. + let prefixedSchema; + if (withIdRefPrefixMap.has(schema)) { + prefixedSchema = withIdRefPrefixMap.get(schema); + } else { + prefixedSchema = _withIdRefPrefix(schema); + withIdRefPrefixMap.set(schema, prefixedSchema); + } + + return subschemaAjv.validate(prefixedSchema, data); } catch (e) { console.warn("Encountered error while validating schema", e); return false; } } +// export const isValid = _.memoize(_isValid, cacheKeyFn); From 667903b89c71cc70cc81ed2569e8c99fabcf5af2 Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Thu, 22 Jul 2021 09:16:33 -0400 Subject: [PATCH 10/17] Import only memoize from lodash --- packages/core/src/utils.js | 8 ++++---- packages/core/src/validate.js | 5 ++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/core/src/utils.js b/packages/core/src/utils.js index 34e0f87145..8c9354606e 100644 --- a/packages/core/src/utils.js +++ b/packages/core/src/utils.js @@ -6,7 +6,7 @@ import jsonpointer from "jsonpointer"; import fields from "./components/fields"; import widgets from "./components/widgets"; import validateFormData, { isValid } from "./validate"; -import _ from "lodash"; +import memoize from "lodash/memoize"; // Use the same default object to optimize memoized functions that rely on referential equality const DEFAULT_ROOT_SCHEMA = {}; @@ -175,7 +175,7 @@ export function hasWidget(schema, widget, registeredWidgets = {}) { } const cacheKeyFn = (...args) => args.map(arg => JSON.stringify(arg)).join("_"); -const computeDefaults = _.memoize(_computeDefaults, cacheKeyFn); +const computeDefaults = memoize(_computeDefaults, cacheKeyFn); function _computeDefaults( _schema, @@ -672,7 +672,7 @@ export function stubExistingAdditionalProperties( return schema; } -export const resolveSchema = _.memoize(_resolveSchema, cacheKeyFn); +export const resolveSchema = memoize(_resolveSchema, cacheKeyFn); export function _resolveSchema( schema, @@ -785,7 +785,7 @@ export function _retrieveSchema( return resolvedSchema; } -export const retrieveSchema = _.memoize(_retrieveSchema, cacheKeyFn); +export const retrieveSchema = memoize(_retrieveSchema, cacheKeyFn); function resolveDependencies(schema, rootSchema, formData) { // Drop the dependencies from the source schema. diff --git a/packages/core/src/validate.js b/packages/core/src/validate.js index 1998aebd57..8f458f2a87 100644 --- a/packages/core/src/validate.js +++ b/packages/core/src/validate.js @@ -9,7 +9,7 @@ import { deepEquals, getDefaultFormState } from "./utils"; const ROOT_SCHEMA_PREFIX = "__rjsf_rootSchema"; import { isObject, mergeObjects } from "./utils"; -import _ from "lodash"; +import memoize from "lodash/memoize"; function createAjvInstance() { const ajv = new Ajv({ @@ -312,7 +312,7 @@ const cacheKeyFn = (...args) => args.map(arg => JSON.stringify(arg)).join("_"); * _withIdRefPrefix creates a new schema object every time it runs, which prevents us from utilizing AJV's cache, triggering a compile every run * We can memoize the function to reuse schemas that we've already resolved, which lets us hit AJV's cache and avoid expensive recompiles */ -export const withIdRefPrefix = _.memoize(_withIdRefPrefix, cacheKeyFn); +export const withIdRefPrefix = memoize(_withIdRefPrefix, cacheKeyFn); let compiledSubschemaMap = new WeakMap(); let withIdRefPrefixMap = new WeakMap(); @@ -351,4 +351,3 @@ export function isValid(schema, data, rootSchema) { return false; } } -// export const isValid = _.memoize(_isValid, cacheKeyFn); From ab36d1ed5336d9774224462ea8ebb1d16a40f5dd Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Thu, 22 Jul 2021 10:11:58 -0400 Subject: [PATCH 11/17] Revert outdated docs reorganization --- docs/index.md | 80 +-------------------------------------------------- 1 file changed, 1 insertion(+), 79 deletions(-) diff --git a/docs/index.md b/docs/index.md index d40cf8d54f..23d433147b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -81,85 +81,7 @@ For more information on what themes we support, see [Using Themes](usage/themes) disabled until https://github.com/rjsf-team/react-jsonschema-form/issues/1584 is resolved -Sometimes you may want to trigger events or modify external state when a field has been focused, so you can pass an `onFocus` handler, which will receive the id of the input that is focused and the field value. - -### Submit form programmatically -You can use the reference to get your `Form` component and call the `submit` method to submit the form programmatically without a submit button. -This method will dispatch the `submit` event of the form, and the function, that is passed to `onSubmit` props, will be called. - -```js -const onSubmit = ({formData}) => console.log("Data submitted: ", formData); -let yourForm; - -render(( - {yourForm = form;}}/> -), document.getElementById("app")); - -yourForm.submit(); -``` - -## Styling your forms - -This library renders form fields and widgets leveraging the [Bootstrap](http://getbootstrap.com/) semantics. That means your forms will be beautiful by default if you're loading its stylesheet in your page. - -You're not necessarily forced to use Bootstrap; while it uses its semantics, it also provides a bunch of other class names so you can bring new styles or override default ones quite easily in your own personalized stylesheet. That's just HTML after allĀ :) - -If you're okay with using styles from the Bootstrap ecosystem though, then the good news is that you have access to many themes for it, which are compatible with our generated forms! - -Here are some examples from the [playground](https://rjsf-team.github.io/react-jsonschema-form/), using some of the [Bootswatch](http://bootswatch.com/) free themes: - -![](https://i.imgur.com/1Z5oUK3.png) -![](https://i.imgur.com/IMFqMwK.png) -![](https://i.imgur.com/HOACwt5.png) - -Last, if you really really want to override the semantics generated by the lib, you can always create and use your own custom [widget](advanced-customization.md#custom-widget-components), [field](advanced-customization.md#custom-field-components) and/or [schema field](advanced-customization.md#custom-schemafield) components. - - -## JSON Schema supporting status - -This component follows [JSON Schema](http://json-schema.org/documentation.html) specs. We currently support JSON Schema-07 by default, but we also support other JSON schema versions through the [custom schema validation](https://react-jsonschema-form.readthedocs.io/en/latest/validation/#custom-schema-validation) feature. Due to the limitation of form widgets, there are some exceptions as follows: - -* `additionalItems` keyword for arrays - - This keyword works when `items` is an array. `additionalItems: true` is not supported because there's no widget to represent an item of any type. In this case it will be treated as no additional items allowed. `additionalItems` being a valid schema is supported. - -* `anyOf`, `allOf`, and `oneOf`, or multiple `types` (i.e. `"type": ["string", "array"]`) - - The `anyOf` and `oneOf` keywords are supported; however, properties declared inside the `anyOf/oneOf` should not overlap with properties "outside" of the `anyOf/oneOf`. - - You can also use `oneOf` with [schema dependencies](dependencies.md#schema-dependencies) to dynamically add schema properties based on input data. - - The `allOf` keyword is partially supported. - -* `"additionalProperties":false` produces incorrect schemas when used with [schema dependencies](#schema-dependencies). This library does not remove extra properties, which causes validation to fail. It is recommended to avoid setting `"additionalProperties":false` when you use schema dependencies. See [#848](https://github.com/mozilla-services/react-jsonschema-form/issues/848) [#902](https://github.com/mozilla-services/react-jsonschema-form/issues/902) [#992](https://github.com/mozilla-services/react-jsonschema-form/issues/992) - -## Handling of schema defaults - -This library automatically fills default values defined the [JSON Schema](http://json-schema.org/documentation.html) as initial values in your form. This also works for complex structures in the schema. If a field has a default defined, it should always appear as default value in form. This also works when using [schema dependencies](#schema-dependencies). - -Since there is a complex interaction between any supplied original form data and any injected defaults, this library tries to do the injection in a way which keeps the original intention of the original form data. - -Check out the defaults example on the [live playground](https://mozilla-services.github.io/react-jsonschema-form/) to see this in action. - -### Merging of defaults into the form data - -There are three different cases which need to be considered for the merging. Objects, arrays and scalar values. This library always deeply merges any defaults with the existing form data for objects. - -This are the rules which are used when injecting the defaults: - -- When the is a scalar in the form data, nothing is changed. -- When the value is `undefined` in the form data, the default is created in the form data. -- When the value is an object in the form data, the defaults are deeply merged into the form data, using the rules defined here for the deep merge. -- Then the value is an array in the form data, defaults are only injected in existing array items. No new array items will be created, even if the schema has minItems or additional items defined. - -### Merging of defaults within the schema - -In the schema itself, defaults of parent elements are propagated into children. So when you have a schema which defines a deeply nested object as default, these defaults will be applied to children of the current node. This also merges objects defined at different levels together with the "deeper" not having precedence. If the parent node defines properties, which are not defined in the child, they will be merged so that the default for the child will be the merged defaults of parent and child. - -For arrays this is not the case. Defining an array, when a parent also defines an array, will be overwritten. This is only true when arrays are used in the same level, for objects within these arrays, they will be deeply merged again. - -## Tips and tricks +## Useful samples - Custom field template: - Multi-step wizard: From b00f49fc565fd6507af7319bad4454cd3f0c8ff8 Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Thu, 22 Jul 2021 10:16:19 -0400 Subject: [PATCH 12/17] Revert `const` changes --- .../core/src/components/fields/SchemaField.js | 2 +- .../core/src/components/widgets/BaseInput.js | 8 +--- .../src/components/widgets/CheckboxWidget.js | 8 +--- packages/core/test/const_test.js | 41 +++---------------- 4 files changed, 9 insertions(+), 50 deletions(-) diff --git a/packages/core/src/components/fields/SchemaField.js b/packages/core/src/components/fields/SchemaField.js index 522a3de9e5..783d69fee5 100644 --- a/packages/core/src/components/fields/SchemaField.js +++ b/packages/core/src/components/fields/SchemaField.js @@ -249,7 +249,7 @@ function SchemaFieldRender(props) { const FieldTemplate = uiSchema["ui:FieldTemplate"] || registry.FieldTemplate || DefaultTemplate; let idSchema = props.idSchema; - var schema = retrieveSchema(props.schema, rootSchema, formData); + const schema = retrieveSchema(props.schema, rootSchema, formData); idSchema = mergeObjects( toIdSchema(schema, null, rootSchema, formData, idPrefix), diff --git a/packages/core/src/components/widgets/BaseInput.js b/packages/core/src/components/widgets/BaseInput.js index 6f6e63e2dc..0d728f3f45 100644 --- a/packages/core/src/components/widgets/BaseInput.js +++ b/packages/core/src/components/widgets/BaseInput.js @@ -8,7 +8,7 @@ function BaseInput(props) { console.log("No id for", props); throw new Error(`no id for props ${JSON.stringify(props)}`); } - var { + const { value, readonly, disabled, @@ -66,12 +66,6 @@ function BaseInput(props) { return props.onChange(value === "" ? options.emptyValue : value); }; - // If its a constant value then we want to disable the control and set the default value - if (typeof schema.const !== "undefined") { - value = schema.const; - disabled = true; - } - return [ {schema.description && ( diff --git a/packages/core/test/const_test.js b/packages/core/test/const_test.js index ad9f9ec148..d47e012bd2 100644 --- a/packages/core/test/const_test.js +++ b/packages/core/test/const_test.js @@ -13,23 +13,6 @@ describe("const", () => { sandbox.restore(); }); - it("should not mutate disabled when field is not a const", () => { - const schema = { - type: "object", - properties: { - foo: { type: "string" }, - }, - }; - - const { node } = createFormComponent({ - schema, - }); - let input = node.querySelector("input#root_foo"); - expect(input).not.eql(null); - expect(input.value).to.eql(""); - expect(input.disabled).to.eql(false); - }); - it("should render a schema that uses const with a string value", () => { const schema = { type: "object", @@ -38,15 +21,11 @@ describe("const", () => { }, }; - const { node, comp } = createFormComponent({ + const { node } = createFormComponent({ schema, }); - let input = node.querySelector("input#root_foo"); - expect(input).not.eql(null); - expect(input.value).to.eql("bar"); - expect(input.disabled).to.eql(true); - expect(comp.state.formData.foo).to.eql("bar"); + expect(node.querySelector("input#root_foo")).not.eql(null); }); it("should render a schema that uses const with a number value", () => { @@ -57,15 +36,11 @@ describe("const", () => { }, }; - const { node, comp } = createFormComponent({ + const { node } = createFormComponent({ schema, }); - let input = node.querySelector("input#root_foo"); - expect(input).not.eql(null); - expect(input.value).to.eql("123"); - expect(input.disabled).to.eql(true); - expect(comp.state.formData.foo).to.eql(123); + expect(node.querySelector("input#root_foo")).not.eql(null); }); it("should render a schema that uses const with a boolean value", () => { @@ -76,14 +51,10 @@ describe("const", () => { }, }; - const { node, comp } = createFormComponent({ + const { node } = createFormComponent({ schema, }); - let input = node.querySelector("input#root_foo[type='checkbox']"); - expect(input).not.eql(null); - expect(input.checked).to.eql(true); - expect(input.disabled).to.eql(true); - expect(comp.state.formData.foo).to.eql(true); + expect(node.querySelector("input#root_foo[type='checkbox']")).not.eql(null); }); }); From 833006a76b2e169d8a0c52ce69101ef916797e5b Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Thu, 22 Jul 2021 10:42:01 -0400 Subject: [PATCH 13/17] Remove default value from enum example --- packages/playground/src/samples/allofifthenelse.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/playground/src/samples/allofifthenelse.js b/packages/playground/src/samples/allofifthenelse.js index 14f96b07af..cd3a4a9e7e 100644 --- a/packages/playground/src/samples/allofifthenelse.js +++ b/packages/playground/src/samples/allofifthenelse.js @@ -8,8 +8,7 @@ module.exports = { "United Kingdom", "France", "Germany", - ], - default: "United States of America", + ] }, }, required: ["country"], From 8796c9d8860db522ee59a7050f4625ac38da90c7 Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Thu, 22 Jul 2021 10:43:30 -0400 Subject: [PATCH 14/17] Delete `allOf` rather than set to undefined. Fixes liveOmit issue. --- packages/core/src/utils.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils.js b/packages/core/src/utils.js index 8c9354606e..3961e2d8ce 100644 --- a/packages/core/src/utils.js +++ b/packages/core/src/utils.js @@ -684,7 +684,7 @@ export function _resolveSchema( } else if (schema.hasOwnProperty("dependencies")) { const resolvedSchema = resolveDependencies(schema, rootSchema, formData); return retrieveSchema(resolvedSchema, rootSchema, formData); - } else if (schema["allOf"]) { + } else if (schema.hasOwnProperty("allOf")) { return { ...schema, allOf: schema.allOf.map(allOfSubschema => @@ -764,8 +764,8 @@ export function _retrieveSchema( allOfSchema = resolveSchema(allOfSchema, rootSchema, formData); // resolve references etc. resolvedSchema = { ...mergeSchemas(resolvedSchema, allOfSchema), - allOf: undefined, }; + delete resolvedSchema.allOf; } } } From f72a62c6022ccc1c8962e0b56b43f2673c7cfb6f Mon Sep 17 00:00:00 2001 From: Juansasa Date: Fri, 6 Aug 2021 15:04:13 +0200 Subject: [PATCH 15/17] Added tests Added if then else logic to resolve schemas --- packages/core/src/utils.js | 27 +++- packages/core/test/utils_test.js | 136 ++++++++++++++++++ packages/playground/src/samples/ifElseThen.js | 45 ++++++ packages/playground/src/samples/index.js | 2 + 4 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 packages/playground/src/samples/ifElseThen.js diff --git a/packages/core/src/utils.js b/packages/core/src/utils.js index d759ea9b70..24ec7a009e 100644 --- a/packages/core/src/utils.js +++ b/packages/core/src/utils.js @@ -572,7 +572,7 @@ export function optionsList(schema) { }); } else { const altSchemas = schema.oneOf || schema.anyOf; - return altSchemas.map((schema, i) => { + return altSchemas.map(schema => { const value = toConstant(schema); const label = schema.title || String(value); return { @@ -662,12 +662,37 @@ export function stubExistingAdditionalProperties( return schema; } +const _resolveCondition = (schema, rootSchema, formdata) => { + let { + if: expression, + then, + else: otherwise, + ...resolvedSchemaLessConditional + } = schema; + + const conditionalSchema = isValid(expression, formdata, rootSchema) + ? then + : otherwise; + + if (conditionalSchema) { + return retrieveSchema( + mergeSchemas(conditionalSchema, resolvedSchemaLessConditional), + rootSchema, + formdata + ); + } else { + return retrieveSchema(resolvedSchemaLessConditional, rootSchema, formdata); + } +}; + export function resolveSchema(schema, rootSchema = {}, formData = {}) { if (schema.hasOwnProperty("$ref")) { return resolveReference(schema, rootSchema, formData); } else if (schema.hasOwnProperty("dependencies")) { const resolvedSchema = resolveDependencies(schema, rootSchema, formData); return retrieveSchema(resolvedSchema, rootSchema, formData); + } else if (schema.hasOwnProperty("if")) { + return _resolveCondition(schema, rootSchema, formData); } else if (schema.hasOwnProperty("allOf")) { return { ...schema, diff --git a/packages/core/test/utils_test.js b/packages/core/test/utils_test.js index d7c554141c..271744b8f6 100644 --- a/packages/core/test/utils_test.js +++ b/packages/core/test/utils_test.js @@ -2388,6 +2388,142 @@ describe("utils", () => { }); }); }); + + describe("ifElseThen", () => { + it("should resolve if, then", () => { + const schema = { + type: "object", + properties: { + country: { + default: "United States of America", + enum: ["United States of America", "Canada"], + }, + }, + if: { + properties: { country: { const: "United States of America" } }, + }, + then: { + properties: { postal_code: { pattern: "[0-9]{5}(-[0-9]{4})?" } }, + }, + else: { + properties: { + postal_code: { pattern: "[A-Z][0-9][A-Z] [0-9][A-Z][0-9]" }, + }, + }, + }; + const definitions = {}; + const formData = { + country: "United States of America", + postal_code: "20500", + }; + expect(retrieveSchema(schema, { definitions }, formData)).eql({ + type: "object", + properties: { + country: { + default: "United States of America", + enum: ["United States of America", "Canada"], + }, + postal_code: { pattern: "[0-9]{5}(-[0-9]{4})?" }, + }, + }); + }); + it("should resolve if, else", () => { + const schema = { + type: "object", + properties: { + country: { + default: "United States of America", + enum: ["United States of America", "Canada"], + }, + }, + if: { + properties: { country: { const: "United States of America" } }, + }, + then: { + properties: { postal_code: { pattern: "[0-9]{5}(-[0-9]{4})?" } }, + }, + else: { + properties: { + postal_code: { pattern: "[A-Z][0-9][A-Z] [0-9][A-Z][0-9]" }, + }, + }, + }; + const definitions = {}; + const formData = { + country: "Canada", + postal_code: "K1M 1M4", + }; + expect(retrieveSchema(schema, { definitions }, formData)).eql({ + type: "object", + properties: { + country: { + default: "United States of America", + enum: ["United States of America", "Canada"], + }, + postal_code: { pattern: "[A-Z][0-9][A-Z] [0-9][A-Z][0-9]" }, + }, + }); + }); + it("should resolve multiple conditions", () => { + const schema = { + type: "object", + properties: { + animal: { + enum: ["Cat", "Fish"], + }, + }, + allOf: [ + { + if: { + properties: { animal: { const: "Cat" } }, + }, + then: { + properties: { + food: { type: "string", enum: ["meat", "grass", "fish"] }, + }, + }, + required: ["food"], + }, + { + if: { + properties: { animal: { const: "Fish" } }, + }, + then: { + properties: { + food: { + type: "string", + enum: ["insect", "worms"], + }, + water: { + type: "string", + enum: ["lake", "sea"], + }, + }, + required: ["food", "water"], + }, + }, + { + required: ["animal"], + }, + ], + }; + const definitions = {}; + const formData = { + animal: "Cat", + }; + + expect(retrieveSchema(schema, { definitions }, formData)).eql({ + type: "object", + properties: { + animal: { + enum: ["Cat", "Fish"], + }, + food: { type: "string", enum: ["meat", "grass", "fish"] }, + }, + required: ["animal", "food"], + }); + }); + }); }); describe("shouldRender", () => { diff --git a/packages/playground/src/samples/ifElseThen.js b/packages/playground/src/samples/ifElseThen.js new file mode 100644 index 0000000000..0c834f2629 --- /dev/null +++ b/packages/playground/src/samples/ifElseThen.js @@ -0,0 +1,45 @@ +module.exports = { + schema: { + type: "object", + properties: { + animal: { + enum: ["Cat", "Fish"], + }, + }, + allOf: [ + { + if: { + properties: { animal: { const: "Cat" } }, + }, + then: { + properties: { + food: { type: "string", enum: ["meat", "grass", "fish"] }, + }, + required: ["food"], + }, + }, + { + if: { + properties: { animal: { const: "Fish" } }, + }, + then: { + properties: { + food: { + type: "string", + enum: ["insect", "worms"], + }, + water: { + type: "string", + enum: ["lake", "sea"], + }, + }, + required: ["food", "water"], + }, + }, + { + required: ["animal"], + }, + ], + }, + formData: {}, +}; diff --git a/packages/playground/src/samples/index.js b/packages/playground/src/samples/index.js index 991f5215f8..3a60790cfc 100644 --- a/packages/playground/src/samples/index.js +++ b/packages/playground/src/samples/index.js @@ -26,6 +26,7 @@ import nullable from "./nullable"; import nullField from "./null"; import errorSchema from "./errorSchema"; import defaults from "./defaults"; +import ifElseThen from "./ifElseThen"; export const samples = { Simple: simple, @@ -52,6 +53,7 @@ export const samples = { "Any Of": anyOf, "One Of": oneOf, "All Of": allOf, + "If Else Then": ifElseThen, "Null fields": nullField, Nullable: nullable, ErrorSchema: errorSchema, From 75c4d84381a3c96ab4d99d94014cd6784f270cce Mon Sep 17 00:00:00 2001 From: Juansasa Date: Mon, 9 Aug 2021 10:26:25 +0200 Subject: [PATCH 16/17] Changed resolve method's name --- packages/core/src/utils.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils.js b/packages/core/src/utils.js index 24ec7a009e..3b076aa9e7 100644 --- a/packages/core/src/utils.js +++ b/packages/core/src/utils.js @@ -662,7 +662,7 @@ export function stubExistingAdditionalProperties( return schema; } -const _resolveCondition = (schema, rootSchema, formdata) => { +const resolveCondition = (schema, rootSchema, formdata) => { let { if: expression, then, @@ -692,7 +692,7 @@ export function resolveSchema(schema, rootSchema = {}, formData = {}) { const resolvedSchema = resolveDependencies(schema, rootSchema, formData); return retrieveSchema(resolvedSchema, rootSchema, formData); } else if (schema.hasOwnProperty("if")) { - return _resolveCondition(schema, rootSchema, formData); + return resolveCondition(schema, rootSchema, formData); } else if (schema.hasOwnProperty("allOf")) { return { ...schema, From a08c56319fe3ededf48b49dca89eccca9a0851cd Mon Sep 17 00:00:00 2001 From: Juansasa Date: Fri, 13 Aug 2021 10:51:49 +0200 Subject: [PATCH 17/17] Added $ref tests --- packages/core/src/utils.js | 5 ++- packages/core/test/utils_test.js | 68 ++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils.js b/packages/core/src/utils.js index 3b076aa9e7..36577e4f7a 100644 --- a/packages/core/src/utils.js +++ b/packages/core/src/utils.js @@ -676,7 +676,10 @@ const resolveCondition = (schema, rootSchema, formdata) => { if (conditionalSchema) { return retrieveSchema( - mergeSchemas(conditionalSchema, resolvedSchemaLessConditional), + mergeSchemas( + retrieveSchema(conditionalSchema, rootSchema, formdata), + resolvedSchemaLessConditional + ), rootSchema, formdata ); diff --git a/packages/core/test/utils_test.js b/packages/core/test/utils_test.js index 271744b8f6..c979f744f1 100644 --- a/packages/core/test/utils_test.js +++ b/packages/core/test/utils_test.js @@ -2512,6 +2512,74 @@ describe("utils", () => { animal: "Cat", }; + expect(retrieveSchema(schema, { definitions }, formData)).eql({ + type: "object", + properties: { + animal: { + enum: ["Cat", "Fish"], + }, + food: { type: "string", enum: ["meat", "grass", "fish"] }, + }, + required: ["animal", "food"], + }); + }); + it.only("should resolve $ref", () => { + const schema = { + type: "object", + properties: { + animal: { + enum: ["Cat", "Fish"], + }, + }, + allOf: [ + { + if: { + properties: { animal: { const: "Cat" } }, + }, + then: { + $ref: "#/definitions/cat", + }, + required: ["food"], + }, + { + if: { + properties: { animal: { const: "Fish" } }, + }, + then: { + $ref: "#/definitions/fish", + }, + }, + { + required: ["animal"], + }, + ], + }; + + const definitions = { + cat: { + properties: { + food: { type: "string", enum: ["meat", "grass", "fish"] }, + }, + }, + fish: { + properties: { + food: { + type: "string", + enum: ["insect", "worms"], + }, + water: { + type: "string", + enum: ["lake", "sea"], + }, + }, + required: ["food", "water"], + }, + }; + + const formData = { + animal: "Cat", + }; + expect(retrieveSchema(schema, { definitions }, formData)).eql({ type: "object", properties: {