diff --git a/.github/workflows/install.sh b/.github/workflows/install.sh index 17bfa6ab1..f5215043d 100755 --- a/.github/workflows/install.sh +++ b/.github/workflows/install.sh @@ -1,5 +1,7 @@ #!/bin/bash +set -e + apt-get update apt-get install -y curl gpg curl -o- https://debian.ctrlo.com/repos/apt/debian/whatever.gpg.key | gpg --dearmor -o /usr/share/keyrings/ctrlo-keyring.gpg @@ -18,4 +20,4 @@ apt-get install -y libmoox-types-mooselike-datetime-perl libdatetime-format-date apt-get install -y libfile-slurp-perl libfile-mimeinfo-perl liblist-compare-perl libnet-oauth2-authorizationserver-perl libfontconfig1 apt-get install -y libctrlo-pdf-perl libpdf-builder-perl fonts-liberation libdate-holidays-gb-perl libcgi-deurl-xs-perl libfile-bom-perl apt-get install -y libdatetime-format-iso8601-perl liblog-log4perl-perl libwww-mechanize-chrome-perl chromium libfile-libmagic-perl libnet-saml2-perl -apt-get install -y liburl-encode-perl libtext-markdown-perl libtest-tempdir-tiny-perl libtest-mocktime-perl +apt-get install -y liburl-encode-perl libtext-markdown-perl libtest-tempdir-tiny-perl libtest-mocktime-perl libdancer2-plugin-logreport-perl diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3617186fd..3492dd262 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -117,6 +117,7 @@ jobs: browser: - name: 'chrome' os: 'ubuntu-22.04' + node_version: 24.x services: postgres: @@ -187,7 +188,7 @@ jobs: strategy: matrix: - node-version: [20.x, 22.x] + node-version: [22.x, 24.x] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: diff --git a/cypress.config.ts b/cypress.config.ts index d8489da64..099ca4329 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -6,5 +6,6 @@ export default defineConfig({ setupNodeEvents(on, config) { // implement node event listeners here }, + allowCypressEnv: false }, }); diff --git a/cypress/e2e/functionality/02-homepage.cy.ts b/cypress/e2e/functionality/02-homepage.cy.ts index 5c3c4eaab..3b088b3d2 100644 --- a/cypress/e2e/functionality/02-homepage.cy.ts +++ b/cypress/e2e/functionality/02-homepage.cy.ts @@ -3,9 +3,7 @@ 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.` +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.` beforeEach(() => { cy.loginAndGoTo(goodUser, goodPassword, 'http://localhost:3000/?did=1'); @@ -21,61 +19,73 @@ 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("a.nav-link").eq(0) .should("exist") - .contains("Home dashboard (shared)") - .should("have.class", "link--active"); - cy.get("li.list__item").eq(1) + .should("have.class", "active") + .contains("Home dashboard (shared)"); + cy.get("a.nav-link").eq(1) .should("exist") - .contains("Home dashboard (personal)") - .should("not.have.class", "link--active"); + .should("not.have.class", "active") + .contains("Home dashboard (personal)"); }); it('Should navigate to the personal dashboard', () => { - cy.get("li.list__item").eq(1) + cy.get("a.nav-link").eq(1) .should("exist") - .should("not.have.class", "link--active") + .should("not.have.class", "active") .click(); + cy.get("a.nav-link").eq(1) + .should("exist") + .should("have.class", "active") + .contains("Home dashboard (personal)"); }); it('Should navigate to the shared dashboard', () => { - cy.get("li.list__item").eq(0) + cy.get("a.nav-link").eq(1) + .click(); + cy.get("a.nav-link").eq(0) .should("exist") - .should("not.have.class", "link--active") + .should("not.have.class", "active") .click(); + cy.get("a.nav-link").eq(0) + .should("exist") + .should("have.class", "active") + .contains("Home dashboard (shared)"); }); context("Shared Dashboard", () => { it('Should cancel creation of a shared dashboard widget', () => { + cy.wait(1000); cy.get(".ld-footer-container") .find("button") .eq(1) .click(); cy.get(".ld-footer-container") - .find(".dropdown__menu") + .find(".dropdown-menu.show") .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.wait(1000); cy.get(".ld-footer-container") .find("button") .eq(1) .click(); cy.get(".ld-footer-container") - .find(".dropdown__menu") + .find(".dropdown-menu.show") .find("a") - .eq(1) + .eq(0) .click(); cy.get("[aria-label='Edit Modal']") .find("input[name='title']", { timeout: 10000 }) .should("be.visible"); cy.get("[aria-label='Edit Modal']") .find(".modal-footer__right") - .find("button.btn-default").click(); + .find("button.btn-primary").click(); cy.get(".ld-widget").should("have.length", 1); cy.get(".ld-widget").find("div").contains("This is a new notice widget - click edit to update the contents"); }); @@ -90,7 +100,7 @@ Ut diam tortor, hendrerit eget ipsum non, suscipit mattis elit. Pellentesque ut .type("With a new title"); cy.get("[aria-label='Edit Modal']") .find(".modal-footer__right") - .find("button.btn-default").click(); + .find("button.btn-primary").click(); cy.get(".ld-widget").find("h4").contains("With a new title"); }); @@ -105,7 +115,7 @@ Ut diam tortor, hendrerit eget ipsum non, suscipit mattis elit. Pellentesque ut .type(bigLipsum); cy.get("[aria-label='Edit Modal']") .find(".modal-footer__right") - .find("button.btn-default").click(); + .find("button.btn-primary").click(); }); it("Should delete a shared dashboard widget", () => { @@ -120,43 +130,45 @@ 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.get("a.nav-link").eq(1).click(); + cy.wait(1000); cy.get(".ld-footer-container") .find("button") .eq(1) .click(); cy.get(".ld-footer-container") - .find(".dropdown__menu") + .find(".dropdown-menu.show") .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.get("a.nav-link").eq(1).click(); + cy.wait(1000); cy.get(".ld-footer-container") .find("button") .eq(1) .click(); cy.get(".ld-footer-container") - .find(".dropdown__menu") + .find(".dropdown-menu.show") .find("a") - .eq(1) + .eq(0) .click(); cy.get("[aria-label='Edit Modal']") .find("input[name='title']", { timeout: 10000 }) .should("be.visible"); cy.get("[aria-label='Edit Modal']") .find(".modal-footer__right") - .find("button.btn-default").click(); + .find("button.btn-primary").click(); cy.get(".ld-widget").should("have.length", 2); cy.get(".ld-widget").eq(1).find("div").contains("This is a new notice widget - click edit to update the contents"); }); it("Should edit a personal dashboard widget", () => { - cy.get("a.link--primary").click(); + cy.get("a.nav-link").eq(1).click(); cy.get(".ld-widget").eq(1).find(".ld-edit-button").click(); cy.get("[aria-label='Edit Modal']") .find("input[name='title']", { timeout: 10000 }) @@ -166,12 +178,12 @@ Ut diam tortor, hendrerit eget ipsum non, suscipit mattis elit. Pellentesque ut .type("With a new title"); cy.get("[aria-label='Edit Modal']") .find(".modal-footer__right") - .find("button.btn-default").click(); + .find("button.btn-primary").click(); cy.get(".ld-widget").eq(1).find("h4").contains("With a new title"); }); it("Should edit a personal dashboard widget with a lot of text", () => { - cy.get("a.link--primary").click(); + cy.get("a.nav-link").eq(1).click(); cy.get(".ld-widget").eq(1).find(".ld-edit-button").click(); cy.get("[aria-label='Edit Modal']") .find("input[name='title']", { timeout: 10000 }) @@ -182,11 +194,11 @@ Ut diam tortor, hendrerit eget ipsum non, suscipit mattis elit. Pellentesque ut .type(bigLipsum); cy.get("[aria-label='Edit Modal']") .find(".modal-footer__right") - .find("button.btn-default").click(); + .find("button.btn-primary").click(); }); it("Should delete a personal dashboard widget", () => { - cy.get("a.link--primary").click(); + cy.get("a.nav-link").eq(1).click(); 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/03-settings.cy.ts b/cypress/e2e/functionality/03-settings.cy.ts index adc39509c..a555b23e4 100644 --- a/cypress/e2e/functionality/03-settings.cy.ts +++ b/cypress/e2e/functionality/03-settings.cy.ts @@ -65,7 +65,7 @@ describe("settings tests", () => { .find("tr") .contains("No titles available"); cy.mainHeader() - .find(".btn-default") + .find(".btn-primary") .should("exist") .click(); cy.location("pathname").should("include", "/title_add"); @@ -93,7 +93,7 @@ describe("settings tests", () => { .find("tr") .contains("No titles available"); cy.mainHeader() - .find(".btn-default") + .find(".btn-primary") .should("exist") .click(); cy.location("pathname").should("include", "/title_add"); @@ -191,7 +191,7 @@ describe("settings tests", () => { .find("tr") .contains("No organisations available"); cy.mainHeader() - .find(".btn-default") + .find(".btn-primary") .should("exist") .click(); cy.location("pathname").should("include", "/organisation_add"); @@ -219,7 +219,7 @@ describe("settings tests", () => { .find("tr") .contains("No organisations available"); cy.mainHeader() - .find(".btn-default") + .find(".btn-primary") .should("exist") .click(); cy.location("pathname").should("include", "/organisation_add"); 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/e2e/functionality/08-data-view.cy.ts b/cypress/e2e/functionality/08-data-view.cy.ts deleted file mode 100644 index f5b4dd9d1..000000000 --- a/cypress/e2e/functionality/08-data-view.cy.ts +++ /dev/null @@ -1,66 +0,0 @@ -import "../../support/commands"; -import { goodPassword, goodUser } from "../../support/constants" - -// This test suite is just to ensure the data table menu items are present and correct -describe('Data table', () => { - // This section is for the admin user in order to check all permissions work and can be applied correctly - describe('Admin user', () => { - beforeEach(() => { - cy.loginAndGoTo(goodUser, goodPassword, 'http://localhost:3000/table1/data'); - }); - - it('All expected tabs and title are present', () => { - cy.get('.table-header__title').should('exist').contains('WebDriverTestSheet'); - const tb = cy.get('.table-header-bottom'); - tb.should('exist'); - const tabs = tb.find('ul').find('li'); - tabs.should('exist').should('have.length', 4); - const tabContent = ['Records', 'Dashboard', 'Edit table', 'Reports']; - tabs.each((tab, idx) => { - cy.wrap(tab).should('exist').contains(tabContent[idx]); - }); - }); - - it('Has the correct items in the left navigation', () => { - const nl = cy.get('.content-block__navigation-left'); - nl.should('exist'); - nl.find('.dropdown').should('exist').contains('Current view'); - const vt = cy.get('.content-block__navigation-left').find('ul').find('li').eq(1); - vt.should('exist'); - vt.find('ul').find('li').should('exist').should('have.length', 3); - const viewTypes = ['Table', 'Graph', 'Timeline']; - cy.get('.content-block__navigation-left').find('ul').find('li').eq(1).find('ul').find('li').each((li, idx) => { - cy.wrap(li).should('exist').contains(viewTypes[idx]); - }); - }); - - it('Has the correct items in the right navigation', () => { - // Add the user to the group - cy.addUserToGroup('test@example.com', 'basic', 'http://localhost:3000/table1/data'); - // Set all table permissions - cy.setAllTablePermissions(); - // Check everything is as expected - cy.get('.content-block__navigation-right').should('exist'); - cy.get('.content-block__navigation-right').find('.dropdown').should('exist').should('have.length', 2); - const dropdowns = ['Manage views', 'Actions']; - cy.get('.content-block__navigation-right').find('.dropdown').each((dropdown,idx)=>{ - cy.wrap(dropdown).should('exist').contains(dropdowns[idx]); - }); - }); - - it('Has the correct items in the right navigation dropdowns', ()=>{ - const dropdownData = { - 'Manage views': ['Add a view', 'Manage views of another user', 'Historic view'], - 'Actions': ['Import records','Delete all records in this view','Update all records in this view','Clone all records in this view','Download records','Manage deleted records','Field Data Purge'] - } - for(const key in dropdownData) { - cy.get('.content-block__navigation-right').find('.dropdown').contains(key).click(); - cy.get('.dropdown-menu').should('exist'); - cy.get('.content-block__navigation-right').find('.dropdown').contains(key).parent().find('li').each((li, idx) => { - cy.wrap(li).should('exist').contains(dropdownData[key][idx]); - }); - cy.get('.content-block__navigation-right').find('.dropdown').contains(key).click(); - } - }); - }); -}); \ No newline at end of file diff --git a/cypress/support/builders/layout/LayoutBuilder.ts b/cypress/support/builders/layout/LayoutBuilder.ts index 3f1a55cde..a42eed8cb 100644 --- a/cypress/support/builders/layout/LayoutBuilder.ts +++ b/cypress/support/builders/layout/LayoutBuilder.ts @@ -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,18 +174,20 @@ 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 - for(let i; i -import { IBuildable } from "./builders/layout/interfaces"; -import { instanceMode } from "./constants"; +import 'cypress-axe'; +import { LayoutDefinition } from "./builders/layout/definitions"; +import { IBuildable, IDropdownLayoutBuilder } from "./builders/layout/interfaces"; +import { LayoutBuilder } from "./builders/layout/LayoutBuilder"; +import { instanceMode, tablePermissions } from "./constants"; +import { goodPassword, goodUser } from "./constants"; export { } declare global { namespace Cypress { interface Chainable { - /** - * Get a component by name - * @param name The name of the element to get - * @example cy.getByName('username') - */ - getByName(name: string): Chainable>; - /** - * Get a component by title - * @param title The title of the element to get - * @example cy.getByTitle('username') - */ - getByTitle(title: string): Chainable>; - /** - * Login to the application - * @param email The email to use to login - * @param password The password to use to login - * @example cy.login('username@example.com','password') - */ - login(email: string, password: string): void; - /** - * Login then navigate to a page - * @param email The email to use to login - * @param password The password to use to login - * @param path The location to navigate to after login - * @example cy.loginAndGoTo('username@example.com','password','/home') - */ - loginAndGoTo(email: string, password: string, path: string): void; - /** - * Get the main body of the page - * @example cy.mainBody() - this is the same as `cy.get('.content-block__main')` - * @see login - */ - mainBody(): Chainable>; - /** - * Get the main header of the page - * @example cy.mainHeader() - this is the same as `cy.get('.content-block__head')` - */ - mainHeader(): Chainable>; - /** - * Get the data table of the page - * @example cy.getDataTable() - this is the same as `cy.mainBody().find('.data-table')` - */ - getDataTable(): Chainable>; - /** - * Create a title in the system - * @param title The title to create in the system - * @example cy.createTitle('New Title') - */ - createTitle(title: string): void; - /** - * Create an organisation in the system - * @param title The title of the organisation to create - * @example cy.createOrganisation('New Organisation') - */ - createOrganisation(title: string): void; - /** - * Create a group in the system - * @param title The title of the group to create - * @example cy.createGroup('New Group') - */ - createGroup(title: string): void; - /** - * Add a user to a group - * @param email The email of the user to add to the group - * @param group The name of the group to add the user to - * @example cy.addUserGroup('bob@home.com','Admin') - * @see createGroup - */ - addUserGroup(email: string, group: string): void; - /** - * Delete a group from the system - * @param title The title of the group to delete - * @example cy.deleteGroup('Admin') - */ - deleteGroup(title: string): void; - /** - * Create a layout in the system - * @param builder The layout builder to use to create the layout - * @example cy.createLayout(LayoutBuilder.createBuilder('TEXT').setName('test').setShortName('t')) - */ - createLayout(builder: IBuildable): void; - /** - * Create a layout in the system - * @param builder The layout builder to use to create the layout - * @param navigate Whether to navigate to the layout page before creating the layout - * @example cy.createLayout(LayoutBuilder.createBuilder('TEXT').setName('test').setShortName('t'), true) - */ - createLayout(builder: IBuildable, navigate: boolean): void; - /** - * Delete a layout by short name - * @param shortName The short name of the layout to delete - * @example cy.deleteLayoutByShortName('t') - */ - deleteLayoutByShortName(shortName: string): void; - /** - * Delete a layout by short name - * @param shortName The short name of the layout to delete - * @param navigate Whether to navigate to the layout page before deleting the layout - * @example cy.deleteLayoutByShortName('t') - */ - deleteLayoutByShortName(shortName: string, navigate: boolean): void; - /** - * Create an instance in the system - * @param instanceName The name of the instance to create - * @example cy.createInstance('Test Instance') - */ - createInstance(instanceName: string, shortName?: string): void; - /** - * Go to an instance by short name - * @param shortName The short name of the instance to go to - * @example cy.gotoInstanceByShortName('test') - */ - gotoInstanceByShortName(shortName: string, mode: instanceMode): void; - /** - * Delete a instance by short name - * @param shortName The short name of the instance to delete - * @example cy.deleteInstanceByShortName('t') - */ - deleteInstanceByShortName(shortName: string): void; - /** - * Navigate to the table permissions page and check all boxes that are unchecked - * @example cy.setAllTablePermissions() - */ - setAllTablePermissions(): void; - /** - * Add a user to a group - * @param user The user to add to a group - * @param name The name of the group to add the user to - * @param location The location to navigate to after adding the user to the group (optional) - * @example cy.addUserToGroup('test@example.com', 'basic') - * @example cy.addUserToGroup('test@example.com', 'basic', '/user_overview/') - */ - addUserToGroup(user: string, name: string, location?: string): void; + getByName(name: string): Chainable; + getByTitle(title: string): Chainable; + login(email: string, password: string): Chainable; + loginAndGoTo(email: string, password: string, path: string): Chainable; + mainBody(): Chainable; + mainHeader(): Chainable; + getDataTable(): Chainable; + createTitle(title: string): Chainable; + createOrganisation(title: string): Chainable; + createGroup(title: string): Chainable; + addUserGroup(email: string, group: string): Chainable; + deleteGroup(title: string): Chainable; + createLayout(builder: IBuildable, navigate?: boolean): Chainable; + deleteLayoutByShortName(shortName: string, navigate?: boolean): Chainable; + createInstance(instanceName: string, shortName?: string): Chainable; + gotoInstanceByShortName(shortName: string, mode: instanceMode): Chainable; + deleteInstanceByShortName(shortName: string): Chainable; + setFieldValueByShortName(shortName: string, value: string | { to: string, from: string }): Chainable; + setTablePermissionsByShortName(shortName: string, permissions: { [key in tablePermissions]?: boolean }): Chainable; + createLayoutsFromDefinition(layoutDefs: LayoutDefinition): Chainable; + addUserToDefaultGroup(user: string, CheckOrUncheck?: 'check' | 'uncheck'): Chainable; + addDataToLayoutFromDefinition(layoutDefs: LayoutDefinition): Chainable; + deleteAllData(table: string): Chainable; + purgeAllDeletedData(shortName: string): Chainable; + deleteLayoutsFromDefinitions(layoutDefs: LayoutDefinition): Chainable; + clearAllTablePermissions(shortName: string): Chainable; + populateTableWithLayouts(shortName: string): Chainable; + cleanTableOfLayouts(shortName: string): Chainable; + clearImports(shortName: string): Chainable; + bulkImportRecords(csvFilePath?: string): Chainable; + logout(): Chainable; + deleteCurrentView(): Chainable; + deleteAllViewsForTable(tableName: string): Chainable; } } } -Cypress.Commands.add('getByName', (name: string) => cy.get(`[name=${name}]`)); +Cypress.Commands.add('getByName', (name: string) => { + return cy.get(`[name=${name}]`); +}); -Cypress.Commands.add('getByTitle', (title: string) => cy.get(`[title=${title}]`)); +Cypress.Commands.add('getByTitle', (title: string) => { + return cy.get(`[title=${title}]`); +}); Cypress.Commands.add("login", (email: string, password: string) => { - cy.visit('http://localhost:3000'); - cy.get("#username").type(email); - cy.get("#password").type(password); - cy.getByName("signin").click(); - cy.location("pathname").should("not.include", "/login"); + return cy.visit('http://localhost:3000') + .get("#username").type(email) + .get("#password").type(password) + .getByName("signin").click() + .location("pathname").should("not.include", "/login"); }); Cypress.Commands.add('loginAndGoTo', (email: string, password: string, path: string) => { - cy.login(email, password); - cy.visit(path); + return cy.login(email, password).visit(path); }); -Cypress.Commands.add('mainBody', () => cy.get(".content-block__main")); +Cypress.Commands.add('mainBody', () => { + return cy.get(".content-block__main"); +}); -Cypress.Commands.add('mainHeader', () => cy.get(".content-block__head")); +Cypress.Commands.add('mainHeader', () => { + return cy.get(".content-block__head"); +}); -Cypress.Commands.add('getDataTable', () => cy.mainBody().find(".data-table")); +Cypress.Commands.add('getDataTable', () => { + return cy.mainBody().find(".data-table"); +}); Cypress.Commands.add('createTitle', (title: string) => { if (!location.pathname.match(/title_add/)) { cy.visit('http://localhost:3000/settings/title_add/'); } - cy.mainBody().find("input[name='title']").type(title); - cy.mainBody().find("button[type='submit']").click(); - cy.getDataTable().find("tbody").find("tr").contains(title); + return cy.mainBody().find("input[name='title']").type(title) + .mainBody().find("button[type='submit']").click() + .getDataTable().find("tbody tr").contains(title); }); Cypress.Commands.add('createOrganisation', (title: string) => { if (!location.pathname.match(/organisation_add/)) { cy.visit('http://localhost:3000/settings/organisation_add/'); } - cy.mainBody().find("input[name='title']").type(title); - cy.mainBody().find("button[type='submit']").click(); - cy.getDataTable().find("tbody").find("tr").contains(title); + return cy.mainBody().find("input[name='title']").type(title) + .mainBody().find("button[type='submit']").click() + .getDataTable().find("tbody tr").contains(title); }); Cypress.Commands.add('createGroup', (title: string) => { if (!location.pathname.match(/group_add/)) { cy.visit('http://localhost:3000/group_add/'); } - cy.mainBody().find('input[name="name"]').type(title); - cy.mainBody().find("button[type='submit']").click(); - cy.getDataTable().find("tbody").find("tr").contains(title); + return cy.mainBody().find('input[name="name"]').type(title) + .mainBody().find("button[type='submit']").click() + .getDataTable().find("tbody tr").contains(title); }); Cypress.Commands.add('addUserGroup', (email: string, group: string) => { if (!location.pathname.match('/user_overview/')) { cy.visit('http://localhost:3000/user_overview/'); } - cy.get('tr').contains('td', email).parent().click(); - cy.get('label.checkbox-label').contains(group).prev('input[type="checkbox"]').check({ force: true }); - cy.mainBody().find("button[type='submit']").click(); + return cy.get('tr').contains('td', email).parent().click() + .get('label.checkbox-label').contains(group).prev('input[type="checkbox"]').check({ force: true }) + .mainBody().find("button[type='submit']").click(); }); - Cypress.Commands.add('deleteGroup', (title: string) => { if (!location.pathname.match(/group_overview/)) { cy.visit('http://localhost:3000/group_overview/'); } - cy.contains('a', title).click(); - cy.get('.btn-delete').click(); - cy.get('.btn.btn-danger').click(); + return cy.contains('a', title).click() + .get('.btn-delete').click() + .get('.btn.btn-danger').click(); }); -Cypress.Commands.add('createLayout', (builder: IBuildable, navigate: boolean = false) => { +Cypress.Commands.add('createLayout', (builder: IBuildable, navigate = false) => { builder.build(navigate); + return cy.mainBody(); }); -Cypress.Commands.add('deleteLayoutByShortName', (shortName: string, navigate: boolean = false) => { +Cypress.Commands.add('deleteLayoutByShortName', (shortName: string, navigate = false) => { if (navigate) { - cy.visit("http://localhost:3000/table"); - cy.getDataTable() - .find("a") - .contains("Edit table") - .click(); - cy.get("a") - .contains("Fields") - .click(); + cy.visit("http://localhost:3000/table") + .getDataTable().find("a").contains("Edit table").click(); + cy.get("a").contains("Fields").click(); } - cy.getDataTable() - .find("tbody") - .find("tr") - .find("td") - .contains(shortName) - .click(); - cy.get(".btn-danger") - .contains("Delete field") - .click(); - cy.get(".modal") - .find(".btn-danger") - .contains("Delete") - .click(); -}); - -Cypress.Commands.add("createInstance", (instanceName: string, shortname: string = instanceName.toLocaleLowerCase().replace(" ", "")) => { - cy.visit("http://localhost:3000/table"); - cy.get("button") - .contains("New table") - .click(); - cy.get(".modal") - .should("be.visible") - .find("input#name[name=name]") - .type(instanceName); - cy.get("input[name=shortName]") - .type(shortname); - cy.get("button.btn-js-next") - .contains("Next") - .should("be.visible") - .click(); - cy.get("button.btn-js-save") - .contains("Save table") - .should("be.visible") - .click(); + return cy.getDataTable().find("tbody tr td").contains(shortName).click() + .get(".btn-danger").contains("Delete field").click() + .get(".modal .btn-danger").contains("Delete").click(); +}); + +Cypress.Commands.add("createInstance", (instanceName: string, shortname: string = instanceName.toLowerCase().replace(" ", "")) => { + return cy.visit("http://localhost:3000/table") + .get("button").contains("New table").click() + .get(".modal").should("be.visible") + .find("input#name[name=name]").type(instanceName) + .get("input[name=shortName]").type(shortname) + .get("button.btn-js-next").contains("Next").click() + .get("button.btn-js-save").contains("Save table").click(); }); Cypress.Commands.add("gotoInstanceByShortName", (shortName: string, mode: instanceMode) => { - cy.visit(`http://localhost:3000/${shortName}/${mode ? mode : ""}`); + return cy.visit(`http://localhost:3000/${shortName}/${mode ? mode : ""}`); }); Cypress.Commands.add("deleteInstanceByShortName", (shortName: string) => { - cy.gotoInstanceByShortName(shortName, "edit"); - cy.get("button") - .contains("Delete table") - .click(); - cy.get(".modal") - .should("be.visible") - .find("button") - .contains("Delete") - .click(); -}); - -Cypress.Commands.add("setAllTablePermissions", () => { - cy.location('pathname').should('include', '/table'); - cy.get('.table-header-bottom').find('ul').find('li').contains('Edit table').click(); - cy.get('.content-block__navigation-left').find('ul').find('li').contains('Permissions').click(); - cy.get('.card__title').contains('Group').click(); - cy.get('.card__content').find('input[type="checkbox"]').each((el: HTMLInputElement) => { - cy.wrap(el).check({ force: true }); + return cy.gotoInstanceByShortName(shortName, "edit") + .get("button").contains("Delete table").click() + .get(".modal").should("be.visible") + .find("button").contains("Delete").click(); +}); + +Cypress.Commands.add("setFieldValueByShortName", (shortName: string, value: string | { to: string, from: string }) => { + return cy.get(`[data-name-short="${shortName}"]`).then(($el) => { + const type = $el.data('column-type'); + if (["string", "intgr", "date", "daterange"].includes(type)) { + const input = $el.find("input"); + cy.wrap(input).then(($input) => { + if (type === "daterange") { + if (typeof value !== "object") throw new Error("Value must be an object with 'to' and 'from'"); + cy.wrap($input).eq(0).type(value.from); + cy.wrap($input).eq(1).type(value.to); + } else { + if (typeof value !== "string") throw new Error("Value must be a string"); + cy.wrap($input).type(value); + } + }); + } else if (type === "enum") { + cy.wrap($el.find(".form-control")).click(); + cy.get(`[data-value="${value}"]`).click({ force: true }); + } }); - cy.get('.btn').contains('Save').click(); - cy.get('.table-header-bottom').find('ul').find('li').contains('Records').click(); -}); - -Cypress.Commands.add("addUserToGroup", (user: string, name: string, location?: string) => { - cy.visit('http://localhost:3000/user_overview/'); - cy.get('td').contains(user).click(); - const group = cy.get('label').contains(name).prev('input[type="checkbox"]'); - group.should('exist'); - group.check({ force: true }); - cy.get('.btn').contains('Save changes').click(); - location && cy.visit(location); -}); \ No newline at end of file +}); + +Cypress.Commands.add("setTablePermissionsByShortName", (shortName: string, permissions: { [key in tablePermissions]?: boolean }) => { + cy.gotoInstanceByShortName(shortName, "edit") + .get("a").contains("Permissions").click() + .get("span").contains('basic').click(); + for (const [group, permission] of Object.entries(permissions)) { + cy.get("label").contains(group).then(($label) => { + const target = $label.attr("for"); + cy.get(`input#${target}`)[permission ? "check" : "uncheck"]({ force: true }); + }); + } + return cy.get("button").contains("Save").click(); +}); + +Cypress.Commands.add("createLayoutsFromDefinition", (layoutDefs: LayoutDefinition) => { + for (const [layoutType, layoutDef] of Object.entries(layoutDefs)) { + const builder = LayoutBuilder.create(layoutType).withName(layoutDef.name).withShortName(layoutDef.shortName); + if ("options" in layoutDef && layoutDef.options) { + for (const option of layoutDef.options.values) { + (builder as IDropdownLayoutBuilder).addOption(option); + } + } + cy.createLayout(builder, true); + builder.checkField(); + } + return cy.mainBody(); +}); + +Cypress.Commands.add("addUserToDefaultGroup", (user: string, CheckOrUncheck: 'check' | 'uncheck' = 'check') => { + return cy.visit('http://localhost:3000/user_overview/') + .get('td').contains(user).click() + .get('input#groups_1')[CheckOrUncheck]({ force: true }) + .get('button[name="submit"]').click(); +}); + +Cypress.Commands.add("addDataToLayoutFromDefinition", (layoutDefs: LayoutDefinition) => { + cy.visit('http://localhost:3000/table1/data'); + cy.get('a.btn-add').contains('Add a record').click(); + for (const layoutDef of Object.values(layoutDefs)) { + cy.setFieldValueByShortName(layoutDef.shortName, layoutDef.data!); + } + return cy.get('button[name="submit"]').contains("Submit and exit").click(); +}); + +Cypress.Commands.add("deleteAllData", (table: string) => { + return cy.setTablePermissionsByShortName(table, { "Delete records": true, "Purge deleted records": true, "Bulk delete records": true }) + .gotoInstanceByShortName(table, "data") + .get("button").contains("Actions").click() + .get("a[data-bs-target='#bulkDelete']").click() + .get("button[type='submit']").contains("Delete").click(); +}); + +Cypress.Commands.add("purgeAllDeletedData", (shortName: string) => { + return cy.visit(`http://localhost:3000/${shortName}/purge`) + .get("input[type='checkbox']").check({ force: true }) + .get("button[data-bs-target='#purge']").click() + .get("button[type='submit']").contains("Confirm").click(); +}); + +Cypress.Commands.add("deleteLayoutsFromDefinitions", (layoutDefs: LayoutDefinition) => { + for (const layoutDef of Object.values(layoutDefs)) { + cy.deleteLayoutByShortName(layoutDef.shortName, true); + } + return cy.mainBody(); +}); + +Cypress.Commands.add("clearAllTablePermissions", (shortName: string) => { + const permissions: { [key in tablePermissions]?: boolean } = { + "Delete records": false, "Purge deleted records": false, "Download records": false, + "Bulk import records": false, "Bulk update records": false, "Bulk delete records": false, + "Manage linked records": false, "Manage child records": false, "Manage views": false, + "Manage group views": false, "Select extra view limits": false, "Manage fields": false, + "Send messages": false + }; + cy.setTablePermissionsByShortName(shortName, permissions); + return cy.mainBody(); +}); + +Cypress.Commands.add("populateTableWithLayouts", () => { + const layoutDefs: LayoutDefinition = { + "TEXT": { name: "Text Field", shortName: "txt_fd" }, + "INTEGER": { name: "Number Field", shortName: "int_fd" }, + "DROPDOWN": { name: "Dropdown Field", shortName: "drop_fd", options: { values: ["Red", "Green", "Blue"]}}, + "DATE": { name: "Date Field", shortName: "date_fd" }, + "DATE-RANGE": { name: "Range Field", shortName: "range_fd" }, + }; + return cy.createLayoutsFromDefinition(layoutDefs); +}); + +Cypress.Commands.add("cleanTableOfLayouts", () => { + const layoutDefs: LayoutDefinition = { + "TEXT": { name: "Text Field", shortName: "txt_fd" }, + "INTEGER": { name: "Number Field", shortName: "int_fd" }, + "DATE": { name: "Date Field", shortName: "date_fd" }, + "DATE-RANGE": { name: "Range Field", shortName: "range_fd" }, + "DROPDOWN": { name: "Dropdown Field", shortName: "drop_fd", options: { values: ["Red", "Green", "Blue"]}} + }; + return cy.deleteLayoutsFromDefinitions(layoutDefs); +}); + +Cypress.Commands.add("clearImports", (shortName) => { + cy.visit(`http://localhost:3000/${shortName}/import`); + cy.contains('button.btn-danger', 'Clear completed reports').click(); + cy.get('#deleteModal').should('be.visible').within(() => { + cy.contains('button', 'Confirm').click(); + }); + return cy.get('td.dt-empty').should('contain.text', 'No imports to show'); +}); + +Cypress.Commands.add("bulkImportRecords", (csvFilePath = 'cypress/fixtures/Import-test-data.csv') => { + cy.visit('http://localhost:3000/table1/data'); + cy.get('button#bulk_actions').click(); + cy.get('a[href="/table1/import/"]').click(); + cy.get('h2.table-header__page-title').should('contain.text', 'Import records'); + cy.get('a[href="/table1/import/data/"]').click(); + cy.get('h2.table-header__page-title').should('contain.text', 'Upload'); + cy.get("label").contains("Dry run").then(($label) => { + const target = $label.attr("for"); + cy.get(`input#${target}`).uncheck({ force: true }); + }); + cy.get('input[type="file"]').selectFile(csvFilePath, { force: true }); + cy.contains('Submit').click(); + cy.get('.alert.alert-success').should('be.visible').and('contain.text', 'The file import process has been started'); + cy.wait(5000); + cy.visit('http://localhost:3000/table1/import'); + cy.get('a.link--plain').contains('Completed').should('exist'); + cy.get('a.link--plain').contains(/errors:\s*0/); + cy.get('a.link--plain').contains(/skipped:\s*0/); + return cy.mainBody(); +}); + +Cypress.Commands.add('logout', () => { + return cy.get('a[href="/logout"]').click(); +}); + +Cypress.Commands.add('deleteCurrentView', () => { + cy.contains("Manage views").click(); + cy.get('a[role="menuitem"]').contains("Edit current view").click(); + cy.get(".btn-js-delete").contains("Delete view").click(); + return cy.get('button[type="submit"]').contains("Delete").click(); +}); + +Cypress.Commands.add('deleteAllViewsForTable', (tableName: string) => { + cy.logout(); + cy.login(goodUser, goodPassword); + cy.visit(`http://localhost:3000/${tableName}/data`); + const checkAndDelete = () => { + cy.get('.dropdown__toggle span').invoke('text').then((text) => { + if (!text.includes('All data')) { + cy.deleteCurrentView(); + checkAndDelete(); + } + }); + }; + checkAndDelete(); + return cy.mainBody(); +}); diff --git a/cypress/support/constants.ts b/cypress/support/constants.ts index 21d58c5c9..5877560ea 100644 --- a/cypress/support/constants.ts +++ b/cypress/support/constants.ts @@ -1,4 +1,9 @@ export const goodUser = "test@example.com"; export const goodPassword = "xyz123"; +export const baseUser = "basic@example.com"; +export const basePassword = "xyz123"; + export type instanceMode = "layout" | "permissions" | "topics" | "edit" | "data" | "report" | undefined; + +export type tablePermissions = "Bulk import records" | "Purge deleted records" | "Download records" | "Delete records" | "Bulk update records" | "Bulk delete records" | "Manage linked records" | "Manage child records" | "Manage views" | "Manage group views" | "Select extra view limits" | "Manage fields" | "Send messages"; \ No newline at end of file diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index f80f74f8e..d2b57b01e 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -17,4 +17,8 @@ import './commands' // Alternatively you can use CommonJS syntax: -// require('./commands') \ No newline at end of file +// require('./commands') + +cy.on('window:confirm', () => { + return true; // Automatically confirm the confirmation dialog +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 12c843cd7..bf77196cb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -8,7 +8,7 @@ import stylistic from "@stylistic/eslint-plugin"; import jsdoc from "eslint-plugin-jsdoc"; export default defineConfig([ - { settings: { react: { version: "detect" } } }, + { settings: { react: { version: "19" } } }, { 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 } } }, diff --git a/lib/GADS.pm b/lib/GADS.pm index 9592bb03b..22807af00 100644 --- a/lib/GADS.pm +++ b/lib/GADS.pm @@ -418,7 +418,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')) @@ -1760,7 +1760,7 @@ put '/api/file/:id' => require_login sub { } else { - my $file = schema->resultset('Fileval')->find_with_permission($id, logged_in_user, + my $file = schema->resultset('Fileval')->find_with_permission($id, logged_in_user, rename_existing => 1) or error __x"File ID {id} cannot be found", id => $id; @@ -2145,7 +2145,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, @@ -2317,7 +2317,7 @@ prefix '/:layout_name' => sub { any ['get', 'post'] => '/data' => require_login sub { my $layout = var('layout') or pass; - + my $user = logged_in_user; my @additional_filters; @@ -3121,10 +3121,10 @@ prefix '/:layout_name' => sub { } } - return template "historic_purge/initial" => { - columns_view => \@columns, - count => $records->count, - columns_selected => $columns_selected + return template "historic_purge/initial" => { + columns_view => \@columns, + count => $records->count, + columns_selected => $columns_selected }; }; diff --git a/package.json b/package.json index ed011cbd2..d14d1565e 100644 --- a/package.json +++ b/package.json @@ -12,14 +12,14 @@ }, "dependencies": { "@egjs/hammerjs": "^2.0.17", - "bootstrap": "^4.6.0", + "@popperjs/core": "^2.11.8", + "axe-core": "^4.12.1", + "bootstrap": "^5.3.8", "bootstrap-datepicker": "^1.9.0", - "bootstrap-select": "^1.13.18", "component-emitter": "^1.3.0", - "datatables.net-bs4": "^2.3.8", - "datatables.net-buttons-bs4": "^3.2.6", - "datatables.net-responsive-bs4": "^3.0.8", - "datatables.net-rowreorder-bs4": "^1.5.1", + "datatables.net-bs5": "^2.3.8", + "datatables.net-responsive-bs5": "^3.0.8", + "datatables.net-rowreorder-bs5": "^1.5.1", "form-serialize": "^0.7.2", "handlebars": "^4.7.9", "jQuery-QueryBuilder": "^3.0.0", @@ -48,6 +48,7 @@ }, "devDependencies": { "@babel/core": "^7.29.7", + "@babel/highlight": "^7.25.9", "@babel/preset-env": "^7.29.7", "@babel/preset-react": "^7.29.7", "@babel/preset-typescript": "^7.29.7", @@ -65,15 +66,19 @@ "@types/react-dom": "^19.2.3", "@types/react-grid-layout": "^1.0.0", "@types/typeahead.js": "^0.11.6", + "@typescript-eslint/type-utils": "^8.61.1", + "@webpack-cli/serve": "^2.0.1", "autoprefixer": "^10.5.0", "babel-loader": "^10.1.1", + "buffer": "^6.0.3", "clean-webpack-plugin": "^4.0.0", "copy-webpack-plugin": "^14.0.0", "core-js": "^3.49.0", "css-loader": "^7.1.4", "cypress": "^15.16.0", - "eslint": "^9.0.0", - "eslint-plugin-jsdoc": "^63.0.2", + "cypress-axe": "^1.7.0", + "eslint": "^10.5.0", + "eslint-plugin-jsdoc": "^63.0.5", "eslint-plugin-react": "^7.37.5", "globals": "^17.6.0", "jest": "^30.4.2", @@ -86,7 +91,7 @@ "terser-webpack-plugin": "^5.6.1", "ts-loader": "~9.5.2", "typescript": "~5.8.0", - "typescript-eslint": "^8.61.0", + "typescript-eslint": "^8.61.1", "webpack": "^5.107.2", "webpack-cli": "^7.0.3" }, diff --git a/src/frontend/components/alert/_alert.scss b/src/frontend/components/alert/_alert.scss index eb62fad14..f99b5f0ad 100644 --- a/src/frontend/components/alert/_alert.scss +++ b/src/frontend/components/alert/_alert.scss @@ -59,3 +59,7 @@ content: "\E822"; color: $black; } + +.alert-warning { + --bs-alert-bg: #{$brand-warning}; +} diff --git a/src/frontend/components/bootstrap-popover/_bootstrap-popover.scss b/src/frontend/components/bootstrap-popover/_bootstrap-popover.scss new file mode 100644 index 000000000..092f711c3 --- /dev/null +++ b/src/frontend/components/bootstrap-popover/_bootstrap-popover.scss @@ -0,0 +1,72 @@ +.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; + + &::before { + transition: font-size 0.2s ease-in-out; + @extend %icon-font; + content: "\E810"; + @include 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); + } + + &.btn-info::after { + content: none; + } +} + +.popover-container:not(.popover-container--text) .btn-popover span { + @include visually-hidden; +} + +.popover-body { + max-height: 25rem; + overflow: auto; + background-color: $white; + border-radius: $border-radius; +} + +.popover { + max-width: 50%; + z-index: 2000; +} + +.list__value { + .btn-popover { + background: $transparent; + color: $primary; + border: solid 1px $primary; + padding: 7px 20px; + + &::before { + content: none; + } + + &::after { + content: "\E810"; + transition: all 0.2s ease-in-out; + } + + &:hover { + border-color: $secondary; + color: $secondary; + + &::after { + color: $secondary; + transform: scale(1.2); + } + } + } +} diff --git a/src/frontend/components/bootstrap-popover/index.ts b/src/frontend/components/bootstrap-popover/index.ts new file mode 100644 index 000000000..ffcb7b985 --- /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); +}; 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..48f7f623a --- /dev/null +++ b/src/frontend/components/bootstrap-popover/lib/component.ts @@ -0,0 +1,38 @@ +import '@popperjs/core'; +import { Popover } from 'bootstrap'; +import { Component } from 'component'; + +/** + * Basic wrapper around the Bootstrap popover component. + */ +export default class BootstrapPopoverComponent extends Component { + /** + * Create a new BootstrapPopoverComponent. + * @param {HTMLElement} element The element to attach the popover to. + */ + 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'; + Popover.Default.allowList = { + ...Popover.Default.allowList, + table: ['class', 'id', 'style'], + tr: ['class', 'id', 'style'], + td: ['class', 'id', 'style'], + th: ['class', 'id', 'style'], + thead: ['class', 'id', 'style'], + tbody: ['class', 'id', 'style'] + }; + new Popover(element, { + html: true, + content: content, + container: 'body' + }); + $el.on('click', (ev)=>{ + ev.preventDefault(); + ev.stopPropagation(); + }); + } +} diff --git a/src/frontend/components/breadcrumbs/_breadcrumbs.scss b/src/frontend/components/breadcrumbs/_breadcrumbs.scss index 2dfc23d87..2d591bd2a 100644 --- a/src/frontend/components/breadcrumbs/_breadcrumbs.scss +++ b/src/frontend/components/breadcrumbs/_breadcrumbs.scss @@ -1,69 +1,17 @@ -.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; +.breadcrumb-item { + a { + color: $secondary; + text-decoration: underline; + transition: $transition-default; + + &:hover { + color: $primary; + text-decoration: none; + } } - } -} - -.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; + &.active { + color: $secondary; + text-decoration: none; } - } - - .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..2b6ef10dd 100644 --- a/src/frontend/components/button/_button.scss +++ b/src/frontend/components/button/_button.scss @@ -1,50 +1,11 @@ -$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-primary, +.btn-sm { + &.btn-inverted { + @include button-variant($white, $primary, $primary, $white, darken($primary, 20%), darken($primary, 20%), $white, darken($primary, 20%), darken($primary, 20%)); + } } .btn-add { - @include btn-default; - &::before { @extend %icon-font; @@ -53,28 +14,12 @@ $btn-border-radius: 23px; 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-inverted::before { + color: $primary; } } .btn-info { - @include btn-default; - &::after { @extend %icon-font; @@ -83,38 +28,17 @@ $btn-border-radius: 23px; color: $white; } - &.btn-inverted { - &::after { - color: $brand-secundary; - } - - &:hover, - &:active, - &:focus, - &:active:focus { - border: 1px solid $btn-default-hover-color; - background-color: $white; + &:focus { + box-shadow: 0 0 0 0.2rem rgba($primary, 0.5); + } - &::after { - color: $btn-default-hover-color; - } - } + &.btn-inverted::after { + color: $primary; } } .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; - } + @include button-variant($white, $gray-extra-dark, $gray-extra-dark, $white, $gray-extra-dark, $gray-extra-dark, $white, $gray-extra-dark, $gray-extra-dark); } .btn-drag-widget { @@ -214,18 +138,14 @@ $btn-border-radius: 23px; } .btn-disabled { - background-color: $gray; - color: $white; + @include button-variant($gray, $gray, $white, $hover-color: $white); &:hover { - color: $white; cursor: not-allowed; } } .btn-remove { - @include btn-default; - &::before { @extend %icon-font; @@ -278,7 +198,6 @@ $btn-border-radius: 23px; } } -/* stylelint-disable no-descending-specificity */ .btn-round { width: auto; padding: 10px 12px; @@ -310,11 +229,6 @@ $btn-border-radius: 23px; } } -/* stylelint-enable no-descending-specificity */ -.btn-inverted { - @include btn-inverted; -} - .btn-back, .btn-title-back { width: auto; @@ -358,7 +272,6 @@ $btn-border-radius: 23px; } } -/* stylelint-disable no-descending-specificity */ .btn-title-back { @include font-style-h2; @@ -383,7 +296,6 @@ $btn-border-radius: 23px; } } -/* stylelint-enable no-descending-specificity */ .btn-link { width: max-content; padding: 0; @@ -417,7 +329,6 @@ $btn-border-radius: 23px; } } -/* stylelint-disable no-descending-specificity */ .btn-import { &::before { content: "\E81f"; @@ -456,7 +367,7 @@ $btn-border-radius: 23px; .btn-filter { &::before { - content: "\E80c"; + content: "\E80C"; } } @@ -482,6 +393,7 @@ $btn-border-radius: 23px; } } +/* This may not be required */ .btn-toggle, .btn-toggle-off { &::before { @@ -500,15 +412,7 @@ $btn-border-radius: 23px; content: "\F204"; } } - -.dt-ordering-asc .btn-sort::before { - transform: rotate(-90deg); -} - -.dt-ordering-desc .btn-sort::before { - transform: rotate(90deg); -} - +/* end */ .btn-add-link { padding: 0; @@ -529,7 +433,6 @@ $btn-border-radius: 23px; word-break: break-all; } - /* stylelint-enable no-descending-specificity */ &::after { @extend %icon-font; @@ -630,4 +533,13 @@ $btn-border-radius: 23px; .rename::before { @extend %icon-font; content: "\E80b"; -} \ No newline at end of file +} + +button.close { + background: $transparent; + border: none; + + &:hover { + color: lighten($brand-primary, 0.1); + } +} diff --git a/src/frontend/components/button/lib/delete-button.test.ts b/src/frontend/components/button/lib/delete-button.test.ts index f6162b4de..a62070288 100644 --- a/src/frontend/components/button/lib/delete-button.test.ts +++ b/src/frontend/components/button/lib/delete-button.test.ts @@ -5,8 +5,8 @@ describe('button tests', () => { it('should throw on absence of id', () => { const button = document.createElement('button'); button.setAttribute('data-title', 'title'); - button.setAttribute('data-target', 'target'); - button.setAttribute('data-toggle', 'toggle'); + button.setAttribute('data-bs-target', 'target'); + button.setAttribute('data-bs-toggle', 'toggle'); document.body.appendChild(button); const $button = $(button); createDeleteButton($button); @@ -17,7 +17,7 @@ describe('button tests', () => { const button = document.createElement('button'); button.setAttribute('data-title', 'title'); button.setAttribute('data-id', 'id'); - button.setAttribute('data-toggle', 'toggle'); + button.setAttribute('data-bs-toggle', 'toggle'); document.body.appendChild(button); const $button = $(button); createDeleteButton($button); @@ -28,7 +28,7 @@ describe('button tests', () => { const button = document.createElement('button'); button.setAttribute('data-title', 'title'); button.setAttribute('data-id', 'id'); - button.setAttribute('data-target', 'target'); + button.setAttribute('data-bs-target', 'target'); document.body.appendChild(button); const $button = $(button); createDeleteButton($button); @@ -39,8 +39,8 @@ describe('button tests', () => { const button = document.createElement('button'); button.setAttribute('data-title', 'title'); button.setAttribute('data-id', 'id'); - button.setAttribute('data-target', 'target'); - button.setAttribute('data-toggle', 'toggle'); + 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'); @@ -59,8 +59,8 @@ describe('button tests', () => { const button = document.createElement('button'); button.setAttribute('data-title', 'title'); button.setAttribute('data-id', 'id'); - button.setAttribute('data-target', 'target'); - button.setAttribute('data-toggle', 'toggle'); + 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'); diff --git a/src/frontend/components/button/lib/delete-button.ts b/src/frontend/components/button/lib/delete-button.ts index 7f281ef5e..ba1960b31 100644 --- a/src/frontend/components/button/lib/delete-button.ts +++ b/src/frontend/components/button/lib/delete-button.ts @@ -9,8 +9,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}`); @@ -25,7 +25,6 @@ export default function createDeleteButton(element: JQuery) { element.on('click', function (e: JQuery.ClickEvent) { e.stopPropagation(); }); - /* @ts-expect-error Global function for testing */ if (window.test) throw e; } diff --git a/src/frontend/components/button/lib/more-info-button.ts b/src/frontend/components/button/lib/more-info-button.ts index f9f640769..8709b3963 100644 --- a/src/frontend/components/button/lib/more-info-button.ts +++ b/src/frontend/components/button/lib/more-info-button.ts @@ -6,8 +6,9 @@ export default function createMoreInfoButton(element: HTMLElement | JQuery { const $button = $(ev.target).closest('.btn'); const record_id = $button.data('record-id'); - const modal_id = $button.data('target'); + const modal_id = $button.data('bs-target'); const $modal = $(document).find(modal_id); + if(!$modal || !$modal.length) throw new Error('Modal not found: ' + modal_id); $modal.find('.modal-title').text(`Record ID: ${record_id}`); $modal.find('.modal-body').text('Loading...'); diff --git a/src/frontend/components/button/lib/rename-button.ts b/src/frontend/components/button/lib/rename-button.ts index 09a205b1e..6088e7df3 100644 --- a/src/frontend/components/button/lib/rename-button.ts +++ b/src/frontend/components/button/lib/rename-button.ts @@ -84,7 +84,7 @@ class RenameButton { type: 'button', textContent: 'Rename', ariaHidden: 'true', - classList: ['btn', 'btn-small', 'btn-default', 'hidden'] + classList: ['btn', 'btn-sm', 'btn-primary', 'hidden'] }).on('click', (ev: JQuery.ClickEvent) => { ev.preventDefault(); this.renameClick(typeof (id) === 'string' ? parseInt(id) : id, ev); @@ -94,7 +94,7 @@ class RenameButton { type: 'button', textContent: 'Cancel', ariaHidden: 'true', - classList: ['btn', 'btn-small', 'btn-danger', 'hidden'] + classList: ['btn', 'btn-sm', 'btn-danger', 'hidden'] }) ) ); 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 fd9a0fd22..6ef488bb0 100644 --- a/src/frontend/components/button/lib/submit-field-button.test.ts +++ b/src/frontend/components/button/lib/submit-field-button.test.ts @@ -10,11 +10,6 @@ describe.skip('Submit field button tests - error in Jest means QB doesn\'t load' initGlobals(); }); - it('should load the relevant libraries', () =>{ - expect($).toBeDefined(); - expect($.extend).toBeDefined(); - }); - async function loadSubmitFieldButtonComponent(element: HTMLElement) { const { default: SubmitFieldButtonComponent } = await import('./submit-field-button'); return new SubmitFieldButtonComponent($(element)); diff --git a/src/frontend/components/button/lib/submit-field-button.ts b/src/frontend/components/button/lib/submit-field-button.ts index a2dbaa31e..5bae5a4b5 100644 --- a/src/frontend/components/button/lib/submit-field-button.ts +++ b/src/frontend/components/button/lib/submit-field-button.ts @@ -1,5 +1,5 @@ import 'jstree'; -import 'datatables.net-bs4'; +import 'datatables.net-bs5'; import 'jQuery-QueryBuilder/dist/js/query-builder.standalone'; import { validateQueryBuilder } from 'validation'; @@ -131,7 +131,6 @@ export default class SubmitFieldButton { * @returns {string} The URL for the tree API */ private getURL(data: JQuery.PlainObject): string { - /* @ts-expect-error Global function for testing */ 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 7dd9ef526..019f2aea0 100644 --- a/src/frontend/components/calculator/lib/component.js +++ b/src/frontend/components/calculator/lib/component.js @@ -44,7 +44,7 @@ class CalculatorComponent extends Component { '
' + '
' + '
' + - ' ' + + ' ' + '
' + '' ); 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..23b911003 100644 --- a/src/frontend/components/card/_card.scss +++ b/src/frontend/components/card/_card.scss @@ -1,197 +1,221 @@ .card { - @include card; -} + box-shadow: $box-shadow; -.card__body { - margin: -$padding-large-vertical (-$padding-base-horizontal) 0; + &:has(.card-header > .collapsed) { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } - & > * { - padding: 0 $padding-base-horizontal; - } + .card-header { + display: flex; + flex-direction: row; + align-items: baseline; + justify-content: space-between; + border: 0; + + h1, h2, h3, h4, h5, h6, p, span, div { + padding: 0; + margin: 0; + font-weight: 600; + } + + h4 { + font-size: $font-size-base; + } + } } -.card__title { - @include font-style-h3; - - display: flex; - margin: 0 0 $padding-base-vertical; - color: $gray-extra-dark; - - &:not(:first-child) { - margin-top: $padding-large-vertical; - } +@each $color in (primary, + secondary, + success, + danger, + warning, + info, + light, + dark) { + .card-#{$color} { + border: none; + + .card-header { + background-color: var(--bs-#{$color}); + + h1, h2, h3, h4, h5, h6, p, span, div { + + @if $color == primary or $color == secondary or $color == danger or $color == dark { + color: var(--bs-white); + } @else { + color: var(--bs-black); + } + } + } + + &.card-inverse { + .card-header { + background-color: var(--bs-white); + color: var(--bs-#{$color}); + + h1, h2, h3, h4, h5, h6, p, span, div { + color: var(--bs-#{$color}); + } + } + } + } } -.card__image, -.card__content { - margin-top: $padding-large-vertical; -} +.card-plain { + border: none; -.card__image { - flex: 1 1 186px; + .card-header { + background-color: var(--bs-white); - img { - width: 100%; - height: auto; - } -} + h1, h2, h3, h4, h5, h6, p, span, div { + color: var(--bs-secondary); + } -.card__content { - flex: 100%; + h4 { + font-size: $font-size-base; + } + } } -.card--primary, -.card--secundary, -.card--record { - border: 0; - border-radius: 0; +.card:not(:last-of-type) { + margin-bottom: $padding-base-vertical; } -// card primary -.card--primary > .card__title { - color: $brand-primary; +.card-header__toggle { + flex-grow: 1; + + .card__title { + @extend .text-primary; + + .card__subtitle { + font-weight: normal; + margin-left: $padding-base-horizontal; + @extend .text-secondary; + } + } } -// card secundary -.card--secundary { - .card__title { - color: $brand-secundary; - } +.card__header-right { + display: flex; + align-items: baseline; + justify-content: flex-end; + flex-direction: row; } -// card help -.card--help { - line-height: 1.5; +.card__title span { + transition: border-bottom 0.3s ease-in-out; + border-bottom: solid 1px transparent; } -// card record -.card--record { - padding: $padding-base-vertical; +// Settings cards +.card--settings { + height: 100%; + border: 0; + box-shadow: $box-shadow; - h3 { - margin-bottom: $padding-large-vertical; - color: $brand-secundary; - } + .card-header { - .list__item { - &:nth-child(2) { - margin-bottom: 1rem; - } + border: 0; + font-size: 1rem; + font-weight: 600; + padding-bottom: 0; - &:nth-child(4) { - margin-bottom: 1.5rem; + span { + border-bottom: solid 1px transparent; + } } - } } -// card header -.card--header { - border-radius: 0; +a.card--settings { + &:hover { + .card-header { + span { + border-bottom: solid 1px $brand-secundary; + + &.icon { + border-bottom: solid 1px $transparent; + + &::before { + transform: scale(1.2); + } + } + } + } + } - &:not(:last-of-type) { - margin-bottom: $padding-base-vertical; - } + .card-header { + span { + transition: all 0.5s ease-in-out; - .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; - } + .icon::before { + transform: scale(1); + } + } + } +} - .card__title { - margin-top: 0; +.card__title { color: $brand-secundary; - } - - .list--key-value:not(:last-of-type) { - margin-bottom: 1.5rem; - } + font-weight: 600; } -// card inside dashboard widget -.dashboard__widget .card { - height: 100%; -} +.card__toggle { + padding: 0; + border: 0; + background-color: $white; + line-height: $headings-line-height; -@include media-breakpoint-up(md) { - .card__image { - flex: 2 0 186px; - } + span { + @extend .visually-hidden; + } - .card__content { - flex: 10 1 200px; - } + &::after { + @extend %icon-font; - .card--primary > .card__title { - font-size: $font-size-lg; - } + content: "\E805"; + margin-left: $padding-base-horizontal; + transform: rotate(90deg); + color: $gray-extra-dark; + } } -// 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__toggle[aria-expanded="false"] { + &::after { + transform: rotate(0deg); + } +} - .card__content { - color: $gray-extra-dark; - } +.card__edit-content, +.btn-view { + display: none; +} - .card__title-icon { - &::before { - @extend %icon-font; +.btn-edit, +.btn-view { + margin-right: 0; + margin-bottom: 0; - content: ""; - margin-right: 0.5rem; - color: $brand-secundary; - font-size: 1.25rem; + span:not(.btn__title) { + @extend .visually-hidden; } - } +} - &:hover { - .card__title span { - border-bottom: solid 1px $brand-secundary; +@include media-breakpoint-up(md) { + .card__toggle::after { + margin-left: $padding-large-horizontal; } - } - - .card__title-icon--user::before { - content: "\E81a"; - } - - .card__title-icon--organisation::before { - content: "\E827"; - } - - .card__title-icon--department::before { - content: "\E820"; - } - - .card__title-icon--groups::before { - content: "\E80e"; - } - - .card__title-icon--table::before { - content: "\E817"; - } +} - .card__title-icon--email::before { - content: "\E81c"; - } +.card--edit { - .card__title-icon--settings::before { - content: "\E814"; - } + .card__edit-content, + .btn-view { + display: block; + } - .card__title-icon--report::before { - content: "\E819"; - } + .card__view-content, + .btn-edit { + display: none; + } } diff --git a/src/frontend/components/card/lib/component.js b/src/frontend/components/card/lib/component.js index 1bab1d970..bdc37a18a 100644 --- a/src/frontend/components/card/lib/component.js +++ b/src/frontend/components/card/lib/component.js @@ -117,10 +117,10 @@ class ExpandableCardComponent extends Component { }; /** - * 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. + * In order to ensure headers on the view filter tables are the correct width */ clearupStyling() { + // using JQuery and DataTables the styling is not as we expect it to be. const tables = $('.table-toggle'); tables.removeAttr('style'); const headers = $('.dt-scroll-headInner'); diff --git a/src/frontend/components/collapsible/lib/component.test.ts b/src/frontend/components/collapsible/lib/component.test.ts index 84786d424..36c5b3534 100644 --- a/src/frontend/components/collapsible/lib/component.test.ts +++ b/src/frontend/components/collapsible/lib/component.test.ts @@ -3,11 +3,10 @@ import Collapsible from './component'; describe('Collapsible', () => { beforeEach(() => { - // Set up the HTML structure for the collapsible component document.body.innerHTML = `
- diff --git a/src/frontend/components/content-block/_content-block.scss b/src/frontend/components/content-block/_content-block.scss index 1c564a374..9209fbb01 100644 --- a/src/frontend/components/content-block/_content-block.scss +++ b/src/frontend/components/content-block/_content-block.scss @@ -4,15 +4,23 @@ } .content-block__navigation { + --bs-nav-link-font-size: $font-size-base; display: flex; position: relative; flex-wrap: wrap; gap: $padding-base-horizontal; align-items: center; - justify-content: space-between; margin-bottom: 1px; // To reserve room for the bottom border of 2px padding: $padding-base-vertical 0 $padding-base-vertical; + &:not(.nav-pills) { + justify-content: space-between; + } + + &.nav-pills { + justify-content: start; + } + &::after { content: ''; position: absolute; 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..5e36f70a9 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 @@ -18,6 +18,7 @@ left: 0; background-color: $white; box-shadow: $box-shadow-light; + z-index: -1; } } diff --git a/src/frontend/components/dashboard/_dashboard.scss b/src/frontend/components/dashboard/_dashboard.scss index a79c73726..8aa46f28f 100644 --- a/src/frontend/components/dashboard/_dashboard.scss +++ b/src/frontend/components/dashboard/_dashboard.scss @@ -87,4 +87,14 @@ margin-right: 0; } } -} \ No newline at end of file +} + +.nav-pills { + .nav-link.active { + background-color: $brand-primary; + color: $white; + h1 { + color: $white; + } + } +} diff --git a/src/frontend/components/dashboard/lib/react/Dashboard/DashboardView.test.tsx b/src/frontend/components/dashboard/lib/react/Dashboard/DashboardView.test.tsx index ff446680d..d45a896e5 100644 --- a/src/frontend/components/dashboard/lib/react/Dashboard/DashboardView.test.tsx +++ b/src/frontend/components/dashboard/lib/react/Dashboard/DashboardView.test.tsx @@ -79,4 +79,4 @@ describe('DashboardView', () => { editButton.click(); expect(props.onEditClick).toHaveBeenCalledWith('0'); }); -}); \ 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 index 85eb8d186..52bfaa054 100644 --- a/src/frontend/components/dashboard/lib/react/EditModal/EditModal.tsx +++ b/src/frontend/components/dashboard/lib/react/EditModal/EditModal.tsx @@ -33,7 +33,6 @@ export default function EditModal({ editModalOpen, closeModal, editError, loadin Modal.setAppElement('#ld-app'); }, []); - /* @ts-expect-error Global function for testing */ const test = window.test; return ( -
+

Edit widget

- +
{editError @@ -61,7 +60,7 @@ export default function EditModal({ editModalOpen, closeModal, editError, loadin
- +
) diff --git a/src/frontend/components/dashboard/lib/react/Footer.tsx b/src/frontend/components/dashboard/lib/react/Footer.tsx index 5116d3711..896e6d44d 100644 --- a/src/frontend/components/dashboard/lib/react/Footer.tsx +++ b/src/frontend/components/dashboard/lib/react/Footer.tsx @@ -13,7 +13,7 @@ export default function Footer({ addWidget, widgetTypes, currentDashboard, readO
{noDownload || - + Download @@ -24,7 +24,7 @@ export default function Footer({ addWidget, widgetTypes, currentDashboard, readO {readOnly || - + Add Widget diff --git a/src/frontend/components/dashboard/lib/react/Header.test.tsx b/src/frontend/components/dashboard/lib/react/Header.test.tsx index 384e04535..dac88e398 100644 --- a/src/frontend/components/dashboard/lib/react/Header.test.tsx +++ b/src/frontend/components/dashboard/lib/react/Header.test.tsx @@ -13,16 +13,16 @@ describe('Header', () => { dashboards: [ { name: 'Dashboard 1', - url: 'http://localhost:3000/dashboard1' + url: 'http://localhost:3000/dashboard/1' }, { name: 'Dashboard 2', - url: 'http://localhost:3000/dashboard2' + url: 'http://localhost:3000/dashboard/2' } ], currentDashboard: { name: 'Dashboard 1', - url: 'http://localhost:3000/dashboard1' + url: 'http://localhost:3000/dashboard/1' }, includeH1: true }; diff --git a/src/frontend/components/dashboard/lib/react/Header.tsx b/src/frontend/components/dashboard/lib/react/Header.tsx index 12383c28e..a3d2a8bc7 100644 --- a/src/frontend/components/dashboard/lib/react/Header.tsx +++ b/src/frontend/components/dashboard/lib/react/Header.tsx @@ -12,7 +12,7 @@ import { Nav } from 'react-bootstrap'; export default function Header ({ hMargin, dashboards, currentDashboard, includeH1 }: HeaderProps): React.JSX.Element { return (
-
'; strHTML += ( - `
- - -
` + `
+
+ ${thisHTML} +
+ +
` ); } }); @@ -759,12 +757,11 @@ class DataTableComponent extends Component { /** * Get the configuration object for the DataTable - * @import { Config } from 'datatables.net-bs4'; - * @param {Parital} overrides Any values to override in the configuration - * @returns {Config} The configuration object for the DataTable + * @param {Readonly>=} overrides Any values to override in the configuration + * @returns {import('datatables.net-bs5').Config} The configuration object for the DataTable */ getConf(overrides = undefined) { - const confData = this.el.data('config'); + const confData = (this.el).data('config'); let conf = {}; if (typeof confData === 'string') { @@ -790,7 +787,7 @@ class DataTableComponent extends Component { const self = this; conf['initComplete'] = (settings, json) => { - const tableElement = this.el; + const tableElement = conf.el || this.el; const dataTable = tableElement.DataTable(); this.json = json; @@ -800,23 +797,34 @@ class DataTableComponent extends Component { const column = this; const $header = $(column.header()); + $header.on('click', (ev) => { + if(ev.stopPropagation) { + ev.stopPropagation(); + ev.preventDefault(); + } else { + ev.cancelBubble = true; + } + }); + const headerContent = $header.html(); - $header.html(`
${headerContent}
`); + if(!headerContent.includes('data-table__header-wrapper')){ + $header.html(`
${headerContent}
`); - // Add sort button to column header - if ($header.hasClass('dt-orderable-asc') || $header.hasClass('dt-orderable-desc')) { - self.addSortButton(dataTable, column, headerContent); - } + // Add sort button to column header + if ($header.hasClass('dt-orderable-asc') || $header.hasClass('dt-orderable-desc')) { + self.addSortButton(dataTable, column, headerContent); + } - // Add button to column headers (only serverside tables) - if ((conf.serverSide) && (tableElement.hasClass('table-search'))) { - const id = settings.oAjaxData.columns[index].name; + // Add button to column headers (only serverside tables) + if ((conf.serverSide) && (tableElement.hasClass('table-search'))) { + const id = settings.oAjaxData.columns[index].name; - if (self.searchParams.has(id)) { - column.search(self.searchParams.get(id)).draw(); - } + if (self.searchParams.has(id)) { + column.search(self.searchParams.get(id)).draw(); + } - self.addSearchDropdown(column, id, index); + self.addSearchDropdown(column, id, index); + } } return true; }); @@ -829,14 +837,16 @@ class DataTableComponent extends Component { } } + initializeRegisteredComponents(tableElement[0]); + this.initializingTable = false; } }; - conf['footerCallback'] = function () { + conf['footerCallback'] = function() { const api = this.api(); // Add aggregate values to table if configured - const agg = api.ajax && api.ajax.json() && api.ajax.json().aggregate; + const agg = api.ajax?.json()?.aggregate; if (agg) { const cols = api.settings()[0].oAjaxData.columns; api.columns().every(function () { @@ -857,11 +867,6 @@ class DataTableComponent extends Component { //Re-initialize more-less components after initialisation is complete moreLess.reinitialize(); - // (Re)enable wide-table toggle button each time. It is disabled during - // any drawing to prevent it being clicked multiple times during a draw - this.el.DataTable().button(0) - .enable(); - this.bindClickHandlersAfterDraw(conf); }; @@ -917,7 +922,7 @@ class DataTableComponent extends Component { /** * Bind click handlers after the DataTable has been drawn - * @import { Config } from 'datatables.net-bs4'; + * @import { Config } from 'datatables.net-bs5'; * @param {Config} conf The configuration object for the DataTable */ bindClickHandlersAfterDraw(conf) { diff --git a/src/frontend/components/data-table/lib/helper.test.ts b/src/frontend/components/data-table/lib/helper.test.ts index a355e773f..e20231c9c 100644 --- a/src/frontend/components/data-table/lib/helper.test.ts +++ b/src/frontend/components/data-table/lib/helper.test.ts @@ -71,4 +71,4 @@ describe.skip('helper - Jest really doesn\'t like JQuery right now!', () => { clearTable($(target)); expect(target.querySelectorAll('tbody tr').length).toBe(1); }); -}); \ No newline at end of file +}); diff --git a/src/frontend/components/error-container/_error-container.scss b/src/frontend/components/error-container/_error-container.scss index bd4c2e2dc..a9dfe632c 100644 --- a/src/frontend/components/error-container/_error-container.scss +++ b/src/frontend/components/error-container/_error-container.scss @@ -4,8 +4,8 @@ } .error-message::before { - @extend .pr-2; + @extend .pe-2; @extend %icon-font; content: "\E822"; } -} \ No newline at end of file +} diff --git a/src/frontend/components/form-group/_form-group.scss b/src/frontend/components/form-group/_form-group.scss index 13ed660bf..e4b5ac84e 100644 --- a/src/frontend/components/form-group/_form-group.scss +++ b/src/frontend/components/form-group/_form-group.scss @@ -10,6 +10,10 @@ margin-left: $padding-large-horizontal; } +.card > * > .form-group { + padding-bottom: 0; +} + .form-control[aria-invalid], .radio-group__input[aria-invalid] + .radio-group__label::before { border-color: $brand-danger; @@ -42,4 +46,8 @@ .tt-suggestion:hover { background-color: #E5E5E5; } -} \ No newline at end of file +} + +.form-group { + margin-bottom: 2rem; +} diff --git a/src/frontend/components/form-group/autosave/lib/autosave.test.ts b/src/frontend/components/form-group/autosave/lib/autosave.test.ts index e88811f25..198fd8eb2 100644 --- a/src/frontend/components/form-group/autosave/lib/autosave.test.ts +++ b/src/frontend/components/form-group/autosave/lib/autosave.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; class TestAutosave extends AutosaveBase { initAutosave(): void { - console.log('initAutosave'); + console.debug('initAutosave'); } } diff --git a/src/frontend/components/form-group/checkbox/_checkbox.scss b/src/frontend/components/form-group/checkbox/_checkbox.scss index cfba1ddfe..f710bbaac 100644 --- a/src/frontend/components/form-group/checkbox/_checkbox.scss +++ b/src/frontend/components/form-group/checkbox/_checkbox.scss @@ -14,7 +14,7 @@ } .checkbox--hide-label { - input[type=checkbox]:checked + label::after { + input[type="checkbox"]:checked + label::after { left: 5px; } diff --git a/src/frontend/components/form-group/display-conditions/lib/component.js b/src/frontend/components/form-group/display-conditions/lib/component.js index a4b7bc9e9..684a42016 100644 --- a/src/frontend/components/form-group/display-conditions/lib/component.js +++ b/src/frontend/components/form-group/display-conditions/lib/component.js @@ -1,7 +1,6 @@ import { Component } from 'component'; +import { refreshSelects } from 'components/form-group/searchable-select/FilterSelectHelper'; import 'jQuery-QueryBuilder/dist/js/query-builder.standalone'; -import 'bootstrap-select/dist/js/bootstrap-select'; -import { refreshSelects } from 'components/form-group/common/bootstrap-select'; /** * Component for managing display conditions in form groups. diff --git a/src/frontend/components/form-group/filter/lib/component.js b/src/frontend/components/form-group/filter/lib/component.js index 242c54236..0618263dd 100644 --- a/src/frontend/components/form-group/filter/lib/component.js +++ b/src/frontend/components/form-group/filter/lib/component.js @@ -1,10 +1,9 @@ /* eslint-disable @typescript-eslint/no-this-alias */ import { Component } from 'component'; import 'jQuery-QueryBuilder/dist/js/query-builder.standalone'; -import 'bootstrap-select/dist/js/bootstrap-select'; +import { refreshSelects } from 'components/form-group/searchable-select/FilterSelectHelper'; import { logging } from 'logging'; import TypeaheadBuilder from 'util/typeahead'; -import { refreshSelects } from 'components/form-group/common/bootstrap-select'; /** * FilterComponent class for managing filter functionality in a query builder. diff --git a/src/frontend/components/form-group/input/lib/documentComponent.ts b/src/frontend/components/form-group/input/lib/documentComponent.ts index eb5ff051c..38ee49850 100644 --- a/src/frontend/components/form-group/input/lib/documentComponent.ts +++ b/src/frontend/components/form-group/input/lib/documentComponent.ts @@ -214,7 +214,7 @@ class DocumentComponent { ${fileName} -
@@ -270,8 +270,8 @@ class DocumentComponent { * Show any errors client-side. * @param e The error to be shown, can be a string or an Error object. */ - showException(e: any) { - this.handler.addError(e instanceof Error ? e.message : typeof e == 'object' && 'message' in e ? e.message : e.toString()); + showException(e: string | Error) { + this.handler.addError(e); } } @@ -280,7 +280,7 @@ class DocumentComponent { * @param {JQuery | HTMLElement} el The element to attach the document component to * @returns {DocumentComponent} The initialized document component */ -export default function documentComponent(el: JQuery | HTMLElement) { +export default function documentComponent(el: JQuery | HTMLElement): DocumentComponent { const component = new DocumentComponent(el); component.init(); return component; diff --git a/src/frontend/components/form-group/input/lib/fileComponent.ts b/src/frontend/components/form-group/input/lib/fileComponent.ts index 45a47b30e..130ad7558 100644 --- a/src/frontend/components/form-group/input/lib/fileComponent.ts +++ b/src/frontend/components/form-group/input/lib/fileComponent.ts @@ -47,7 +47,12 @@ class FileComponent { this.fileDelete.on('click', this.deleteFile); } - // As some of these, if not all, are event handlers, scoping can get a bit wiggy; using arrow functions to keep the scope of `this` to the class + /** + * Handle the file upload process. + * @param {File} file The file to be uploaded. + * @param {number} index The index of the file in the upload queue. + * @param {number} length The total number of files in the upload queue. + */ handleFormUpload = (file: File, index:number, length: number) => { if (!file) throw new Error('No file provided'); diff --git a/src/frontend/components/form-group/query-builder/_query-builder.scss b/src/frontend/components/form-group/query-builder/_query-builder.scss index 048d1a9ea..9effac08f 100644 --- a/src/frontend/components/form-group/query-builder/_query-builder.scss +++ b/src/frontend/components/form-group/query-builder/_query-builder.scss @@ -190,3 +190,21 @@ } } } + +[data-add], [data-delete] { + &::before { + @extend %icon-font; + } +} + +[data-add] { + &::before { + content: "\E800"; + } +} + +[data-delete] { + &::before { + content: "\E807"; + } +} diff --git a/src/frontend/components/form-group/radio-group/_radio-group.scss b/src/frontend/components/form-group/radio-group/_radio-group.scss index 8a7bfdbcb..5bf012e88 100644 --- a/src/frontend/components/form-group/radio-group/_radio-group.scss +++ b/src/frontend/components/form-group/radio-group/_radio-group.scss @@ -25,7 +25,7 @@ } } - input[type=radio]:checked + label { + input[type="radio"]:checked + label { background-color: $brand-secundary; color: $white; diff --git a/src/frontend/components/form-group/common/bootstrap-select.js b/src/frontend/components/form-group/searchable-select/FilterSelectHelper.ts similarity index 59% rename from src/frontend/components/form-group/common/bootstrap-select.js rename to src/frontend/components/form-group/searchable-select/FilterSelectHelper.ts index 668145d0e..8b556d239 100644 --- a/src/frontend/components/form-group/common/bootstrap-select.js +++ b/src/frontend/components/form-group/searchable-select/FilterSelectHelper.ts @@ -1,33 +1,29 @@ -/** - * Set up event listeners for query builder rule filters and operators to initialise the bootstrap-select component. - * @param {JQuery} el The jQuery element to attach the event listeners to. - */ -export const refreshSelects = (el) => { +import './JQuerySearchableSelect'; + +export const refreshSelects = (el: JQuery) => { const ruleFilterSelects = []; const operatorSelects = []; - el.on('afterCreateRuleFilters.queryBuilder', (e, rule) => { + el.on('afterCreateRuleFilters.queryBuilder', (e: JQuery.TriggeredEvent, rule: any) => { const ruleFilterSelect = $(rule.$el.find(`select[name=${rule.id}_filter]`)); if (!ruleFilterSelects.includes(ruleFilterSelect[0])) ruleFilterSelects.push(ruleFilterSelect[0]); if (!ruleFilterSelect || !ruleFilterSelect[0]) { console.error('No select found'); return; } - ruleFilterSelect.data('live-search', 'true'); - ruleFilterSelect.selectpicker(); + if (ruleFilterSelect.data('searchableSelect')) return; + ruleFilterSelect.searchableSelect(); }); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - el.on('afterCreateRuleOperators.queryBuilder', (e, rule, operators) => { + el.on('afterCreateRuleOperators.queryBuilder', (e: JQuery.TriggeredEvent, rule: any) => { const operatorSelect = $(rule.$el.find(`select[name=${rule.id}_operator]`)); if (!operatorSelect || !operatorSelect[0]) { console.error('No operator select found'); return; } if (!operatorSelects.includes(operatorSelect[0])) operatorSelects.push(operatorSelect[0]); - if (operatorSelect.data('live-search')) return; - operatorSelect.data('live-search', 'true'); - operatorSelect.selectpicker(); + if (operatorSelect.data('searchableSelect')) return; + operatorSelect.searchableSelect(); }); el.on('afterSetRules.queryBuilder', () => { @@ -35,11 +31,13 @@ export const refreshSelects = (el) => { if (!ruleFilterSelect) { continue; } - $(ruleFilterSelect).selectpicker('refresh'); + $(ruleFilterSelect).getSearchableSelect() + .refresh(); } for (const operatorSelect of operatorSelects) { if (!operatorSelect) continue; - $(operatorSelect).selectpicker('refresh'); + $(operatorSelect).getSearchableSelect() + .refresh(); } }); @@ -48,7 +46,8 @@ export const refreshSelects = (el) => { if (!operatorSelect) { continue; } - $(operatorSelect).selectpicker('refresh'); + $(operatorSelect).getSearchableSelect() + .refresh(); } }); }; diff --git a/src/frontend/components/form-group/searchable-select/JQuerySearchableSelect.test.ts b/src/frontend/components/form-group/searchable-select/JQuerySearchableSelect.test.ts new file mode 100644 index 000000000..9f50cca4e --- /dev/null +++ b/src/frontend/components/form-group/searchable-select/JQuerySearchableSelect.test.ts @@ -0,0 +1,23 @@ +/* eslint-disable */ +import { describe, it, expect, beforeEach } from "@jest/globals"; +import "./JQuerySearchableSelect"; + +describe("JQuery SearchableSelect component", () => { + beforeEach(() => { + document.body.innerHTML = ''; // Clear the document body before each test + }); + + it("Should define the searchableSelect jQuery plugin", () => { + if(typeof jQuery === 'undefined') expect(true).toBe(false); // fail if jQuery is not loaded + expect(jQuery.fn.searchableSelect).toBeDefined(); + }); + + it("Should initialize searchableSelect on a select element", () => { + const select = document.createElement('select'); + document.body.appendChild(select); + $(select).searchableSelect(); + expect($(select).getSearchableSelect).toBeDefined(); + expect($(select).getSearchableSelect()).toBeInstanceOf(Object); + document.body.removeChild(select); + }); +}); diff --git a/src/frontend/components/form-group/searchable-select/JQuerySearchableSelect.ts b/src/frontend/components/form-group/searchable-select/JQuerySearchableSelect.ts new file mode 100644 index 000000000..eb885fec7 --- /dev/null +++ b/src/frontend/components/form-group/searchable-select/JQuerySearchableSelect.ts @@ -0,0 +1,46 @@ +import { SearchableSelect } from './lib/SearchableSelect'; +import { SearchableSelectOptions } from './lib/options'; + +if (typeof jQuery === 'undefined') throw new Error('jQuery is not loaded. Please include jQuery before this script.'); + +declare global { + interface JQuery { + searchableSelect: (options?: SearchableSelectOptions) => JQuery; + getSearchableSelect: () => SearchableSelect; + } +} + +export { }; + +(($) => { + const selectMap = new Map(); + $.fn.searchableSelect = function (options?: SearchableSelectOptions) { + if (this.length === 0) return this; + const settings: SearchableSelectOptions = $.extend({ + target: this.parent()[0], + classList: [], + placeholder: 'Select an option', + element: this[0] as HTMLSelectElement + }, options); + this.each(function () { + const element = this as HTMLSelectElement; + if (element.tagName.toLowerCase() === 'select') { + const select = new SearchableSelect(settings); + selectMap.set(element, select); + $(element).data('searchableSelect', 'true'); + } else { + console.warn('Element is not a select:', element); + } + }); + return this; + }; + $.fn.getSearchableSelect = function () { + const element = this[0] as HTMLSelectElement; + if (element && selectMap.get(element)) { + return selectMap.get(element) as SearchableSelect; + } else { + console.warn('No SearchableSelect instance found for this element:', element); + return null; + } + }; +})(jQuery); diff --git a/src/frontend/components/form-group/searchable-select/_searchable-select.scss b/src/frontend/components/form-group/searchable-select/_searchable-select.scss new file mode 100644 index 000000000..7049dc3a3 --- /dev/null +++ b/src/frontend/components/form-group/searchable-select/_searchable-select.scss @@ -0,0 +1,41 @@ +.btn-searchable-select.dropdown-toggle { + @include button-variant($gray-100, $gray-100, $secondary); + margin-top: .75rem; + margin-bottom: 1rem; + min-width: 100%; + max-width: 100%; + text-align: center; + white-space: nowrap; + display: flex; + justify-content: center; + align-items: center; + + & span { + flex-grow: 1; + } +} + +.btn-searchable-select { + .dropdown-menu.show { + border-color: $gray-200; + } +} + +.rule-container { + display: flex; + align-items: center; +} + +.searchable-select-options { + max-height: 300px; + overflow-y: auto; + + .searchable-select-search { + @extend .sticky-top; + @extend .p-3; + @extend .bg-light; + @extend .border-bottom; + border-color: $gray-200; + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1); + } +} diff --git a/src/frontend/components/form-group/searchable-select/lib/SearchableSelect.test.ts b/src/frontend/components/form-group/searchable-select/lib/SearchableSelect.test.ts new file mode 100644 index 000000000..b3f3179f2 --- /dev/null +++ b/src/frontend/components/form-group/searchable-select/lib/SearchableSelect.test.ts @@ -0,0 +1,57 @@ +/* eslint-disable */ +import { describe, it, expect, beforeEach, afterEach } from "@jest/globals"; +import { SearchableSelect } from "./SearchableSelect"; + +describe("SearchableSelect", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("should get the correct last created dropdown ID", () => { + // Make this a semi-random order to ensure the logic works correctly + document.body.innerHTML = ` + + + + + `; + const lastId = SearchableSelect.LastCreatedDropdownId; + expect(lastId).toBe("dropdown-5"); + }); + + it("should return dropdown-1 when no dropdowns exist", () => { + const lastId = SearchableSelect.LastCreatedDropdownId; + expect(lastId).toBe("dropdown-1"); + }); + + it("should return the correct dropdown ID when no dropdowns match the pattern", () => { + document.body.innerHTML = ` + + + `; + const lastId = SearchableSelect.LastCreatedDropdownId; + expect(lastId).toBe("dropdown-1"); + }); + + it("should get the version of Bootstrap", () => { + const version = SearchableSelect.BootstrapVersion; + expect(version).toBeGreaterThan(0); + }); + + it("should be able to create a dropdown", () => { + const selectElement = document.createElement('select'); + selectElement.innerHTML = ` + + + + `; + document.body.appendChild(selectElement); + + const searchableSelect = new SearchableSelect({ element: selectElement }); + expect(searchableSelect).toBeInstanceOf(SearchableSelect); + }) +}); diff --git a/src/frontend/components/form-group/searchable-select/lib/SearchableSelect.ts b/src/frontend/components/form-group/searchable-select/lib/SearchableSelect.ts new file mode 100644 index 000000000..1de3246b0 --- /dev/null +++ b/src/frontend/components/form-group/searchable-select/lib/SearchableSelect.ts @@ -0,0 +1,169 @@ +import { Tooltip } from 'bootstrap'; +import { SearchableSelectOptions } from './options'; + +/** + * SearchableSelect class provides a searchable dropdown interface for a standard HTML select element. + */ +export class SearchableSelect { + dropdown: HTMLDivElement = null; + button: HTMLElement = null; + target: HTMLElement; + element: HTMLSelectElement; + classList: string[]; + placeholder: string; + + /** + * Create a SearchableSelect instance. + * @param target The target HTMLElement where the dropdown will be appended. + * @param element The HTMLSelectElement that will be transformed into a searchable dropdown. + */ + constructor({ element, target, classList, placeholder }: SearchableSelectOptions) { + this.element = element; + this.target = target || element.parentElement || document.body; + this.classList = classList || []; + this.placeholder = placeholder || 'Select an option'; + this.init(); + } + + /** + * Initializes the SearchableSelect by creating the dropdown and hiding the original select element. + * It also sets up the event listeners for the search input and option selection. + * @private + */ + private init() { + const options = Array.from(this.element.options); + this.createDropdown(options); + this.element.style.display = 'none'; // Hide the original select element + } + + /** + * Creates the dropdown element and appends it to the target. + * This method generates a unique ID for the dropdown and creates the button and options list. + * @param options An array of HTMLOptionElement objects to populate the dropdown. + * @private + */ + private createDropdown(options: HTMLOptionElement[]) { + const id = SearchableSelect.LastCreatedDropdownId; + this.dropdown = document.createElement('div'); + this.dropdown.className = 'dropdown btn-searchable-select'; + this.dropdown.id = id; + this.createButton(this.dropdown); + this.createOptions(options, this.dropdown); + this.target.appendChild(this.dropdown); + this.refresh(); + } + + /** + * Creates the options list for the dropdown. + * It includes a search input field at the top for filtering options. + * @param options An array of HTMLOptionElement objects to populate the dropdown. + * @param dropdown The HTMLDivElement that represents the dropdown container. + * @private + */ + private createOptions(options: HTMLOptionElement[], dropdown: HTMLDivElement) { + const ul = document.createElement('ul'); + ul.className = 'dropdown-menu searchable-select-options pt-0'; + const searchLi = document.createElement('li'); + searchLi.classList.add('searchable-select-search'); + const searchInput = document.createElement('input'); + searchInput.type = 'text'; + searchInput.className = 'form-control'; + searchInput.placeholder = 'Search...'; + searchInput.addEventListener('input', () => { + this.createOptionsList(options, searchInput.value, ul); + }); + searchLi.appendChild(searchInput); + ul.appendChild(searchLi); + this.createOptionsList(options, '', ul); + dropdown.appendChild(ul); + } + + /** + * Creates the list of options based on the current search input. + * @param options Array of HTMLOptionElement objects to filter and display in the dropdown. + * @param searchInput The current value of the search input field. + * @param ul The HTMLUListElement where the filtered options will be appended. + */ + private createOptionsList(options: HTMLOptionElement[], searchInput: string, ul: HTMLUListElement) { + ul.querySelectorAll('.searchable-select-option').forEach(option => option.remove()); + options.filter(o => o.text.toLowerCase().includes(searchInput.toLowerCase())).forEach(option => { + const li = document.createElement('li'); + const a = document.createElement('a'); + a.classList.add('dropdown-item', 'searchable-select-option'); + a.href = '#'; + a.textContent = option.text; + a.role = 'option'; + a.addEventListener('click', (e) => { + e.preventDefault(); + this.element.value = option.value; + this.refresh(); + this.element.dispatchEvent(new Event('change', { bubbles: true })); + }); + li.appendChild(a); + ul.appendChild(li); + }); + } + + /** + * Creates the button that toggles the dropdown. + * @param dropdown The HTMLDivElement that represents the dropdown container. + */ + private createButton(dropdown: HTMLDivElement) { + const button = document.createElement('button'); + button.classList.add('btn', 'dropdown-toggle', 'btn-searchable-select', ...this.classList); + button.type = 'button'; + button.setAttribute(SearchableSelect.BootstrapVersion >= 5 ? 'data-bs-toggle' : 'data-toggle', 'dropdown'); + button.setAttribute('aria-expanded', 'false'); + const span = document.createElement('span'); + span.textContent = 'Select an option'; + button.appendChild(span); + this.button = span; + dropdown.appendChild(button); + } + + /** + * Creates a unique ID for the dropdown based on existing dropdowns in the document. + * @returns A unique ID for the dropdown, incrementing from the last created dropdown ID. + */ + static get LastCreatedDropdownId(): string { + const dropdowns = document.querySelectorAll('.dropdown'); + if (dropdowns.length === 0) { + return 'dropdown-1'; + } + const filteredDropdowns = Array.from(dropdowns).filter((dropdown) => { + return dropdown.id.startsWith('dropdown-'); + }); + if (filteredDropdowns.length === 0) { + return 'dropdown-1'; + } + const lastID = filteredDropdowns.map((dropdown) => { + const match = dropdown.id.match(/dropdown-(\d+)/); + const r = match ? parseInt(match[1], 10) : 0; + return r; + }).reduce((max, id) => Math.max(max, id), 0); + return `dropdown-${lastID + 1}`; + } + + /** + * Gets the major version of Bootstrap being used. + * @returns The major version of Bootstrap being used, based on the Tooltip.VERSION. + */ + static get BootstrapVersion(): number { + return parseInt(Tooltip.VERSION.split('.')[0]); + } + + /** + * Refreshes the dropdown button text and triggers a change event on the original select element. + */ + refresh() { + this.button.textContent = this.element.options[this.element.selectedIndex]?.text || 'Select an option'; + $(this.dropdown).find('.dropdown-item') + .each((index, item) => { + if (item.textContent === this.button.textContent) { + item.classList.add('active'); + } else { + item.classList.remove('active'); + } + }); + } +} diff --git a/src/frontend/components/form-group/searchable-select/lib/options.ts b/src/frontend/components/form-group/searchable-select/lib/options.ts new file mode 100644 index 000000000..7c0519217 --- /dev/null +++ b/src/frontend/components/form-group/searchable-select/lib/options.ts @@ -0,0 +1,6 @@ +export type SearchableSelectOptions = { + element: HTMLSelectElement; + target?: HTMLElement; + classList?: string[]; + placeholder?: string; +}; diff --git a/src/frontend/components/form-group/select-widget/lib/component.js b/src/frontend/components/form-group/select-widget/lib/component.js index 6cb5da7c2..551e4891b 100644 --- a/src/frontend/components/form-group/select-widget/lib/component.js +++ b/src/frontend/components/form-group/select-widget/lib/component.js @@ -1,4 +1,3 @@ -// We import Bootstrap because there is an error that throws if we don't (this.collapse is not a function). /* eslint-disable @typescript-eslint/no-this-alias */ import { Component } from 'component'; import { fromJson } from 'util/common'; @@ -270,7 +269,6 @@ class SelectWidgetComponent extends Component { /** * Checks if the widget should be closed based on focus changes. - * @param {JQuery.TriggeredEvent} e The event triggered when the widget might need to be closed. */ possibleCloseWidget(e) { const newlyFocussedElement = e.relatedTarget || document.activeElement; @@ -462,7 +460,7 @@ class SelectWidgetComponent extends Component { // Add space at beginning to keep format consistent with that in template const detailsButton = '
' + - ' @@ -143,7 +144,7 @@ class CurvalModalComponent extends ModalComponent { const removeButton = $( ` - `, @@ -155,7 +156,7 @@ class CurvalModalComponent extends ModalComponent { // guids in the autosave let is_new_row; if (!guid && !current_id) { - guid = crypto.randomUUID(); + guid = Guid(); is_new_row = true; } const hidden_input = $('').attr({ @@ -196,7 +197,7 @@ class CurvalModalComponent extends ModalComponent { $answersList.find('li input').prop('checked', false); } - guid ||= crypto.randomUUID(); + guid ||= Guid(); const id = `field${col_id}_${guid}`; const deleteButton = multi ? '' @@ -221,7 +222,7 @@ class CurvalModalComponent extends ModalComponent {
-
@@ -354,7 +355,7 @@ class CurvalModalComponent extends ModalComponent { if (mode === 'edit') { guid = hidden.data('guid'); if (!guid) { - guid = crypto.randomUUID(); + guid = Guid(); hidden.attr('data-guid', guid); } } @@ -366,8 +367,7 @@ class CurvalModalComponent extends ModalComponent { fetch(this.getURL(current_id, instance_name, layout_id), { method: 'POST', body: form_data - }) - .then((response)=>response.text()) + }).then((response)=>response.text()) .then((text) => $m.find('.modal-body').html(text)) .then(() => { if (mode === 'edit') { diff --git a/src/frontend/components/more-less/_more-less.scss b/src/frontend/components/more-less/_more-less.scss index ae6c95a9c..b65d21c3d 100644 --- a/src/frontend/components/more-less/_more-less.scss +++ b/src/frontend/components/more-less/_more-less.scss @@ -1,56 +1,78 @@ // DisclosureWidget .trigger { - white-space: nowrap; + white-space: nowrap; } .expandable { - display: none; + display: none; } .expandable.expanded { - display: block; + display: block; - &.card { - box-shadow: $box-shadow-dark; - } + &.card { + box-shadow: $box-shadow-dark; + } - &.popover { - max-width: none; - } + &.popover { + max-width: none; + } } .more-less { - position: relative; - - .table-curcommon .link { - display: table-row; - } - - .table-curcommon tr { - td { - padding: $padding-small-horizontal; - border-top: 1px solid $gray; - } + position: relative; - &:first-of-type td { - border-top: none; - padding-top: 0; + .card { + background: var(--bs-white); + z-index: 1020; + position: absolute; } - &:last-of-type td { - padding-bottom: 0; + .table-curcommon { + margin: $padding-small-horizontal $padding-small-vertical; } - & td:first-child { - padding-left: 0; + .table-curcommon .link { + display: table-row; } - & td:last-child { - padding-right: 0; - } + .table-curcommon tr { + td { + padding: $padding-small-horizontal; + border-top: 1px solid $gray; + } + + &:first-of-type td { + border-top: none; + padding-top: 0; + } + + &:last-of-type td { + padding-bottom: 0; + } - &:hover { - background-color: $transparent; + & td:first-child { + padding-left: 0; + } + + & td:last-child { + padding-right: 0; + } + + &:hover { + background-color: $transparent; + } } - } +} + +.table-curcommon { + // I have found that sometimes setting 100% width can make elements overflow (or be a bit odd in their display). + max-width: 98%; + --bs-table-background: $white; + // This is a workaround for the inset shadow that appears on the table when using Bootstrap 5. + --bs-table-bg-state: transparent; +} + +.popover.expanded:not(:has(*)) { + padding: $padding-small-horizontal $padding-small-vertical; } diff --git a/src/frontend/components/more-less/lib/component.js b/src/frontend/components/more-less/lib/component.js index 6c32e3297..9cd3abd4a 100644 --- a/src/frontend/components/more-less/lib/component.js +++ b/src/frontend/components/more-less/lib/component.js @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-this-alias */ import { Component } from 'component'; import { setupDisclosureWidgets } from './disclosure-widgets'; import { moreLess } from './more-less'; @@ -84,10 +83,9 @@ class MoreLessComponent extends Component { // Add a unique identifier to each more-less class, before cloning. Once we // measure the height on the cloned elements, we can apply the height as a // data value to its real equivalent element using this unique class. - const self = this; $parent.find('.more-less').each(function () { const $e = $(this); - $e.addClass('more-less-id-' + self.uuid()); + $e.addClass('more-less-id-' + crypto.randomUUID()); }); // Clone the element and show it to find out its height @@ -159,14 +157,14 @@ class MoreLessComponent extends Component { $ml.addClass('clipped'); const $expandable = $('
', { - class: 'expandable popover column-content card card--secundary', - html: content + class: 'expandable popover column-content card card-secondary', + html: `
${content}
` }); const toggleLabel = 'Show ' + column + ' ⇒'; const $expandToggle = $(' diff --git a/views/admin/default_welcome_email.tt b/views/admin/default_welcome_email.tt index 1f6a4b70e..cd05a5413 100644 --- a/views/admin/default_welcome_email.tt +++ b/views/admin/default_welcome_email.tt @@ -10,15 +10,15 @@
[% INCLUDE fields/hidden.tt name="csrf_token" value=csrf_token; %] - +
Default welcome email - +
- +
[% @@ -33,7 +33,7 @@ %]
- +
[% @@ -48,7 +48,7 @@ %]
- +
[% @@ -79,7 +79,7 @@ type = "button", name = "update", value = "update", - class = "btn btn-default", + class = "btn btn-primary", label = "Save" }] }]; diff --git a/views/admin/user_editable_personal_details.tt b/views/admin/user_editable_personal_details.tt index cf10b2bc7..320495d72 100644 --- a/views/admin/user_editable_personal_details.tt +++ b/views/admin/user_editable_personal_details.tt @@ -18,8 +18,8 @@ Allow users to edit the following details of their account
- -
+ +
    [% FOREACH field in instance.user_fields %] @@ -58,7 +58,7 @@ type = "button", name = "update", value = "update", - class = "btn btn-default", + class = "btn btn-primary", label = "Save" }] }]; diff --git a/views/approval.tt b/views/approval.tt index 5a6be6034..93e66883e 100644 --- a/views/approval.tt +++ b/views/approval.tt @@ -2,7 +2,7 @@

    [% IF records.size %] - + diff --git a/views/chronology.tt b/views/chronology.tt index 74d1abac5..06d3d5caf 100644 --- a/views/chronology.tt +++ b/views/chronology.tt @@ -14,14 +14,16 @@ action_datetime = version.datetime.as_string; action_type = initial ? 'created' : 'updated'; action_by = version.editor.id ? " by " _ version.editor.as_string : ''; - + %] -
    -

    - [% action_datetime _ " - " _ " record " _ action_type _ action_by | html %] -

    - -
    +
    +
    +

    + [% action_datetime _ " - " _ " record " _ action_type _ action_by | html %] +

    +
    + +
    Table for [% layout.record_name_plural | html %] requiring approvalTable for [% layout.record_name_plural | html %] requiring approval
    ID Submitted by
    - + @@ -53,15 +53,15 @@
    Table for departmentsTable for departments
    Name
    - + @@ -72,8 +73,8 @@ [% FOREACH metric IN metricgroup.metrics %] - + [% END %] [% INCLUDE tables/basic_row.tt row=header_row %] diff --git a/views/team.tt b/views/team.tt index 992a98d43..eddbc2620 100644 --- a/views/team.tt +++ b/views/team.tt @@ -8,7 +8,7 @@ [% IF team.id %] [% END %] -
    +
    @@ -17,7 +17,7 @@

    [% IF team.id %] - Delete + Delete [% END %]

    @@ -28,10 +28,10 @@
    Table for metric groupsTable for metric groups
    X-axis value
    - - - + + +
    -
    -

    - Metrics help -

    - -
    +
    +
    +

    + Metrics help +

    +
    + +

    Use this page to define a set of metrics to plot graphs against. @@ -137,7 +140,7 @@

    - +
    @@ -153,7 +156,7 @@
    [% IF button_type == "button" %] - [% ELSE %] diff --git a/views/tables/basic_cell_modal_button.tt b/views/tables/basic_cell_modal_button.tt index d55f3306f..dc15a89f9 100644 --- a/views/tables/basic_cell_modal_button.tt +++ b/views/tables/basic_cell_modal_button.tt @@ -6,9 +6,9 @@
    [% table_caption | html %][% table_caption | html %]
    - + @@ -53,7 +53,7 @@
    Table for teamsTable for teams
    Name
    - + @@ -53,7 +53,7 @@
    Table for titlesTable for titles
    Name
    - +
    Table for topicsTable for topics
    @@ -430,7 +426,7 @@ label = "Back" }] right_buttons = [{ - class = "btn-js-next btn-default" + class = "btn-js-next btn-primary" label = "Next" }]; %] @@ -464,10 +460,10 @@ label = "Back" }] right_buttons = [{ - class = "btn-inverted btn-js-skip" + class = "btn btn-primary btn-inverted btn-js-skip" label = "Skip creating fields" }, { - class = "btn-js-next btn-default" + class = "btn-js-next btn-primary" label = "Next" }]; %] @@ -558,7 +554,7 @@ label = "Back" }] right_buttons = [{ - class = "btn-inverted btn-js-skip" + class = "btn btn-primary btn-inverted btn-js-skip" label = "Skip creating fields" }, { class = "btn-js-next btn-disabled" @@ -616,7 +612,7 @@ -
    +
    [% INCLUDE fields/sub/checkbox.tt id = "show_calculator" @@ -641,7 +637,7 @@
    -
    +
    [% INCLUDE fields/sub/checkbox.tt id = "show_datepicker_date" @@ -662,7 +658,7 @@
    -
    +
    [% INCLUDE fields/sub/checkbox.tt id = "default_today" @@ -687,7 +683,7 @@
    -
    +
    [% INCLUDE fields/sub/checkbox.tt id = "show_datepicker_date-range" @@ -719,7 +715,7 @@
    -
    @@ -751,7 +747,7 @@ @@ -764,7 +760,7 @@

    Each value in the tree is referred to as a node.

    -
    +
    [% INCLUDE fields/sub/checkbox.tt id = "end_node_only" @@ -805,7 +801,7 @@
    -
    +
    [% INCLUDE fields/sub/checkbox.tt id = "default_to_login" @@ -880,7 +876,7 @@
    -
    +
    [% INCLUDE fields/sub/checkbox.tt id = "no_alerts_calc_rag" @@ -969,7 +965,7 @@
    -
    +
    [% INCLUDE fields/sub/checkbox.tt id = "no_alerts_calc" @@ -1036,7 +1032,7 @@
    -
    +
    [% INCLUDE fields/sub/checkbox.tt id = "show_add" @@ -1058,7 +1054,7 @@
    -
    +
    [% INCLUDE fields/sub/checkbox.tt id = "delete_not_used" @@ -1080,7 +1076,7 @@
    -
    +
    [% INCLUDE fields/sub/checkbox.tt id = "override_permissions" @@ -1161,7 +1157,7 @@
    -
    +
    [% PROCESS builder.tt builder_id = instance_layout.instance_id @@ -1193,7 +1189,7 @@ label = "Back" }] right_buttons = [{ - class = "btn-js-next btn-default" + class = "btn-js-next btn-primary" label = "Next" }]; %] @@ -1219,12 +1215,6 @@ # prepare table config table_id = 'custom_field_permissions_table'; table_class = 'table-lines table-no-margin'; - table_layout = { - topStart => "search", - topEnd => "pageLength", - bottomStart => "paging", - bottomEnd => "info" - } table_language = { emptyTable => "There are no groups available", lengthMenu => "Rows per page _MENU_", @@ -1232,7 +1222,7 @@ next => "Next", previous => "Previous" }, - search => "Search in groups:", + search => "Search in groups:", searchPlaceholder => "Search group" } table_page_length = 5; @@ -1419,7 +1409,7 @@ label = "Back" }] right_buttons = [{ - class = "btn-js-next btn-default" + class = "btn-js-next btn-primary" label = "Next" }]; %] @@ -1450,19 +1440,13 @@ [% table_id = 'fields'; table_class = 'table-lines'; - table_layout = { - topStart => "search", - topEnd => "pageLength", - bottomStart => "paging", - bottomEnd => "info" - } table_language = { lengthMenu => "Rows per page _MENU_", paginate => { next => "Next", previous => "Previous" }, - search => "Search in fields:", + search => "Search in fields:", searchPlaceholder => "Search field" } table_page_length = 5; @@ -1501,7 +1485,7 @@ label = "Back" }] right_buttons = [{ - class = "btn-js-save btn-default" + class = "btn-js-save btn-primary" label = "Save" }]; %] diff --git a/views/wizard/timeline_options.tt b/views/wizard/timeline_options.tt index 9e330ebf4..0824a6fe0 100644 --- a/views/wizard/timeline_options.tt +++ b/views/wizard/timeline_options.tt @@ -6,7 +6,7 @@