diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..b196c135c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,28 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false +insert_final_newline = false + +[*.{yml,yaml}] +indent_size = 2 + +[*.{ts,js,jsx,tsx}] +quote_type = double +continuation_indent_size = 2 +curly_brace_next_line = false +indent_brace_style = BSD +spaces_around_operators = true +spaces_around_brackets = true + +[*.{pl,pm,t,PL}] +max_line_length = off +continuation_indent_size = 4 diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index bbf6f23e2..000000000 --- a/.eslintignore +++ /dev/null @@ -1,11 +0,0 @@ -public -src/frontend/components/dashboard/lib/react/polyfills -babel.config.js -webpack.config.js -jest.config.js -tsconfig.json -src/frontend/js/lib/jqplot -src/frontend/js/lib/jquery -src/frontend/js/lib/plotly -src/frontend/components/timeline -fengari-web.js diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 9d2f57a34..000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,46 +0,0 @@ -module.exports = { - "env": { - "browser": true, - "es6": true, - "jest": true, - "jquery": true, - }, - "settings": { - "react": { - "version": "detect" - } - }, - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:react/recommended" - ], - "overrides": [ - { - "env": { - "node": true, - }, - "files": [ - ".eslintrc.{js,cjs}" - ], - "parserOptions": { - "sourceType": "script" - } - } - ], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": "es6", - "sourceType": "module" - }, - "plugins": [ - "@typescript-eslint", - "react" - ], - "rules": { - "react/prop-types": 0, - "@typescript-eslint/no-explicit-any": 0, - "no-prototype-builtins": 0, - "@typescript-eslint/ban-types": 0, - } -}; diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 000000000..b402bafb9 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,44 @@ +import js from "@eslint/js"; +import globals from "globals"; +import tseslint from "typescript-eslint"; +import pluginReact from "eslint-plugin-react"; +import css from "@eslint/css"; +import { defineConfig } from "eslint/config"; +import stylistic from "@stylistic/eslint-plugin"; +import jsdoc from "eslint-plugin-jsdoc"; + +export default defineConfig([ + { settings: { react: { version: "detect" } } }, + { ignores: ["*.cjs", "eslint.config.mjs", "**/public/**", "**/node_modules/**", "**/cypress/**", "cypress.config.ts", ".stylelintrc.js", "src/frontend/testing/**", "src/frontend/css/stylesheets/external/**", "src/frontend/components/dashboard/lib/react/polyfills/**", "babel.config.js", "webpack.config.js", "jest.config.js", "tsconfig.json", "src/frontend/js/lib/jqplot/**", "src/frontend/js/lib/jquery/**", "src/frontend/js/lib/plotly/**", "src/frontend/components/timeline/**", "fengari-web.js"] }, + { files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], plugins: { js }, extends: ["js/recommended"] }, + { files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], languageOptions: { globals: { ...globals.browser, ...globals.jquery, ...globals.jest } } }, + tseslint.configs.recommended, + pluginReact.configs.flat.recommended, + { files: ["**/*.css"], plugins: { css }, language: "css/css", extends: ["css/recommended"] }, + { plugins: {'@stylistic': stylistic, jsdoc} }, + { + rules: { + "@typescript-eslint/no-explicit-any": "off", + 'react/prop-types': 'off', + 'react/no-deprecated': 'off', // We are currently using deprecated React features, so we disable this rule - this will change in the future + '@stylistic/quotes': ['error', 'single'], + '@stylistic/no-extra-semi': 'error', + '@stylistic/semi': ['error', 'always'], + '@stylistic/curly-newline': 'error', + '@stylistic/indent': ['error', 4], + '@stylistic/comma-dangle': ['error', 'never'], + "jsdoc/require-jsdoc": [ + "error", + { + require: { + FunctionDeclaration: true, + MethodDefinition: true, + ClassDeclaration: true, + ArrowFunctionExpression: false, + FunctionExpression: false + } + } + ], + } + } +]); diff --git a/package.json b/package.json index 00751886e..2bd6bc20f 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "test": "jest", "build:dev": "webpack --env development --progress -w", "test:watch": "jest --watch", - "e2e": "yarn cypress run" + "e2e": "yarn cypress run", + "prebuild": "npx update-browserslist-db@latest", + "pretest": "npx update-browserslist-db@latest" }, "dependencies": { "@egjs/hammerjs": "^2.0.0", @@ -54,7 +56,10 @@ "@babel/preset-react": "^7.16.7", "@babel/preset-typescript": "^7.16.7", "@babel/runtime-corejs3": "^7.14.7", + "@eslint/css": "^0.10.0", + "@eslint/js": "^9.31.0", "@jest/globals": "^29.7.0", + "@stylistic/eslint-plugin": "^5.2.0", "@testing-library/react": "12", "@types/jest": "^29.5.6", "@types/jquery": "^3.5.24", @@ -63,8 +68,6 @@ "@types/react-dom": "^17.0.14", "@types/react-grid-layout": "^1.3.2", "@types/typeahead.js": "^0.11.6", - "@typescript-eslint/eslint-plugin": "^7.7.0", - "@typescript-eslint/parser": "^7.7.0", "@webpack-cli/serve": "^2.0.1", "autoprefixer": "^9.8.8", "babel-loader": "^8.2.2", @@ -74,8 +77,10 @@ "core-js": "^3.15.2", "css-loader": "^3.2.0", "cypress": "^13.7.2", - "eslint": "^8.57.0", - "eslint-plugin-react": "^7.34.1", + "eslint": "^9.31.0", + "eslint-plugin-jsdoc": "^52.0.0", + "eslint-plugin-react": "^7.37.5", + "globals": "^16.3.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "mini-css-extract-plugin": "^2.7.2", @@ -85,6 +90,7 @@ "terser-webpack-plugin": "^5.3.6", "ts-loader": "~8.2.0", "typescript": "5.4.3", + "typescript-eslint": "^8.37.0", "webpack": "^5.75.0", "webpack-cli": "^5.0.1", "webpack-dev-server": "^4.11.1", diff --git a/src/frontend/components/alert/lib/alertBase.ts b/src/frontend/components/alert/lib/alertBase.ts index 9f5b0c617..7d25e7add 100644 --- a/src/frontend/components/alert/lib/alertBase.ts +++ b/src/frontend/components/alert/lib/alertBase.ts @@ -1,6 +1,9 @@ import { Hidable, Renderable } from 'util/renderable'; import { AlertType } from './types'; +/** + * AlertBase serves as a foundational class for creating various types of alert components in the application. + */ export abstract class AlertBase extends Hidable implements Renderable { /** * Create an instance of AlertBase. diff --git a/src/frontend/components/alert/lib/dangerAlert.ts b/src/frontend/components/alert/lib/dangerAlert.ts index af77e65f3..b853554e3 100644 --- a/src/frontend/components/alert/lib/dangerAlert.ts +++ b/src/frontend/components/alert/lib/dangerAlert.ts @@ -1,5 +1,8 @@ -import { AlertBase } from "./alertBase"; +import { AlertBase } from './alertBase'; +/** + * Class representing a danger alert. + */ export class DangerAlert extends AlertBase { /** * Create an instance of InfoAlert. diff --git a/src/frontend/components/alert/lib/infoAlert.ts b/src/frontend/components/alert/lib/infoAlert.ts index 6a9214dbb..1eff8111e 100644 --- a/src/frontend/components/alert/lib/infoAlert.ts +++ b/src/frontend/components/alert/lib/infoAlert.ts @@ -1,10 +1,12 @@ import { AlertBase } from './alertBase'; +/** + * InfoAlert class represents an informational alert in the application. + */ export class InfoAlert extends AlertBase { /** * Create an instance of InfoAlert. * This class extends AlertBase to provide a specific implementation for info alerts. - * It uses the AlertType.INFO to set the alert type. * @class * @public * @memberof alert.lib @@ -12,6 +14,6 @@ export class InfoAlert extends AlertBase { * @param {string} message - The message to be displayed in the info alert. */ constructor(message: string) { - super(message, "info"); + super(message, 'info'); } -} \ No newline at end of file +} diff --git a/src/frontend/components/alert/lib/successAlert.ts b/src/frontend/components/alert/lib/successAlert.ts index ee49396b6..99fdc280c 100644 --- a/src/frontend/components/alert/lib/successAlert.ts +++ b/src/frontend/components/alert/lib/successAlert.ts @@ -1,5 +1,8 @@ -import { AlertBase } from "./alertBase"; +import { AlertBase } from './alertBase'; +/** + * Class representing a success alert. +*/ export class SuccessAlert extends AlertBase { /** * Create an instance of InfoAlert. diff --git a/src/frontend/components/alert/lib/warningAlert.ts b/src/frontend/components/alert/lib/warningAlert.ts index 2dce92e08..9a668c638 100644 --- a/src/frontend/components/alert/lib/warningAlert.ts +++ b/src/frontend/components/alert/lib/warningAlert.ts @@ -1,10 +1,12 @@ -import { AlertBase } from "./alertBase"; +import { AlertBase } from './alertBase'; +/** + * Class representing a warning alert. This class extends AlertBase to provide a specific implementation for warning alerts. + */ export class WarningAlert extends AlertBase { /** * Create an instance of InfoAlert. * This class extends AlertBase to provide a specific implementation for info alerts. - * It uses the AlertType.INFO to set the alert type. * @class * @public * @memberof alert.lib @@ -12,6 +14,6 @@ export class WarningAlert extends AlertBase { * @param {string} message - The message to be displayed in the info alert. */ constructor(message: string) { - super(message, "warning"); + super(message, 'warning'); } } diff --git a/src/frontend/components/button/index.js b/src/frontend/components/button/index.js index 107ccd5b2..f4643ac20 100644 --- a/src/frontend/components/button/index.js +++ b/src/frontend/components/button/index.js @@ -1,4 +1,4 @@ -import { initializeComponent } from 'component' -import ButtonComponent from './lib/component' +import { initializeComponent } from 'component'; +import ButtonComponent from './lib/component'; -export default (scope) => initializeComponent(scope, 'button[class*="btn-js-"]', ButtonComponent) +export default (scope) => initializeComponent(scope, 'button[class*="btn-js-"]', ButtonComponent); diff --git a/src/frontend/components/button/lib/RenderableButton.ts b/src/frontend/components/button/lib/RenderableButton.ts index b8004011a..b67b43559 100644 --- a/src/frontend/components/button/lib/RenderableButton.ts +++ b/src/frontend/components/button/lib/RenderableButton.ts @@ -1,18 +1,31 @@ -import { Renderable } from "util/renderable"; +import { Renderable } from 'util/renderable'; +/** + * A simple button component that can be rendered to the DOM. It takes a text, an onClick handler, and an optional list of CSS classes. + */ export class RenderableButton implements Renderable { classList: string[] = []; + /** + * Creates a new RenderableButton instance. + * @param text The text to display in the button + * @param onClick The onclick listener for when the button is clicked + * @param classList Any classes to add to the button + */ constructor(private readonly text: string, private readonly onClick: (ev: MouseEvent)=>void, ...classList: string[]) { this.classList = classList; } + /** + * Renders the button to an HTMLButtonElement. + * @returns A button element to attach to the DOM + */ render(): HTMLButtonElement { const button = document.createElement('button'); button.textContent = this.text; button.addEventListener('click', this.onClick); button.classList.add(...this.classList, 'btn'); - const btnType = this.classList.find(b=>b.startsWith('btn-')) ? '' : 'btn-default' + const btnType = this.classList.find(b=>b.startsWith('btn-')) ? '' : 'btn-default'; if(btnType) { button.classList.add(btnType); } diff --git a/src/frontend/components/button/lib/cancel-button.ts b/src/frontend/components/button/lib/cancel-button.ts index a81518d80..c9ba511cd 100644 --- a/src/frontend/components/button/lib/cancel-button.ts +++ b/src/frontend/components/button/lib/cancel-button.ts @@ -1,9 +1,14 @@ -import { clearSavedFormValues } from "./common"; +import { clearSavedFormValues } from './common'; +/** + * Create a cancel button that navigates away from the page + * This component will navigate away to the parameter defined in the data-href attribute, or will navigate back + * @param { HTMLElement | JQuery } el The button element + */ export default function createCancelButton(el: HTMLElement | JQuery) { const $el = $(el); if ($el[0].tagName !== 'BUTTON') return; - $el.data('cancel-button', "true"); + $el.data('cancel-button', 'true'); $el.on('click', async () => { const href = $el.data('href'); await clearSavedFormValues(); @@ -12,4 +17,4 @@ export default function createCancelButton(el: HTMLElement | JQuery else window.history.back(); }); -} \ No newline at end of file +} diff --git a/src/frontend/components/button/lib/common.test.ts b/src/frontend/components/button/lib/common.test.ts index 674324fa1..ee0c1c25e 100644 --- a/src/frontend/components/button/lib/common.test.ts +++ b/src/frontend/components/button/lib/common.test.ts @@ -1,23 +1,23 @@ -import { describe, it, expect } from "@jest/globals" -import { layoutId, recordId, table_key } from "./common"; +import { describe, it, expect } from '@jest/globals'; +import { layoutId, recordId, table_key } from './common'; -describe("Common button tests", () => { - it("should populate table_key", () => { - expect(table_key()).toBe("linkspace-record-change-undefined-0"); // Undefined because $('body').data('layout-identifier') is not defined +describe('Common button tests', () => { + it('should populate table_key', () => { + expect(table_key()).toBe('linkspace-record-change-undefined-0'); // Undefined because $('body').data('layout-identifier') is not defined }); - it("should have a layoutId", () => { + it('should have a layoutId', () => { $('body').data('layout-identifier', 'layoutId'); expect(layoutId()).toBe('layoutId'); }); - it("should have a recordId", () => { - expect(isNaN(parseInt(location.pathname.split('/').pop() ?? ""))).toBe(true); + it('should have a recordId', () => { + expect(isNaN(parseInt(location.pathname.split('/').pop() ?? ''))).toBe(true); expect(recordId()).toBe(0); }); - it("should populate table_key fully", () => { + it('should populate table_key fully', () => { $('body').data('layout-identifier', 'layoutId'); - expect(table_key()).toBe("linkspace-record-change-layoutId-0"); + expect(table_key()).toBe('linkspace-record-change-layoutId-0'); }); }); diff --git a/src/frontend/components/button/lib/common.ts b/src/frontend/components/button/lib/common.ts index 7654c2103..970a97fec 100644 --- a/src/frontend/components/button/lib/common.ts +++ b/src/frontend/components/button/lib/common.ts @@ -1,8 +1,7 @@ -import StorageProvider from "util/storageProvider"; +import StorageProvider from 'util/storageProvider'; /** * Clear all saved form values for the current record - * @param $form The form to clear the data for */ export async function clearSavedFormValues() { const ls = storage(); @@ -13,32 +12,33 @@ export async function clearSavedFormValues() { /** * Get the layout identifier from the body data - * @returns The layout identifier + * @returns { number } The layout identifier */ -export function layoutId() { +export function layoutId(): number { return $('body').data('layout-identifier'); } /** * Get the record identifier from the body data - * @returns The record identifier + * @returns { number } The record identifier */ -export function recordId() { - return $('body').find('.form-edit').data('current-id') || 0; +export function recordId(): number { + return $('body').find('.form-edit') + .data('current-id') || 0; } /** * Get the key for the table used for saving form values - * @returns The key for the table + * @returns {string} The key for the table */ -export function table_key() { +export function table_key(): string { return `linkspace-record-change-${layoutId()}-${recordId()}`; } /** * Get the storage object - this originally was used in debugging to allow for the storage object to be mocked - * @returns The storage object + * @returns { StorageProvider } The storage object */ -export function storage() { +export function storage(): StorageProvider { return new StorageProvider(table_key()); -} \ No newline at end of file +} diff --git a/src/frontend/components/button/lib/component.test.ts b/src/frontend/components/button/lib/component.test.ts index f0c8bb8dd..c2267c634 100644 --- a/src/frontend/components/button/lib/component.test.ts +++ b/src/frontend/components/button/lib/component.test.ts @@ -1,29 +1,29 @@ -import { describe, it, expect } from "@jest/globals" +import { describe, it, expect } from '@jest/globals'; import ButtonComponent from './component'; -describe("Button Component", () => { +describe('Button Component', () => { const buttonDefinitions = [ - { name: "report", class: "btn-js-report" }, - { name: "more info", class: "btn-js-more-info" }, - { name: "delete", class: "btn-js-delete" }, - { name: "submit field", class: "btn-js-submit-field" }, - { name: "add all fields", class: "btn-js-toggle-all-fields" }, - { name: "submit draft record", class: "btn-js-submit-draft-record" }, - { name: "submit record", class: "btn-js-submit-record" }, - { name: "save view", class: "btn-js-save-view" }, - { name: "show blank", class: "btn-js-show-blank" }, - { name: "curval remove", class: "btn-js-curval-remove" }, - { name: "remove unload", class: "btn-js-remove-unload" } + { name: 'report', class: 'btn-js-report' }, + { name: 'more info', class: 'btn-js-more-info' }, + { name: 'delete', class: 'btn-js-delete' }, + { name: 'submit field', class: 'btn-js-submit-field' }, + { name: 'add all fields', class: 'btn-js-toggle-all-fields' }, + { name: 'submit draft record', class: 'btn-js-submit-draft-record' }, + { name: 'submit record', class: 'btn-js-submit-record' }, + { name: 'save view', class: 'btn-js-save-view' }, + { name: 'show blank', class: 'btn-js-show-blank' }, + { name: 'curval remove', class: 'btn-js-curval-remove' }, + { name: 'remove unload', class: 'btn-js-remove-unload' } ]; - it("should not create a button with an invalid type", () => { + it('should not create a button with an invalid type', () => { const buttonElement = document.createElement('button'); buttonElement.classList.add('btn'); const button = new ButtonComponent(buttonElement); expect(button.linkedClasses).toStrictEqual([]); }); - it("should not create a button with an invalid type but with valid class prefix", () => { + it('should not create a button with an invalid type but with valid class prefix', () => { const buttonElement = document.createElement('button'); buttonElement.classList.add('btn-js-nope'); const button = new ButtonComponent(buttonElement); @@ -39,7 +39,7 @@ describe("Button Component", () => { }); } - it("Should create a composite button", () => { + it('Should create a composite button', () => { const buttonElement = document.createElement('button'); buttonElement.classList.add('btn-js-report'); buttonElement.classList.add('btn-js-remove-unload'); diff --git a/src/frontend/components/button/lib/component.ts b/src/frontend/components/button/lib/component.ts index 6ae1ced67..1e5ae7768 100644 --- a/src/frontend/components/button/lib/component.ts +++ b/src/frontend/components/button/lib/component.ts @@ -1,8 +1,7 @@ -import {Component} from 'component' +import {Component} from 'component'; /** * Button component - * @extends Component */ class ButtonComponent extends Component { /** @@ -19,6 +18,7 @@ class ButtonComponent extends Component { /** * Get the map of button components + * @returns {Map) => void>} The map of button components */ private get buttonsMap(): Map) => void> { if (!ButtonComponent.staticButtonsMap) ButtonComponent.initMap(); @@ -27,11 +27,11 @@ class ButtonComponent extends Component { /** * Create a button component - * @param element {HTMLElement} The button element + * @param { HTMLElement } element The button element */ constructor(element: HTMLElement) { - super(element) - this.initButton(element) + super(element); + this.initButton(element); } /** @@ -42,13 +42,13 @@ class ButtonComponent extends Component { map.set('btn-js-report', (el) => { import(/* webpackChunkName: "create-report-button" */ './create-report-button') .then(({default: CreateReportButtonComponent}) => { - new CreateReportButtonComponent(el) + new CreateReportButtonComponent(el); }); }); map.set('btn-js-more-info', (el) => { import(/* webpackChunkName: "more-info-button" */ './more-info-button') .then(({default: createMoreInfoButton}) => { - createMoreInfoButton(el) + createMoreInfoButton(el); }); }); map.set('btn-js-delete', (el) => { @@ -58,7 +58,7 @@ class ButtonComponent extends Component { }); }); map.set('btn-js-submit-field', (el) => { - import(/* webpackChunkName: "submit-field-button" */ "./submit-field-button") + import(/* webpackChunkName: "submit-field-button" */ './submit-field-button') .then(({default: SubmitFieldButtonComponent}) => { new SubmitFieldButtonComponent(el); }); @@ -116,13 +116,13 @@ class ButtonComponent extends Component { /** * Initialize the button - * @param element {HTMLElement} The button element + * @param {HTMLElement} element The button element */ private initButton(element: HTMLElement) { - const el: JQuery = $(element) + const el: JQuery = $(element); element.classList.forEach((className) => { if(!className.startsWith('btn-js-')) return; - if (!this.buttonsMap) throw "Buttons map is not initialized"; + if (!this.buttonsMap) throw 'Buttons map is not initialized'; if (!this.buttonsMap.has(className)) return; this.linkedClasses.push(className); this.buttonsMap.get(className)(el); @@ -130,4 +130,4 @@ class ButtonComponent extends Component { } } -export default ButtonComponent +export default ButtonComponent; diff --git a/src/frontend/components/button/lib/create-report-button.test.ts b/src/frontend/components/button/lib/create-report-button.test.ts index 77d9f35b1..dc73d8b79 100644 --- a/src/frontend/components/button/lib/create-report-button.test.ts +++ b/src/frontend/components/button/lib/create-report-button.test.ts @@ -1,6 +1,6 @@ import { validateRequiredFields } from 'validation'; -import CreateReportButtonComponent from "./create-report-button"; -import { describe, it, expect, jest } from "@jest/globals" +import CreateReportButtonComponent from './create-report-button'; +import { describe, it, expect } from '@jest/globals'; describe('create-report-button', () => { it('does not submit form if no checkboxes are checked', () => { @@ -54,7 +54,7 @@ describe('create-report-button', () => { `; - let $submit = $('#submit'); + const $submit = $('#submit'); new CreateReportButtonComponent($submit); const submitSpy = jest.fn((ev) => { ev.preventDefault(); diff --git a/src/frontend/components/button/lib/create-report-button.ts b/src/frontend/components/button/lib/create-report-button.ts index a2cc6efe2..fde0a1941 100644 --- a/src/frontend/components/button/lib/create-report-button.ts +++ b/src/frontend/components/button/lib/create-report-button.ts @@ -1,4 +1,4 @@ -import {validateRequiredFields} from "validation"; +import {validateRequiredFields} from 'validation'; /** * CreateReportButtonComponent class to create a report submission button component @@ -13,7 +13,7 @@ export default class CreateReportButtonComponent { constructor(element:JQuery) { element.on('click', (ev) => { const $button = $(ev.target).closest('button'); - const $form = $button.closest("form"); + const $form = $button.closest('form'); if (!this.canSubmitRecordForm) { ev.preventDefault(); @@ -33,7 +33,7 @@ export default class CreateReportButtonComponent { * @param {JQuery} $button form to submit */ submit($button:JQuery) { - $button.trigger("click"); - $button.prop("disabled", true); + $button.trigger('click'); + $button.prop('disabled', true); } } diff --git a/src/frontend/components/button/lib/delete-button.test.ts b/src/frontend/components/button/lib/delete-button.test.ts index ef11b3b9d..f6162b4de 100644 --- a/src/frontend/components/button/lib/delete-button.test.ts +++ b/src/frontend/components/button/lib/delete-button.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "@jest/globals"; +import { describe, it, expect } from '@jest/globals'; import createDeleteButton from './delete-button'; describe('button tests', () => { @@ -9,8 +9,8 @@ describe('button tests', () => { button.setAttribute('data-toggle', 'toggle'); document.body.appendChild(button); const $button = $(button); - createDeleteButton($button) - expect(() => { $button.trigger('click') }).toThrow('Delete button should have data attributes id, toggle and target!'); + createDeleteButton($button); + expect(() => { $button.trigger('click'); }).toThrow('Delete button should have data attributes id, toggle and target!'); }); it('should throw on absence of target', () => { @@ -21,7 +21,7 @@ describe('button tests', () => { document.body.appendChild(button); const $button = $(button); createDeleteButton($button); - expect(() => { $button.trigger('click') }).toThrow('Delete button should have data attributes id, toggle and target!'); + expect(() => { $button.trigger('click'); }).toThrow('Delete button should have data attributes id, toggle and target!'); }); it('should throw on absence of toggle', () => { @@ -32,7 +32,7 @@ describe('button tests', () => { document.body.appendChild(button); const $button = $(button); createDeleteButton($button); - expect(() => { $button.trigger('click') }).toThrow('Delete button should have data attributes id, toggle and target!'); + expect(() => { $button.trigger('click'); }).toThrow('Delete button should have data attributes id, toggle and target!'); }); it('should set the title of the modal', () => { @@ -51,7 +51,8 @@ describe('button tests', () => { const $button = $(button); createDeleteButton($button); $button.trigger('click'); - expect($(modal).find('.modal-title').text()).toBe('Delete - title'); + expect($(modal).find('.modal-title') + .text()).toBe('Delete - title'); }); it('should set the id of the delete button', () => { @@ -73,6 +74,7 @@ describe('button tests', () => { const $button = $(button); createDeleteButton($button); $button.trigger('click'); - expect($(modal).find('button[type=submit]').val()).toBe('id'); - }) + expect($(modal).find('button[type=submit]') + .val()).toBe('id'); + }); }); diff --git a/src/frontend/components/button/lib/delete-button.ts b/src/frontend/components/button/lib/delete-button.ts index f776b7303..a947171d2 100644 --- a/src/frontend/components/button/lib/delete-button.ts +++ b/src/frontend/components/button/lib/delete-button.ts @@ -1,36 +1,34 @@ -// noinspection ExceptionCaughtLocallyJS - -import {logging} from "logging"; +import { logging } from 'logging'; /** * Create delete button - * @param element {JQuery} - Element to act as a delete button + * @param {JQuery} element Element to act as a delete button */ export default function createDeleteButton(element: JQuery) { element.on('click', (ev) => { - const $button = $(ev.target).closest('button') - const title = $button.attr('data-title') - const id = $button.attr('data-id') - const target = $button.attr('data-target') - const toggle = $button.attr('data-toggle') - const modalTitle = title ? `Delete - ${title}` : 'Delete' - const $deleteModal = $(document).find(`.modal--delete${target}`) + const $button = $(ev.target).closest('button'); + const title = $button.attr('data-title'); + const id = $button.attr('data-id'); + const target = $button.attr('data-target'); + const toggle = $button.attr('data-toggle'); + const modalTitle = title ? `Delete - ${title}` : 'Delete'; + const $deleteModal = $(document).find(`.modal--delete${target}`); try { if (!id || !target || !toggle) { - throw new Error('Delete button should have data attributes id, toggle and target!') + throw new Error('Delete button should have data attributes id, toggle and target!'); } else if ($deleteModal.length === 0) { - throw `There is no modal with id: ${target}` + throw `There is no modal with id: ${target}`; } } catch (e) { - logging.error(e) + logging.error(e); element.on('click', function (e: JQuery.ClickEvent) { - e.stopPropagation() + e.stopPropagation(); }); - if(window.test) throw e; + if (window.test) throw e; } - $deleteModal.find('.modal-title').text(modalTitle) - $deleteModal.find('button[type=submit]').val(id) + $deleteModal.find('.modal-title').text(modalTitle); + $deleteModal.find('button[type=submit]').val(id); }); } diff --git a/src/frontend/components/button/lib/more-info-button.ts b/src/frontend/components/button/lib/more-info-button.ts index f14b64fcc..f9f640769 100644 --- a/src/frontend/components/button/lib/more-info-button.ts +++ b/src/frontend/components/button/lib/more-info-button.ts @@ -2,29 +2,29 @@ * Create a more info button that will load the record body into a modal. * @param {HTMLElement | JQuery} element The button element to attach the event to. */ -export default function createMoreInfoButton(element:HTMLElement | JQuery) { - $(element).on("click", (ev) => { +export default function createMoreInfoButton(element: HTMLElement | JQuery) { + $(element).on('click', (ev) => { const $button = $(ev.target).closest('.btn'); const record_id = $button.data('record-id'); const modal_id = $button.data('target'); const $modal = $(document).find(modal_id); - $modal.find(".modal-title").text(`Record ID: ${record_id}`); - $modal.find(".modal-body").text("Loading..."); - $modal.find(".modal-body").load("/record_body/" + record_id); + $modal.find('.modal-title').text(`Record ID: ${record_id}`); + $modal.find('.modal-body').text('Loading...'); + $modal.find('.modal-body').load('/record_body/' + record_id); /* Trigger focus restoration on modal close */ - $modal.one("show.bs.modal", (ev) => { + $modal.one('show.bs.modal', (ev) => { /* Only register focus restorer if modal will actually get shown */ if (ev.isDefaultPrevented()) return; - $modal.one("hidden.bs.modal", () => { - $button.is(":visible") && $button.trigger("focus"); + $modal.one('hidden.bs.modal', () => { + if ($button.is(':visible')) $button.trigger('focus'); }); }); /* Stop propagation of the escape key, as may have side effects, like closing select widgets. */ - $modal.one("keyup", (ev) => { - if (ev.key === "Escape") ev.stopPropagation(); + $modal.one('keyup', (ev) => { + if (ev.key === 'Escape') ev.stopPropagation(); }); }); } diff --git a/src/frontend/components/button/lib/remove-curval-button.test.js b/src/frontend/components/button/lib/remove-curval-button.test.js index fb52076e6..a22dd35f8 100644 --- a/src/frontend/components/button/lib/remove-curval-button.test.js +++ b/src/frontend/components/button/lib/remove-curval-button.test.js @@ -1,36 +1,36 @@ -import { jest, describe, it, expect, beforeAll, afterEach } from "@jest/globals"; -import createRemoveCurvalButton from "./remove-curval-button"; +import { jest, describe, it, expect, beforeAll, afterEach } from '@jest/globals'; +import createRemoveCurvalButton from './remove-curval-button'; -describe("RemoveCurvalButton", () => { +describe('RemoveCurvalButton', () => { // @ts-expect-error - jest types are not complete window.confirm = jest.fn().mockReturnValue(true); beforeAll(() => { - document.body.innerHTML = ""; + document.body.innerHTML = ''; }); afterEach(() => { - document.body.innerHTML = ""; + document.body.innerHTML = ''; }); - it("should mock as expected", () => { - expect(confirm("Are you sure you wish to continue?")).toBe(true); + it('should mock as expected', () => { + expect(confirm('Are you sure you wish to continue?')).toBe(true); }); it('Should remove a value from a table', () => { - const table = document.createElement("table"); - table.className = "table-curval-group"; - const tbody = document.createElement("tbody"); - const tr = document.createElement("tr"); - tr.className = "table-curval-item"; - const td = document.createElement("td"); + const table = document.createElement('table'); + table.className = 'table-curval-group'; + const tbody = document.createElement('tbody'); + const tr = document.createElement('tr'); + tr.className = 'table-curval-item'; + const td = document.createElement('td'); tr.appendChild(td); tbody.appendChild(tr); table.appendChild(tbody); document.body.appendChild(table); - const td2 = document.createElement("td"); - const button = document.createElement("button"); - button.className = "remove-curval"; + const td2 = document.createElement('td'); + const button = document.createElement('button'); + button.className = 'remove-curval'; td2.appendChild(button); tr.appendChild(td2); createRemoveCurvalButton($(button)); @@ -39,26 +39,26 @@ describe("RemoveCurvalButton", () => { }); it('Should remove a value from a select widget', () => { - const selectWidget = document.createElement("div"); - selectWidget.className = "select-widget"; - const answer = document.createElement("div"); - answer.className = "answer"; - const input = document.createElement("input"); - input.id = "input"; + const selectWidget = document.createElement('div'); + selectWidget.className = 'select-widget'; + const answer = document.createElement('div'); + answer.className = 'answer'; + const input = document.createElement('input'); + input.id = 'input'; answer.appendChild(input); selectWidget.appendChild(answer); - const current = document.createElement("div"); - current.className = "current"; - const li = document.createElement("li"); - li.dataset.listItem = "input"; + const current = document.createElement('div'); + current.className = 'current'; + const li = document.createElement('li'); + li.dataset.listItem = 'input'; current.appendChild(li); selectWidget.appendChild(current); document.body.appendChild(selectWidget); - const button = document.createElement("button"); - button.className = "remove-curval"; + const button = document.createElement('button'); + button.className = 'remove-curval'; answer.appendChild(button); createRemoveCurvalButton($(button)); button.click(); expect(current.children.length).toBe(0); }); -}); +}); \ No newline at end of file diff --git a/src/frontend/components/button/lib/remove-curval-button.ts b/src/frontend/components/button/lib/remove-curval-button.ts index 86f534837..3250be2bc 100644 --- a/src/frontend/components/button/lib/remove-curval-button.ts +++ b/src/frontend/components/button/lib/remove-curval-button.ts @@ -1,31 +1,33 @@ /** * Create remove curval button - * @param element {JQuery} - The element to function as a remove curval button + * @param {JQuery} element The element to function as a remove curval button */ export default function createRemoveCurvalButton(element: JQuery) { element.on('click', (ev: JQuery.ClickEvent) => { const $btn = $(ev.target); if ($btn.closest('.table-curval-group').length) { - if (confirm("Are you sure want to permanently remove this item?")) { - const curvalItem = $btn.closest(".table-curval-item"); + if (confirm('Are you sure want to permanently remove this item?')) { + const curvalItem = $btn.closest('.table-curval-item'); const parent = curvalItem.parent(); curvalItem.remove(); if (parent && parent.children().length === 1) { - parent.children('.odd').children('.dataTables_empty').show(); + parent.children('.odd').children('.dataTables_empty') + .show(); } } else { ev.preventDefault(); } } else if ($btn.closest('.select-widget').length) { - const fieldId = $btn.closest(".answer").find("input").prop("id"); - const $current = $btn.closest(".select-widget").find(".current"); + const fieldId = $btn.closest('.answer').find('input') + .prop('id'); + const $current = $btn.closest('.select-widget').find('.current'); $current.find(`li[data-list-item=${fieldId}]`).remove(); - $btn.closest(".answer").remove(); + $btn.closest('.answer').remove(); - const $visible = $current.children("[data-list-item]:not([hidden])"); - $current.toggleClass("empty", $visible.length === 0); + const $visible = $current.children('[data-list-item]:not([hidden])'); + $current.toggleClass('empty', $visible.length === 0); } }); } diff --git a/src/frontend/components/button/lib/remove-unload-button.ts b/src/frontend/components/button/lib/remove-unload-button.ts index ddcd9adef..ffc6d39cd 100644 --- a/src/frontend/components/button/lib/remove-unload-button.ts +++ b/src/frontend/components/button/lib/remove-unload-button.ts @@ -1,6 +1,6 @@ /** * Create a button that removes the unload event listener - * @param element {JQuery} - The button element to add the click event to + * @param {JQuery} element The button element to add the click event to */ export default function createRemoveUnloadButton(element: JQuery) { element.on('click', () => { diff --git a/src/frontend/components/button/lib/rename-button.ts b/src/frontend/components/button/lib/rename-button.ts index 4e66e6f31..09a205b1e 100644 --- a/src/frontend/components/button/lib/rename-button.ts +++ b/src/frontend/components/button/lib/rename-button.ts @@ -1,4 +1,4 @@ -import { createElement } from "util/domutils"; +import { createElement } from 'util/domutils'; /** * Event fired when the file is renamed @@ -60,25 +60,26 @@ class RenameButton { * @param {string | number} id The file ID to trigger the rename for */ private createElements(button: JQuery, id: string | number) { - if (!id) throw new Error("File ID is null or empty"); - if (!button || button.length < 1) throw new Error("Button element is null or empty") + if (!id) throw new Error('File ID is null or empty'); + if (!button || button.length < 1) throw new Error('Button element is null or empty'); const fileId = id as number ?? parseInt(id.toString()); - if (!fileId) throw new Error("Invalid file id!"); - button.closest(".row") + if (!fileId) throw new Error('Invalid file id!'); + button.closest('.row') .append( createElement('div', { classList: ['col', 'align-content-center'] }) .append( - createElement("input", { + createElement('input', { type: 'text', id: `file-rename-${fileId}`, classList: ['input', 'input--text', 'form-control', 'hidden'], ariaHidden: 'true' }) ) - ).append( + ) + .append( createElement('div', { classList: ['col', 'align-content-center'] }) .append( - createElement("button", { + createElement('button', { id: `rename-confirm-${fileId}`, type: 'button', textContent: 'Rename', @@ -88,7 +89,7 @@ class RenameButton { ev.preventDefault(); this.renameClick(typeof (id) === 'string' ? parseInt(id) : id, ev); }), - createElement("button", { + createElement('button', { id: `rename-cancel-${fileId}`, type: 'button', textContent: 'Cancel', @@ -102,7 +103,7 @@ class RenameButton { /** * Perform click event * @param {number} id The id of the field - * @param {JQuery.ClickEvent} ev The event object + * @param {JQuery.ClickEvent} ev The event object */ private renameClick(id: number, ev: JQuery.ClickEvent) { ev.preventDefault(); @@ -122,12 +123,12 @@ class RenameButton { .on('keydown', (e) => this.renameKeydown(id, $(ev.target), e)) .on('blur', (e) => { this.value = (e.target as HTMLInputElement)?.value; - }) + }); $(`#rename-confirm-${id}`) .removeClass('hidden') .attr('aria-hidden', null) - .on('click', (e) => { - this.triggerRename(id, ev.target, e) + .on('click', () => { + this.triggerRename(id, ev.target); }); $(`#rename-cancel-${id}`) .removeClass('hidden') @@ -135,8 +136,9 @@ class RenameButton { .on('click', () => { const e = $.Event('keydown', { key: 'Escape', code: 27 }); $(`#file-rename-${id}`).trigger(e); - }) - $(ev.target).addClass('hidden').attr('aria-hidden', 'true'); + }); + $(ev.target).addClass('hidden') + .attr('aria-hidden', 'true'); } /** @@ -156,9 +158,8 @@ class RenameButton { * Rename blur event * @param {number} id The id of the field * @param {JQuery} button The button that was clicked - * @param {JQuery.BlurEvent} e The blur event */ - private triggerRename(id: number, button: JQuery, e: JQuery.Event) { + private triggerRename(id: number, button: JQuery) { const previousValue = $(`#current-${id}`).text(); const extension = '.' + previousValue.split('.').pop(); const newName = this.value.endsWith(extension) ? this.value : this.value + extension; @@ -169,8 +170,14 @@ class RenameButton { this.hideRenameControls(id, button); } + /** + * Hides the rename controls + * @param {number} id The id of the field + * @param {JQuery} button The button that was clicked + */ private hideRenameControls(id: number, button: JQuery) { - $(`#current-${id}`).removeClass('hidden').attr('aria-hidden', 'false'); + $(`#current-${id}`).removeClass('hidden') + .attr('aria-hidden', 'false'); $(`#file-rename-${id}`) .addClass('hidden') .attr('aria-hidden', 'true') @@ -183,11 +190,12 @@ class RenameButton { .addClass('hidden') .attr('aria-hidden', null) .off('click'); - $(button).removeClass('hidden').attr('aria-hidden', 'false'); + $(button).removeClass('hidden') + .attr('aria-hidden', 'false'); } } -if(typeof jQuery !== 'undefined') { +if (typeof jQuery !== 'undefined') { (function ($) { $.fn.renameButton = function () { return this.each(function (_: unknown, el: HTMLButtonElement) { diff --git a/src/frontend/components/button/lib/save-view-button.ts b/src/frontend/components/button/lib/save-view-button.ts index 1c212e897..cb17960f3 100644 --- a/src/frontend/components/button/lib/save-view-button.ts +++ b/src/frontend/components/button/lib/save-view-button.ts @@ -1,21 +1,22 @@ -import {validateRequiredFields} from "validation"; -import "@lol768/jquery-querybuilder-no-eval"; +import { validateRequiredFields } from 'validation'; +import '@lol768/jquery-querybuilder-no-eval'; /** - * SaveViewButtonComponent + * Button component for saving views for an instance. + * @param {JQuery} el The jQuery element that represents the Save View button. */ export default function createSaveViewButtonComponent(el: JQuery) { const $form = el.closest('form'); const $global = $form.find('#global'); - const $dropdown = $form.find(".select.dropdown") + const $dropdown = $form.find('.select.dropdown'); $global.on('change', (ev) => { const $input = $form.find('input[type=hidden][name=group_id]'); if ((ev.target as HTMLInputElement)?.checked) { $input.attr('required', 'required'); - if ($dropdown && $dropdown.attr && $dropdown.attr("placeholder") && $dropdown.attr("placeholder").match(/All [Uu]sers/)) $dropdown.addClass('select--required'); + if ($dropdown && $dropdown.attr && $dropdown.attr('placeholder') && $dropdown.attr('placeholder').match(/All [Uu]sers/)) $dropdown.addClass('select--required'); } else { $input.removeAttr('required'); - if ($dropdown && $dropdown.attr && $dropdown.attr("placeholder") && $dropdown.attr("placeholder").match(/All [Uu]sers/)) $dropdown.removeClass('select--required'); + if ($dropdown && $dropdown.attr && $dropdown.attr('placeholder') && $dropdown.attr('placeholder').match(/All [Uu]sers/)) $dropdown.removeClass('select--required'); } }); el.on('click', (ev) => { @@ -26,11 +27,12 @@ export default function createSaveViewButtonComponent(el: JQuery) { select.val(''); select.removeAttr('required'); } - $(".filter").each((_i, el) => { + $('.filter').each((_i, el) => { //Bit of typecasting here, purely because the queryBuilder plugin doesn't have types if (!($(el)).queryBuilder('validate')) ev.preventDefault(); - const res = ($(el)).queryBuilder('getRules') - $(el).next('#filter').val(JSON.stringify(res, null, 2)) - }) + const res = ($(el)).queryBuilder('getRules'); + $(el).next('#filter') + .val(JSON.stringify(res, null, 2)); + }); }); -} \ No newline at end of file +} diff --git a/src/frontend/components/button/lib/show-blank-button.test.ts b/src/frontend/components/button/lib/show-blank-button.test.ts index 7d36ebd01..3d13895ca 100644 --- a/src/frontend/components/button/lib/show-blank-button.test.ts +++ b/src/frontend/components/button/lib/show-blank-button.test.ts @@ -1,32 +1,32 @@ -import { describe, it, expect, afterEach, jest } from "@jest/globals"; -import showBlankButton from "./show-blank-button"; +import { describe, it, expect, afterEach, jest } from '@jest/globals'; +import showBlankButton from './show-blank-button'; -describe("ShowBlankButton", () => { +describe('ShowBlankButton', () => { afterEach(() => { jest.clearAllMocks(); }); - it("shows blank fields", () => { - const element = $("
"); - const button = $(""); + it('shows blank fields', () => { + const element = $('
'); + const button = $(''); element.append(button); - const item = $("
"); + const item = $('
'); element.append(item); - $("body").append(element); + $('body').append(element); showBlankButton(element); - button.trigger("click"); - expect(item.css("display")).not.toBe("none"); + button.trigger('click'); + expect(item.css('display')).not.toBe('none'); }); // For some reason this won't behave as expected - disabling the test for now - it.skip("hides blank fields", () => { - const element = $("
"); - const button = $(""); + it.skip('hides blank fields', () => { + const element = $('
'); + const button = $(''); element.append(button); - const item = $("
"); + const item = $('
'); element.append(item); showBlankButton(element); - button.trigger("click"); - expect(item.css("display")).toBe("none"); + button.trigger('click'); + expect(item.css('display')).toBe('none'); }); -}); +}); \ No newline at end of file diff --git a/src/frontend/components/button/lib/show-blank-button.ts b/src/frontend/components/button/lib/show-blank-button.ts index 4579476f0..e668bc64d 100644 --- a/src/frontend/components/button/lib/show-blank-button.ts +++ b/src/frontend/components/button/lib/show-blank-button.ts @@ -1,17 +1,17 @@ /** * Create a button that toggles the visibility of blank fields. - * @param element {JQuery} The element to attach the button to. + * @param {JQuery} element The element to attach the button to. */ export default function createShowBlankButton(element: JQuery) { element.on('click', (ev) => { const $button = $(ev.target).closest('.btn-js-show-blank'); const $buttonTitle = $button.find('.btn__title')[0]; - const showBlankFields = $buttonTitle.innerHTML === "Show blank values"; + const showBlankFields = $buttonTitle.innerHTML === 'Show blank values'; - $(".list__item--blank").toggle(showBlankFields); + $('.list__item--blank').toggle(showBlankFields); $buttonTitle.innerHTML = showBlankFields - ? "Hide blank values" - : "Show blank values"; + ? 'Hide blank values' + : 'Show blank values'; }); -} \ No newline at end of file +} diff --git a/src/frontend/components/button/lib/submit-draft-record-button.ts b/src/frontend/components/button/lib/submit-draft-record-button.ts index 2b3d67e89..0f6700592 100644 --- a/src/frontend/components/button/lib/submit-draft-record-button.ts +++ b/src/frontend/components/button/lib/submit-draft-record-button.ts @@ -1,16 +1,16 @@ -import { clearSavedFormValues } from "./common"; +import { clearSavedFormValues } from './common'; /** * Create a submit draft record button - * @param element {JQuery} The button element + * @param {JQuery} element The button element */ export default function createSubmitDraftRecordButton(element: JQuery) { - element.on("click", async (ev: JQuery.ClickEvent) => { + element.on('click', async (ev: JQuery.ClickEvent) => { const $button = $(ev.target).closest('button'); - const $form = $button.closest("form"); + const $form = $button.closest('form'); // Remove the required attribute from hidden required dependent fields - $form.find(".form-group *[aria-required]").removeAttr('required'); + $form.find('.form-group *[aria-required]').removeAttr('required'); clearSavedFormValues(); }); } diff --git a/src/frontend/components/button/lib/submit-field-button.test.ts b/src/frontend/components/button/lib/submit-field-button.test.ts index d80a761c0..bbf8d2ca4 100644 --- a/src/frontend/components/button/lib/submit-field-button.test.ts +++ b/src/frontend/components/button/lib/submit-field-button.test.ts @@ -1,36 +1,38 @@ -import { describe, it, expect, beforeEach } from "@jest/globals" -import { initGlobals } from "testing/globals.definitions"; -import SubmitFieldButtonComponent from "./submit-field-button"; +/* eslint-disable jsdoc/require-jsdoc */ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +/* @ts-ignore */ +import { initGlobals } from 'testing/globals.definitions'; +import SubmitFieldButtonComponent from './submit-field-button'; -describe("Submit field button tests", () => { +describe('Submit field button tests', () => { beforeEach(() => { initGlobals(); - }) + }); async function loadSubmitFieldButtonComponent(element: HTMLElement) { - const { default: SubmitFieldButtonComponent } = await import("./submit-field-button"); + const { default: SubmitFieldButtonComponent } = await import('./submit-field-button'); return new SubmitFieldButtonComponent($(element)); } - it("should create a button", async () => { - const element = document.createElement("button"); - element.id = "submit-field-button"; - element.classList.add("btn-js-submit-field"); + it('should create a button', async () => { + const element = document.createElement('button'); + element.id = 'submit-field-button'; + element.classList.add('btn-js-submit-field'); const button = await loadSubmitFieldButtonComponent(element); expect(button).toBeTruthy(); expect(button).toBeInstanceOf(SubmitFieldButtonComponent); }); - it("should perform changes to tree component when one is present", async () => { - const treeConfig = document.createElement("div") - treeConfig.id = "tree-config"; - const treeElement = document.createElement("div"); - treeElement.classList.add("tree-widget-container"); + it('should perform changes to tree component when one is present', async () => { + const treeConfig = document.createElement('div'); + treeConfig.id = 'tree-config'; + const treeElement = document.createElement('div'); + treeElement.classList.add('tree-widget-container'); treeConfig.appendChild(treeElement); document.body.appendChild(treeConfig); - const buttonElement = document.createElement("button"); - buttonElement.id = "submit-field-button"; - buttonElement.classList.add("btn-js-submit-field"); + const buttonElement = document.createElement('button'); + buttonElement.id = 'submit-field-button'; + buttonElement.classList.add('btn-js-submit-field'); await loadSubmitFieldButtonComponent(buttonElement); document.body.appendChild(buttonElement); buttonElement.click(); diff --git a/src/frontend/components/button/lib/submit-field-button.ts b/src/frontend/components/button/lib/submit-field-button.ts index a511f272d..dd14d03e6 100644 --- a/src/frontend/components/button/lib/submit-field-button.ts +++ b/src/frontend/components/button/lib/submit-field-button.ts @@ -1,6 +1,6 @@ -import "jstree"; -import "datatables.net"; -import "@lol768/jquery-querybuilder-no-eval" +import 'jstree'; +import 'datatables.net'; +import '@lol768/jquery-querybuilder-no-eval'; declare global { interface Window { @@ -23,9 +23,9 @@ export default class SubmitFieldButton { /** * Create a submit field button - * @param element The submit button element + * @param {JQuery} element The submit button element */ - constructor(element:JQuery) { + constructor(element: JQuery) { element.on('click', (ev) => { const $jstreeContainer = $('#field_type_tree'); @@ -49,13 +49,13 @@ export default class SubmitFieldButton { let bUpdateDisplayConditions = false; let bUpdatePeopleFilter = false; - const $showInEdit = $("#show_in_edit") + const $showInEdit = $('#show_in_edit'); if (($calcCode.length && $calcCode.is(':visible')) && !$showInEdit.val()) { if (!this.errored) { - const error = document.createElement("div"); - error.classList.add("form-text", "form-text--error"); - error.innerHTML = "Please select the calculation field visibility before submitting the form"; - $showInEdit.closest(".form-group").append(error); + const error = document.createElement('div'); + error.classList.add('form-text', 'form-text--error'); + error.innerHTML = 'Please select the calculation field visibility before submitting the form'; + $showInEdit.closest('.form-group').append(error); error.scrollIntoView(); this.errored = true; } @@ -74,13 +74,13 @@ export default class SubmitFieldButton { bUpdateDisplayConditions = true; } - if(peopleConditionsFieldEl.length && $peopleConditionsFieldRes) { + if (peopleConditionsFieldEl.length && $peopleConditionsFieldRes) { bUpdatePeopleFilter = true; } if (bUpdateTree) { //Bit of typecasting here, purely because the jstree plugin doesn't have types - const v = $jstreeEl.jstree(true).get_json('#', {flat: false}); + const v = $jstreeEl.jstree(true).get_json('#', { flat: false }); const mytext = JSON.stringify(v); const data = $jstreeEl.data(); @@ -88,10 +88,10 @@ export default class SubmitFieldButton { async: false, type: 'POST', url: this.getURL(data), - data: {data: mytext, csrf_token: data.csrfToken} + data: { data: mytext, csrf_token: data.csrfToken } }).done(() => { - // eslint-disable-next-line no-alert - alert('Tree has been updated') + + alert('Tree has been updated'); }); } @@ -101,7 +101,7 @@ export default class SubmitFieldButton { window.UpdateFilter($filterEl, ev); } - if(bUpdatePeopleFilter && window.UpdatePeopleFilter) { + if (bUpdatePeopleFilter && window.UpdatePeopleFilter) { window.UpdatePeopleFilter(peopleConditionsFieldEl, ev); } @@ -123,14 +123,14 @@ export default class SubmitFieldButton { /** * Get the URL for the tree API - * @param data The data for the tree - * @returns The URL for the tree API + * @param {JQuery.PlainObject} data The data for the tree + * @returns {string} The URL for the tree API */ - private getURL(data:JQuery.PlainObject):string { - if (window.test) return ""; + private getURL(data: JQuery.PlainObject): string { + if (window.test) return ''; const devEndpoint = window.siteConfig && window.siteConfig.urls.treeApi; - return devEndpoint ? devEndpoint : `/${data.layoutIdentifier}/tree/${data.columnId}` + return devEndpoint ? devEndpoint : `/${data.layoutIdentifier}/tree/${data.columnId}`; } } diff --git a/src/frontend/components/button/lib/submit-record-button.ts b/src/frontend/components/button/lib/submit-record-button.ts index a857fa9c2..7cb42a193 100644 --- a/src/frontend/components/button/lib/submit-record-button.ts +++ b/src/frontend/components/button/lib/submit-record-button.ts @@ -1,4 +1,4 @@ -import {validateRequiredFields} from "validation"; +import { validateRequiredFields } from 'validation'; /** * Button to submit records @@ -10,13 +10,13 @@ export default class SubmitRecordButton { /** * Create a button to submit records - * @param el {JQuery} Element to create as a button + * @param {JQuery} el Element to create as a button */ constructor(private el: JQuery) { - this.el.on("click", async (ev: JQuery.ClickEvent) => { + this.el.on('click', async (ev: JQuery.ClickEvent) => { const $button = $(ev.target).closest('button'); - const $form = $button.closest("form"); - const $requiredHiddenRecordDependentFields = $form.find(".form-group[data-has-dependency='1'][style*='display: none'] *[aria-required]"); + const $form = $button.closest('form'); + const $requiredHiddenRecordDependentFields = $form.find('.form-group[data-has-dependency=\'1\'][style*=\'display: none\'] *[aria-required]'); const $parent = $button.closest('.modal-body'); if (!this.requiredHiddenRecordDependentFieldsCleared) { @@ -36,15 +36,15 @@ export default class SubmitRecordButton { this.canSubmitRecordForm = true; this.disableButton = false; if ($parent.hasClass('modal-body')) { - $form.trigger("submit"); + $form.trigger('submit'); } else { $button.trigger('click'); } // Prevent double-submission this.disableButton = true; - $button.prop("disabled", true); - if ($button.prop("name")) { - $button.after(``); + $button.prop('disabled', true); + if ($button.prop('name')) { + $button.after(``); } } else { // Re-add the required attribute to required dependent fields @@ -52,7 +52,7 @@ export default class SubmitRecordButton { this.requiredHiddenRecordDependentFieldsCleared = false; } } - this.disableButton && $button.prop("disabled", this.requiredHiddenRecordDependentFieldsCleared); + if (this.disableButton) $button.prop('disabled', this.requiredHiddenRecordDependentFieldsCleared); }); } } diff --git a/src/frontend/components/button/lib/toggle-all-fields-button.ts b/src/frontend/components/button/lib/toggle-all-fields-button.ts index 5322277dd..547e620e5 100644 --- a/src/frontend/components/button/lib/toggle-all-fields-button.ts +++ b/src/frontend/components/button/lib/toggle-all-fields-button.ts @@ -1,18 +1,18 @@ /** * Toggles (switches) all fields from the source toggle table to the destination toggle table - * @param element {JQuery} - The button element + * @param {JQuery} element The button element */ export default function createToggleAllFieldsButton(element: JQuery) { element.on('click', (ev) => { - ev.preventDefault() - const sourceTableId = $(ev.target).data('toggleSource') - const clickedSourceTable = document.querySelector(sourceTableId) - const destinationTableID = $(ev.target).data('toggleDestination') - const rows = $(sourceTableId).find('tbody tr') + ev.preventDefault(); + const sourceTableId = $(ev.target).data('toggleSource'); + const clickedSourceTable = document.querySelector(sourceTableId); + const destinationTableID = $(ev.target).data('toggleDestination'); + const rows = $(sourceTableId).find('tbody tr'); import(/* webpackChunkName: "datatable-toggle-table" */ '../../data-table/lib/toggle-table') - .then(({toggleRowInTable}) => { + .then(({ toggleRowInTable }) => { rows.each((index, row) => { - toggleRowInTable( row, clickedSourceTable, destinationTableID, true) + toggleRowInTable(row, clickedSourceTable, destinationTableID, true); }); }); }); diff --git a/src/frontend/components/calculator/index.js b/src/frontend/components/calculator/index.js index f2c9a2ba8..37e53dffe 100644 --- a/src/frontend/components/calculator/index.js +++ b/src/frontend/components/calculator/index.js @@ -1,9 +1,9 @@ -import { getComponentElements, initializeComponent } from 'component' +import { getComponentElements, initializeComponent } from 'component'; export default (scope) => { if (!getComponentElements(scope, '.calculator').length) return; - import(/* webpackChunkName: "calculator" */ "./lib/component").then( + import(/* webpackChunkName: "calculator" */ './lib/component').then( ({ default: CalculatorComponent }) => initializeComponent(scope, '.calculator', CalculatorComponent)); -} +}; diff --git a/src/frontend/components/calculator/lib/component.js b/src/frontend/components/calculator/lib/component.js index 91e68856e..7dd9ef526 100644 --- a/src/frontend/components/calculator/lib/component.js +++ b/src/frontend/components/calculator/lib/component.js @@ -1,165 +1,180 @@ -import { Component } from 'component' +import { Component } from 'component'; +/** + * CalculatorComponent class to handle calculator functionality in the UI. + * It initializes a calculator dropdown for input fields, allowing users to perform basic arithmetic operations. + */ class CalculatorComponent extends Component { - constructor(element) { - super(element) - this.el = $(this.element) - - this.initCalculator() - } - - initCalculator() { - const selector = this.el.find('input:not([type="checkbox"])') - const $nodes = this.el.find('label:not(.checkbox-label)') - - $nodes.each((i, node) => { - const $el = $(node); - const calculator_id = 'calculator_div' - const calculator_elem = $(``) - - calculator_elem.css({ - position: 'absolute', - 'z-index': 1100, - display: 'none', - padding: '10px' - }) - - $('body').append(calculator_elem) - - calculator_elem.append( - '
' + - '
' + - '
' + - '
' + - ' ' + - "
" + - "
" - ) - - $(document).on('mouseup',(e) => { - if ( - !calculator_elem.is(e.target) && + /** + * Creates an instance of CalculatorComponent. + * @param {HTMLElement} element The element to be initialized as a calculator component. + */ + constructor(element) { + super(element); + this.el = $(this.element); + + this.initCalculator(); + } + + /** + * Initializes the calculator functionality by creating a dropdown + * with buttons for arithmetic operations and an input field for numbers. + * @todo This method should be refactored to improve readability and maintainability. + */ + initCalculator() { + const selector = this.el.find('input:not([type="checkbox"])'); + const $nodes = this.el.find('label:not(.checkbox-label)'); + + $nodes.each((i, node) => { + const $el = $(node); + const calculator_id = 'calculator_div'; + const calculator_elem = $(``); + + calculator_elem.css({ + position: 'absolute', + 'z-index': 1100, + display: 'none', + padding: '10px' + }); + + $('body').append(calculator_elem); + + calculator_elem.append( + '
' + + '
' + + '
' + + '
' + + ' ' + + '
' + + '
' + ); + + $(document).on('mouseup', (e) => { + if ( + !calculator_elem.is(e.target) && calculator_elem.has(e.target).length === 0 - ) { - calculator_elem.hide() - } - }) - - let calculator_operation - let integer_input_elem - - const calculator_button = [ - { - action: 'add', - label: '+', - keypress: ['+'], - operation: function(a, b) { - return a + b - } - }, - { - action: 'subtract', - label: '-', - keypress: ['-'], - operation: function(a, b) { - return a - b - } - }, - { - action: 'multiply', - label: '×', - keypress: ['*', 'X', 'x', '×'], - operation: function(a, b) { - return a * b - } - }, - { - action: 'divide', - label: '÷', - keypress: ['/', '÷'], - operation: function(a, b) { - return a / b - } - } - ] - const keypress_action = {} - const operator_btns_elem = calculator_elem.find('.radio-group--buttons') - - $(calculator_button).each((i) => { - const btn = calculator_button[i] - const button_elem = $( - `
` + - `` + - `` + - `
` - ) - - operator_btns_elem.append(button_elem) - - $(button_elem).find('.radio-group__label').on('click', () => { - $(button_elem).find('.radio-group__input').prop("checked", true) - calculator_operation = btn.operation - calculator_elem.find(':text').focus() - }) - - for (const j in btn.keypress) { - const keypress = btn.keypress[j] - keypress_action[keypress] = btn.action - } - }) - - calculator_elem.find(':text').on('keypress', (e) => { - const key_pressed = e.key - - if (key_pressed in keypress_action) { - const button_selector = `.btn_label_${keypress_action[key_pressed]}` - calculator_elem.find(button_selector).trigger("click") - e.preventDefault() - } - }) - - calculator_elem.find('form').on('submit', (e) => { - const new_value = calculator_operation( - +integer_input_elem.val(), - +calculator_elem.find(':text').val() - ) - - integer_input_elem.val(new_value) - calculator_elem.hide() - e.preventDefault() - }) - - const $calc_button = $('Calculator') - - $calc_button.insertAfter($el).on('click', (e) => { - const calc_elem = $(e.target) - const container_elem = calc_elem.closest('.form-group') - const input_elem = container_elem.find(selector) - - const container_y_offset = container_elem.offset().top - const container_height = container_elem.height() - const calc_div_height = $('#calculator_div').height() - let calculator_y_offset - - if (container_y_offset > calc_div_height) { - calculator_y_offset = container_y_offset - calc_div_height - } else { - calculator_y_offset = container_y_offset + container_height - } - - calculator_elem.css({ - top: calculator_y_offset, - left: container_elem.offset().left - }) - - const calc_input = calculator_elem.find(':text') - calc_input.val('') - calculator_elem.show() - calc_input.trigger("focus") - integer_input_elem = input_elem - }) - }) - } + ) { + calculator_elem.hide(); + } + }); + + let calculator_operation; + let integer_input_elem; + + const calculator_button = [ + { + action: 'add', + label: '+', + keypress: ['+'], + operation: function (a, b) { + return a + b; + } + }, + { + action: 'subtract', + label: '-', + keypress: ['-'], + operation: function (a, b) { + return a - b; + } + }, + { + action: 'multiply', + label: '×', + keypress: ['*', 'X', 'x', '×'], + operation: function (a, b) { + return a * b; + } + }, + { + action: 'divide', + label: '÷', + keypress: ['/', '÷'], + operation: function (a, b) { + return a / b; + } + } + ]; + const keypress_action = {}; + const operator_btns_elem = calculator_elem.find('.radio-group--buttons'); + + $(calculator_button).each((i) => { + const btn = calculator_button[i]; + const button_elem = $( + '
' + + `` + + `` + + '
' + ); + + operator_btns_elem.append(button_elem); + + $(button_elem).find('.radio-group__label') + .on('click', () => { + $(button_elem).find('.radio-group__input') + .prop('checked', true); + calculator_operation = btn.operation; + calculator_elem.find(':text').focus(); + }); + + for (const j in btn.keypress) { + const keypress = btn.keypress[j]; + keypress_action[keypress] = btn.action; + } + }); + + calculator_elem.find(':text').on('keypress', (e) => { + const key_pressed = e.key; + + if (key_pressed in keypress_action) { + const button_selector = `.btn_label_${keypress_action[key_pressed]}`; + calculator_elem.find(button_selector).trigger('click'); + e.preventDefault(); + } + }); + + calculator_elem.find('form').on('submit', (e) => { + const new_value = calculator_operation( + +integer_input_elem.val(), + +calculator_elem.find(':text').val() + ); + + integer_input_elem.val(new_value); + calculator_elem.hide(); + e.preventDefault(); + }); + + const $calc_button = $('Calculator'); + + $calc_button.insertAfter($el).on('click', (e) => { + const calc_elem = $(e.target); + const container_elem = calc_elem.closest('.form-group'); + const input_elem = container_elem.find(selector); + + const container_y_offset = container_elem.offset().top; + const container_height = container_elem.height(); + const calc_div_height = $('#calculator_div').height(); + let calculator_y_offset; + + if (container_y_offset > calc_div_height) { + calculator_y_offset = container_y_offset - calc_div_height; + } else { + calculator_y_offset = container_y_offset + container_height; + } + + calculator_elem.css({ + top: calculator_y_offset, + left: container_elem.offset().left + }); + + const calc_input = calculator_elem.find(':text'); + calc_input.val(''); + calculator_elem.show(); + calc_input.trigger('focus'); + integer_input_elem = input_elem; + }); + }); + } } -export default CalculatorComponent +export default CalculatorComponent; diff --git a/src/frontend/components/card/index.js b/src/frontend/components/card/index.js index fcb29c372..2187a85e8 100644 --- a/src/frontend/components/card/index.js +++ b/src/frontend/components/card/index.js @@ -1,4 +1,4 @@ -import { initializeComponent } from 'component' -import ExpandableCardComponent from './lib/component' +import { initializeComponent } from 'component'; +import ExpandableCardComponent from './lib/component'; -export default (scope) => initializeComponent(scope, '.card--expandable', ExpandableCardComponent) +export default (scope) => initializeComponent(scope, '.card--expandable', ExpandableCardComponent); diff --git a/src/frontend/components/card/lib/component.js b/src/frontend/components/card/lib/component.js index fa2e28b27..1bab1d970 100644 --- a/src/frontend/components/card/lib/component.js +++ b/src/frontend/components/card/lib/component.js @@ -1,112 +1,131 @@ -import { Component } from 'component' -import "bootstrap"; +import 'bootstrap'; +import { Component } from 'component'; +/** + * Creates an expandable card component. + */ class ExpandableCardComponent extends Component { - constructor(element) { - super(element) - this.$el = $(this.element) - this.$contentBlock = this.$el.closest('.content-block') - this.initExpandableCard() + /** + * Creates an instance of ExpandableCardComponent. + * @param {HTMLElement} element The HTML element to attach the component to. + */ + constructor(element) { + super(element); + this.$el = $(this.element); + this.$contentBlock = this.$el.closest('.content-block'); - if (this.$el.hasClass('card--topic')) { - this.initTopicCard() + this.initExpandableCard(); + + if (this.$el.hasClass('card--topic')) { + this.initTopicCard(); + } + } + + /** + * Initializes the expandable card functionality. + */ + initExpandableCard() { + const $collapsibleElm = this.$el.find('.collapse'); + const $btnEdit = this.$el.find('.btn-js-edit'); + const $btnView = this.$el.find('.btn-js-view'); + const $btnCancel = this.$contentBlock.find('.btn-js-cancel'); + const $recordPopup = this.$el.find('.record-popup'); + + $btnEdit.on('click', () => { + this.$contentBlock.addClass('content-block--edit'); + this.$el.addClass('card--edit'); + $collapsibleElm.collapse('show'); + $(window).on('beforeunload', (ev) => this.confirmOnPageExit(ev)); + }); + + $btnView.on('click', () => { + this.$el.removeClass('card--edit'); + if (this.canRemoveEditClass()) this.$contentBlock.removeClass('content-block--edit'); + $(window).off('beforeunload'); + }); + + $btnCancel.on('click', () => { + this.$contentBlock.find('.card--edit').removeClass('card--edit'); + this.$contentBlock.removeClass('content-block--edit'); + $(window).off('beforeunload'); + }); + + // Adjust column widths of datatables when collapsible element is expanded + $collapsibleElm.on('shown.bs.collapse', () => { + if ($.fn.dataTable) { + $($.fn.dataTable.tables(true)).DataTable() + .columns.adjust(); + this.clearupStyling(); + } + }); + + $(window).on('resize', () => { + if ($.fn.dataTable) { + $($.fn.dataTable.tables(true)).DataTable() + .columns.adjust(); + this.clearupStyling(); + } + }); + + $recordPopup.each((i, el) => { + import(/* webpackChunkName: "record-popup" */ '../../record-popup/lib/component') + .then(({ default: RecordPopupComponent }) => + new RecordPopupComponent(el) + ); + }); } - } - - initExpandableCard() { - const $collapsibleElm = this.$el.find('.collapse') - const $btnEdit = this.$el.find('.btn-js-edit') - const $btnView = this.$el.find('.btn-js-view') - const $btnCancel = this.$contentBlock.find('.btn-js-cancel') - const $recordPopup = this.$el.find('.record-popup') - - $btnEdit.on('click', () => { - this.$contentBlock.addClass('content-block--edit') - this.$el.addClass('card--edit') - $collapsibleElm.collapse('show') - $(window).on('beforeunload', (ev) => this.confirmOnPageExit(ev)) - }) - - $btnView.on('click', () => { - this.$el.removeClass('card--edit') - this.canRemoveEditClass() && this.$contentBlock.removeClass('content-block--edit') - $(window).off('beforeunload') - }) - - $btnCancel.on('click', () => { - this.$contentBlock.find('.card--edit').removeClass('card--edit') - this.$contentBlock.removeClass('content-block--edit') - $(window).off('beforeunload') - }) - - // Adjust column widths of datatables when collapsible element is expanded - $collapsibleElm.on('shown.bs.collapse', () => { - if ($.fn.dataTable) { - $($.fn.dataTable.tables(true)).DataTable() - .columns.adjust() - this.clearupStyling(); - } - }) - - $(window).on('resize', () => { - if ($.fn.dataTable) { - $($.fn.dataTable.tables(true)).DataTable() - .columns.adjust() - this.clearupStyling(); - } - }) - - $recordPopup.each((i, el) => { - import(/* webpackChunkName: "record-popup" */ '../../record-popup/lib/component') - .then(({ default: RecordPopupComponent }) => - new RecordPopupComponent(el) - ); - }) - } - - initTopicCard() { - // Now that fields are shown/hidden on page load, for each topic check - // whether it has zero displayed fields, in which case hide the whole - // topic (this also happens on field value change dynamically when a user - // edits the page). - // This applies to all of: historical view, main record view page, and main - // record edit page. Use display:none parameter rather than visibility, - // as fields will not be visible if view-mode is used in a normal record, - // and also check .table-fields as historical view will not include any - // of the linkspace-field fields - if (!this.$el.find('.list--fields').find('ul li').filter(function () { - return $(this).css("display") != "none"; - }).length && !this.$el.find('.linkspace-field').filter(function () { - return $(this).css("display") != "none"; - }).length) { - this.$el.hide(); + + /** + * Initializes the topic card functionality. + */ + initTopicCard() { + // Now that fields are shown/hidden on page load, for each topic check + // whether it has zero displayed fields, in which case hide the whole + // topic (this also happens on field value change dynamically when a user + // edits the page). + // This applies to all of: historical view, main record view page, and main + // record edit page. Use display:none parameter rather than visibility, + // as fields will not be visible if view-mode is used in a normal record, + // and also check .table-fields as historical view will not include any + // of the linkspace-field fields + if (!this.$el.find('.list--fields').find('ul li') + .filter(function () { + return $(this).css('display') != 'none'; + }).length && !this.$el.find('.linkspace-field').filter(function () { + return $(this).css('display') != 'none'; + }).length) { + this.$el.hide(); + } + } + + /** + * Checks if the edit class can be removed from the content block. + * @returns {boolean} True if the edit class can be removed, false otherwise. + */ + canRemoveEditClass() { + return !this.$contentBlock.find('.card--edit').length; } - } - canRemoveEditClass() { - return ! this.$contentBlock.find('.card--edit').length - } + confirmOnPageExit = function (ev) { + ev = ev || window.event; + const message = 'Please note that any changes will be lost.'; + if (ev) { + ev.returnValue = message; + } + return message; + }; - confirmOnPageExit = function(ev) { - ev = ev || window.event - const message = "Please note that any changes will be lost." - if (ev) { - ev.returnValue = message + /** + * In order to ensure headers on the view filter tables are the correct width, we need to remove any styling that has been added to the header elements. + * And for some reason, using JQuery and DataTables, the styling is not reset as we expect it to be. + */ + clearupStyling() { + const tables = $('.table-toggle'); + tables.removeAttr('style'); + const headers = $('.dt-scroll-headInner'); + headers.removeAttr('style'); } - return message - } - - /* - In order to ensure headers on the view filter tables are the correct width, we need to remove any styling that has been added to the header elements. - And for some reason, using JQuery and DataTables, the styling is not reset as we expect it to be. - */ - clearupStyling() { - const tables = $('.table-toggle') - tables.removeAttr('style'); - const headers = $('.dt-scroll-headInner'); - headers.removeAttr('style'); - } } -export default ExpandableCardComponent +export default ExpandableCardComponent; diff --git a/src/frontend/components/card/lib/component.test.ts b/src/frontend/components/card/lib/component.test.ts index 5acf2c1a9..ae8c0225e 100644 --- a/src/frontend/components/card/lib/component.test.ts +++ b/src/frontend/components/card/lib/component.test.ts @@ -1,9 +1,8 @@ -import { describe, it, expect, beforeEach, afterEach } from "@jest/globals"; -import ExpandableCardComponent from "./component"; +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import ExpandableCardComponent from './component'; describe('ExpandableCardComponent', () => { beforeEach(() => { - // Create the HTML structure for the tests document.body.innerHTML = `
@@ -170,9 +169,10 @@ describe('ExpandableCardComponent', () => { if (!target) throw new Error('Target not found'); // Set the items in the card to be invisible, as if there was nothing to show const $target = $(target); - $target.find('.list--fields').find('ul li').each((_i, el) => { - $(el).css('display', 'none'); - }); + $target.find('.list--fields').find('ul li') + .each((_i, el) => { + $(el).css('display', 'none'); + }); $target.find('.linkspace-field').each((_i, el) => { $(el).css('display', 'none'); }); diff --git a/src/frontend/components/collapsible/index.js b/src/frontend/components/collapsible/index.js index 46abd7f4d..b101ec9e9 100644 --- a/src/frontend/components/collapsible/index.js +++ b/src/frontend/components/collapsible/index.js @@ -1,4 +1,4 @@ -import { initializeComponent } from 'component' -import CollapsibleComponent from './lib/component' +import { initializeComponent } from 'component'; +import CollapsibleComponent from './lib/component'; -export default (scope) => initializeComponent(scope, '.collapsible', CollapsibleComponent) +export default (scope) => initializeComponent(scope, '.collapsible', CollapsibleComponent); diff --git a/src/frontend/components/collapsible/lib/component.js b/src/frontend/components/collapsible/lib/component.js index 77584525e..3a323a203 100644 --- a/src/frontend/components/collapsible/lib/component.js +++ b/src/frontend/components/collapsible/lib/component.js @@ -1,29 +1,43 @@ -import { Component } from 'component' +import { Component } from 'component'; +/** + * + */ class CollapsibleComponent extends Component { - constructor(element) { - super(element) - this.el = $(this.element) - this.button = this.el.find('.btn-collapsible') - this.titleCollapsed = this.el.find('.btn__title--collapsed') - this.titleExpanded = this.el.find('.btn__title--expanded') + /** + * Creates an instance of CollapsibleComponent. + * @param {HTMLElement} element The element to be initialized as a collapsible component. + */ + constructor(element) { + super(element); + this.el = $(this.element); + this.button = this.el.find('.btn-collapsible'); + this.titleCollapsed = this.el.find('.btn__title--collapsed'); + this.titleExpanded = this.el.find('.btn__title--expanded'); - this.initCollapsible(this.button) + this.initCollapsible(this.button); } + /** + * Initializes the collapsible component. + * @param {HTMLElement} button The button element that will toggle the collapsible content. + */ initCollapsible(button) { if (!button) { - return + return; } - this.titleExpanded.addClass('hidden') - button.click( () => { this.handleClick() }) + this.titleExpanded.addClass('hidden'); + button.click(() => { this.handleClick(); }); } + /** + * Handles the click event on the collapsible button. + */ handleClick() { - this.titleExpanded.toggleClass('hidden') - this.titleCollapsed.toggleClass('hidden') + this.titleExpanded.toggleClass('hidden'); + this.titleCollapsed.toggleClass('hidden'); } } -export default CollapsibleComponent +export default CollapsibleComponent; diff --git a/src/frontend/components/collapsible/lib/component.test.ts b/src/frontend/components/collapsible/lib/component.test.ts index 02862e892..b2ac1af55 100644 --- a/src/frontend/components/collapsible/lib/component.test.ts +++ b/src/frontend/components/collapsible/lib/component.test.ts @@ -1,9 +1,8 @@ -import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import {describe, it, expect, beforeEach, afterEach} from '@jest/globals'; import Collapsible from './component'; describe('Collapsible', () => { beforeEach(() => { - // Set up the HTML structure for the collapsible component document.body.innerHTML = `
@@ -20,7 +19,7 @@ describe('Collapsible', () => {
Please make a secure note of this content now, as it will not be displayed again.
- ` + `; }); afterEach(() => { @@ -37,7 +36,7 @@ describe('Collapsible', () => { it('should toggle the collapsible content', () => { const target = document.getElementById('target'); - if (target === null) throw new Error('Target element not found'); + if(target === null) throw new Error('Target element not found'); new Collapsible(target as HTMLElement); const button = target.querySelector('.btn-collapsible') as HTMLButtonElement; const titleCollapsed = target.querySelector('.btn__title--collapsed') as HTMLSpanElement; diff --git a/src/frontend/components/dashboard/dashboard-graph/index.js b/src/frontend/components/dashboard/dashboard-graph/index.js index 72dfc56a9..a9f0616fb 100644 --- a/src/frontend/components/dashboard/dashboard-graph/index.js +++ b/src/frontend/components/dashboard/dashboard-graph/index.js @@ -1,8 +1,8 @@ -import { getComponentElements, initializeComponent } from 'component' +import { getComponentElements, initializeComponent } from 'component'; export default (scope) => { - if(getComponentElements(scope, '.dashboard-graph').length === 0) return; - import('./lib/component').then(({default: DashboardGraphComponent}) =>{ - initializeComponent(scope, '.dashboard-graph', DashboardGraphComponent) + if (getComponentElements(scope, '.dashboard-graph').length === 0) return; + import('./lib/component').then(({ default: DashboardGraphComponent }) => { + initializeComponent(scope, '.dashboard-graph', DashboardGraphComponent); }); -} +}; diff --git a/src/frontend/components/dashboard/dashboard-graph/lib/component.js b/src/frontend/components/dashboard/dashboard-graph/lib/component.js index 4cc9e4c9a..77d0cf46d 100644 --- a/src/frontend/components/dashboard/dashboard-graph/lib/component.js +++ b/src/frontend/components/dashboard/dashboard-graph/lib/component.js @@ -1,20 +1,30 @@ -import { do_plot_json } from '../../../graph/lib/chart' -import GraphComponent from '../../../graph/lib/component' +import { do_plot_json } from '../../../graph/lib/chart'; +import GraphComponent from '../../../graph/lib/component'; +/** + * DashboardGraphComponent class that initializes the dashboard graph and renders the graph using do_plot_json. + */ class DashboardGraphComponent extends GraphComponent { - constructor(element) { - super(element) - this.initDashboardGraph() - } + /** + * Create a DashboardGraphComponent instance. + * @param {HTMLElement} element The HTML element that this component will be attached to. + */ + constructor(element) { + super(element); + this.initDashboardGraph(); + } - initDashboardGraph() { - const $graph = $(this.element) - const graph_data = $graph.data('plot-data') - const options_in = $graph.data('plot-options') + /** + * Initialize the dashboard graph by rendering the graph using do_plot_json. + */ + initDashboardGraph() { + const $graph = $(this.element); + const graph_data = $graph.data('plot-data'); + const options_in = $graph.data('plot-options'); - do_plot_json(graph_data, options_in) + do_plot_json(graph_data, options_in); - } + } } -export default DashboardGraphComponent +export default DashboardGraphComponent; diff --git a/src/frontend/components/dashboard/index.js b/src/frontend/components/dashboard/index.js index 5fc99dcb3..e3f9a715e 100644 --- a/src/frontend/components/dashboard/index.js +++ b/src/frontend/components/dashboard/index.js @@ -1,23 +1,23 @@ -import { initializeComponent, getComponentElements } from 'component' +import { initializeComponent, getComponentElements } from 'component'; export default (scope) => { - if (!getComponentElements(scope, '.dashboard').length) { - return; - } + if (!getComponentElements(scope, '.dashboard').length) { + return; + } - import( - /* webpackChunkName: "dashboard" */ - './lib/component' - ).then(({ default: Component }) => { - initializeComponent(scope, '.dashboard', Component) - }).then(() => { import( - /* webpackChunkName: "dashboardgraph" */ - './dashboard-graph/lib/component' + /* webpackChunkName: "dashboard" */ + './lib/component' ).then(({ default: Component }) => { - initializeComponent(scope, '.dashboard-graph', Component) - }) - }); + initializeComponent(scope, '.dashboard', Component); + }).then(() => { + import( + /* webpackChunkName: "dashboardgraph" */ + './dashboard-graph/lib/component' + ).then(({ default: Component }) => { + initializeComponent(scope, '.dashboard-graph', Component); + }); + }); -} +}; diff --git a/src/frontend/components/dashboard/lib/component.js b/src/frontend/components/dashboard/lib/component.js index 5214afd99..68ec6f9b4 100644 --- a/src/frontend/components/dashboard/lib/component.js +++ b/src/frontend/components/dashboard/lib/component.js @@ -1,60 +1,70 @@ -import { Component } from 'component' -import "react-app-polyfill/stable"; +import { Component } from 'component'; +import 'react-app-polyfill/stable'; -import "core-js/es/array/is-array"; -import "core-js/es/map"; -import "core-js/es/set"; -import "core-js/es/object/define-property"; -import "core-js/es/object/keys"; -import "core-js/es/object/set-prototype-of"; +import 'core-js/es/array/is-array'; +import 'core-js/es/map'; +import 'core-js/es/set'; +import 'core-js/es/object/define-property'; +import 'core-js/es/object/keys'; +import 'core-js/es/object/set-prototype-of'; -import "./react/polyfills/classlist"; +import './react/polyfills/classlist'; -import React from "react"; -import ReactDOM from "react-dom"; -import App from "./react/app"; -import ApiClient from "./react/api"; +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './react/app'; +import ApiClient from './react/api'; +/** + * DashboardComponent class that initializes the dashboard and renders the App component. + */ class DashboardComponent extends Component { - constructor(element) { - super(element) - this.el = $(this.element) - - this.gridConfig = { - cols: 2, - margin: [32, 32], - containerPadding: [0, 10], - rowHeight: 80, - }; - - this.initDashboard() - } - - initDashboard() { - this.element.className = ""; - const widgetsEls = Array.prototype.slice.call(document.querySelectorAll("#ld-app > div")); - const widgets = widgetsEls.map(el => ({ - html: el.innerHTML, - config: JSON.parse(el.getAttribute("data-grid")), - })); - const api = new ApiClient(this.element.getAttribute("data-dashboard-endpoint") || ""); - - ReactDOM.render( - , - this.element, - ); - } + /** + * Create a DashboardComponent instance. + * @param {HTMLElement} element The HTML element that this component will be attached to. + */ + constructor(element) { + super(element); + this.el = $(this.element); + + this.gridConfig = { + cols: 2, + margin: [32, 32], + containerPadding: [0, 10], + rowHeight: 80 + }; + + this.initDashboard(); + } + + /** + * Initialize the dashboard by rendering the App component with widgets and configurations. + */ + initDashboard() { + this.element.className = ''; + const widgetsEls = Array.prototype.slice.call(document.querySelectorAll('#ld-app > div')); + const widgets = widgetsEls.map(el => ({ + html: el.innerHTML, + config: JSON.parse(el.getAttribute('data-grid')) + })); + const api = new ApiClient(this.element.getAttribute('data-dashboard-endpoint') || ''); + + ReactDOM.render( + , + this.element + ); + } } -export default DashboardComponent +export default DashboardComponent; diff --git a/src/frontend/components/dashboard/lib/react/Footer.tsx b/src/frontend/components/dashboard/lib/react/Footer.tsx index 84f0e5ae7..e62ab4afe 100644 --- a/src/frontend/components/dashboard/lib/react/Footer.tsx +++ b/src/frontend/components/dashboard/lib/react/Footer.tsx @@ -1,37 +1,47 @@ -import React from "react"; +import React from 'react'; -const Footer = ({ addWidget, widgetTypes, currentDashboard, readOnly, noDownload }) => { - return ( -
- {noDownload ? null :
- -
- -
-
} +/** + * Create a Footer component that displays options for downloading the dashboard and adding widgets. + * @param param0 The properties for the Footer component, including a function to add a widget, a list of widget types, the current dashboard, a read-only flag, and a no-download flag. + * @param {function} param0.addWidget Function to call when adding a widget. + * @param {Array} param0.widgetTypes The list of widget types available for addition. + * @param {object} param0.currentDashboard The currently active dashboard. + * @param {boolean} param0.readOnly A flag indicating whether the dashboard is in read-only mode. + * @param {boolean} param0.noDownload A flag indicating whether the download option + * @returns {JSX.Element} The rendered Footer component. + */ +const Footer = ({ addWidget, widgetTypes, currentDashboard, readOnly, noDownload }): JSX.Element => { + return ( +
+ {noDownload ? null :
+ +
+ +
+
} - {readOnly ? null :
- -
- + {readOnly ? null :
+ +
+ +
+
}
-
} -
- ); + ); }; export default Footer; diff --git a/src/frontend/components/dashboard/lib/react/Header.tsx b/src/frontend/components/dashboard/lib/react/Header.tsx index 6715e921d..05a41b305 100644 --- a/src/frontend/components/dashboard/lib/react/Header.tsx +++ b/src/frontend/components/dashboard/lib/react/Header.tsx @@ -1,33 +1,43 @@ -import React from "react"; +import React from 'react'; -const Header = ({ hMargin, dashboards, currentDashboard, loading, includeH1 }) => { - const renderMenuItem = (dashboard) => { - if (dashboard.name === currentDashboard.name) { - if (includeH1) { - return

{dashboard.name}

- } else { - return {dashboard.name} - } - } else { - return {dashboard.name} - } - } - return ( -
-
- {loading ?

: null} -
- +/** + * Create a Header component that displays a navigation header for dashboards. + * @param {object} param0 The properties for the Header component, including horizontal margin, list of dashboards, current dashboard, loading state, and a flag to include an H1 element. + * @param {number} param0.hMargin The horizontal margin to apply to the header. + * @param {Array} param0.dashboards The list of dashboards to display in the header. + * @param {{name: string}} param0.currentDashboard The currently active dashboard. + * @param {boolean} param0.loading A flag indicating whether the header is in a loading state. + * @param {boolean} param0.includeH1 A flag indicating whether to include an + * @returns {JSX.Element} The rendered Header component. + */ +const Header = ({ hMargin, dashboards, currentDashboard, loading, includeH1 }: { hMargin: number; dashboards: Array; currentDashboard: {name: string}; loading: boolean; includeH1: boolean; }): JSX.Element => { + const renderMenuItem = (dashboard) => { + if (dashboard.name === currentDashboard.name) { + if (includeH1) { + return

{dashboard.name}

; + } else { + return {dashboard.name}; + } + } else { + return {dashboard.name}; + } + }; + return ( +
+
+ {loading ?

: null} +
+ +
+
-
-
- ); + ); }; export default Header; diff --git a/src/frontend/components/dashboard/lib/react/Widget.tsx b/src/frontend/components/dashboard/lib/react/Widget.tsx index de1e5dda2..c049f54cf 100644 --- a/src/frontend/components/dashboard/lib/react/Widget.tsx +++ b/src/frontend/components/dashboard/lib/react/Widget.tsx @@ -1,41 +1,52 @@ -import React from "react"; -import { initializeRegisteredComponents } from 'component' +import React from 'react'; +import { initializeRegisteredComponents } from 'component'; +/** + * Widget component that renders a widget with HTML content. + */ export default class Widget extends React.Component { - private ref; + private ref; - constructor(props) { - super(props); + /** + * Create a Widget component. + * @param {*} props The properties passed to the component, including the widget HTML and a flag for read-only mode. + */ + constructor(props: any) { + super(props); - this.ref = React.createRef(); - } + this.ref = React.createRef(); + } - shouldComponentUpdate = (nextProps) => { - return nextProps.widget.html !== this.props.widget.html; - } + shouldComponentUpdate = (nextProps) => { + return nextProps.widget.html !== this.props.widget.html; + }; - componentDidUpdate = () => { - this.initializeLinkspace(); - } + componentDidUpdate = () => { + this.initializeLinkspace(); + }; - initializeLinkspace = () => { - if (!this.ref) { - return; - } - initializeRegisteredComponents(this.ref.current) - } + initializeLinkspace = () => { + if (!this.ref) { + return; + } + initializeRegisteredComponents(this.ref.current); + }; - render() { - return ( - -
-
- {this.props.readOnly ? null : - edit widget - drag widget - } -
- - ); - } + /** + * Render the Widget component. + * @returns {JSX.Element} The rendered widget component. + */ + render() { + return ( + +
+
+ {this.props.readOnly ? null : + edit widget + drag widget + } +
+ + ); + } } diff --git a/src/frontend/components/dashboard/lib/react/api.tsx b/src/frontend/components/dashboard/lib/react/api.tsx index c171d5c19..f199cddce 100644 --- a/src/frontend/components/dashboard/lib/react/api.tsx +++ b/src/frontend/components/dashboard/lib/react/api.tsx @@ -1,76 +1,152 @@ +/** + * API Client for interacting with the backend services. + * @todo Cleanup + */ export default class ApiClient { - private baseUrl; - private headers; - private isDev; - - constructor(baseUrl = "") { - this.baseUrl = baseUrl; - this.headers = {}; - // @ts-expect-error "isDev is not valid" - this.isDev = window.siteConfig && window.siteConfig.isDev - } - - async _fetch(route, method, body) { - if (!route) throw new Error("Route is undefined"); - - let csrfParam = ""; - if (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE") { - const body = document.querySelector("body"); - const csrfToken = body ? body.getAttribute("data-csrf") : null; - if (csrfToken) { - csrfParam = route.indexOf("?") > -1 ? `&csrf-token=${csrfToken}` : `?csrf-token=${csrfToken}`; - } + private baseUrl; + private headers; + private isDev; + + /** + * Creates a new instance of ApiClient. + * @param {string} baseUrl Base URL for the API endpoints. + */ + constructor(baseUrl: string = '') { + this.baseUrl = baseUrl; + this.headers = {}; + // @ts-expect-error "isDev is not valid" + this.isDev = window.siteConfig && window.siteConfig.isDev; } - const fullRoute = `${this.baseUrl}${route}${csrfParam}`; - - const opts : any = { - method, - headers: Object.assign(this.headers), - credentials: 'same-origin', // Needed for older versions of Firefox, otherwise cookies not sent - }; - if (body) { - opts.body = JSON.stringify(body); - } - return fetch(fullRoute, opts); - } - - GET(route) { return this._fetch(route, "GET", null); } - - POST(route, body) { return this._fetch(route, "POST", body); } - - PUT(route, body) { return this._fetch(route, "PUT", body); } - - PATCH(route, body) { return this._fetch(route, "PATCH", body); } - - DELETE(route) { return this._fetch(route, "DELETE", null); } - - saveLayout = (id, layout) => { - if (!this.isDev) { - const strippedLayout = layout.map(widget => ({ ...widget, moved: undefined })); - return this.PUT(`/dashboard/${id}`, strippedLayout); + /** + * Execute a fetch request to the API. + * @description This is a basic wrapper around the fetch API. + * @param {string} route The API route to fetch. + * @param { 'GET'|'POST'|'PUT'|'PATCH'|'DELETE' } method The API method (GET, POST, PUT, PATCH, DELETE). + * @param {*} body The body of the request, if applicable. + * @returns {Promise} The response from the fetch call. + */ + async _fetch(route: string, method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', body: any): Promise { + if (!route) throw new Error('Route is undefined'); + + let csrfParam = ''; + if (method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE') { + const body = document.querySelector('body'); + const csrfToken = body ? body.getAttribute('data-csrf') : null; + if (csrfToken) { + csrfParam = route.indexOf('?') > -1 ? `&csrf-token=${csrfToken}` : `?csrf-token=${csrfToken}`; + } + } + + const fullRoute = `${this.baseUrl}${route}${csrfParam}`; + + const opts: any = { + method, + headers: Object.assign(this.headers), + credentials: 'same-origin' // Needed for older versions of Firefox, otherwise cookies not sent + }; + if (body) { + opts.body = JSON.stringify(body); + } + return fetch(fullRoute, opts); } - } - createWidget = async type => { - const response = this.isDev ? await this.GET(`/widget/create.json?type=${type}`) : await this.POST(`/widget?type=${type}`, null) - return await response.json() - } + /** + * Performs a GET request to the specified route. + * @param {string} route The API route to fetch. + * @returns {Promise} The response from the fetch call. + */ + GET(route: string): Promise { return this._fetch(route, 'GET', null); } + + /** + * Performs a POST request to the specified route. + * @param {string} route The API route to fetch. + * @param {*} body The body of the request, if applicable. + * @returns {Promise} The response from the fetch call. + */ + POST(route: string, body: any): Promise { return this._fetch(route, 'POST', body); } + + /** + * Performs a PUT request to the specified route. + * @param {string} route The API route to fetch. + * @param {*} body The body of the request, if applicable. + * @returns {Promise} The response from the fetch call. + */ + PUT(route: string, body: any): Promise { return this._fetch(route, 'PUT', body); } + + /** + * Performs a PATCH request to the specified route. + * @param {string} route The API route to fetch. + * @param {*} body The body of the request, if applicable. + * @returns {Promise} The response from the fetch call. + */ + PATCH(route: string, body: any): Promise { return this._fetch(route, 'PATCH', body); } + + /** + * Performs a DELETE request to the specified route. + * @param {string} route The API route to fetch. + * @returns {Promise} The response from the fetch call. + */ + DELETE(route: string): Promise { return this._fetch(route, 'DELETE', null); } + + /** + * Save the layout of a dashboard. + * @param {string} id The ID of the dashboard to save the layout for. + * @param {object} layout The layout to save, typically an array of widget configurations. + * @returns {Promise} The response from the save operation. + */ + saveLayout = (id: string, layout: Array): Promise => { + if (!this.isDev) { + const strippedLayout = layout.map(widget => ({ ...widget, moved: undefined })); + return this.PUT(`/dashboard/${id}`, strippedLayout); + } + }; - getWidgetHtml = async id => { - const html = this.isDev ? await this.GET(`/widget/${id}/create`) : await this.GET(`/widget/${id}`) - return html.text(); - } + /** + * Create a new widget. + * @param {string} type The type of widget to create. + * @returns {Promise} The response from the widget creation request. + */ + createWidget = async (type: string): Promise => { + const response = this.isDev ? await this.GET(`/widget/create.json?type=${type}`) : await this.POST(`/widget?type=${type}`, null); + return await response.json(); + }; - deleteWidget = id => !this.isDev && this.DELETE(`/widget/${id}`) + /** + * Get the HTML content of a widget. + * @param {string} id The ID of the widget to retrieve HTML for. + * @returns {Promise} The HTML content of the widget. + */ + getWidgetHtml = async (id: string): Promise => { + const html = this.isDev ? await this.GET(`/widget/${id}/create`) : await this.GET(`/widget/${id}`); + return html.text(); + }; - getEditForm = async id => { - const response = await this.GET(`/widget/${id}/edit`); - return response.json(); - } + /** + * Delete a widget by its ID. + * @param {string} id The ID of the widget to delete. + * @returns {promise} A promise that resolves when the widget is deleted. + */ + deleteWidget = (id: string): Promise => !this.isDev && this.DELETE(`/widget/${id}`); + + /** + * Get the edit form for a widget. + * @param {string} id The ID of the widget to retrieve the edit form for. + * @returns {Promise} The JSON response containing the edit form data for the widget. + */ + getEditForm = async (id: string): Promise => { + const response = await this.GET(`/widget/${id}/edit`); + return response.json(); + }; - saveWidget = async (url, params) => { - const result = this.isDev ? await this.GET(`/widget/update.json`) : await this.PUT(`${url}`, params); - return await result.json(); - } + /** + * Save a widget. + * @param {string} url The URL to save the widget. + * @param {object} params The parameters to save the widget. + * @returns {Promise} The JSON response containing the saved widget data. + */ + saveWidget = async (url, params) => { + const result = this.isDev ? await this.GET('/widget/update.json') : await this.PUT(`${url}`, params); + return await result.json(); + }; } diff --git a/src/frontend/components/dashboard/lib/react/app.tsx b/src/frontend/components/dashboard/lib/react/app.tsx index 3a4e6dc0c..0024705f8 100644 --- a/src/frontend/components/dashboard/lib/react/app.tsx +++ b/src/frontend/components/dashboard/lib/react/app.tsx @@ -1,349 +1,361 @@ -import React from "react"; -import serialize from "form-serialize"; +import React from 'react'; +import serialize from 'form-serialize'; -import Modal from "react-modal"; -import RGL, { WidthProvider } from "react-grid-layout"; +import Modal from 'react-modal'; +import RGL, { WidthProvider } from 'react-grid-layout'; -import Header from "./Header"; +import Header from './Header'; import Widget from './Widget'; -import Footer from "./Footer"; +import Footer from './Footer'; import { sidebarObservable } from '../../../sidebar/lib/sidebarObservable'; declare global { - interface Window { - Linkspace : any, - // @ts-expect-error "Typings clash with JSTree" - siteConfig: any - } + interface Window { + Linkspace: any, + // @ts-expect-error "Typings clash with JSTree" + siteConfig: any + } } const ReactGridLayout = WidthProvider(RGL); const modalStyle = { - content: { - minWidth: "350px", - maxWidth: "80vw", - maxHeight: "90vh", - top: "50%", - left: "50%", - right: "auto", - bottom: "auto", - marginRight: "-50%", - transform: "translate(-50%, -50%)", - msTransform: "translate(-50%, -50%)", - padding: 0 - }, - overlay: { - zIndex: 1030, - background: "rgba(0, 0, 0, .15)" - } + content: { + minWidth: '350px', + maxWidth: '80vw', + maxHeight: '90vh', + top: '50%', + left: '50%', + right: 'auto', + bottom: 'auto', + marginRight: '-50%', + transform: 'translate(-50%, -50%)', + msTransform: 'translate(-50%, -50%)', + padding: 0 + }, + overlay: { + zIndex: 1030, + background: 'rgba(0, 0, 0, .15)' + } }; +/** + * App component for the dashboard, managing widgets and layout. + */ class App extends React.Component { - private formRef; - - constructor(props) { - super(props); - Modal.setAppElement("#ld-app"); - - const layout = props.widgets.map(widget => widget.config); - this.formRef = React.createRef(); - sidebarObservable.addSubscriber(this); - - this.state = { - widgets: props.widgets, - layout, - editModalOpen: false, - activeItem: 0, - editHtml: "", - editError: null, - loading: false, - loadingEditHtml: true, + private formRef; + + /** + * Create an instance of the App component. + * @param {any} props The properties passed to the component, including widgets, api, dashboardId, etc. + * @todo Use concrete types instead of `any` for better type safety. + */ + constructor(props: any) { + super(props); + Modal.setAppElement('#ld-app'); + + const layout = props.widgets.map(widget => widget.config); + this.formRef = React.createRef(); + sidebarObservable.addSubscriber(this); + + this.state = { + widgets: props.widgets, + layout, + editModalOpen: false, + activeItem: 0, + editHtml: '', + editError: null, + loading: false, + loadingEditHtml: true + }; + } + + componentDidMount = () => { + this.initializeGlobeComponents(); }; - } - componentDidMount = () => { - this.initializeGlobeComponents(); - } + componentDidUpdate = (prevProps: any, prevState: any) => { + window.requestAnimationFrame(this.overWriteSubmitEventListener); - componentDidUpdate = (prevProps, prevState) => { - window.requestAnimationFrame(this.overWriteSubmitEventListener); + if (this.state.editModalOpen && prevState.loadingEditHtml && !this.state.loadingEditHtml && this.formRef) { + this.initializeSummernoteComponent(); + } - if (this.state.editModalOpen && prevState.loadingEditHtml && !this.state.loadingEditHtml && this.formRef) { - this.initializeSummernoteComponent(); - } + if (!this.state.editModalOpen && !prevState.loadingEditHtml && !this.state.loadingEditHtml) { + this.initializeGlobeComponents(); + } + }; - if (!this.state.editModalOpen && !prevState.loadingEditHtml && !this.state.loadingEditHtml) { - this.initializeGlobeComponents(); - } - } - - initializeSummernoteComponent = () => { - const summernoteEl = this.formRef.current.querySelector('.summernote'); - if (summernoteEl) { - import(/* WebpackChunkName: "summernote" */ "../../../summernote/lib/component") - .then(({ default: SummerNoteComponent }) => { - new SummerNoteComponent(summernoteEl) + initializeSummernoteComponent = () => { + const summernoteEl = this.formRef.current.querySelector('.summernote'); + if (summernoteEl) { + import(/* WebpackChunkName: "summernote" */ '../../../summernote/lib/component') + .then(({ default: SummerNoteComponent }) => { + new SummerNoteComponent(summernoteEl); + }); + } + }; + + initializeGlobeComponents = () => { + const arrGlobe = document.querySelectorAll('.globe'); + import('../../../globe/lib/component').then(({ default: GlobeComponent }) => { + arrGlobe.forEach((globe) => { + new GlobeComponent(globe as HTMLElement); + }); }); - } - } - - initializeGlobeComponents = () => { - const arrGlobe = document.querySelectorAll(".globe"); - import('../../../globe/lib/component').then(({default: GlobeComponent}) => { - arrGlobe.forEach((globe) => { - new GlobeComponent(globe) - }); - }); - } - - updateWidgetHtml = async (id) => { - const newHtml = await this.props.api.getWidgetHtml(id); - const newWidgets = this.state.widgets.map(widget => { - if (widget.config.i === id) { - return { - ...widget, - html: newHtml, + }; + + updateWidgetHtml = async (id) => { + const newHtml = await this.props.api.getWidgetHtml(id); + const newWidgets = this.state.widgets.map(widget => { + if (widget.config.i === id) { + return { + ...widget, + html: newHtml + }; + } + return widget; + }); + this.setState({ widgets: newWidgets }); + }; + + fetchEditForm = async (id) => { + const editFormHtml = await this.props.api.getEditForm(id); + if (editFormHtml.is_error) { + this.setState({ loadingEditHtml: false, editError: editFormHtml.message }); + return; + } + this.setState({ loadingEditHtml: false, editError: false, editHtml: editFormHtml.content }); + }; + + onEditClick = id => (event) => { + event.preventDefault(); + this.showEditForm(id); + }; + + showEditForm = (id) => { + this.setState({ editModalOpen: true, loadingEditHtml: true, activeItem: id }); + this.fetchEditForm(id); + }; + + closeModal = () => { + this.setState({ editModalOpen: false }); + }; + + deleteActiveWidget = () => { + + if (!window.confirm('Deleting a widget is permanent! Are you sure?')) + return; + + this.setState({ + widgets: this.state.widgets.filter(item => item.config.i !== this.state.activeItem), + editModalOpen: false + }); + this.props.api.deleteWidget(this.state.activeItem); + }; + + saveActiveWidget = async (event) => { + event.preventDefault(); + const formEl = this.formRef.current.querySelector('form'); + if (!formEl) { + + console.error('No form element was found!'); + return; + } + + const form = serialize(formEl, { hash: true }); + const result = await this.props.api.saveWidget(formEl.getAttribute('action'), form); + if (result.is_error) { + this.setState({ editError: result.message }); + return; + } + this.updateWidgetHtml(this.state.activeItem); + this.closeModal(); + }; + + isGridConflict = (x, y, w, h) => { + const ulc = { x, y }; + const drc = { x: x + w, y: y + h }; + return this.state.layout.some((widget) => { + if (ulc.x >= (widget.x + widget.w) || widget.x >= drc.x) { + return false; + } + if (ulc.y >= (widget.y + widget.h) || widget.y >= drc.y) { + return false; + } + return true; + }); + }; + + firstAvailableSpot = (w, h) => { + let x = 0; + let y = 0; + while (this.isGridConflict(x, y, w, h)) { + if ((x + w) < this.props.gridConfig.cols) { + x += 1; + } else { + x = 0; + y += 1; + } + if (y > 200) break; + } + return { x, y }; + }; + + + addWidget = async (type) => { + this.setState({ loading: true }); + const result = await this.props.api.createWidget(type); + if (result.error) { + this.setState({ loading: false }); + alert(result.message); + return; + } + const id = result.message; + const { x, y } = this.firstAvailableSpot(1, 1); + const widgetLayout = { + i: id, + x, + y, + w: 1, + h: 1 }; - } - return widget; - }); - this.setState({ widgets: newWidgets }); - } - - fetchEditForm = async (id) => { - const editFormHtml = await this.props.api.getEditForm(id); - if (editFormHtml.is_error) { - this.setState({ loadingEditHtml: false, editError: editFormHtml.message }); - return; - } - this.setState({ loadingEditHtml: false, editError: false, editHtml: editFormHtml.content }); - } - - onEditClick = id => (event) => { - event.preventDefault(); - this.showEditForm(id); - } - - showEditForm = (id) => { - this.setState({ editModalOpen: true, loadingEditHtml: true, activeItem: id }); - this.fetchEditForm(id); - } - - closeModal = () => { - this.setState({ editModalOpen: false }); - } - - deleteActiveWidget = () => { - // eslint-disable-next-line no-alert - if (!window.confirm("Deleting a widget is permanent! Are you sure?")) - return - - this.setState({ - widgets: this.state.widgets.filter(item => item.config.i !== this.state.activeItem), - editModalOpen: false, - }); - this.props.api.deleteWidget(this.state.activeItem); - } - - saveActiveWidget = async (event) => { - event.preventDefault(); - const formEl = this.formRef.current.querySelector("form"); - if (!formEl) { - // eslint-disable-next-line no-console - console.error("No form element was found!"); - return; - } + const newLayout = this.state.layout.concat(widgetLayout); + this.setState({ + widgets: this.state.widgets.concat({ + config: widgetLayout, + html: 'Loading...' + }), + layout: newLayout, + loading: false + }, () => this.updateWidgetHtml(id)); + this.props.api.saveLayout(this.props.dashboardId, newLayout); + this.showEditForm(id); + }; - const form = serialize(formEl, { hash: true }); - const result = await this.props.api.saveWidget(formEl.getAttribute("action"), form); - if (result.is_error) { - this.setState({ editError: result.message }); - return; - } - this.updateWidgetHtml(this.state.activeItem); - this.closeModal(); - } - - isGridConflict = (x, y, w, h) => { - const ulc = { x, y }; - const drc = { x: x + w, y: y + h }; - return this.state.layout.some((widget) => { - if (ulc.x >= (widget.x + widget.w) || widget.x >= drc.x) { - return false; - } - if (ulc.y >= (widget.y + widget.h) || widget.y >= drc.y) { - return false; - } - return true; - }); - } - - firstAvailableSpot = (w, h) => { - let x = 0; - let y = 0; - while (this.isGridConflict(x, y, w, h)) { - if ((x + w) < this.props.gridConfig.cols) { - x += 1; - } else { - x = 0; - y += 1; - } - if (y > 200) break; - } - return { x, y }; - } - - // eslint-disable-next-line no-unused-vars - addWidget = async (type) => { - this.setState({loading: true}); - const result = await this.props.api.createWidget(type) - if (result.error) { - this.setState({loading: false}); - alert(result.message); - return; - } - const id = result.message; - const { x, y } = this.firstAvailableSpot(1, 1); - const widgetLayout = { - i: id, - x, - y, - w: 1, - h: 1, + generateDOM = () => ( + this.state.widgets.map(widget => ( +
+ +
+ )) + ); + + onLayoutChange = (layout) => { + if (this.shouldSaveLayout(this.state.layout, layout)) { + this.props.api.saveLayout(this.props.dashboardId, layout); + } + this.setState({ layout }); }; - const newLayout = this.state.layout.concat(widgetLayout); - this.setState({ - widgets: this.state.widgets.concat({ - config: widgetLayout, - html: "Loading...", - }), - layout: newLayout, - loading: false, - }, () => this.updateWidgetHtml(id)); - this.props.api.saveLayout(this.props.dashboardId, newLayout); - this.showEditForm(id); - } - - generateDOM = () => ( - this.state.widgets.map(widget => ( -
- -
- )) - ) - - onLayoutChange = (layout) => { - if (this.shouldSaveLayout(this.state.layout, layout)) { - this.props.api.saveLayout(this.props.dashboardId, layout); - } - this.setState({ layout }); - } - shouldSaveLayout = (prevLayout, newLayout) => { - if (prevLayout.length !== newLayout.length) { - return true; - } - for (let i = 0; i < prevLayout.length; i += 1) { - const entriesNew = Object.entries(newLayout[i]); - const isDifferent = entriesNew.some((keypair) => { - const [key, value] = keypair; - if (key === "moved" || key === "static") return false; - if (value !== prevLayout[i][key]) return true; + shouldSaveLayout = (prevLayout, newLayout) => { + if (prevLayout.length !== newLayout.length) { + return true; + } + for (let i = 0; i < prevLayout.length; i += 1) { + const entriesNew = Object.entries(newLayout[i]); + const isDifferent = entriesNew.some((keypair) => { + const [key, value] = keypair; + if (key === 'moved' || key === 'static') return false; + if (value !== prevLayout[i][key]) return true; + return false; + }); + if (isDifferent) return true; + } return false; - }); - if (isDifferent) return true; - } - return false; - } - - renderModal = () => ( - -
-
-

Edit widget

-
- -
-
- {this.state.editError - ?

{this.state.editError}

: null} - {this.state.loadingEditHtml - ? Loading... :
} -
-
-
- -
-
- -
-
- - ) - - overWriteSubmitEventListener = () => { - const formContainer = document.getElementById("ld-form-container"); - if (!formContainer) - return - - const form = formContainer.querySelector("form"); - if (!form) - return - - form.addEventListener("submit", this.saveActiveWidget); - const submitButton = document.createElement("input"); - submitButton.setAttribute("type", "submit"); - submitButton.setAttribute("style", "visibility: hidden"); - form.appendChild(submitButton); - } - - handleSideBarChange = () => { - window.dispatchEvent(new Event('resize')); - } - - render() { - return ( -
- {this.props.hideMenu ? null :
} - {this.renderModal()} -
- - {this.generateDOM()} - -
- {this.props.hideMenu ? null :
} -
+ }; + + renderModal = () => ( + +
+
+

Edit widget

+
+ +
+
+ {this.state.editError + ?

{this.state.editError}

: null} + {this.state.loadingEditHtml + ? Loading... :
} +
+
+
+ +
+
+ +
+
+ ); - } + + overWriteSubmitEventListener = () => { + const formContainer = document.getElementById('ld-form-container'); + if (!formContainer) + return; + + const form = formContainer.querySelector('form'); + if (!form) + return; + + form.addEventListener('submit', this.saveActiveWidget); + const submitButton = document.createElement('input'); + submitButton.setAttribute('type', 'submit'); + submitButton.setAttribute('style', 'visibility: hidden'); + form.appendChild(submitButton); + }; + + handleSideBarChange = () => { + window.dispatchEvent(new Event('resize')); + }; + + /** + * Renders the App component. + * @returns {React.JSX.Element} The rendered component, including the header, footer, and grid layout with widgets. + */ + render(): React.JSX.Element { + return ( +
+ {this.props.hideMenu ? null :
} + {this.renderModal()} +
+ + {this.generateDOM()} + +
+ {this.props.hideMenu ? null :
} +
+ ); + } } export default App; diff --git a/src/frontend/components/data-table/_data-table.scss b/src/frontend/components/data-table/_data-table.scss index fe9108785..dab6a9882 100644 --- a/src/frontend/components/data-table/_data-table.scss +++ b/src/frontend/components/data-table/_data-table.scss @@ -351,11 +351,9 @@ div.dataTables_wrapper div.dataTables_filter input.form-control { } } -.dataTables_processing, -.dt-processing { - position: fixed; - top: 50%; - left: 50%; +div.dataTables_wrapper div.dataTables_processing, +div.td-container div.dt-processing { + top: 10rem; } table.table-purge { diff --git a/src/frontend/components/data-table/index.js b/src/frontend/components/data-table/index.js index 448de7853..1fb4b53f5 100644 --- a/src/frontend/components/data-table/index.js +++ b/src/frontend/components/data-table/index.js @@ -1,14 +1,14 @@ -import { initializeComponent, getComponentElements } from 'component' +import { initializeComponent, getComponentElements } from 'component'; export default (scope) => { - if (!getComponentElements(scope, '.data-table').length) { - return; - } + if (!getComponentElements(scope, '.data-table').length) { + return; + } - import( + import( /* webpackChunkName: "datatable" */ - './lib/component' - ).then(({ default: Component }) => { - initializeComponent(scope, '.data-table', Component) - }); -} + './lib/component' + ).then(({ default: Component }) => { + initializeComponent(scope, '.data-table', Component); + }); +}; diff --git a/src/frontend/components/data-table/lib/component.js b/src/frontend/components/data-table/lib/component.js index c78b9b6fc..28bcf1166 100644 --- a/src/frontend/components/data-table/lib/component.js +++ b/src/frontend/components/data-table/lib/component.js @@ -1,262 +1,329 @@ /* eslint-disable @typescript-eslint/no-this-alias */ -import { Component, initializeRegisteredComponents } from 'component' -import 'datatables.net-bs4' -import 'datatables.net-buttons-bs4' -import 'datatables.net-responsive-bs4' -import 'datatables.net-rowreorder-bs4' -import { setupDisclosureWidgets, onDisclosureClick } from 'components/more-less/lib/disclosure-widgets' -import { moreLess } from 'components/more-less/lib/more-less' -import { bindToggleTableClickHandlers } from './toggle-table' - -const MORE_LESS_TRESHOLD = 50 - -//TODO: It is worth noting that there are significant changes between DataTables.net v1 and v2 (hence the major version increase) -// We are currently using v2 in this component, but with various deprecated features in use that may need to be updated in the future +import { Component, initializeRegisteredComponents } from 'component'; +import 'datatables.net-bs4'; +import 'datatables.net-buttons-bs4'; +import 'datatables.net-responsive-bs4'; +import 'datatables.net-rowreorder-bs4'; +import { setupDisclosureWidgets, onDisclosureClick } from 'components/more-less/lib/disclosure-widgets'; +import { moreLess } from 'components/more-less/lib/more-less'; +import { bindToggleTableClickHandlers } from './toggle-table'; + +const MORE_LESS_TRESHOLD = 50; + +/** + * Component for initializing and managing DataTables + * @todo It is worth noting that there are significant changes between DataTables.net v1 and v2 (hence the major version increase) + We are currently using v2 in this component, but with various deprecated features in use that may need to be updated in the future + */ class DataTableComponent extends Component { - constructor(element) { - super(element) - this.el = $(this.element) - this.hasCheckboxes = this.el.hasClass('table-selectable') - this.hasClearState = this.el.hasClass('table-clear-state') - this.forceButtons = this.el.hasClass('table-force-buttons') - this.searchParams = new URLSearchParams(window.location.search) - this.base_url = this.el.data('href') ? this.el.data('href') : undefined - this.isFullScreen = false - this.initTable() - } - - initTable() { - if(this.hasClearState) { - this.clearTableStateForPage() - - const url = new URL(window.location.href) - url.searchParams.delete('table_clear_state') - const targetUrl = url.toString() - window.location.replace(targetUrl.endsWith('?') ? targetUrl.slice(0, -1) : targetUrl) - - return - } + /** + * Creates a new DataTable component + * @param {HTMLElement} element The element to attach the DataTable functionality to + */ + constructor(element) { + super(element); + this.el = $(this.element); + this.hasCheckboxes = this.el.hasClass('table-selectable'); + this.hasClearState = this.el.hasClass('table-clear-state'); + this.forceButtons = this.el.hasClass('table-force-buttons'); + this.searchParams = new URLSearchParams(window.location.search); + this.base_url = this.el.data('href') ? this.el.data('href') : undefined; + this.isFullScreen = false; + this.initTable(); + } + + /** + * Initializes the DataTable component + */ + initTable() { + if (this.hasClearState) { + this.clearTableStateForPage(); + + const url = new URL(window.location.href); + url.searchParams.delete('table_clear_state'); + const targetUrl = url.toString(); + window.location.replace(targetUrl.endsWith('?') ? targetUrl.slice(0, -1) : targetUrl); + + return; + } - const conf = this.getConf() - const {columns} = conf - this.columns = columns - this.el.DataTable(conf) - this.initializingTable = true - $('.dt-column-order').remove() //datatables.net adds it's own ordering class - we remove it because it's easier than rewriting basically everywhere we use datatables + const conf = this.getConf(); + const { columns } = conf; + this.columns = columns; + this.el.DataTable(conf); + this.initializingTable = true; + $('.dt-column-order').remove(); //datatables.net adds it's own ordering class - we remove it because it's easier than rewriting basically everywhere we use datatables - if (this.hasCheckboxes) { - this.addSelectAllCheckbox() - } + if (this.hasCheckboxes) { + this.addSelectAllCheckbox(); + } - if (this.el.hasClass('table-account-requests')) { - this.modal = $.find('#userModal') - this.initClickableTable() - this.el.on('draw.dt', ()=> { - this.initClickableTable() - }) - } + if (this.el.hasClass('table-account-requests')) { + this.modal = $.find('#userModal'); + this.initClickableTable(); + this.el.on('draw.dt', () => { + this.initClickableTable(); + }); + } - bindToggleTableClickHandlers(this.el) + bindToggleTableClickHandlers(this.el); - // Bind events to disclosure buttons and record-popup links on opening of child row - $(this.el).on('childRow.dt', (e, show, row) => { - const $childRow = $(row.child()) - const recordPopupElements = $childRow.find('.record-popup') + // Bind events to disclosure buttons and record-popup links on opening of child row + $(this.el).on('childRow.dt', (e, show, row) => { + const $childRow = $(row.child()); + const recordPopupElements = $childRow.find('.record-popup'); - setupDisclosureWidgets($childRow) + setupDisclosureWidgets($childRow); - if (recordPopupElements) { - import(/* webpackChunkName: "record-popup" */ 'components/record-popup/lib/component').then(({ default: RecordPopupComponent }) => { - recordPopupElements.each((i, el) => { - new RecordPopupComponent(el) - }); + if (recordPopupElements) { + import(/* webpackChunkName: "record-popup" */ 'components/record-popup/lib/component').then(({ default: RecordPopupComponent }) => { + recordPopupElements.each((i, el) => { + new RecordPopupComponent(el); + }); + }); + } }); - } - }) - } + } - clearTableStateForPage() { - for (let i = 0; i < localStorage.length; i++) { - const storageKey = localStorage.key( i ) + /** + * Clears the table state for the current page + */ + clearTableStateForPage() { + for (let i = 0; i < localStorage.length; i++) { + const storageKey = localStorage.key(i); - if (!storageKey.startsWith("DataTables")) { - continue; - } + if (!storageKey.startsWith('DataTables')) { + continue; + } - const keySegments = storageKey.split('/') + const keySegments = storageKey.split('/'); - if (!keySegments || keySegments.length <= 1) { - continue; - } + if (!keySegments || keySegments.length <= 1) { + continue; + } - if(window.location.href.indexOf('/' + keySegments.slice(1).join('/')) !== -1) { - localStorage.removeItem(storageKey) - } + if (window.location.href.indexOf('/' + keySegments.slice(1).join('/')) !== -1) { + localStorage.removeItem(storageKey); + } + } + } + + /** + * Initializes the clickable table functionality + */ + initClickableTable() { + const links = this.el.find('tbody td .link'); + // Remove all existing click events to prevent multiple bindings + links.off('click'); + links.off('focus'); + links.off('blur'); + links.on('click', (ev) => { this.handleClick(ev); }); + links.on('focus', (ev) => { this.toggleFocus(ev, true); }); + links.on('blur', (ev) => { this.toggleFocus(ev, false); }); + } + + /** + * Toggles focus on a row + * @param {JQuery.FocusEvent} ev The event that triggered the focus change + * @param {boolean} hasFocus Whether the row has focus or not + */ + toggleFocus(ev, hasFocus) { + const row = $(ev.target).closest('tr'); + if (hasFocus) { + row.addClass('tr--focus'); + } else { + row.removeClass('tr--focus'); + } } - } - - initClickableTable() { - const links = this.el.find('tbody td .link') - // Remove all existing click events to prevent multiple bindings - links.off('click') - links.off('focus') - links.off('blur') - links.on('click', (ev) => { this.handleClick(ev) }) - links.on('focus', (ev) => { this.toggleFocus(ev, true) }) - links.on('blur', (ev) => { this.toggleFocus(ev, false) }) - } - - toggleFocus(ev, hasFocus) { - const row = $(ev.target).closest('tr') - if (hasFocus) { - row.addClass('tr--focus') - } else { - row.removeClass('tr--focus') + + /** + * Handle click event on a row + * @param {JQuery.ClickEvent} ev The click event + */ + handleClick(ev) { + const rowClicked = $(ev.target).closest('tr'); + ev.preventDefault(); + this.fillModalData(rowClicked); + $(this.modal).modal('show'); } - } - handleClick(ev) { - const rowClicked = $(ev.target).closest('tr') - ev.preventDefault() - this.fillModalData(rowClicked) - $(this.modal).modal('show') - } + /** + * Fill the modal data from the clicked row + * @param {HTMLTableRowElement} row The row to fill the modal data from + */ + fillModalData(row) { + const fields = $(this.modal).find('input, textarea'); + const btnReject = $(this.modal).find('.btn-js-reject-request-send'); + const id = parseInt($(row).find('td[data-id]') + .data('id'), 10); - fillModalData(row) { - const fields = $(this.modal).find('input, textarea') - const btnReject = $(this.modal).find('.btn-js-reject-request-send') - const id = parseInt($(row).find(`td[data-id]`).data('id'), 10) + if (id) $(this.modal).data('config').id = id; - if (id) $(this.modal).data('config').id = id + if (btnReject && id && (!isNaN(id))) { + btnReject.val(id); + } - if (btnReject && id && (!isNaN(id))) { - btnReject.val(id) + fields.each((i, field) => { + const fieldName = $(field).attr('name'); + const fieldValue = $(row).find(`td[data-${fieldName}]`) + .data(fieldName); + + if (fieldName && fieldValue) { + const $field = $(field); + $field.data('original-value', fieldValue); + if ($field.is(':radio, :checkbox')) { + if ($field.val() == fieldValue) { + $field.trigger('click'); + } + } else { + $field.data('original-value', fieldValue); + $field.trigger('change'); + $field.val(fieldValue); + } + } + }); } - fields.each((i, field) => { - const fieldName = $(field).attr('name') - const fieldValue = $(row).find(`td[data-${fieldName}]`).data(fieldName) - - if (fieldName && fieldValue) { - const $field = $(field) - $field.data('original-value', fieldValue) - if ($field.is(":radio, :checkbox")) { - if ($field.val() == fieldValue) { - $field.trigger("click") - } - } else { - $field.data('original-value', fieldValue) - $field.trigger('change') - $field.val(fieldValue) - } - } - }) - } - - getCheckboxElement(id, label) { - return ( - `
` + - `` + - `` + - '
' - ) - } - - addSelectAllCheckbox() { - const $selectAllElm = this.el.find('thead th.check') - const $checkBoxes = this.el.find('tbody .check .checkbox input') - - if ($selectAllElm.length) { - $selectAllElm.html(this.getCheckboxElement('all', 'Select all')) + /** + * Get a checkbox element as an HTML string + * @param {number } id The ID for the checkbox element + * @param {string} label The label for the checkbox element + * @returns {string} The HTML string for the checkbox element + */ + getCheckboxElement(id, label) { + return ( + '
' + + `` + + `` + + '
' + ); + } + + /** + * Add a select all checkbox to the table header + */ + addSelectAllCheckbox() { + const $selectAllElm = this.el.find('thead th.check'); + const $checkBoxes = this.el.find('tbody .check .checkbox input'); + + if ($selectAllElm.length) { + $selectAllElm.html(this.getCheckboxElement('all', 'Select all')); + } + + // Check if all checkboxes are checked and the 'select all' checkbox needs to be checked + this.checkSelectAll($checkBoxes, $selectAllElm.find('input')); + + $checkBoxes.on('click', () => { + this.checkSelectAll($checkBoxes, $selectAllElm.find('input')); + }); + + // Check if the 'select all' checkbox is checked and all checkboxes need to be checked + $selectAllElm.find('input').on('click', (ev) => { + const checkbox = $(ev.target); + + if ($(checkbox).is(':checked')) { + this.checkAllCheckboxes($checkBoxes, true); + } else { + this.checkAllCheckboxes($checkBoxes, false); + } + }); } - // Check if all checkboxes are checked and the 'select all' checkbox needs to be checked - this.checkSelectAll($checkBoxes, $selectAllElm.find('input')) - - $checkBoxes.on('click', () => { - this.checkSelectAll($checkBoxes, $selectAllElm.find('input')) - }) - - // Check if the 'select all' checkbox is checked and all checkboxes need to be checked - $selectAllElm.find('input').on( 'click', (ev) => { - const checkbox = $(ev.target) - - if ($(checkbox).is( ':checked' )) { - this.checkAllCheckboxes($checkBoxes, true) - } else { - this.checkAllCheckboxes($checkBoxes, false) - } - }) - } - - checkAllCheckboxes($checkBoxes, bCheckAll) { - if (bCheckAll) { - $checkBoxes.prop( 'checked', true ) - } else { - $checkBoxes.prop('checked', false) + /** + * Check or uncheck all checkboxes in the table + * @param {JQuery} $checkBoxes The checkboxes to check or uncheck + * @param {boolean} bCheckAll True to check all checkboxes, false to uncheck all + */ + checkAllCheckboxes($checkBoxes, bCheckAll) { + if (bCheckAll) { + $checkBoxes.prop('checked', true); + } else { + $checkBoxes.prop('checked', false); + } } - } - checkSelectAll($checkBoxes, $selectAllCheckBox) { - let bSelectAll = true + /** + * Check or uncheck the 'select all' checkbox based on the state of the individual checkboxes + * @param {JQuery} $checkBoxes The checkboxes to check or uncheck + * @param {JQuery} $selectAllCheckBox The select all checkbox to update + */ + checkSelectAll($checkBoxes, $selectAllCheckBox) { + let bSelectAll = true; - $checkBoxes.each((i, checkBox) => { - if (!checkBox.checked) { - $selectAllCheckBox.prop('checked', false) - bSelectAll = false - } - }) + $checkBoxes.each((i, checkBox) => { + if (!checkBox.checked) { + $selectAllCheckBox.prop('checked', false); + bSelectAll = false; + } + }); - if (bSelectAll) { - $selectAllCheckBox.prop('checked', true) + if (bSelectAll) { + $selectAllCheckBox.prop('checked', true); + } } - } - addSortButton(dataTable, column, headerContent) { - const $header = $(column.header()) - const $button = $(` + /** + * 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 = $(` ` - ) + ); - $header - .off() - .find('.data-table__header-wrapper').html($button) + $header + .off() + .find('.data-table__header-wrapper') + .html($button); - dataTable.order.listener($button, column.index() ) - } + dataTable.order.listener($button, column.index()); + } - toggleFilter(column) { - const $header = $(column.header()) + /** + * 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 (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(); + } } - } - - // Self reference included due to scoping - async addSearchDropdown(column, id, index) { - 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') - } + ); + + /* 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'); + } + + $header.find('.data-table__header-wrapper').prepend($searchElement); + + this.toggleFilter(column); + + 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(); + }); + } + + $header.find('.data-table__header-wrapper').prepend($searchElement); + + this.toggleFilter(column); + + 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(); + }); + } + + // 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); - $header.find('.data-table__header-wrapper').prepend($searchElement) - - this.toggleFilter(column) - - 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(); - }); + // 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(); + + 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]}`; + + if (self.searchParams.entries().next().value !== undefined) { + url += `?${self.searchParams.toString()}`; + } + + // Update URL. See comment above about the same + window.history.replaceState(null, '', url); + } + }); } - // 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 - self.searchParams.has(id) ? - self.searchParams.set(id, this.value) : - 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() - - 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]}` - - if (self.searchParams.entries().next().value !== undefined) { - url += `?${self.searchParams.toString()}` - } - - // Update URL. See comment above about the same - window.history.replaceState(null, '', url); - } - }) - } - - getApiEndpoint(columnId) { - const table = $("body").data("layout-identifier"); - return `/${table}/match/layout/${columnId}?q=`; - } - - encodeHTMLEntities(text) { - return $("