├── .eslintrc.js ├── .github ├── dependabot.yml ├── release-drafter.yml └── workflows │ ├── npmpublish.yml │ └── release-drafter.yml ├── .gitignore ├── .tool-versions ├── LICENSE ├── README.md ├── index.js └── package.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "root": true, 3 | "extends": "eslint:recommended", 4 | "env": { 5 | "node": true, 6 | "es6": true, 7 | "amd": true, 8 | "browser": true 9 | }, 10 | "parserOptions": { 11 | "ecmaFeatures": { 12 | "globalReturn": true, 13 | "generators": false, 14 | "objectLiteralDuplicateProperties": false, 15 | "experimentalObjectRestSpread": true 16 | }, 17 | "ecmaVersion": 2017, 18 | "sourceType": "module" 19 | }, 20 | "plugins": [ 21 | "import" 22 | ], 23 | "settings": { 24 | "import/core-modules": [], 25 | "import/ignore": [ 26 | "node_modules", 27 | "\\.(coffee|scss|css|less|hbs|svg|json)$" 28 | ] 29 | }, 30 | "rules": { 31 | "no-console": 0, 32 | "comma-dangle": [ 33 | "error", 34 | { 35 | "arrays": "always-multiline", 36 | "objects": "always-multiline", 37 | "imports": "always-multiline", 38 | "exports": "always-multiline", 39 | "functions": "ignore" 40 | } 41 | ] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$NEXT_PATCH_VERSION 🌈' 2 | tag-template: 'v$NEXT_PATCH_VERSION' 3 | categories: 4 | - title: '🚀 Features' 5 | labels: 6 | - 'feature' 7 | - 'enhancement' 8 | - title: '🐛 Bug Fixes' 9 | labels: 10 | - 'fix' 11 | - 'bugfix' 12 | - 'bug' 13 | - title: '🧰 Maintenance' 14 | labels: 15 | - 'dependencies' 16 | - 'documentation' 17 | change-template: '- $TITLE @$AUTHOR (#$NUMBER)' 18 | template: | 19 | ## Changes 20 | $CHANGES 21 | -------------------------------------------------------------------------------- /.github/workflows/npmpublish.yml: -------------------------------------------------------------------------------- 1 | name: Node.js Package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | 13 | - name: Install nodejs dependencies 14 | run: sudo apt-get install gpg dirmngr 15 | 16 | - name: Read .tool-versions 17 | uses: marocchino/tool-versions-action@v1 18 | id: versions 19 | 20 | - name: Set up Node 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: ${{ steps.versions.outputs.nodejs}} 24 | 25 | - name: Install dependencies 26 | run: yarn install 27 | 28 | - name: Publish the package to npmjs 29 | run: | 30 | npm config set //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN 31 | npm config set @oddcamp:registry https://registry.npmjs.org/ 32 | npm publish --access public 33 | env: 34 | NODE_AUTH_TOKEN: ${{secrets.NPM_AUTH_TOKEN}} 35 | 36 | - name: Publish the package to GPR 37 | run: | 38 | npm config set //npm.pkg.github.com/:_authToken=$NODE_AUTH_TOKEN 39 | npm config set @oddcamp:registry https://npm.pkg.github.com/ 40 | npm publish --access public 41 | env: 42 | NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} 43 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | # branches to consider in the event; optional, defaults to all 6 | branches: 7 | - master 8 | 9 | jobs: 10 | update_release_draft: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: release-drafter/release-drafter@v5 14 | id: release_drafter 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 12.9.0 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2021 Odd Camp AB 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cocoon-vanilla-js 2 | 3 | A vanilla JS replacement for (Rails) [Cocoon](https://github.com/nathanvda/cocoon)'s jQuery script 4 | 5 | ## Usage 6 | 7 | Run: 8 | 9 | ``` 10 | yarn add @oddcamp/cocoon-vanilla-js 11 | ``` 12 | 13 | Import as ES6 module: 14 | 15 | ```js 16 | import "@oddcamp/cocoon-vanilla-js"; 17 | ``` 18 | 19 | ## Notes 20 | 21 | To broaden browser support, include the following polyfills in your project: 22 | 23 | - [Element.closest](https://www.npmjs.com/package/element-closest) 24 | - [Element.classList](https://www.npmjs.com/package/classlist-polyfill) 25 | - [Array.from](https://www.npmjs.com/package/array-from-polyfill) 26 | - [CustomEvent](https://www.npmjs.com/package/custom-event-polyfill) 27 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | let cocoon_element_counter = 0; 2 | 3 | const create_new_id = function() { 4 | return (new Date().getTime() + cocoon_element_counter++); 5 | }; 6 | 7 | const newcontent_braced = function(id) { 8 | return '[' + id + ']$1'; 9 | }; 10 | 11 | const newcontent_underscord = function(id) { 12 | return '_' + id + '_$1'; 13 | }; 14 | 15 | const getInsertionNodeElem = function(insertionNode, insertionTraversal, btn) { 16 | if(!insertionNode){ 17 | return btn.parentNode; 18 | } 19 | 20 | if(typeof insertionNode === 'function') { 21 | if(insertionTraversal) { 22 | console.warn('association-insertion-traversal is ignored, because association-insertion-node is given as a function.'); 23 | } 24 | return insertionNode(btn); 25 | } 26 | 27 | if(typeof insertionNode == 'string') { 28 | if (insertionTraversal) { 29 | // @TODO 30 | // - prevUntil 31 | // - nextUntil 32 | 33 | const prevNext = { 34 | prev: 'previousElementSibling', 35 | next: 'nextElementSibling', 36 | }[insertionTraversal] 37 | 38 | if (prevNext) { 39 | const el = btn[prevNext].closest(insertionNode) 40 | if (el === btn[prevNext]) return el 41 | } 42 | else if (insertionTraversal == 'closest') { 43 | return btn.closest(insertionNode) 44 | } 45 | else { 46 | console.warn('The provided association-insertion-traversal is not supported'); 47 | } 48 | } 49 | else { 50 | return insertionNode == 'this' ? btn : document.querySelector(insertionNode); 51 | } 52 | } 53 | }; 54 | 55 | const addFieldsHandler = (btn) => { 56 | const assoc = btn.getAttribute('data-association'); 57 | const assocs = btn.getAttribute('data-associations'); 58 | const content = btn.getAttribute('data-association-insertion-template'); 59 | const insertionNode = btn.getAttribute('data-association-insertion-node'); 60 | const insertionTraversal = btn.getAttribute('data-association-insertion-traversal'); 61 | let insertionMethod = btn.getAttribute('data-association-insertion-method') || btn.getAttribute('data-association-insertion-position') || 'before'; 62 | let new_id = create_new_id(); 63 | let count = parseInt(btn.getAttribute('data-count'), 10); 64 | let regexp_braced = new RegExp('\\[new_' + assoc + '\\](.*?\\s)', 'g'); 65 | let regexp_underscord = new RegExp('_new_' + assoc + '_(\\w*)', 'g'); 66 | let new_content = content.replace(regexp_braced, newcontent_braced(new_id)); 67 | let new_contents = []; 68 | 69 | if(new_content == content) { 70 | regexp_braced = new RegExp('\\[new_' + assocs + '\\](.*?\\s)', 'g'); 71 | regexp_underscord = new RegExp('_new_' + assocs + '_(\\w*)', 'g'); 72 | new_content = content.replace(regexp_braced, newcontent_braced(new_id)); 73 | } 74 | 75 | new_content = new_content.replace(regexp_underscord, newcontent_underscord(new_id)); 76 | new_contents = [new_content]; 77 | 78 | count = (isNaN(count) ? 1 : Math.max(count, 1)); 79 | count -= 1; 80 | 81 | while(count) { 82 | new_id = create_new_id(); 83 | new_content = content.replace(regexp_braced, newcontent_braced(new_id)); 84 | new_content = new_content.replace(regexp_underscord, newcontent_underscord(new_id)); 85 | new_contents.push(new_content); 86 | count -= 1; 87 | } 88 | 89 | const insertionNodeElem = getInsertionNodeElem(insertionNode, insertionTraversal, btn); 90 | 91 | if(!insertionNodeElem || (insertionNodeElem.length == 0)) { 92 | console.warn("Couldn't find the element to insert the template. Make sure your `data-association-insertion-*` on `link_to_add_association` is correct."); 93 | } 94 | 95 | new_contents.forEach((node) => { 96 | const event = new CustomEvent('cocoon:before-insert', {detail: node, bubbles: true, cancelable: true}); 97 | insertionNodeElem.dispatchEvent(event); 98 | 99 | if(!event.defaultPrevented) { 100 | switch(insertionMethod) { 101 | default: 102 | case 'before': 103 | insertionMethod = 'beforebegin'; 104 | break; 105 | case 'after': 106 | insertionMethod = 'afterend'; 107 | break; 108 | case 'append': 109 | insertionMethod = 'beforeend'; 110 | break; 111 | case 'prepend': 112 | insertionMethod = 'afterbegin'; 113 | break; 114 | } 115 | 116 | insertionNodeElem.insertAdjacentHTML(insertionMethod, node); 117 | insertionNodeElem.dispatchEvent( 118 | new CustomEvent('cocoon:after-insert', {detail: node, bubbles: true, cancelable: true}) 119 | ); 120 | } 121 | }); 122 | }; 123 | 124 | document.addEventListener('click', (e) => { 125 | if(e.target.closest('.add_fields')) { 126 | e.preventDefault(); 127 | e.stopPropagation(); 128 | addFieldsHandler(e.target.closest('.add_fields')); 129 | } 130 | }); 131 | 132 | const removeFieldsHandler = (btn) => { 133 | const wrapperClass = btn.getAttribute('data-wrapper-class') || 'nested-fields'; 134 | const nodeToDelete = btn.closest(`.${wrapperClass}`); 135 | const triggerNode = nodeToDelete.parentNode; 136 | 137 | const event = new CustomEvent('cocoon:before-remove', {detail: nodeToDelete, bubbles: true, cancelable: true}); 138 | triggerNode.dispatchEvent(event); 139 | 140 | if(!event.defaultPrevented) { 141 | const timeout = triggerNode.getAttribute('data-remove-timeout') || 0; 142 | 143 | setTimeout(() => { 144 | if(btn.classList.contains('dynamic')) { 145 | // nodeToDelete.remove(); 146 | nodeToDelete.parentNode.removeChild(nodeToDelete); 147 | } 148 | else { 149 | const input = nodeToDelete.querySelector('input[type=hidden][name*="[_destroy]"'); 150 | if(input) { 151 | input.value = 1; 152 | } 153 | nodeToDelete.style.display = 'none'; 154 | } 155 | 156 | triggerNode.dispatchEvent( 157 | new CustomEvent('cocoon:after-remove', {detail: nodeToDelete, bubbles: true, cancelable: true}) 158 | ); 159 | }, timeout); 160 | } 161 | }; 162 | 163 | document.addEventListener('click', (e) => { 164 | const btn = 165 | e.target.closest('.remove_fields.dynamic') || 166 | e.target.closest('.remove_fields.existing'); 167 | 168 | if(btn) { 169 | e.preventDefault(); 170 | e.stopPropagation(); 171 | removeFieldsHandler(btn); 172 | } 173 | }); 174 | 175 | const hideFields = () => { 176 | Array.from(document.querySelectorAll('.remove_fields.existing.destroyed')).forEach((btn) => { 177 | const wrapperClass = btn.getAttribute('data-wrapper-class') || 'nested-fields'; 178 | btn.closest(`.${wrapperClass}`).style.display = 'none'; 179 | }); 180 | }; 181 | 182 | document.addEventListener('DOMContentLoaded', hideFields); 183 | document.addEventListener('turbolinks:load', hideFields); 184 | document.addEventListener('page:load', hideFields); 185 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@oddcamp/cocoon-vanilla-js", 3 | "version": "1.1.3", 4 | "description": "A vanilla JS replacement for (Rails) Cocoon's jQuery script", 5 | "main": "index.js", 6 | "repository": "git+https://github.com/oddcamp/cocoon-vanilla-js.git", 7 | "author": "Odd Camp", 8 | "license": "MIT", 9 | "bugs": { 10 | "url": "https://github.com/oddcamp/cocoon-vanilla-js/issues" 11 | }, 12 | "homepage": "https://github.com/oddcamp/cocoon-vanilla-js#readme", 13 | "devDependencies": {}, 14 | "dependencies": {} 15 | } 16 | --------------------------------------------------------------------------------