├── .github └── workflows │ └── ci.yml ├── .gitignore ├── README.md ├── babel.config.js ├── example.gif ├── js └── dynamic_nested.js ├── package-lock.json ├── package.json ├── static └── dynamic_nested.js ├── test ├── adding_nested_test.js └── removing_nested_test.js └── webpack.config.js /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [10.x, 12.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v1 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - name: npm install, build, and test 25 | run: | 26 | npm ci 27 | npm run build --if-present 28 | npm test 29 | env: 30 | CI: true 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dynamic Nested 2 | 3 | ![Actions Status](https://github.com/feliperenan/dynamic_nested/workflows/CI/badge.svg) 4 | 5 | Add dynamic support to add and remove nested associations generated by `Phoenix.HTML.inputs_for`. 6 | 7 | ![](example.gif) 8 | 9 | ## Examples 10 | 11 | In order to get it working, the following attributes must be added in the markup: 12 | 13 | ``` 14 | [dynamic-nested] - to active this component. 15 | [dynamic-nested-index=${index}] - to get nested association. 16 | [dynamic-nested-add] - to add nested. 17 | [dynamic-nested-remove] - to remove nested. 18 | ``` 19 | 20 | ```HTML 21 |
22 | <%= inputs_for @f, :categories, [skip_hidden: true], fn c -> %> 23 | <%= content_tag :div, dynamic_nested_index: c.index do %> 24 | # PS: generate hidden fields inside rows group to handle them easily. 25 | = for {key, value} <- row.hidden do 26 | = hidden_input c, key, value: value, dynamic_nested_field_id: true 27 | <%= text_input c, :name %> 28 | 29 | <% end %> 30 | <% end %> 31 |
32 | 33 | 34 | ``` 35 | 36 | Also, make sure to initialize this script after importing it on your application. 37 | 38 | ```JS 39 | import DynamicNested from 'dynamic_nested' 40 | 41 | document.querySelectorAll('[dynamic-nested]').forEach(element => DynamicNested(element)) 42 | ``` 43 | 44 | It supports the following callbacks: 45 | 46 | * beforeClone - You might want to do something before cloning the element. 47 | * afterAdd - You might want to do something after adding the new element. 48 | * afterRemove - You might want to do something after removing the element. 49 | 50 | ```JS 51 | const beforeClone = (element) => { ... } 52 | const afterAdd = (element, newElement) => { ... } 53 | const afterRemove = (elements) => { ... } 54 | 55 | document 56 | .querySelectorAll('[dynamic-nested]') 57 | .forEach(element => DynamicNested(element, { beforeClone, afterAdd, afterRemove })) 58 | ``` 59 | 60 | Everytime a User adds a new row, it is going to generate a new index for that row incrementing 61 | +1 from the last row on the page. As soon as an User removes a row, all indexes will be updated 62 | accordingly to reflect their position on the page. 63 | 64 | ## Instalattion 65 | ``` 66 | npm install --save dynamic_nested 67 | ``` 68 | 69 | ## Know caveats 70 | 71 | * It must contains at least one nested markup rendered on the page since `DinamicNested` will 72 | use it as a template to clone. 73 | * You must be using the last version of `Phoenix.HTML` that supports `skip_hidden` fields. 74 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | node: 'current', 8 | }, 9 | }, 10 | ], 11 | ], 12 | }; 13 | -------------------------------------------------------------------------------- /example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feliperenan/dynamic_nested/0b25b2cb17cced2fcd3bc658eb465cbd2ae9f937/example.gif -------------------------------------------------------------------------------- /js/dynamic_nested.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Add dynamic support to add and remove nested associations generated by `Phoenix.HTML.inputs_for`. 3 | * 4 | * In order to get it working, the following attributes must be added in the markup: 5 | * 6 | * [dynamic-nested] - to active this component. 7 | * [dynamic-nested-index=${index}] - to get nested association. 8 | * [dynamic-nested-add] - to add nested. 9 | * [dynamic-nested-remove] - to remove nested. 10 | * 11 | * ## Examples 12 | * 13 | * ``` 14 | *
15 | * <%= inputs_for @f, :categories, [skip_hidden: true], fn c -> %> 16 | * <%= content_tag :div, dynamic_nested_index: c.index do %> 17 | * # PS: generate hidden fields manually for handling them easily. 18 | * = for {key, value} <- c.hidden do 19 | * = hidden_input c, key, value: value, dynamic_nested_field_id: true 20 | * <%= text_input c, :name %> 21 | * 22 | * <% end %> 23 | * <% end %> 24 | *
25 | * ``` 26 | * 27 | * 28 | * 29 | * Everytime a User adds a new nested, it is going to generate a new index for that nested incrementing 30 | * +1 from the last nested on the page. As soon as an User removes one, all indexes will be updated 31 | * accordingly to reflect their position on the page. 32 | * 33 | * Also, make sure to initialize this script after importing it on your application. 34 | * 35 | * ```JS 36 | * import DynamicNested from 'dynamic-nested' 37 | * 38 | * document.querySelectorAll('[dynamic-nested]').forEach(element => DynamicNested(element)) 39 | * ``` 40 | * 41 | * It supports the following callbacks: 42 | * 43 | * * beforeClone - You might want to do something before cloning the element. 44 | * * afterAdd - You might want to do something after adding the new element. 45 | * * afterRemove - You might want to do something after removing the element. 46 | * 47 | * ```JS 48 | * const beforeClone = (element) => { ... } 49 | * const afterAdd = (element, newElement) => { ... } 50 | * const afterRemove = (elements) => { ... } 51 | * 52 | * new DynamicNested(element, { beforeClone, afterAdd, afterRemove }) 53 | * ``` 54 | * 55 | * ## Know caveats 56 | * 57 | * * It must contains at least one nested markup rendered on the page since `DinamicNested` will 58 | * use it as a template to clone. 59 | * * You must be using the last version of `Phoenix.HTML` that supports `skip_hidden` fields. 60 | **/ 61 | class DynamicNested { 62 | constructor(element, options = {}) { 63 | this.element = element 64 | this.options = options 65 | 66 | this.toggleRemoveButtonDisplay() 67 | 68 | document.addEventListener('click', event => { 69 | if(event.target.matches('[dynamic-nested-add]')) { 70 | const $allNested = this.element.querySelectorAll('[dynamic-nested-index]') 71 | 72 | this.add($allNested) 73 | this.toggleRemoveButtonDisplay() 74 | } 75 | 76 | if(event.target.matches('[dynamic-nested-remove]')) { 77 | const $nested = event.target.closest('[dynamic-nested-index]') 78 | 79 | this.remove($nested) 80 | this.toggleRemoveButtonDisplay() 81 | } 82 | }, false) 83 | } 84 | 85 | toggleRemoveButtonDisplay() { 86 | const $allNested = this.element.querySelectorAll('[dynamic-nested-index]') 87 | 88 | if($allNested.length <= 1) { 89 | const $button = $allNested[0].querySelector('[dynamic-nested-remove]') 90 | 91 | $button.style.display = 'none' 92 | } else { 93 | for(let $button of this.element.querySelectorAll('[dynamic-nested-remove]')) { 94 | $button.style.display = 'block' 95 | } 96 | } 97 | } 98 | 99 | add($allNested) { 100 | const $lastNested = $allNested[$allNested.length -1] 101 | 102 | if (this.options.beforeClone) { this.options.beforeClone($lastNested) } 103 | 104 | const $newNested = $lastNested.cloneNode(true) 105 | 106 | // copy selected options from the cloned to the new nested since they are not copied when cloned. 107 | $newNested.querySelectorAll('select').forEach((select, index) => { 108 | const cloneSelect = $lastNested.querySelectorAll('select')[index] 109 | 110 | if(select.multiple) { 111 | for(let option of select.options) { 112 | const cloneSelectOption = Array.from(cloneSelect.options).find(o => o.value == option.value) 113 | 114 | option.selected = cloneSelectOption.selected 115 | } 116 | } else { 117 | select.selectedIndex = cloneSelect.selectedIndex 118 | } 119 | }) 120 | 121 | // When editing the form, the cloned element will have hidden ids. They must be removed from 122 | // the new element. 123 | const hiddenId = $newNested.querySelector('[dynamic-nested-field-id]') 124 | if (hiddenId) { $newNested.removeChild(hiddenId) } 125 | 126 | // Create a new index according to the last nested. 127 | const index = +$lastNested.getAttribute('dynamic-nested-index') + 1 128 | this.replaceIndex($newNested, index) 129 | 130 | // Add new nested on the page. 131 | this.element.appendChild($newNested) 132 | 133 | if (this.options.afterAdd) { this.options.afterAdd($lastNested, $newNested) } 134 | } 135 | 136 | remove($nested) { 137 | this.element.removeChild($nested) 138 | 139 | const $allNested = this.element.querySelectorAll('[dynamic-nested-index]') 140 | 141 | Array.from($allNested).forEach(($nested, index) => { 142 | this.replaceIndex($nested, index) 143 | }) 144 | 145 | if (this.options.afterRemove) { this.options.afterRemove($allNested) } 146 | } 147 | 148 | /** 149 | * Replace indexes in `id`, `name` and `for` for all children from the given nested element. 150 | * 151 | * @param {object} element - DOM element. 152 | * @param {string} index - The new index that will be used in the attribute name. 153 | * 154 | * Examples 155 | * 156 | * Given the following nested element: 157 | *
158 | * 159 | *
160 | * 161 | * replaceIndex(element, "1") 162 | * 163 | * Will replace indexes in the and changes the DOM to: 164 | *
165 | * 166 | *
167 | **/ 168 | replaceIndex($nested, newIndex) { 169 | for(let attribute of ['id', 'name', 'for']) { 170 | const $children = $nested.querySelectorAll(`[${attribute}]`) 171 | 172 | Array.from($children).forEach($child => { 173 | const value = this.newAttributeName($child, attribute, newIndex) 174 | 175 | $child.setAttribute(attribute, value) 176 | }) 177 | 178 | $nested.setAttribute('dynamic-nested-index', newIndex) 179 | } 180 | } 181 | 182 | /** 183 | * Build a new attribute name according to the previous attribute name and new index. 184 | * 185 | * @param {object} element - DOM element that contains the attributes' name as base. 186 | * @param {string} attribute - Attribute name present in the given element. 187 | * @param {string} index - The new index that will be used in the attribute name. 188 | * 189 | * Examples 190 | * 191 | * newAttributeName(, "name", 1) 192 | * => "user[categories][1][name]" 193 | * 194 | * newAttributeName(, "id", 1) 195 | * => "user_categories_1" 196 | **/ 197 | newAttributeName(element, attribute, index) { 198 | return element.getAttribute(attribute).replace(/[0-9]/g, index) 199 | } 200 | } 201 | 202 | export default DynamicNested 203 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dynamic_nested", 3 | "version": "0.1.4", 4 | "description": "Add/Remove nested associations dynamically in forms that generates markup like Phoenix Framework.", 5 | "main": "./static/dynamic_nested.js", 6 | "scripts": { 7 | "build": "webpack --mode production", 8 | "test": "jest" 9 | }, 10 | "author": "Felipe Renan", 11 | "license": "MIT", 12 | "devDependencies": { 13 | "@babel/core": "^7.6.4", 14 | "@babel/preset-env": "^7.6.3", 15 | "@testing-library/jest-dom": "^4.1.2", 16 | "babel-jest": "^24.9.0", 17 | "babel-loader": "^8.0.6", 18 | "copy-webpack-plugin": "^5.1.1", 19 | "expose-loader": "^0.7.5", 20 | "extract-text-webpack-plugin": "^3.0.2", 21 | "jest": "^24.9.0", 22 | "webpack": "^4.41.2", 23 | "webpack-cli": "^3.3.9" 24 | }, 25 | "jest": { 26 | "testRegex": "/test/.*_test\\.js$" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /static/dynamic_nested.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.dynamic_nested=t():e.dynamic_nested=t()}(window,(function(){return function(e){var t={};function n(o){if(t[o])return t[o].exports;var r=t[o]={i:o,l:!1,exports:{}};return e[o].call(r.exports,r,r.exports,n),r.l=!0,r.exports}return n.m=e,n.c=t,n.d=function(e,t,o){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:o})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var o=Object.create(null);if(n.r(o),Object.defineProperty(o,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var r in e)n.d(o,r,function(t){return e[t]}.bind(null,r));return o},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=0)}([function(e,t,n){(function(t){t.Phoenix||(t.Phoenix={}),e.exports=t.Phoenix.LiveView=n(2)}).call(this,n(1))},function(e,t){var n;n=function(){return this}();try{n=n||new Function("return this")()}catch(e){"object"==typeof window&&(n=window)}e.exports=n},function(e,t,n){"use strict";n.r(t);t.default=class{constructor(e,t={}){this.element=e,this.options=t,this.toggleRemoveButtonDisplay(),document.addEventListener("click",e=>{if(e.target.matches("[dynamic-nested-add]")){const e=this.element.querySelectorAll("[dynamic-nested-index]");this.add(e),this.toggleRemoveButtonDisplay()}if(e.target.matches("[dynamic-nested-remove]")){const t=e.target.closest("[dynamic-nested-index]");this.remove(t),this.toggleRemoveButtonDisplay()}},!1)}toggleRemoveButtonDisplay(){const e=this.element.querySelectorAll("[dynamic-nested-index]");if(e.length<=1){e[0].querySelector("[dynamic-nested-remove]").style.display="none"}else for(let e of this.element.querySelectorAll("[dynamic-nested-remove]"))e.style.display="block"}add(e){const t=e[e.length-1];this.options.beforeClone&&this.options.beforeClone(t);const n=t.cloneNode(!0);n.querySelectorAll("select").forEach((e,n)=>{const o=t.querySelectorAll("select")[n];if(e.multiple)for(let t of e.options){const e=Array.from(o.options).find(e=>e.value==t.value);t.selected=e.selected}else e.selectedIndex=o.selectedIndex});const o=n.querySelector("[dynamic-nested-field-id]");o&&n.removeChild(o);const r=+t.getAttribute("dynamic-nested-index")+1;this.replaceIndex(n,r),this.element.appendChild(n),this.options.afterAdd&&this.options.afterAdd(t,n)}remove(e){this.element.removeChild(e);const t=this.element.querySelectorAll("[dynamic-nested-index]");Array.from(t).forEach((e,t)=>{this.replaceIndex(e,t)}),this.options.afterRemove&&this.options.afterRemove(t)}replaceIndex(e,t){for(let n of["id","name","for"]){const o=e.querySelectorAll(`[${n}]`);Array.from(o).forEach(e=>{const o=this.newAttributeName(e,n,t);e.setAttribute(n,o)}),e.setAttribute("dynamic-nested-index",t)}}newAttributeName(e,t,n){return e.getAttribute(t).replace(/[0-9]/g,n)}}}])})); -------------------------------------------------------------------------------- /test/adding_nested_test.js: -------------------------------------------------------------------------------- 1 | import DynamicNested from '../js/dynamic_nested' 2 | import '@testing-library/jest-dom/extend-expect' 3 | 4 | beforeEach(() => { 5 | document.body.innerHTML = 6 | `
7 |
8 | 9 | 10 | 11 | 15 | 20 | 21 |
22 |
23 | 24 | ` 25 | }) 26 | 27 | afterEach(() => { 28 | document.body.innerHTML = '' 29 | }) 30 | 31 | test('adds a new nested association markup incrementing +1 from the previous index', () => { 32 | const element = document.querySelector('[dynamic-nested]') 33 | 34 | new DynamicNested(element) 35 | 36 | // Simulates a User clicks on add button. 37 | document.querySelector('[dynamic-nested-add]').click() 38 | 39 | expect(element.querySelectorAll('[dynamic-nested-index]').length).toBe(2) 40 | 41 | expect(element).toContainHTML( 42 | '
' 43 | ) 44 | expect(element).toContainHTML( 45 | '' 46 | ) 47 | expect(element).toContainHTML( 48 | '' 49 | ) 50 | expect(element).toContainElement( 51 | element.querySelector('[dynamic-nested-remove]') 52 | ) 53 | 54 | // copies selected values from the cloned nested 55 | const select = element.querySelector('#user_countries_1_id') 56 | const multipleSelect = element.querySelector('#user_tags_1_id') 57 | 58 | expect(select).toHaveValue('2') 59 | expect(multipleSelect).toHaveValue(['2', '3']) 60 | 61 | // does not copy hidden ids. 62 | expect(element).not.toContainElement( 63 | element.querySelector('#user_colors_1_id') 64 | ) 65 | }) 66 | 67 | test('copy selected values from multiple select after user interaction', () => { 68 | const element = document.querySelector('[dynamic-nested]') 69 | 70 | new DynamicNested(element) 71 | 72 | const multipleSelect = document.querySelector('#user_tags_0_id') 73 | 74 | // Simulates the first tag being selected. 75 | multipleSelect.options[0].selected = true 76 | 77 | // Simulates the second tag being unselected. 78 | multipleSelect.options[1].selected = false 79 | 80 | // Simulates a User clicks on add button. 81 | document.querySelector('[dynamic-nested-add]').click() 82 | 83 | expect( 84 | element.querySelector('#user_tags_1_id') 85 | ).toHaveValue(['1', '3']) 86 | }) 87 | 88 | test('supports callbacks beforeClone and afterAdd', () => { 89 | const element = document.querySelector('[dynamic-nested]') 90 | 91 | const beforeClone = (element) => { 92 | element.setAttribute("data-before-clone", true) 93 | } 94 | 95 | const afterAdd = (element, newElement) => { 96 | element.setAttribute("data-after-added", true) 97 | newElement.setAttribute("data-after-added", true) 98 | } 99 | 100 | new DynamicNested(element, { beforeClone, afterAdd }) 101 | 102 | // Simulates a User clicks on add button. 103 | document.querySelector('[dynamic-nested-add]').click() 104 | 105 | const clonedElement = element.querySelector('[dynamic-nested-index="0"]') 106 | const newElement = element.querySelector('[dynamic-nested-index="1"]') 107 | 108 | expect(clonedElement).toHaveAttribute('data-before-clone', 'true') 109 | expect(clonedElement).toHaveAttribute('data-after-added', 'true') 110 | expect(newElement).toHaveAttribute('data-after-added', 'true') 111 | }) 112 | 113 | -------------------------------------------------------------------------------- /test/removing_nested_test.js: -------------------------------------------------------------------------------- 1 | import DynamicNested from '../js/dynamic_nested' 2 | import '@testing-library/jest-dom/extend-expect' 3 | 4 | beforeEach(() => { 5 | document.body.innerHTML = 6 | `
7 |
8 | 9 | 10 | 11 |
12 |
13 | 14 | 15 | 16 |
17 |
18 | 19 | 20 | 21 |
22 |
23 | 24 | `.trim() 25 | }) 26 | 27 | test('removes the clicked nested and rebuild indexes and support afterRemove callback', () => { 28 | const element = document.querySelector('[dynamic-nested]') 29 | 30 | const afterRemove = (elements) => { 31 | Array.from(elements).forEach(element => element.setAttribute('data-after-remove', true)) 32 | } 33 | 34 | new DynamicNested(element, { afterRemove }) 35 | 36 | // Simulates a User clicks on second nested association remove's button. 37 | element 38 | .querySelector('[dynamic-nested-index="1"]') 39 | .querySelector('[dynamic-nested-remove]') 40 | .click() 41 | 42 | expect(element.querySelectorAll('[dynamic-nested-index]').length).toBe(2) 43 | 44 | Array.from(element.querySelectorAll('[dynamic-nested-index]')).forEach(element => { 45 | expect(element).toHaveAttribute('data-after-remove', 'true') 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | entry: './js/dynamic_nested.js', 5 | output: { 6 | filename: 'dynamic_nested.js', 7 | path: path.resolve(__dirname, './static'), 8 | library: 'dynamic_nested', 9 | libraryTarget: 'umd' 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | test: path.resolve(__dirname, './js/dynamic_nested.js'), 15 | use: [{ 16 | loader: 'expose-loader', 17 | options: 'Phoenix.LiveView' 18 | }] 19 | }, 20 | { 21 | test: /\.js$/, 22 | exclude: /node_modules/, 23 | use: { 24 | loader: 'babel-loader' 25 | } 26 | } 27 | ] 28 | }, 29 | plugins: [] 30 | } 31 | --------------------------------------------------------------------------------