├── .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 | 
4 |
5 | Add dynamic support to add and remove nested associations generated by `Phoenix.HTML.inputs_for`.
6 |
7 | 
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 | Remove
29 | <% end %>
30 | <% end %>
31 |
32 |
33 | Add
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 | * Remove
22 | * <% end %>
23 | * <% end %>
24 | *
25 | * ```
26 | *
27 | * Add
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 | Color
10 |
11 |
12 | Brazil
13 | Estonia
14 |
15 |
16 | tag-1
17 | tag-2
18 | tag-3
19 |
20 | Remove
21 |
22 |
23 | Add more
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 | '
Color '
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 | `
23 |
Add more
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 |
--------------------------------------------------------------------------------