Repository: mozilla-services/react-jsonschema-form
Pull request #1402 (2019-08-12 20:26:10)

Adding support for $ref pointers to additionalProperties.
### Reasons for making this change

This adds support for `$ref` pointers on `additionalProperties` objects.

### Checklist

* [ ] **I'm updating documentation**
  - [ ] I've [checked the rendering](https://react-jsonschema-form.readthedocs.io/en/latest/#contributing) of the Markdown text I've added
* [x] **I'm adding or updating code**
  - [x] I've added and/or updated tests
  - [ ] I've updated [docs](https://react-jsonschema-form.readthedocs.io/) if needed
* [x] **I'm adding a new feature**
  - [ ] I've updated the playground with an example use of the feature


erunion: @epicfaace Done!
epicfaace: By the way, here's a [playground link](https://mozilla-services.github.io/react-jsonschema-form/#eyJmb3JtRGF0YSI6eyJmaXJzdE5hbWUiOiJDaHVjayIsImxhc3ROYW1lIjoiTm9ycmlzIiwiYXNzS2lja0NvdW50IjoiIn0sInNjaGVtYSI6eyJ0aXRsZSI6IkEgY3VzdG9taXphYmxlIHJlZ2lzdHJhdGlvbiBmb3JtIiwiZGVzY3JpcHRpb24iOiJBIHNpbXBsZSBmb3JtIHdpdGggYWRkaXRpb25hbCBwcm9wZXJ0aWVzIGV4YW1wbGUuIiwidHlwZSI6Im9iamVjdCIsImRlZmluaXRpb25zIjp7ImEiOnsidHlwZSI6InN0cmluZyIsImVudW0iOlsiYSIsImIiLCJjIl19fSwicmVxdWlyZWQiOlsiZmlyc3ROYW1lIiwibGFzdE5hbWUiXSwiYWRkaXRpb25hbFByb3BlcnRpZXMiOnsiJHJlZiI6IiMvZGVmaW5pdGlvbnMvYSJ9LCJwcm9wZXJ0aWVzIjp7ImZpcnN0TmFtZSI6eyJ0eXBlIjoic3RyaW5nIiwidGl0bGUiOiJGaXJzdCBuYW1lIn0sImxhc3ROYW1lIjp7InR5cGUiOiJzdHJpbmciLCJ0aXRsZSI6Ikxhc3QgbmFtZSJ9fX0sInVpU2NoZW1hIjp7ImZpcnN0TmFtZSI6eyJ1aTphdXRvZm9jdXMiOnRydWUsInVpOmVtcHR5VmFsdWUiOiIifX19) to an example that doesn't work currently on the main playground using $ref with additionalProperties.

Patch:
diff --git a/netlify.toml b/netlify.toml
index 480331996..5efd3d154 100644
--- a/netlify.toml
+++ b/netlify.toml
@@ -1,2 +1,2 @@
-[context.deploy-preview.environment]
-  SHOW_NETLIFY_BADGE = "true"
\ No newline at end of file
+[build.environment]
+  SHOW_NETLIFY_BADGE = "true"
diff --git a/playground/app.js b/playground/app.js
index a8d8c77fd..c1d6bd8ea 100644
--- a/playground/app.js
+++ b/playground/app.js
@@ -384,12 +384,14 @@ class App extends Component {
     this.setState({ formData, shareURL: null });
 
   onShare = () => {
-    const { formData, schema, uiSchema } = this.state;
+    const { formData, schema, uiSchema, liveSettings } = this.state;
     const {
       location: { origin, pathname },
     } = document;
     try {
-      const hash = btoa(JSON.stringify({ formData, schema, uiSchema }));
+      const hash = btoa(
+        JSON.stringify({ formData, schema, uiSchema, liveSettings })
+      );
       this.setState({ shareURL: `${origin}${pathname}#${hash}` });
     } catch (err) {
       this.setState({ shareURL: null });
diff --git a/src/components/Form.js b/src/components/Form.js
index 4eacafc8d..38d11b341 100644
--- a/src/components/Form.js
+++ b/src/components/Form.js
@@ -139,7 +139,12 @@ export default class Form extends Component {
       return formData;
     }
 
-    return _pick(formData, fields);
+    let data = _pick(formData, fields);
+    if (Array.isArray(formData)) {
+      return Object.keys(data).map(key => data[key]);
+    }
+
+    return data;
   };
 
   getFieldNames = (pathSchema, formData) => {
diff --git a/src/components/fields/ObjectField.js b/src/components/fields/ObjectField.js
index c9bcce445..ba15449f2 100644
--- a/src/components/fields/ObjectField.js
+++ b/src/components/fields/ObjectField.js
@@ -70,6 +70,7 @@ class ObjectField extends Component {
   };
 
   state = {
+    wasPropertyKeyModified: false,
     additionalProperties: {},
   };
 
@@ -128,6 +129,7 @@ class ObjectField extends Component {
       if (oldValue === value) {
         return;
       }
+
       value = this.getAvailableKey(value, this.props.formData);
       const newFormData = { ...this.props.formData };
       const newKeys = { [oldValue]: value };
@@ -136,6 +138,9 @@ class ObjectField extends Component {
         return { [newKey]: newFormData[key] };
       });
       const renamedObj = Object.assign({}, ...keyValues);
+
+      this.setState({ wasPropertyKeyModified: true });
+
       this.props.onChange(
         renamedObj,
         errorSchema &&
@@ -168,11 +173,24 @@ class ObjectField extends Component {
   }
 
   handleAddClick = schema => () => {
-    const type = schema.additionalProperties.type;
+    let type = schema.additionalProperties.type;
     const newFormData = { ...this.props.formData };
+
+    if (schema.additionalProperties.hasOwnProperty("$ref")) {
+      const { registry = getDefaultRegistry() } = this.props;
+      const refSchema = retrieveSchema(
+        { $ref: schema.additionalProperties["$ref"] },
+        registry.definitions,
+        this.props.formData
+      );
+
+      type = refSchema.type;
+    }
+
     newFormData[
       this.getAvailableKey("newKey", newFormData)
     ] = this.getDefaultValue(type);
+
     this.props.onChange(newFormData);
   };
 
@@ -191,10 +209,19 @@ class ObjectField extends Component {
       onFocus,
       registry = getDefaultRegistry(),
     } = this.props;
+
     const { definitions, fields, formContext } = registry;
     const { SchemaField, TitleField, DescriptionField } = fields;
     const schema = retrieveSchema(this.props.schema, definitions, formData);
-    const title = schema.title === undefined ? name : schema.title;
+
+    // If this schema has a title defined, but the user has set a new key/label, retain their input.
+    let title;
+    if (this.state.wasPropertyKeyModified) {
+      title = name;
+    } else {
+      title = schema.title === undefined ? name : schema.title;
+    }
+
     const description = uiSchema["ui:description"] || schema.description;
     let orderedProperties;
     try {
@@ -242,6 +269,7 @@ class ObjectField extends Component {
               idSchema={idSchema[name]}
               idPrefix={idPrefix}
               formData={(formData || {})[name]}
+              wasPropertyKeyModified={this.state.wasPropertyKeyModified}
               onKeyChange={this.onKeyChange(name)}
               onChange={this.onPropertyChange(
                 name,
diff --git a/src/components/fields/SchemaField.js b/src/components/fields/SchemaField.js
index 593a19868..83744135f 100644
--- a/src/components/fields/SchemaField.js
+++ b/src/components/fields/SchemaField.js
@@ -235,6 +235,7 @@ function SchemaFieldRender(props) {
     onDropPropertyClick,
     required,
     registry = getDefaultRegistry(),
+    wasPropertyKeyModified = false,
   } = props;
   const { definitions, fields, formContext } = registry;
   const FieldTemplate =
@@ -296,8 +297,15 @@ function SchemaFieldRender(props) {
 
   const { type } = schema;
   const id = idSchema.$id;
-  const label =
-    uiSchema["ui:title"] || props.schema.title || schema.title || name;
+
+  // If this schema has a title defined, but the user has set a new key/label, retain their input.
+  let label;
+  if (wasPropertyKeyModified) {
+    label = name;
+  } else {
+    label = uiSchema["ui:title"] || props.schema.title || schema.title || name;
+  }
+
   const description =
     uiSchema["ui:description"] ||
     props.schema.description ||
diff --git a/src/utils.js b/src/utils.js
index 44ef93834..3b982c93e 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -537,21 +537,32 @@ export function stubExistingAdditionalProperties(
     ...schema,
     properties: { ...schema.properties },
   };
+
   Object.keys(formData).forEach(key => {
     if (schema.properties.hasOwnProperty(key)) {
       // No need to stub, our schema already has the property
       return;
     }
-    const additionalProperties = schema.additionalProperties.hasOwnProperty(
-      "type"
-    )
-      ? { ...schema.additionalProperties }
-      : { type: guessType(formData[key]) };
+
+    let additionalProperties;
+    if (schema.additionalProperties.hasOwnProperty("$ref")) {
+      additionalProperties = retrieveSchema(
+        { $ref: schema.additionalProperties["$ref"] },
+        definitions,
+        formData
+      );
+    } else if (schema.additionalProperties.hasOwnProperty("type")) {
+      additionalProperties = { ...schema.additionalProperties };
+    } else {
+      additionalProperties = { type: guessType(formData[key]) };
+    }
+
     // The type of our new key should match the additionalProperties value;
     schema.properties[key] = additionalProperties;
     // Set our additional property flag so we know it was dynamically added
     schema.properties[key][ADDITIONAL_PROPERTY_FLAG] = true;
   });
+
   return schema;
 }
 
diff --git a/webpack.config.prod.js b/webpack.config.prod.js
index c66b8fd61..8a4b3eaaf 100644
--- a/webpack.config.prod.js
+++ b/webpack.config.prod.js
@@ -9,7 +9,7 @@ module.exports = {
   output: {
     path: path.join(__dirname, "build"),
     filename: "bundle.js",
-    publicPath: "/react-jsonschema-form/"
+    publicPath: process.env.SHOW_NETLIFY_BADGE ? "": "/react-jsonschema-form/"
   },
   plugins: [
     new MonacoWebpackPlugin({


Test patch:
diff --git a/test/Form_test.js b/test/Form_test.js
index 7084d553a..0984be005 100644
--- a/test/Form_test.js
+++ b/test/Form_test.js
@@ -516,6 +516,45 @@ describe("Form", () => {
       expect(node.querySelector("input[type=text]").value).eql("hello");
     });
 
+    it("should propagate referenced definition defaults in objects with additionalProperties", () => {
+      const schema = {
+        definitions: {
+          testdef: { type: "string" },
+        },
+        type: "object",
+        additionalProperties: {
+          $ref: "#/definitions/testdef",
+        },
+      };
+
+      const { node } = createFormComponent({ schema });
+
+      Simulate.click(node.querySelector(".btn-add"));
+
+      expect(node.querySelector("input[type=text]").value).eql("newKey");
+    });
+
+    it("should propagate referenced definition defaults in objects with additionalProperties that have a type present", () => {
+      // Though `additionalProperties` has a `type` present here, it also has a `$ref` so that
+      // referenced schema should override it.
+      const schema = {
+        definitions: {
+          testdef: { type: "number" },
+        },
+        type: "object",
+        additionalProperties: {
+          type: "string",
+          $ref: "#/definitions/testdef",
+        },
+      };
+
+      const { node } = createFormComponent({ schema });
+
+      Simulate.click(node.querySelector(".btn-add"));
+
+      expect(node.querySelector("input[type=number]").value).eql("0");
+    });
+
     it("should recursively handle referenced definitions", () => {
       const schema = {
         $ref: "#/definitions/node",
@@ -866,6 +905,25 @@ describe("Form", () => {
       expect(result).eql("foo");
     });
 
+    it("should return the root level array", () => {
+      const schema = {
+        type: "array",
+        items: {
+          type: "string",
+        },
+      };
+      const formData = [];
+      const onSubmit = sandbox.spy();
+      const { comp } = createFormComponent({
+        schema,
+        formData,
+        onSubmit,
+      });
+
+      const result = comp.getUsedFormData(formData, []);
+      expect(result).eql([]);
+    });
+
     it("should call getUsedFormData with data from fields in event", () => {
       const schema = {
         type: "object",
diff --git a/test/ObjectField_test.js b/test/ObjectField_test.js
index 3605b9702..eb41819e1 100644
--- a/test/ObjectField_test.js
+++ b/test/ObjectField_test.js
@@ -552,6 +552,26 @@ describe("ObjectField", () => {
       expect(comp.state.formData.newFirst).eql(1);
     });
 
+    it("should retain user-input data if key-value pair has a title present in the schema", () => {
+      const { comp, node } = createFormComponent({
+        schema: {
+          type: "object",
+          additionalProperties: {
+            title: "Custom title",
+            type: "string",
+          },
+        },
+        formData: { "Custom title": 1 },
+      });
+
+      const textNode = node.querySelector("#root_Custom\\ title-key");
+      Simulate.blur(textNode, {
+        target: { value: "Renamed custom title" },
+      });
+
+      expect(comp.state.formData["Renamed custom title"]).eql(1);
+    });
+
     it("should keep order of renamed key-value pairs while renaming key", () => {
       const { comp, node } = createFormComponent({
         schema,
diff --git a/test/utils_test.js b/test/utils_test.js
index d9210af1d..afef8c140 100644
--- a/test/utils_test.js
+++ b/test/utils_test.js
@@ -2,6 +2,7 @@ import { expect } from "chai";
 import React from "react";
 
 import {
+  ADDITIONAL_PROPERTY_FLAG,
   asNumber,
   orderProperties,
   dataURItoBlob,
@@ -1071,6 +1072,64 @@ describe("utils", () => {
       expect(retrieveSchema(schema, definitions)).eql(address);
     });
 
+    it("should 'resolve' and stub out a schema which contains an `additionalProperties` with a definition", () => {
+      const schema = {
+        type: "object",
+        additionalProperties: {
+          $ref: "#/definitions/components/schemas/address",
+        },
+      };
+
+      const address = {
+        type: "object",
+        properties: {
+          street_address: { type: "string" },
+          city: { type: "string" },
+          state: { type: "string" },
+        },
+        required: ["street_address", "city", "state"],
+      };
+
+      const definitions = { components: { schemas: { address } } };
+      const formData = { newKey: {} };
+
+      expect(retrieveSchema(schema, definitions, formData)).eql({
+        ...schema,
+        properties: {
+          newKey: {
+            ...address,
+            [ADDITIONAL_PROPERTY_FLAG]: true,
+          },
+        },
+      });
+    });
+
+    it.only("should 'resolve' and stub out a schema which contains an `additionalProperties` with a type and definition", () => {
+      const schema = {
+        type: "string",
+        additionalProperties: {
+          $ref: "#/definitions/number",
+        },
+      };
+
+      const number = {
+        type: "number",
+      };
+
+      const definitions = { number };
+      const formData = { newKey: {} };
+
+      expect(retrieveSchema(schema, definitions, formData)).eql({
+        ...schema,
+        properties: {
+          newKey: {
+            ...number,
+            [ADDITIONAL_PROPERTY_FLAG]: true,
+          },
+        },
+      });
+    });
+
     it("should priorize local definitions over foreign ones", () => {
       const schema = {
         $ref: "#/definitions/address",