View | Details | Raw Unified | Return to bug 37829
Collapse All | Expand All

(-)a/koha-tmpl/intranet-tmpl/prog/js/additional-fields.js (-1 / +1095 lines)
Line 0 Link Here
0
- 
1
/**
2
 * AdditionalFields Module
3
 *
4
 * A reusable module for handling additional fields in Koha forms.
5
 * This module provides functionality for managing extended attributes
6
 * in a generic way that can be used across different Koha forms.
7
 *
8
 * @example
9
 * Expected HTML structure:
10
 * <fieldset class="brief" id="booking_extended_attributes">
11
 *   <div class="form-group">
12
 *     <label class="control-label">Field Name</label>
13
 *     <input type="text" class="extended-attribute form-control form-control-sm w-50" />
14
 *   </div>
15
 * </fieldset>
16
 *
17
 *
18
 * // Usage:
19
 * const additionalFields = AdditionalFields.init({
20
 *   containerId: 'booking_extended_attributes',
21
 *   resourceType: 'booking',
22
 *   onFieldsChanged: (values) => console.log('Fields changed:', values)
23
 * });
24
 *
25
 * // Fetch and render fields
26
 * additionalFields.fetchExtendedAttributes('booking')
27
 *   .then(types => additionalFields.renderExtendedAttributes(types, []));
28
 */
29
30
/**
31
 * @typedef {Object} AdditionalFieldsConfig
32
 * @property {string} containerId - ID of the container element
33
 * @property {string} resourceType - Type of resource (e.g., 'booking', 'patron')
34
 * @property {Function} onFieldsChanged - Callback for field changes
35
 * @property {Object} selectors - Custom selectors
36
 * @property {string} selectors.repeatableFieldClass - Class for repeatable fields
37
 * @property {string} selectors.inputClass - Class for input elements
38
 * @property {string} selectors.fieldPrefix - Prefix for field names
39
 */
40
41
/**
42
 * @typedef {Object} AuthorizedValue
43
 * @property {number} authorised_value_id - The ID of the authorized value
44
 * @property {string} category_name - The category name
45
 * @property {string} description - The description text
46
 * @property {string} image_url - The image URL, if any
47
 * @property {string|null} opac_description - The OPAC description, if any
48
 * @property {string} value - The value code
49
 */
50
51
/**
52
 * @typedef {Object} ExtendedAttributeType
53
 * @property {number} extended_attribute_type_id - The ID of the extended attribute type
54
 * @property {string} name - The name of the extended attribute type
55
 * @property {string|null} authorised_value_category_name - The category name for authorized values, if any
56
 * @property {boolean} repeatable - Whether the attribute is repeatable
57
 * @property {string} resource_type - The resource type this attribute belongs to
58
 * @property {string} marc_field - The MARC field associated with this attribute
59
 * @property {string} marc_field_mode - The MARC field mode (get/set)
60
 * @property {boolean} searchable - Whether the attribute is searchable
61
 */
62
63
/**
64
 * @typedef {Object} ExtendedAttribute
65
 * @property {number} field_id - The ID of the extended attribute
66
 * @property {string} id - The unique identifier
67
 * @property {string} record_id - The ID of the record this attribute belongs to
68
 * @property {string} value - The value of the extended attribute
69
 */
70
71
/**
72
 * @typedef {Object} ExtendedAttributeValue
73
 * @property {number} field_id - The ID of the extended attribute type
74
 * @property {string} value - The value of the extended attribute
75
 */
76
77
const AdditionalFields = (function () {
78
    // Constants for class names and selectors
79
    const CLASS_NAMES = {
80
        // Layout components
81
        CONTAINER: {
82
            FLEX: "d-flex",
83
            FLEX_ALIGN_CENTER: "align-items-center",
84
            MARGIN: {
85
                BOTTOM: "mb-2",
86
                START: "ms-2",
87
                START_AUTO: "ms-auto",
88
            },
89
            WIDTH: {
90
                HALF: "w-50",
91
            },
92
        },
93
94
        // Form components
95
        FORM: {
96
            GROUP: "form-group",
97
            LABEL: "control-label",
98
            INPUT: {
99
                BASE: "form-control",
100
                SELECT: "form-select",
101
                SMALL: "form-control-sm",
102
            },
103
        },
104
105
        // Extended attributes components
106
        EXTENDED_ATTRIBUTE: {
107
            BASE: "extended-attribute",
108
            REPEATABLE: {
109
                CONTAINER: "repeatable-field",
110
                ADD_BUTTON: "add-repeatable",
111
                REMOVE_BUTTON: "remove-repeatable",
112
            },
113
        },
114
115
        // State classes
116
        STATE: {
117
            HIDDEN: "d-none",
118
            VISUALLY_HIDDEN: "visually-hidden",
119
            FADE: "fade",
120
            SHOW: "show",
121
        },
122
123
        // Button components
124
        BUTTON: {
125
            BASE: "btn",
126
            SMALL: "btn-sm",
127
            LINK: "btn-link",
128
            PRIMARY: "text-primary",
129
        },
130
131
        // Icon components
132
        ICON: {
133
            BASE: "fa",
134
            ADD: "fa-plus",
135
            REMOVE: "fa-trash",
136
        },
137
    };
138
139
    const SELECTORS = {
140
        REPEATABLE_FIELD: `.${CLASS_NAMES.EXTENDED_ATTRIBUTE.REPEATABLE.CONTAINER}`,
141
        EXTENDED_ATTRIBUTE: `.${CLASS_NAMES.EXTENDED_ATTRIBUTE.BASE}`,
142
        FORM_GROUP: `li.${CLASS_NAMES.FORM.GROUP}`,
143
        INPUT: "input, select",
144
    };
145
146
    const NAMES = {
147
        EXTENDED_ATTRIBUTE: (fieldId, index) =>
148
            `extended_attributes.${fieldId}${index !== null ? `[${index}]` : ""}`,
149
    };
150
151
    const TEXTS = {
152
        EXTENDED_ATTRIBUTES: __("Additional Fields"),
153
        LOADING: __("Loading"),
154
        SELECT_AN_OPTION: __("Select an option"),
155
        ADD: __("New"),
156
        REMOVE: __("Remove"),
157
    };
158
159
    // Helper functions
160
    const Helpers = {
161
        /**
162
         * Get container element or return null if not found
163
         * @param {string} containerId - ID of the container element
164
         * @returns {HTMLElement|null} Container element or null if not found
165
         */
166
        getContainer: containerId => {
167
            return document.getElementById(containerId);
168
        },
169
170
        /**
171
         * Check if the given element is an input or select element
172
         * @param {Element} element - The element to check
173
         * @returns {element is HTMLInputElement|HTMLSelectElement} True if the element is an instance of HTMLInputElement or HTMLSelectElement, otherwise false.
174
         */
175
        isInputOrSelect(element) {
176
            return (
177
                element instanceof HTMLInputElement ||
178
                element instanceof HTMLSelectElement
179
            );
180
        },
181
182
        /**
183
         * Extracts the field ID from an input element's name attribute
184
         * @param {Element} input - The input element
185
         * @param {string} fieldPrefix - The prefix for field names
186
         * @returns {number} The extracted field ID
187
         */
188
        getFieldIdFromInputName(input, fieldPrefix) {
189
            if (!Helpers.isInputOrSelect(input)) {
190
                return 0;
191
            }
192
193
            const name = input.name.replace(`${fieldPrefix}.`, "");
194
            const fieldId = name.replace(/\[.*\]/, "");
195
            return parseInt(fieldId, 10) || 0;
196
        },
197
198
        /**
199
         * Create element with classes and optional attributes
200
         * @template {keyof HTMLElementTagNameMap} T
201
         * @param {T} tagName - HTML tag name
202
         * @param {string[]} classes - Array of class names
203
         * @param {Object.<string, string>} [attributes] - Optional attributes to set
204
         * @returns {HTMLElementTagNameMap[T]} Created element
205
         */
206
        createElement: (tagName, classes = [], attributes = {}) => {
207
            const element = document.createElement(tagName);
208
            if (classes.length) {
209
                element.className = classes.join(" ");
210
            }
211
            Object.entries(attributes).forEach(([key, value]) => {
212
                element.setAttribute(key, value);
213
            });
214
            return element;
215
        },
216
217
        /**
218
         * Create button with icon and text
219
         * @param {string} text - Button text
220
         * @param {string} iconClass - Icon class
221
         * @param {string[]} classes - Additional classes
222
         * @param {Object.<string, string>} [attributes] - Optional attributes
223
         * @returns {HTMLButtonElement} Button element
224
         */
225
        createButton: (text, iconClass, classes = [], attributes = {}) => {
226
            const button = Helpers.createElement(
227
                "button",
228
                [
229
                    CLASS_NAMES.BUTTON.BASE,
230
                    CLASS_NAMES.BUTTON.SMALL,
231
                    CLASS_NAMES.BUTTON.LINK,
232
                    ...classes,
233
                ],
234
                { type: "button", ...attributes }
235
            );
236
237
            const icon = Helpers.createElement("i", [
238
                CLASS_NAMES.ICON.BASE,
239
                iconClass,
240
            ]);
241
            button.appendChild(icon);
242
243
            const textNode = document.createTextNode(text);
244
            button.appendChild(textNode);
245
246
            return button;
247
        },
248
249
        /**
250
         * Group values by field_id
251
         * @param {Array<ExtendedAttributeValue>} values - Array of extended attribute values
252
         * @returns {Object.<number, string[]>} Grouped values
253
         * @todo use Object.groupBy once it is widely available
254
         */
255
        groupValues: values => {
256
            if (!values) {
257
                return {};
258
            }
259
260
            return values.reduce((acc, { field_id, value }) => {
261
                if (!acc[field_id]) {
262
                    acc[field_id] = [];
263
                }
264
                acc[field_id].push(value);
265
                return acc;
266
            }, {});
267
        },
268
269
        /**
270
         * Get unique categories from field types
271
         * @param {Array<ExtendedAttributeType>} types - Array of extended attribute types
272
         * @returns {string[]} Unique categories
273
         */
274
        getUniqueCategories: types => {
275
            return [
276
                ...new Set(
277
                    types
278
                        .map(type => type.authorised_value_category_name)
279
                        .filter(category => category !== null)
280
                ),
281
            ];
282
        },
283
284
        /**
285
         * Convert array of {category, values} to object
286
         * @param {Array<{category: string, values: AuthorizedValue[]}>} results - Array of results
287
         * @returns {Object.<string, AuthorizedValue[]>} Object of authorized values by category
288
         */
289
        convertToAuthorizedValues: results => {
290
            return results.reduce((acc, { category, values }) => {
291
                acc[category] = values;
292
                return acc;
293
            }, {});
294
        },
295
296
        /**
297
         * Handle adding a new repeatable field
298
         * @param {Event} event - The click event
299
         * @param {ExtendedAttributeType} type - The field type
300
         * @param {string|string[]|null} currentValues - Current values
301
         * @param {Object.<string, Array<AuthorizedValue>>} authorizedValues - Authorized values by category
302
         */
303
        handleAddRepeatable: (event, type, currentValues, authorizedValues) => {
304
            const button = /** @type {HTMLButtonElement} */ (
305
                event.currentTarget
306
            );
307
            const repeatableContainer = button.previousElementSibling;
308
            if (!repeatableContainer) {
309
                return;
310
            }
311
312
            const values =
313
                typeof currentValues === "string"
314
                    ? [currentValues]
315
                    : Array.isArray(currentValues)
316
                      ? currentValues
317
                      : [];
318
            const newInput = createInput(
319
                type,
320
                "",
321
                values.length,
322
                authorizedValues
323
            );
324
            if (newInput && repeatableContainer) {
325
                repeatableContainer.appendChild(newInput);
326
            }
327
            if (typeof config.onFieldsChanged === "function") {
328
                config.onFieldsChanged(getValues());
329
            }
330
        },
331
332
        /**
333
         * Handle removing a repeatable field
334
         * @param {Event} event - The click event
335
         */
336
        handleRemoveRepeatable: event => {
337
            const button = /** @type {HTMLButtonElement} */ (
338
                event.currentTarget
339
            );
340
            const wrapper = button.parentElement;
341
            if (wrapper) {
342
                wrapper.remove();
343
                if (typeof config.onFieldsChanged === "function") {
344
                    config.onFieldsChanged(getValues());
345
                }
346
            }
347
        },
348
    };
349
350
    /** @type {AdditionalFieldsConfig} */
351
    let config = {
352
        containerId: "",
353
        resourceType: "",
354
        onFieldsChanged: () => {},
355
        selectors: {
356
            repeatableFieldClass:
357
                CLASS_NAMES.EXTENDED_ATTRIBUTE.REPEATABLE.CONTAINER,
358
            inputClass: CLASS_NAMES.EXTENDED_ATTRIBUTE.BASE,
359
            fieldPrefix: "extended_attributes",
360
        },
361
    };
362
363
    /**
364
     * Initialize the module with configuration options
365
     * @param {Object} options - Configuration options
366
     * @param {string} options.containerId - ID of the container element
367
     * @param {string} options.resourceType - Type of resource (e.g., 'booking', 'patron')
368
     * @param {Function} options.onFieldsChanged - Callback for field changes
369
     * @param {Object} options.selectors - Custom selectors
370
     * @param {string} options.selectors.repeatableFieldClass - Class for repeatable fields
371
     * @param {string} options.selectors.inputClass - Class for input elements
372
     * @param {string} options.selectors.fieldPrefix - Prefix for field names
373
     */
374
    function init(options) {
375
        config = {
376
            ...config,
377
            ...options,
378
            selectors: {
379
                ...config.selectors,
380
                ...(options.selectors || {}),
381
            },
382
        };
383
384
        return {
385
            init,
386
            getValues,
387
            setValues,
388
            clear,
389
            renderExtendedAttributes,
390
            fetchExtendedAttributes,
391
            fetchAndProcessExtendedAttributes,
392
            fetchAndProcessAuthorizedValues,
393
            renderExtendedAttributesValues,
394
        };
395
    }
396
397
    /**
398
     * Get all field values from the form
399
     * @returns {ExtendedAttributeValue[]} Array of extended attribute values
400
     */
401
    function getValues() {
402
        const container = Helpers.getContainer(config.containerId);
403
        if (!container) {
404
            return [];
405
        }
406
407
        const values = [];
408
        const inputs = container.querySelectorAll(SELECTORS.INPUT);
409
410
        inputs.forEach(input => {
411
            if (!Helpers.isInputOrSelect(input)) {
412
                return;
413
            }
414
415
            const fieldId = Helpers.getFieldIdFromInputName(
416
                input,
417
                config.selectors.fieldPrefix
418
            );
419
420
            values.push({
421
                field_id: fieldId,
422
                value: input.value,
423
            });
424
        });
425
426
        return values;
427
    }
428
429
    /**
430
     * Set values for all extended attribute fields
431
     * @param {Array<ExtendedAttributeValue>} values - Array of extended attribute values
432
     * @param {Object.<string, Array<AuthorizedValue>>} [authorizedValues] - Optional authorized values by category
433
     */
434
    function setValues(values, authorizedValues = {}) {
435
        const container = Helpers.getContainer(config.containerId);
436
        if (!container) {
437
            return;
438
        }
439
440
        // Get all field types from the container
441
        const fieldTypes = Array.from(
442
            container?.querySelectorAll(SELECTORS.FORM_GROUP) ?? []
443
        )
444
            .map(li => {
445
                /** @type {HTMLInputElement|HTMLSelectElement|null} */
446
                const input = li.querySelector(SELECTORS.INPUT);
447
                if (!input) return null;
448
449
                const fieldId = Helpers.getFieldIdFromInputName(
450
                    input,
451
                    config.selectors.fieldPrefix
452
                );
453
454
                return {
455
                    extended_attribute_type_id: fieldId,
456
                    name: li.querySelector("label")?.textContent || "",
457
                    authorised_value_category_name:
458
                        input.tagName === "SELECT"
459
                            ? (input.dataset.category ?? null)
460
                            : null,
461
                    repeatable:
462
                        li.querySelector(SELECTORS.REPEATABLE_FIELD) !== null,
463
                    resource_type: config.resourceType,
464
                    marc_field: "",
465
                    marc_field_mode: "",
466
                    searchable: false,
467
                };
468
            })
469
            .filter(type => type !== null);
470
471
        // Group values by field_id
472
        const groupedValues = Helpers.groupValues(values);
473
474
        // Get existing fields
475
        const existingFields = new Map(
476
            Array.from(
477
                container?.querySelectorAll(SELECTORS.FORM_GROUP) || []
478
            ).map(field => {
479
                const input = field.querySelector("input, select");
480
                if (!input) {
481
                    return [, field];
482
                }
483
484
                const fieldId = Helpers.getFieldIdFromInputName(
485
                    input,
486
                    config.selectors.fieldPrefix
487
                );
488
489
                return [Number(fieldId), field];
490
            })
491
        );
492
493
        // Check if we need to fetch authorized values
494
        const hasSelectFields = fieldTypes.some(
495
            type => type.authorised_value_category_name
496
        );
497
498
        const needsAuthorizedValues =
499
            hasSelectFields && Object.keys(authorizedValues).length === 0;
500
        if (!needsAuthorizedValues) {
501
            // Track which fields we've processed
502
            const processedFields = new Set();
503
504
            // Update or create fields
505
            fieldTypes.forEach(type => {
506
                const fieldId = type.extended_attribute_type_id;
507
                processedFields.add(fieldId);
508
509
                const fieldValues =
510
                    groupedValues[type.extended_attribute_type_id] || [];
511
                const currentValues = type.repeatable
512
                    ? fieldValues
513
                    : [fieldValues[0] || ""];
514
515
                const existingField = existingFields.get(fieldId);
516
                if (!existingField) {
517
                    // Create new field
518
                    const field = createField(
519
                        type,
520
                        currentValues,
521
                        authorizedValues
522
                    );
523
                    if (field && container) container.appendChild(field);
524
                    return;
525
                }
526
527
                // Update existing field
528
                updateExistingField(
529
                    existingField,
530
                    type,
531
                    currentValues,
532
                    authorizedValues
533
                );
534
            });
535
536
            // Remove fields that no longer exist
537
            existingFields.forEach((field, fieldId) => {
538
                if (!processedFields.has(fieldId)) {
539
                    field.remove();
540
                }
541
            });
542
            return;
543
        }
544
545
        // Get unique categories
546
        const categories = Helpers.getUniqueCategories(fieldTypes);
547
548
        // Fetch authorized values for each category
549
        Promise.all(
550
            categories.map(category =>
551
                fetchAuthorizedValues(category).then(values => ({
552
                    category,
553
                    values,
554
                }))
555
            )
556
        )
557
            .then(results => {
558
                // Convert array of {category, values} to object
559
                const fetchedValues =
560
                    Helpers.convertToAuthorizedValues(results);
561
562
                // Re-render with fetched values
563
                renderExtendedAttributes(fieldTypes, values, fetchedValues);
564
            })
565
            .catch(error => {
566
                console.error("Error fetching authorized values:", error);
567
            });
568
    }
569
570
    /**
571
     * Clear all field values
572
     */
573
    function clear() {
574
        const container = Helpers.getContainer(config.containerId);
575
        if (!container) {
576
            return;
577
        }
578
579
        const inputs = container.querySelectorAll(SELECTORS.INPUT);
580
        inputs.forEach(input => {
581
            if (!Helpers.isInputOrSelect(input)) {
582
                return;
583
            }
584
585
            input.value = "";
586
        });
587
    }
588
589
    /**
590
     * Update a single input field
591
     * @param {Element} field - The field element
592
     * @param {string} value - The new value
593
     */
594
    function updateSingleField(field, value) {
595
        const input = field.querySelector("input, select");
596
        if (!input || !Helpers.isInputOrSelect(input)) {
597
            return;
598
        }
599
600
        input.value = value || "";
601
    }
602
603
    /**
604
     * Update a repeatable field
605
     * @param {Element} field - The field element
606
     * @param {ExtendedAttributeType} type - The field type
607
     * @param {string[]} values - The new values
608
     * @param {Object.<string, Array<AuthorizedValue>>} authorizedValues - Authorized values by category
609
     */
610
    function updateRepeatableField(field, type, values, authorizedValues) {
611
        const repeatableContainer = field.querySelector(
612
            SELECTORS.REPEATABLE_FIELD
613
        );
614
        if (!repeatableContainer) {
615
            return;
616
        }
617
618
        const existingInputs = Array.from(
619
            repeatableContainer.querySelectorAll("input, select")
620
        );
621
622
        // Update or add inputs
623
        values.forEach((value, index) => {
624
            const input = existingInputs[index];
625
            if (!Helpers.isInputOrSelect(input)) {
626
                const newInput = createInput(
627
                    type,
628
                    value,
629
                    index,
630
                    authorizedValues
631
                );
632
                if (newInput) repeatableContainer.appendChild(newInput);
633
                return;
634
            }
635
636
            input.value = value;
637
        });
638
639
        // Remove excess inputs
640
        while (existingInputs.length > values.length) {
641
            existingInputs.pop()?.parentElement?.remove();
642
        }
643
    }
644
645
    /**
646
     * Update an existing field
647
     * @param {Element} field - The field element
648
     * @param {ExtendedAttributeType} type - The field type
649
     * @param {string[]} values - The new values
650
     * @param {Object.<string, Array<AuthorizedValue>>} authorizedValues - Authorized values by category
651
     */
652
    function updateExistingField(field, type, values, authorizedValues) {
653
        if (!type.repeatable) {
654
            updateSingleField(field, values[0]);
655
            return;
656
        }
657
658
        updateRepeatableField(field, type, values, authorizedValues);
659
    }
660
661
    /**
662
     * Render extended attributes in the form
663
     * @param {Array<ExtendedAttributeType>} types - Extended attribute types
664
     * @param {Array<ExtendedAttributeValue>} values - Current values
665
     * @param {Object.<string, Array<AuthorizedValue>>} [authorizedValues] - Optional authorized values by category
666
     */
667
    function renderExtendedAttributes(types, values, authorizedValues = {}) {
668
        const container = Helpers.getContainer(config.containerId);
669
        if (!container) {
670
            return;
671
        }
672
673
        if (!types || types.length === 0) {
674
            container.innerHTML = "";
675
            return;
676
        }
677
678
        // Group values by field_id to handle repeatable fields
679
        const groupedValues = Helpers.groupValues(values);
680
681
        // Get existing fields
682
        const existingFields = new Map(
683
            Array.from(
684
                container?.querySelectorAll(SELECTORS.FORM_GROUP) || []
685
            ).map(field => {
686
                const input = field.querySelector("input, select");
687
                if (!input) {
688
                    return [, field];
689
                }
690
691
                const fieldId = Helpers.isInputOrSelect(input)
692
                    ? input.name.split(".")[1]?.split("[")[0]
693
                    : undefined;
694
                return [fieldId, field];
695
            })
696
        );
697
698
        // Check if we need to fetch authorized values
699
        const hasSelectFields = types.some(
700
            type => type.authorised_value_category_name
701
        );
702
703
        const needsAuthorizedValues =
704
            hasSelectFields && Object.keys(authorizedValues).length === 0;
705
        if (!needsAuthorizedValues) {
706
            // Track which fields we've processed
707
            const processedFields = new Set();
708
709
            // Update or create fields
710
            types.forEach(type => {
711
                const fieldId = type.extended_attribute_type_id.toString();
712
                processedFields.add(fieldId);
713
714
                const fieldValues =
715
                    groupedValues[type.extended_attribute_type_id] || [];
716
                const currentValues = type.repeatable
717
                    ? fieldValues
718
                    : [fieldValues[0] || ""];
719
720
                const existingField = existingFields.get(fieldId);
721
                if (!existingField) {
722
                    // Create new field
723
                    const field = createField(
724
                        type,
725
                        currentValues,
726
                        authorizedValues
727
                    );
728
                    if (field && container) container.appendChild(field);
729
                    return;
730
                }
731
732
                // Update existing field
733
                updateExistingField(
734
                    existingField,
735
                    type,
736
                    currentValues,
737
                    authorizedValues
738
                );
739
            });
740
741
            // Remove fields that no longer exist
742
            existingFields.forEach((field, fieldId) => {
743
                if (!processedFields.has(fieldId)) {
744
                    field.remove();
745
                }
746
            });
747
            return;
748
        }
749
750
        // Get unique categories
751
        const categories = Helpers.getUniqueCategories(types);
752
753
        // Fetch authorized values for each category
754
        Promise.all(
755
            categories.map(category =>
756
                fetchAuthorizedValues(category).then(values => ({
757
                    category,
758
                    values,
759
                }))
760
            )
761
        )
762
            .then(results => {
763
                // Convert array of {category, values} to object
764
                const fetchedValues =
765
                    Helpers.convertToAuthorizedValues(results);
766
767
                // Re-render with fetched values
768
                renderExtendedAttributes(types, values, fetchedValues);
769
            })
770
            .catch(error => {
771
                console.error("Error fetching authorized values:", error);
772
            });
773
    }
774
775
    /**
776
     * Create a field element for an extended attribute type
777
     * @param {ExtendedAttributeType} type - Extended attribute type
778
     * @param {string|string[]|null} values - Current value
779
     * @param {Object.<string, Array<AuthorizedValue>>} [authorizedValues] - Optional authorized values by category
780
     * @returns {HTMLElement} Field element
781
     */
782
    function createField(type, values, authorizedValues = {}) {
783
        const field = Helpers.createElement("li", [
784
            CLASS_NAMES.FORM.GROUP,
785
            CLASS_NAMES.STATE.FADE,
786
            CLASS_NAMES.STATE.SHOW,
787
        ]);
788
789
        const label = Helpers.createElement("label", [CLASS_NAMES.FORM.LABEL]);
790
        label.setAttribute(
791
            "for",
792
            `extended_attribute_${type.extended_attribute_type_id}`
793
        );
794
        label.textContent = type.name + ":";
795
        field.appendChild(label);
796
797
        if (type.repeatable) {
798
            const repeatableContainer = Helpers.createElement("div", [
799
                config.selectors.repeatableFieldClass,
800
            ]);
801
802
            // Always create at least one input field for repeatable fields
803
            values = Array.isArray(values) && values.length > 0 ? values : [""];
804
            values.forEach((value, index) => {
805
                const input = createInput(type, value, index, authorizedValues);
806
                if (input) {
807
                    repeatableContainer.appendChild(input);
808
                }
809
            });
810
            field.appendChild(repeatableContainer);
811
812
            const addButton = Helpers.createButton(
813
                TEXTS.ADD,
814
                CLASS_NAMES.ICON.ADD,
815
                [CLASS_NAMES.EXTENDED_ATTRIBUTE.REPEATABLE.ADD_BUTTON],
816
                {
817
                    "data-attribute-id": `extended_attribute_${type.extended_attribute_type_id}`,
818
                }
819
            );
820
821
            addButton.addEventListener("click", event => {
822
                Helpers.handleAddRepeatable(
823
                    event,
824
                    type,
825
                    values,
826
                    authorizedValues
827
                );
828
            });
829
830
            field.appendChild(addButton);
831
        } else {
832
            const input = createInput(
833
                type,
834
                values?.[0] || "",
835
                0,
836
                authorizedValues
837
            );
838
            if (input) {
839
                field.appendChild(input);
840
            }
841
        }
842
843
        return field;
844
    }
845
846
    /**
847
     * Create an input element for an extended attribute type
848
     * @param {ExtendedAttributeType} type - Extended attribute type
849
     * @param {string} value - Current value
850
     * @param {number} index - Index for repeatable fields
851
     * @param {Object.<string, Array<AuthorizedValue>>} [authorizedValues] - Optional authorized values by category
852
     * @returns {HTMLElement} Input element
853
     */
854
    function createInput(type, value, index = 0, authorizedValues = {}) {
855
        const wrapper = Helpers.createElement("div", [
856
            CLASS_NAMES.CONTAINER.FLEX,
857
            CLASS_NAMES.CONTAINER.FLEX_ALIGN_CENTER,
858
            CLASS_NAMES.CONTAINER.MARGIN.BOTTOM,
859
        ]);
860
861
        let input;
862
        if (type.authorised_value_category_name) {
863
            input = Helpers.createElement("select", [
864
                config.selectors.inputClass,
865
                CLASS_NAMES.FORM.INPUT.SELECT,
866
                CLASS_NAMES.CONTAINER.WIDTH.HALF,
867
            ]);
868
            input.id = `extended_attribute_${type.extended_attribute_type_id}${type.repeatable ? `_${index}` : ""}`;
869
            input.name = NAMES.EXTENDED_ATTRIBUTE(
870
                type.extended_attribute_type_id,
871
                type.repeatable ? index : null
872
            );
873
            input.dataset.category = type.authorised_value_category_name;
874
875
            // Add default option
876
            const defaultOption = Helpers.createElement("option");
877
            defaultOption.value = "";
878
            defaultOption.textContent = TEXTS.SELECT_AN_OPTION;
879
            input.appendChild(defaultOption);
880
881
            // Add authorized values from the correct category
882
            const categoryValues =
883
                authorizedValues[type.authorised_value_category_name] || [];
884
            if (categoryValues.length > 0) {
885
                categoryValues.forEach(val => {
886
                    const option = Helpers.createElement("option");
887
                    option.value = val.value;
888
                    option.textContent = val.description;
889
                    input.appendChild(option);
890
                });
891
            }
892
893
            // Set the value after all options are added
894
            if (value) {
895
                input.value = value;
896
            }
897
        } else {
898
            input = Helpers.createElement("input", [
899
                config.selectors.inputClass,
900
                CLASS_NAMES.FORM.INPUT.BASE,
901
                CLASS_NAMES.CONTAINER.WIDTH.HALF,
902
            ]);
903
            input.type = "text";
904
            input.id = `extended_attribute_${type.extended_attribute_type_id}${type.repeatable ? `_${index}` : ""}`;
905
            input.name = NAMES.EXTENDED_ATTRIBUTE(
906
                type.extended_attribute_type_id,
907
                type.repeatable ? index : null
908
            );
909
            input.value = value || "";
910
        }
911
912
        // Add change event listener to notify of field changes
913
        input.addEventListener("change", () => {
914
            if (typeof config.onFieldsChanged === "function") {
915
                config.onFieldsChanged(getValues());
916
            }
917
        });
918
919
        wrapper.appendChild(input);
920
921
        if (type.repeatable) {
922
            const removeButton = Helpers.createButton(
923
                TEXTS.REMOVE,
924
                CLASS_NAMES.ICON.REMOVE,
925
                [
926
                    CLASS_NAMES.EXTENDED_ATTRIBUTE.REPEATABLE.REMOVE_BUTTON,
927
                    CLASS_NAMES.CONTAINER.MARGIN.START,
928
                ],
929
                {
930
                    "data-attribute-id": `extended_attribute_${type.extended_attribute_type_id}`,
931
                }
932
            );
933
            removeButton.addEventListener(
934
                "click",
935
                Helpers.handleRemoveRepeatable
936
            );
937
            wrapper.appendChild(removeButton);
938
        }
939
940
        return wrapper;
941
    }
942
943
    /**
944
     * Fetch authorized values for a category
945
     * @param {string} category - The category to fetch values for
946
     * @returns {Promise<AuthorizedValue[]>} - The authorized values
947
     */
948
    async function fetchAuthorizedValues(category) {
949
        try {
950
            const authorisedValuesClient =
951
                window["APIClient"].authorised_values;
952
            return authorisedValuesClient.values.get(category);
953
        } catch (error) {
954
            console.error(
955
                `Error fetching authorized values for category ${category}:`,
956
                error
957
            );
958
            return [];
959
        }
960
    }
961
962
    /**
963
     * Fetch extended attribute types for a resource type
964
     * @param {string} resourceType - Type of resource
965
     * @returns {Promise<ExtendedAttributeType[]>} - The extended attribute types
966
     */
967
    async function fetchExtendedAttributes(resourceType) {
968
        try {
969
            const additionalFieldsClient =
970
                window["APIClient"].additional_fields;
971
            return additionalFieldsClient.additional_fields.getAll(
972
                resourceType
973
            );
974
        } catch (error) {
975
            console.error(
976
                `Error fetching extended attributes for resource type ${resourceType}:`,
977
                error
978
            );
979
            return [];
980
        }
981
    }
982
983
    /**
984
     * Fetch and process extended attribute types
985
     * @param {string} resourceType - Type of resource (e.g., 'booking')
986
     * @returns {Promise<Object.<string, Pick<ExtendedAttributeType, "authorised_value_category_name" | "name">>>} Processed extended attribute types
987
     */
988
    async function fetchAndProcessExtendedAttributes(resourceType) {
989
        try {
990
            const additionalFieldsClient =
991
                window["APIClient"].additional_fields;
992
            const response =
993
                await additionalFieldsClient.additional_fields.getAll(
994
                    resourceType
995
                );
996
997
            return response.reduce(
998
                (
999
                    acc,
1000
                    {
1001
                        extended_attribute_type_id,
1002
                        authorised_value_category_name,
1003
                        name,
1004
                    }
1005
                ) => {
1006
                    acc[extended_attribute_type_id] = {
1007
                        authorised_value_category_name,
1008
                        name,
1009
                    };
1010
                    return acc;
1011
                },
1012
                {}
1013
            );
1014
        } catch (error) {
1015
            console.error(
1016
                `Error fetching extended attributes for resource type ${resourceType}:`,
1017
                error
1018
            );
1019
            return {};
1020
        }
1021
    }
1022
1023
    /**
1024
     * Fetch and process authorized values for categories
1025
     * @param {string[]} categories - Array of category names
1026
     * @returns {Promise<Object.<string, Pick<AuthorizedValue, "description">>>} Processed authorized values
1027
     */
1028
    async function fetchAndProcessAuthorizedValues(categories) {
1029
        try {
1030
            const authorisedValuesClient =
1031
                window["APIClient"].authorised_values;
1032
            const response =
1033
                await authorisedValuesClient.values.getCategoriesWithValues([
1034
                    JSON.stringify(categories),
1035
                ]);
1036
1037
            return response.reduce((acc, item) => {
1038
                const { category_name, authorised_values } = item;
1039
                acc[category_name] = acc[category_name] || {};
1040
                authorised_values.forEach(({ value, description }) => {
1041
                    acc[category_name][value] = description;
1042
                });
1043
                return acc;
1044
            }, {});
1045
        } catch (error) {
1046
            console.error("Error fetching authorized values:", error);
1047
            return {};
1048
        }
1049
    }
1050
1051
    /**
1052
     * Render extended attributes values with their names and authorized values
1053
     * @param {Array<ExtendedAttribute>} attributes - Array of extended attributes
1054
     * @param {Object.<string, Pick<ExtendedAttributeType, "name" | "authorised_value_category_name">>} types - Extended attribute types
1055
     * @param {Object.<string, Object.<string, string>>} authorizedValues - Authorized values by category
1056
     * @param {string} recordId - The record ID to filter attributes by
1057
     * @returns {string[]} Array of strings of rendered attributes
1058
     */
1059
    function renderExtendedAttributesValues(
1060
        attributes,
1061
        types = {},
1062
        authorizedValues = {},
1063
        recordId
1064
    ) {
1065
        if (!attributes || attributes.length === 0) return [""];
1066
        return attributes
1067
            .filter(attribute => attribute.record_id == recordId)
1068
            .map(attribute => {
1069
                const fieldId = attribute.field_id;
1070
                const typeInfo = types[fieldId] || {};
1071
                const name = typeInfo.name || fieldId;
1072
                const categoryName =
1073
                    typeInfo.authorised_value_category_name || "";
1074
                const valueDescription =
1075
                    authorizedValues[categoryName]?.[attribute.value] ||
1076
                    attribute.value;
1077
1078
                return [name, valueDescription].join(": ");
1079
            });
1080
    }
1081
1082
    return {
1083
        init,
1084
        getValues,
1085
        setValues,
1086
        clear,
1087
        renderExtendedAttributes,
1088
        fetchExtendedAttributes,
1089
        fetchAndProcessExtendedAttributes,
1090
        fetchAndProcessAuthorizedValues,
1091
        renderExtendedAttributesValues,
1092
    };
1093
})();
1094
1095
window["AdditionalFields"] = AdditionalFields;

Return to bug 37829