1/** 2 * Collection 3 * 4 * HTML Markup (assume app_example as form name): 5 * <div class="c-collection" 6 * data-sortable="{{ sortable }}" 7 * data-allow-delete="{{ allow_delete }}" 8 * data-allow-add="{{ allow_add }}" 9 * data-delete-button-template="{{ collection_delete_button|default(block('collection_delete_button'))|raw }}" 10 * ... > 11 * <div id="app_example" class="ct-collection-container" data-prototype="..."> 12 * <div id="app_example_0"> 13 * <div class="ct-buttons-container">{{ sort & delete buttons }}</div> 14 * {{ form rows }} 15 * <div class="c-collection"></div> // potential nested collection 16 * </div> 17 * <div id="app_example_1">...</div> 18 * <div id="app_example_2">...</div> 19 * <div id="app_example_3">...</div> 20 * </div> 21 * <button type="button class="c-collection-add" /> 22 * </div> 23 * 24 * Events: 25 * - sort: whenever the order of the collection entries has changed 26 * - collection-sort (jquery event): whenever the order of the collection entries has changed. originates from the root (c-collection) element, provides the moved entry as an extra parameter 27 * - entry-added: whenever a new entry has been added to the collection 28 * - collection-entry-added (jquery event): whenever a new entry has been added to the collection. originates from the add button, provides the added entry as an extra parameter 29 * 30 * Usage: 31 * CloudtecCollection.options.addEntryOnEmpty = false; // global default 32 * new CloudtecCollection($('#custom-collection'), {allowAdd: false}); // initialization with jQuery object & options 33 * new CloudtecCollection('.c-collection'); // initialization with selector 34 * new CloudtecCollection(element).updateElementOrderBasedOnInputPosition() // getting already initialized collection and call a method 35 * 36 * Notes: 37 * - Dragula is disabled on mobile devices as dragging and scrolling results in poor user experience. Sort buttons are displayed instead. Also disabled if the entry contains radio buttons due to a bug. 38 * - If the collection entry is very big (more than half the screen height), then the sort buttons are displayed as well. 39 * - The sort order of the collection is determined by the position input field of the form fields. The input name (entry[1], entry[2], ...) is irrelevant, but has to be unique. 40 * - The position input values do not need to be continuous, they just need to be in the correct order. 41 */
42class CloudtecCollection extends EventTarget { 43 // global defaults 44 static options = { 45 allowAdd: true, 46 allowDelete: true, 47 sortable: false, // user sortable. sort functions can still be used through js 48 addEntryOnEmpty: true, // add an entry if the collection is empty 49 showSortButtonsOnDesktop: undefined, // if undefined, it's automatically determined based on the size of the collection entries and screen size 50 buttonContainer: '<div class="ct-buttons-container"></div>', 51 deleteButtonTemplate: '<button type="button" class="c-collection-delete">Delete</button>', 52 sortButtonTemplate: '<button type="button" class="c-collection-sort">Sort</button>', 53 sortPreviousButtonTemplate: '<button type="button" class="c-collection-sort-previous">â</button>', 54 sortNextButtonTemplate: '<button type="button" class="c-collection-sort-next">â</button>', 55 nextFreeEntryName: 0, 56 }; 57 58 #container; // container of the entire collection with control widgets and entries 59 #entryContainer; // container of the collection entries 60 #options = {} 61 #nextFreeEntryName; // used to generate unique ids for new entries 62 63 addButton; 64 dragula; 65 66 constructor(containerOrSelector, options) { 67 super(); 68 this.#container = $(containerOrSelector); // make sure it's a jQuery object 69 70 // prevent double initialization 71 if (!this.#container.hasClass('c-collection')) { 72 throw new Error('CloudtecCollection: container is not a collection'); 73 } 74 if (this.#container.length === 0) { 75 throw new Error('CloudtecCollection: Empty jquery result set given as container'); 76 } 77 if (this.#container.length > 1) { 78 throw new Error('CloudtecCollection: container is not unique'); 79 } 80 if (this.#container.data('cloudtec-collection')) { 81 return this.#container.data('cloudtec-collection'); 82 } else { 83 this.#container.data('cloudtec-collection', this); 84 } 85 86 this.#entryContainer = $('> .ct-collection-container', this.#container); 87 88 // merge options 89 let dataAttributeOptions = this.#container.data(); 90 Object.keys(dataAttributeOptions).forEach(key => { // convert form type boolean (empty string or 1) to actual boolean 91 if (['', 1].includes(dataAttributeOptions[key]) && key != 'nextFreeEntryName') dataAttributeOptions[key] = (dataAttributeOptions[key] === 1); 92 }); 93 $.extend(this.#options, CloudtecCollection.options, dataAttributeOptions, options); 94 95 this.addButton = $('.c-collection-add', this.controls); 96 this.addButton.click(event => { 97 event.preventDefault(); 98 this.addEntry(); 99 }); 100 101 // initialize collection 102 this.#nextFreeEntryName = this.#options.nextFreeEntryName; 103 104 this.entries.each((index, entry) => { 105 entry = $(entry); 106 if (this.#options.allowDelete) 107 this.addDeleteButton(entry); 108 if (this.#options.sortable) 109 this.addSortButtons(entry); 110 }); 111 112 // dragula has a bug where dragging will reset all radio buttons 113 if (this.draggingEnabled) { 114 // dragula - dragging entries around 115 this.dragula = dragula([this.#entryContainer.get(0)], { 116 mirrorContainer: this.#entryContainer.get(0), 117 // prevents the dragging originating from a nested collection on a parent collection 118 moves: (el, container, handle) => $(handle).closest('.ct-collection-container').get(0) === container 119 }); 120 this.dragula.on('drop', function (entry) { 121 this.updateInputPositionBasedOnElementOrder(); 122 this.dispatchEvent(new CustomEvent('sort', {detail: entry})); 123 $(this.#container).trigger('collection-sort', [entry]); 124 }.bind(this)); 125 } 126 127 if (this.#options.addEntryOnEmpty && this.length === 0) { 128 this.addEntry(); 129 } 130 } 131 132 /** 133 * Finds the collection that contains the given element. Useful for jquery event listeners. 134 */ 135 static getCollection(element) { 136 return new CloudtecCollection($(element).closest('.c-collection')); 137 } 138 139 /** 140 * Adds delete button to button container of entry 141 */ 142 addDeleteButton(entry) { 143 let button = $(this.#options.deleteButtonTemplate); 144 this.getButtonContainer(entry).prepend(button); 145 $(button).click(function (event) { 146 event.preventDefault(); 147 entry.remove(); 148 }); 149 }; 150 151 /**
152 * Adds a new entry to the collection 153 * @returns {jQuery} the newly added entry 154 */ 155 addEntry() { 156 let prototypeHtml = this.#entryContainer.data('prototype'); 157 // this regex matches attribute start with " or " and ends with " or " and contains __name__. It replaces the first __name__ with the counter. 158 // The " is used in nested collections because their markup is in an attribute of an element that is in the data attribute of this collection - so double escaped. 159 let prototype = $(prototypeHtml.replace(/(?<pre>(?:"|")(?:.(?!"|"))*?)(?<counter>__name__)(?<post>(?:[^"&](?!"|"))*.?(?:"|"))/g, "$<pre>" + this.#nextFreeEntryName + "$<post>")); 160 this.#nextFreeEntryName++; 161 this.#entryContainer.append(prototype); 162 this.addDeleteButton(prototype); // newly added entries can always be deleted 163 164 if (this.#options.sortable) { 165 this.addSortButtons(prototype); 166 this.updateInputPositionBasedOnElementOrder(); 167 } 168 169 this.dispatchEvent(new CustomEvent('entry-added', {detail: prototype})); 170 $(this.addButton).trigger('collection-entry-added', [prototype]); 171 172 return prototype; 173 } 174 175 // SORTING 176 177 addSortButtons(entry) { 178 if (this.draggingEnabled) { 179 // dragula is only available on desktop 180 let button = $(this.#options.sortButtonTemplate); 181 this.getButtonContainer(entry).prepend(button); 182 } 183 184 if (this.#options.showSortButtonsOnDesktop === undefined) { 185 // determine if the sort buttons should be displayed based on the size of the collection entries and screen size 186 let entryHeight = entry.height(); 187 let windowHeight = $(window).height(); 188 this.#options.showSortButtonsOnDesktop = entryHeight > windowHeight / 2; 189 } 190 191 if (is_mobile || this.#options.showSortButtonsOnDesktop || !this.draggingEnabled) { 192 let previousButton = $(this.#options.sortPreviousButtonTemplate); 193 this.getButtonContainer(entry).prepend(previousButton); 194 previousButton.click(event => { 195 event.preventDefault(); 196 let entry = this.entries.toArray().find(entry => entry.contains(event.target)); 197 this.moveEntry(entry, -1); 198 this.dispatchEvent(new CustomEvent('sort', {detail: entry})); 199 $(this.#container).trigger('collection-sort', [entry]); 200 }); 201 let nextButton = $(this.#options.sortNextButtonTemplate); 202 nextButton.click(event => { 203 event.preventDefault(); 204 let entry = this.entries.toArray().find(entry => entry.contains(event.target)); 205 this.moveEntry(entry, 1); 206 this.dispatchEvent(new CustomEvent('sort', {detail: entry})); 207 $(this.#container).trigger('collection-sort', [entry]); 208 }); 209 this.getButtonContainer(entry).prepend(nextButton); 210 } 211 } 212 213 /** 214 * used when the order of elements in the dom has changed to update the position value in the input fields accordingly 215 */ 216 updateInputPositionBasedOnElementOrder() { 217 this.entries.each((index, entry) => { 218 this.entryFormFields(entry).filter('input[name$="[position]"]').val(index); 219 }); 220 } 221 222 /** 223 * Used when the position value in the input fields has changed to reorder the dom elements accordingly 224 */ 225 updateElementOrderBasedOnInputPosition() { 226 let sortedEntries = this.entries.toArray().sort((a, b) => { 227 let positionA = this.entryFormFields(a).filter('input[name$="[position]"]').val(); 228 let positionB = this.entryFormFields(b).filter('input[name$="[position]"]').val(); 229 return positionA - positionB; 230 }); 231 let elementsInContainer = [...this.#entryContainer.get(0).children]; 232 for (let i = 0, allSorted = true; i < sortedEntries.length; i++) { 233 // Skip over already sorted entries. This prevents scrolling on certain devices 234 if (allSorted && sortedEntries[i] === elementsInContainer[i]) { 235 continue; 236 } 237 allSorted = false;
238 this.#entryContainer.append(sortedEntries[i]); 239 } 240 } 241 242 moveEntry(entry, delta) { 243 let entries = this.entries.toArray(); 244 let index = entries.indexOf(entry); 245 let newIndex = index + delta; 246 newIndex = Math.max(0, Math.min(entries.length - 1, newIndex)); // clamp the new index to the bounds of the array 247 entries.splice(index, 1); // remove the entry from the old position 248 entries.splice(newIndex, 0, entry); // insert the entry at the new position 249 for (const [index, entry] of entries.entries()) { 250 this.entryFormFields(entry).filter('input[name$="[position]"]').val(index); 251 } 252 this.updateElementOrderBasedOnInputPosition(); 253 } 254 255 // UTILITY 256 /** 257 * Returns the action button container of an entry. 258 * Creates one if it doesn't exist yet. 259 */ 260 getButtonContainer(entry) { 261 // search & return button container 262 let container = $('> .ct-buttons-container', entry); 263 if (container.length) { 264 return container; 265 } 266 // check if entry is a collection entry 267 if (!$(entry).parent().hasClass('ct-collection-container')) throw new Error('CloudtecCollection: entry is not a collection entry'); 268 // create button container 269 container = $(this.#options.buttonContainer); 270 $(entry).prepend(container); 271 return container; 272 } 273 274 /** 275 * Get the collection entries 276 */ 277 get entries() { 278 return this.#entryContainer.find('> div:not(".gu-mirror")'); // gu-mirror is the dragula ghost element 279 } 280 281 /** 282 * Returns all form fields that belong to the entry. Prevents nested collections from being included 283 */ 284 entryFormFields(entry) { 285 return $('input, select, textarea', entry).filter(function (index, element) { 286 return CloudtecCollection.getCollection(element) === this; 287 }.bind(this)); 288 } 289 290 /** 291 * Select elements that are not collection entries 292 */ 293 get controls() { 294 return $('> *:not(.ct-collection-container)', this.#container); 295 } 296 297 /** 298 * Get the number of entries in the collection 299 */ 300 get length() { 301 return this.entries.length; 302 } 303 304 get draggingEnabled() { 305 return this.#options.sortable && !is_mobile && !(new RegExp('type=[\'\"]?radio')).test(this.#entryContainer.data('prototype')); 306 } 307}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.