`
- )
- }
- })
-
- return strHTML
- }
-
- renderFile(data) {
- let strHTML = ''
-
- if (!data.values.length) {
- return strHTML
+ if (bSelectAll) {
+ $selectAllCheckBox.prop("checked", true);
+ }
}
- data.values.forEach((file) => {
- strHTML += ``
- if (file.mimetype.match('^image/')) {
- strHTML += ``
- } else {
- strHTML += `${this.encodeHTMLEntities(file.name)} `
- }
- strHTML += ``
- })
-
- return strHTML
- }
-
- renderRag(data) {
- let strRagType = ''
- const arrRagTypes = {
- a_grey: 'undefined',
- b_red: 'danger',
- b_attention: 'attention',
- c_amber: 'warning',
- c_yellow: 'advisory',
- d_green: 'success',
- d_blue: 'complete',
- e_purple: 'unexpected'
+ /**
+ * Add a sort button to the column header
+ * @param {DataTable} dataTable The DataTable instance
+ * @param {any} column The column to add the sort button to
+ * @param {any} headerContent The content of the column header
+ */
+ addSortButton(dataTable, column, headerContent) {
+ const $header = $(column.header());
+ const $button = $(`
+
+ ${headerContent}
+
+ Sort
+
+
+ `);
+
+ $header
+ .find(".data-table__header-wrapper")
+ .html($button);
+
+ dataTable.order.listener($button, column.index());
+ }
+
+ /**
+ * Toggle the filter for a column
+ * @param {any} column The column to toggle the filter for
+ */
+ toggleFilter(column) {
+ const $header = $(column.header());
+
+ if (column.search() !== "") {
+ $header.find(".data-table__header-wrapper").addClass("filter");
+ $header.find(".data-table__clear").show();
+ } else {
+ $header.find(".data-table__header-wrapper").removeClass("filter");
+ $header.find(".data-table__clear").hide();
+ }
}
- if (data.values.length) {
- const value = data.values[0] // There's always only one rag
- strRagType = arrRagTypes[value] || 'blank'
- } else {
- strRagType = 'blank'
- }
+ /**
+ * Add a search dropdown to the column header
+ * @param {any} column The column to add the search dropdown to
+ * @param {string} id The ID of the column
+ * @param {number} index The index of the column
+ */
+ async addSearchDropdown(column, id, index) {
+ // Self reference included due to scoping
+ const $header = $(column.header());
+ const title = $header.text().trim();
+ const searchValue = column.search();
+ const self = this;
+ const { context } = column;
+ const { oAjaxData } = context[0];
+ const { columns } = oAjaxData;
+ const columnId = columns[column.index()].name;
+ const col = this.columns[column.index()];
+
+ const $searchElement = $(
+ `
+
+
+
+
+
+
`
+ );
+
+ /* Construct search box for filtering. If the filter has a typeahead and if
+ * it uses an ID rather than text, then add a second (hidden) input field
+ * to store the ID. If we already have a stored search value for the
+ * column, then if it's an ID we will need to look up the textual value for
+ * insertion into the visible input */
+ const $searchInput = $(``);
+ $searchInput.appendTo($(".input", $searchElement));
+ if (col.typeahead_use_id) {
+ $searchInput.after("");
+ if (searchValue) {
+ const response = await fetch(this.getApiEndpoint(columnId) + searchValue + "&use_id=1", { method: "POST", data: { csrf_token: $("body").data("csrf") } });
+ const data = await response.json();
+ if (!data.error) {
+ if (data.records.length != 0) {
+ $searchInput.val(data.records[0].label);
+ $("input.search", $searchElement).val(data.records[0].id)
+ .trigger("change");
+ }
+ }
+ }
+ } else {
+ $("input", $searchElement).addClass("search");
+ }
- const text = $('#rag_' + strRagType + '_meaning').text();
+ if (col && col.typeahead) {
+ import(/*webpackChunkName: "typeahead" */ "util/typeahead")
+ .then(({ default: TypeaheadBuilder }) => {
+ const builder = new TypeaheadBuilder();
+ builder
+ .withAjaxSource(this.getApiEndpoint(columnId))
+ .withMethod("POST")
+ .withData({ csrf_token: $("body").data("csrf") })
+ .withInput($("input", $header))
+ .withAppendQuery()
+ .withDefaultMapper()
+ .withName(columnId.replace(/\s+/g, "") + "Search")
+ .withCallback((data) => {
+ if (col.typeahead_use_id) {
+ $searchInput.val(data.name);
+ $("input.search", $searchElement).val(data.id)
+ .trigger("change");
+ } else {
+ $("input", $searchElement).addClass("search")
+ .val(data.name)
+ .trigger("change");
+ }
+ })
+ .build();
+ });
+ }
- return `✗`
- }
+ $header.find(".data-table__header-wrapper").prepend($searchElement);
- renderCurCommon(data) {
- let strHTML = ''
+ this.toggleFilter(column);
- if (data.values.length === 0) {
- return strHTML
- }
+ // Apply the search
+ $("input.search", $header).on("change", function (ev) {
+ let value = this.value || ev.target.value;
+ if (column.search() !== value) {
+ column
+ .search(value)
+ .draw();
+ }
+
+ self.toggleFilter(column);
+
+ // Update or add the filter to the searchParams
+ if (self.searchParams.has(id)) {
+ self.searchParams.set(id, this.value);
+ } else {
+ self.searchParams.append(id, this.value);
+ }
+
+ // Update URL. Do not reload otherwise the data is fetched twice (already
+ // redrawn in the previous statement)
+ const url = `${window.location.href.split("?")[0]}?${self.searchParams.toString()}`;
+ window.history.replaceState(null, "", url);
+ });
+
+ // Clear the search
+ $(".data-table__clear", $header).on("click", function () {
+ $(this).closest(".dropdown-menu")
+ .find("input")
+ .val("");
+ column
+ .search("")
+ .draw();
- strHTML = this.renderCurCommonTable(data)
- return this.renderMoreLess(strHTML, data.name)
- }
+ self.toggleFilter(column);
+
+ // Delete the filter from the searchparams and update and reload the url
+ if (self.searchParams.has(id)) {
+ self.searchParams.delete(id);
+ let url = `${window.location.href.split("?")[0]}`;
- renderCurCommonTable(data) {
- let strHTML = ''
+ if (self.searchParams.entries().next().value !== undefined) {
+ url += `?${self.searchParams.toString()}`;
+ }
- if (data.values.length === 0) {
- return strHTML
+ // Update URL. See comment above about the same
+ window.history.replaceState(null, "", url);
+ }
+ });
}
- if (data.values[0].fields.length === 0) {
- // No columns visible to user
- return strHTML
+
+ /**
+ * Get the API endpoint for the column typeahead
+ * @param {number} columnId The ID of the column to get the API endpoint for
+ * @returns {string} The API endpoint for the column typeahead
+ */
+ getApiEndpoint(columnId) {
+ const table = $("body").data("layout-identifier");
+ return `/${table}/match/layout/${columnId}?q=`;
+ }
+
+ /**
+ * Encode HTML entities in a string
+ * @param {string} text The text to encode
+ * @returns {string} The encoded text
+ */
+ encodeHTMLEntities(text) {
+ return $("").text(text)
+ .html();
+ }
+
+ /**
+ * Decode HTML entities in a string
+ * @param {string} text The text to decode
+ * @returns {string} The decoded text
+ */
+ decodeHTMLEntities(text) {
+ return $("").html(text)
+ .text();
+ }
+
+ /**
+ * Render a more-less component if the HTML string exceeds the threshold
+ * @param {string} strHTML The HTML string to render
+ * @param {string} strColumnName The name of the column to render
+ * @returns {string} The rendered HTML string with more-less component if applicable
+ */
+ renderMoreLess(strHTML, strColumnName) {
+ if (strHTML.toString().length > MORE_LESS_TRESHOLD) {
+ return (
+ `
(showing maximum ${data.limit_rows} rows.
- view all)
-
`
+ /**
+ * Render the default data type
+ * @param {object} data The data to render
+ * @returns {string} The rendered HTML string
+ * @todo Would it be better to use an abstract factory method here to handle different data types?
+ */
+ renderDefault(data) {
+ let strHTML = "";
+
+ if (!data.values || !data.values.length) {
+ return strHTML;
+ }
+
+ data.values.forEach((value, i) => {
+ strHTML += this.encodeHTMLEntities(value);
+ strHTML += (data.values.length > (i + 1)) ? ", " : "";
+ });
+
+ return this.renderMoreLess(strHTML, data.name);
}
- return strHTML
- }
-
- renderDataType(data) {
- switch (data.type) {
- case 'id':
- return this.renderId(data)
- case 'person':
- case 'createdby':
- return this.renderPerson(data)
- case 'curval':
- case 'autocur':
- case 'filval':
- return this.renderCurCommon(data)
- case 'file':
- return this.renderFile(data)
- case 'rag':
- return this.renderRag(data)
- default:
- return this.renderDefault(data)
+ /**
+ * Render the ID data type
+ * @param {object} data The data to render
+ * @returns {string} The rendered HTML string for the ID
+ */
+ renderId(data) {
+ let retval = "";
+ const id = data.values[0];
+ if (!id) return retval;
+ if (data.parent_id) {
+ retval = `${data.parent_id} → `;
+ }
+ return retval + `${id}`;
}
- }
- renderData(type, row, meta) {
- const strColumnName = meta ? meta.settings.oAjaxData.columns[meta.col].name : ""
- const data = row[strColumnName]
+ /**
+ * Render the person data type
+ * @param {string} data The data to render
+ * @returns {string} The rendered HTML string for the person data type
+ */
+ renderPerson(data) {
+ let strHTML = "";
- if (typeof data !== 'object') {
- return ''
+ if (!data.values.length) {
+ return strHTML;
+ }
+
+ data.values.forEach((value) => {
+ if (value.details.length) {
+ let thisHTML = "
Auto-recover is disabled as your browser does not support encryption
");
+ $("body").data("encryption-disabled", "true");
}
}
};
diff --git a/src/frontend/components/form-group/autosave/lib/autosave.test.ts b/src/frontend/components/form-group/autosave/lib/autosave.test.ts
index c3c958447..198fd8eb2 100644
--- a/src/frontend/components/form-group/autosave/lib/autosave.test.ts
+++ b/src/frontend/components/form-group/autosave/lib/autosave.test.ts
@@ -1,9 +1,10 @@
-import "../../../../testing/globals.definitions";
+/* eslint-disable jsdoc/require-jsdoc */
import AutosaveBase from './autosaveBase';
+import { describe, it, expect, beforeAll, afterAll } from '@jest/globals';
class TestAutosave extends AutosaveBase {
initAutosave(): void {
- console.log('initAutosave');
+ console.debug('initAutosave');
}
}
@@ -17,7 +18,7 @@ describe('AutosaveBase', () => {
$('body').data('layout-identifier', 1);
});
- afterAll(()=>{
+ afterAll(() => {
document.body.innerHTML = '';
});
diff --git a/src/frontend/components/form-group/autosave/lib/autosaveBase.ts b/src/frontend/components/form-group/autosave/lib/autosaveBase.ts
index 912947632..7ff9641ce 100644
--- a/src/frontend/components/form-group/autosave/lib/autosaveBase.ts
+++ b/src/frontend/components/form-group/autosave/lib/autosaveBase.ts
@@ -2,12 +2,12 @@ import { Component } from "component";
import StorageProvider from "util/storageProvider";
/**
- * Base class for autosave
+ * Base class for autosave/recovery functionality.
*/
export default abstract class AutosaveBase extends Component {
/**
* Creates a new autosave component
- * @param element The element to attach the autosave functionality to
+ * @param {HTMLElement} element The element to attach the autosave functionality to
*/
constructor(element: HTMLElement) {
super(element);
@@ -16,50 +16,57 @@ export default abstract class AutosaveBase extends Component {
/**
* Whether the current form is a clone
+ * @returns {boolean} True if the form is a clone, false otherwise
*/
- get isClone() {
- return !!$('body').find('.form-edit').data('from');
+ get isClone(): boolean {
+ return !!$("body").find(".form-edit")
+ .data("from");
}
/**
* The layout identifier of the current form
+ * @returns {string} The layout identifier of the current form
*/
- get layoutId() {
- return $('body').data('layout-identifier');
+ get layoutId(): string {
+ return $("body").data("layout-identifier");
}
/**
* The record identifier of the current form
+ * @returns {number} The record identifier of the current form
*/
- get recordId() {
- return $('body').find('.form-edit').data('current-id') || 0;
+ get recordId(): number {
+ return $("body").find(".form-edit")
+ .data("current-id") || 0;
}
/**
* The storage object to use for autosave - this is a variable to allow for mocking in testing
+ * @returns {StorageProvider} The storage provider for autosave
*/
- get storage() {
+ get storage(): StorageProvider {
return new StorageProvider(`linkspace-record-change-${this.layoutId}-${this.recordId}`);
}
/**
* The key to use for storing the autosave data
+ * @returns {string} The key to use for storing the autosave data
*/
- get table_key() {
+ get table_key(): string {
return `linkspace-record-change-${this.layoutId}-${this.recordId}`;
}
/**
* Get the key to use for storing the autosave data for a given field
- * @param $field The field to get the column key for
- * @returns The key to use for storing the autosave data for the given field
+ * @param {JQuery} $field The field to get the column key for
+ * @returns {string} The key to use for storing the autosave data for the given field
*/
- columnKey($field: JQuery) {
- return `linkspace-column-${$field.data('column-id')}-${this.layoutId}-${this.recordId}`;
+ columnKey($field: JQuery): string {
+ return `linkspace-column-${$field.data("column-id")}-${this.layoutId}-${this.recordId}`;
}
/**
* Initialize the autosave functionality - this is implemented in the child classes
*/
abstract initAutosave(): void;
-}
\ No newline at end of file
+}
diff --git a/src/frontend/components/form-group/autosave/lib/component.js b/src/frontend/components/form-group/autosave/lib/component.js
index ae8a114a0..356b85b11 100644
--- a/src/frontend/components/form-group/autosave/lib/component.js
+++ b/src/frontend/components/form-group/autosave/lib/component.js
@@ -1,68 +1,67 @@
import { getFieldValues } from "get-field-values";
-import AutosaveBase from './autosaveBase';
+import AutosaveBase from "./autosaveBase";
/**
* Autosave component
- * @inherits AutosaveBase
*/
class AutosaveComponent extends AutosaveBase {
- /**
- * @inheritdoc
- */
- async initAutosave() {
- const $field = $(this.element);
- const self = this;
- if ($field.data('is-readonly')) return;
+ /**
+ * @inheritdoc
+ */
+ async initAutosave() {
+ const $field = $(this.element);
+ const self = this; // eslint-disable-line @typescript-eslint/no-this-alias
+ if ($field.data("is-readonly")) return;
- // For each field, when it changes save the value to the local storage
- $field.on('change', async function () {
- const recovering = await self.storage.getItem('recovering');
- if(recovering) return;
- if ($field.hasClass('field--changed')) $field.removeClass('field--changed');
- let values = getFieldValues($field, false, false, true);
- values = (Array.isArray(values) ? values : [values]).filter(function (element) {
- return !!element;
- });
+ // For each field, when it changes save the value to the local storage
+ $field.on("change", async function () {
+ const recovering = await self.storage.getItem("recovering");
+ if (recovering) return;
+ if ($field.hasClass("field--changed")) $field.removeClass("field--changed");
+ let values = getFieldValues($field, false, false, true);
+ values = (Array.isArray(values) ? values : [values]).filter(function (element) {
+ return !!element;
+ });
- const column_key = self.columnKey($field);
+ const column_key = self.columnKey($field);
- const $form = $field.closest('form');
- if ($form.hasClass('curval-edit-form')) {
- // For curval modals, don't write anything immediately in case user
- // cancels the modal. Store in form data in the modal and then save
- // locally on submit
- let existing = $form.data('autosave-changes') || {};
- existing[column_key] = values;
- $form.data('autosave-changes', existing);
- } else if ($field.data('value-selector') != 'noshow') {
- // If this field is a curval with an add button, then make sure that
- // any values that have been added (and will already be in storage)
- // are retained. These will be returned from getFieldValues() as guids,
- // but we need to retain all the associated values
- if ($field.data('show-add') && await self.storage.getItem(column_key)) {
- // Get all existing values for this curval
- let existing = JSON.parse(await self.storage.getItem(column_key));
- // Map them into an index
- let indexed = existing.filter((item) => !Number.isInteger(item)).reduce((a, v) => ({ ...a, [v.identifier]: v }), {});
- // For each value, if it's not an ID then get the full set of values
- // that were previously retrieved from local storage
- values = values.map((item) => Number.isInteger(item) ? item : indexed[item]);
- }
- await self.storage.setItem(column_key, JSON.stringify(values));
- if(!(await self.storage.getItem(self.table_key))) await self.storage.setItem(self.table_key, true);
- } else {
- // Delete any values now deleted
- let existing = await self.storage.getItem(column_key) ? JSON.parse(await self.storage.getItem(column_key)) : [];
- existing = existing.filter((item) => values.includes(item.identifier));
- await self.storage.setItem(column_key, JSON.stringify(existing));
- // And flag that something has changed (even if nothing has been
- // deleted, this will need setting if the change was triggered as a
- // result of a modal submit for a curval add - everything else will
- // have already been saved)
- if(!(await self.storage.getItem(self.table_key))) await self.storage.setItem(self.table_key, true);
- }
- });
- }
+ const $form = $field.closest("form");
+ if ($form.hasClass("curval-edit-form")) {
+ // For curval modals, don't write anything immediately in case user
+ // cancels the modal. Store in form data in the modal and then save
+ // locally on submit
+ let existing = $form.data("autosave-changes") || {};
+ existing[column_key] = values;
+ $form.data("autosave-changes", existing);
+ } else if ($field.data("value-selector") != "noshow") {
+ // If this field is a curval with an add button, then make sure that
+ // any values that have been added (and will already be in storage)
+ // are retained. These will be returned from getFieldValues() as guids,
+ // but we need to retain all the associated values
+ if ($field.data("show-add") && await self.storage.getItem(column_key)) {
+ // Get all existing values for this curval
+ let existing = JSON.parse(await self.storage.getItem(column_key));
+ // Map them into an index
+ let indexed = existing.filter((item) => !Number.isInteger(item)).reduce((a, v) => ({ ...a, [v.identifier]: v }), {});
+ // For each value, if it's not an ID then get the full set of values
+ // that were previously retrieved from local storage
+ values = values.map((item) => Number.isInteger(item) ? item : indexed[item]);
+ }
+ await self.storage.setItem(column_key, JSON.stringify(values));
+ if (!(await self.storage.getItem(self.table_key))) await self.storage.setItem(self.table_key, true);
+ } else {
+ // Delete any values now deleted
+ let existing = await self.storage.getItem(column_key) ? JSON.parse(await self.storage.getItem(column_key)) : [];
+ existing = existing.filter((item) => values.includes(item.identifier));
+ await self.storage.setItem(column_key, JSON.stringify(existing));
+ // And flag that something has changed (even if nothing has been
+ // deleted, this will need setting if the change was triggered as a
+ // result of a modal submit for a curval add - everything else will
+ // have already been saved)
+ if (!(await self.storage.getItem(self.table_key))) await self.storage.setItem(self.table_key, true);
+ }
+ });
+ }
}
export default AutosaveComponent;
diff --git a/src/frontend/components/form-group/autosave/lib/modal.js b/src/frontend/components/form-group/autosave/lib/modal.js
index 32e12324b..22f4b3d8a 100644
--- a/src/frontend/components/form-group/autosave/lib/modal.js
+++ b/src/frontend/components/form-group/autosave/lib/modal.js
@@ -1,5 +1,5 @@
import { setFieldValues } from "set-field-values";
-import AutosaveBase from './autosaveBase';
+import AutosaveBase from "./autosaveBase";
import { fromJson } from "util/common";
import { InfoAlert } from "components/alert/lib/infoAlert";
import { RenderableButton } from "components/button/lib/RenderableButton";
@@ -8,160 +8,160 @@ import { RenderableButton } from "components/button/lib/RenderableButton";
* A modal that allows the user to restore autosaved values.
*/
class AutosaveModal extends AutosaveBase {
- /**
- * @inheritdoc
- */
- async initAutosave() {
- const $modal = $(this.element);
- const $form = $('.form-edit');
+ /** @inheritdoc */
+ async initAutosave() {
+ const $modal = $(this.element);
+ const $form = $(".form-edit");
- $modal.find('.btn-js-restore-values').on('click', async (e) => {
- e.preventDefault();
- e.stopPropagation();
+ $modal.find(".btn-js-restore-values").on("click", async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
- // Hide all the buttons (we don't want any interaction to close the modal or the restore process fails)
- $modal.find(".modal-footer").find("button").hide();
+ // Hide all the buttons (we don't want any interaction to close the modal or the restore process fails)
+ $modal.find(".modal-footer").find("button")
+ .hide();
- // This need awaiting or it returns before the value is fully set meaning if the recovery is "fast" it will not clear
- await this.storage.setItem('recovering', true);
- // Count the curvals so we don't return too early
- let curvalCount = 0;
- // Only count changed curvals - as each in the array has it's own event, we count the number of changes, not the number of fields
- await Promise.all($form.find('.linkspace-field[data-column-type="curval"]').map(async (_, field) => {
- await this.storage.getItem(this.columnKey($(field))) && (curvalCount += fromJson(await this.storage.getItem(this.columnKey($(field)))).length);
- }));
+ // This need awaiting or it returns before the value is fully set meaning if the recovery is "fast" it will not clear
+ await this.storage.setItem("recovering", true);
+ // Count the curvals so we don't return too early
+ let curvalCount = 0;
+ // Only count changed curvals - as each in the array has it's own event, we count the number of changes, not the number of fields
+ await Promise.all($form.find(".linkspace-field[data-column-type=\"curval\"]").map(async (_, field) => {
+ if(await this.storage.getItem(this.columnKey($(field))))
+ (curvalCount += fromJson(await this.storage.getItem(this.columnKey($(field)))).length);
+ }));
- let errored = false;
+ let errored = false;
- let $list = $("
Please be aware that linked records may take a moment to finish restoring.
")
- .append($list);
- // Convert the fields to promise functions (using the fields) that are run in parallel
- // This is only done because various parts of the codebase use the fields in different ways dependent on types (i.e. curval)
- await Promise.all($form.find('.linkspace-field').map(async (_, field) => {
- const $field = $(field);
- // This was originally a bunch of promises, but as the code is async, we can await things here
- try {
- const json = await this.storage.getItem(this.columnKey($field))
- let values = json ? JSON.parse(json) : undefined;
- // If the value can't be parsed, ignore it
- if (!values) return;
- // If we are in view mode and we need to switch to edit mode, do that
- const $editButton = $field.closest('.card--topic').find('.btn-js-edit');
- if ($editButton && $editButton.length) $editButton.trigger('click');
- if (Array.isArray(values)) {
- const name = $field.data("name");
- const type = $field.data("column-type");
- if (type === "curval") {
- // Curvals need to work event-driven - this is because the modal doesn't always load fully,
- // meaning the setvalue doesn't work correctly for dropdowns (mainly)
- $field.off("validationFailed");
- $field.off("validationPassed");
- $field.on("validationFailed", (e) => {
- // Decrement the curval count
- curvalCount--;
- const $li = $(`
Error restoring ${name}, please check these values before submission
${e.message}
`);
- $list.append($li);
- // If we've done all fields, turn off the recovery flag
- if (!curvalCount) {
- // Hide the restore button and show the close button
- $modal.find(".modal-footer").find(".btn-cancel").text("Close").show();
- this.storage.removeItem('recovering');
+ let $list = $("
Please be aware that linked records may take a moment to finish restoring.
")
+ .append($list);
+ // Convert the fields to promise functions (using the fields) that are run in parallel
+ // This is only done because various parts of the codebase use the fields in different ways dependent on types (i.e. curval)
+ await Promise.all($form.find(".linkspace-field").map(async (_, field) => {
+ const $field = $(field);
+ // This was originally a bunch of promises, but as the code is async, we can await things here
+ try {
+ const json = await this.storage.getItem(this.columnKey($field));
+ let values = json ? JSON.parse(json) : undefined;
+ // If the value can't be parsed, ignore it
+ if (!values) return;
+ // If we are in view mode and we need to switch to edit mode, do that
+ const $editButton = $field.closest(".card--topic").find(".btn-js-edit");
+ if ($editButton && $editButton.length) $editButton.trigger("click");
+ if (Array.isArray(values)) {
+ const name = $field.data("name");
+ const type = $field.data("column-type");
+ if (type === "curval") {
+ // Curvals need to work event-driven - this is because the modal doesn't always load fully,
+ // meaning the setvalue doesn't work correctly for dropdowns (mainly)
+ $field.off("validationFailed");
+ $field.off("validationPassed");
+ $field.on("validationFailed", (e) => {
+ // Decrement the curval count
+ curvalCount--;
+ const $li = $(`
Error restoring ${name}, please check these values before submission
${e.message}
`);
+ $list.append($li);
+ // If we've done all fields, turn off the recovery flag
+ if (!curvalCount) {
+ // Hide the restore button and show the close button
+ $modal.find(".modal-footer").find(".btn-cancel").text("Close").show();
+ this.storage.removeItem("recovering");
+ }
+ });
+ $field.on("validationPassed", () => {
+ // Decrement the curval count
+ curvalCount--;
+ const $li = $(`
Restored ${name}
`);
+ $list.append($li);
+ // If we've done all fields, turn off the recovery flag
+ if (!curvalCount) {
+ // Hide the restore button and show the close button
+ $modal.find(".modal-footer").find(".btn-cancel").text("Close").show();
+ this.storage.removeItem("recovering");
+ }
+ });
+ }
+ setFieldValues($field, values);
+ if (type !== "curval") {
+ const $li = $(`
`);
- $list.append($li);
- // If we've done all fields, turn off the recovery flag
- if (!curvalCount) {
- // Hide the restore button and show the close button
- $modal.find(".modal-footer").find(".btn-cancel").text("Close").show();
- this.storage.removeItem('recovering');
+ })).then(() => {
+ // If there are errors, show an appropriate message, otherwise show a success message
+ $body.append(`
${errored ? "Values restored with errors." : "All values restored."} Please check that all field values are as expected.
`);
+ }).catch(e => {
+ // If there are any errors that can't be handled in the mapped promises, show a critical error message
+ $body.append(`
Critical error restoring values
${e}
`);
+ }).finally(() => {
+ // Only allow to close once recovery is finished
+ if (!curvalCount || errored) {
+ // Show the close button
+ $modal.find(".modal-footer").find(".btn-cancel").text("Close").show();
+ this.storage.removeItem("recovering");
}
- });
- }
- setFieldValues($field, values);
- if (type !== "curval") {
- const $li = $(`
`);
- console.error(e);
- $list.append($li);
- errored = true;
- }
- })).then(() => {
- // If there are errors, show an appropriate message, otherwise show a success message
- $body.append(`
${errored ? "Values restored with errors." : "All values restored."} Please check that all field values are as expected.
`);
- }).catch(e => {
- // If there are any errors that can't be handled in the mapped promises, show a critical error message
- $body.append(`
Critical error restoring values
${e}
`);
- }).finally(() => {
- // Only allow to close once recovery is finished
- if (!curvalCount || errored) {
- // Show the close button
- $modal.find(".modal-footer").find(".btn-cancel").text("Close").show();
- this.storage.removeItem('recovering');
- }
- });
- });
+ });
+ });
- // Do we need to run an autorecover?
- const item = await this.storage.getItem(this.table_key);
+ // Do we need to run an autorecover?
+ const item = await this.storage.getItem(this.table_key);
- // If there is no item, or there are already alerts, do not show the alert
- if ($('.alert-danger').text() || $('.alert-warning').text() || !item) return;
- const alert = new InfoAlert("There are unsaved values from the last time you edited this record. Would you like to preview the changes?");
- const alertElement = alert.render();
+ // If there is no item, or there are already alerts, do not show the alert
+ if ($(".alert-danger").text() || $(".alert-warning").text() || !item) return;
+ const alert = new InfoAlert("There are unsaved values from the last time you edited this record. Would you like to preview the changes?");
+ const alertElement = alert.render();
- alertElement.classList.add('alert-restore');
+ alertElement.classList.add("alert-restore");
- const restoreButton = new RenderableButton("Preview", () => {
- const $display = $modal.find(".modal-autosave")
- const list = $("");
- // Get a list of the field values to restore
- Promise.all($form.find('.linkspace-field').map(async (_, field)=>{
- const $field = $(field);
- const key = this.columnKey($field);
- const value = await this.storage.getItem(key)
- if(!value) return;
- const fieldName = $field.data('name');
- const li = $(`
${fieldName}
`)
- list.append(li);
- })).then(()=> {
- // Append the list to the modal display
- $display.append(list)
- }).then(()=>{
- // Show the modal
- $modal.modal('show');
- alert.hide();
- });
- }, 'btn-primary', 'btn-inverted', 'btn-alert-restore');
- const restoreButtonElement = restoreButton.render();
+ const restoreButton = new RenderableButton("Preview", () => {
+ const $display = $modal.find(".modal-autosave");
+ const list = $("");
+ // Get a list of the field values to restore
+ Promise.all($form.find(".linkspace-field").map(async (_, field)=>{
+ const $field = $(field);
+ const key = this.columnKey($field);
+ const value = await this.storage.getItem(key);
+ if(!value) return;
+ const fieldName = $field.data("name");
+ const li = $(`
${fieldName}
`);
+ list.append(li);
+ })).then(()=> {
+ // Append the list to the modal display
+ $display.append(list);
+ }).then(()=>{
+ // Show the modal
+ $modal.modal("show");
+ alert.hide();
+ });
+ }, "btn-primary", "btn-inverted", "btn-alert-restore");
+ const restoreButtonElement = restoreButton.render();
- const cancelButton = new RenderableButton("Cancel", () => {
- alert.hide();
- }, 'btn-secondary', 'btn-inverted', 'btn-alert-restore-cancel');
- const cancelButtonElement = cancelButton.render();
+ const cancelButton = new RenderableButton("Cancel", () => {
+ alert.hide();
+ }, "btn-secondary", "btn-inverted", "btn-alert-restore-cancel");
+ const cancelButtonElement = cancelButton.render();
- const buttonDiv = document.createElement('div');
- buttonDiv.className = 'button-group d-flex justify-content-end';
- buttonDiv.appendChild(restoreButtonElement);
- buttonDiv.appendChild(cancelButtonElement);
+ const buttonDiv = document.createElement("div");
+ buttonDiv.className = "button-group d-flex justify-content-end";
+ buttonDiv.appendChild(restoreButtonElement);
+ buttonDiv.appendChild(cancelButtonElement);
- alertElement.appendChild(buttonDiv);
+ alertElement.appendChild(buttonDiv);
- $('.content-block').prepend(alertElement);
- }
+ $(".content-block").prepend(alertElement);
+ }
}
export default AutosaveModal;
diff --git a/src/frontend/components/form-group/calc-fields/index.js b/src/frontend/components/form-group/calc-fields/index.js
index a632b79e8..4a84ccb6e 100644
--- a/src/frontend/components/form-group/calc-fields/index.js
+++ b/src/frontend/components/form-group/calc-fields/index.js
@@ -1,4 +1,4 @@
-import { initializeComponent } from 'component'
-import CalcFieldsComponent from './lib/component'
+import { initializeComponent } from "component";
+import CalcFieldsComponent from "./lib/component";
-export default (scope) => initializeComponent(scope, '[data-calc-depends-on]', CalcFieldsComponent)
+export default (scope) => initializeComponent(scope, "[data-calc-depends-on]", CalcFieldsComponent);
diff --git a/src/frontend/components/form-group/calc-fields/lib/component.js b/src/frontend/components/form-group/calc-fields/lib/component.js
index 3885f973f..78003760a 100644
--- a/src/frontend/components/form-group/calc-fields/lib/component.js
+++ b/src/frontend/components/form-group/calc-fields/lib/component.js
@@ -1,106 +1,124 @@
-import { Component } from 'component'
-import { getFieldValues } from "get-field-values"
+import { Component } from "component";
+import { getFieldValues } from "get-field-values";
+/**
+ * Component to handle calculation fields that depend on other fields.
+ */
class CalcFieldsComponent extends Component {
- constructor(element) {
- super(element)
- this.initCalcFields()
- }
-
- initCalcFields() {
- const field = this.getFieldCalc();
- this.setupCalcField(field);
- }
-
- getFieldCalc() {
-
- const dependency = $(this.element).data("calc-depends-on")
- const depends_on_ids = JSON.parse(atob(dependency))
- const depends_on = jQuery.map(depends_on_ids, function(id) {
- return $('[data-column-id="' + id + '"]')
- });
-
- return {
- field: $(this.element),
- code: atob($(this.element).data("code")).toString(),
- params: JSON.parse(atob($(this.element).data("code-params"))),
- depends_on: depends_on
- };
-
- }
-
- setupCalcField(field) {
- let {code} = field
- const {depends_on,params} = field
- const $field = field.field
-
- // Change standard backend code format to a format that works for
- // evaluating in the browser
- var re = /^function\s+evaluate\s+/gi
- code = code.replace(re, "function ")
- code = "return " + code
-
- depends_on.forEach(function($depend_on) {
-
- // Standard change of visible form field that this calc depends on. When
- // it changes get all the values this code depends on and evaluate the
- // code
- $depend_on.on("change", function() {
-
- // Recursively shift all the array fields in an object to start at index 1
- const shiftFields = (obj) => {
- // if obj is an array, shift all the elements into an object starting
- // at index 1
- if (Array.isArray(obj)) {
- // If an array is passed in as-is, then its first element will be at
- // array index 0, which ipairs will not recognise. Therefore,
- // offset all the elements into an object starting at index 1
- const obj2 = [];
- obj.forEach((element, index) => {
- obj2[index + 1] = element;
- });
- obj = obj2;
- } else {
- // If the field is an object, recursively shift its fields
- if (typeof obj === 'object') {
- for (const field in obj) {
- obj[field] = shiftFields(obj[field]);
+ /**
+ * Create a new instance of the CalcFieldsComponent.
+ * @param {HTMLElement} element The HTML element that this component is attached to.
+ */
+ constructor(element) {
+ super(element);
+ this.initCalcFields();
+ }
+
+ /**
+ * Initializes the calculation fields component.
+ */
+ initCalcFields() {
+ const field = this.getFieldCalc();
+ this.setupCalcField(field);
+ }
+
+ /**
+ * Get the calculation field data.
+ * @returns {object} An object containing the field, code, parameters, and dependencies for the calculation field.
+ */
+ getFieldCalc() {
+
+ const dependency = $(this.element).data("calc-depends-on");
+ const depends_on_ids = JSON.parse(atob(dependency));
+ const depends_on = jQuery.map(depends_on_ids, function (id) {
+ return $("[data-column-id=\"" + id + "\"]");
+ });
+
+ return {
+ field: $(this.element),
+ code: atob($(this.element).data("code")).toString(),
+ params: JSON.parse(atob($(this.element).data("code-params"))),
+ depends_on: depends_on
+ };
+
+ }
+
+ /**
+ * Setup the calculation field by attaching change events to its dependencies.
+ * @param {object} field The field object containing the code and dependencies for the calculation field.
+ */
+ setupCalcField(field) {
+ let { code } = field;
+ const { depends_on, params } = field;
+ const $field = field.field;
+
+ // Change standard backend code format to a format that works for
+ // evaluating in the browser
+ var re = /^function\s+evaluate\s+/gi;
+ code = code.replace(re, "function ");
+ code = "return " + code;
+
+ depends_on.forEach(function ($depend_on) {
+
+ // Standard change of visible form field that this calc depends on. When
+ // it changes get all the values this code depends on and evaluate the
+ // code
+ $depend_on.on("change", function () {
+
+ // Recursively shift all the array fields in an object to start at index 1
+ const shiftFields = (obj) => {
+ // if obj is an array, shift all the elements into an object starting
+ // at index 1
+ if (Array.isArray(obj)) {
+ // If an array is passed in as-is, then its first element will be at
+ // array index 0, which ipairs will not recognise. Therefore,
+ // offset all the elements into an object starting at index 1
+ const obj2 = [];
+ obj.forEach((element, index) => {
+ obj2[index + 1] = element;
+ });
+ obj = obj2;
+ } else {
+ // If the field is an object, recursively shift its fields
+ if (typeof obj === "object") {
+ for (const field in obj) {
+ obj[field] = shiftFields(obj[field]);
+ }
+ }
}
+ return obj;
+ };
+
+ // All the values
+ var vars = params.map(function (value) {
+ var $depends = $(".linkspace-field[data-name-short=\"" + value + "\"]");
+ let ret = getFieldValues($depends, false, true);
+ shiftFields(ret);
+
+ return ret;
+ });
+
+ // Evaluate the code with the values
+ // eslint-disable-next-line no-undef
+ var func = fengari.load(code)();
+ var first = vars.shift();
+ // Use apply() to be able to pass the params as a single array. The
+ // first needs to be passed separately so shift it off and do so
+ var returnval = func.apply(first, vars);
+
+ const $textArea = $field.find("textarea");
+ // If the value in the textarea isn't the same as the return value, update it
+ if ($textArea.val() != returnval) {
+ // Update the field holding the code's value
+ $textArea.val(returnval);
+ // And trigger a change on its parent div to trigger any display
+ // conditions
+ $field.closest(".linkspace-field").trigger("change");
+ $textArea.trigger("change");
}
- }
- return obj;
- }
-
- // All the values
- var vars = params.map(function(value) {
- var $depends = $('.linkspace-field[data-name-short="'+value+'"]')
- let ret = getFieldValues($depends, false, true)
- shiftFields(ret)
-
- return ret
+ });
});
-
- // Evaluate the code with the values
- // eslint-disable-next-line no-undef
- var func = fengari.load(code)()
- var first = vars.shift()
- // Use apply() to be able to pass the params as a single array. The
- // first needs to be passed separately so shift it off and do so
- var returnval = func.apply(first, vars)
-
- const $textArea = $field.find('textarea');
- // If the value in the textarea isn't the same as the return value, update it
- if($textArea.val() != returnval){
- // Update the field holding the code's value
- $textArea.val(returnval)
- // And trigger a change on its parent div to trigger any display
- // conditions
- $field.closest('.linkspace-field').trigger("change")
- $textArea.trigger('change');
- }
- });
- });
- }
+ }
}
-export default CalcFieldsComponent
+export default CalcFieldsComponent;
diff --git a/src/frontend/components/form-group/checkbox/_checkbox.scss b/src/frontend/components/form-group/checkbox/_checkbox.scss
index cfba1ddfe..f710bbaac 100644
--- a/src/frontend/components/form-group/checkbox/_checkbox.scss
+++ b/src/frontend/components/form-group/checkbox/_checkbox.scss
@@ -14,7 +14,7 @@
}
.checkbox--hide-label {
- input[type=checkbox]:checked + label::after {
+ input[type="checkbox"]:checked + label::after {
left: 5px;
}
diff --git a/src/frontend/components/form-group/checkbox/index.js b/src/frontend/components/form-group/checkbox/index.js
index 34902714f..f696dfbc8 100644
--- a/src/frontend/components/form-group/checkbox/index.js
+++ b/src/frontend/components/form-group/checkbox/index.js
@@ -1,4 +1,4 @@
-import { initializeComponent } from 'component'
-import CheckboxComponent from './lib/component'
+import { initializeComponent } from "component";
+import CheckboxComponent from "./lib/component";
-export default (scope) => initializeComponent(scope, '.checkbox--reveal', CheckboxComponent)
+export default (scope) => initializeComponent(scope, ".checkbox--reveal", CheckboxComponent);
diff --git a/src/frontend/components/form-group/checkbox/lib/component.js b/src/frontend/components/form-group/checkbox/lib/component.js
index 3069205bc..fb5702a37 100644
--- a/src/frontend/components/form-group/checkbox/lib/component.js
+++ b/src/frontend/components/form-group/checkbox/lib/component.js
@@ -1,41 +1,55 @@
-import { Component } from 'component'
+import { Component } from "component";
+/**
+ * Component to handle checkbox functionality with reveal elements.
+ */
class CheckboxComponent extends Component {
- constructor(element) {
- super(element)
- this.el = $(this.element)
-
- this.initCheckbox()
- }
-
- // Intializes the checkbox
- initCheckbox() {
- const inputEl = $(this.el).find('input')
- const id = $(inputEl).attr('id')
- const $revealEl = $(`#${id}-reveal`)
+ /**
+ * Create a new instance of the CheckboxComponent.
+ * @param {HTMLElement} element The HTML element that this component is attached to.
+ */
+ constructor(element) {
+ super(element);
+ this.el = $(this.element);
+
+ this.initCheckbox();
+ }
- if ($(inputEl).is(':checked')) {
- this.showRevealElement($revealEl, true)
+ /**
+ * Intializes the checkbox
+ */
+ initCheckbox() {
+ const inputEl = $(this.el).find("input");
+ const id = $(inputEl).attr("id");
+ const $revealEl = $(`#${id}-reveal`);
+
+ if ($(inputEl).is(":checked")) {
+ this.showRevealElement($revealEl, true);
+ }
+
+ $(inputEl).on("change", () => {
+ if ($(inputEl).is(":checked")) {
+ this.showRevealElement($revealEl, true);
+ } else {
+ this.showRevealElement($revealEl, false);
+ }
+ });
}
- $(inputEl).on('change', () => {
- if ($(inputEl).is(':checked')) {
- this.showRevealElement($revealEl, true)
- } else {
- this.showRevealElement($revealEl, false)
- }
- })
- }
-
- showRevealElement($revealEl, bShow) {
- const strCheckboxRevealShowClassName = 'checkbox-reveal--show'
-
- if (bShow) {
- $revealEl.addClass(strCheckboxRevealShowClassName)
- } else {
- $revealEl.removeClass(strCheckboxRevealShowClassName)
+ /**
+ * Show or hide the reveal element based on the checkbox state.
+ * @param {JQuery} $revealEl The reveal element to show or hide
+ * @param {boolean} bShow True to show the reveal element, false to hide it
+ */
+ showRevealElement($revealEl, bShow) {
+ const strCheckboxRevealShowClassName = "checkbox-reveal--show";
+
+ if (bShow) {
+ $revealEl.addClass(strCheckboxRevealShowClassName);
+ } else {
+ $revealEl.removeClass(strCheckboxRevealShowClassName);
+ }
}
- }
}
-export default CheckboxComponent
+export default CheckboxComponent;
diff --git a/src/frontend/components/form-group/common/bootstrap-select.js b/src/frontend/components/form-group/common/bootstrap-select.js
deleted file mode 100644
index 586e886d9..000000000
--- a/src/frontend/components/form-group/common/bootstrap-select.js
+++ /dev/null
@@ -1,50 +0,0 @@
-export const refreshSelects = (el)=>{
- const ruleFilterSelects=[];
- const operatorSelects=[];
-
- el.on("afterCreateRuleFilters.queryBuilder", (e, rule) => {
- const ruleFilterSelect= $(rule.$el.find(`select[name=${rule.id}_filter]`));
- if(!ruleFilterSelects.includes(ruleFilterSelect[0])) ruleFilterSelects.push(ruleFilterSelect[0]);
- if(!ruleFilterSelect || !ruleFilterSelect[0]) {
- console.error("No select found");
- return;
- }
- ruleFilterSelect.data("live-search","true");
- ruleFilterSelect.selectpicker();
- });
-
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- el.on("afterCreateRuleOperators.queryBuilder", (e, rule, operators) => {
- const operatorSelect = $(rule.$el.find(`select[name=${rule.id}_operator]`));
- if(!operatorSelect || !operatorSelect[0]) {
- console.error("No operator select found");
- return;
- }
- if(!operatorSelects.includes(operatorSelect[0])) operatorSelects.push(operatorSelect[0]);
- if(operatorSelect.data("live-search")) return;
- operatorSelect.data("live-search","true");
- operatorSelect.selectpicker();
- });
-
- el.on("afterSetRules.queryBuilder", () => {
- for(const ruleFilterSelect of ruleFilterSelects) {
- if(!ruleFilterSelect) {
- continue;
- }
- $(ruleFilterSelect).selectpicker("refresh");
- }
- for(const operatorSelect of operatorSelects) {
- if(!operatorSelect) continue;
- $(operatorSelect).selectpicker("refresh");
- }
- });
-
- el.on("afterSetRuleOperator.queryBuilder", () => {
- for(const operatorSelect of operatorSelects) {
- if(!operatorSelect) {
- continue;
- }
- $(operatorSelect).selectpicker("refresh");
- }
- });
-}
\ No newline at end of file
diff --git a/src/frontend/components/form-group/dependent-fields/index.js b/src/frontend/components/form-group/dependent-fields/index.js
index fe2dd03cb..25be9c610 100644
--- a/src/frontend/components/form-group/dependent-fields/index.js
+++ b/src/frontend/components/form-group/dependent-fields/index.js
@@ -1,4 +1,4 @@
-import { initializeComponent } from 'component'
-import DependentFieldsComponent from './lib/component'
+import { initializeComponent } from "component";
+import DependentFieldsComponent from "./lib/component";
-export default (scope) => initializeComponent(scope, '[data-has-dependency]', DependentFieldsComponent)
+export default (scope) => initializeComponent(scope, "[data-has-dependency]", DependentFieldsComponent);
diff --git a/src/frontend/components/form-group/dependent-fields/lib/component.js b/src/frontend/components/form-group/dependent-fields/lib/component.js
index 91ec5cf9f..a592c7687 100644
--- a/src/frontend/components/form-group/dependent-fields/lib/component.js
+++ b/src/frontend/components/form-group/dependent-fields/lib/component.js
@@ -1,168 +1,181 @@
-import { Component } from 'component'
-import { getFieldValues } from "get-field-values"
+import { Component } from "component";
+import { getFieldValues } from "get-field-values";
+/**
+ * Component to handle dependent fields in forms.
+ */
class DependentFieldsComponent extends Component {
- constructor(element) {
- super(element)
- this.initDependentFields()
- }
-
- initDependentFields() {
- const field = this.getFieldDependency();
- this.setupDependentField(field);
- }
-
- getFieldDependency() {
- const dependency = $(this.element).data("dependency");
- const decoded = JSON.parse(atob(dependency));
- const rules = decoded.rules;
- const condition = decoded.condition;
-
- const rr = jQuery.map(rules, function(rule) {
- const match_type = rule.operator;
- const is_negative = match_type.indexOf("not") !== -1 ? true : false;
- const regexp =
- match_type.indexOf("equal") !== -1
- ? new RegExp("^" + rule.value + "$", "i")
- : new RegExp(rule.value, "i");
- let id = rule.id;
- let filtered = false;
- if (rule.filtered) { // Whether the field is of type "filval"
- id = rule.filtered;
- filtered = true;
- }
- return {
- dependsOn: $(`[data-column-id="${id}"]`),
- regexp: regexp,
- is_negative: is_negative,
- filtered: filtered
- };
- });
-
- return {
- field: $(this.element),
- condition: condition,
- rules: rr
- };
- }
-
- /***
- *
- * Handle the dependency connections between fields
- * via regular expression checks on field values
- *
- * FIXME: It would be an improvement to abstract the
- * different field types in GADS behind a common interface
- * as opposed to using dom-attributes.
- *
- */
- setupDependentField(field) {
- const condition = field.condition;
- const rules = field.rules;
- const $field = field.field;
- // In order to hide the relevant fields, we used to trigger a change event
- // on all the fields they depended on. However, this doesn't work for
- // display fields that depend on a filval type field, as the values to
- // check are not rendered on the page until the relevant filtered curval
- // field is opened. As such, use the dependent-not-shown property instead,
- // which is evaluated server-side
- if ($field.data("dependent-not-shown")) {
- $field.hide();
+ /**
+ * Create a new instance of the DependentFieldsComponent.
+ * @param {HTMLElement} element The HTML element that this component is attached to.
+ */
+ constructor(element) {
+ super(element);
+ this.initDependentFields();
}
- const test_all = function(condition, rules) {
- if (rules.length == 0) {
- return true;
- }
-
- let is_shown = false;
-
- rules.some(function(rule) {
- // Break if returns true
-
- const $depends = rule.dependsOn;
- const regexp = rule.regexp;
- const is_negative = rule.is_negative;
- const values = getFieldValues($depends, rule.filtered);
- let this_not_shown = is_negative ? false : true;
- $.each(values, function(index, value) {
- // Blank values are returned as undefined for consistency with
- // backend calc code. Convert to empty string, otherwise they will
- // be rendered as the string "undefined" in a regex
- if (value === undefined) value = '';
- if (is_negative) {
- if (regexp.test(value)) this_not_shown = 1;
- } else {
- if (regexp.test(value)) this_not_shown = 0;
- }
+ /**
+ * Initialize the dependent fields by retrieving the field dependencies
+ */
+ initDependentFields() {
+ const field = this.getFieldDependency();
+ this.setupDependentField(field);
+ }
+
+ /**
+ * Get the field dependency from the element's data attribute.
+ * @returns {object} An object containing the field, condition, and rules for dependencies.
+ */
+ getFieldDependency() {
+ const dependency = $(this.element).data("dependency");
+ const decoded = JSON.parse(atob(dependency));
+ const rules = decoded.rules;
+ const condition = decoded.condition;
+
+ const rr = jQuery.map(rules, function (rule) {
+ const match_type = rule.operator;
+ const is_negative = match_type.indexOf("not") !== -1 ? true : false;
+ const regexp =
+ match_type.indexOf("equal") !== -1
+ ? new RegExp("^" + rule.value + "$", "i")
+ : new RegExp(rule.value, "i");
+ let id = rule.id;
+ let filtered = false;
+ if (rule.filtered) { // Whether the field is of type "filval"
+ id = rule.filtered;
+ filtered = true;
+ }
+ return {
+ dependsOn: $(`[data-column-id="${id}"]`),
+ regexp: regexp,
+ is_negative: is_negative,
+ filtered: filtered
+ };
});
- if (!this_not_shown) {
- is_shown = true;
- }
+ return {
+ field: $(this.element),
+ condition: condition,
+ rules: rr
+ };
+ }
- if (condition) {
- if (condition == "OR") {
- return is_shown; // Whether to break
- }
- if (this_not_shown) {
- is_shown = false;
- }
- return !is_shown; // Whether to break
+ /**
+ * Handle the dependency connections between fields via regular expression checks on field values
+ * @todo It would be an improvement to abstract the different field types in GADS behind a common interface as opposed to using dom-attributes.
+ * @param {object} field The field object containing the condition and rules for dependencies.
+ */
+ setupDependentField(field) {
+ const condition = field.condition;
+ const rules = field.rules;
+ const $field = field.field;
+ // In order to hide the relevant fields, we used to trigger a change event
+ // on all the fields they depended on. However, this doesn't work for
+ // display fields that depend on a filval type field, as the values to
+ // check are not rendered on the page until the relevant filtered curval
+ // field is opened. As such, use the dependent-not-shown property instead,
+ // which is evaluated server-side
+ if ($field.data("dependent-not-shown")) {
+ $field.hide();
}
- return false; // Continue loop
- });
-
- return is_shown;
- };
-
- rules.forEach(function(rule) {
- const $depends = rule.dependsOn;
-
- const processChange = function() {
- test_all(condition, rules) ? $field.show() : $field.hide();
- const $expandableCard = $field.closest(".card--expandable");
+ const test_all = function (condition, rules) {
+ if (rules.length == 0) {
+ return true;
+ }
- if ($expandableCard.length) {
- // Check each field in the card to see if none are shown, and
- // hide/show the card accordingly
- let none_shown = true; // Assume card not shown
- $expandableCard.find(".linkspace-field").each(function() {
- if ($(this).css("display") != "none") {
- none_shown = false;
- return; // Shortcut checking any more fields
+ let is_shown = false;
+
+ rules.some(function (rule) {
+ // Break if returns true
+
+ const $depends = rule.dependsOn;
+ const regexp = rule.regexp;
+ const is_negative = rule.is_negative;
+ const values = getFieldValues($depends, rule.filtered);
+ let this_not_shown = is_negative ? false : true;
+ $.each(values, function (index, value) {
+ // Blank values are returned as undefined for consistency with
+ // backend calc code. Convert to empty string, otherwise they will
+ // be rendered as the string "undefined" in a regex
+ if (value === undefined) value = "";
+ if (is_negative) {
+ if (regexp.test(value)) this_not_shown = 1;
+ } else {
+ if (regexp.test(value)) this_not_shown = 0;
+ }
+ });
+
+ if (!this_not_shown) {
+ is_shown = true;
+ }
+
+ if (condition) {
+ if (condition == "OR") {
+ return is_shown; // Whether to break
+ }
+ if (this_not_shown) {
+ is_shown = false;
+ }
+ return !is_shown; // Whether to break
+ }
+
+ return false; // Continue loop
+ });
+
+ return is_shown;
+ };
+
+ rules.forEach(function (rule) {
+ const $depends = rule.dependsOn;
+
+ const processChange = function () {
+ if (test_all(condition, rules)) {
+ $field.show();
+ } else {
+ $field.hide();
+ }
+ const $expandableCard = $field.closest(".card--expandable");
+
+ if ($expandableCard.length) {
+ // Check each field in the card to see if none are shown, and
+ // hide/show the card accordingly
+ let none_shown = true; // Assume card not shown
+ $expandableCard.find(".linkspace-field").each(function () {
+ if ($(this).css("display") != "none") {
+ none_shown = false;
+ return; // Shortcut checking any more fields
+ }
+ });
+ const $collapsibleElm = $expandableCard.find(".collapse");
+ if (none_shown) {
+ $collapsibleElm.closest(".card").hide();
+ } else {
+ $collapsibleElm.closest(".card").show();
+ }
+ }
+
+ // Trigger value check on any fields that depend on this one, e.g.
+ // if this one is now hidden then that will change its value to
+ // blank. Don't do this if the dependent field is the same as the field
+ // with the display condition.
+ if ($field.data("column-id") != $depends.data("column-id"))
+ $field.trigger("change");
+ };
+
+ // If the field depended on is not actually in the form (e.g. if the
+ // user doesn't have access to it) then treat it as an empty value and
+ // process as normal. Process immediately as the value won't change
+ if ($depends.length == 0) {
+ processChange();
}
- });
- const $collapsibleElm = $expandableCard.find(".collapse");
- if (none_shown) {
- $collapsibleElm.closest('.card').hide();
- } else {
- $collapsibleElm.closest('.card').show();
- }
- }
- // Trigger value check on any fields that depend on this one, e.g.
- // if this one is now hidden then that will change its value to
- // blank. Don't do this if the dependent field is the same as the field
- // with the display condition.
- if ($field.data('column-id') != $depends.data('column-id'))
- $field.trigger("change");
- };
-
- // If the field depended on is not actually in the form (e.g. if the
- // user doesn't have access to it) then treat it as an empty value and
- // process as normal. Process immediately as the value won't change
- if ($depends.length == 0) {
- processChange();
- }
-
- // Standard change of visible form field
- $depends.on("change", function() {
- processChange();
- });
- });
- }
+ // Standard change of visible form field
+ $depends.on("change", function () {
+ processChange();
+ });
+ });
+ }
}
-export default DependentFieldsComponent
+export default DependentFieldsComponent;
diff --git a/src/frontend/components/form-group/display-conditions/index.js b/src/frontend/components/form-group/display-conditions/index.js
index 748622717..e99a60db8 100644
--- a/src/frontend/components/form-group/display-conditions/index.js
+++ b/src/frontend/components/form-group/display-conditions/index.js
@@ -1,14 +1,14 @@
-import { initializeComponent, getComponentElements } from 'component'
+import { initializeComponent, getComponentElements } from "component";
export default (scope) => {
- if (!getComponentElements(scope, '.display-conditions').length) {
- return;
+ if (!getComponentElements(scope, ".display-conditions").length) {
+ return;
}
-
+
import(
- /* webpackChunkName: "display-conditions" */
- './lib/component'
+ /* webpackChunkName: "display-conditions" */
+ "./lib/component"
).then(({ default: Component }) => {
- initializeComponent(scope, '.display-conditions', Component)
+ initializeComponent(scope, ".display-conditions", Component);
});
- }
\ No newline at end of file
+};
\ No newline at end of file
diff --git a/src/frontend/components/form-group/display-conditions/lib/component.js b/src/frontend/components/form-group/display-conditions/lib/component.js
index 188c2b999..58c1b90bb 100644
--- a/src/frontend/components/form-group/display-conditions/lib/component.js
+++ b/src/frontend/components/form-group/display-conditions/lib/component.js
@@ -1,36 +1,45 @@
-import { Component } from 'component'
-import '@lol768/jquery-querybuilder-no-eval/dist/js/query-builder.standalone.min'
-import 'bootstrap-select/dist/js/bootstrap-select'
-import { refreshSelects } from 'components/form-group/common/bootstrap-select'
+import { Component } from "component";
+import { refreshSelects } from "components/form-group/searchable-select/FilterSelectHelper";
+import "jQuery-QueryBuilder/dist/js/query-builder.standalone";
+/**
+ * Component for managing display conditions in form groups.
+ */
class DisplayConditionsComponent extends Component {
- constructor(element) {
- super(element)
- this.el = $(this.element)
- this.initDisplayConditions()
- }
+ /**
+ * Create a new DisplayConditionsComponent.
+ * @param {HTMLElement} element The HTML element for the component.
+ */
+ constructor(element) {
+ super(element);
+ this.el = $(this.element);
+ this.initDisplayConditions();
+ }
- initDisplayConditions() {
- const builderData = this.el.data()
- const filters = JSON.parse(atob(builderData.filters))
- if (!filters.length) return
+ /**
+ * Initialize the display conditions for the form group.
+ */
+ initDisplayConditions() {
+ const builderData = this.el.data();
+ const filters = JSON.parse(atob(builderData.filters));
+ if (!filters.length) return;
- refreshSelects(this.el)
+ refreshSelects(this.el);
- this.el.queryBuilder({
- filters: filters,
- allow_groups: 0,
- operators: [
- { type: 'equal', accept_values: true, apply_to: ['string'] },
- { type: 'contains', accept_values: true, apply_to: ['string'] },
- { type: 'not_equal', accept_values: true, apply_to: ['string'] },
- { type: 'not_contains', accept_values: true, apply_to: ['string'] }
- ],
- allow_empty: true
- }).queryBuilder('setRules', builderData.filterBase
- ? JSON.parse(atob(builderData.filterBase))
- : {rules:[]});
- }
+ this.el.queryBuilder({
+ filters: filters,
+ allow_groups: 0,
+ operators: [
+ { type: "equal", accept_values: true, apply_to: ["string"] },
+ { type: "contains", accept_values: true, apply_to: ["string"] },
+ { type: "not_equal", accept_values: true, apply_to: ["string"] },
+ { type: "not_contains", accept_values: true, apply_to: ["string"] }
+ ],
+ allow_empty: true
+ }).queryBuilder("setRules", builderData.filterBase
+ ? JSON.parse(atob(builderData.filterBase))
+ : {rules:[]});
+ }
}
-export default DisplayConditionsComponent
+export default DisplayConditionsComponent;
diff --git a/src/frontend/components/form-group/field-length/index.js b/src/frontend/components/form-group/field-length/index.js
index 0e512d00d..d16502085 100644
--- a/src/frontend/components/form-group/field-length/index.js
+++ b/src/frontend/components/form-group/field-length/index.js
@@ -1,6 +1,6 @@
-import { initializeComponent } from "component"
-import FieldLengthComponent from "./lib/component"
+import { initializeComponent } from "component";
+import FieldLengthComponent from "./lib/component";
export default (scope) => {
initializeComponent(scope, "[data-max]", FieldLengthComponent);
-}
+};
diff --git a/src/frontend/components/form-group/field-length/lib/component.test.ts b/src/frontend/components/form-group/field-length/lib/component.test.ts
index f67264d6e..d2f77696d 100644
--- a/src/frontend/components/form-group/field-length/lib/component.test.ts
+++ b/src/frontend/components/form-group/field-length/lib/component.test.ts
@@ -1,89 +1,89 @@
-import {describe, it, expect} from "@jest/globals";
-import FieldLengthComponent from "./component";
+import {describe, it, expect} from '@jest/globals';
+import FieldLengthComponent from './component';
-describe("FieldLengthComponent", () => {
- it("should error when not passed the correct type of component", () => {
- const div = document.createElement("div");
- expect(() => new FieldLengthComponent(div)).toThrow("Element must be a textarea or input");
+describe('FieldLengthComponent', () => {
+ it('should error when not passed the correct type of component', () => {
+ const div = document.createElement('div');
+ expect(() => new FieldLengthComponent(div)).toThrow('Element must be a textarea or input');
});
- it("should not create a counter if the element lacks the data-max attribute", () => {
- const input = document.createElement("input");
+ it('should not create a counter if the element lacks the data-max attribute', () => {
+ const input = document.createElement('input');
document.body.appendChild(input);
new FieldLengthComponent(input);
const counter = input.nextElementSibling as HTMLElement;
expect(counter).toBeNull();
});
- it("should create a counter with the correct values when the input is empty", () => {
- const input = document.createElement("input");
- input.dataset.max = "10";
+ it('should create a counter with the correct values when the input is empty', () => {
+ const input = document.createElement('input');
+ input.dataset.max = '10';
document.body.appendChild(input);
new FieldLengthComponent(input);
const counter = input.nextElementSibling as HTMLElement;
expect(counter).not.toBeNull();
- expect(counter.textContent).toBe("0/10");
+ expect(counter.textContent).toBe('0/10');
});
- it("should create a counter with the correct values when the input has some text", () => {
- const input = document.createElement("input");
- input.dataset.max = "10";
- input.value = "Hello";
+ it('should create a counter with the correct values when the input has some text', () => {
+ const input = document.createElement('input');
+ input.dataset.max = '10';
+ input.value = 'Hello';
document.body.appendChild(input);
new FieldLengthComponent(input);
const counter = input.nextElementSibling as HTMLElement;
expect(counter).not.toBeNull();
- expect(counter.textContent).toBe("5/10");
+ expect(counter.textContent).toBe('5/10');
});
- it("should update the counter with the correct values when the input value changes", () => {
- const input = document.createElement("input");
- input.dataset.max = "10";
+ it('should update the counter with the correct values when the input value changes', () => {
+ const input = document.createElement('input');
+ input.dataset.max = '10';
document.body.appendChild(input);
new FieldLengthComponent(input);
const counter = input.nextElementSibling as HTMLElement;
expect(counter).not.toBeNull();
- input.value = "Hello";
- input.dispatchEvent(new Event("keyup"));
- expect(counter.textContent).toBe("5/10");
+ input.value = 'Hello';
+ input.dispatchEvent(new Event('keyup'));
+ expect(counter.textContent).toBe('5/10');
});
- it("should add the invalid class to the counter when the input value exceeds the max length", () => {
- const input = document.createElement("input");
- input.dataset.max = "10";
+ it('should add the invalid class to the counter when the input value exceeds the max length', () => {
+ const input = document.createElement('input');
+ input.dataset.max = '10';
document.body.appendChild(input);
new FieldLengthComponent(input);
const counter = input.nextElementSibling as HTMLElement;
expect(counter).not.toBeNull();
- input.value = "Hello World!";
- input.dispatchEvent(new Event("keyup"));
- expect(counter.classList.contains("invalid")).toBe(true);
+ input.value = 'Hello World!';
+ input.dispatchEvent(new Event('keyup'));
+ expect(counter.classList.contains('invalid')).toBe(true);
});
- it("should remove the invalid class from the counter when the input value is reduced to be within the max length", () => {
- const input = document.createElement("input");
- input.dataset.max = "10";
+ it('should remove the invalid class from the counter when the input value is reduced to be within the max length', () => {
+ const input = document.createElement('input');
+ input.dataset.max = '10';
document.body.appendChild(input);
new FieldLengthComponent(input);
const counter = input.nextElementSibling as HTMLElement;
expect(counter).not.toBeNull();
- input.value = "Hello World!";
- input.dispatchEvent(new Event("keyup"));
- expect(counter.classList.contains("invalid")).toBe(true);
- input.value = "Hello";
- input.dispatchEvent(new Event("keyup"));
- expect(counter.classList.contains("invalid")).toBe(false);
+ input.value = 'Hello World!';
+ input.dispatchEvent(new Event('keyup'));
+ expect(counter.classList.contains('invalid')).toBe(true);
+ input.value = 'Hello';
+ input.dispatchEvent(new Event('keyup'));
+ expect(counter.classList.contains('invalid')).toBe(false);
});
- it("Should work with textarea elements", () => {
- const textarea = document.createElement("textarea");
- textarea.dataset.max = "10";
+ it('Should work with textarea elements', () => {
+ const textarea = document.createElement('textarea');
+ textarea.dataset.max = '10';
document.body.appendChild(textarea);
new FieldLengthComponent(textarea);
const counter = textarea.nextElementSibling as HTMLElement;
expect(counter).not.toBeNull();
- textarea.value = "Hello World!";
- textarea.dispatchEvent(new Event("keyup"));
- expect(counter.classList.contains("invalid")).toBe(true);
+ textarea.value = 'Hello World!';
+ textarea.dispatchEvent(new Event('keyup'));
+ expect(counter.classList.contains('invalid')).toBe(true);
});
});
diff --git a/src/frontend/components/form-group/field-length/lib/component.ts b/src/frontend/components/form-group/field-length/lib/component.ts
index 3cc78c03f..aaed16b6e 100644
--- a/src/frontend/components/form-group/field-length/lib/component.ts
+++ b/src/frontend/components/form-group/field-length/lib/component.ts
@@ -1,6 +1,15 @@
import { Component } from "component";
+/**
+ * Component to display the character count on a text or input field with a max length.
+ * It will display the current character count and the max length, and will add an "invalid"
+ * class to the counter if the current length exceeds the max length.
+ */
export default class FieldLengthComponent extends Component {
+ /**
+ * Creates a new FieldLengthComponent instance.
+ * @param element The element to attach the counter to
+ */
constructor(element: HTMLElement) {
super(element);
if (!(element instanceof HTMLTextAreaElement) && !(element instanceof HTMLInputElement)) {
@@ -9,6 +18,9 @@ export default class FieldLengthComponent extends Component {
this.init();
}
+ /**
+ * Initializes the component by creating the counter and adding the event listener for keyup events.
+ */
private init() {
const input = this.element as HTMLTextAreaElement | HTMLInputElement;
if (!input) return;
@@ -17,6 +29,10 @@ export default class FieldLengthComponent extends Component {
input.addEventListener("keyup", () => this.updateCounter(input));
}
+ /**
+ * Create the counter element and insert it after the input field.
+ * @param input The input field to assign the counter to
+ */
private createCounter(input: HTMLTextAreaElement | HTMLInputElement) {
const max = parseInt(input.dataset.max || "0", 10);
const counter = document.createElement("div");
@@ -25,6 +41,10 @@ export default class FieldLengthComponent extends Component {
input.insertAdjacentElement("afterend", counter);
}
+ /**
+ * Update a counter when the data needs to be updated.
+ * @param input The input field to update the counter for
+ */
private updateCounter(input: HTMLTextAreaElement | HTMLInputElement) {
const max = parseInt(input.dataset.max || "0", 10);
const counter = input.nextElementSibling as HTMLElement;
@@ -38,4 +58,4 @@ export default class FieldLengthComponent extends Component {
counter.classList.remove("invalid");
}
}
-}
\ No newline at end of file
+}
diff --git a/src/frontend/components/form-group/filter/index.js b/src/frontend/components/form-group/filter/index.js
index e3b743958..a13da7b18 100644
--- a/src/frontend/components/form-group/filter/index.js
+++ b/src/frontend/components/form-group/filter/index.js
@@ -1,15 +1,14 @@
-import { initializeComponent, getComponentElements } from 'component'
+import { initializeComponent, getComponentElements } from "component";
export default (scope) => {
- if (!getComponentElements(scope, '.filter').length) {
- return;
+ if (!getComponentElements(scope, ".filter").length) {
+ return;
}
-
+
import(
- /* webpackChunkName: "filter" */
- './lib/component'
+ /* webpackChunkName: "filter" */
+ "./lib/component"
).then(({ default: Component }) => {
- initializeComponent(scope, '.filter', Component)
+ initializeComponent(scope, ".filter", Component);
});
- }
-
\ No newline at end of file
+};
diff --git a/src/frontend/components/form-group/filter/lib/component.js b/src/frontend/components/form-group/filter/lib/component.js
index 2f0142a0a..c53befaff 100644
--- a/src/frontend/components/form-group/filter/lib/component.js
+++ b/src/frontend/components/form-group/filter/lib/component.js
@@ -1,281 +1,328 @@
/* eslint-disable @typescript-eslint/no-this-alias */
-import { Component } from 'component'
-import '@lol768/jquery-querybuilder-no-eval/dist/js/query-builder.standalone.min'
-import 'bootstrap-select/dist/js/bootstrap-select'
-import { logging } from 'logging'
-import TypeaheadBuilder from 'util/typeahead'
-import { refreshSelects } from 'components/form-group/common/bootstrap-select'
+import { Component } from "component";
+import "jQuery-QueryBuilder/dist/js/query-builder.standalone";
+import { refreshSelects } from "components/form-group/searchable-select/FilterSelectHelper";
+import { logging } from "logging";
+import TypeaheadBuilder from "util/typeahead";
+/**
+ * FilterComponent class for managing filter functionality in a query builder.
+ */
class FilterComponent extends Component {
- constructor(element) {
- super(element)
- this.el = $(this.element)
- this.operators = [
- {
- type: 'equal',
- accept_values: true,
- apply_to: ['string', 'number', 'datetime']
- },
- {
- type: 'not_equal',
- accept_values: true,
- apply_to: ['string', 'number', 'datetime']
- },
- {
- type: 'less',
- accept_values: true,
- apply_to: ['string', 'number', 'datetime']
- },
- {
- type: 'less_or_equal',
- accept_values: true,
- apply_to: ['string', 'number', 'datetime']
- },
- {
- type: 'greater',
- accept_values: true,
- apply_to: ['string', 'number', 'datetime']
- },
- {
- type: 'greater_or_equal',
- accept_values: true,
- apply_to: ['string', 'number', 'datetime']
- },
- {
- type: 'contains',
- accept_values: true,
- apply_to: ['datetime', 'string']
- },
- {
- type: 'not_contains',
- accept_values: true,
- apply_to: ['datetime', 'string']
- },
- { type: 'begins_with', accept_values: true, apply_to: ['string'] },
- { type: 'not_begins_with', accept_values: true, apply_to: ['string'] },
- {
- type: 'is_empty',
- accept_values: false,
- apply_to: ['string', 'number', 'datetime']
- },
- {
- type: 'is_not_empty',
- accept_values: false,
- apply_to: ['string', 'number', 'datetime']
- },
- {
- type: 'changed_after',
- nb_inputs: 1,
- accept_values: true,
- multiple: false,
- apply_to: ['string', 'number', 'datetime']
- }
- ]
- this.ragProperties = {
- input: 'select',
- values: {
- b_red: 'Red',
- c_amber: 'Amber',
- c_yellow: 'Yellow',
- d_green: 'Green',
- a_grey: 'Grey',
- e_purple: 'Purple',
- d_blue: 'Blue',
- b_attention: 'Red (Attention)'
- }
+ /**
+ * Create an instance of FilterComponent
+ * @param {HTMLElement} element The HTML element that contains the filter component
+ */
+ constructor(element) {
+ super(element);
+ this.el = $(this.element);
+ this.operators = [
+ {
+ type: "equal",
+ accept_values: true,
+ apply_to: ["string", "number", "datetime"]
+ },
+ {
+ type: "not_equal",
+ accept_values: true,
+ apply_to: ["string", "number", "datetime"]
+ },
+ {
+ type: "less",
+ accept_values: true,
+ apply_to: ["string", "number", "datetime"]
+ },
+ {
+ type: "less_or_equal",
+ accept_values: true,
+ apply_to: ["string", "number", "datetime"]
+ },
+ {
+ type: "greater",
+ accept_values: true,
+ apply_to: ["string", "number", "datetime"]
+ },
+ {
+ type: "greater_or_equal",
+ accept_values: true,
+ apply_to: ["string", "number", "datetime"]
+ },
+ {
+ type: "contains",
+ accept_values: true,
+ apply_to: ["datetime", "string"]
+ },
+ {
+ type: "not_contains",
+ accept_values: true,
+ apply_to: ["datetime", "string"]
+ },
+ { type: "begins_with", accept_values: true, apply_to: ["string"] },
+ { type: "not_begins_with", accept_values: true, apply_to: ["string"] },
+ {
+ type: "is_empty",
+ accept_values: false,
+ apply_to: ["string", "number", "datetime"]
+ },
+ {
+ type: "is_not_empty",
+ accept_values: false,
+ apply_to: ["string", "number", "datetime"]
+ },
+ {
+ type: "changed_after",
+ nb_inputs: 1,
+ accept_values: true,
+ multiple: false,
+ apply_to: ["string", "number", "datetime"]
+ }
+ ];
+ this.ragProperties = {
+ input: "select",
+ values: {
+ b_red: "Red",
+ c_amber: "Amber",
+ c_yellow: "Yellow",
+ d_green: "Green",
+ a_grey: "Grey",
+ e_purple: "Purple",
+ d_blue: "Blue",
+ b_attention: "Red (Attention)"
+ }
+ };
+
+ this.initFilter();
}
- this.initFilter()
- }
+ /**
+ * Initializes the filter component by setting up the query builder
+ */
+ initFilter() {
+ const self = this;
+ const $builderEl = this.el;
+ const builderID = $(this.el).data("builder-id");
+ const $builderJSON = $(`#builder_json_${builderID}`);
- initFilter() {
- const self = this
- const $builderEl = this.el
- const builderID = $(this.el).data('builder-id')
- const $builderJSON = $(`#builder_json_${builderID}`)
+ if (!$builderJSON.length) return;
- if (!$builderJSON.length) return
+ const builderConfig = JSON.parse($builderJSON.html());
+ const filterBase = $builderEl.data("filter-base");
- const builderConfig = JSON.parse($builderJSON.html())
- const filterBase = $builderEl.data('filter-base')
+ if (!builderConfig.filters.length) return;
+ if (builderConfig.filterNotDone) this.makeUpdateFilter();
- if (!builderConfig.filters.length) return
- if (builderConfig.filterNotDone) this.makeUpdateFilter()
+ refreshSelects(this.el);
- refreshSelects(this.el);
+ $builderEl.queryBuilder({
+ showPreviousValues: builderConfig.showPreviousValues,
+ filters: builderConfig.filters.map(col =>
+ this.buildFilter(builderConfig, col)
+ ),
+ allow_empty: true,
+ operators: this.operators,
+ lang: {
+ operators: {
+ changed_after: "changed on or after"
+ }
+ }
+ });
- $builderEl.queryBuilder({
- showPreviousValues: builderConfig.showPreviousValues,
- filters: builderConfig.filters.map(col =>
- this.buildFilter(builderConfig, col)
- ),
- allow_empty: true,
- operators: this.operators,
- lang: {
- operators: {
- changed_after: 'changed on or after'
- }
- }
- })
+ $builderEl.on("validationError.queryBuilder", function (e, node, error, value) {
+ logging.log(error);
+ logging.log(value);
+ logging.log(e);
+ logging.log(node);
+ });
- $builderEl.on('validationError.queryBuilder', function(e, node, error, value) {
- logging.log(error);
- logging.log(value);
- logging.log(e);
- logging.log(node);
- });
+ $builderEl.on("afterCreateRuleInput.queryBuilder", function (e, rule) {
+ let filterConfig;
- $builderEl.on('afterCreateRuleInput.queryBuilder', function(e, rule) {
- let filterConfig
+ builderConfig.filters.forEach(function (value) {
+ if (value.filterId === rule.filter.id) {
+ filterConfig = value;
+ return false;
+ }
+ });
- builderConfig.filters.forEach(function(value) {
- if (value.filterId === rule.filter.id) {
- filterConfig = value
- return false
- }
- })
+ if (!filterConfig || filterConfig.type === "rag" || !filterConfig.hasFilterTypeahead) {
+ return;
+ }
- if (!filterConfig || filterConfig.type === 'rag' || !filterConfig.hasFilterTypeahead) {
- return
- }
+ const $ruleInputText = $(
+ `#${rule.id} .rule-value-container input[type='text']`
+ );
- const $ruleInputText = $(
- `#${rule.id} .rule-value-container input[type='text']`
- )
+ const $ruleInputHidden = $(
+ `#${rule.id} .rule-value-container input[type='hidden']`
+ );
- const $ruleInputHidden = $(
- `#${rule.id} .rule-value-container input[type='hidden']`
- )
+ $ruleInputText.attr("autocomplete", "off");
- $ruleInputText.attr('autocomplete', 'off')
+ $ruleInputText.on("keyup", () => {
+ $ruleInputHidden.val($ruleInputText.val());
+ });
- $ruleInputText.on('keyup', () => {
- $ruleInputHidden.val($ruleInputText.val())
- })
+ const filterCallback = (suggestion) => {
+ if (filterConfig.useIdInFilter) {
+ $ruleInputHidden.val(suggestion.id);
+ } else {
+ $ruleInputHidden.val(suggestion.name);
+ }
+ };
- const filterCallback = (suggestion) => {
- if(filterConfig.useIdInFilter) {
- $ruleInputHidden.val(suggestion.id)
- }else {
- $ruleInputHidden.val(suggestion.name)
- }
- }
+ // This is required to ensure that the correct query is sent each time
+ const buildQuery = () => {
+ return {
+ q: $ruleInputText.val(),
+ oi: filterConfig.instanceId,
+ csrf_token: $("body").data("csrf")
+ };
+ };
- // This is required to ensure that the correct query is sent each time
- const buildQuery = () => {return {q:$ruleInputText.val(), oi:filterConfig.instanceId, csrf_token: $('body').data('csrf')}}
+ const builder = new TypeaheadBuilder();
+ builder
+ .withInput($ruleInputText)
+ .withAjaxSource(self.getURL(builderConfig.layoutId, filterConfig.urlSuffix))
+ .withMethod("POST")
+ .withDataBuilder(buildQuery)
+ .withDefaultMapper()
+ .withName("rule")
+ .withAppendQuery()
+ .withCallback(filterCallback)
+ .build();
+ });
- const builder = new TypeaheadBuilder();
- builder
- .withInput($ruleInputText)
- .withAjaxSource(self.getURL(builderConfig.layoutId, filterConfig.urlSuffix))
- .withMethod('POST')
- .withDataBuilder(buildQuery)
- .withDefaultMapper()
- .withName('rule')
- .withAppendQuery()
- .withCallback(filterCallback)
- .build()
- })
-
- if(filterBase) {
- const data = atob(filterBase, 'base64')
- try {
- const obj = JSON.parse(data);
- if (obj.rules && obj.rules.length) {
- $builderEl.queryBuilder('setRules', obj)
- } else {
- // Ensure that no blank rules by default, otherwise view cannot be submitted
- $builderEl.queryBuilder('setRules', {rules:[]})
+ if (filterBase) {
+ const data = atob(filterBase, "base64");
+ try {
+ const obj = JSON.parse(data);
+ if (obj.rules && obj.rules.length) {
+ $builderEl.queryBuilder("setRules", obj);
+ } else {
+ // Ensure that no blank rules by default, otherwise view cannot be submitted
+ $builderEl.queryBuilder("setRules", { rules: [] });
+ }
+ } catch {
+ logging.log("Incorrect data object passed to queryBuilder");
+ }
}
- } catch (error) {
- logging.log('Incorrect data object passed to queryBuilder')
- }
- } else {
- $builderEl.queryBuilder('setRules', {rules:[]});// Ensure that no blank rules by default, otherwise view cannot be submitted
}
- }
- getURL(layoutId, urlSuffix) {
- const devEndpoint = window.siteConfig && window.siteConfig.urls.filterApi
+ /**
+ * Get the URL for the filter API
+ * @param {number} layoutId The ID of the layout
+ * @param {string} urlSuffix The suffix for the URL to fetch filter data
+ * @returns {string} The complete URL for the filter API
+ */
+ getURL(layoutId, urlSuffix) {
+ const devEndpoint = window.siteConfig && window.siteConfig.urls.filterApi;
- if (devEndpoint) {
- return devEndpoint
- } else {
- return `/${layoutId}/match/layout/${urlSuffix}?q=`
+ if (devEndpoint) {
+ return devEndpoint;
+ } else {
+ return `/${layoutId}/match/layout/${urlSuffix}?q=`;
+ }
}
- }
- makeUpdateFilter() {
- window.UpdateFilter = (builder, ev) => {
- if (!builder.queryBuilder('validate')) ev.preventDefault();
- const res = builder.queryBuilder('getRules')
- $('#filter').val(JSON.stringify(res, null, 2))
+ /**
+ * Creates a global function to update the filter
+ */
+ makeUpdateFilter() {
+ window.UpdateFilter = (builder, ev) => {
+ if (!builder.queryBuilder("validate")) ev.preventDefault();
+ const res = builder.queryBuilder("getRules");
+ $("#filter").val(JSON.stringify(res, null, 2));
+ };
}
- }
- buildFilter = (builderConfig, col) => {
- return ({
- id: col.filterId,
- label: col.label,
- type: 'string',
- operators: this.buildFilterOperators(col.type),
- ...(col.type === 'rag'
- ? this.ragProperties
- : col.hasFilterTypeahead
- ? this.typeaheadProperties
- : {})
- })
-}
+ /**
+ * Build the filter object for the query builder
+ * @param {object} builderConfig The configuration object for the query builder
+ * @param {object} col The column object containing filter properties
+ * @returns {object} The filter object to be used in the query builder
+ */
+ buildFilter = (builderConfig, col) => {
+ return ({
+ id: col.filterId,
+ label: col.label,
+ type: "string",
+ operators: this.buildFilterOperators(col.type),
+ ...(col.type === "rag"
+ ? this.ragProperties
+ : col.hasFilterTypeahead
+ ? this.typeaheadProperties
+ : {})
+ });
+ };
- buildFilterOperators(type) {
- if (!['date', 'daterange'].includes(type)) return undefined
- const operators = [
- 'equal',
- 'not_equal',
- 'less',
- 'less_or_equal',
- 'greater',
- 'greater_or_equal',
- 'is_empty',
- 'is_not_empty'
- ]
- type === 'daterange' && operators.push('contain')
- return operators
- }
+ /**
+ * Build the operators for a filter based on its type
+ * @param {string} type The type of the filter (e.g., 'date', 'daterange')
+ * @returns {string[]} An array of operators for the filter type
+ */
+ buildFilterOperators(type) {
+ if (!["date", "daterange"].includes(type)) return undefined;
+ const operators = [
+ "equal",
+ "not_equal",
+ "less",
+ "less_or_equal",
+ "greater",
+ "greater_or_equal",
+ "is_empty",
+ "is_not_empty"
+ ];
+ if (type === "daterange") {
+ operators.push("contain");
+ }
+ return operators;
+ }
- get typeaheadProperties() {
- return {
- input: (container, input_name) => {
- return (
- `
+ /**
+ * Get the properties for the typeahead used on this component
+ * @returns {object} Returns an object that renders the typeahead as expected
+ */
+ get typeaheadProperties() {
+ return {
+ input: (container, input_name) => {
+ return (
+ `
`
- )
- },
- valueSetter: (rule, value) => {
- rule.$el.find('.typeahead_hidden').val(value)
- const typeahead = rule.$el.find('.typeahead_text')
- typeahead.typeahead('val',rule.data.text)
- typeahead.val(rule.data.text)
- },
- validation: {
- callback: () => {return true}
- }
+ );
+ },
+ valueSetter: (rule, value) => {
+ rule.$el.find(".typeahead_hidden").val(value);
+ const typeahead = rule.$el.find(".typeahead_text");
+ typeahead.typeahead("val", rule.data.text);
+ typeahead.val(rule.data.text);
+ },
+ validation: {
+ callback: () => { return true; }
+ }
+ };
}
- }
- getRecords = (layoutId, urlSuffix, instanceId, query) => {
- return (
- $.ajax({
- type: 'POST',
- url: this.getURL(layoutId, urlSuffix),
- data: { q: query, oi: instanceId },
- dataType: 'json',
- path: 'records'
- })
- )
- }
+ /**
+ * Get the records based on the filter criteria
+ * @param {number} layoutId The ID of the layout
+ * @param {string} urlSuffix The suffix for the URL to fetch records
+ * @param {number} instanceId The ID of the instance
+ * @param {string} query The query string to filter records
+ * @returns {JQuery.jqXHR} An AJAX promise that resolves with the records
+ */
+ getRecords = (layoutId, urlSuffix, instanceId, query) => {
+ return (
+ $.ajax({
+ type: "POST",
+ url: this.getURL(layoutId, urlSuffix),
+ data: { q: query, oi: instanceId },
+ dataType: "json",
+ path: "records"
+ })
+ );
+ };
}
-export default FilterComponent
+export default FilterComponent;
diff --git a/src/frontend/components/form-group/input/_input.scss b/src/frontend/components/form-group/input/_input.scss
index 46c2bce87..a4ce7db9f 100644
--- a/src/frontend/components/form-group/input/_input.scss
+++ b/src/frontend/components/form-group/input/_input.scss
@@ -31,6 +31,10 @@
margin-top: 0.5rem;
}
+
+ p {
+ color: $secondary;
+ }
}
.input__field {
@@ -206,7 +210,7 @@
color: $brand-success;
text-align: center;
vertical-align: middle;
-
+
.progress-bar__progress {
background-color: rgba($brand-success, 0.2);
.progress-bar__percentage {
@@ -240,4 +244,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/frontend/components/form-group/input/index.js b/src/frontend/components/form-group/input/index.js
index 762161fc2..27cd3a5b8 100644
--- a/src/frontend/components/form-group/input/index.js
+++ b/src/frontend/components/form-group/input/index.js
@@ -1,4 +1,4 @@
-import { initializeComponent } from 'component'
-import InputComponent from './lib/component'
+import { initializeComponent } from "component";
+import InputComponent from "./lib/component";
-export default (scope) => initializeComponent(scope, '.input', InputComponent)
+export default (scope) => initializeComponent(scope, ".input", InputComponent);
diff --git a/src/frontend/components/form-group/input/lib/autocompleteComponent.ts b/src/frontend/components/form-group/input/lib/autocompleteComponent.ts
index b8900bc86..182f77422 100644
--- a/src/frontend/components/form-group/input/lib/autocompleteComponent.ts
+++ b/src/frontend/components/form-group/input/lib/autocompleteComponent.ts
@@ -1,19 +1,29 @@
+/**
+ * A component for handling typeahead functionality
+ */
class AutocompleteComponent {
- readonly type = 'autocomplete';
+ readonly type = "autocomplete";
input: JQuery;
el: JQuery;
+ /**
+ * Create an instance of AutocompleteComponent
+ * @param {HTMLElement | JQuery} el The HTML element or jQuery object that contains the input field for autocomplete
+ */
constructor(el: HTMLElement | JQuery) {
this.el = $(el);
- this.input = this.el.find('.form-control');
+ this.input = this.el.find(".form-control");
}
+ /**
+ * Initializes the autocomplete functionality by setting up the typeahead
+ */
init() {
- const suggestionCallback = (suggestion: { id: number, name:string }) => {
- this.el.find('input[type="hidden"]').val(suggestion.id);
+ const suggestionCallback = (suggestion: { id: number, name: string }) => {
+ this.el.find("input[type=\"hidden\"]").val(suggestion.id);
};
- import(/* webpackChunkName: "typeahead" */ 'util/typeahead')
+ import(/* webpackChunkName: "typeahead" */ "util/typeahead")
.then(({ default: TypeaheadBuilder }) => {
const builder = new TypeaheadBuilder();
builder
@@ -21,20 +31,28 @@ class AutocompleteComponent {
.withCallback(suggestionCallback)
.withAjaxSource(this.getURL())
.withAppendQuery()
- .withName('users')
+ .withName("users")
.build();
});
}
+ /**
+ * Create the URL for the autocomplete API endpoint
+ * @returns {string} The URL for the autocomplete API endpoint
+ */
getURL(): string {
//@ts-expect-error "Testing code used by Digitpaint."
const devEndpoint = window.siteConfig?.urls?.autocompleteApi;
- const layoutIdentifier = $('body').data('layout-identifier');
+ const layoutIdentifier = $("body").data("layout-identifier");
- return devEndpoint ?? `/${layoutIdentifier ? layoutIdentifier + '/' : ''}match/user/?q=`;
+ return devEndpoint ?? `/${layoutIdentifier ? layoutIdentifier + "/" : ""}match/user/?q=`;
}
}
+/**
+ * Creates an instance of AutocompleteComponent and initializes it
+ * @param {HTMLElement | JQuery} el The HTML element or jQuery object that contains the input field for autocomplete
+ */
export default function autocompleteComponent(el: HTMLElement | JQuery) {
new AutocompleteComponent(el).init();
}
diff --git a/src/frontend/components/form-group/input/lib/component.ts b/src/frontend/components/form-group/input/lib/component.ts
index 7a4a2c9a8..9eb6fba2f 100644
--- a/src/frontend/components/form-group/input/lib/component.ts
+++ b/src/frontend/components/form-group/input/lib/component.ts
@@ -7,24 +7,46 @@ import dateComponent from "./dateComponent";
import autocompleteComponent from "./autocompleteComponent";
import { initValidationOnField } from "validation";
+/**
+ * ComponentInitializer type for functions that initialize specific input components.
+ * @param {JQuery | HTMLElement} element The element to initialize the component on, can be a jQuery object or a native HTMLElement.
+ */
type ComponentInitializer = (element: JQuery | HTMLElement) => void;
+/**
+ * InputComponent class to handle various input types.
+ * It initializes the appropriate component based on the class of the element.
+ */
class InputComponent extends Component {
+ /**
+ * Map of component class names to their respective initializers.
+ * This allows for dynamic initialization of components based on the class of the element.
+ * @type { {[key: string]: ComponentInitializer} }
+ * @private
+ * @static
+ */
private static componentMap: { [key: string]: ComponentInitializer } = {
- 'input--password': passwordComponent,
- 'input--logo': logoComponent,
- 'input--document': documentComponent,
- 'input--file': fileComponent,
- 'input--datepicker': dateComponent,
- 'input--autocomplete': autocompleteComponent
+ "input--password": passwordComponent,
+ "input--logo": logoComponent,
+ "input--document": documentComponent,
+ "input--file": fileComponent,
+ "input--datepicker": dateComponent,
+ "input--autocomplete": autocompleteComponent
};
+ /**
+ * Create an instance of InputComponent.
+ * @param {HTMLElement | JQuery} element The HTML element or jQuery object to initialize the component on.
+ */
constructor(element: HTMLElement | JQuery) {
- super(element);
+ super(element instanceof HTMLElement ? element : element[0]);
this.initializeComponent();
this.initializeValidation();
}
+ /**
+ * Initializes the component based on the class of the element.
+ */
private initializeComponent() {
const $el = $(this.element);
@@ -36,10 +58,13 @@ class InputComponent extends Component {
}
}
+ /**
+ * Initializes validation on the input field if it has the 'input--required' class.
+ */
private initializeValidation() {
const $el = $(this.element);
- if ($el.hasClass('input--required')) {
+ if ($el.hasClass("input--required")) {
initValidationOnField($el);
}
}
diff --git a/src/frontend/components/form-group/input/lib/dateComponent.ts b/src/frontend/components/form-group/input/lib/dateComponent.ts
index 08ac4b24b..8d1fd5994 100644
--- a/src/frontend/components/form-group/input/lib/dateComponent.ts
+++ b/src/frontend/components/form-group/input/lib/dateComponent.ts
@@ -1,20 +1,34 @@
import initDateField from "../../../datepicker/lib/helper";
+/**
+ * DateComponent class to handle date input fields.
+ */
class DateComponent {
- readonly type = 'date';
+ readonly type = "date";
el: JQuery;
input: JQuery;
+ /**
+ * Creates an instance of DateComponent.
+ * @param {JQuery | HTMLElement} el The element to initialize the date component on, can be a jQuery object or a native HTMLElement.
+ */
constructor(el: JQuery | HTMLElement) {
this.el = el instanceof HTMLElement ? $(el) : el;
- this.input = this.el.find('.form-control');
+ this.input = this.el.find(".form-control");
}
+ /**
+ * Initializes the date input field. This is really just a wrapper around the datepicker initialization.
+ */
init() {
initDateField(this.input);
}
}
+/**
+ * Create and initialize a date component.
+ * @param {JQuery | HTMLElement} el The element to initialize the date component on, can be a jQuery object or a native HTMLElement.
+ */
export default function dateComponent(el: JQuery | HTMLElement) {
(new DateComponent(el)).init();
}
diff --git a/src/frontend/components/form-group/input/lib/documentComponent.ts b/src/frontend/components/form-group/input/lib/documentComponent.ts
index 7c47c4bee..f3affaea1 100644
--- a/src/frontend/components/form-group/input/lib/documentComponent.ts
+++ b/src/frontend/components/form-group/input/lib/documentComponent.ts
@@ -1,95 +1,145 @@
-import 'components/button/lib/rename-button';
-import 'util/filedrag';
-import { upload } from 'util/upload/UploadControl';
-import { validateCheckboxGroup } from 'validation';
-import { formdataMapper } from 'util/mapper/formdataMapper';
-import { logging } from 'logging';
-import { RenameEvent } from 'components/button/lib/rename-button';
-import { FileDropEvent } from 'util/filedrag';
-import ErrorHandler from 'util/errorHandler';
+import "components/button/lib/rename-button";
+import "util/filedrag";
+import { upload } from "util/upload/UploadControl";
+import { validateCheckboxGroup } from "validation";
+import { formdataMapper } from "util/mapper/formdataMapper";
+import { RenameEvent } from "components/button/lib/rename-button";
+import { FileDropEvent } from "util/filedrag";
+import ErrorHandler from "util/errorHandler";
+/**
+ * Interface for the file data returned from the server.
+ */
interface FileData {
+ /**
+ * Identifier for the file.
+ * @type {number | string}
+ */
id: number | string;
+ /**
+ * Name of the file.
+ * @type {string}
+ */
filename: string;
}
+/**
+ * Interface for the response received after renaming a file.
+ */
interface RenameResponse {
+ /**
+ * Identifier for the file.
+ * @type {number | string}
+ */
id: number | string;
+ /**
+ * Name of the file after renaming.
+ * @type {string}
+ */
name: string;
+ /**
+ * Indicates whether the operation was successful.
+ * @type {boolean}
+ */
is_ok: boolean;
}
+/**
+ * DocumentComponent class for handling document upload functionality.
+ */
class DocumentComponent {
- readonly type = 'document';
+ readonly type = "document";
readonly el: JQuery;
readonly fileInput: JQuery;
- errors: (string|Error)[];
- handler: ErrorHandler;
+ errors!: (string|Error)[];
+ handler!: ErrorHandler;
+ /**
+ * Create a new DocumentComponent.
+ * @param {JQuery | HTMLElement} el The HTML element for the document component, can be a jQuery object or a plain HTMLElement.
+ */
constructor(el: JQuery | HTMLElement) {
this.el = $(el);
- this.el.closest('.fieldset').find('.rename').renameButton().on('rename', async (ev: RenameEvent) => {
- if (!ev) throw new Error("e is not a RenameEvent - this shouldn't happen!")
- const $target = $(ev.target);
- await this.renameFile($target.data('field-id'), ev.oldName, ev.newName, $('body').data('csrf'));
- });
- this.fileInput = this.el.find('.form-control-file');
+ this.el.closest(".fieldset").find(".rename")
+ .renameButton()
+ .on("rename", async (ev: RenameEvent) => {
+ if (!ev) throw new Error("e is not a RenameEvent - this shouldn't happen!");
+ const $target = $(ev.target);
+ await this.renameFile($target.data("field-id"), ev.oldName, ev.newName, $("body").data("csrf"));
+ });
+ this.fileInput = this.el.find(".form-control-file");
}
+ /**
+ * Initialize the document component by setting up event listeners and drag-and-drop functionality.
+ * @throws {Error} If the file upload element cannot be found.
+ */
init() {
- const url = this.el.data('fileupload-url');
+ const url = this.el.data("fileupload-url");
- const tokenField = this.el.closest('form').find('input[name="csrf_token"]');
+ const tokenField = this.el.closest("form").find("input[name=\"csrf_token\"]");
const csrf_token = tokenField.val() as string;
- const dropTarget = this.el.closest('.file-upload');
+ const dropTarget = this.el.closest(".file-upload");
- const columnId = this.el.closest('.linkspace-field')?.data('column-id') ?? 0;
- this.handler = new ErrorHandler(this.el.find('.error-messages')[0]);
+ const columnId = this.el.closest(".linkspace-field")?.data("column-id") ?? 0;
+ this.handler = new ErrorHandler(this.el.find(".error-messages")[0]);
if (dropTarget) {
const dragOptions = { allowMultiple: true };
- dropTarget.filedrag(dragOptions).on('fileDrop', ({ file }: FileDropEvent) => {
+ dropTarget.filedrag(dragOptions).on("fileDrop", ({ file }: FileDropEvent) => {
this.handler.clearErrors();
this.handleAjaxUpload(url, csrf_token, file, columnId);
});
} else {
- throw new Error('Could not find file-upload element');
+ throw new Error("Could not find file-upload element");
}
- this.fileInput.on('change', (ev) => {
+ this.fileInput.on("change", (ev) => {
if (!(ev.target instanceof HTMLInputElement)) {
- throw new Error('Could not find file-upload element');
+ throw new Error("Could not find file-upload element");
}
const file = ev.target.files![0];
if (!file || file === undefined || !file.name) return;
const formData = formdataMapper({ file, csrf_token, column_id: columnId });
- upload(url, formData, 'POST', (loaded, total) => this.showProgress(file.name, loaded, total)).then((data)=>{
+ upload(url, formData, "POST", (loaded, total) => this.showProgress(file.name, loaded, total)).then((data)=>{
this.addFileToField({ id: data.id, name: data.filename });
- }).catch((e) => {
- if(JSON.parse(e as string)?.message)
- e = JSON.parse(e as string).message;
- this.handler.addError(e);
- });
+ })
+ .catch((e) => {
+ if(JSON.parse(e as string)?.message)
+ e = JSON.parse(e as string).message;
+ this.handler.addError(e);
+ });
});
}
+ /**
+ * Show the progress of the file upload.
+ * @param {string} file The file the progress bar is for
+ * @param {number} loaded The number of bytes loaded so far.
+ * @param {number} total The total number of bytes to be loaded.
+ */
showProgress(file: string, loaded: number, total: number) {
let uploadProgression = Math.round((loaded / total) * 100);
if (uploadProgression == Infinity) {
// This will occur when there is an error uploading the file or the file is empty
uploadProgression = 100;
}
- let barContainer = this.el?.find('.progress-bar__container[data-file-name="' + file + '"] ');
+ let barContainer = this.el?.find(".progress-bar__container[data-file-name=\"" + file + "\"] ");
if (!barContainer || barContainer.length < 1) {
this.createProgressBar(this.el, file);
- barContainer = this.el.find('.progress-bar__container[data-file-name="' + file + '"]');
+ barContainer = this.el.find(".progress-bar__container[data-file-name=\"" + file + "\"]");
}
- barContainer.css('width', undefined)
- barContainer.find('.progress-bar__percentage').html(uploadProgression === 100 ? 'complete' : `${uploadProgression}%`);
- barContainer.find('.progress-bar__progress').css('width', `${uploadProgression}%`);
+ barContainer.css("width", undefined);
+ barContainer.find(".progress-bar__percentage").html(uploadProgression === 100 ? "complete" : `${uploadProgression}%`);
+ barContainer.find(".progress-bar__progress").css("width", `${uploadProgression}%`);
}
+ /**
+ * Create a progress bar for the file upload
+ * @param el The HTML element to create the progressbar in
+ * @param file The name of the file the progress bar is for
+ */
createProgressBar(el: JQuery, file: string) {
const progressBar = $(`
@@ -103,17 +153,24 @@ class DocumentComponent {
progressBar.show();
}
- handleAjaxUpload(uri: string, csrf_token: string, file: File, columnId: number) {
+ /**
+ * Upload a file via AJAX.
+ * @param {string} uri The URI to which the file will be uploaded.
+ * @param {string} csrf_token The CSRF token for security.
+ * @param {File} file The file to be uploaded.
+ * @param {number} columnId The column ID the upload is for
+ */
+ async handleAjaxUpload(uri: string, csrf_token: string, file: File, columnId: number) {
try {
- if (!file) this.showException(new Error('No file provided'));
+ if (!file) this.showException(new Error("No file provided"));
const fileData = formdataMapper({ file, csrf_token, column_id: columnId });
- upload(uri, fileData, 'POST', (loaded, total) => this.showProgress(file.name, loaded, total)).then((data) => {
+ upload(uri, fileData, "POST", (loaded, total) => this.showProgress(file.name, loaded, total)).then((data) => {
this.addFileToField({ id: data.id, name: data.filename });
}).then(
- () => {
- $(this.el.find('.progress-bar__container[data-file-name="' + file.name + '"]'))
+ () => {
+ $(this.el.find(".progress-bar__container[data-file-name=\"" + file.name + "\"]"))
.hide();
}
).catch((e) => {
@@ -122,7 +179,7 @@ class DocumentComponent {
else if (typeof e == "object" && "message" in e)
e = e.message;
this.handler.addError(e);
- $(this.el.find('.progress-bar__container[data-file-name="' + file.name + '"]'))
+ $(this.el.find(".progress-bar__container[data-file-name=\"" + file.name + "\"]"))
.hide();
});
} catch (e) {
@@ -130,15 +187,21 @@ class DocumentComponent {
}
}
+ /**
+ * Add a file to the field.
+ * @param {object} file The file to be added to the field.
+ * @param {number | string} file.id The ID of the file.
+ * @param {string} file.name The name of the file.
+ */
addFileToField(file: { id: number | string; name: string }) {
- const $fieldset = this.el.closest('.fieldset');
- const $ul = $fieldset.find('.fileupload__files');
+ const $fieldset = this.el.closest(".fieldset");
+ const $ul = $fieldset.find(".fileupload__files");
const fileId = file.id;
const fileName = file.name;
- const field = $fieldset.find('.input--file').data('field');
- const csrf_token = $('body').data('csrf');
+ const field = $fieldset.find(".input--file").data("field");
+ const csrf_token = $("body").data("csrf");
- if (!this.el || !this.el.length || !this.el.closest('.linkspace-field').data('is-multivalue')) {
+ if (!this.el || !this.el.length || !this.el.closest(".linkspace-field").data("is-multivalue")) {
$ul.empty();
}
@@ -151,7 +214,7 @@ class DocumentComponent {
${fileName}
-
@@ -159,31 +222,40 @@ class DocumentComponent {
`);
$ul.append($li);
- $ul.closest('.linkspace-field').trigger('change');
- validateCheckboxGroup($fieldset.find('.list'));
- $fieldset.find('input[type="file"]').removeAttr('required');
+ $ul.closest(".linkspace-field").trigger("change");
+ validateCheckboxGroup($fieldset.find(".list"));
+ $fieldset.find("input[type=\"file\"]").removeAttr("required");
const button = `.rename[data-field-id="${file.id}"]`;
const $button = $(button);
- $button.renameButton().on('rename', async (ev: RenameEvent) => {
+ $button.renameButton().on("rename", async (ev: RenameEvent) => {
await this.renameFile(fileId as number ?? parseInt(fileId.toString()), ev.oldName, ev.newName, csrf_token, true);
});
}
+ /**
+ * Rename a file.
+ * @param {number} fileId The ID of the file to be renamed.
+ * @param {string} oldName The current name of the file.
+ * @param {string} newName The new name for the file.
+ * @param {string} csrf_token The CSRF token for security.
+ * @param {boolean} is_new Indicates if the file is new (default is false).
+ */
private async renameFile(fileId: number, oldName: string, newName: string, csrf_token: string, is_new: boolean = false) { // for some reason using the ev.target doesn't allow for changing of the data attribute - I don't know why, so I've used the button itself
try {
const filename = newName;
const url = `/api/file/${fileId}`;
const mappedData = formdataMapper({ csrf_token, filename, is_new: is_new ? 1 : 0 });
- const data = await upload(url, mappedData, 'PUT')
+ const data = await upload(url, mappedData, "PUT");
if (is_new) {
$(`#current-${fileId}`).text(data.name);
} else {
- $(`#current-${fileId}`).closest('li').remove();
+ $(`#current-${fileId}`).closest("li")
+ .remove();
const { id, name } = data;
this.addFileToField({ id, name });
}
} catch (error) {
- let e=error
+ let e=error;
if(JSON.parse(error as string)?.message)
e = JSON.parse(error as string).message;
else if (typeof error == "object" && "message" in error)
@@ -194,16 +266,21 @@ class DocumentComponent {
}
}
- showException(e: any) {
- this.handler.addError(e instanceof Error ? e.message : typeof e == "object" && "message" in e ? e.message : e.toString());
+ /**
+ * Show any errors client-side.
+ * @param e The error to be shown, can be a string or an Error object.
+ */
+ showException(e: string | Error) {
+ this.handler.addError(e);
}
}
/**
* Create a new document component
* @param {JQuery | HTMLElement} el The element to attach the document component to
+ * @returns {DocumentComponent} The initialized document component
*/
-export default function documentComponent(el: JQuery | HTMLElement) {
+export default function documentComponent(el: JQuery | HTMLElement): DocumentComponent {
const component = new DocumentComponent(el);
component.init();
return component;
diff --git a/src/frontend/components/form-group/input/lib/fileComponent.ts b/src/frontend/components/form-group/input/lib/fileComponent.ts
index 5a8af7d19..09bd2c852 100644
--- a/src/frontend/components/form-group/input/lib/fileComponent.ts
+++ b/src/frontend/components/form-group/input/lib/fileComponent.ts
@@ -1,8 +1,11 @@
-import { logging } from 'logging';
-import { FileDropEvent } from 'util/filedrag';
-import { formdataMapper } from 'util/mapper/formdataMapper';
-import { upload } from 'util/upload/UploadControl';
+import { logging } from "logging";
+import { FileDropEvent } from "util/filedrag";
+import { formdataMapper } from "util/mapper/formdataMapper";
+import { upload } from "util/upload/UploadControl";
+/**
+ * FileComponent class for handling file upload functionality.
+ */
class FileComponent {
el: JQuery;
fileInput: JQuery;
@@ -10,47 +13,59 @@ class FileComponent {
fileDelete: JQuery;
inputFileLabel: JQuery;
- protected readonly type = 'file';
+ protected readonly type = "file";
+ /**
+ * Create a new FileComponent.
+ * @param {HTMLElement | JQuery} el The HTML element for the file component, can be a jQuery object or a plain HTMLElement.
+ */
constructor(el: HTMLElement | JQuery) {
this.el = el instanceof HTMLElement ? $(el) : el;
- this.fileInput = this.el.find('.form-control-file') as JQuery;
- this.fileName = this.el.find('.file__name');
- this.fileDelete = this.el.find('.file__delete');
- this.inputFileLabel = this.el.find('.input__file-label');
+ this.fileInput = this.el.find(".form-control-file") as JQuery;
+ this.fileName = this.el.find(".file__name");
+ this.fileDelete = this.el.find(".file__delete");
+ this.inputFileLabel = this.el.find(".input__file-label");
}
+ /**
+ * Initialize the file component by setting up event listeners and drag-and-drop functionality.
+ */
init() {
- const dropTarget = this.el.closest('.file-upload');
+ const dropTarget = this.el.closest(".file-upload");
if (dropTarget) {
const dragOptions = { allowMultiple: true };
- dropTarget.filedrag(dragOptions).on('fileDrop', ({ file, index, length }: FileDropEvent) => { // eslint-disable-line @typescript-eslint/no-explicit-any
+ dropTarget.filedrag(dragOptions).on("fileDrop", ({ file, index, length }: FileDropEvent) => {
this.handleFormUpload(file, index, length);
});
} else {
- throw new Error('Could not find file-upload element');
+ throw new Error("Could not find file-upload element");
}
- this.fileInput.on('change', this.changeFile);
- this.inputFileLabel.on('keyup', this.uploadFile);
- this.fileDelete.addClass('hidden');
- this.fileDelete.on('click', this.deleteFile);
+ this.fileInput.on("change", this.changeFile);
+ this.inputFileLabel.on("keyup", this.uploadFile);
+ this.fileDelete.addClass("hidden");
+ this.fileDelete.on("click", this.deleteFile);
}
- // As some of these, if not all, are event handlers, scoping can get a bit wiggy; using arrow functions to keep the scope of `this` to the class
+ /**
+ * Handle the file upload process.
+ * @param {File} file The file to be uploaded.
+ * @param {number} index The index of the file in the upload queue.
+ * @param {number} length The total number of files in the upload queue.
+ */
handleFormUpload = (file: File, index:number, length: number) => {
- if (!file) throw new Error('No file provided');
+ if (!file) throw new Error("No file provided");
- const form = this.el.closest('form');
- const action = form.attr('action') ? window.location.href + form.attr('action') : window.location.href;
- const method = (form.attr('method') || 'GET').toUpperCase();
- const tokenField = form.find('input[name="csrf_token"]');
+ const form = this.el.closest("form");
+ const action = form.attr("action") ? window.location.href + form.attr("action") : window.location.href;
+ const method = (form.attr("method") || "GET").toUpperCase();
+ const tokenField = form.find("input[name=\"csrf_token\"]");
const csrf_token = tokenField.val() as string ?? tokenField.val()?.toString();
const formData = formdataMapper({ file, csrf_token });
- if (method === 'POST') {
+ if (method === "POST") {
logging.info(`Uploading file: ${file.name} (${index} of ${length}) to ${action} using POST method`);
- const uploadPromise = upload(action, formData, 'POST')
+ const uploadPromise = upload(action, formData, "POST");
if(index === length-1) {
uploadPromise.then(() => {
logging.info("File upload complete, reloading page");
@@ -59,34 +74,49 @@ class FileComponent {
}
uploadPromise.catch(console.error);
} else {
- throw new Error('Method not supported');
+ throw new Error("Method not supported");
}
};
+ /**
+ * Handle the change event when a file is selected.
+ * @param {JQuery.ChangeEvent} ev The change event triggered when a file is selected.
+ */
changeFile = (ev: JQuery.ChangeEvent) => {
const [file] = ev.target.files!;
const { name: fileName } = file;
this.fileName.text(fileName);
- this.fileName.attr('title', fileName);
- this.fileDelete.removeClass('hidden');
+ this.fileName.attr("title", fileName);
+ this.fileDelete.removeClass("hidden");
};
+ /**
+ * Upload the file when the input file label is focused and the space or enter key is pressed.
+ * @param {JQuery.KeyUpEvent} ev The keyup event triggered when the input file label is focused.
+ */
uploadFile = (ev: JQuery.KeyUpEvent) => {
if (ev.which === 32 || ev.which === 13) {
- this.fileInput.trigger('click');
+ this.fileInput.trigger("click");
}
};
+ /**
+ * Delete the selected file and reset the file input and display.
+ * @todo set focus back to input__file-label without triggering keyup event on it
+ */
deleteFile = () => {
- this.fileName.text('No file chosen');
- this.fileName.attr('title', '');
- this.fileInput.val('');
- this.fileDelete.addClass('hidden');
- // TO DO: set focus back to input__file-label without triggering keyup event on it
+ this.fileName.text("No file chosen");
+ this.fileName.attr("title", "");
+ this.fileInput.val("");
+ this.fileDelete.addClass("hidden");
};
}
+/**
+ * Create a new FileComponent instance and initialize it.
+ * @param {HTMLElement | JQuery} el The HTML element for the file component, can be a jQuery object or a plain HTMLElement.
+ */
export default function fileComponent(el: HTMLElement | JQuery) {
const component = new FileComponent(el);
component.init();
diff --git a/src/frontend/components/form-group/input/lib/logoComponent.ts b/src/frontend/components/form-group/input/lib/logoComponent.ts
index 2c23b5317..3f6d5d19e 100644
--- a/src/frontend/components/form-group/input/lib/logoComponent.ts
+++ b/src/frontend/components/form-group/input/lib/logoComponent.ts
@@ -1,46 +1,64 @@
-import { formdataMapper } from 'util/mapper/formdataMapper';
-import { upload } from 'util/upload/UploadControl';
+import { formdataMapper } from "util/mapper/formdataMapper";
+import { upload } from "util/upload/UploadControl";
+/**
+ * LogoComponent class for handling logo upload functionality.
+ */
class LogoComponent {
el: JQuery;
logoDisplay: JQuery;
fileInput: JQuery;
- protected readonly type = 'logo';
+ protected readonly type = "logo";
+ /**
+ * Create a new LogoComponent.
+ * @param {JQuery | HTMLElement} el The HTML element for the logo component, can be a jQuery object or a plain HTMLElement.
+ */
constructor(el: JQuery | HTMLElement) {
this.el = $(el);
- this.logoDisplay = this.el.parent().find('img');
- this.fileInput = this.el.find('.form-control-file') as JQuery;
+ this.logoDisplay = this.el.parent().find("img");
+ this.fileInput = this.el.find(".form-control-file") as JQuery;
}
+ /**
+ * Initialize the LogoComponent.
+ */
init() {
- if (this.logoDisplay.attr('src') === '#') {
+ if (this.logoDisplay.attr("src") === "#") {
this.logoDisplay.hide();
}
- this.el.find('.file').hide();
+ this.el.find(".file").hide();
- this.fileInput.on('change', this.handleFileChange);
+ this.fileInput.on("change", this.handleFileChange);
}
+ /**
+ * Handle the file input change event to upload the selected file.
+ * @param {JQuery.ChangeEvent} ev The change event triggered when a file is selected.
+ */
handleFileChange = (ev: JQuery.ChangeEvent) => {
ev.preventDefault();
- const url = this.el.data('fileupload-url');
+ const url = this.el.data("fileupload-url");
const file = this.fileInput[0].files?.[0];
- const csrf_token = $('body').data('csrf');
+ const csrf_token = $("body").data("csrf");
if (file) {
const formData = formdataMapper({ file, csrf_token });
- upload<{ url: string }>(url, formData, 'POST').then((data) => {
- const version = this.logoDisplay.attr('src')!.split('?')[1];
+ upload<{ url: string }>(url, formData, "POST").then((data) => {
+ const version = this.logoDisplay.attr("src")!.split("?")[1];
const newVersion = version ? parseInt(version, 10) + 1 : 1;
- this.logoDisplay.attr('src', `${data.url}?${newVersion}`).show();
+ this.logoDisplay.attr("src", `${data.url}?${newVersion}`).show();
});
}
};
}
+/**
+ * Create a new LogoComponent.
+ * @param {HTMLElement} el The HTML element for the logo component.
+ */
export default function logoComponent(el: JQuery | HTMLElement) {
const component = new LogoComponent(el);
component.init();
diff --git a/src/frontend/components/form-group/input/lib/passwordComponent.ts b/src/frontend/components/form-group/input/lib/passwordComponent.ts
index 47d09a6fe..f87cef045 100644
--- a/src/frontend/components/form-group/input/lib/passwordComponent.ts
+++ b/src/frontend/components/form-group/input/lib/passwordComponent.ts
@@ -1,30 +1,47 @@
+/**
+ * PasswordComponent class for handling password input fields with reveal functionality.
+ */
class PasswordComponent {
// For testing purposes
- protected readonly type = 'password';
+ protected readonly type = "password";
el: JQuery;
btnReveal: JQuery;
input: JQuery;
+ /**
+ * Create a new PasswordComponent.
+ * @param {JQuery} el The HTML element for the password component, can be a jQuery object or a plain HTMLElement.
+ */
constructor(el: JQuery | HTMLElement) {
this.el = $(el);
- this.btnReveal = this.el.find('.input__reveal-password');
- this.input = this.el.find('.form-control') as JQuery;
+ this.btnReveal = this.el.find(".input__reveal-password");
+ this.input = this.el.find(".form-control") as JQuery;
}
+ /**
+ * Initialize the PasswordComponent.
+ */
init() {
if (this.btnReveal.length === 0) return;
- this.btnReveal.removeClass('show').on('click', this.handleClickReveal);
+ this.btnReveal.removeClass("show").on("click", this.handleClickReveal);
}
+ /**
+ * Handle the click event to toggle password visibility.
+ */
private handleClickReveal = () => {
- const inputType = this.input.attr('type');
- this.input.attr('type', inputType === 'password' ? 'text' : 'password');
- this.btnReveal.toggleClass('show');
+ const inputType = this.input.attr("type");
+ this.input.attr("type", inputType === "password" ? "text" : "password");
+ this.btnReveal.toggleClass("show");
};
}
+/**
+ * Create a new PasswordComponent.
+ * @param {HTMLElement | JQuery} el The HTML element for the password component.
+ */
export default function passwordComponent(el: JQuery | HTMLElement) {
new PasswordComponent(el).init();
}
diff --git a/src/frontend/components/form-group/multiple-select/index.js b/src/frontend/components/form-group/multiple-select/index.js
index ab5eb0863..29581a4b1 100644
--- a/src/frontend/components/form-group/multiple-select/index.js
+++ b/src/frontend/components/form-group/multiple-select/index.js
@@ -1,4 +1,4 @@
-import { initializeComponent } from 'component'
-import MultipleSelectComponent from './lib/component'
+import { initializeComponent } from "component";
+import MultipleSelectComponent from "./lib/component";
-export default (scope) => initializeComponent(scope, '.multiple-select', MultipleSelectComponent)
+export default (scope) => initializeComponent(scope, ".multiple-select", MultipleSelectComponent);
diff --git a/src/frontend/components/form-group/multiple-select/lib/component.js b/src/frontend/components/form-group/multiple-select/lib/component.js
index a2f95ff58..310edc5d1 100644
--- a/src/frontend/components/form-group/multiple-select/lib/component.js
+++ b/src/frontend/components/form-group/multiple-select/lib/component.js
@@ -1,134 +1,160 @@
-import { Component } from 'component'
-import initDateField from 'components/datepicker/lib/helper'
+import { Component } from "component";
+import initDateField from "components/datepicker/lib/helper";
-const SELECT_PLACEHOLDER = 'select__placeholder'
-const SELECT_MENU_ITEM_ACTIVE = 'select__menu-item--active'
+const SELECT_PLACEHOLDER = "select__placeholder";
+const SELECT_MENU_ITEM_ACTIVE = "select__menu-item--active";
+/**
+ * Component for multiple select form elements.
+ */
class MultipleSelectComponent extends Component {
- constructor(element) {
- super(element)
- this.el = $(this.element)
- this.multipleSelectList = this.el.find('.multiple-select__list')
- this.delBtn = this.el.find('.btn-delete')
- this.addBtn = this.el.find('.btn-add-link')
- this.countSelect = 1
-
- this.initMultipleSelect(this.el)
+ /**
+ * Create a new MultipleSelectComponent.
+ * @param {HTMLElement} element The HTML element for the multiple select component.
+ */
+ constructor(element) {
+ super(element);
+ this.el = $(this.element);
+ this.multipleSelectList = this.el.find(".multiple-select__list");
+ this.delBtn = this.el.find(".btn-delete");
+ this.addBtn = this.el.find(".btn-add-link");
+ this.countSelect = 1;
+
+ this.initMultipleSelect(this.el);
}
+ /**
+ * Initialize the multiple select component.
+ */
initMultipleSelect() {
if (!this.multipleSelectList) {
- return
+ return;
}
- this.el.find('.multiple-select__row').each((i, row) => {
- this.handleDeleteButtonVisibility(row)
+ this.el.find(".multiple-select__row").each((i, row) => {
+ this.handleDeleteButtonVisibility(row);
- $(row).find('input[type="hidden"]').on('change', () => this.handleDeleteButtonVisibility(row))
- })
+ $(row).find("input[type=\"hidden\"]")
+ .on("change", () => this.handleDeleteButtonVisibility(row));
+ });
- this.delBtn.on('click', (ev) => { this.handleClickDelete(ev) } )
- this.addBtn.on('click', () => { this.handleClick() } )
+ this.delBtn.on("click", (ev) => { this.handleClickDelete(ev); });
+ this.addBtn.on("click", () => { this.handleClick(); });
}
+ /**
+ * Handle the visibility of the delete button based on input values.
+ * @param {HTMLElement} row The row element to check for delete button visibility.
+ */
handleDeleteButtonVisibility(row) {
- const delBtn = $(row).find('.btn-delete')
- const rowInputs = $(row).find('input[type="hidden"]')
+ const delBtn = $(row).find(".btn-delete");
+ const rowInputs = $(row).find("input[type=\"hidden\"]");
- delBtn.addClass('btn-delete--hidden')
+ delBtn.addClass("btn-delete--hidden");
- rowInputs.each((i, rowInput) => {
- if ($(rowInput).val().length) {
- delBtn.removeClass('btn-delete--hidden')
- }
- })
+ rowInputs.each((i, rowInput) => {
+ if ($(rowInput).val().length) {
+ delBtn.removeClass("btn-delete--hidden");
+ }
+ });
}
+ /**
+ * Handle the click event to add a new multiple select row.
+ */
handleClick() {
- this.el.find('.btn-delete').removeClass('btn-delete--hidden')
+ this.el.find(".btn-delete").removeClass("btn-delete--hidden");
- const $lastMultipleSelectRow = this.el.find('.multiple-select__row').last()
- const $newMultipleSelectRow = $lastMultipleSelectRow.clone()
- const $selectElmsInNewRow = $newMultipleSelectRow.find('.select')
- const $dateElmsInNewRow = $newMultipleSelectRow.find('.input--datepicker').find('.form-control')
+ const $lastMultipleSelectRow = this.el.find(".multiple-select__row").last();
+ const $newMultipleSelectRow = $lastMultipleSelectRow.clone();
+ const $selectElmsInNewRow = $newMultipleSelectRow.find(".select");
+ const $dateElmsInNewRow = $newMultipleSelectRow.find(".input--datepicker").find(".form-control");
- this.countSelect += 1
+ this.countSelect += 1;
- // Change the id's of the select elements in the new row
- $selectElmsInNewRow.each( (i, selectEl) => {
- const $newLabel = $(selectEl).find('.select__label > label')
- $newLabel.attr('for', `${$newLabel.attr('for')}-${this.countSelect}` )
- $newLabel.attr('id', `${$newLabel.attr('id')}-${this.countSelect}` )
+ // Change the id's of the select elements in the new row
+ $selectElmsInNewRow.each((i, selectEl) => {
+ const $newLabel = $(selectEl).find(".select__label > label");
+ $newLabel.attr("for", `${$newLabel.attr("for")}-${this.countSelect}`);
+ $newLabel.attr("id", `${$newLabel.attr("id")}-${this.countSelect}`);
- const $newButton = $(selectEl).find('.select__toggle')
- $newButton.attr('id', `${$newButton.attr('id')}-${this.countSelect}` )
+ const $newButton = $(selectEl).find(".select__toggle");
+ $newButton.attr("id", `${$newButton.attr("id")}-${this.countSelect}`);
- const $newInput = $(selectEl).find('input[type="hidden"]')
- $newInput.attr('id', `${$newInput.attr('id')}-${this.countSelect}` )
+ const $newInput = $(selectEl).find("input[type=\"hidden\"]");
+ $newInput.attr("id", `${$newInput.attr("id")}-${this.countSelect}`);
- const $selectMenu = $(selectEl).find('.select__menu')
- $selectMenu.attr('aria-labelledby', `${$selectMenu.attr('aria-labelledby')}-${this.countSelect}`)
+ const $selectMenu = $(selectEl).find(".select__menu");
+ $selectMenu.attr("aria-labelledby", `${$selectMenu.attr("aria-labelledby")}-${this.countSelect}`);
- // Bind events to the new select element
- import(/* webpackChunkName: "selectBuilder" */ '../../select/lib/component')
- .then(({ default: SelectComponent }) => {
- const newSelectComponent = new SelectComponent(selectEl)
- newSelectComponent.initSelect()
- newSelectComponent.resetSelect()
- });
- })
+ // Bind events to the new select element
+ import(/* webpackChunkName: "selectBuilder" */ "../../select/lib/component")
+ .then(({ default: SelectComponent }) => {
+ const newSelectComponent = new SelectComponent(selectEl);
+ newSelectComponent.initSelect();
+ newSelectComponent.resetSelect();
+ });
+ });
- $dateElmsInNewRow.each( (i, dateEl) => {
- initDateField($(dateEl))
- })
+ $dateElmsInNewRow.each((i, dateEl) => {
+ initDateField($(dateEl));
+ });
- // Bind click event to new delete button
- const $delBtn = $newMultipleSelectRow.find('.btn-delete')
- $delBtn.on('click', (ev) => { this.handleClickDelete(ev) } )
+ // Bind click event to new delete button
+ const $delBtn = $newMultipleSelectRow.find(".btn-delete");
+ $delBtn.on("click", (ev) => { this.handleClickDelete(ev); });
- $newMultipleSelectRow.appendTo(this.multipleSelectList)
+ $newMultipleSelectRow.appendTo(this.multipleSelectList);
}
+ /**
+ * Handle the click event to delete a multiple select row.
+ * @param {JQuery.ClickEvent} ev The click event triggered by the delete button.
+ */
handleClickDelete(ev) {
- const multipleSelectArray = this.multipleSelectList.find('> .multiple-select__row')
-
- if (multipleSelectArray.length === 1) {
- this.resetRow(multipleSelectArray[0])
- } else {
- const target = $(ev.currentTarget)
- target.closest('.multiple-select__row').remove()
- this.el.trigger("change")
-
- const newMultipleSelectArray = this.multipleSelectList.find('> .multiple-select__row')
-
- if (newMultipleSelectArray.length === 1) {
- this.handleDeleteButtonVisibility(newMultipleSelectArray[0])
+ const multipleSelectArray = this.multipleSelectList.find("> .multiple-select__row");
+
+ if (multipleSelectArray.length === 1) {
+ this.resetRow(multipleSelectArray[0]);
+ } else {
+ const target = $(ev.currentTarget);
+ target.closest(".multiple-select__row").remove();
+ this.el.trigger("change");
+
+ const newMultipleSelectArray = this.multipleSelectList.find("> .multiple-select__row");
+
+ if (newMultipleSelectArray.length === 1) {
+ this.handleDeleteButtonVisibility(newMultipleSelectArray[0]);
+ }
}
- }
}
+ /**
+ * Reset a row in the multiple select component.
+ * @param {HTMLElement} row The row element to reset.
+ */
resetRow(row) {
- const rowInputs = $(row).find('input[type="hidden"]')
+ const rowInputs = $(row).find("input[type=\"hidden\"]");
+
+ rowInputs.each((i, input) => {
+ const placeholder = input.placeholder;
+ const select = $(input).closest(".select");
+ const toggleButton = select.find(".select__toggle");
+ const options = select.find(".select__menu-item");
- rowInputs.each((i, input) => {
- const placeholder = input.placeholder
- const select = $(input).closest('.select')
- const toggleButton = select.find('.select__toggle')
- const options = select.find('.select__menu-item')
+ toggleButton.find("span").html(placeholder);
+ toggleButton.find("span").addClass(SELECT_PLACEHOLDER);
- toggleButton.find('span').html(placeholder)
- toggleButton.find('span').addClass(SELECT_PLACEHOLDER)
-
- options.removeClass(SELECT_MENU_ITEM_ACTIVE)
- options.attr('aria-selected', false)
+ options.removeClass(SELECT_MENU_ITEM_ACTIVE);
+ options.attr("aria-selected", false);
- $(input).removeAttr('value')
- $(input).removeAttr('data-restore-value')
- })
+ $(input).removeAttr("value");
+ $(input).removeAttr("data-restore-value");
+ });
- this.handleDeleteButtonVisibility(row)
+ this.handleDeleteButtonVisibility(row);
}
}
-export default MultipleSelectComponent
+export default MultipleSelectComponent;
diff --git a/src/frontend/components/form-group/people-filter/index.js b/src/frontend/components/form-group/people-filter/index.js
index 07b4f7c13..95655b89f 100644
--- a/src/frontend/components/form-group/people-filter/index.js
+++ b/src/frontend/components/form-group/people-filter/index.js
@@ -1,9 +1,9 @@
-import { getComponentElements, initializeComponent } from "component"
+import { getComponentElements, initializeComponent } from "component";
export default (scope) => {
- if(!getComponentElements(scope,".people-filter").length) return;
+ if (!getComponentElements(scope, ".people-filter").length) return;
- import(/* webpackChunkName: "people-filter" */ "./lib/component").then(({default: component}) => {
+ import(/* webpackChunkName: "people-filter" */ "./lib/component").then(({ default: component }) => {
initializeComponent(scope, ".people-filter", component);
});
-}
\ No newline at end of file
+};
\ No newline at end of file
diff --git a/src/frontend/components/form-group/people-filter/lib/component.ts b/src/frontend/components/form-group/people-filter/lib/component.ts
index da6e83179..6d6f7eff5 100644
--- a/src/frontend/components/form-group/people-filter/lib/component.ts
+++ b/src/frontend/components/form-group/people-filter/lib/component.ts
@@ -1,33 +1,82 @@
import { Component } from "component";
-import "@lol768/jquery-querybuilder-no-eval";
+import "jQuery-QueryBuilder/dist/js/query-builder.standalone";
declare global {
+ // Global interface for the window object to include the UpdatePeopleFilter method.
interface Window {
+ /**
+ * Update the people filter based on the current query builder state.
+ * @param builder The jQuery QueryBuilder instance.
+ * @param ev The event that triggered the update.
+ */
UpdatePeopleFilter: (builder: JQuery, ev: Event | JQuery.Event) => void;
}
+ /**
+ * jQuery interface extension to include queryBuilder methods.
+ */
interface JQuery {
+ /**
+ * Create or initialize a query builder with the given filters.
+ * @param filters The filter settings to initialize the query builder.
+ * @returns A jQuery object for chaining.
+ */
queryBuilder(filters: any): JQuery;
+ /**
+ * Perform a method call on the query builder.
+ * @param method The method to call on the query builder.
+ * @param args The arguments to pass to the method.
+ * @returns The result of the method call.
+ */
queryBuilder(method: string, ...args: any[]): any;
}
}
+/**
+ * Interface for filter settings used in the query builder.
+ */
interface FilterSettings {
+ /**
+ * Optional filter for items that are not done.
+ * @type {any}
+ */
filterNotDone?: any;
+ /**
+ * Settings for the query builder filters.
+ * @type {any}
+ */
filters: any;
+ /**
+ * List of operators available for the query builder.
+ * @type {string[]}
+ */
operators: string[];
+ /**
+ * Whether to allow empty values in the query builder.
+ * @type {boolean}
+ */
allow_empty: boolean;
}
+/**
+ * Component for the people filter form group.
+ */
class PeopleFilterComponent extends Component {
+ /**
+ * Create a new PeopleFilterComponent.
+ * @param {HTMLElement} element The HTML element for the people filter component.
+ */
constructor(public element: HTMLElement) {
super(element);
this.init();
}
+ /**
+ * Initialize the people filter component.
+ */
init() {
- const elementData = $(this.element).data('filters');
- const peopleDisplayData = $('#people-display').data('filter-base64');
+ const elementData = $(this.element).data("filters");
+ const peopleDisplayData = $("#people-display").data("filter-base64");
if (!elementData || !peopleDisplayData) return;
@@ -36,8 +85,8 @@ class PeopleFilterComponent extends Component {
const settings: FilterSettings = {
filters: filters,
operators: [
- 'equal', 'not_equal', 'contains', 'not_contains',
- 'begins_with', 'ends_with', 'is_empty', 'is_not_empty'
+ "equal", "not_equal", "contains", "not_contains",
+ "begins_with", "ends_with", "is_empty", "is_not_empty"
],
allow_empty: true
};
@@ -46,11 +95,11 @@ class PeopleFilterComponent extends Component {
el.queryBuilder(settings);
try {
- if (Object.keys(values).length > 0) el.queryBuilder('setRules', values);
+ if (Object.keys(values).length > 0) el.queryBuilder("setRules", values);
window.UpdatePeopleFilter = (builder, ev) => {
- if (!builder.queryBuilder('validate')) ev.preventDefault();
- const query = builder.queryBuilder('getRules');
- $('#people-display').val(JSON.stringify(query, null, 2));
+ if (!builder.queryBuilder("validate")) ev.preventDefault();
+ const query = builder.queryBuilder("getRules");
+ $("#people-display").val(JSON.stringify(query, null, 2));
};
} catch (e) {
console.error("Error:", e);
@@ -58,4 +107,4 @@ class PeopleFilterComponent extends Component {
}
}
-export default PeopleFilterComponent;
\ No newline at end of file
+export default PeopleFilterComponent;
diff --git a/src/frontend/components/form-group/query-builder/_query-builder.scss b/src/frontend/components/form-group/query-builder/_query-builder.scss
index 048d1a9ea..9effac08f 100644
--- a/src/frontend/components/form-group/query-builder/_query-builder.scss
+++ b/src/frontend/components/form-group/query-builder/_query-builder.scss
@@ -190,3 +190,21 @@
}
}
}
+
+[data-add], [data-delete] {
+ &::before {
+ @extend %icon-font;
+ }
+}
+
+[data-add] {
+ &::before {
+ content: "\E800";
+ }
+}
+
+[data-delete] {
+ &::before {
+ content: "\E807";
+ }
+}
diff --git a/src/frontend/components/form-group/radio-group/_radio-group.scss b/src/frontend/components/form-group/radio-group/_radio-group.scss
index 8a7bfdbcb..5bf012e88 100644
--- a/src/frontend/components/form-group/radio-group/_radio-group.scss
+++ b/src/frontend/components/form-group/radio-group/_radio-group.scss
@@ -25,7 +25,7 @@
}
}
- input[type=radio]:checked + label {
+ input[type="radio"]:checked + label {
background-color: $brand-secundary;
color: $white;
diff --git a/src/frontend/components/form-group/radio-group/index.js b/src/frontend/components/form-group/radio-group/index.js
index c85994ffc..8f4f81f51 100644
--- a/src/frontend/components/form-group/radio-group/index.js
+++ b/src/frontend/components/form-group/radio-group/index.js
@@ -1,4 +1,4 @@
-import { initializeComponent } from 'component'
-import RadioGroupComponent from './lib/component'
+import { initializeComponent } from "component";
+import RadioGroupComponent from "./lib/component";
-export default (scope) => initializeComponent(scope, '.radio-group', RadioGroupComponent)
+export default (scope) => initializeComponent(scope, ".radio-group", RadioGroupComponent);
diff --git a/src/frontend/components/form-group/radio-group/lib/component.js b/src/frontend/components/form-group/radio-group/lib/component.js
index 9040f4566..40a5dd9c8 100644
--- a/src/frontend/components/form-group/radio-group/lib/component.js
+++ b/src/frontend/components/form-group/radio-group/lib/component.js
@@ -1,15 +1,22 @@
-import { Component } from 'component'
-import { initValidationOnField } from 'validation'
+import { Component } from "component";
+import { initValidationOnField } from "validation";
+/**
+ * Component for radio group form elements.
+ */
class RadioGroupComponent extends Component {
- constructor(element) {
- super(element)
- this.el = $(this.element)
+ /**
+ * Create a new RadioGroupComponent.
+ * @param {HTMLElement} element The HTML element for the radio group.
+ */
+ constructor(element) {
+ super(element);
+ this.el = $(this.element);
- if (this.el.hasClass("radio-group--required")) {
- initValidationOnField(this.el)
- }
+ if (this.el.hasClass("radio-group--required")) {
+ initValidationOnField(this.el);
+ }
}
}
-export default RadioGroupComponent
+export default RadioGroupComponent;
diff --git a/src/frontend/components/form-group/searchable-select/FilterSelectHelper.ts b/src/frontend/components/form-group/searchable-select/FilterSelectHelper.ts
new file mode 100644
index 000000000..db85b9871
--- /dev/null
+++ b/src/frontend/components/form-group/searchable-select/FilterSelectHelper.ts
@@ -0,0 +1,53 @@
+import "./JQuerySearchableSelect";
+
+export const refreshSelects = (el: JQuery) => {
+ const ruleFilterSelects = [];
+ const operatorSelects = [];
+
+ el.on("afterCreateRuleFilters.queryBuilder", (e: JQuery.TriggeredEvent, rule: any) => {
+ const ruleFilterSelect = $(rule.$el.find(`select[name=${rule.id}_filter]`));
+ if (!ruleFilterSelects.includes(ruleFilterSelect[0])) ruleFilterSelects.push(ruleFilterSelect[0]);
+ if (!ruleFilterSelect || !ruleFilterSelect[0]) {
+ console.error("No select found");
+ return;
+ }
+ if (ruleFilterSelect.data("searchableSelect")) return;
+ ruleFilterSelect.searchableSelect();
+ });
+
+ el.on("afterCreateRuleOperators.queryBuilder", (e: JQuery.TriggeredEvent, rule: any) => {
+ const operatorSelect = $(rule.$el.find(`select[name=${rule.id}_operator]`));
+ if (!operatorSelect || !operatorSelect[0]) {
+ console.error("No operator select found");
+ return;
+ }
+ if (!operatorSelects.includes(operatorSelect[0])) operatorSelects.push(operatorSelect[0]);
+ if (operatorSelect.data("searchableSelect")) return;
+ operatorSelect.searchableSelect();
+ });
+
+ el.on("afterSetRules.queryBuilder", () => {
+ for (const ruleFilterSelect of ruleFilterSelects) {
+ if (!ruleFilterSelect) {
+ continue;
+ }
+ $(ruleFilterSelect).getSearchableSelect()
+ .refresh();
+ }
+ for (const operatorSelect of operatorSelects) {
+ if (!operatorSelect) continue;
+ $(operatorSelect).getSearchableSelect()
+ .refresh();
+ }
+ });
+
+ el.on("afterSetRuleOperator.queryBuilder", () => {
+ for (const operatorSelect of operatorSelects) {
+ if (!operatorSelect) {
+ continue;
+ }
+ $(operatorSelect).getSearchableSelect()
+ .refresh();
+ }
+ });
+};
diff --git a/src/frontend/components/form-group/searchable-select/JQuerySearchableSelect.test.ts b/src/frontend/components/form-group/searchable-select/JQuerySearchableSelect.test.ts
new file mode 100644
index 000000000..9f50cca4e
--- /dev/null
+++ b/src/frontend/components/form-group/searchable-select/JQuerySearchableSelect.test.ts
@@ -0,0 +1,23 @@
+/* eslint-disable */
+import { describe, it, expect, beforeEach } from "@jest/globals";
+import "./JQuerySearchableSelect";
+
+describe("JQuery SearchableSelect component", () => {
+ beforeEach(() => {
+ document.body.innerHTML = ''; // Clear the document body before each test
+ });
+
+ it("Should define the searchableSelect jQuery plugin", () => {
+ if(typeof jQuery === 'undefined') expect(true).toBe(false); // fail if jQuery is not loaded
+ expect(jQuery.fn.searchableSelect).toBeDefined();
+ });
+
+ it("Should initialize searchableSelect on a select element", () => {
+ const select = document.createElement('select');
+ document.body.appendChild(select);
+ $(select).searchableSelect();
+ expect($(select).getSearchableSelect).toBeDefined();
+ expect($(select).getSearchableSelect()).toBeInstanceOf(Object);
+ document.body.removeChild(select);
+ });
+});
diff --git a/src/frontend/components/form-group/searchable-select/JQuerySearchableSelect.ts b/src/frontend/components/form-group/searchable-select/JQuerySearchableSelect.ts
new file mode 100644
index 000000000..40cd689b4
--- /dev/null
+++ b/src/frontend/components/form-group/searchable-select/JQuerySearchableSelect.ts
@@ -0,0 +1,46 @@
+import { SearchableSelect } from "./lib/SearchableSelect";
+import { SearchableSelectOptions } from "./lib/options";
+
+if (typeof jQuery === "undefined") throw new Error("jQuery is not loaded. Please include jQuery before this script.");
+
+declare global {
+ interface JQuery {
+ searchableSelect: (options?: SearchableSelectOptions) => JQuery;
+ getSearchableSelect: () => SearchableSelect;
+ }
+}
+
+export { };
+
+(($) => {
+ const selectMap = new Map();
+ $.fn.searchableSelect = function (options?: SearchableSelectOptions) {
+ if (this.length === 0) return this;
+ const settings: SearchableSelectOptions = $.extend({
+ target: this.parent()[0],
+ classList: [],
+ placeholder: "Select an option",
+ element: this[0] as HTMLSelectElement
+ }, options);
+ this.each(function () {
+ const element = this as HTMLSelectElement;
+ if (element.tagName.toLowerCase() === "select") {
+ const select = new SearchableSelect(settings);
+ selectMap.set(element, select);
+ $(element).data("searchableSelect", "true");
+ } else {
+ console.warn("Element is not a select:", element);
+ }
+ });
+ return this;
+ };
+ $.fn.getSearchableSelect = function () {
+ const element = this[0] as HTMLSelectElement;
+ if (element && selectMap.get(element)) {
+ return selectMap.get(element) as SearchableSelect;
+ } else {
+ console.warn("No SearchableSelect instance found for this element:", element);
+ return null;
+ }
+ };
+})(jQuery);
diff --git a/src/frontend/components/form-group/searchable-select/_searchable-select.scss b/src/frontend/components/form-group/searchable-select/_searchable-select.scss
new file mode 100644
index 000000000..8e5863f02
--- /dev/null
+++ b/src/frontend/components/form-group/searchable-select/_searchable-select.scss
@@ -0,0 +1,41 @@
+.btn-searchable-select.dropdown-toggle {
+ @include button-variant($gray-100, $gray-100, $secondary);
+ margin-top: .75rem;
+ margin-bottom: 1rem;
+ min-width: 100%;
+ max-width: 100%;
+ text-align: center;
+ white-space: nowrap;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ & span {
+ flex-grow: 1;
+ }
+}
+
+.btn-searchable-select {
+ .dropdown-menu.show {
+ border-color: $gray-200;
+ }
+}
+
+.rule-container {
+ display: flex;
+ align-items: center;
+}
+
+.searchable-select-options {
+ max-height: 300px;
+ overflow-y: auto;
+
+ .searchable-select-search {
+ @extend .sticky-top;
+ @extend .p-3;
+ @extend .bg-light;
+ @extend .border-bottom;
+ border-color: $gray-200;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ }
+}
diff --git a/src/frontend/components/form-group/searchable-select/lib/SearchableSelect.test.ts b/src/frontend/components/form-group/searchable-select/lib/SearchableSelect.test.ts
new file mode 100644
index 000000000..b3f3179f2
--- /dev/null
+++ b/src/frontend/components/form-group/searchable-select/lib/SearchableSelect.test.ts
@@ -0,0 +1,57 @@
+/* eslint-disable */
+import { describe, it, expect, beforeEach, afterEach } from "@jest/globals";
+import { SearchableSelect } from "./SearchableSelect";
+
+describe("SearchableSelect", () => {
+ beforeEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ it("should get the correct last created dropdown ID", () => {
+ // Make this a semi-random order to ensure the logic works correctly
+ document.body.innerHTML = `
+
+
+
+
+ `;
+ const lastId = SearchableSelect.LastCreatedDropdownId;
+ expect(lastId).toBe("dropdown-5");
+ });
+
+ it("should return dropdown-1 when no dropdowns exist", () => {
+ const lastId = SearchableSelect.LastCreatedDropdownId;
+ expect(lastId).toBe("dropdown-1");
+ });
+
+ it("should return the correct dropdown ID when no dropdowns match the pattern", () => {
+ document.body.innerHTML = `
+
+
+ `;
+ const lastId = SearchableSelect.LastCreatedDropdownId;
+ expect(lastId).toBe("dropdown-1");
+ });
+
+ it("should get the version of Bootstrap", () => {
+ const version = SearchableSelect.BootstrapVersion;
+ expect(version).toBeGreaterThan(0);
+ });
+
+ it("should be able to create a dropdown", () => {
+ const selectElement = document.createElement('select');
+ selectElement.innerHTML = `
+
+
+
+ `;
+ document.body.appendChild(selectElement);
+
+ const searchableSelect = new SearchableSelect({ element: selectElement });
+ expect(searchableSelect).toBeInstanceOf(SearchableSelect);
+ })
+});
diff --git a/src/frontend/components/form-group/searchable-select/lib/SearchableSelect.ts b/src/frontend/components/form-group/searchable-select/lib/SearchableSelect.ts
new file mode 100644
index 000000000..f1ba1ae6f
--- /dev/null
+++ b/src/frontend/components/form-group/searchable-select/lib/SearchableSelect.ts
@@ -0,0 +1,169 @@
+import { Tooltip } from "bootstrap";
+import { SearchableSelectOptions } from "./options";
+
+/**
+ * SearchableSelect class provides a searchable dropdown interface for a standard HTML select element.
+ */
+export class SearchableSelect {
+ dropdown: HTMLDivElement = null;
+ button: HTMLElement = null;
+ target: HTMLElement;
+ element: HTMLSelectElement;
+ classList: string[];
+ placeholder: string;
+
+ /**
+ * Create a SearchableSelect instance.
+ * @param target The target HTMLElement where the dropdown will be appended.
+ * @param element The HTMLSelectElement that will be transformed into a searchable dropdown.
+ */
+ constructor({ element, target, classList, placeholder }: SearchableSelectOptions) {
+ this.element = element;
+ this.target = target || element.parentElement || document.body;
+ this.classList = classList || [];
+ this.placeholder = placeholder || "Select an option";
+ this.init();
+ }
+
+ /**
+ * Initializes the SearchableSelect by creating the dropdown and hiding the original select element.
+ * It also sets up the event listeners for the search input and option selection.
+ * @private
+ */
+ private init() {
+ const options = Array.from(this.element.options);
+ this.createDropdown(options);
+ this.element.style.display = "none"; // Hide the original select element
+ }
+
+ /**
+ * Creates the dropdown element and appends it to the target.
+ * This method generates a unique ID for the dropdown and creates the button and options list.
+ * @param options An array of HTMLOptionElement objects to populate the dropdown.
+ * @private
+ */
+ private createDropdown(options: HTMLOptionElement[]) {
+ const id = SearchableSelect.LastCreatedDropdownId;
+ this.dropdown = document.createElement("div");
+ this.dropdown.className = "dropdown btn-searchable-select";
+ this.dropdown.id = id;
+ this.createButton(this.dropdown);
+ this.createOptions(options, this.dropdown);
+ this.target.appendChild(this.dropdown);
+ this.refresh();
+ }
+
+ /**
+ * Creates the options list for the dropdown.
+ * It includes a search input field at the top for filtering options.
+ * @param options An array of HTMLOptionElement objects to populate the dropdown.
+ * @param dropdown The HTMLDivElement that represents the dropdown container.
+ * @private
+ */
+ private createOptions(options: HTMLOptionElement[], dropdown: HTMLDivElement) {
+ const ul = document.createElement("ul");
+ ul.className = "dropdown-menu searchable-select-options pt-0";
+ const searchLi = document.createElement("li");
+ searchLi.classList.add("searchable-select-search");
+ const searchInput = document.createElement("input");
+ searchInput.type = "text";
+ searchInput.className = "form-control";
+ searchInput.placeholder = "Search...";
+ searchInput.addEventListener("input", () => {
+ this.createOptionsList(options, searchInput.value, ul);
+ });
+ searchLi.appendChild(searchInput);
+ ul.appendChild(searchLi);
+ this.createOptionsList(options, "", ul);
+ dropdown.appendChild(ul);
+ }
+
+ /**
+ * Creates the list of options based on the current search input.
+ * @param options Array of HTMLOptionElement objects to filter and display in the dropdown.
+ * @param searchInput The current value of the search input field.
+ * @param ul The HTMLUListElement where the filtered options will be appended.
+ */
+ private createOptionsList(options: HTMLOptionElement[], searchInput: string, ul: HTMLUListElement) {
+ ul.querySelectorAll(".searchable-select-option").forEach(option => option.remove());
+ options.filter(o => o.text.toLowerCase().includes(searchInput.toLowerCase())).forEach(option => {
+ const li = document.createElement("li");
+ const a = document.createElement("a");
+ a.classList.add("dropdown-item", "searchable-select-option");
+ a.href = "#";
+ a.textContent = option.text;
+ a.role = "option";
+ a.addEventListener("click", (e) => {
+ e.preventDefault();
+ this.element.value = option.value;
+ this.refresh();
+ this.element.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+ li.appendChild(a);
+ ul.appendChild(li);
+ });
+ }
+
+ /**
+ * Creates the button that toggles the dropdown.
+ * @param dropdown The HTMLDivElement that represents the dropdown container.
+ */
+ private createButton(dropdown: HTMLDivElement) {
+ const button = document.createElement("button");
+ button.classList.add("btn", "dropdown-toggle", "btn-searchable-select", ...this.classList);
+ button.type = "button";
+ button.setAttribute(SearchableSelect.BootstrapVersion >= 5 ? "data-bs-toggle" : "data-toggle", "dropdown");
+ button.setAttribute("aria-expanded", "false");
+ const span = document.createElement("span");
+ span.textContent = "Select an option";
+ button.appendChild(span);
+ this.button = span;
+ dropdown.appendChild(button);
+ }
+
+ /**
+ * Creates a unique ID for the dropdown based on existing dropdowns in the document.
+ * @returns A unique ID for the dropdown, incrementing from the last created dropdown ID.
+ */
+ static get LastCreatedDropdownId(): string {
+ const dropdowns = document.querySelectorAll(".dropdown");
+ if (dropdowns.length === 0) {
+ return "dropdown-1";
+ }
+ const filteredDropdowns = Array.from(dropdowns).filter((dropdown) => {
+ return dropdown.id.startsWith("dropdown-");
+ });
+ if (filteredDropdowns.length === 0) {
+ return "dropdown-1";
+ }
+ const lastID = filteredDropdowns.map((dropdown) => {
+ const match = dropdown.id.match(/dropdown-(\d+)/);
+ const r = match ? parseInt(match[1], 10) : 0;
+ return r;
+ }).reduce((max, id) => Math.max(max, id), 0);
+ return `dropdown-${lastID + 1}`;
+ }
+
+ /**
+ * Gets the major version of Bootstrap being used.
+ * @returns The major version of Bootstrap being used, based on the Tooltip.VERSION.
+ */
+ static get BootstrapVersion(): number {
+ return parseInt(Tooltip.VERSION.split(".")[0]);
+ }
+
+ /**
+ * Refreshes the dropdown button text and triggers a change event on the original select element.
+ */
+ refresh() {
+ this.button.textContent = this.element.options[this.element.selectedIndex]?.text || "Select an option";
+ $(this.dropdown).find(".dropdown-item")
+ .each((index, item) => {
+ if (item.textContent === this.button.textContent) {
+ item.classList.add("active");
+ } else {
+ item.classList.remove("active");
+ }
+ });
+ }
+}
diff --git a/src/frontend/components/form-group/searchable-select/lib/options.ts b/src/frontend/components/form-group/searchable-select/lib/options.ts
new file mode 100644
index 000000000..7c0519217
--- /dev/null
+++ b/src/frontend/components/form-group/searchable-select/lib/options.ts
@@ -0,0 +1,6 @@
+export type SearchableSelectOptions = {
+ element: HTMLSelectElement;
+ target?: HTMLElement;
+ classList?: string[];
+ placeholder?: string;
+};
diff --git a/src/frontend/components/form-group/select-widget/index.js b/src/frontend/components/form-group/select-widget/index.js
index 0b34e79af..722938802 100644
--- a/src/frontend/components/form-group/select-widget/index.js
+++ b/src/frontend/components/form-group/select-widget/index.js
@@ -1,7 +1,7 @@
-import { getComponentElements, initializeComponent } from 'component'
+import { getComponentElements, initializeComponent } from "component";
export default (scope) => {
- if (!getComponentElements(scope, '.select-widget')) return;
- import(/* webpackChunkName: "select-widget" */ './lib/component')
- .then(({ default: SelectWidgetComponent }) => { initializeComponent(scope, '.select-widget', SelectWidgetComponent) });
-}
+ if (!getComponentElements(scope, ".select-widget")) return;
+ import(/* webpackChunkName: "select-widget" */ "./lib/component")
+ .then(({ default: SelectWidgetComponent }) => { initializeComponent(scope, ".select-widget", SelectWidgetComponent); });
+};
diff --git a/src/frontend/components/form-group/select-widget/lib/component.js b/src/frontend/components/form-group/select-widget/lib/component.js
index 1dcb265aa..405e223c3 100644
--- a/src/frontend/components/form-group/select-widget/lib/component.js
+++ b/src/frontend/components/form-group/select-widget/lib/component.js
@@ -1,653 +1,730 @@
-// We import Bootstrap because there is an error that throws if we don't (this.collapse is not a function).
/* eslint-disable @typescript-eslint/no-this-alias */
-import "bootstrap";
-import { Component } from 'component'
-import { logging } from 'logging'
-import { fromJson } from 'util/common'
-import { initValidationOnField } from 'validation'
-
- /*
- * A SelectWidget is a custom disclosure widget
- * with multi or single options selectable.
- * SelectWidgets can depend on each other;
- * for instance if Value "1" is selected in Widget "A",
- * Widget "B" might not be displayed.
- */
+import { Component } from "component";
+import { fromJson } from "util/common";
+import { logging } from "logging";
+import { initValidationOnField } from "validation";
+
+/**
+ * A SelectWidget is a custom disclosure widget
+ * with multi or single options selectable.
+ * SelectWidgets can depend on each other;
+ * for instance if Value "1" is selected in Widget "A",
+ * Widget "B" might not be displayed.
+ */
class SelectWidgetComponent extends Component {
- constructor(element) {
- super(element)
- this.el = $(this.element)
- this.$selectWidget = this.el;
- this.$widget = this.el.find(".form-control");
- this.$trigger = this.$widget.find("[aria-expanded]");
- this.$current = this.el.find(".current");
- this.$available = this.el.find(".available");
- this.$availableItems = this.el.find(".available .answer input");
- this.$moreInfoButtons = this.el.find(".available .answer .btn-js-more-info");
- this.$target = this.el.find("#" + this.$trigger.attr("aria-controls"));
- this.$currentItems = this.$current.find("[data-list-item]");
- this.$answers = this.el.find(".answer");
- this.$fakeInput = null;
- this.$search = this.el.find(".form-control-search");
- this.lastFetchParams = null;
- this.multi = this.el.hasClass("multi")
- this.timeout
- this.required = this.el.hasClass("select-widget--required")
- // Give each AJAX load its own ID. If a higher ID has started by the time
- // we get the results, then cancel the current process to prevent
- // duplicate items being added to the dropdown
- this.loadCounter = 0
-
- this.initSelectWidget()
-
- if (this.required) {
- initValidationOnField(this.el)
- }
- }
-
- initSelectWidget() {
- this.updateState()
- if (this.$widget.is('[readonly]')) return
- this.connect()
-
- this.$widget.unbind("click")
- this.$widget.on("click", () => { this.handleWidgetClick() })
-
- this.$search.unbind("blur")
- this.$search.on("blur", (e) => { this.possibleCloseWidget(e) })
-
- this.$availableItems.unbind("blur")
- this.$availableItems.on("blur", (e) => { this.possibleCloseWidget(e) })
-
- this.$moreInfoButtons.unbind("blur")
- this.$moreInfoButtons.on("blur", (e) => { this.possibleCloseWidget(e) })
-
- $(document).on("click", (e) => { this.handleDocumentClick(e) })
-
- $(document).keyup(function(e) {
- if (e.keyCode == 27) {
- this.collapse(this.$widget, this.$trigger, this.$target)
- }
- })
-
- this.$widget.delegate(".select-widget-value__delete", "click", function(e) {
- e.preventDefault()
- e.stopPropagation()
-
- // Uncheck checkbox
- const checkboxId = e.target.parentElement.getAttribute("data-list-item")
- const checkbox = document.getElementById(checkboxId)
- checkbox.checked = false
- $(checkbox).parent().trigger("click") // Needed for single-select
- $(checkbox).trigger("change")
- })
-
- this.$search.unbind("focus", this.expandWidgetHandler)
- this.$search.on("focus", (e) => { this.expandWidgetHandler(e) })
-
- this.$search.unbind("keydown")
- this.$search.on("keydown", (e) => { this.handleKeyDown(e) })
-
- this.$search.unbind("keyup")
- this.$search.on("keyup", (e) => { this.handleKeyUp(e) })
-
- this.$search.unbind("click")
- this.$search.on("click", (e) => {
- // Prevent bubbling the click event to the $widget (which expands/collapses the widget on click).
- e.stopPropagation()
- })
- }
-
- handleWidgetClick() {
- if (this.$trigger.attr("aria-expanded") === "true") {
- this.collapse(this.$widget, this.$trigger, this.$target)
- } else {
- this.expand(this.$widget, this.$trigger, this.$target)
- }
- }
-
- handleDocumentClick(e) {
- const clickedOutside = !this.el.is(e.target) && this.el.has(e.target).length === 0
- if (clickedOutside) {
- this.collapse(this.$widget, this.$trigger, this.$target)
- }
- }
-
- handleKeyUp(e) {
- const searchValue = $(e.target)
- .val()
- .toLowerCase()
- const self = this
-
- this.$fakeInput =
- this.$fakeInput ||
- $("")
- .addClass("form-control-search")
- .css("white-space", "nowrap")
- this.$fakeInput.text(searchValue)
- this.$search.css("width", this.$fakeInput.insertAfter(this.$search).width() + 100)
- this.$fakeInput.detach()
-
- if (this.$selectWidget.data("value-selector") == "typeahead") {
- const url = `/${this.$selectWidget.data(
- "layout-id"
- )}/match/layout/${this.$selectWidget.data("typeahead-id")}`
- // Debounce the user input, only execute after 200ms if another one
- // hasn't started
- clearTimeout(this.timeout)
- this.$available.find(".spinner").removeAttr("hidden")
- this.timeout = setTimeout(function() {
- self.$available.find(".answer").not('.answer--blank').each(function() {
- const $answer = $(this)
- if (!$answer.find('input:checked').length) {
- $answer.remove()
- }
- })
- self.updateJson(url + '?noempty=1&q=' + searchValue, true)
- }, 200)
- } else {
- // hide the answers that do not contain the searchvalue
- let anyHits = false
- $.each(this.$answers, function() {
- const labelValue = $(this)
- .find("label")[0]
- .innerHTML.toLowerCase()
- if (labelValue.indexOf(searchValue) === -1) {
- $(this).attr("hidden", "")
- } else {
- anyHits = true
- $(this).removeAttr("hidden", "")
+ /**
+ * Create a new SelectWidgetComponent.
+ * @param {HTMLElement} element - The HTML element that this component is attached to.
+ */
+ constructor(element) {
+ super(element);
+ this.el = $(this.element);
+ this.$selectWidget = this.el;
+ this.$widget = this.el.find(".form-control");
+ this.$trigger = this.$widget.find("[aria-expanded]");
+ this.$current = this.el.find(".current");
+ this.$available = this.el.find(".available");
+ this.$availableItems = this.el.find(".available .answer input");
+ this.$moreInfoButtons = this.el.find(".available .answer .btn-js-more-info");
+ this.$target = this.el.find("#" + this.$trigger.attr("aria-controls"));
+ this.$currentItems = this.$current.find("[data-list-item]");
+ this.$answers = this.el.find(".answer");
+ this.$fakeInput = null;
+ this.$search = this.el.find(".form-control-search");
+ this.lastFetchParams = null;
+ this.multi = this.el.hasClass("multi");
+ this.timeout = undefined;
+ this.required = this.el.hasClass("select-widget--required");
+ // Give each AJAX load its own ID. If a higher ID has started by the time
+ // we get the results, then cancel the current process to prevent
+ // duplicate items being added to the dropdown
+ this.loadCounter = 0;
+
+ this.initSelectWidget();
+
+ if (this.required) {
+ initValidationOnField(this.el);
}
- })
-
- if (anyHits) {
- this.$available.find(".has-noresults").attr("hidden", "")
- } else {
- this.$available.find(".has-noresults").removeAttr("hidden", "")
- }
}
- }
- handleKeyDown(e) {
- const key = e.which || e.keyCode
+ /**
+ * Initializes the SelectWidget component.
+ */
+ initSelectWidget() {
+ this.updateState();
+ if (this.$widget.is("[readonly]")) return;
+ this.connect();
- // If still in search text after previous search and select, ensure that
- // widget expands again to show results
- this.expand(this.$widget, this.$trigger, this.$target)
+ this.$widget.off("click");
+ this.$widget.on("click", () => { this.handleWidgetClick(); });
- switch (key) {
- case 38: // UP
- case 40: // DOWN
- {
- const items = this.$available.find(".answer:not([hidden]) input")
- let nextItem
+ this.$search.off("blur");
+ this.$search.on("blur", (e) => { this.possibleCloseWidget(e); });
- e.preventDefault()
+ this.$availableItems.off("blur");
+ this.$availableItems.on("blur", (e) => { this.possibleCloseWidget(e); });
- if (key === 38) {
- nextItem = items[items.length - 1]
- } else {
- nextItem = items[0]
- }
+ this.$moreInfoButtons.off("blur");
+ this.$moreInfoButtons.on("blur", (e) => { this.possibleCloseWidget(e); });
- if (nextItem) {
- $(nextItem).focus()
- }
+ $(document).on("click", (e) => { this.handleDocumentClick(e); });
- break
- }
- case 13: // ENTER
- {
- e.preventDefault()
-
- // Select the first (visible) item
- const firstItem = this.$available.find(".answer:not([hidden]) input").get(0)
- if (firstItem) {
- $(firstItem)
- .parent()
- .trigger("click")
- }
-
- break
+ $(document).on("keyup", (e) => {
+ if (e.key === "Escape") {
+ this.collapse(this.$widget, this.$trigger, this.$target);
+ }
+ });
+
+ this.$widget.on("click", ".select-widget-value__delete", function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Uncheck checkbox
+ const checkboxId = e.target.parentElement.getAttribute("data-list-item");
+ const checkbox = document.getElementById(checkboxId);
+ checkbox.checked = false;
+ $(checkbox).parent()
+ .trigger("click"); // Needed for single-select
+ $(checkbox).trigger("change");
+ });
+
+ this.$search.off("focus", this.expandWidgetHandler);
+ this.$search.on("focus", (e) => { this.expandWidgetHandler(e); });
+
+ this.$search.off("keydown");
+ this.$search.on("keydown", (e) => { this.handleKeyDown(e); });
+
+ this.$search.off("keyup");
+ this.$search.on("keyup", (e) => { this.handleKeyUp(e); });
+
+ this.$search.off("click");
+ this.$search.on("click", (e) => {
+ // Prevent bubbling the click event to the $widget (which expands/collapses the widget on click).
+ e.stopPropagation();
+ });
+ }
+
+ /**
+ * Handles the click event on the widget.
+ */
+ handleWidgetClick() {
+ if (this.$trigger.attr("aria-expanded") === "true") {
+ this.collapse(this.$widget, this.$trigger, this.$target);
+ } else {
+ this.expand(this.$widget, this.$trigger, this.$target);
}
}
- }
-
- expandWidgetHandler(e) {
- e.stopPropagation()
- this.expand(this.$widget, this.$trigger, this.$target)
- }
-
- collapse($widget, $trigger) {
- this.$selectWidget.removeClass("select-widget--open")
- $trigger.attr("aria-expanded", false)
-
- // Add a small delay when hiding the select widget, to allow IE to also
- // fire the default actions when selecting a radio button by clicking on
- // its label. When the input is hidden on the click event of the label
- // the input isn't actually being selected.
- setTimeout(() => {
- this.$search.val("")
- this.$target.attr("hidden", "")
- this.$answers.removeAttr("hidden")
- }, 50)
- }
-
- updateState() {
- const $visible = this.$current.children("[data-list-item]:not([hidden])")
-
- this.$current.toggleClass("empty", $visible.length === 0)
- }
-
- possibleCloseWidget(e) {
- const newlyFocussedElement = e.relatedTarget || document.activeElement
-
- if (
- !this.$selectWidget.find(newlyFocussedElement).length &&
- newlyFocussedElement &&
- !$(newlyFocussedElement).is(".modal, .page, body") &&
- this.$selectWidget.get(0).parentNode !== newlyFocussedElement
- ) {
- this.collapse(this.$widget, this.$trigger, this.$target)
+
+ /**
+ * Handles clicks outside the widget to collapse it.
+ * @param {JQuery.ClickEvent} e The click event triggered on the document.
+ */
+ handleDocumentClick(e) {
+ const clickedOutside = !this.el.is(e.target) && this.el.has(e.target).length === 0;
+ if (clickedOutside) {
+ this.collapse(this.$widget, this.$trigger, this.$target);
+ }
}
- }
-
- connectMulti() {
- const self = this
- return function() {
- const $item = $(this)
- const itemId = $item.data("list-item")
- const $associated = $("#" + itemId)
-
- $associated.unbind("change")
- $associated.on("change", (e) => {
- if ($(e.target).prop("checked")) {
- $item.removeAttr("hidden")
+
+ /**
+ * Handles keyup events on the search input.
+ */
+ handleKeyUp(e) {
+ const searchValue = $(e.target)
+ .val()
+ .toLowerCase();
+ const self = this;
+
+ this.$fakeInput = this.$fakeInput ||
+ $("")
+ .addClass("form-control-search")
+ .css("white-space", "nowrap");
+ this.$fakeInput.text(searchValue);
+ this.$search.css("width", this.$fakeInput.insertAfter(this.$search).width() + 100);
+ this.$fakeInput.detach();
+
+ if (this.$selectWidget.data("value-selector") == "typeahead") {
+ const url = `/${this.$selectWidget.data(
+ "layout-id"
+ )}/match/layout/${this.$selectWidget.data("typeahead-id")}`;
+ // Debounce the user input, only execute after 200ms if another one
+ // hasn't started
+ clearTimeout(this.timeout);
+ this.$available.find(".spinner").removeAttr("hidden");
+ this.timeout = setTimeout(function () {
+ self.$available.find(".answer").not(".answer--blank")
+ .each(function () {
+ const $answer = $(this);
+ if (!$answer.find("input:checked").length) {
+ $answer.remove();
+ }
+ });
+ self.updateJson(url + "?noempty=1&q=" + searchValue, true);
+ }, 200);
} else {
- $item.attr("hidden", "")
+ // hide the answers that do not contain the searchvalue
+ let anyHits = false;
+ $.each(this.$answers, function () {
+ const labelValue = $(this)
+ .find("label")[0]
+ .innerHTML.toLowerCase();
+ if (labelValue.indexOf(searchValue) === -1) {
+ $(this).attr("hidden", "");
+ } else {
+ anyHits = true;
+ $(this).removeAttr("hidden", "");
+ }
+ });
+
+ if (anyHits) {
+ this.$available.find(".has-noresults").attr("hidden", "");
+ } else {
+ this.$available.find(".has-noresults").removeAttr("hidden", "");
+ }
}
- self.updateState()
- })
+ }
+
+ /**
+ * Handles keydown events on the search input.
+ */
+ handleKeyDown(e) {
+ const key = e.key;
- $associated.unbind("keydown")
- $associated.on("keydown", function(e) {
- const key = e.which || e.keyCode
+ // If still in search text after previous search and select, ensure that
+ // widget expands again to show results
+ this.expand(this.$widget, this.$trigger, this.$target);
switch (key) {
- case 38: // UP
- case 40: // DOWN
+ case "ArrowUp": // UP
+ case "ArrowDown": // DOWN
{
- const currentIndex = self.$answers.index($associated.closest(".answer"))
- let nextItem
+ const items = this.$available.find(".answer:not([hidden]) input");
+ let nextItem;
- e.preventDefault()
+ e.preventDefault();
- if (key === 38) {
- nextItem = self.$answers[currentIndex - 1]
- } else {
- nextItem = self.$answers[currentIndex + 1]
- }
+ if (key === "ArrowUp") {
+ nextItem = items[items.length - 1];
+ } else {
+ nextItem = items[0];
+ }
- if (nextItem) {
- $(nextItem)
- .find("input")
- .focus()
- }
+ if (nextItem) {
+ $(nextItem).trigger("focus");
+ }
- break
+ break;
}
- case 13:
+ case "Enter": // ENTER
{
- e.preventDefault()
- $(this).trigger("click")
- break
+ e.preventDefault();
+
+ // Select the first (visible) item
+ const firstItem = this.$available.find(".answer:not([hidden]) input").get(0);
+ if (firstItem) {
+ $(firstItem)
+ .parent()
+ .trigger("click");
+ }
+
+ break;
}
}
- })
}
- }
-
- connectSingle() {
- const self = this;
- this.$currentItems.each((_, item) => {
- const $item = $(item)
- const itemId = $item.data("list-item")
- const $associated = $("#" + itemId)
-
- $associated.off("click");
- $associated.on("click", function(e) {
+ /**
+ * Handles the focus event on the search input to expand the widget.
+ */
+ expandWidgetHandler(e) {
e.stopPropagation();
- });
-
- $associated.off("change");
- $associated.on("change", function() {
- // First hide all items in the drop-down display
- self.$currentItems.each((_, currentItem) => {
- $(currentItem).attr("hidden", "")
- })
- // Then show the one selected
- if ($associated.prop("checked")) {
- $item.removeAttr("hidden")
- }
- // Update state so as to show "select option" default text for nothing
- // selected
- self.updateState()
- });
-
- $associated.parent().unbind("keypress")
- $associated.parent().on("keypress", (e) => {
- // KeyCode Enter or Spacebar
- if (e.keyCode === 13 || e.keyCode === 32) {
- e.preventDefault()
- $(e.target).parent().trigger("click")
- }
- })
-
- $associated.parent().off("click")
- $associated.parent().on("click", () => {
- // Need to collapse on click (not change) otherwise drop-down will
- // collapse when changing using the keyboard
- this.collapse(this.$widget, this.$trigger, this.$target)
- })
- })
- }
-
- connect() {
- if (this.multi) {
- this.$currentItems.each(this.connectMulti())
- } else {
- this.connectSingle()
+ this.expand(this.$widget, this.$trigger, this.$target);
}
- }
- currentLi(multi, field, value_id, value_text, value_html, checked) {
- if (multi && !value_id) {
- return $('