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; |