Repository: mozilla-services/react-jsonschema-form
Pull request #1129 (2019-01-16 11:26:26)

Fix bug where matching anyOf branch is not selected correctly
This change improves the logic that selects a matching anyOf branch
based on form data. Previously the behaviour was to just check if the
form data was valid against an anyOf branch, however due to the
permissive nature of JSON schema this has unexpected behaviour. For
example, given the following schema:

```json
{
  "type": "object",
  "anyOf": [
    {
      "properties": {
        "foo": {
          "type": "string"
        }
      }
    },
    {
      "properties": {
        "bar": {
          "type": "string"
        }
      }
    }
  ]
}
```

The form data `{ bar: 'baz' }` will actually match the first branch. To
mitigate this, when doing the matching, the branch schema is augmented
to require at least one of the keys in the branch. For example the
schema above would become:

```json
{
  "type": "object",
  "anyOf": [
    {
      "properties": {
        "foo": {
          "type": "string"
        }
      },
      "anyOf": [
        { "required": [ "foo" ] }
      ]
    },
    {
      "properties": {
        "bar": {
          "type": "string"
        }
      },
      "anyOf": [
        { "required": [ "bar" ] }
      ]
    }
  ]
}
```

Signed-off-by: Lucian <lucian.buzzo@gmail.com>

### Checklist

* [ ] **I'm updating documentation**
  - [ ] I've checked the rendering of the Markdown text I've added
  - [ ] If I'm adding a new section, I've updated the Table of Content
* [ ] **I'm adding or updating code**
  - [ ] I've added and/or updated tests
  - [ ] I've updated docs if needed
  - [ ] I've run `npm run cs-format` on my branch to conform my code to [prettier](https://github.com/prettier/prettier) coding style
* [ ] **I'm adding a new feature**
  - [ ] I've updated the playground with an example use of the feature


LucianBuzzo: @glasserc If you have time a review would be great! This fixes an annoying bug with my original `anyOf` PR.
LucianBuzzo: @glasserc Thanks for the detailed review :+1: . I've addressed your comments and the PR should be good to go now.
epicfaace: @LucianBuzzo I'm wondering, why did you wrap the required attribute in another `anyOf` -- couldn't you have created an augmented schema like this?:

```
{
  "type": "object",
  "anyOf": [
    {
      "properties": {
        "foo": {
          "type": "string"
        }
      },
      "required": [ "foo" ]
    },
    {
      "properties": {
        "bar": {
          "type": "string"
        }
      },
      "required": [ "bar" ]
    }
  ]
}
```
LucianBuzzo: @epicfaace Wrapping the `required` in `anyOf` is a trick to allow you to require at least one of a list of keys, which is what you need to do if the subschema is a more complex object, for example:

```
{
  "type": "object",
  "anyOf": [
    {
      "properties": {
        "foo": {
          "type": "string"
        },
        "bar": {
          "type": "string"
        }
      },
      "anyOf": [
        { "required": [ "foo" ] },
        { "required": [ "bar" ] }
      ]
    },
    {
      "properties": {
        "buzz": {
          "type": "string"
        }
      },
      "anyOf": [
        { "required": [ "buzz" ] }
      ]
    }
  ]
}
```

If you just use a plain `required` statement, then the first object wouldn't match if you start typing into the `foo` field, but the `bar` field is still empty.

Patch:
diff --git a/src/components/fields/MultiSchemaField.js b/src/components/fields/MultiSchemaField.js
index e11b9434a..593590e6b 100644
--- a/src/components/fields/MultiSchemaField.js
+++ b/src/components/fields/MultiSchemaField.js
@@ -30,7 +30,49 @@ class AnyOfField extends Component {
 
   getMatchingOption(formData, options) {
     for (let i = 0; i < options.length; i++) {
-      if (isValid(options[i], formData)) {
+      const option = options[i];
+
+      // If the schema describes an object then we need to add slightly more
+      // strict matching to the schema, because unless the schema uses the
+      // "requires" keyword, an object will match the schema as long as it
+      // doesn't have matching keys with a conflicting type. To do this we use an
+      // "anyOf" with an array of requires. This augmentation expresses that the
+      // schema should match if any of the keys in the schema are present on the
+      // object and pass validation.
+      if (option.properties) {
+        // Create an "anyOf" schema that requires at least one of the keys in the
+        // "properties" object
+        const requiresAnyOf = {
+          anyOf: Object.keys(option.properties).map(key => ({
+            required: [key],
+          })),
+        };
+
+        let augmentedSchema;
+
+        // If the "anyOf" keyword already exists, wrap the augmentation in an "allOf"
+        if (option.anyOf) {
+          // Create a shallow clone of the option
+          const { ...shallowClone } = option;
+
+          if (!shallowClone.allOf) {
+            shallowClone.allOf = [];
+          } else {
+            // If "allOf" already exists, shallow clone the array
+            shallowClone.allOf = shallowClone.allOf.slice();
+          }
+
+          shallowClone.allOf.push(requiresAnyOf);
+
+          augmentedSchema = shallowClone;
+        } else {
+          augmentedSchema = Object.assign({}, option, requiresAnyOf);
+        }
+
+        if (isValid(augmentedSchema, formData)) {
+          return i;
+        }
+      } else if (isValid(options[i], formData)) {
         return i;
       }
     }


Test patch:
diff --git a/test/anyOf_test.js b/test/anyOf_test.js
index cc845af6f..51566c3bf 100644
--- a/test/anyOf_test.js
+++ b/test/anyOf_test.js
@@ -300,6 +300,235 @@ describe("anyOf", () => {
     expect(node.querySelector("select").value).eql("1");
   });
 
+  it("should not change the selected option when entering values", () => {
+    const schema = {
+      type: "object",
+      anyOf: [
+        {
+          title: "First method of identification",
+          properties: {
+            firstName: {
+              type: "string",
+            },
+            lastName: {
+              type: "string",
+            },
+          },
+        },
+        {
+          title: "Second method of identification",
+          properties: {
+            idCode: {
+              type: "string",
+            },
+          },
+        },
+      ],
+    };
+
+    const { node } = createFormComponent({
+      schema,
+    });
+
+    const $select = node.querySelector("select");
+
+    expect($select.value).eql("0");
+
+    Simulate.change($select, {
+      target: { value: $select.options[1].value },
+    });
+
+    expect($select.value).eql("1");
+
+    Simulate.change(node.querySelector("input#root_idCode"), {
+      target: { value: "Lorem ipsum dolor sit amet" },
+    });
+
+    expect($select.value).eql("1");
+  });
+
+  it("should not change the selected option when entering values and the subschema uses `anyOf`", () => {
+    const schema = {
+      type: "object",
+      anyOf: [
+        {
+          title: "First method of identification",
+          properties: {
+            firstName: {
+              type: "string",
+            },
+            lastName: {
+              type: "string",
+            },
+          },
+        },
+        {
+          title: "Second method of identification",
+          properties: {
+            idCode: {
+              type: "string",
+            },
+          },
+          anyOf: [
+            {
+              properties: {
+                foo: {
+                  type: "string",
+                },
+              },
+            },
+            {
+              properties: {
+                bar: {
+                  type: "string",
+                },
+              },
+            },
+          ],
+        },
+      ],
+    };
+
+    const { node } = createFormComponent({
+      schema,
+    });
+
+    const $select = node.querySelector("select");
+
+    expect($select.value).eql("0");
+
+    Simulate.change($select, {
+      target: { value: $select.options[1].value },
+    });
+
+    expect($select.value).eql("1");
+
+    Simulate.change(node.querySelector("input#root_idCode"), {
+      target: { value: "Lorem ipsum dolor sit amet" },
+    });
+
+    expect($select.value).eql("1");
+  });
+
+  it("should not change the selected option when entering values and the subschema uses `allOf`", () => {
+    const schema = {
+      type: "object",
+      anyOf: [
+        {
+          title: "First method of identification",
+          properties: {
+            firstName: {
+              type: "string",
+            },
+            lastName: {
+              type: "string",
+            },
+          },
+        },
+        {
+          title: "Second method of identification",
+          properties: {
+            idCode: {
+              type: "string",
+            },
+          },
+          allOf: [
+            {
+              properties: {
+                foo: {
+                  type: "string",
+                },
+              },
+            },
+            {
+              properties: {
+                bar: {
+                  type: "string",
+                },
+              },
+            },
+          ],
+        },
+      ],
+    };
+
+    const { node } = createFormComponent({
+      schema,
+    });
+
+    const $select = node.querySelector("select");
+
+    expect($select.value).eql("0");
+
+    Simulate.change($select, {
+      target: { value: $select.options[1].value },
+    });
+
+    expect($select.value).eql("1");
+
+    Simulate.change(node.querySelector("input#root_idCode"), {
+      target: { value: "Lorem ipsum dolor sit amet" },
+    });
+
+    expect($select.value).eql("1");
+  });
+
+  it("should not mutate a schema that contains nested anyOf and allOf", () => {
+    const schema = {
+      type: "object",
+      anyOf: [
+        {
+          properties: {
+            foo: { type: "string" },
+          },
+          allOf: [
+            {
+              properties: {
+                baz: { type: "string" },
+              },
+            },
+          ],
+          anyOf: [
+            {
+              properties: {
+                buzz: { type: "string" },
+              },
+            },
+          ],
+        },
+      ],
+    };
+
+    createFormComponent({
+      schema,
+    });
+
+    expect(schema).to.eql({
+      type: "object",
+      anyOf: [
+        {
+          properties: {
+            foo: { type: "string" },
+          },
+          allOf: [
+            {
+              properties: {
+                baz: { type: "string" },
+              },
+            },
+          ],
+          anyOf: [
+            {
+              properties: {
+                buzz: { type: "string" },
+              },
+            },
+          ],
+        },
+      ],
+    });
+  });
+
   describe("Arrays", () => {
     it("should correctly render form inputs for anyOf inside array items", () => {
       const schema = {