diff --git a/README.md b/README.md
index e2a7fbd1c5..809cf9003d 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,8 @@ A [live playground](https://mozilla-services.github.io/react-jsonschema-form/) i
- [Multiple files](#multiple-files)
- [File widget input ref](#file-widget-input-ref)
- [Object fields ordering](#object-fields-ordering)
+ - [Object item options](#object-item-options)
+ - [expandable option](#expandable-option)
- [Array item options](#array-item-options)
- [orderable option](#orderable-option)
- [addable option](#addable-option)
@@ -484,6 +486,20 @@ const uiSchema = {
};
```
+### Object item options
+
+#### `expandable` option
+
+If `additionalProperties` contains a schema object, an add button for new properies is shown by default. You can turn this off with the `expandable` option in `uiSchema`:
+
+```jsx
+const uiSchema = {
+ "ui:options": {
+ expandable: false
+ }
+};
+```
+
### Array item options
#### `orderable` option
diff --git a/playground/samples/additionalProperties.js b/playground/samples/additionalProperties.js
new file mode 100644
index 0000000000..6a28a138f9
--- /dev/null
+++ b/playground/samples/additionalProperties.js
@@ -0,0 +1,32 @@
+module.exports = {
+ schema: {
+ title: "A customizable registration form",
+ description: "A simple form with additional properties example.",
+ type: "object",
+ required: ["firstName", "lastName"],
+ additionalProperties: {
+ type: "string",
+ },
+ properties: {
+ firstName: {
+ type: "string",
+ title: "First name",
+ },
+ lastName: {
+ type: "string",
+ title: "Last name",
+ },
+ },
+ },
+ uiSchema: {
+ firstName: {
+ "ui:autofocus": true,
+ "ui:emptyValue": "",
+ },
+ },
+ formData: {
+ firstName: "Chuck",
+ lastName: "Norris",
+ assKickCount: "infinity",
+ },
+};
diff --git a/playground/samples/index.js b/playground/samples/index.js
index 9d8806f8dc..b496c0fb6d 100644
--- a/playground/samples/index.js
+++ b/playground/samples/index.js
@@ -17,6 +17,7 @@ import customObject from "./customObject";
import alternatives from "./alternatives";
import propertyDependencies from "./propertyDependencies";
import schemaDependencies from "./schemaDependencies";
+import additionalProperties from "./additionalProperties";
export const samples = {
Simple: simple,
@@ -38,4 +39,5 @@ export const samples = {
Alternatives: alternatives,
"Property dependencies": propertyDependencies,
"Schema dependencies": schemaDependencies,
+ "Additional Properties": additionalProperties,
};
diff --git a/src/components/AddButton.js b/src/components/AddButton.js
new file mode 100644
index 0000000000..b16c3475d8
--- /dev/null
+++ b/src/components/AddButton.js
@@ -0,0 +1,19 @@
+import React from "react";
+import IconButton from "./IconButton";
+
+export default function AddButton({ className, onClick, disabled }) {
+ return (
+
+ {additional && (
+
+
+
+
+ )}
{displayLabel &&
}
{displayLabel && description ? description : null}
{children}
@@ -157,6 +185,7 @@ function SchemaFieldRender(props) {
errorSchema,
idPrefix,
name,
+ onKeyChange,
required,
registry = getDefaultRegistry(),
} = props;
@@ -255,6 +284,7 @@ function SchemaFieldRender(props) {
id,
label,
hidden,
+ onKeyChange,
required,
disabled,
readonly,
diff --git a/src/utils.js b/src/utils.js
index 8d00e3fc86..a9b9651ffa 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -2,6 +2,8 @@ import React from "react";
import validateFormData from "./validate";
import fill from "core-js/library/fn/array/fill";
+export const ADDITIONAL_PROPERTY_FLAG = "__additional_property";
+
const widgetMap = {
boolean: {
checkbox: "CheckboxWidget",
@@ -402,7 +404,56 @@ function findSchemaDefinition($ref, definitions = {}) {
throw new Error(`Could not find a definition for ${$ref}.`);
}
-export function retrieveSchema(schema, definitions = {}, formData = {}) {
+// In the case where we have to implicitly create a schema, it is useful to know what type to use
+// based on the data we are defining
+const guessType = function guessType(value) {
+ if (Array.isArray(value)) {
+ return "array";
+ } else if (typeof value === "string") {
+ return "string";
+ } else if (value == null) {
+ return "null";
+ } else if (typeof value === "boolean") {
+ return "boolean";
+ } else if (!isNaN(value)) {
+ return "number";
+ } else if (typeof value === "object") {
+ return "object";
+ }
+ // Default to string if we can't figure it out
+ return "string";
+};
+
+// This function will create new "properties" items for each key in our formData
+export function stubExistingAdditionalProperties(
+ schema,
+ definitions = {},
+ formData = {}
+) {
+ // Clone the schema so we don't ruin the consumer's original
+ schema = {
+ ...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]) };
+ // 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;
+}
+
+export function resolveSchema(schema, definitions = {}, formData = {}) {
if (schema.hasOwnProperty("$ref")) {
// Retrieve the referenced schema definition.
const $refSchema = findSchemaDefinition(schema.$ref, definitions);
@@ -423,6 +474,21 @@ export function retrieveSchema(schema, definitions = {}, formData = {}) {
}
}
+export function retrieveSchema(schema, definitions = {}, formData = {}) {
+ const resolvedSchema = resolveSchema(schema, definitions, formData);
+ const hasAdditionalProperties =
+ resolvedSchema.hasOwnProperty("additionalProperties") &&
+ resolvedSchema.additionalProperties !== false;
+ if (hasAdditionalProperties) {
+ return stubExistingAdditionalProperties(
+ resolvedSchema,
+ definitions,
+ formData
+ );
+ }
+ return resolvedSchema;
+}
+
function resolveDependencies(schema, definitions, formData) {
// Drop the dependencies from the source schema.
let { dependencies = {}, ...resolvedSchema } = schema;
diff --git a/test/ObjectField_test.js b/test/ObjectField_test.js
index 1099f35811..04dab222c4 100644
--- a/test/ObjectField_test.js
+++ b/test/ObjectField_test.js
@@ -3,6 +3,7 @@ import { expect } from "chai";
import { Simulate } from "react-addons-test-utils";
import { createFormComponent, createSandbox } from "./test_utils";
+import validateFormData from "../src/validate";
describe("ObjectField", () => {
let sandbox;
@@ -381,4 +382,268 @@ describe("ObjectField", () => {
expect(node.querySelector("#title-")).to.be.null;
});
});
+
+ describe("additionalProperties", () => {
+ const schema = {
+ type: "object",
+ additionalProperties: {
+ type: "string",
+ },
+ };
+
+ it("should automatically add a property field if in formData", () => {
+ const { node } = createFormComponent({
+ schema,
+ formData: { first: 1 },
+ });
+
+ expect(node.querySelectorAll(".field-string")).to.have.length.of(1);
+ });
+
+ it("should pass through non-schema properties and not throw validation errors if additionalProperties is undefined", () => {
+ const undefinedAPSchema = {
+ ...schema,
+ properties: { second: { type: "string" } },
+ };
+ delete undefinedAPSchema.additionalProperties;
+ const { comp } = createFormComponent({
+ schema: undefinedAPSchema,
+ formData: { nonschema: 1 },
+ });
+
+ expect(comp.state.formData.nonschema).eql(1);
+
+ const result = validateFormData(comp.state.formData, comp.state.schema);
+ expect(result.errors).eql([]);
+ });
+
+ it("should pass through non-schema properties but throw a validation error if additionalProperties is false", () => {
+ const { comp } = createFormComponent({
+ schema: {
+ ...schema,
+ additionalProperties: false,
+ properties: { second: { type: "string" } },
+ },
+ formData: { nonschema: 1 },
+ });
+
+ expect(comp.state.formData.nonschema).eql(1);
+
+ const result = validateFormData(comp.state.formData, comp.state.schema);
+ expect(result.errors[0].name).eql("additionalProperties");
+ });
+
+ it("should still obey properties if additionalProperties is defined", () => {
+ const { node } = createFormComponent({
+ schema: {
+ ...schema,
+ properties: {
+ definedProperty: {
+ type: "string",
+ },
+ },
+ },
+ });
+
+ expect(node.querySelectorAll(".field-string")).to.have.length.of(1);
+ });
+
+ it("should render a label for the additional property key", () => {
+ const { node } = createFormComponent({
+ schema,
+ formData: { first: 1 },
+ });
+
+ expect(node.querySelector("[for='root_first-key']").textContent).eql(
+ "first Key"
+ );
+ });
+
+ it("should render a label for the additional property key if additionalProperties is true", () => {
+ const { node } = createFormComponent({
+ schema: { ...schema, additionalProperties: true },
+ formData: { first: 1 },
+ });
+
+ expect(node.querySelector("[for='root_first-key']").textContent).eql(
+ "first Key"
+ );
+ });
+
+ it("should not render a label for the additional property key if additionalProperties is false", () => {
+ const { node } = createFormComponent({
+ schema: { ...schema, additionalProperties: false },
+ formData: { first: 1 },
+ });
+
+ expect(node.querySelector("[for='root_first-key']")).eql(null);
+ });
+
+ it("should render a text input for the additional property key", () => {
+ const { node } = createFormComponent({
+ schema,
+ formData: { first: 1 },
+ });
+
+ expect(node.querySelector("#root_first-key").value).eql("first");
+ });
+
+ it("should render a label for the additional property value", () => {
+ const { node } = createFormComponent({
+ schema,
+ formData: { first: 1 },
+ });
+
+ expect(node.querySelector("[for='root_first']").textContent).eql("first");
+ });
+
+ it("should render a text input for the additional property value", () => {
+ const { node } = createFormComponent({
+ schema,
+ formData: { first: 1 },
+ });
+
+ expect(node.querySelector("#root_first").value).eql("1");
+ });
+
+ it("should rename formData key if key input is renamed", () => {
+ const { comp, node } = createFormComponent({
+ schema,
+ formData: { first: 1 },
+ });
+
+ const textNode = node.querySelector("#root_first-key");
+ Simulate.blur(textNode, {
+ target: { value: "newFirst" },
+ });
+
+ expect(comp.state.formData.newFirst).eql(1);
+ });
+
+ it("should attach suffix to formData key if new key already exists when key input is renamed", () => {
+ const formData = {
+ first: 1,
+ second: 2,
+ };
+ const { comp, node } = createFormComponent({
+ schema,
+ formData,
+ });
+
+ const textNode = node.querySelector("#root_first-key");
+ Simulate.blur(textNode, {
+ target: { value: "second" },
+ });
+
+ expect(comp.state.formData["second-1"]).eql(1);
+ });
+
+ it("should continue incrementing suffix to formData key until that key name is unique after a key input collision", () => {
+ const formData = {
+ first: 1,
+ second: 2,
+ "second-1": 2,
+ "second-2": 2,
+ "second-3": 2,
+ "second-4": 2,
+ "second-5": 2,
+ "second-6": 2,
+ };
+ const { comp, node } = createFormComponent({
+ schema,
+ formData,
+ });
+
+ const textNode = node.querySelector("#root_first-key");
+ Simulate.blur(textNode, {
+ target: { value: "second" },
+ });
+
+ expect(comp.state.formData["second-7"]).eql(1);
+ });
+
+ it("should have an expand button", () => {
+ const { node } = createFormComponent({ schema });
+
+ expect(node.querySelector(".object-property-expand button")).not.eql(
+ null
+ );
+ });
+
+ it("should not have an expand button if expandable is false", () => {
+ const { node } = createFormComponent({
+ schema,
+ uiSchema: { "ui:options": { expandable: false } },
+ });
+
+ expect(node.querySelector(".object-property-expand button")).to.be.null;
+ });
+
+ it("should add a new property when clicking the expand button", () => {
+ const { comp, node } = createFormComponent({ schema });
+
+ Simulate.click(node.querySelector(".object-property-expand button"));
+
+ expect(comp.state.formData.newKey).eql("New Value");
+ });
+
+ it("should add a new property with suffix when clicking the expand button and 'newKey' already exists", () => {
+ const { comp, node } = createFormComponent({
+ schema,
+ formData: { newKey: 1 },
+ });
+
+ Simulate.click(node.querySelector(".object-property-expand button"));
+
+ expect(comp.state.formData["newKey-1"]).eql("New Value");
+ });
+
+ it("should not provide an expand button if length equals maxProperties", () => {
+ const { node } = createFormComponent({
+ schema: { maxProperties: 1, ...schema },
+ formData: { first: 1 },
+ });
+
+ expect(node.querySelector(".object-property-expand button")).to.be.null;
+ });
+
+ it("should provide an expand button if length is less than maxProperties", () => {
+ const { node } = createFormComponent({
+ schema: { maxProperties: 2, ...schema },
+ formData: { first: 1 },
+ });
+
+ expect(node.querySelector(".object-property-expand button")).not.eql(
+ null
+ );
+ });
+
+ it("should not provide an expand button if expandable is expliclty false regardless of maxProperties value", () => {
+ const { node } = createFormComponent({
+ schema: { maxProperties: 2, ...schema },
+ formData: { first: 1 },
+ uiSchema: {
+ "ui:options": {
+ expandable: false,
+ },
+ },
+ });
+
+ expect(node.querySelector(".object-property-expand button")).to.be.null;
+ });
+
+ it("should ignore expandable value if maxProperties constraint is not satisfied", () => {
+ const { node } = createFormComponent({
+ schema: { maxProperties: 1, ...schema },
+ formData: { first: 1 },
+ uiSchema: {
+ "ui:options": {
+ expandable: true,
+ },
+ },
+ });
+
+ expect(node.querySelector(".object-property-expand button")).to.be.null;
+ });
+ });
});