diff --git a/src/components/fields/ArrayField.js b/src/components/fields/ArrayField.js index f229baee66..71c8cb7613 100644 --- a/src/components/fields/ArrayField.js +++ b/src/components/fields/ArrayField.js @@ -139,6 +139,8 @@ function DefaultFixedArrayFieldTemplate(props) { } function DefaultNormalArrayFieldTemplate(props) { + const canDelete = + !("minItems" in props.schema) || props.items.length > props.schema.minItems; return (
@@ -161,7 +163,14 @@ function DefaultNormalArrayFieldTemplate(props) {
- {props.items && props.items.map(p => DefaultArrayItem(p))} + {props.items && + props.items.map(p => { + const { hasRemove, ...ps } = p; + if (canDelete) { + ps.hasRemove = hasRemove; + } + return DefaultArrayItem(ps); + })}
{props.canAdd && @@ -301,7 +310,8 @@ class ArrayField extends Component { const { addable = true } = getUiOptions(uiSchema); const arrayProps = { - canAdd: addable, + canAdd: addable && + (!("maxItems" in schema) || formData.length < schema.maxItems), items: formData.map((item, index) => { const itemErrorSchema = errorSchema ? errorSchema[index] : undefined; const itemIdPrefix = idSchema.$id + "_" + index; diff --git a/src/components/fields/ObjectField.js b/src/components/fields/ObjectField.js index a401fa6454..966f77c4f2 100644 --- a/src/components/fields/ObjectField.js +++ b/src/components/fields/ObjectField.js @@ -45,7 +45,7 @@ class ObjectField extends Component { onBlur, } = this.props; const { definitions, fields, formContext } = this.props.registry; - const { SchemaField, TitleField, DescriptionField } = fields; + const { ObjectPropertyField, TitleField, DescriptionField } = fields; const schema = retrieveSchema(this.props.schema, definitions); const title = schema.title === undefined ? name : schema.title; let orderedProperties; @@ -79,8 +79,12 @@ class ObjectField extends Component { formContext={formContext} />} {orderedProperties.map((name, index) => { + const isDefault = + !(name in formData) || + typeof Object.getOwnPropertyDescriptor(formData, name).get === + "function"; return ( - ); })} diff --git a/src/components/fields/ObjectPropertyField.js b/src/components/fields/ObjectPropertyField.js new file mode 100644 index 0000000000..821aa27d0d --- /dev/null +++ b/src/components/fields/ObjectPropertyField.js @@ -0,0 +1,74 @@ +import React, { Component, PropTypes } from "react"; + +import { getDefaultRegistry } from "../../utils"; + +class ObjectPropertyField extends Component { + static defaultProps = { + uiSchema: {}, + errorSchema: {}, + idSchema: {}, + registry: getDefaultRegistry(), + disabled: false, + readonly: false, + autofocus: false, + isDefault: true, + }; + + constructor(props) { + super(props); + this.state = { present: props.required || !props.isDefault }; + } + + show = event => { + event.preventDefault(); + this.setState({ present: true }); + }; + + hide = event => { + event.preventDefault(); + this.setState({ present: false }); + }; + + render() { + const title = this.props.schema.title === undefined + ? this.props.name + : this.props.schema.title; + + if (this.state.present) { + const SchemaField = this.props.registry.fields.SchemaField; + const { isDefault, ...props } = this.props; + return ( +
+ + {this.props.required || + } +
+ ); + } else { + return ; + } + } +} + +if (process.env.NODE_ENV !== "production") { + ObjectPropertyField.propTypes = { + schema: PropTypes.object.isRequired, + uiSchema: PropTypes.object, + idSchema: PropTypes.object, + formData: PropTypes.any, + errorSchema: PropTypes.object, + registry: PropTypes.shape({ + widgets: PropTypes.objectOf( + PropTypes.oneOfType([PropTypes.func, PropTypes.object]) + ).isRequired, + fields: PropTypes.objectOf(PropTypes.func).isRequired, + definitions: PropTypes.object.isRequired, + ArrayFieldTemplate: PropTypes.func, + FieldTemplate: PropTypes.func, + formContext: PropTypes.object.isRequired, + }), + isDefault: PropTypes.bool, + }; +} + +export default ObjectPropertyField; diff --git a/src/components/fields/index.js b/src/components/fields/index.js index c8e92e5dde..61b7ccaaa2 100644 --- a/src/components/fields/index.js +++ b/src/components/fields/index.js @@ -3,6 +3,7 @@ import BooleanField from "./BooleanField"; import DescriptionField from "./DescriptionField"; import NumberField from "./NumberField"; import ObjectField from "./ObjectField"; +import ObjectPropertyField from "./ObjectPropertyField"; import SchemaField from "./SchemaField"; import StringField from "./StringField"; import TitleField from "./TitleField"; @@ -14,6 +15,7 @@ export default { DescriptionField, NumberField, ObjectField, + ObjectPropertyField, SchemaField, StringField, TitleField, diff --git a/src/utils.js b/src/utils.js index 31c8b4ca37..8eab952244 100644 --- a/src/utils.js +++ b/src/utils.js @@ -130,16 +130,40 @@ function computeDefaults(schema, parentDefaults, definitions = {}) { switch (schema.type) { // We need to recur for object schema inner default values. case "object": - return Object.keys(schema.properties).reduce((acc, key) => { - // Compute the defaults for this node, with the parent defaults we might - // have from a previous run: defaults[key]. - acc[key] = computeDefaults( - schema.properties[key], - (defaults || {})[key], - definitions - ); - return acc; - }, {}); + return new Proxy( + {}, + { + get(obj, key) { + if (!(key in obj) && key in schema.properties) { + obj[key] = computeDefaults( + schema.properties[key], + (defaults || {})[key], + definitions + ); + } + + return obj[key]; + }, + + has(obj, key) { + return key in schema.properties; + }, + + ownKeys(obj) { + return Object.keys(schema.properties); + }, + + getOwnPropertyDescriptor(obj, key) { + return this.has(obj, key) + ? { + configurable: true, + enumerable: true, + get: this.get.bind(this, obj, key), + } + : void 0; + }, + } + ); case "array": if (schema.minItems) { @@ -157,15 +181,47 @@ export function getDefaultFormState(_schema, formData, definitions = {}) { } const schema = retrieveSchema(_schema, definitions); const defaults = computeDefaults(schema, _schema.default, definitions); + return getDefault(formData, defaults); +} + +function getDefault(formData, defaults) { if (typeof formData === "undefined") { // No form data? Use schema defaults. return defaults; } if (isObject(formData)) { // Override schema defaults with form data. - return mergeObjects(defaults, formData); + let copyObj = {}; + Object.keys(formData).forEach(key => { + copyObj[key] = getDefault(formData[key], defaults[key]); + }); + + return new Proxy(copyObj, { + get(obj, key) { + if (!(key in obj) && key in defaults) { + obj[key] = defaults[key]; + } + + return obj[key]; + }, + + has(obj, key) { + return key in defaults; + }, + + ownKeys(obj) { + return Object.keys(defaults); + }, + + getOwnPropertyDescriptor(obj, key) { + return Object.getOwnPropertyDescriptor( + key in obj ? obj : defaults, + key + ); + }, + }); } - return formData || defaults; + return formData; } export function getUiOptions(uiSchema) { @@ -320,6 +376,7 @@ function findSchemaDefinition($ref, definitions = {}) { let current = definitions; for (let part of parts) { part = part.replace(/~1/g, "/").replace(/~0/g, "~"); + current = retrieveSchema(current, definitions); if (current.hasOwnProperty(part)) { current = current[part]; } else { @@ -335,16 +392,18 @@ function findSchemaDefinition($ref, definitions = {}) { } export function retrieveSchema(schema, definitions = {}) { + let current = schema; + while (current.hasOwnProperty("$ref")) { + // Retrieve the referenced schema definition. + const $refSchema = findSchemaDefinition(current.$ref, definitions); + // Drop the $ref property of the source schema. + const { $ref, ...localSchema } = current; + // Update referenced schema definition with local schema properties. + current = { ...$refSchema, ...localSchema }; + } + // No $ref attribute found, returning the original schema. - if (!schema.hasOwnProperty("$ref")) { - 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 }; + return current; } function isArguments(object) { @@ -447,12 +506,35 @@ export function toIdSchema(schema, id, definitions) { if (schema.type !== "object") { return idSchema; } - for (const name in schema.properties || {}) { - const field = schema.properties[name]; - const fieldId = idSchema.$id + "_" + name; - idSchema[name] = toIdSchema(field, fieldId, definitions); - } - return idSchema; + return new Proxy(idSchema, { + get(idSchema, name) { + if (!(name in idSchema) && name in schema.properties) { + const field = schema.properties[name]; + const fieldId = idSchema.$id + "_" + name; + idSchema[name] = toIdSchema(field, fieldId, definitions); + } + + return idSchema[name]; + }, + + has(idSchema, name) { + return name === "$id" || name in schema.properties; + }, + + ownKeys(idSchema) { + return ["$id"].concat(Object.keys(schema.properties)); + }, + + getOwnPropertyDescriptor(idSchema, name) { + return this.has(idSchema, name) + ? { + configurable: true, + enumerable: true, + get: this.get.bind(this, idSchema, name), + } + : void 0; + }, + }); } export function parseDateString(dateString, includeTime = true) { diff --git a/test/ArrayField_test.js b/test/ArrayField_test.js index 34d15be5af..6fd5f2d111 100644 --- a/test/ArrayField_test.js +++ b/test/ArrayField_test.js @@ -333,9 +333,11 @@ describe("ArrayField", () => { it("should render enough inputs with proper defaults to match minItems in schema when no formData is set", () => { const complexSchema = { type: "object", + required: ["foo"], definitions: { Thing: { type: "object", + required: ["name"], properties: { name: { type: "string", @@ -870,6 +872,7 @@ describe("ArrayField", () => { it("should pass field name to TitleField if there is no title", () => { const schema = { type: "object", + required: ["array"], properties: { array: { type: "array", diff --git a/test/BooleanField_test.js b/test/BooleanField_test.js index 95bb58853b..3592796371 100644 --- a/test/BooleanField_test.js +++ b/test/BooleanField_test.js @@ -186,6 +186,7 @@ describe("BooleanField", () => { it("should pass field name to widget if there is no title", () => { const schema = { type: "object", + required: ["boolean"], properties: { boolean: { type: "boolean", diff --git a/test/Form_test.js b/test/Form_test.js index 6fcaea1a57..a9faa8482c 100644 --- a/test/Form_test.js +++ b/test/Form_test.js @@ -197,6 +197,7 @@ describe("Form", () => { testdef: { type: "string" }, }, type: "object", + required: ["foo", "bar"], properties: { foo: { $ref: "#/definitions/testdef" }, bar: { $ref: "#/definitions/testdef" }, @@ -214,9 +215,11 @@ describe("Form", () => { testdef: { type: "string" }, }, type: "object", + required: ["foo"], properties: { foo: { type: "object", + required: ["bar"], properties: { bar: { $ref: "#/definitions/testdef" }, }, @@ -234,12 +237,14 @@ describe("Form", () => { definitions: { testdef: { type: "object", + required: ["bar"], properties: { bar: { type: "string" }, }, }, }, type: "object", + required: ["foo"], properties: { foo: { $ref: "#/definitions/testdef/properties/bar" }, }, @@ -250,6 +255,75 @@ describe("Form", () => { expect(node.querySelectorAll("input[type=text]")).to.have.length.of(1); }); + it("should follow recursive references", () => { + const schema = { + definitions: { + bar: { $ref: "#/definitions/qux" }, + qux: { type: "string" }, + }, + type: "object", + required: ["foo"], + properties: { + foo: { $ref: "#/definitions/bar" }, + }, + }; + + const { node } = createFormComponent({ schema }); + + expect(node.querySelectorAll("input[type=text]")).to.have.length.of(1); + }); + + it("should follow recursive references to deep schema definitions", () => { + const schema = { + definitions: { + testdef: { $ref: "#/definitions/bar" }, + bar: { + type: "object", + required: ["qux"], + properties: { + qux: { type: "string" }, + }, + }, + }, + type: "object", + required: ["foo"], + properties: { + foo: { $ref: "#/definitions/testdef/properties/qux" }, + }, + }; + + const { node } = createFormComponent({ schema }); + + expect(node.querySelectorAll("input[type=text]")).to.have.length.of(1); + }); + + it("should cope with circular schema", () => { + const schema = { + definitions: { + bar: { + type: "object", + properties: { + qux: { $ref: "#/definitions/baz" }, + }, + }, + baz: { + type: "object", + properties: { + quz: { $ref: "#/definitions/bar" }, + }, + }, + }, + type: "object", + properties: { + foo: { $ref: "#/definitions/bar" }, + }, + }; + + const { node } = createFormComponent({ schema }); + + expect(node.querySelectorAll("input")).to.have.length.of(0); + }); + it("should handle referenced definitions for array items", () => { const schema = { definitions: { @@ -307,6 +381,7 @@ describe("Form", () => { testdef: { type: "string", default: "hello" }, }, type: "object", + required: ["foo"], properties: { foo: { $ref: "#/definitions/testdef" }, }, @@ -341,6 +416,7 @@ describe("Form", () => { definitions: { node: { type: "object", + required: ["name", "children"], properties: { name: { type: "string" }, children: { @@ -367,6 +443,7 @@ describe("Form", () => { // Refs bug #140 const schema = { type: "object", + required: ["name", "childObj"], properties: { name: { type: "string" }, childObj: { @@ -377,6 +454,7 @@ describe("Form", () => { definitions: { childObj: { type: "object", + required: ["otherName"], properties: { otherName: { type: "string" }, }, @@ -393,6 +471,7 @@ describe("Form", () => { // Refs bug #140 const schema = { type: "object", + required: ["foo"], properties: { foo: { title: "custom title", @@ -402,6 +481,7 @@ describe("Form", () => { definitions: { objectDef: { type: "object", + required: ["field"], title: "definition title", properties: { field: { type: "string" }, @@ -412,7 +492,7 @@ describe("Form", () => { const { node } = createFormComponent({ schema }); - expect(node.querySelector("legend").textContent).eql("custom title"); + expect(node.querySelector("legend").textContent).eql("custom title*"); }); it("should propagate and handle a resolved schema definition", () => { @@ -421,6 +501,7 @@ describe("Form", () => { enumDef: { type: "string", enum: ["a", "b"] }, }, type: "object", + required: ["name"], properties: { name: { $ref: "#/definitions/enumDef" }, }, @@ -452,16 +533,19 @@ describe("Form", () => { describe("Defaults array items default propagation", () => { const schema = { type: "object", + required: ["object"], title: "lvl 1 obj", properties: { object: { type: "object", + required: ["array"], title: "lvl 2 obj", properties: { array: { type: "array", items: { type: "object", + required: ["bool"], title: "lvl 3 obj", properties: { bool: { diff --git a/test/ObjectField_test.js b/test/ObjectField_test.js index 7b9b07e3bb..739c1cf2b7 100644 --- a/test/ObjectField_test.js +++ b/test/ObjectField_test.js @@ -20,7 +20,7 @@ describe("ObjectField", () => { type: "object", title: "my object", description: "my description", - required: ["foo"], + required: ["foo", "bar"], default: { foo: "hey", bar: true, @@ -168,6 +168,7 @@ describe("ObjectField", () => { describe("fields ordering", () => { const schema = { type: "object", + required: ["foo", "bar", "baz", "qux"], properties: { foo: { type: "string" }, bar: { type: "string" }, @@ -188,7 +189,7 @@ describe("ObjectField", () => { l => l.textContent ); - expect(labels).eql(["baz", "qux", "bar", "foo"]); + expect(labels).eql(["baz*", "qux*", "bar*", "foo*"]); }); it("should insert unordered properties at wildcard position", () => { @@ -203,7 +204,7 @@ describe("ObjectField", () => { l => l.textContent ); - expect(labels).eql(["baz", "bar", "qux", "foo"]); + expect(labels).eql(["baz*", "bar*", "qux*", "foo*"]); }); it("should throw when order list contains an extraneous property", () => { @@ -251,6 +252,7 @@ describe("ObjectField", () => { testdef: { type: "string" }, }, type: "object", + required: ["foo", "bar"], properties: { foo: { $ref: "#/definitions/testdef" }, bar: { $ref: "#/definitions/testdef" }, @@ -268,7 +270,7 @@ describe("ObjectField", () => { l => l.textContent ); - expect(labels).eql(["bar", "foo"]); + expect(labels).eql(["bar*", "foo*"]); }); it("should order referenced object schema definition properties", () => { @@ -276,6 +278,7 @@ describe("ObjectField", () => { definitions: { testdef: { type: "object", + required: ["foo", "bar"], properties: { foo: { type: "string" }, bar: { type: "string" }, @@ -283,6 +286,7 @@ describe("ObjectField", () => { }, }, type: "object", + required: ["root"], properties: { root: { $ref: "#/definitions/testdef" }, }, @@ -301,12 +305,13 @@ describe("ObjectField", () => { l => l.textContent ); - expect(labels).eql(["bar", "foo"]); + expect(labels).eql(["bar*", "foo*"]); }); it("should render the widget with the expected id", () => { const schema = { type: "object", + required: ["foo", "bar"], properties: { foo: { type: "string" }, bar: { type: "string" }, @@ -336,6 +341,7 @@ describe("ObjectField", () => { it("should pass field name to TitleField if there is no title", () => { const schema = { type: "object", + required: ["object"], properties: { object: { type: "object", diff --git a/test/SchemaField_test.js b/test/SchemaField_test.js index 153d748d6a..f0c4745923 100644 --- a/test/SchemaField_test.js +++ b/test/SchemaField_test.js @@ -51,6 +51,7 @@ describe("SchemaField", () => { const schema = { type: "object", + required: ["foo", "bar"], properties: { foo: { type: "string" }, bar: { type: "string" }, @@ -165,6 +166,7 @@ describe("SchemaField", () => { describe("label support", () => { const schema = { type: "object", + required: ["foo"], properties: { foo: { type: "string" }, }, @@ -197,6 +199,7 @@ describe("SchemaField", () => { describe("description support", () => { const schema = { type: "object", + required: ["foo", "bar"], properties: { foo: { type: "string", description: "A Foo field" }, bar: { type: "string" }, @@ -215,6 +218,7 @@ describe("SchemaField", () => { // Overriding. const schemaWithReference = { type: "object", + required: ["foo", "bar"], properties: { foo: { $ref: "#/definitions/foo" }, bar: { type: "string" }, @@ -260,6 +264,7 @@ describe("SchemaField", () => { describe("errors", () => { const schema = { type: "object", + required: ["foo"], properties: { foo: { type: "string" }, }, @@ -293,8 +298,8 @@ describe("SchemaField", () => { const matches = node.querySelectorAll( "form > .form-group > div > .error-detail .text-danger" ); - expect(matches).to.have.length.of(1); - expect(matches[0].textContent).to.eql("container"); + expect(matches).to.have.length.of(2); + expect(matches[1].textContent).to.eql("container"); }); it("should pass errors to child component", () => { diff --git a/test/StringField_test.js b/test/StringField_test.js index 4c8281062e..cb51de8966 100644 --- a/test/StringField_test.js +++ b/test/StringField_test.js @@ -1543,6 +1543,7 @@ describe("StringField", () => { it("should pass field name to widget if there is no title", () => { const schema = { type: "object", + required: ["string"], properties: { string: { type: "string", diff --git a/test/uiSchema_test.js b/test/uiSchema_test.js index 5e931311b5..7c03628e4f 100644 --- a/test/uiSchema_test.js +++ b/test/uiSchema_test.js @@ -18,6 +18,7 @@ describe("uiSchema", () => { describe("custom classNames", () => { const schema = { type: "object", + required: ["foo", "bar"], properties: { foo: { type: "string" }, bar: { type: "string" }, @@ -84,6 +85,7 @@ describe("uiSchema", () => { // instance of a widget are persistent across all instances schema = { type: "object", + required: ["funcAll", "funcNone", "stringAll", "stringNone"], properties: { funcAll: { type: "string" }, funcNone: { type: "string" }, @@ -200,6 +202,7 @@ describe("uiSchema", () => { describe("nested widget", () => { const schema = { type: "object", + required: ["field"], properties: { field: { type: "string", @@ -240,6 +243,7 @@ describe("uiSchema", () => { describe("options", () => { const schema = { type: "object", + required: ["field"], properties: { field: { type: "string", @@ -296,6 +300,7 @@ describe("uiSchema", () => { describe("enum fields native options", () => { const schema = { type: "object", + required: ["field"], properties: { field: { type: "string", @@ -528,6 +533,7 @@ describe("uiSchema", () => { describe("string", () => { const schema = { type: "object", + required: ["foo"], properties: { foo: { type: "string", @@ -731,6 +737,7 @@ describe("uiSchema", () => { describe("string (enum)", () => { const schema = { type: "object", + required: ["foo"], properties: { foo: { type: "string", @@ -785,6 +792,7 @@ describe("uiSchema", () => { describe("number", () => { const schema = { type: "object", + required: ["foo"], properties: { foo: { type: "number", @@ -952,6 +960,7 @@ describe("uiSchema", () => { describe("radio", () => { const schema = { type: "object", + required: ["foo"], properties: { foo: { type: "number", @@ -1043,6 +1052,7 @@ describe("uiSchema", () => { describe("integer", () => { const schema = { type: "object", + required: ["foo"], properties: { foo: { type: "integer", @@ -1137,6 +1147,7 @@ describe("uiSchema", () => { describe("radio", () => { const schema = { type: "object", + required: ["foo"], properties: { foo: { type: "integer", @@ -1228,6 +1239,7 @@ describe("uiSchema", () => { describe("boolean", () => { const schema = { type: "object", + required: ["foo"], properties: { foo: { type: "boolean", @@ -1403,6 +1415,7 @@ describe("uiSchema", () => { it("should use a custom root field id for objects", () => { const schema = { type: "object", + required: ["foo", "bar"], properties: { foo: { type: "string" }, bar: { type: "string" }, @@ -1507,6 +1520,7 @@ describe("uiSchema", () => { beforeEach(() => { const schema = { type: "object", + required: ["foo", "bar"], properties: { foo: { type: "string" }, bar: { type: "string" }, @@ -1715,6 +1729,7 @@ describe("uiSchema", () => { beforeEach(() => { const schema = { type: "object", + required: ["foo", "bar"], properties: { foo: { type: "string" }, bar: { type: "string" }, diff --git a/test/utils_test.js b/test/utils_test.js index ad42abd5df..3ca19c575d 100644 --- a/test/utils_test.js +++ b/test/utils_test.js @@ -481,9 +481,11 @@ describe("utils", () => { it("should return an idSchema for nested objects", () => { const schema = { type: "object", + required: ["level1"], properties: { level1: { type: "object", + required: ["level2"], properties: { level2: { type: "string" }, }, @@ -503,9 +505,11 @@ describe("utils", () => { it("should return an idSchema for multiple nested objects", () => { const schema = { type: "object", + required: ["level1a", "level1b"], properties: { level1a: { type: "object", + required: ["level1a2a", "level1a2b"], properties: { level1a2a: { type: "string" }, level1a2b: { type: "string" }, @@ -513,6 +517,7 @@ describe("utils", () => { }, level1b: { type: "object", + required: ["level1b2a", "level1b2b"], properties: { level1b2a: { type: "string" }, level1b2b: { type: "string" }, @@ -539,6 +544,7 @@ describe("utils", () => { it("schema with an id property must not corrupt the idSchema", () => { const schema = { type: "object", + required: ["metadata"], properties: { metadata: { type: "object", @@ -565,6 +571,7 @@ describe("utils", () => { type: "array", items: { type: "object", + required: ["foo"], properties: { foo: { type: "string" }, }, @@ -582,6 +589,7 @@ describe("utils", () => { definitions: { testdef: { type: "object", + required: ["foo", "bar"], properties: { foo: { type: "string" }, bar: { type: "string" },