From 6c3e8f5c4cf8514518c17bb09bf2603039e76d70 Mon Sep 17 00:00:00 2001 From: Dave Roberts Date: Fri, 12 Sep 2025 16:35:26 +0100 Subject: [PATCH] Fix for Auto-Recover being invasive - Add `Buildable` interface - Add the `Hidable` abstract class for hiding elements - Add the `ButtonBuilder` for building custom buttons - Add the `AlertBuilder` abstract class for creating alerts, as well as a number of specific Alert types that can be built using this class - Prevent alert showing when not on record pages - Prevent modal showing when other alerts have values - Reword values in alert and modal - Update modal to show fields being changed --- .../components/alert/lib/alertBase.ts | 43 ++++++++++++++ .../components/alert/lib/dangerAlert.test.ts | 18 ++++++ .../components/alert/lib/dangerAlert.ts | 17 ++++++ .../components/alert/lib/infoAlert.test.ts | 18 ++++++ .../components/alert/lib/infoAlert.ts | 17 ++++++ .../components/alert/lib/successAlert.test.ts | 18 ++++++ .../components/alert/lib/successAlert.ts | 17 ++++++ .../components/alert/lib/types/index.ts | 1 + .../components/alert/lib/warningAlert.test.ts | 18 ++++++ .../components/alert/lib/warningAlert.ts | 17 ++++++ .../button/lib/RenderableButton.test.ts | 45 +++++++++++++++ .../components/button/lib/RenderableButton.ts | 21 +++++++ .../form-group/autosave/_autosave.scss | 31 ++++++++++ .../components/form-group/autosave/index.js | 26 +++++---- .../form-group/autosave/lib/modal.js | 57 ++++++++++++++++--- src/frontend/js/lib/util/renderable/index.ts | 2 + .../js/lib/util/renderable/lib/Hidable.ts | 20 +++++++ .../js/lib/util/renderable/lib/Renderable.ts | 26 +++++++++ 18 files changed, 394 insertions(+), 18 deletions(-) create mode 100644 src/frontend/components/alert/lib/alertBase.ts create mode 100644 src/frontend/components/alert/lib/dangerAlert.test.ts create mode 100644 src/frontend/components/alert/lib/dangerAlert.ts create mode 100644 src/frontend/components/alert/lib/infoAlert.test.ts create mode 100644 src/frontend/components/alert/lib/infoAlert.ts create mode 100644 src/frontend/components/alert/lib/successAlert.test.ts create mode 100644 src/frontend/components/alert/lib/successAlert.ts create mode 100644 src/frontend/components/alert/lib/types/index.ts create mode 100644 src/frontend/components/alert/lib/warningAlert.test.ts create mode 100644 src/frontend/components/alert/lib/warningAlert.ts create mode 100644 src/frontend/components/button/lib/RenderableButton.test.ts create mode 100644 src/frontend/components/button/lib/RenderableButton.ts create mode 100644 src/frontend/js/lib/util/renderable/index.ts create mode 100644 src/frontend/js/lib/util/renderable/lib/Hidable.ts create mode 100644 src/frontend/js/lib/util/renderable/lib/Renderable.ts diff --git a/src/frontend/components/alert/lib/alertBase.ts b/src/frontend/components/alert/lib/alertBase.ts new file mode 100644 index 000000000..9f5b0c617 --- /dev/null +++ b/src/frontend/components/alert/lib/alertBase.ts @@ -0,0 +1,43 @@ +import { Hidable, Renderable } from 'util/renderable'; +import { AlertType } from './types'; + +export abstract class AlertBase extends Hidable implements Renderable { + /** + * Create an instance of AlertBase. + * This class serves as a base for alert components. + * It implements the Renderable interface, which requires a render method. + * The render method should be implemented by subclasses to provide specific rendering logic. + * @implements {Renderable} + * @param {string} message - The message to be displayed in the alert. + * @param {AlertType} type - The type of alert, which determines its styling and behavior. + * @see Renderable + * @see Hidable + * @see AlertType + * @example + * const alert = new AlertBase('This is an alert message', AlertType.INFO); + * document.body.appendChild(alert.render()); + */ + constructor(private readonly message: string, private readonly type: AlertType, private readonly transparent: boolean = false) { + super(); + } + + /** + * Render the alert as an HTMLDivElement. + * @returns {HTMLDivElement} The rendered HTML element representing the alert. + */ + render(): HTMLDivElement { + if(this.element) throw new Error('AlertBase.render() should not be called multiple times without resetting the element.'); + const alertDiv = document.createElement('div'); + alertDiv.classList.add('alert', `alert-${this.type}`); + if(this.transparent) { + alertDiv.classList.add('alert-no-bg'); + } + for(const item of this.message.split('\n')) { + const pDiv = document.createElement('p'); + pDiv.textContent = item; + alertDiv.appendChild(pDiv); + } + this.element = alertDiv; + return alertDiv; + } +} diff --git a/src/frontend/components/alert/lib/dangerAlert.test.ts b/src/frontend/components/alert/lib/dangerAlert.test.ts new file mode 100644 index 000000000..d3ccc78a7 --- /dev/null +++ b/src/frontend/components/alert/lib/dangerAlert.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from '@jest/globals'; +import { DangerAlert } from './dangerAlert'; + +describe('Error Alert Tests', () => { + it('should display an error alert with the correct message', () => { + const errorMessage = 'This is a test error message'; + const errorAlert = new DangerAlert(errorMessage); + + const alert = errorAlert.render(); + + document.body.appendChild(alert); + + expect(alert.classList.contains('alert-danger')).toBeTruthy(); + expect(alert.textContent).toContain(errorMessage); + + document.body.removeChild(alert); + }); +}); diff --git a/src/frontend/components/alert/lib/dangerAlert.ts b/src/frontend/components/alert/lib/dangerAlert.ts new file mode 100644 index 000000000..af77e65f3 --- /dev/null +++ b/src/frontend/components/alert/lib/dangerAlert.ts @@ -0,0 +1,17 @@ +import { AlertBase } from "./alertBase"; + +export class DangerAlert extends AlertBase { + /** + * Create an instance of InfoAlert. + * This class extends AlertBase to provide a specific implementation for info alerts. + * It uses the 'info' alert type for styling and behavior. + * @class + * @public + * @memberof alert.lib + * @constructor + * @param {string} message - The message to be displayed in the alert. + */ + constructor(message: string) { + super(message, 'danger'); + } +} diff --git a/src/frontend/components/alert/lib/infoAlert.test.ts b/src/frontend/components/alert/lib/infoAlert.test.ts new file mode 100644 index 000000000..8f9bb52be --- /dev/null +++ b/src/frontend/components/alert/lib/infoAlert.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from '@jest/globals'; +import { InfoAlert } from './infoAlert'; + +describe('Info Alert Tests', () => { + it('should display an info alert with the correct message', () => { + const infoMessage = 'This is a test info message'; + const infoAlert = new InfoAlert(infoMessage); + + const alert = infoAlert.render(); + + document.body.appendChild(alert); + + expect(alert.classList.contains('alert-info')).toBeTruthy(); + expect(alert.textContent).toContain(infoMessage); + + document.body.removeChild(alert); + }); +}); diff --git a/src/frontend/components/alert/lib/infoAlert.ts b/src/frontend/components/alert/lib/infoAlert.ts new file mode 100644 index 000000000..6a9214dbb --- /dev/null +++ b/src/frontend/components/alert/lib/infoAlert.ts @@ -0,0 +1,17 @@ +import { AlertBase } from './alertBase'; + +export class InfoAlert extends AlertBase { + /** + * Create an instance of InfoAlert. + * This class extends AlertBase to provide a specific implementation for info alerts. + * It uses the AlertType.INFO to set the alert type. + * @class + * @public + * @memberof alert.lib + * @constructor + * @param {string} message - The message to be displayed in the info alert. + */ + constructor(message: string) { + super(message, "info"); + } +} \ No newline at end of file diff --git a/src/frontend/components/alert/lib/successAlert.test.ts b/src/frontend/components/alert/lib/successAlert.test.ts new file mode 100644 index 000000000..fde8cca1d --- /dev/null +++ b/src/frontend/components/alert/lib/successAlert.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from '@jest/globals'; +import { SuccessAlert } from './successAlert'; + +describe('Success Alert Tests', () => { + it('should create a success alert', () => { + const message = 'Operation completed successfully'; + const alert = new SuccessAlert(message); + + const result = alert.render(); + + document.body.appendChild(result); + + expect(result.classList.contains('alert-success')).toBeTruthy(); + expect(result.textContent).toBe(message); + + document.body.removeChild(result); + }); +}); diff --git a/src/frontend/components/alert/lib/successAlert.ts b/src/frontend/components/alert/lib/successAlert.ts new file mode 100644 index 000000000..ee49396b6 --- /dev/null +++ b/src/frontend/components/alert/lib/successAlert.ts @@ -0,0 +1,17 @@ +import { AlertBase } from "./alertBase"; + +export class SuccessAlert extends AlertBase { + /** + * Create an instance of InfoAlert. + * This class extends AlertBase to provide a specific implementation for info alerts. + * It uses the 'info' alert type for styling and behavior. + * @class + * @public + * @memberof alert.lib + * @constructor + * @param {string} message - The message to be displayed in the alert. + */ + constructor(message: string) { + super(message, 'success', true); + } +} diff --git a/src/frontend/components/alert/lib/types/index.ts b/src/frontend/components/alert/lib/types/index.ts new file mode 100644 index 000000000..eecc79f02 --- /dev/null +++ b/src/frontend/components/alert/lib/types/index.ts @@ -0,0 +1 @@ +export type AlertType = 'info' | 'success' | 'warning' | 'danger'; diff --git a/src/frontend/components/alert/lib/warningAlert.test.ts b/src/frontend/components/alert/lib/warningAlert.test.ts new file mode 100644 index 000000000..abb4a6a0d --- /dev/null +++ b/src/frontend/components/alert/lib/warningAlert.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from '@jest/globals'; +import { WarningAlert } from './warningAlert'; + +describe('Warning Alert Tests', () => { + it('should create a warning alert', () => { + const message = 'This is a warning message'; + const alert = new WarningAlert(message); + + const result = alert.render(); + + document.body.appendChild(result); + + expect(result.classList.contains('alert-warning')).toBe(true); + expect(result.textContent).toBe(message); + + document.body.removeChild(result); + }); +}); diff --git a/src/frontend/components/alert/lib/warningAlert.ts b/src/frontend/components/alert/lib/warningAlert.ts new file mode 100644 index 000000000..2dce92e08 --- /dev/null +++ b/src/frontend/components/alert/lib/warningAlert.ts @@ -0,0 +1,17 @@ +import { AlertBase } from "./alertBase"; + +export class WarningAlert extends AlertBase { + /** + * Create an instance of InfoAlert. + * This class extends AlertBase to provide a specific implementation for info alerts. + * It uses the AlertType.INFO to set the alert type. + * @class + * @public + * @memberof alert.lib + * @constructor + * @param {string} message - The message to be displayed in the info alert. + */ + constructor(message: string) { + super(message, "warning"); + } +} diff --git a/src/frontend/components/button/lib/RenderableButton.test.ts b/src/frontend/components/button/lib/RenderableButton.test.ts new file mode 100644 index 000000000..a1421adf3 --- /dev/null +++ b/src/frontend/components/button/lib/RenderableButton.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, jest } from '@jest/globals'; +import { RenderableButton } from './RenderableButton'; + +describe('Renderable Button Tests', () => { + it('should create a Renderable Button', () => { + const caption = 'Test Button'; + const button = new RenderableButton(caption, ()=>{}); + + const rendered = button.render(); + + document.body.appendChild(rendered); + + expect(rendered.textContent).toBe('Test Button'); + expect(rendered.classList.contains('btn')).toBeTruthy(); + expect(rendered.classList.contains('btn-default')).toBeTruthy(); + + document.body.removeChild(rendered); + }); + + it('should handle click events', () => { + const mockCallback = jest.fn(); + const button = new RenderableButton('Click Me', mockCallback); + + const rendered = button.render(); + document.body.appendChild(rendered); + + rendered.click(); + + expect(mockCallback).toHaveBeenCalled(); + + document.body.removeChild(rendered); + }); + + it('should apply custom classes', () => { + const button = new RenderableButton('Custom Class', ()=>{}, 'btn-custom'); + + const rendered = button.render(); + document.body.appendChild(rendered); + + expect(rendered.classList.contains('btn-custom')).toBeTruthy(); + expect(rendered.classList.contains('btn-default')).toBeFalsy(); + + document.body.removeChild(rendered); + }); +}); \ No newline at end of file diff --git a/src/frontend/components/button/lib/RenderableButton.ts b/src/frontend/components/button/lib/RenderableButton.ts new file mode 100644 index 000000000..b8004011a --- /dev/null +++ b/src/frontend/components/button/lib/RenderableButton.ts @@ -0,0 +1,21 @@ +import { Renderable } from "util/renderable"; + +export class RenderableButton implements Renderable { + classList: string[] = []; + + constructor(private readonly text: string, private readonly onClick: (ev: MouseEvent)=>void, ...classList: string[]) { + this.classList = classList; + } + + render(): HTMLButtonElement { + const button = document.createElement('button'); + button.textContent = this.text; + button.addEventListener('click', this.onClick); + button.classList.add(...this.classList, 'btn'); + const btnType = this.classList.find(b=>b.startsWith('btn-')) ? '' : 'btn-default' + if(btnType) { + button.classList.add(btnType); + } + return button; + } +} diff --git a/src/frontend/components/form-group/autosave/_autosave.scss b/src/frontend/components/form-group/autosave/_autosave.scss index 8011cead7..6989a0278 100644 --- a/src/frontend/components/form-group/autosave/_autosave.scss +++ b/src/frontend/components/form-group/autosave/_autosave.scss @@ -29,4 +29,35 @@ li.li-error { font-size: 1.5em; margin-right: 0.5em; } +} + +// I don't know why this button wasn't hidden before, but it is now +#restoreValuesModal .btn-js-delete-values { + visibility: hidden; + display: none; +} + +.alert-restore { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + p { + flex-grow: 1; + } + + // TODO: This will be moved over to BS5 on merge - remember, syntax is _completely_ different + .btn-alert-restore { + @include button-variant($white, $brand-secundary, $white, $brand-primary, $brand-secundary, $brand-secundary); + color: $brand-secundary; + + &:hover { + color: $brand-primary; + } + } + + .btn-alert-restore-cancel { + @extend .btn-danger; + } } \ No newline at end of file diff --git a/src/frontend/components/form-group/autosave/index.js b/src/frontend/components/form-group/autosave/index.js index efd08884b..769c7822e 100644 --- a/src/frontend/components/form-group/autosave/index.js +++ b/src/frontend/components/form-group/autosave/index.js @@ -4,19 +4,23 @@ import AutosaveModal from './lib/modal'; import gadsStorage from 'util/gadsStorage'; export default (scope) => { - if (gadsStorage.enabled) { - try { - initializeComponent(scope, '.linkspace-field', AutosaveComponent); - initializeComponent(scope, '#restoreValuesModal', AutosaveModal); - } catch(e) { - console.error(e); + // Ensure the autosave functionality is only initialized on record pages. + // This is to deactivate autosave on other pages that may use the form-edit class + if(location.pathname.match(/record/)) { + if (gadsStorage.enabled) { + try { + initializeComponent(scope, '.linkspace-field', AutosaveComponent); + initializeComponent(scope, '#restoreValuesModal', AutosaveModal); + } catch(e) { + console.error(e); + if($('body').data('encryption-disabled')) return; + $('.content-block__main-content').prepend('
Auto-recover failed to initialize. ' + e.message ? e.message : e + '
'); + $('body').data('encryption-disabled', 'true'); + } + } else { if($('body').data('encryption-disabled')) return; - $('.content-block__main-content').prepend('
Auto-recover failed to initialize. ' + e.message ? e.message : e + '
'); + $('.content-block__main-content').prepend('
Auto-recover is disabled as your browser does not support encryption
'); $('body').data('encryption-disabled', 'true'); } - } else { - if($('body').data('encryption-disabled')) return; - $('.content-block__main-content').prepend('
Auto-recover is disabled as your browser does not support encryption
'); - $('body').data('encryption-disabled', 'true'); } }; diff --git a/src/frontend/components/form-group/autosave/lib/modal.js b/src/frontend/components/form-group/autosave/lib/modal.js index 95638700c..32e12324b 100644 --- a/src/frontend/components/form-group/autosave/lib/modal.js +++ b/src/frontend/components/form-group/autosave/lib/modal.js @@ -1,6 +1,8 @@ import { setFieldValues } from "set-field-values"; import AutosaveBase from './autosaveBase'; import { fromJson } from "util/common"; +import { InfoAlert } from "components/alert/lib/infoAlert"; +import { RenderableButton } from "components/button/lib/RenderableButton"; /** * A modal that allows the user to restore autosaved values. @@ -33,10 +35,12 @@ class AutosaveModal extends AutosaveBase { let $list = $("
    "); const $body = $modal.find(".modal-body"); - $body.html("

    Restoring values...

    Please be aware that linked records may take a moment to finish restoring.

    ").append($list); + $body + .html("

    Restoring values...

    Please be aware that linked records may take a moment to finish restoring.

    ") + .append($list); // Convert the fields to promise functions (using the fields) that are run in parallel // This is only done because various parts of the codebase use the fields in different ways dependent on types (i.e. curval) - Promise.all($form.find('.linkspace-field').map(async (_, field) => { + await Promise.all($form.find('.linkspace-field').map(async (_, field) => { const $field = $(field); // This was originally a bunch of promises, but as the code is async, we can await things here try { @@ -103,7 +107,7 @@ class AutosaveModal extends AutosaveBase { $body.append(`

    Critical error restoring values

    ${e}

    `); }).finally(() => { // Only allow to close once recovery is finished - if(!curvalCount || errored) { + if (!curvalCount || errored) { // Show the close button $modal.find(".modal-footer").find(".btn-cancel").text("Close").show(); this.storage.removeItem('recovering'); @@ -114,10 +118,49 @@ class AutosaveModal extends AutosaveBase { // Do we need to run an autorecover? const item = await this.storage.getItem(this.table_key); - if (item) { - $modal.modal('show'); - $modal.find('.btn-js-delete-values').attr('disabled', 'disabled').hide(); - } + // If there is no item, or there are already alerts, do not show the alert + if ($('.alert-danger').text() || $('.alert-warning').text() || !item) return; + const alert = new InfoAlert("There are unsaved values from the last time you edited this record. Would you like to preview the changes?"); + const alertElement = alert.render(); + + alertElement.classList.add('alert-restore'); + + const restoreButton = new RenderableButton("Preview", () => { + const $display = $modal.find(".modal-autosave") + const list = $("
  • "); + // Get a list of the field values to restore + Promise.all($form.find('.linkspace-field').map(async (_, field)=>{ + const $field = $(field); + const key = this.columnKey($field); + const value = await this.storage.getItem(key) + if(!value) return; + const fieldName = $field.data('name'); + const li = $(`
  • ${fieldName}
  • `) + list.append(li); + })).then(()=> { + // Append the list to the modal display + $display.append(list) + }).then(()=>{ + // Show the modal + $modal.modal('show'); + alert.hide(); + }); + }, 'btn-primary', 'btn-inverted', 'btn-alert-restore'); + const restoreButtonElement = restoreButton.render(); + + const cancelButton = new RenderableButton("Cancel", () => { + alert.hide(); + }, 'btn-secondary', 'btn-inverted', 'btn-alert-restore-cancel'); + const cancelButtonElement = cancelButton.render(); + + const buttonDiv = document.createElement('div'); + buttonDiv.className = 'button-group d-flex justify-content-end'; + buttonDiv.appendChild(restoreButtonElement); + buttonDiv.appendChild(cancelButtonElement); + + alertElement.appendChild(buttonDiv); + + $('.content-block').prepend(alertElement); } } diff --git a/src/frontend/js/lib/util/renderable/index.ts b/src/frontend/js/lib/util/renderable/index.ts new file mode 100644 index 000000000..8cbafad73 --- /dev/null +++ b/src/frontend/js/lib/util/renderable/index.ts @@ -0,0 +1,2 @@ +export { Renderable } from "./lib/Renderable"; +export { Hidable } from "./lib/Hidable"; \ No newline at end of file diff --git a/src/frontend/js/lib/util/renderable/lib/Hidable.ts b/src/frontend/js/lib/util/renderable/lib/Hidable.ts new file mode 100644 index 000000000..1a9da787e --- /dev/null +++ b/src/frontend/js/lib/util/renderable/lib/Hidable.ts @@ -0,0 +1,20 @@ +/** + * Hidable class for managing visibility of HTML elements. + * This class provides methods to hide an element by setting its display and visibility styles, + * and updating its ARIA attributes. + * + * @template T - The type of the HTML element, defaulting to HTMLElement. + * @abstract + * @class Hidable + */ +export abstract class Hidable { + protected element: T | null = null; + + /** + * Hides the component. + */ + hide(): void { + if(!this.element) return; + this.element.remove(); + } +} \ No newline at end of file diff --git a/src/frontend/js/lib/util/renderable/lib/Renderable.ts b/src/frontend/js/lib/util/renderable/lib/Renderable.ts new file mode 100644 index 000000000..650735e3c --- /dev/null +++ b/src/frontend/js/lib/util/renderable/lib/Renderable.ts @@ -0,0 +1,26 @@ +/** + * Renderable interface for defining renderable components. + * This interface requires a render method that returns an HTML element or a jQuery-wrapped element. + * It also includes a renderAsync method that returns a Promise resolving to the same type. + * + * @template T - The type of the HTML element to be rendered, defaulting to HTMLElement. + * @interface Renderable + * @property {function(): T | JQuery} render - Synchronous render method. + * @property {function(): Promise>} renderAsync - Asynchronous render method returning a Promise. + * + * @example + * class MyComponent implements Renderable { + * render(): HTMLDivElement { + * const div = document.createElement('div'); + * div.textContent = 'Hello, World!'; + * return div; + * } + * } + */ +export interface Renderable { + /** + * Synchronous render method that returns an HTML element or a jQuery-wrapped element. + * @returns {T} The rendered HTML element or jQuery-wrapped element. + */ + render(): T; +} \ No newline at end of file