PageSourceSearch

https://www.sentieri-svizzeri.ch/bundles/cloudtecadmin/js/cloudtec-collection.js?2.2.43

js sentieri-svizzeri.ch collected 2026-09-29 12:13:41 UTC 13,894 bytes, 307 lines download raw bytes

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 &quot; and ends with " or &quot; and contains __name__. It replaces the first __name__ with the counter.
158        // The &quot; 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>(?:"|&quot;)(?:.(?!"|&quot;))*?)(?<counter>__name__)(?<post>(?:[^"&](?!"|&quot;))*.?(?:"|&quot;))/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.