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/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7e2e364dc..5280c46f7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -157,9 +157,11 @@ jobs: echo '*:*:*:postgres:postgrespassword' > ~/.pgpass chmod 600 ~/.pgpass - name: 'Install YARN dependencies' + # If the code isn't built (public dir) then the tests will fail run: | sudo apt-get install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2 libxtst6 xauth xvfb yarn + yarn build --no-watch - name: 'Start the application' env: DANCER_CONFDIR: 'webdriver' diff --git a/.gitigore b/.gitigore deleted file mode 100644 index 722d5e71d..000000000 --- a/.gitigore +++ /dev/null @@ -1 +0,0 @@ -.vscode diff --git a/.jshintrc b/.jshintrc deleted file mode 100644 index a552917c6..000000000 --- a/.jshintrc +++ /dev/null @@ -1,22 +0,0 @@ -{ - "curly":true, - "eqeq":true, - "esversion":5, - "forin":true, - "freeze":true, - "futurehostile":true, - "latedef":true, - "nocomma":true, - "nonbsp":true, - "nonew":true, - "shadow":"inner", - "singleGroups":true, - "strict":true, - "trailingcomma":true, - "undef":true, - "unused":true, - "varstmt":false, - - "browser":true - -} diff --git a/.stylelintrc.js b/.stylelintrc.js deleted file mode 100644 index e1aa130db..000000000 --- a/.stylelintrc.js +++ /dev/null @@ -1,233 +0,0 @@ -module.exports = { - extends: 'stylelint-config-standard', - plugins: ['stylelint-order', 'stylelint-declaration-strict-value'], - rules: { - 'at-rule-no-unknown': [ - true, - { - ignoreAtRules: ['at-root', 'content', 'function', 'include', 'mixin', 'if', 'else', 'return', 'extend'], - }, - ], - 'at-rule-no-vendor-prefix': true, - 'color-hex-case': 'upper', - 'declaration-block-trailing-semicolon': 'always', - 'declaration-no-important': true, - 'property-no-vendor-prefix': true, - 'selector-max-id': 0, - 'selector-max-universal': 1, - 'selector-no-qualifying-type': [ - true, - { - ignore: ['attribute'], - } - ], - 'value-no-vendor-prefix': true, - 'order/order': [ - 'dollar-variables', - 'custom-properties', - { - type: 'at-rule', - name: 'include', - hasBlock: false, - }, - 'declarations', - 'rules', - { - type: 'at-rule', - name: 'include', - hasBlock: true, - }, - ], - 'order/properties-order': [ - 'content', - 'quotes', - 'display', - 'visibility', - 'position', - 'z-index', - 'top', - 'right', - 'bottom', - 'left', - 'box-sizing', - 'flex', - 'flex-basis', - 'flex-direction', - 'flex-flow', - 'flex-grow', - 'flex-shrink', - 'flex-wrap', - 'align-content', - 'align-items', - 'align-self', - 'justify-content', - 'order', - 'width', - 'min-width', - 'max-width', - 'height', - 'min-height', - 'max-height', - 'margin', - 'margin-top', - 'margin-right', - 'margin-bottom', - 'margin-left', - 'padding', - 'padding-top', - 'padding-right', - 'padding-bottom', - 'padding-left', - 'float', - 'clear', - 'overflow', - 'overflow-x', - 'overflow-y', - 'clip', - 'zoom', - 'columns', - 'column-gap', - 'column-fill', - 'column-rule', - 'column-span', - 'column-count', - 'column-width', - 'data-table-layout', - 'empty-cells', - 'caption-side', - 'border-spacing', - 'border-collapse', - 'list-style', - 'list-style-position', - 'list-style-type', - 'list-style-image', - 'transform', - 'transform-origin', - 'transform-style', - 'backface-visibility', - 'perspective', - 'perspective-origin', - 'transition', - 'transition-property', - 'transition-duration', - 'transition-timing-function', - 'transition-delay', - 'animation', - 'animation-name', - 'animation-duration', - 'animation-play-state', - 'animation-timing-function', - 'animation-delay', - 'animation-iteration-count', - 'animation-direction', - 'border', - 'border-top', - 'border-right', - 'border-bottom', - 'border-left', - 'border-width', - 'border-top-width', - 'border-right-width', - 'border-bottom-width', - 'border-left-width', - 'border-style', - 'border-top-style', - 'border-right-style', - 'border-bottom-style', - 'border-left-style', - 'border-radius', - 'border-top-left-radius', - 'border-top-right-radius', - 'border-bottom-left-radius', - 'border-bottom-right-radius', - 'border-color', - 'border-top-color', - 'border-right-color', - 'border-bottom-color', - 'border-left-color', - 'outline', - 'outline-color', - 'outline-offset', - 'outline-style', - 'outline-width', - 'stroke-width', - 'stroke-linecap', - 'stroke-dasharray', - 'stroke-dashoffset', - 'stroke', - 'opacity', - 'background', - 'background-color', - 'background-image', - 'background-repeat', - 'background-position', - 'background-size', - 'box-shadow', - 'fill', - 'color', - 'font', - 'font-family', - 'font-size', - 'font-size-adjust', - 'font-stretch', - 'font-effect', - 'font-style', - 'font-variant', - 'font-weight', - 'font-emphasize', - 'font-emphasize-position', - 'font-emphasize-style', - 'letter-spacing', - 'line-height', - 'list-style', - 'word-spacing', - 'text-align', - 'text-align-last', - 'text-decoration', - 'text-indent', - 'text-justify', - 'text-overflow', - 'text-overflow-ellipsis', - 'text-overflow-mode', - 'text-rendering', - 'text-outline', - 'text-shadow', - 'text-transform', - 'text-wrap', - 'word-wrap', - 'word-break', - 'text-emphasis', - 'text-emphasis-color', - 'text-emphasis-style', - 'text-emphasis-position', - 'vertical-align', - 'white-space', - 'word-spacing', - 'hyphens', - 'src', - 'tab-size', - 'counter-reset', - 'counter-increment', - 'resize', - 'cursor', - 'pointer-events', - 'speak', - 'user-select', - 'nav-index', - 'nav-up', - 'nav-right', - 'nav-down', - 'nav-left', - ], - 'scale-unlimited/declaration-strict-value': [ - ["/color/", "fill", "stroke"], - { - ignoreKeywords: { - "color": ["currentColor", "transparent", "inherit"], - "fill": ["currentColor", "transparent", "inherit"], - "stroke": ["currentColor", "transparent", "inherit"], - } - }, - ], - } -} diff --git a/cypress/e2e/functionality/02-homepage.cy.ts b/cypress/e2e/functionality/02-homepage.cy.ts index 5c3c4eaab..bba917fdd 100644 --- a/cypress/e2e/functionality/02-homepage.cy.ts +++ b/cypress/e2e/functionality/02-homepage.cy.ts @@ -2,17 +2,14 @@ import { goodPassword, goodUser } from "../../support/constants"; describe('Dashboard Tests', () => { const bigLipsum = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque accumsan, sem et vulputate imperdiet, enim ipsum rhoncus massa, sit amet pellentesque lacus urna sit amet enim. In tristique mollis tincidunt. Sed eget ligula metus. Integer sodales placerat erat. Aliquam erat volutpat. Curabitur varius lacinia diam fringilla efficitur. Sed euismod purus vel turpis molestie, ac fringilla nisi vestibulum. Maecenas ullamcorper ornare dui sodales gravida. Donec maximus egestas eleifend. Etiam in ultrices ante. Duis quis volutpat turpis. Nulla dignissim ornare aliquet. -Fusce interdum gravida est, sit amet vehicula nisi suscipit at. Pellentesque nec fermentum leo, in vehicula nulla. Nam dapibus ultricies tortor in maximus. Donec enim velit, molestie nec feugiat sed, posuere non ex. Nulla pellentesque gravida feugiat. Sed ornare purus vel libero semper aliquet. Nulla rutrum nunc sed vulputate gravida. Cras lobortis, lacus non tincidunt suscipit, leo quam vehicula libero, in vehicula diam justo ac est. -Mauris tempus, mi nec sodales semper, metus neque blandit sem, non scelerisque nunc libero eu augue. Cras ornare ut lectus in mattis. Quisque magna elit, efficitur nec dolor sed, semper dictum nunc. Cras ultricies, augue eget interdum aliquam, quam ex blandit sem, nec sollicitudin ex elit non mauris. Fusce dui justo, feugiat id lacus sit amet, pulvinar tristique felis. Etiam rhoncus ex ut congue aliquet. Sed at felis eget neque rhoncus malesuada. Aliquam commodo condimentum massa, sed volutpat nibh congue et. Maecenas blandit massa sed nisl pulvinar, vitae consectetur tortor placerat. Nulla laoreet diam ipsum, sit amet consectetur sem condimentum quis. Nullam et justo sem. Sed et sapien tempus, scelerisque nisl ac, pretium arcu. -Donec quis finibus ante. Nulla et dui posuere, semper elit quis, maximus ipsum. Aliquam ante nulla, pellentesque sed neque sit amet, finibus cursus est. Aliquam sed sollicitudin orci. Nulla malesuada augue lectus, ac tincidunt orci fermentum ac. Nullam pulvinar diam felis, sed condimentum arcu ornare sit amet. Praesent eget lobortis purus. In hac habitasse platea dictumst. Ut lorem nisl, fringilla vitae quam lobortis, vehicula egestas magna. Sed convallis placerat ante quis convallis. Vivamus pharetra quam diam, ut ultricies neque mollis vitae. Morbi augue tellus, feugiat a interdum a, tincidunt vel ante. -Ut diam tortor, hendrerit eget ipsum non, suscipit mattis elit. Pellentesque ut porttitor risus, at pulvinar tortor. Integer eleifend volutpat efficitur. Maecenas massa odio, pharetra eu eleifend eu, volutpat sed dui. Donec efficitur sed risus sit amet imperdiet. Pellentesque nec arcu non nibh congue cursus et a sapien. Phasellus ullamcorper magna nec varius facilisis. Curabitur et tempus est. Nulla tincidunt porttitor mollis. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Aenean vitae pretium felis. Aliquam vehicula et nisl sit amet pharetra. Nulla ac sollicitudin velit. Cras egestas ac sem vitae sollicitudin. Nullam convallis risus id massa vestibulum imperdiet. Cras in enim sit amet ligula placerat eleifend.` +Fusce interdum gravida est, sit amet vehicula nisi suscipit at. Pellentesque nec fermentum leo, in vehicula nulla. Nam dapibus ultricies tortor in maximus. Donec enim velit, molestie nec feugiat sed, posuere non ex. Nulla pellentesque gravida feugiat. Sed ornare purus vel libero semper aliquet. Nulla rutrum nunc sed vulputate gravida. Cras lobortis, lacus non tincidunt suscipit, leo quam vehicula libero, in vehicula diam justo ac est.` beforeEach(() => { - cy.loginAndGoTo(goodUser, goodPassword, 'http://localhost:3000/?did=1'); + cy.loginAndGoTo(goodUser, goodPassword, 'http://localhost:3000/'); }) afterEach(() => { - cy.visit('http://localhost:3000/?did=1'); + cy.visit('http://localhost:3000/'); cy.location("pathname").should("not.include", "/login"); cy.getByTitle("Logout") .should("exist") @@ -21,54 +18,66 @@ Ut diam tortor, hendrerit eget ipsum non, suscipit mattis elit. Pellentesque ut }) it('Displays the home page correctly', () => { - cy.get("li.list__item").eq(0) + cy.get("div.nav-item").eq(0) .should("exist") .contains("Home dashboard (shared)") - .should("have.class", "link--active"); - cy.get("li.list__item").eq(1) + .should("have.class", "active"); + cy.get("div.nav-item").eq(1) .should("exist") .contains("Home dashboard (personal)") - .should("not.have.class", "link--active"); + .should("not.have.class", "active"); }); it('Should navigate to the personal dashboard', () => { - cy.get("li.list__item").eq(1) + cy.get("div.nav-item").eq(1) .should("exist") - .should("not.have.class", "link--active") + .children("a") + .should("not.have.class", "active") .click(); + cy.get("div.nav-item").eq(1) + .should("exist") + .children("a") + .should("have.class", "active"); }); it('Should navigate to the shared dashboard', () => { - cy.get("li.list__item").eq(0) + cy.get("div.nav-item").eq(0) .should("exist") - .should("not.have.class", "link--active") + .children("a") + .should("not.have.class", "active") .click(); + cy.get("div.nav-item").eq(0) + .should("exist") + .children("a") + .should("have.class", "active"); }); context("Shared Dashboard", () => { it('Should cancel creation of a shared dashboard widget', () => { + cy.visit('http://localhost:3000/?did=1'); cy.get(".ld-footer-container") .find("button") .eq(1) .click(); cy.get(".ld-footer-container") - .find(".dropdown__menu") + .find(".dropdown-menu") .find("a") - .eq(1) + .eq(0) .click(); cy.get("[aria-label='Edit Modal']").find("button.btn-cancel").click(); cy.get(".ld-widget").should("have.length", 0); }); it("Should create a shared dashboard widget", () => { + cy.visit('http://localhost:3000/?did=1'); cy.get(".ld-footer-container") .find("button") .eq(1) .click(); cy.get(".ld-footer-container") - .find(".dropdown__menu") + .find(".dropdown-menu") .find("a") - .eq(1) + .eq(0) .click(); cy.get("[aria-label='Edit Modal']") .find("input[name='title']", { timeout: 10000 }) @@ -81,6 +90,7 @@ Ut diam tortor, hendrerit eget ipsum non, suscipit mattis elit. Pellentesque ut }); it("Should edit a shared dashboard widget", () => { + cy.visit('http://localhost:3000/?did=1'); cy.get(".ld-widget").find(".ld-edit-button").click(); cy.get("[aria-label='Edit Modal']") .find("input[name='title']", { timeout: 10000 }) @@ -95,6 +105,7 @@ Ut diam tortor, hendrerit eget ipsum non, suscipit mattis elit. Pellentesque ut }); it("Should edit a shared dashboard widget with a lot of text", () => { + cy.visit('http://localhost:3000/?did=1'); cy.get(".ld-widget").find(".ld-edit-button").click(); cy.get("[aria-label='Edit Modal']") .find("input[name='title']", { timeout: 10000 }) @@ -109,6 +120,7 @@ Ut diam tortor, hendrerit eget ipsum non, suscipit mattis elit. Pellentesque ut }); it("Should delete a shared dashboard widget", () => { + cy.visit('http://localhost:3000/?did=1'); cy.get(".ld-widget").find(".ld-edit-button").click(); cy.get("[aria-label='Edit Modal']") .find("input[name='title']", { timeout: 10000 }) @@ -120,30 +132,30 @@ Ut diam tortor, hendrerit eget ipsum non, suscipit mattis elit. Pellentesque ut context("Personal Dashboard", () => { it('Should cancel creation of a personal dashboard widget', () => { - cy.get("a.link--primary").click(); + cy.visit('http://localhost:3000/?did=2'); cy.get(".ld-footer-container") .find("button") .eq(1) .click(); cy.get(".ld-footer-container") - .find(".dropdown__menu") + .find(".dropdown-menu") .find("a") - .eq(1) + .eq(0) .click(); cy.get("[aria-label='Edit Modal']").find("button.btn-cancel").click(); cy.get(".ld-widget").should("have.length", 1); }); it("Should create a personal dashboard widget", () => { - cy.get("a.link--primary").click(); + cy.visit('http://localhost:3000/?did=2'); cy.get(".ld-footer-container") .find("button") .eq(1) .click(); cy.get(".ld-footer-container") - .find(".dropdown__menu") + .find(".dropdown-menu") .find("a") - .eq(1) + .eq(0) .click(); cy.get("[aria-label='Edit Modal']") .find("input[name='title']", { timeout: 10000 }) @@ -156,7 +168,7 @@ Ut diam tortor, hendrerit eget ipsum non, suscipit mattis elit. Pellentesque ut }); it("Should edit a personal dashboard widget", () => { - cy.get("a.link--primary").click(); + cy.visit('http://localhost:3000/?did=2'); cy.get(".ld-widget").eq(1).find(".ld-edit-button").click(); cy.get("[aria-label='Edit Modal']") .find("input[name='title']", { timeout: 10000 }) @@ -171,7 +183,7 @@ Ut diam tortor, hendrerit eget ipsum non, suscipit mattis elit. Pellentesque ut }); it("Should edit a personal dashboard widget with a lot of text", () => { - cy.get("a.link--primary").click(); + cy.visit('http://localhost:3000/?did=2'); cy.get(".ld-widget").eq(1).find(".ld-edit-button").click(); cy.get("[aria-label='Edit Modal']") .find("input[name='title']", { timeout: 10000 }) @@ -186,7 +198,7 @@ Ut diam tortor, hendrerit eget ipsum non, suscipit mattis elit. Pellentesque ut }); it("Should delete a personal dashboard widget", () => { - cy.get("a.link--primary").click(); + cy.visit('http://localhost:3000/?did=2'); cy.get(".ld-widget").eq(1).find(".ld-edit-button").click(); cy.get("[aria-label='Edit Modal']") .find("input[name='title']", { timeout: 10000 }) diff --git a/cypress/e2e/functionality/06-tables-and-wizard.cy.ts b/cypress/e2e/functionality/06-tables-and-wizard.cy.ts index 715778acb..1905d7050 100644 --- a/cypress/e2e/functionality/06-tables-and-wizard.cy.ts +++ b/cypress/e2e/functionality/06-tables-and-wizard.cy.ts @@ -8,7 +8,7 @@ describe('Another Test Suite', () => { //attempt save with incorrect (fails) it.skip('should fail to save new table with invalid shortname ', () => { - cy.get('[data-target="#newTableModal"]').click(); + cy.get('[data-bs-target="#newTableModal"]').click(); cy.get('#shortName').type('This value wont $4v£'); cy.get("#name").type("table to fail"); cy.get('.btn-js-next').eq(0).click(); @@ -19,7 +19,7 @@ describe('Another Test Suite', () => { }); it('should save a new table successfully', () => { - cy.get('[data-target="#newTableModal"]').click(); + cy.get('[data-bs-target="#newTableModal"]').click(); cy.get('#shortName').type('1-test_table'); cy.get("#name").type("1-test-table"); cy.get('.btn-js-next').eq(0).click(); diff --git a/cypress/support/builders/layout/LayoutBuilder.ts b/cypress/support/builders/layout/LayoutBuilder.ts index 4d10a75fd..7568543ca 100644 --- a/cypress/support/builders/layout/LayoutBuilder.ts +++ b/cypress/support/builders/layout/LayoutBuilder.ts @@ -52,7 +52,7 @@ abstract class LayoutBuilderBase implements ILayoutBuilder { build(navigate:boolean = false): void { if (navigate) { cy.visit("http://localhost:3000/table"); - cy.getDataTable() + cy.get("table") .find("tbody") .find("tr").first() .find("a").contains("Edit table") @@ -68,7 +68,7 @@ abstract class LayoutBuilderBase implements ILayoutBuilder { .type(this.name); this.setType(); if (this.shortName) { - cy.get("button") + cy.get("span") .contains("Advanced settings") .click(); cy.get("input[name='name_short']") @@ -101,7 +101,7 @@ abstract class LayoutBuilderBase implements ILayoutBuilder { } protected setPermissions(): void { - cy.get("button") + cy.get("span") .contains("Permissions") .click(); cy.getDataTable() @@ -135,14 +135,14 @@ class CodeLayoutBuilder extends LayoutBuilderBase implements ICodeLayoutBuilder buildSpecific() { // Expand the code editor if (this.layoutType === "RAG") { - cy.get("button") + cy.get("span") .contains("Field settings for RAG") .click(); // Enter the code cy.get("textarea[name='code_rag']") .type(this.code); } else if (this.layoutType === "CALC") { - cy.get("button") + cy.get("span") .contains("Field settings for calculated value") .click(); cy.get("textarea[name='code_calc']") @@ -174,7 +174,7 @@ class DropdownLayoutBuilder extends LayoutBuilderBase implements IDropdownLayout buildSpecific() { // Expand the options - cy.get("button") + cy.get("span") .contains("Field settings for dropdown list") .click(); // Enter the options @@ -210,7 +210,7 @@ class CurvalLayoutBuilder extends LayoutBuilderBase implements ICurvalLayoutBuil buildSpecific() { // Expand Field settings - cy.get("button") + cy.get("span") .contains("Field settings for fields from another table") .click(); // Enter the options @@ -222,7 +222,7 @@ class CurvalLayoutBuilder extends LayoutBuilderBase implements ICurvalLayoutBuil cy.get("label") .contains(this.field) .click(); - cy.get("button.btn-xs[data-delete='rule']") + cy.get("button.btn-sm[data-delete='rule']") .eq(1) .click(); //Someone owes me a drink! diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 0e6209977..0f0b1acec 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-namespace */ /// import { IBuildable } from "./builders/layout/interfaces"; diff --git a/jest.config.js b/jest.config.js index f7fea59ff..487f06dbf 100644 --- a/jest.config.js +++ b/jest.config.js @@ -97,6 +97,7 @@ const config = { "^components/(.*)$": "/src/frontend/components/$1", "^set-field-values$": "/src/frontend/js/lib/set-field-values", "^guid$": "/src/frontend/js/lib/guid", + "^testing/(.*)$": "/src/frontend/testing/$1", }, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader @@ -141,7 +142,7 @@ const config = { // runner: "jest-runner", // The paths to modules that run some code to configure or set up the testing environment before each test - // setupFiles: [], + setupFiles: ['./src/frontend/testing/setup.ts'], // A list of paths to modules that run some code to configure or set up the testing framework before each test // setupFilesAfterEnv: [], diff --git a/lib/GADS.pm b/lib/GADS.pm index 501ad4f85..3a7818db8 100644 --- a/lib/GADS.pm +++ b/lib/GADS.pm @@ -414,7 +414,7 @@ get '/' => require_login sub { dashboards_json => schema->resultset('Dashboard')->dashboards_json(%params), page => 'index', 'content_block_main_custom_classes' => 'pt-0', - 'content_block_custom_classes' => 'pl-0' + 'content_block_custom_classes' => 'ps-0' }; if (my $download = param('download')) @@ -2129,7 +2129,7 @@ prefix '/:layout_name' => sub { dashboards_json => schema->resultset('Dashboard')->dashboards_json(%params), page => 'table_index', header_type => "table_tabs", - content_block_custom_classes => "pl-0", + content_block_custom_classes => "ps-0", content_block_main_custom_classes => "pt-0", header_back_url => "${base_url}table", layout_obj => $layout, diff --git a/package.json b/package.json index 08d0428c1..074d4d867 100644 --- a/package.json +++ b/package.json @@ -16,83 +16,85 @@ "e2e:electron": "yarn cypress run --browser electron" }, "dependencies": { - "@egjs/hammerjs": "^2.0.0", - "@lol768/jquery-querybuilder-no-eval": "^2.6.0", - "bootstrap": "4.6", - "bootstrap-datepicker": "^1.9.0", + "@egjs/hammerjs": "^2.0.17", + "@popperjs/core": "^2.11.8", + "bootstrap": "^5.3.3", + "bootstrap-datepicker": "^1.10.0", "bootstrap-html5sortable": "^1.0.0", "bootstrap-select": "^1.13.18", "component-emitter": "^1.3.0", - "datatables.net-bs4": "^2.0.8", - "datatables.net-buttons-bs4": "^3.0.2", - "datatables.net-responsive-bs4": "^3.0.2", - "datatables.net-rowreorder-bs4": "^1.5.0", + "datatables.net-bs5": "^2.2.2", + "datatables.net-buttons-bs5": "^3.2.2", + "datatables.net-responsive-bs5": "^3.0.3", + "datatables.net-rowreorder-bs5": "^1.5.0", "form-serialize": "^0.7.2", - "handlebars": "^4.7.7", - "imports-loader": "^1.2.0", - "jquery": "^3.6.0", + "handlebars": "^4.7.8", + "imports-loader": "^5.0.0", + "jQuery-QueryBuilder": "^3.0.0", + "jquery": "^3.7.1", "jquery-ui-sortable-npm": "^1.0.0", - "jstree": "^3.3.12", - "keycharm": "^0.3.0", - "marked": "^9.1.1", - "moment": "^2.24.0", - "popper.js": "^1.16.1", - "propagating-hammerjs": "^1.4.0", - "react": "^16.13.1", - "react-app-polyfill": "^1.0.6", - "react-dom": "^16.13.1", - "react-grid-layout": "^0.18.3", - "react-modal": "^3.11.2", - "regenerator-runtime": "^0.13.11", - "summernote": "^0.8.20", + "jstree": "^3.3.17", + "keycharm": "^0.4.0", + "marked": "^15.0.7", + "moment": "^2.30.1", + "postcss": "^8.5.1", + "propagating-hammerjs": "^2.0.0", + "react": "^19.0.0", + "react-app-polyfill": "^3.0.0", + "react-bootstrap": "^2.10.9", + "react-dom": "^19.0.0", + "react-grid-layout": "^1.5.0", + "react-modal": "^3.16.3", + "regenerator-runtime": "^0.14.1", + "summernote": "^0.9.1", "tippy.js": "^6.3.7", "typeahead.js": "^0.11.1", - "uuid": "^7.0.0", - "vis-data": "^6.3.0", - "vis-timeline": "7.4.3", - "vis-util": "^4.0.0" + "uuid": "^9.0.0", + "vis-data": "^7.1.9", + "vis-timeline": "^7.7.3", + "vis-util": "^5.0.7", + "xss": "^1.0.15" }, "devDependencies": { - "@babel/core": "^7.14.6", - "@babel/plugin-transform-react-jsx": "^7.22.15", - "@babel/preset-env": "^7.14.7", - "@babel/preset-react": "^7.16.7", - "@babel/preset-typescript": "^7.16.7", - "@babel/runtime-corejs3": "^7.14.7", + "@babel/core": "^7.26.8", + "@babel/plugin-transform-react-jsx": "^7.25.9", + "@babel/preset-env": "^7.26.8", + "@babel/preset-react": "^7.26.3", + "@babel/preset-typescript": "^7.26.0", + "@babel/runtime-corejs3": "^7.26.7", "@jest/globals": "^29.7.0", - "@types/jest": "^29.5.6", - "@types/jquery": "^3.5.24", + "@testing-library/dom": "^10.4.0", + "@testing-library/react": "^16.2.0", + "@types/bootstrap": "^5.2.10", + "@types/form-serialize": "^0.7.4", + "@types/jquery": "^3.5.32", "@types/jstree": "^3.3.46", - "@types/react": "^17.0.41", - "@types/react-dom": "^17.0.14", - "@types/react-grid-layout": "^1.3.2", + "@types/react": "^19.0.8", + "@types/react-bootstrap": "^0.32.37", + "@types/react-dom": "^19.0.3", + "@types/react-grid-layout": "^1.3.5", + "@types/react-modal": "^3.16.3", "@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-jest": "^29.7.0", - "babel-loader": "^8.2.2", - "buffer": "^6.0.3", + "@webpack-cli/serve": "^3.0.1", + "autoprefixer": "^10.4.20", + "babel-loader": "^9.2.1", "clean-webpack-plugin": "^4.0.0", - "copy-webpack-plugin": "6", - "core-js": "^3.15.2", - "css-loader": "^3.2.0", - "cypress": "^13.7.2", - "eslint": "^8.57.0", - "eslint-plugin-react": "^7.34.1", + "copy-webpack-plugin": "^12.0.2", + "core-js": "^3.40.0", + "css-loader": "^7.1.2", + "cypress": "^14.0.2", + "express": "^4.21.2", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", - "mini-css-extract-plugin": "^2.7.2", - "postcss-loader": "^3.0.0", - "sass": "^1.23.7", - "sass-loader": "^8.0.0", - "terser-webpack-plugin": "^5.3.6", - "ts-loader": "~8.2.0", - "typescript": "5.4.3", - "webpack": "^5.75.0", - "webpack-cli": "^5.0.1", - "webpack-dev-server": "^4.11.1", + "mini-css-extract-plugin": "^2.9.2", + "postcss-loader": "^8.1.1", + "sass": "^1.84.0", + "sass-loader": "^16.0.4", + "terser-webpack-plugin": "^5.3.11", + "ts-loader": "^9.5.2", + "typescript": "^5.7.3", + "webpack": "^5.97.1", + "webpack-cli": "^6.0.1", "webpack-manifest-plugin": "^5.0.0" }, "browserslist": [ diff --git a/src/frontend/components/bootstrap-popover/_popover.scss b/src/frontend/components/bootstrap-popover/_popover.scss new file mode 100644 index 000000000..3104e9c14 --- /dev/null +++ b/src/frontend/components/bootstrap-popover/_popover.scss @@ -0,0 +1,30 @@ +.btn-popover { + @include button-variant($transparent, $transparent, $primary, $hover-color: darken($primary, 10%)); + padding: 0; + margin: 0 $padding-small-vertical; + align-content: center; + border: 0; + span { + @include visually-hidden; + } + &::before { + transition: font-size 0.2s ease-in-out; + @extend %icon-font; + content: quote("\E810"); + font-size: 0.9rem; + } + &:hover::before { + font-size: 1rem; + } + &:focus::before { + border-radius: 50%; + box-shadow: 0 0 0 0.2rem rgba($secondary, 0.2); + } +} + +.popover-body { + max-height: 25rem; + overflow: auto; + background-color: $white; + border-radius: $border-radius; +} \ No newline at end of file diff --git a/src/frontend/components/bootstrap-popover/index.ts b/src/frontend/components/bootstrap-popover/index.ts new file mode 100644 index 000000000..4391f032d --- /dev/null +++ b/src/frontend/components/bootstrap-popover/index.ts @@ -0,0 +1,7 @@ +import { initializeComponent } from "component"; +import BootstrapPopoverComponent from "./lib/component"; + +export default (scope:any) =>{ + //@ts-expect-error Typings on initializeComponent are incorrect + initializeComponent(scope, '[data-bs-toggle="popover"]', BootstrapPopoverComponent); +} \ No newline at end of file diff --git a/src/frontend/components/bootstrap-popover/lib/component.ts b/src/frontend/components/bootstrap-popover/lib/component.ts new file mode 100644 index 000000000..7327408c5 --- /dev/null +++ b/src/frontend/components/bootstrap-popover/lib/component.ts @@ -0,0 +1,18 @@ +import { Popover } from "bootstrap"; +import { Component } from "component"; + +// This is a basic wrapper around the Bootstrap popover component. +// We will (as time goes on) replace our own popover component with this to reduce the amount of code we have to maintain. +export default class BootstrapPopoverComponent extends Component { + constructor(element:HTMLElement) { + super(element); + const $el = $(element); + const $contentElement = $el.closest(".popover-container")?.find(".popover-content"); + $contentElement?.hide(); + const content = $contentElement?.html() || $el.data("content") || $el.data("bs-content") || "empty"; + new Popover(element, { + html: true, + content: content, + }); + } +} \ No newline at end of file diff --git a/src/frontend/components/breadcrumbs/_breadcrumbs.scss b/src/frontend/components/breadcrumbs/_breadcrumbs.scss deleted file mode 100644 index 2dfc23d87..000000000 --- a/src/frontend/components/breadcrumbs/_breadcrumbs.scss +++ /dev/null @@ -1,69 +0,0 @@ -.breadcrumbs { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: $padding-small-horizontal; - font-size: $font-size-sm; - - .link { - display: flex; - align-items: center; - color: $brand-primary; - - &:hover { - border-bottom-color: $transparent; - } - } -} - -.breadcrumbs__item.link:not(:last-of-type) { - @include visually-hidden; -} - -.breadcrumbs__item.link:last-of-type { - &::before { - @extend %icon-font; - - content: "\E805"; - margin-right: 4px; - transform: rotate(180deg); - font-size: $font-size-xsm; - } -} - -.breadcrumbs__item--active, -.breadcrumbs__divider { - @include visually-hidden; -} - -.breadcrumbs__divider::after { - @extend %icon-font; -} - -@include media-breakpoint-up(md) { - .breadcrumbs__item.link, - .breadcrumbs__item.link:not(:last-of-type) { - border-bottom: 1px solid $brand-primary; - - &:last-of-type::before { - content: normal; - } - } - - .breadcrumbs__item.link:not(:last-of-type), - .breadcrumbs__item--active, - .breadcrumbs__divider { - @include visually-hidden-off; - } - - .breadcrumbs__item--active { - border-bottom: 1px solid $transparent; - color: $text-color; - } - - .breadcrumbs__divider::after { - content: "\E805"; - display: block; - font-size: 8px; - } -} diff --git a/src/frontend/components/button/_button.scss b/src/frontend/components/button/_button.scss index 261b9849f..9100fd20b 100644 --- a/src/frontend/components/button/_button.scss +++ b/src/frontend/components/button/_button.scss @@ -1,238 +1,34 @@ -$btn-border-radius: 23px; - .btn { - width: 100%; - padding: 10px 30px; - transition: 0.2s all ease; - border-radius: $btn-border-radius; - line-height: $headings-line-height; - vertical-align: unset; - &:not(:last-of-type) { margin-bottom: $padding-base-vertical; } } -.btn-default { - @include btn-default; -} - -.btn-small { - padding: 7px 20px; - font-size: $font-size-sm; -} - -.btn-primary { - @include btn-default; - - background-color: $brand-primary; - - &:hover, - &:focus, - &:not(:disabled):not(.disabled):active, - &:not(:disabled):not(.disabled):active:focus, - &:not(:disabled):not(.disabled).active, - &:not(:disabled):not(.disabled).active:focus { - border: 1px solid $transparent; - background-color: rgba($brand-primary, 0.8); - } - - &.disabled { - @include btn-disabled; - } -} - -.btn-add { - @include btn-default; - - &::before { - @extend %icon-font; - - content: "\E800"; - margin-right: 0.5rem; - color: $white; - } - - &.btn-inverted { - &::before { - color: $brand-secundary; - } - - &:hover, - &:active, - &:focus, - &:active:focus { - border: 1px solid $btn-default-hover-color; - background-color: $white; - - &::before { - color: $btn-default-hover-color; - } - } - } -} - -.btn-info { - @include btn-default; - - &::after { - @extend %icon-font; - - content: "\E810"; - margin-left: 0.5rem; - color: $white; - } - - &.btn-inverted { - &::after { - color: $brand-secundary; - } - - &:hover, - &:active, - &:focus, - &:active:focus { - border: 1px solid $btn-default-hover-color; - background-color: $white; - - &::after { - color: $btn-default-hover-color; - } - } - } -} - -.btn-cancel { - border: 1px solid $gray-extra-dark; - background-color: $white; - color: $gray-extra-dark; - - &:hover, - &:active, - &:focus, - &:active:focus { - border-color: $btn-cancel-hover-color; - // background-color: rgba($gray-dark, 0.2); - color: $btn-cancel-hover-color; - } -} - -.btn-drag-widget { - margin-right: $padding-small-horizontal; - - &::before { - content: "\E829"; - } -} - -.btn-edit-widget { - margin-right: $padding-small-horizontal; - - &::before { - content: "\E80b"; - } -} - -.btn-edit-widget, -.btn-drag-widget { - width: 1.5rem; - height: 1.5rem; - margin: 0; - padding: 0; - transition: 0.2s all ease; - border-radius: 5px; - background-color: $white; - - &::before { - @extend %icon-font; - - transition: 0.2s all ease; - } - - span { - @include visually-hidden; - } - - &:hover, - &:active, - &:focus, - &:active:focus { - background-color: $brand-primary; - color: $brand-primary; - - &::before { - color: $white; - } - } +.btn-inverted { + @include button-variant($white, $brand-secundary, $brand-secundary); } .btn-delete { - display: flex; - align-items: center; - width: auto; + @include button-variant(transparent, transparent, $brand-danger, $hover-color: $brand-danger); padding: 0; - border: 0; - border-radius: 0; - color: $brand-danger; - - span { - transition: 0.2s all ease; - border-bottom: solid 1px $transparent; - } - - &:hover, - &:active, - &:focus, - &:active:focus { - box-shadow: unset; - color: $brand-danger; - - span { - border-bottom: solid 1px $brand-danger; - } - } -} - -.btn-icon-close { - width: auto; - padding: 0; - border: 0; - border-radius: 0; - - &::before { - @extend %icon-font; - - content: "\E807"; - font-size: $font-size-xsm; - } -} - -.btn-delete.btn-icon-close { - &::before { - margin-right: 0.75rem; + &:hover { + text-decoration: underline; } } -.btn-disabled { - background-color: $gray; - color: $white; - +.btn-link { + @include button-variant($transparent, $transparent, $brand-primary, $hover-color: $brand-secundary); &:hover { - color: $white; - cursor: not-allowed; + text-decoration: underline; } } -.btn-remove { - @include btn-default; - - &::before { - @extend %icon-font; +.btn-cancel { + @include button-variant($white, $gray-extra-dark, $gray-extra-dark, $hover-border: $btn-cancel-hover-color, $hover-color: $btn-cancel-hover-color); +} - content: "\e82b"; - margin-right: 0.5rem; - color: $white; - } +.btn-default { + @include button-variant($brand-secundary, $brand-secundary, $white); } .btn-import, @@ -278,356 +74,27 @@ $btn-border-radius: 23px; } } -/* stylelint-disable no-descending-specificity */ -.btn-round { - width: auto; - padding: 10px 12px; - transition: 0.2s all ease; - border-radius: $btn-border-radius; - background-color: $brand-secundary; - box-shadow: $box-shadow-dark; - - &::before { - @extend %icon-font; - - color: $white; - font-size: $font-size-lg; - } - - span { - @include visually-hidden; - } - - &:hover, - &:active, - &:focus, - &:active:focus { - transform: translateY(-4px); - } - - &.btn-add::before { - margin-right: 0; - } -} - -/* stylelint-enable no-descending-specificity */ -.btn-inverted { - @include btn-inverted; -} - -.btn-back, -.btn-title-back { - width: auto; - padding: 0; - border: 0; - border-radius: 0; - color: $gray-extra-dark; - font-weight: $font-weight-semibold; - text-align: left; - - &::before { +.btn-info { + &::after { @extend %icon-font; - - content: "\E805"; - margin-right: 4px; - transform: rotate(180deg); - transition: margin-left 0.3s ease-in-out, padding-left 0.3s ease-in-out; - font-size: $font-size-sm; - } - - &:hover, - &:active { - &::before { - margin-left: -4px; - padding-left: 4px; - } - } - - &:focus, - &:active:focus { - box-shadow: unset; - - .btn__title { - border-bottom: solid 1px $gray-extra-dark; - } - - &::before { - margin-left: -4px; - padding-left: 4px; - } - } -} - -/* stylelint-disable no-descending-specificity */ -.btn-title-back { - @include font-style-h2; - - margin-top: 0; - color: $brand-primary; - - &::before { - color: $brand-primary; - font-size: $font-size-base; - } - - &:hover, - &:active { - color: $brand-primary; - } - - &:focus, - &:active:focus { - .btn__title { - border-bottom: solid 1px $brand-primary; - } - } -} - -/* stylelint-enable no-descending-specificity */ -.btn-link { - width: max-content; - padding: 0; - border-radius: 0; - - &:hover, - &:focus, - &:active { - border-bottom: solid 1px $brand-secundary; - text-decoration: none; + content: "\E810"; + margin-left: 0.5rem; } } -.btn-help { +.btn-add { &::before { @extend %icon-font; - transition: all 0.2s ease; - - content: "\E816"; - } -} - -.btn-plain { - border: 0; - color: $gray-extra-dark; - - &:hover, - &:focus, - &:active { - color: $brand-secundary; - } -} - -/* stylelint-disable no-descending-specificity */ -.btn-import { - &::before { - content: "\E81f"; - } -} - -.btn-edit { - padding: 0; - - &::before { - content: "\E80b"; - } -} - -.btn-view { - padding: 0; - - &::before { - content: "\E80f"; - font-size: 1rem; - } -} - -.btn-email { - &::before { - content: "\E81c"; - } -} - -.btn-clear { - &::before { - content: "\E80B"; - transform: rotate(180deg); - } -} - -.btn-filter { - &::before { - content: "\E80c"; - } -} - -.btn-search, -.btn-sort { - padding-top: 0; - - &::before { - content: "\E80c"; - margin-right: 0; - font-size: $font-size-base; - } - - span { - @include visually-hidden; - } -} - -.btn-sort { - &::before { - content: "\E805"; - transform: rotate(90deg); - } -} - -.btn-toggle, -.btn-toggle-off { - &::before { - font-size: 1.4rem; - } -} - -.btn-toggle { - &::before { - content: "\F205"; - } -} - -.btn-toggle-off { - &::before { - content: "\F204"; - } -} - -.dt-ordering-asc .btn-sort::before { - transform: rotate(-90deg); -} - -.dt-ordering-desc .btn-sort::before { - transform: rotate(90deg); -} - -.btn-add-link { - padding: 0; - - &::before { content: "\E800"; - font-size: $font-size-base; + margin-right: 0.75rem; } } .btn-download { - display: flex; - width: max-content; - padding: 0; - border-radius: 0; - - .btn__title { - text-align: left; - word-break: break-all; - } - - /* stylelint-enable no-descending-specificity */ &::after { @extend %icon-font; - - content: "\E80a"; - margin-left: 0.5rem; - padding: 0.25rem; - transition: 0.2s all ease; - border-radius: 0.25rem; - color: $brand-secundary; - } - - &:hover { - &::after { - background-color: $brand-secundary; - color: $white; - } - } -} - -.btn-download.btn-round { - padding: 10px 12px; - border-radius: $btn-border-radius; - background-color: $brand-tertiary; - - &::after { - margin-left: 0; - padding: 0; - color: $white; - font-size: $font-size-lg; - } - - &:hover::after { - background-color: $brand-tertiary; - } -} - -.btn-invisible { - display: none; -} - -@include media-breakpoint-up(sm) { - .btn { - width: auto; - - &:not(:last-of-type) { - margin-right: $padding-base-horizontal; - margin-bottom: 0; - } - } - - .btn-import, - .btn-export, - .btn-add-link, - .btn-filter, - .btn-clear { // to overwrite auto width of .btn - width: max-content; - } - - .btn-edit-widget, - .btn-drag-widget, - .btn-resize-widget { - width: 1.5rem; - height: 1.5rem; - - &:not(:last-of-type) { - margin-right: $padding-small-horizontal; - } + content: "\E80A"; + margin-right: 0.75rem; } -} - -.table-toggle--add .btn, -.table-toggle--remove .btn { - $button-size: 19px; - - position: absolute; - right: 1rem; - width: $button-size; - height: $button-size; - margin: 0; padding: 0; - font-size: 18px; - font-weight: bolder; - line-height: 16px; -} - -.table-toggle--add .btn::before { - @extend %icon-font; - content: "\E804"; - color: $brand-success; -} - -.table-toggle--remove .btn::before { - @extend %icon-font; - content: "\E807"; - color: $brand-danger; -} - -.rename::before { - @extend %icon-font; - content: "\E80b"; } \ 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 33729acaf..5ef9c7628 100644 --- a/src/frontend/components/button/lib/common.test.ts +++ b/src/frontend/components/button/lib/common.test.ts @@ -1,5 +1,6 @@ import "../../../testing/globals.definitions"; import {layoutId, recordId, table_key} from "./common"; +import {describe, it, expect} from "@jest/globals"; describe("Common button tests",()=>{ it("should populate table_key",()=>{ diff --git a/src/frontend/components/button/lib/component.test.ts b/src/frontend/components/button/lib/component.test.ts index d1c128f12..dc43de9a4 100644 --- a/src/frontend/components/button/lib/component.test.ts +++ b/src/frontend/components/button/lib/component.test.ts @@ -1,4 +1,4 @@ -import "../../../testing/globals.definitions"; +import { describe, it, expect} from "@jest/globals"; import ButtonComponent from './component'; describe("Button Component", () => { diff --git a/src/frontend/components/button/lib/component.ts b/src/frontend/components/button/lib/component.ts index 6ae1ced67..a7481be1e 100644 --- a/src/frontend/components/button/lib/component.ts +++ b/src/frontend/components/button/lib/component.ts @@ -111,6 +111,12 @@ class ButtonComponent extends Component { createCancelButton(el); }); }); + map.set('btn-js-select-all', (el)=>{ + import(/* webpackChunkName: "select-all-button" */ './select-all-button') + .then(({default: createSelectAllButton}) => { + createSelectAllButton(el); + }); + }) ButtonComponent.staticButtonsMap = map; } 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 88f9fc910..6e3d53592 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 "../../../testing/globals.definitions"; import {validateRequiredFields} from 'validation'; import CreateReportButtonComponent from "./create-report-button"; +import {describe, expect, it, jest} from "@jest/globals"; describe('create-report-button', () => { it('does not submit form if no checkboxes are checked', () => { @@ -24,7 +24,7 @@ describe('create-report-button', () => { const $submit = $('#submit'); new CreateReportButtonComponent($submit); - const submitSpy = jest.fn((ev) => { + const submitSpy = jest.fn((ev: Event) => { ev.preventDefault(); ev.stopPropagation(); }); @@ -56,7 +56,7 @@ describe('create-report-button', () => { let $submit = $('#submit'); new CreateReportButtonComponent($submit); - const submitSpy = jest.fn((ev) => { + const submitSpy = jest.fn((ev:Event) => { ev.preventDefault(); ev.stopPropagation(); }); @@ -90,7 +90,7 @@ describe('create-report-button', () => { const $submit = $('#submit'); new CreateReportButtonComponent($submit); - const formSpyFn = jest.fn((ev) => { + const formSpyFn = jest.fn((ev:Event) => { ev.preventDefault(); ev.stopPropagation(); }); diff --git a/src/frontend/components/button/lib/delete-button.test.ts b/src/frontend/components/button/lib/delete-button.test.ts new file mode 100644 index 000000000..588e9a2ae --- /dev/null +++ b/src/frontend/components/button/lib/delete-button.test.ts @@ -0,0 +1,78 @@ +import {describe, it, expect} from '@jest/globals'; +import createDeleteButton from './delete-button'; + +describe('button tests', ()=>{ + it('should throw on absence of id', ()=>{ + const button = document.createElement('button'); + button.setAttribute('data-title', 'title'); + button.setAttribute('data-bs-target', 'target'); + button.setAttribute('data-bs-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!'); + }); + + it('should throw on absence of target', ()=>{ + const button = document.createElement('button'); + button.setAttribute('data-title', 'title'); + button.setAttribute('data-id', 'id'); + button.setAttribute('data-bs-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!'); + }); + + it('should throw on absence of toggle', ()=>{ + const button = document.createElement('button'); + button.setAttribute('data-title', 'title'); + button.setAttribute('data-id', 'id'); + button.setAttribute('data-bs-target', 'target'); + document.body.appendChild(button); + const $button = $(button); + createDeleteButton($button); + expect(()=>{$button.trigger('click')}).toThrow('Delete button should have data attributes id, toggle and target!'); + }); + + it('should set the title of the modal', ()=>{ + const button = document.createElement('button'); + button.setAttribute('data-title', 'title'); + button.setAttribute('data-id', 'id'); + button.setAttribute('data-bs-target', 'target'); + button.setAttribute('data-bs-toggle', 'toggle'); + document.body.appendChild(button); + const modal = document.createElement('div'); + modal.classList.add('modal--deletetarget'); + const title = document.createElement('div'); + title.classList.add('modal-title'); + modal.appendChild(title); + document.body.appendChild(modal); + const $button = $(button); + createDeleteButton($button); + $button.trigger('click'); + expect($(modal).find('.modal-title').text()).toBe('Delete - title'); + }); + + it('should set the id of the delete button', ()=>{ + const button = document.createElement('button'); + button.setAttribute('data-title', 'title'); + button.setAttribute('data-id', 'id'); + button.setAttribute('data-bs-target', 'target'); + button.setAttribute('data-bs-toggle', 'toggle'); + document.body.appendChild(button); + const modal = document.createElement('div'); + modal.classList.add('modal--deletetarget'); + const title = document.createElement('div'); + title.classList.add('modal-title'); + modal.appendChild(title); + const submit = document.createElement('button'); + submit.setAttribute('type', 'submit'); + modal.appendChild(submit); + document.body.appendChild(modal); + const $button = $(button); + createDeleteButton($button); + $button.trigger('click'); + expect($(modal).find('button[type=submit]').val()).toBe('id'); + }) +}) \ No newline at end of file diff --git a/src/frontend/components/button/lib/delete-button.ts b/src/frontend/components/button/lib/delete-button.ts index b9ac6b091..6e2794f11 100644 --- a/src/frontend/components/button/lib/delete-button.ts +++ b/src/frontend/components/button/lib/delete-button.ts @@ -11,8 +11,8 @@ export default function createDeleteButton(element: JQuery) { 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 target = $button.attr('data-bs-target') + const toggle = $button.attr('data-bs-toggle') const modalTitle = title ? `Delete - ${title}` : 'Delete' const $deleteModal = $(document).find(`.modal--delete${target}`) @@ -23,7 +23,11 @@ export default function createDeleteButton(element: JQuery) { throw `There is no modal with id: ${target}` } } catch (e) { - logging.error(e) + //@ts-expect-error - test is a global variable + if(window.test) + throw e; + else + logging.error(e) this.el.on('click', function (e: JQuery.ClickEvent) { e.stopPropagation() }); diff --git a/src/frontend/components/button/lib/remove-curval-button.test.ts b/src/frontend/components/button/lib/remove-curval-button.test.ts new file mode 100644 index 000000000..983b89dbc --- /dev/null +++ b/src/frontend/components/button/lib/remove-curval-button.test.ts @@ -0,0 +1,64 @@ +import {jest, describe, it, expect, beforeAll, afterEach} from "@jest/globals"; +import createRemoveCurvalButton from "./remove-curval-button"; + +describe("RemoveCurvalButton", ()=>{ + // @ts-ignore + window.confirm = jest.fn().mockReturnValue(true); + + beforeAll(()=>{ + document.body.innerHTML = ""; + }); + + afterEach(()=>{ + document.body.innerHTML = ""; + }); + + 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"); + 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"; + td2.appendChild(button); + tr.appendChild(td2); + createRemoveCurvalButton($(button)); + button.click(); + expect(table.children[0].children.length).toBe(0); + }); + + 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"; + answer.appendChild(input); + selectWidget.appendChild(answer); + 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"; + answer.appendChild(button); + createRemoveCurvalButton($(button)); + button.click(); + expect(current.children.length).toBe(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..5a879e87b 100644 --- a/src/frontend/components/button/lib/remove-unload-button.ts +++ b/src/frontend/components/button/lib/remove-unload-button.ts @@ -7,3 +7,4 @@ export default function createRemoveUnloadButton(element: JQuery) { $(window).off('beforeunload'); }); } +// No unit test required here as it's so simple \ No newline at end of file diff --git a/src/frontend/components/button/lib/rename-button.ts b/src/frontend/components/button/lib/rename-button.ts index f1a004082..4e54a891e 100644 --- a/src/frontend/components/button/lib/rename-button.ts +++ b/src/frontend/components/button/lib/rename-button.ts @@ -26,6 +26,7 @@ declare global { renameButton(): JQuery; /** * Handle the rename event + * @template TElement The element type * @param { RenameEvent } events The event name * @param { 'rename' } handler The event handler * @returns {JQuery} the JQuery element @@ -102,16 +103,17 @@ 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(); - const original = $(`#current-${id}`) + let $current = $(`#current-${id}`); + const original = $current .text() .split('.') .slice(0, -1) .join('.'); - $(`#current-${id}`) + $current .addClass('hidden') .attr('aria-hidden', 'true'); $(`#file-rename-${id}`) @@ -127,7 +129,7 @@ class RenameButton { .removeClass('hidden') .attr('aria-hidden', null) .on('click', (e) => { - this.triggerRename(id, ev.target, e) + this.triggerRename(id, ev.target, e) }); $(`#rename-cancel-${id}`) .removeClass('hidden') @@ -158,12 +160,13 @@ class RenameButton { * @param {JQuery} button The button that was clicked * @param {JQuery.BlurEvent} ev The blur event */ - private triggerRename(id: number, button: JQuery, e: JQuery.Event) { - const previousValue = $(`#current-${id}`).text(); + private triggerRename(id: number, button: JQuery, ev: JQuery.BlurEvent) { + let $current = $(`#current-${id}`); + const previousValue = $current.text(); const extension = '.' + previousValue.split('.').pop(); const newName = this.value.endsWith(extension) ? this.value : this.value + extension; if (newName === '' || newName === previousValue) return; - $(`#current-${id}`).text(newName); + $current.text(newName); const event = $.Event('rename', { oldName: previousValue, newName, target: button }); $(button).trigger(event); this.hideRenameControls(id, button); diff --git a/src/frontend/components/button/lib/save-view-button.ts b/src/frontend/components/button/lib/save-view-button.ts index 1c212e897..6230b7d8a 100644 --- a/src/frontend/components/button/lib/save-view-button.ts +++ b/src/frontend/components/button/lib/save-view-button.ts @@ -1,5 +1,5 @@ import {validateRequiredFields} from "validation"; -import "@lol768/jquery-querybuilder-no-eval"; +import "jQuery-QueryBuilder"; /** * SaveViewButtonComponent @@ -28,8 +28,8 @@ export default function createSaveViewButtonComponent(el: JQuery) { } $(".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') + if (!$(el).queryBuilder('validate')) ev.preventDefault(); + const res = $(el).queryBuilder('getRules') $(el).next('#filter').val(JSON.stringify(res, null, 2)) }) }); diff --git a/src/frontend/components/button/lib/select-all-button.test.ts b/src/frontend/components/button/lib/select-all-button.test.ts new file mode 100644 index 000000000..9cbb0f112 --- /dev/null +++ b/src/frontend/components/button/lib/select-all-button.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; +import createSelectAllButton from './select-all-button'; + +describe('createSelectAllButton', () => { + beforeEach(() => { + const div = document.createElement('div'); + div.classList.add('togglelist'); + const box = document.createElement('input'); + box.type = 'checkbox'; + div.appendChild(box); + const button = document.createElement('button'); + button.type = 'button'; + div.appendChild(button); + document.body.appendChild(div); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('Errors without an action', ()=>{ + const button = document.querySelector('button') as HTMLElement; + expect(() => createSelectAllButton(button)).toThrowError('Invalid data-action value'); + }); + + it('Checks all checkboxes when action is check', ()=>{ + const button = document.querySelector('button') as HTMLElement; + button.dataset.action = 'check'; + const checkbox = document.querySelector('input[type="checkbox"]') as HTMLInputElement; + checkbox.checked = false; + createSelectAllButton(button); + button.click(); + expect(checkbox.checked).toBe(true); + }); + + it('Unchecks all checkboxes when action is uncheck', ()=>{ + const button = document.querySelector('button') as HTMLElement; + button.dataset.action = 'uncheck'; + const checkbox = document.querySelector('input[type="checkbox"]') as HTMLInputElement; + checkbox.checked = true; + createSelectAllButton(button); + button.click(); + expect(checkbox.checked).toBe(false); + }); + + it('Fires the change event on the checkbox', ()=>{ + expect.assertions(2); + const fn = jest.fn((ev:JQuery.ClickEvent)=>{ + expect(ev.target.checked).toBe(true); + }); + const button = document.querySelector('button') as HTMLElement; + button.dataset.action = 'check'; + const checkbox = document.querySelector('input[type="checkbox"]') as HTMLInputElement; + checkbox.checked = false; + //@ts-ignore + $(checkbox).on('change', fn); + createSelectAllButton(button); + button.click(); + expect(fn).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/frontend/components/button/lib/select-all-button.ts b/src/frontend/components/button/lib/select-all-button.ts new file mode 100644 index 000000000..aa238e312 --- /dev/null +++ b/src/frontend/components/button/lib/select-all-button.ts @@ -0,0 +1,18 @@ +export default function createSelectAllButton(el: HTMLElement | JQuery) { + const $el = $(el); + if(!$el.data('action')) throw new Error('Invalid data-action value'); + $el.on('click', () => { + const $checkboxes = $el.closest('.togglelist').find('input[type="checkbox"]'); + $checkboxes.each((_index, element) => { + const $element = $(element); + if ($el.data('action') == 'check') { + $element.attr('checked', 'checked'); + $element.prop('checked', true); + } else if ($el.data('action') == 'uncheck') { + $element.removeAttr('checked'); + $element.prop('checked', false); + } + $element.trigger('change'); + }); + }); +} diff --git a/src/frontend/components/button/lib/show-blank-button.test.ts b/src/frontend/components/button/lib/show-blank-button.test.ts new file mode 100644 index 000000000..cde680f3e --- /dev/null +++ b/src/frontend/components/button/lib/show-blank-button.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect, afterEach, jest } from "@jest/globals"; +import showBlankButton from "./show-blank-button"; + +describe("ShowBlankButton", ()=>{ + afterEach(()=>{ + jest.clearAllMocks(); + }); + + it("shows blank fields", ()=>{ + const element = $("
"); + const button = $(""); + element.append(button); + const item = $("
"); + element.append(item); + $("body").append(element); + showBlankButton(element); + button.trigger("click"); + expect(item.css("display")).not.toBe("none"); + }); +}); 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 489e8115f..ae4e24a11 100644 --- a/src/frontend/components/button/lib/submit-field-button.test.ts +++ b/src/frontend/components/button/lib/submit-field-button.test.ts @@ -1,4 +1,5 @@ -import { initGlobals } from "../../../testing/globals.definitions"; +import {describe, beforeEach, it, expect} from "@jest/globals"; +import { initGlobals } from "testing/globals.definitions"; import SubmitFieldButtonComponent from "./submit-field-button"; describe("Submit field button tests", () => { diff --git a/src/frontend/components/button/lib/submit-field-button.ts b/src/frontend/components/button/lib/submit-field-button.ts index a511f272d..59bfe603c 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 "jQuery-QueryBuilder" declare global { interface Window { @@ -90,7 +90,6 @@ export default class SubmitFieldButton { url: this.getURL(data), data: {data: mytext, csrf_token: data.csrfToken} }).done(() => { - // eslint-disable-next-line no-alert alert('Tree has been updated') }); } @@ -127,6 +126,7 @@ export default class SubmitFieldButton { * @returns The URL for the tree API */ private getURL(data:JQuery.PlainObject):string { + // @ts-expect-error - This is a global variable if (window.test) return ""; const devEndpoint = window.siteConfig && window.siteConfig.urls.treeApi; diff --git a/src/frontend/components/calculator/lib/component.js b/src/frontend/components/calculator/lib/component.js index 91e68856e..a7826d0c1 100644 --- a/src/frontend/components/calculator/lib/component.js +++ b/src/frontend/components/calculator/lib/component.js @@ -28,9 +28,9 @@ class CalculatorComponent extends Component { calculator_elem.append( '
' + - '
' + - '
' + - '
' + + '
' + + '
' + + '
' + ' ' + "
" + "" @@ -90,7 +90,7 @@ class CalculatorComponent extends Component { const button_elem = $( `
` + `` + - `` + + `` + `
` ) diff --git a/src/frontend/components/card/_card-expandable.scss b/src/frontend/components/card/_card-expandable.scss deleted file mode 100644 index b8da29963..000000000 --- a/src/frontend/components/card/_card-expandable.scss +++ /dev/null @@ -1,146 +0,0 @@ -.card--expandable { - padding: 0; - border: 0; - border-radius: 0; - - .card__header { - display: flex; - align-items: center; - justify-content: space-between; - } - - .card__title { - flex-wrap: wrap; - margin-bottom: 0; - transition: 0.2s all ease; - border-bottom: 1px solid $transparent; - color: $brand-secundary; - } - - .card__subtitle { - display: block; - flex: 0 0 100%; - transition: 0.2s all ease; - color: $text-color; - font-weight: normal; - text-align: left; - } - - .card__description { - display: block; - margin-bottom: 1.5rem; - } - - .card__header-left { - display: flex; - flex: 1; - padding: $padding-base-vertical; - border: 0; - background-color: $transparent; - text-align: left; - cursor: pointer; - } - - .card__header-right { - display: flex; - padding-right: $padding-base-vertical; - } - - .card__toggle { - padding: 0; - border: 0; - background-color: $white; - line-height: $headings-line-height; - - span { - @include visually-hidden; - } - - &::after { - @extend %icon-font; - - content: "\E805"; - margin-left: $padding-base-horizontal; - transform: rotate(90deg); - color: $gray-extra-dark; - } - } - - .card__toggle[aria-expanded="false"] { - &::after { - transform: rotate(0deg); - } - } - - .card__link { - margin-bottom: 0; - color: $brand-secundary; - - span { - @include visually-hidden; - - transition: 0.2s all ease; - border-bottom: solid 1px $transparent; - } - - &::before { - @extend %icon-font; - - content: "\E80B"; - margin-right: 0.5rem; - color: $brand-secundary; - } - - &:hover { - span { - border-bottom: solid 1px $brand-secundary; - } - } - } - - .card__content { - margin-top: 0; - padding: $padding-base-vertical; - } - - .card__edit-content, - .btn-view { - display: none; - } - - .btn-edit, - .btn-view { - margin-right: 0; - margin-bottom: 0; - } - - @include media-breakpoint-up(sm) { - .card__subtitle { - display: inline-block; - flex: 1 1 auto; - margin-left: 1.25rem; - } - } - - @include media-breakpoint-up(md) { - .card__link span { - @include visually-hidden-off; - } - - .card__toggle::after { - margin-left: $padding-large-horizontal; - } - } -} - -.card--edit { - .card__edit-content, - .btn-view { - display: block; - } - - .card__view-content, - .btn-edit { - display: none; - } -} diff --git a/src/frontend/components/card/_card.scss b/src/frontend/components/card/_card.scss index 580564816..21df56f8f 100644 --- a/src/frontend/components/card/_card.scss +++ b/src/frontend/components/card/_card.scss @@ -1,197 +1,133 @@ .card { - @include card; -} - -.card__body { - margin: -$padding-large-vertical (-$padding-base-horizontal) 0; + box-shadow: 0 0 7px rgba(0, 0, 0, 0.4); - & > * { - padding: 0 $padding-base-horizontal; + &:not(:last-of-type) { + margin-bottom: $padding-base-vertical; } -} -.card__title { - @include font-style-h3; + .card-header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; - display: flex; - margin: 0 0 $padding-base-vertical; - color: $gray-extra-dark; + span { + @include font-style-h3; + } - &:not(:first-child) { - margin-top: $padding-large-vertical; + *:not(.btn) { + color: $brand-secundary; + } } -} -.card__image, -.card__content { - margin-top: $padding-large-vertical; -} + &.card-info { + .card-header { + background-color: $brand-primary; + color: $white; -.card__image { - flex: 1 1 186px; - - img { - width: 100%; - height: auto; + & *:not(.btn) { + color: inherit; + } + } } } -.card__content { - flex: 100%; -} - -.card--primary, -.card--secundary, -.card--record { - border: 0; - border-radius: 0; -} - -// card primary -.card--primary > .card__title { - color: $brand-primary; -} - -// card secundary -.card--secundary { - .card__title { +.card.card--topic, +.card.card--expandable { + .card-header { + border-bottom: 0; + background-color: $white; color: $brand-secundary; - } -} - -// card help -.card--help { - line-height: 1.5; -} + display: flex; + cursor: pointer; -// card record -.card--record { - padding: $padding-base-vertical; + &:not(:first-child) { + margin-top: $padding-large-vertical; + } - h3 { - margin-bottom: $padding-large-vertical; - color: $brand-secundary; - } + .card__header-right { + display: flex; + align-items: center; + * { + margin-bottom: 0; + } + } - .list__item { - &:nth-child(2) { - margin-bottom: 1rem; + * { + color: $brand-secundary; } - &:nth-child(4) { - margin-bottom: 1.5rem; + .card-header__toggle { + width: 100%; + height: 100%; + padding: 1rem 0; } } } -// card header -.card--header { - border-radius: 0; - - &:not(:last-of-type) { - margin-bottom: $padding-base-vertical; - } - - .card__header { - margin: (-$padding-base-vertical) (-$padding-base-horizontal) $padding-base-vertical; - padding: $padding-base-vertical $padding-base-horizontal; - background-color: $brand-primary; - color: $white; - } - - .card__title { - margin-top: 0; - color: $brand-secundary; - } - - .list--key-value:not(:last-of-type) { - margin-bottom: 1.5rem; +.card__title { + @include font-style-h3; + margin: 1rem $padding-base-vertical; + display: flex; + + &:not(:first-child) { + margin-top: $padding-large-vertical; } } -// card inside dashboard widget -.dashboard__widget .card { - height: 100%; +.card__edit-content, +.btn-view { + display: none; } -@include media-breakpoint-up(md) { - .card__image { - flex: 2 0 186px; - } - - .card__content { - flex: 10 1 200px; - } - - .card--primary > .card__title { - font-size: $font-size-lg; +.card.card--topic { + .card__toggle { + margin: 0 0.5rem; } } -// card settings -.card--settings { - height: 100%; - border: 0; - - .card__title { - color: $brand-secundary; - } - - .card__title span { - transition: border-bottom 0.3s ease-in-out; - border-bottom: solid 1px transparent; - } - - .card__content { - color: $gray-extra-dark; - } - - .card__title-icon { - &::before { - @extend %icon-font; - - content: ""; - margin-right: 0.5rem; - color: $brand-secundary; - font-size: 1.25rem; - } - } - - &:hover { - .card__title span { - border-bottom: solid 1px $brand-secundary; - } +.card__toggle { + span { + @include visually-hidden; } - .card__title-icon--user::before { - content: "\E81a"; + &::after { + @extend %icon-font; + transition: all 0.3s ease-in-out; + content: quote("\E805"); + font-size: 1.25rem; + color: $brand-primary; } - .card__title-icon--organisation::before { - content: "\E827"; - } - - .card__title-icon--department::before { - content: "\E820"; + &[aria-expanded="true"]::after { + transform: rotate(90deg); } +} - .card__title-icon--groups::before { - content: "\E80e"; +.card-header:not(.collapsed) { + .card__toggle::after { + transform: rotate(90deg); } +} - .card__title-icon--table::before { - content: "\E817"; - } +.card--edit { - .card__title-icon--email::before { - content: "\E81c"; + .card__edit-content, + .btn-view { + display: block; } - .card__title-icon--settings::before { - content: "\E814"; + .card__view-content, + .btn-edit { + display: none; } +} - .card__title-icon--report::before { - content: "\E819"; +.card-header { + .row { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; } } diff --git a/src/frontend/components/collapsible/_collapsible-dropdown.scss b/src/frontend/components/collapsible/_collapsible-dropdown.scss deleted file mode 100644 index b58fd39c0..000000000 --- a/src/frontend/components/collapsible/_collapsible-dropdown.scss +++ /dev/null @@ -1,15 +0,0 @@ -.collapsible-dropdown { - width: max-content; - - .collapsible__toggle { - @include dropdown-toggle; - - padding: 0; - color: $gray-extra-dark; - } - - .collapsible-dropdown__content { - margin-top: 10px; - background-color: $white; - } -} diff --git a/src/frontend/components/collapsible/index.js b/src/frontend/components/collapsible/index.js deleted file mode 100644 index 46abd7f4d..000000000 --- a/src/frontend/components/collapsible/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import { initializeComponent } from 'component' -import CollapsibleComponent from './lib/component' - -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 deleted file mode 100644 index 77584525e..000000000 --- a/src/frontend/components/collapsible/lib/component.js +++ /dev/null @@ -1,29 +0,0 @@ -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') - - this.initCollapsible(this.button) - } - - initCollapsible(button) { - if (!button) { - return - } - - this.titleExpanded.addClass('hidden') - button.click( () => { this.handleClick() }) - } - - handleClick() { - this.titleExpanded.toggleClass('hidden') - this.titleCollapsed.toggleClass('hidden') - } -} - -export default CollapsibleComponent diff --git a/src/frontend/components/content-block/content-block-footer/content-block-footer.scss b/src/frontend/components/content-block/content-block-footer/content-block-footer.scss index c8f408b3a..371d3b81e 100644 --- a/src/frontend/components/content-block/content-block-footer/content-block-footer.scss +++ b/src/frontend/components/content-block/content-block-footer/content-block-footer.scss @@ -3,6 +3,9 @@ z-index: 999; bottom: 0; margin: $padding-large-vertical 0 (-$padding-large-vertical) -1.25rem; + .row { + position: relative; + } } .content-block__footer-container { diff --git a/src/frontend/components/dashboard/_dashboard.scss b/src/frontend/components/dashboard/_dashboard.scss index 60feb0826..ebae45afc 100644 --- a/src/frontend/components/dashboard/_dashboard.scss +++ b/src/frontend/components/dashboard/_dashboard.scss @@ -76,3 +76,13 @@ opacity: 1; } } + +.ld-footer-container { + .btn-group { + margin-right: $padding-base-vertical; + + &:last-of-type { + margin-right: 0; + } + } +} diff --git a/src/frontend/components/dashboard/lib/component.js b/src/frontend/components/dashboard/lib/component.tsx similarity index 74% rename from src/frontend/components/dashboard/lib/component.js rename to src/frontend/components/dashboard/lib/component.tsx index 5214afd99..a4ca04ef1 100644 --- a/src/frontend/components/dashboard/lib/component.js +++ b/src/frontend/components/dashboard/lib/component.tsx @@ -1,22 +1,16 @@ 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 "./react/polyfills/classlist"; import React from "react"; -import ReactDOM from "react-dom"; -import App from "./react/app"; +import ReactDOM from "react-dom/client"; +import App from "./react/App"; import ApiClient from "./react/api"; +import { ReactGridLayoutProps } from 'react-grid-layout'; class DashboardComponent extends Component { - constructor(element) { + el: JQuery; + gridConfig: ReactGridLayoutProps; + + constructor(element:HTMLElement) { super(element) this.el = $(this.element) @@ -33,13 +27,15 @@ class DashboardComponent extends Component { initDashboard() { this.element.className = ""; const widgetsEls = Array.prototype.slice.call(document.querySelectorAll("#ld-app > div")); - const widgets = widgetsEls.map(el => ({ + const widgets = widgetsEls.map((el: HTMLElement) => ({ html: el.innerHTML, config: JSON.parse(el.getAttribute("data-grid")), })); const api = new ApiClient(this.element.getAttribute("data-dashboard-endpoint") || ""); - ReactDOM.render( + const root = ReactDOM.createRoot(this.element); + + root.render( , - this.element, + gridConfig={this.gridConfig} /> ); } } diff --git a/src/frontend/components/dashboard/lib/react/App.tsx b/src/frontend/components/dashboard/lib/react/App.tsx new file mode 100644 index 000000000..dea2b8143 --- /dev/null +++ b/src/frontend/components/dashboard/lib/react/App.tsx @@ -0,0 +1,277 @@ +import React, { useEffect, useRef } from "react"; + +import Header from "./Header"; +import Footer from "./Footer"; +import { sidebarObservable } from 'components/sidebar/lib/sidebarObservable'; +import DashboardView from "./Dashboard/DashboardView"; +import EditModal from "./EditModal/EditModal"; + +import { AppProps } from "./types" +import serialize from "form-serialize"; +import { initializeRegisteredComponents } from "component"; + +function App(props: AppProps) { + const formRef = useRef(null); + + const [editModalOpen, setEditModalOpen] = React.useState(false); + const [editHtml, setEditHtml] = React.useState(""); + const [loadingEditHtml, setLoadingEditHtml] = React.useState(false); + const [editError, setEditError] = React.useState(""); + const [loading, setLoading] = React.useState(false); + const [layout, setLayout] = React.useState(props.widgets.map((widget) => widget.config)); + const [widgets, setWidgets] = React.useState(props.widgets); + const [activeItem, setActiveItem] = React.useState(""); + + useEffect(() => { + sidebarObservable.addSubscriberFunction(handleSideBarChange); + + initializeGlobeComponents(); + }, []); + + useEffect(() => { + if (editModalOpen && !loadingEditHtml && formRef) { + initializeSummernoteComponent(); + } + + if (!editModalOpen && !loadingEditHtml) { + initializeComponents(); + } + }, [editModalOpen, loadingEditHtml]); + + useEffect(()=>{ + initializeComponents(); + }, [layout]); + + const initializeComponents = () => { + initializeRegisteredComponents(document.body); + initializeGlobeComponents(); + } + + const updateWidgetHtml = async (id) => { + const newHtml = await props.api.getWidgetHtml(id); + const newWidgets = widgets.map(widget => { + if (widget.config.i === id) { + return { + ...widget, + html: newHtml, + }; + } + return widget; + }); + setWidgets(newWidgets); + } + + const fetchEditForm = async (id) => { + const editFormHtml = await props.api.getEditForm(id); + if (editFormHtml.is_error) { + setLoadingEditHtml(false); + setEditError(editFormHtml.message); + return; + } + setLoadingEditHtml(false); + setEditError(""); + setEditHtml(editFormHtml.content); + } + + const onEditClick = id => (event) => { + event.preventDefault(); + showEditForm(id); + } + + const showEditForm = (id) => { + setEditModalOpen(true); + setLoadingEditHtml(true) + setActiveItem(id); + fetchEditForm(id); + } + + const closeModal = () => { + setEditModalOpen(false); + } + + const deleteActiveWidget = () => { + // eslint-disable-next-line no-alert + if (!window.confirm("Deleting a widget is permanent! Are you sure?")) + return + + setWidgets(widgets.filter(item => item.config.i !== activeItem)), + setEditModalOpen(false); + props.api.deleteWidget(activeItem); + } + + const saveActiveWidget = async (event) => { + event.preventDefault(); + const formEl = formRef.current.querySelector("form"); + if (!formEl) { + // eslint-disable-next-line no-console + console.error("No form element was found!"); + return; + } + + const form = serialize(formEl, { hash: true }); + const result = await props.api.saveWidget(formEl.getAttribute("action"), form); + if (result.is_error) { + setEditError(result.message); + return; + } + updateWidgetHtml(activeItem); + closeModal(); + } + + const isGridConflict = (x, y, w, h) => { + const ulc = { x, y }; + const drc = { x: x + w, y: y + h }; + return 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; + }); + } + + const firstAvailableSpot = (w, h) => { + let x = 0; + let y = 0; + while (isGridConflict(x, y, w, h)) { + if ((x + w) < props.gridConfig.cols) { + x += 1; + } else { + const x = 0; + y += 1; + } + if (y > 200) break; + } + return { x, y }; + } + + // eslint-disable-next-line no-unused-vars + const addWidget = async (type) => { + setLoading(true); + const result = await props.api.createWidget(type) + if (result.error) { + setLoading(false); + alert(result.message); + return; + } + const id = result.message; + const { x, y } = firstAvailableSpot(1, 1); + const widgetLayout = { + i: id, + x, + y, + w: 1, + h: 1, + }; + const newLayout = layout.concat(widgetLayout); + setWidgets(widgets.concat({ + config: widgetLayout, + html: "Loading...", + })); + setLayout(newLayout); + setLoading(false); + props.api.saveLayout(props.dashboardId, newLayout); + showEditForm(id); + } + + const onLayoutChange = (newLayout) => { + if (shouldSaveLayout(layout, newLayout)) { + props.api.saveLayout(props.dashboardId, newLayout); + } + setLayout(newLayout); + } + + const 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; + } + + const overWriteSubmitEventListener = () => { + const formContainer = document.getElementById("ld-form-container"); + if (!formContainer) + return + + const form = formContainer.querySelector("form"); + if (!form) + return + + form.addEventListener("submit", saveActiveWidget); + const submitButton = document.createElement("input"); + submitButton.setAttribute("type", "submit"); + submitButton.setAttribute("style", "visibility: hidden"); + form.appendChild(submitButton); + } + + const handleSideBarChange = () => { + window.dispatchEvent(new Event('resize')); + } + + const initializeSummernoteComponent = () => { + const summernoteEl = formRef.current.querySelector('.summernote'); + if (summernoteEl) { + import(/* WebpackChunkName: "summernote" */ "../../../summernote/lib/component") + .then(({ default: SummerNoteComponent }) => { + new SummerNoteComponent(summernoteEl) + }); + } + } + + const initializeGlobeComponents = () => { + const arrGlobe = document.querySelectorAll(".globe"); + import(/* WebpackChunkName: "globe" */ '../../../globe/lib/component').then(({ default: GlobeComponent }) => { + arrGlobe.forEach((globe) => { + new GlobeComponent(globe) + }); + }); + } + + return ( +
+ {props.hideMenu ||
} + + + {props.hideMenu ||
} +
+ ); +} + +export default App; diff --git a/src/frontend/components/dashboard/lib/react/Dashboard/DashboardView.test.tsx b/src/frontend/components/dashboard/lib/react/Dashboard/DashboardView.test.tsx new file mode 100644 index 000000000..63079b8dc --- /dev/null +++ b/src/frontend/components/dashboard/lib/react/Dashboard/DashboardView.test.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/dom'; +import { describe, it, expect, jest } from '@jest/globals'; + +import DashboardView from './DashboardView'; +import { WidgetProps } from '../types'; +import { ReactGridLayoutProps } from 'react-grid-layout'; + +describe('DashboardView', () => { + it('Creates a dashboard', () => { + const gridConfig:ReactGridLayoutProps = { + cols: 2, + margin: [32, 32], + containerPadding: [0, 10], + rowHeight: 80, + } + + const widgets:WidgetProps[] = [ + { + config:{ + h: 1, + i: "0", + w: 1, + x: 0, + y: 0, + }, + html: '
Widget 1
' + } + ]; + + const props = { + readOnly: false, + gridConfig, + layout: widgets.map(w => w.config), + onEditClick: jest.fn(), + onLayoutChange: jest.fn(), + widgets, + } + render(); + + expect(screen.getByTestId('widget1')).toBeInstanceOf(HTMLDivElement); + expect(screen.getByTestId('widget1').textContent).toBe('Widget 1'); + }); + + it('should trigger event on edit button click', () => { + const gridConfig:ReactGridLayoutProps = { + cols: 2, + margin: [32, 32], + containerPadding: [0, 10], + rowHeight: 80, + } + + const widgets:WidgetProps[] = [ + { + config:{ + h: 1, + i: "0", + w: 1, + x: 0, + y: 0, + }, + html: '
Widget 1
' + } + ]; + + const props = { + readOnly: false, + gridConfig, + layout: widgets.map(w => w.config), + onEditClick: jest.fn(), + onLayoutChange: jest.fn(), + widgets, + } + render(); + + const editButton = screen.getByTestId('edit'); + expect(editButton).toBeInstanceOf(HTMLAnchorElement); + editButton.click(); + expect(props.onEditClick).toHaveBeenCalledWith("0"); + }); +}); \ No newline at end of file diff --git a/src/frontend/components/dashboard/lib/react/Dashboard/DashboardView.tsx b/src/frontend/components/dashboard/lib/react/Dashboard/DashboardView.tsx new file mode 100644 index 000000000..e54573990 --- /dev/null +++ b/src/frontend/components/dashboard/lib/react/Dashboard/DashboardView.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import RGL, { WidthProvider } from "react-grid-layout"; +import Widget from "../Widget/Widget"; + +const ReactGridLayout = WidthProvider(RGL); + +export default function DashboardView({ readOnly, layout, onLayoutChange, gridConfig, widgets, onEditClick }) { + return (
+ + {widgets.map(widget => ( +
+ +
+ ))} +
+
); +} \ No newline at end of file diff --git a/src/frontend/components/dashboard/lib/react/EditModal/EditModal.test.tsx b/src/frontend/components/dashboard/lib/react/EditModal/EditModal.test.tsx new file mode 100644 index 000000000..72401daea --- /dev/null +++ b/src/frontend/components/dashboard/lib/react/EditModal/EditModal.test.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/dom'; +import { describe, it, expect, jest } from '@jest/globals'; + +import EditModal from './EditModal'; +import {AppModalProps} from '../types'; + +describe('EditModal', () => { + it('Creates a modal',()=>{ + const modalProps:AppModalProps = { + closeModal:()=>{}, + deleteActiveWidget:()=>{}, + editError:'', + editHtml:'', + editModalOpen:true, + formRef:React.createRef(), + loadingEditHtml:true, + saveActiveWidget:()=>{} + } + + const rendered = render( +
+
+ +
+ ); + + expect(screen.getByText('Edit widget')).toBeInstanceOf(HTMLHeadingElement); + expect(screen.getByText('Loading...')).toBeInstanceOf(HTMLSpanElement); + }); + + it('Creates a modal with the HTML content',()=>{ + const modalProps:AppModalProps = { + closeModal:()=>{}, + deleteActiveWidget:()=>{}, + editError:'', + editHtml:'
Test
', + editModalOpen:true, + formRef:React.createRef(), + loadingEditHtml:false, + saveActiveWidget:()=>{} + } + + const rendered = render( +
+
+ +
+ ); + + expect(screen.getByText('Edit widget')).toBeInstanceOf(HTMLHeadingElement); + expect(screen.getByText('Test')).toBeInstanceOf(HTMLDivElement); + }); + + it('Creates a modal with the error message',()=>{ + const modalProps:AppModalProps = { + closeModal:()=>{}, + deleteActiveWidget:()=>{}, + editError:'Error', + editHtml:'', + editModalOpen:true, + formRef:React.createRef(), + loadingEditHtml:false, + saveActiveWidget:()=>{} + } + + const rendered = render( +
+
+ +
+ ); + + expect(screen.getByText('Edit widget')).toBeInstanceOf(HTMLHeadingElement); + expect(screen.getByText('Error')).toBeInstanceOf(HTMLParagraphElement); + }); + + it('Fires the close event as expected', ()=>{ + const modalProps:AppModalProps = { + closeModal:jest.fn(), + deleteActiveWidget:jest.fn(), + editError:'', + editHtml:'', + editModalOpen:true, + formRef:React.createRef(), + loadingEditHtml:true, + saveActiveWidget:jest.fn() + } + + const rendered = render( +
+
+ +
+ ); + + screen.getByText('Close').click(); + expect(modalProps.closeModal).toHaveBeenCalled(); + }); + + it('Fires the delete event as expected', ()=>{ + const modalProps:AppModalProps = { + closeModal:jest.fn(), + deleteActiveWidget:jest.fn(), + editError:'', + editHtml:'', + editModalOpen:true, + formRef:React.createRef(), + loadingEditHtml:true, + saveActiveWidget:jest.fn() + } + + const rendered = render( +
+
+ +
+ ); + + screen.getByText('Delete').click(); + expect(modalProps.deleteActiveWidget).toHaveBeenCalled(); + }); + + it('Fires the save event as expected', ()=>{ + const modalProps:AppModalProps = { + closeModal:jest.fn(), + deleteActiveWidget:jest.fn(), + editError:'', + editHtml:'', + editModalOpen:true, + formRef:React.createRef(), + loadingEditHtml:true, + saveActiveWidget:jest.fn() + } + + const rendered = render( +
+
+ +
+ ); + + screen.getByText('Save').click(); + expect(modalProps.saveActiveWidget).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/frontend/components/dashboard/lib/react/EditModal/EditModal.tsx b/src/frontend/components/dashboard/lib/react/EditModal/EditModal.tsx new file mode 100644 index 000000000..f9aa41ab1 --- /dev/null +++ b/src/frontend/components/dashboard/lib/react/EditModal/EditModal.tsx @@ -0,0 +1,62 @@ +import React, { useEffect } from 'react'; +import Modal from 'react-modal'; + +// A lot of state here - I think I will revisit this later +export default function EditModal({ editModalOpen, closeModal, editError, loadingEditHtml, editHtml, formRef, deleteActiveWidget, saveActiveWidget }) { + const modalStyle: ReactModal.Styles = { + 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: "2rem 1.5rem" + }, + overlay: { + zIndex: 1030, + background: "rgba(0, 0, 0, .15)" + } + }; + + useEffect(() => { + Modal.setAppElement("#ld-app"); + }, []); + + // @ts-expect-error test is a global variable + const test = window.test; + + return ( +
+
+

Edit widget

+
+ +
+
+ {editError + &&

{editError}

} + {loadingEditHtml + ? Loading... :
} +
+
+
+ +
+
+ +
+
+ ) +} \ No newline at end of file diff --git a/src/frontend/components/dashboard/lib/react/Footer.test.tsx b/src/frontend/components/dashboard/lib/react/Footer.test.tsx new file mode 100644 index 000000000..45b8020f1 --- /dev/null +++ b/src/frontend/components/dashboard/lib/react/Footer.test.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import '@testing-library/dom'; +import { describe, it, expect, jest } from '@jest/globals'; + +import Footer from './Footer'; +import { FooterProps } from './types'; + +import 'testing/extentions'; + +describe('Footer', () => { + it('Creates a footer', () => { + const footerProps: FooterProps = { + addWidget: jest.fn(), + currentDashboard: { + name: 'Dashboard 1', + url: 'http://localhost:3000/dashboard/1', + download_url: 'http://localhost:3000/dashboard/1/download' + }, + noDownload: false, + readOnly: false, + widgetTypes: ['type1', 'type2'] + }; + + render(