diff --git a/docs/advanced-customization.md b/docs/advanced-customization.md index 69ec69fef3..3983c1f567 100644 --- a/docs/advanced-customization.md +++ b/docs/advanced-customization.md @@ -112,6 +112,7 @@ The following props are part of each element in `items`: - `hasRemove`: A boolean value stating whether the array item can be removed. - `hasToolbar`: A boolean value stating whether the array item has a toolbar. - `index`: A number stating the index the array item occurs in `items`. +- `key`: A stable, unique key for the array item. - `onDropIndexClick: (index) => (event) => void`: Returns a function that removes the item at `index`. - `onReorderClick: (index, newIndex) => (event) => void`: Returns a function that swaps the items at `index` with `newIndex`. - `readonly`: A boolean value stating if the array item is read-only. diff --git a/package-lock.json b/package-lock.json index f6a474b48f..d45e72d66e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8170,6 +8170,11 @@ "dev": true, "optional": true }, + "nanoid": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-2.0.3.tgz", + "integrity": "sha512-NbaoqdhIYmY6FXDRB4eYtDVC9Z9eCbn8TyaiC16LNKtpPv/aqa0tOPD8y6gNE4yUNnaZ7LLhYtXOev/6+cBtfw==" + }, "nanomatch": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", @@ -10729,6 +10734,11 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz", "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==" }, + "react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" + }, "react-proxy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/react-proxy/-/react-proxy-1.1.8.tgz", @@ -11268,6 +11278,14 @@ "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", "dev": true }, + "shortid": { + "version": "2.2.14", + "resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.14.tgz", + "integrity": "sha1-gNtqr8vD46RoULPIjTngUbhMjRg=", + "requires": { + "nanoid": "^2.0.0" + } + }, "signal-exit": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", diff --git a/package.json b/package.json index a44c246b4e..8a58da5be7 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,9 @@ "lodash.pick": "^4.4.0", "lodash.topath": "^4.5.2", "prop-types": "^15.5.8", - "react-is": "^16.8.4" + "react-is": "^16.8.4", + "react-lifecycles-compat": "^3.0.4", + "shortid": "^2.2.14" }, "devDependencies": { "@babel/cli": "^7.4.4", diff --git a/playground/samples/customArray.js b/playground/samples/customArray.js index 0e6934cae1..4e907b5485 100644 --- a/playground/samples/customArray.js +++ b/playground/samples/customArray.js @@ -5,7 +5,7 @@ function ArrayFieldTemplate(props) {
{props.items && props.items.map(element => ( -
+
{element.children}
{element.hasMoveDown && ( + )} + {(element.hasMoveUp || element.hasMoveDown) && ( + + )} + {element.hasRemove && ( + + )} + +
+
+ ))} + + {props.canAdd && ( +
+ +
+ )} +
+ ); +}; + describe("ArrayField", () => { let sandbox; const CustomComponent = props => { @@ -164,6 +220,18 @@ describe("ArrayField", () => { expect(node.querySelectorAll(".field-string")).to.have.length.of(1); }); + it("should assign new keys/ids when clicking the add button", () => { + const { node } = createFormComponent({ + schema, + ArrayFieldTemplate: ExposedArrayKeyTemplate, + }); + + Simulate.click(node.querySelector(".array-item-add button")); + + expect(node.querySelector(".array-item").hasAttribute(ArrayKeyDataAttr)) + .to.be.true; + }); + it("should not provide an add button if length equals maxItems", () => { const { node } = createFormComponent({ schema: { maxItems: 2, ...schema }, @@ -182,6 +250,33 @@ describe("ArrayField", () => { expect(node.querySelector(".array-item-add button")).not.eql(null); }); + it("should retain existing row keys/ids when adding new row", () => { + const { node } = createFormComponent({ + schema: { maxItems: 2, ...schema }, + formData: ["foo"], + ArrayFieldTemplate: ExposedArrayKeyTemplate, + }); + + const startRows = node.querySelectorAll(".array-item"); + const startRow1_key = startRows[0].getAttribute(ArrayKeyDataAttr); + const startRow2_key = startRows[1] + ? startRows[1].getAttribute(ArrayKeyDataAttr) + : undefined; + + Simulate.click(node.querySelector(".array-item-add button")); + + const endRows = node.querySelectorAll(".array-item"); + const endRow1_key = endRows[0].getAttribute(ArrayKeyDataAttr); + const endRow2_key = endRows[1].getAttribute(ArrayKeyDataAttr); + + expect(startRow1_key).to.equal(endRow1_key); + expect(startRow2_key).to.not.equal(endRow2_key); + + expect(startRow2_key).to.be.undefined; + expect(endRows[0].hasAttribute(ArrayKeyDataAttr)).to.be.true; + expect(endRows[1].hasAttribute(ArrayKeyDataAttr)).to.be.true; + }); + it("should not provide an add button if addable is expliclty false regardless maxItems value", () => { const { node } = createFormComponent({ schema: { maxItems: 2, ...schema }, @@ -281,6 +376,62 @@ describe("ArrayField", () => { expect(inputs[2].value).eql("bar"); }); + it("should retain row keys/ids when moving down", () => { + const { node } = createFormComponent({ + schema, + formData: ["foo", "bar", "baz"], + ArrayFieldTemplate: ExposedArrayKeyTemplate, + }); + const moveDownBtns = node.querySelectorAll(".array-item-move-down"); + const startRows = node.querySelectorAll(".array-item"); + const startRow1_key = startRows[0].getAttribute(ArrayKeyDataAttr); + const startRow2_key = startRows[1].getAttribute(ArrayKeyDataAttr); + const startRow3_key = startRows[2].getAttribute(ArrayKeyDataAttr); + + Simulate.click(moveDownBtns[0]); + + const endRows = node.querySelectorAll(".array-item"); + const endRow1_key = endRows[0].getAttribute(ArrayKeyDataAttr); + const endRow2_key = endRows[1].getAttribute(ArrayKeyDataAttr); + const endRow3_key = endRows[2].getAttribute(ArrayKeyDataAttr); + + expect(startRow1_key).to.equal(endRow2_key); + expect(startRow2_key).to.equal(endRow1_key); + expect(startRow3_key).to.equal(endRow3_key); + + expect(endRows[0].hasAttribute(ArrayKeyDataAttr)).to.be.true; + expect(endRows[1].hasAttribute(ArrayKeyDataAttr)).to.be.true; + expect(endRows[2].hasAttribute(ArrayKeyDataAttr)).to.be.true; + }); + + it("should retain row keys/ids when moving up", () => { + const { node } = createFormComponent({ + schema, + formData: ["foo", "bar", "baz"], + ArrayFieldTemplate: ExposedArrayKeyTemplate, + }); + const moveUpBtns = node.querySelectorAll(".array-item-move-up"); + const startRows = node.querySelectorAll(".array-item"); + const startRow1_key = startRows[0].getAttribute(ArrayKeyDataAttr); + const startRow2_key = startRows[1].getAttribute(ArrayKeyDataAttr); + const startRow3_key = startRows[2].getAttribute(ArrayKeyDataAttr); + + Simulate.click(moveUpBtns[2]); + + const endRows = node.querySelectorAll(".array-item"); + const endRow1_key = endRows[0].getAttribute(ArrayKeyDataAttr); + const endRow2_key = endRows[1].getAttribute(ArrayKeyDataAttr); + const endRow3_key = endRows[2].getAttribute(ArrayKeyDataAttr); + + expect(startRow1_key).to.equal(endRow1_key); + expect(startRow2_key).to.equal(endRow3_key); + expect(startRow3_key).to.equal(endRow2_key); + + expect(endRows[0].hasAttribute(ArrayKeyDataAttr)).to.be.true; + expect(endRows[1].hasAttribute(ArrayKeyDataAttr)).to.be.true; + expect(endRows[2].hasAttribute(ArrayKeyDataAttr)).to.be.true; + }); + it("should move from first to last in the list", () => { function moveAnywhereArrayItemTemplate(props) { const buttons = []; @@ -295,7 +446,10 @@ describe("ArrayField", () => { ); } return ( -
+
{props.children} {buttons}
@@ -316,6 +470,11 @@ describe("ArrayField", () => { ArrayFieldTemplate: moveAnywhereArrayFieldTemplate, }); + const startRows = node.querySelectorAll(".array-item"); + const startRow1_key = startRows[0].getAttribute(ArrayKeyDataAttr); + const startRow2_key = startRows[1].getAttribute(ArrayKeyDataAttr); + const startRow3_key = startRows[2].getAttribute(ArrayKeyDataAttr); + const button = node.querySelector(".item-0 .array-item-move-to-2"); Simulate.click(button); @@ -323,6 +482,19 @@ describe("ArrayField", () => { expect(inputs[0].value).eql("bar"); expect(inputs[1].value).eql("baz"); expect(inputs[2].value).eql("foo"); + + const endRows = node.querySelectorAll(".array-item"); + const endRow1_key = endRows[0].getAttribute(ArrayKeyDataAttr); + const endRow2_key = endRows[1].getAttribute(ArrayKeyDataAttr); + const endRow3_key = endRows[2].getAttribute(ArrayKeyDataAttr); + + expect(startRow1_key).to.equal(endRow3_key); + expect(startRow2_key).to.equal(endRow1_key); + expect(startRow3_key).to.equal(endRow2_key); + + expect(endRows[0].hasAttribute(ArrayKeyDataAttr)).to.be.true; + expect(endRows[1].hasAttribute(ArrayKeyDataAttr)).to.be.true; + expect(endRows[2].hasAttribute(ArrayKeyDataAttr)).to.be.true; }); it("should disable move buttons on the ends of the list", () => { @@ -366,6 +538,26 @@ describe("ArrayField", () => { expect(inputs[0].value).eql("bar"); }); + it("should retain row keys/ids of remaining rows when a row is removed", () => { + const { node } = createFormComponent({ + schema, + formData: ["foo", "bar"], + ArrayFieldTemplate: ExposedArrayKeyTemplate, + }); + + const startRows = node.querySelectorAll(".array-item"); + const startRow2_key = startRows[1].getAttribute(ArrayKeyDataAttr); + + const dropBtns = node.querySelectorAll(".array-item-remove"); + Simulate.click(dropBtns[0]); + + const endRows = node.querySelectorAll(".array-item"); + const endRow1_key = endRows[0].getAttribute(ArrayKeyDataAttr); + + expect(startRow2_key).to.equal(endRow1_key); + expect(endRows[0].hasAttribute(ArrayKeyDataAttr)).to.be.true; + }); + it("should not show remove button if removable is false", () => { const { node } = createFormComponent({ schema, @@ -1249,8 +1441,17 @@ describe("ArrayField", () => { const { comp, node } = createFormComponent({ schema: schemaAdditional, formData: [1, 2, "foo"], + ArrayFieldTemplate: ExposedArrayKeyTemplate, }); + const startRows = node.querySelectorAll(".array-item"); + const startRow1_key = startRows[0].getAttribute(ArrayKeyDataAttr); + const startRow2_key = startRows[1].getAttribute(ArrayKeyDataAttr); + const startRow3_key = startRows[2].getAttribute(ArrayKeyDataAttr); + const startRow4_key = startRows[3] + ? startRows[3].getAttribute(ArrayKeyDataAttr) + : undefined; + it("should add a field when clicking add button", () => { const addBtn = node.querySelector(".array-item-add button"); @@ -1260,6 +1461,25 @@ describe("ArrayField", () => { expect(comp.state.formData).eql([1, 2, "foo", undefined]); }); + it("should retain existing row keys/ids when adding additional items", () => { + const endRows = node.querySelectorAll(".array-item"); + const endRow1_key = endRows[0].getAttribute(ArrayKeyDataAttr); + const endRow2_key = endRows[1].getAttribute(ArrayKeyDataAttr); + const endRow3_key = endRows[2].getAttribute(ArrayKeyDataAttr); + const endRow4_key = endRows[3].getAttribute(ArrayKeyDataAttr); + + expect(startRow1_key).to.equal(endRow1_key); + expect(startRow2_key).to.equal(endRow2_key); + expect(startRow3_key).to.equal(endRow3_key); + + expect(startRow4_key).to.not.equal(endRow4_key); + expect(startRow4_key).to.be.undefined; + expect(endRows[0].hasAttribute(ArrayKeyDataAttr)).to.be.true; + expect(endRows[1].hasAttribute(ArrayKeyDataAttr)).to.be.true; + expect(endRows[2].hasAttribute(ArrayKeyDataAttr)).to.be.true; + expect(endRows[3].hasAttribute(ArrayKeyDataAttr)).to.be.true; + }); + it("should change the state when changing input value", () => { const inputs = node.querySelectorAll(".field-string input[type=text]");