diff --git a/lib/GADS/Column/String.pm b/lib/GADS/Column/String.pm index 9e9e014f7..86c12a1be 100644 --- a/lib/GADS/Column/String.pm +++ b/lib/GADS/Column/String.pm @@ -20,12 +20,33 @@ package GADS::Column::String; use Log::Report 'linkspace'; use Moo; -use MooX::Types::MooseLike::Base qw/Bool Str Maybe/; +use MooX::Types::MooseLike::Base qw/Bool Str Int Maybe/; extends 'GADS::Column'; with 'GADS::Role::Presentation::Column::String'; +has '+option_names' => ( + default => sub { + [+{ + name => 'max_length', + user_configurable => 1, + }] + } +); + +has max_length => ( + is => 'rw', + isa => Maybe[Int], + lazy => 1, + builder => sub { + my $self = shift; + $self->has_options ? $self->options->{max_length} // undef : undef; # explicitly return undef if no options, to avoid confusion with 0 + }, + trigger => sub { $_[0]->reset_options }, + coerce => sub { defined $_[0] && $_[0] ne "" && $_[0] =~ /^\d+$/i ? int($_[0]) : undef }, +); + has textbox => ( is => 'rw', isa => Bool, diff --git a/lib/GADS/Role/Presentation/Column/String.pm b/lib/GADS/Role/Presentation/Column/String.pm index b89e349bd..6f14132ed 100644 --- a/lib/GADS/Role/Presentation/Column/String.pm +++ b/lib/GADS/Role/Presentation/Column/String.pm @@ -5,7 +5,8 @@ use Moo::Role; sub after_presentation { my ($self, $return) = @_; - $return->{textbox} = $self->textbox; + $return->{textbox} = $self->textbox; + $return->{max_length} = $self->max_length if defined $self->max_length; } 1; diff --git a/src/frontend/components/form-group/field-length/_field-length.scss b/src/frontend/components/form-group/field-length/_field-length.scss new file mode 100644 index 000000000..774d81a22 --- /dev/null +++ b/src/frontend/components/form-group/field-length/_field-length.scss @@ -0,0 +1,10 @@ +.counter { + @extend .d-flex; + @extend .justify-content-end; + @extend .text-muted; + @extend .small; + + &.invalid { + @extend .text-danger + } +} diff --git a/src/frontend/components/form-group/field-length/index.js b/src/frontend/components/form-group/field-length/index.js new file mode 100644 index 000000000..0e512d00d --- /dev/null +++ b/src/frontend/components/form-group/field-length/index.js @@ -0,0 +1,6 @@ +import { initializeComponent } from "component" +import FieldLengthComponent from "./lib/component" + +export default (scope) => { + initializeComponent(scope, "[data-max]", FieldLengthComponent); +} diff --git a/src/frontend/components/form-group/field-length/lib/component.test.ts b/src/frontend/components/form-group/field-length/lib/component.test.ts new file mode 100644 index 000000000..f67264d6e --- /dev/null +++ b/src/frontend/components/form-group/field-length/lib/component.test.ts @@ -0,0 +1,89 @@ +import {describe, it, expect} from "@jest/globals"; +import FieldLengthComponent from "./component"; + +describe("FieldLengthComponent", () => { + it("should error when not passed the correct type of component", () => { + const div = document.createElement("div"); + expect(() => new FieldLengthComponent(div)).toThrow("Element must be a textarea or input"); + }); + + it("should not create a counter if the element lacks the data-max attribute", () => { + const input = document.createElement("input"); + document.body.appendChild(input); + new FieldLengthComponent(input); + const counter = input.nextElementSibling as HTMLElement; + expect(counter).toBeNull(); + }); + + it("should create a counter with the correct values when the input is empty", () => { + const input = document.createElement("input"); + input.dataset.max = "10"; + document.body.appendChild(input); + new FieldLengthComponent(input); + const counter = input.nextElementSibling as HTMLElement; + expect(counter).not.toBeNull(); + expect(counter.textContent).toBe("0/10"); + }); + + it("should create a counter with the correct values when the input has some text", () => { + const input = document.createElement("input"); + input.dataset.max = "10"; + input.value = "Hello"; + document.body.appendChild(input); + new FieldLengthComponent(input); + const counter = input.nextElementSibling as HTMLElement; + expect(counter).not.toBeNull(); + expect(counter.textContent).toBe("5/10"); + }); + + it("should update the counter with the correct values when the input value changes", () => { + const input = document.createElement("input"); + input.dataset.max = "10"; + document.body.appendChild(input); + new FieldLengthComponent(input); + const counter = input.nextElementSibling as HTMLElement; + expect(counter).not.toBeNull(); + input.value = "Hello"; + input.dispatchEvent(new Event("keyup")); + expect(counter.textContent).toBe("5/10"); + }); + + it("should add the invalid class to the counter when the input value exceeds the max length", () => { + const input = document.createElement("input"); + input.dataset.max = "10"; + document.body.appendChild(input); + new FieldLengthComponent(input); + const counter = input.nextElementSibling as HTMLElement; + expect(counter).not.toBeNull(); + input.value = "Hello World!"; + input.dispatchEvent(new Event("keyup")); + expect(counter.classList.contains("invalid")).toBe(true); + }); + + it("should remove the invalid class from the counter when the input value is reduced to be within the max length", () => { + const input = document.createElement("input"); + input.dataset.max = "10"; + document.body.appendChild(input); + new FieldLengthComponent(input); + const counter = input.nextElementSibling as HTMLElement; + expect(counter).not.toBeNull(); + input.value = "Hello World!"; + input.dispatchEvent(new Event("keyup")); + expect(counter.classList.contains("invalid")).toBe(true); + input.value = "Hello"; + input.dispatchEvent(new Event("keyup")); + expect(counter.classList.contains("invalid")).toBe(false); + }); + + it("Should work with textarea elements", () => { + const textarea = document.createElement("textarea"); + textarea.dataset.max = "10"; + document.body.appendChild(textarea); + new FieldLengthComponent(textarea); + const counter = textarea.nextElementSibling as HTMLElement; + expect(counter).not.toBeNull(); + textarea.value = "Hello World!"; + textarea.dispatchEvent(new Event("keyup")); + expect(counter.classList.contains("invalid")).toBe(true); + }); +}); diff --git a/src/frontend/components/form-group/field-length/lib/component.ts b/src/frontend/components/form-group/field-length/lib/component.ts new file mode 100644 index 000000000..3cc78c03f --- /dev/null +++ b/src/frontend/components/form-group/field-length/lib/component.ts @@ -0,0 +1,41 @@ +import { Component } from "component"; + +export default class FieldLengthComponent extends Component { + constructor(element: HTMLElement) { + super(element); + if (!(element instanceof HTMLTextAreaElement) && !(element instanceof HTMLInputElement)) { + throw new Error("Element must be a textarea or input"); + } + this.init(); + } + + private init() { + const input = this.element as HTMLTextAreaElement | HTMLInputElement; + if (!input) return; + if(!input.dataset.max) return; + this.createCounter(input); + input.addEventListener("keyup", () => this.updateCounter(input)); + } + + private createCounter(input: HTMLTextAreaElement | HTMLInputElement) { + const max = parseInt(input.dataset.max || "0", 10); + const counter = document.createElement("div"); + counter.classList.add("counter"); + counter.textContent = `${input.value.length}/${max}`; + input.insertAdjacentElement("afterend", counter); + } + + private updateCounter(input: HTMLTextAreaElement | HTMLInputElement) { + const max = parseInt(input.dataset.max || "0", 10); + const counter = input.nextElementSibling as HTMLElement; + const length = input.value.length; + counter.textContent = `${length}/${max}`; + if(length>max) { + if(!counter.classList.contains("invalid")) { + counter.classList.add("invalid"); + } + } else if(counter.classList.contains("invalid")) { + counter.classList.remove("invalid"); + } + } +} \ No newline at end of file diff --git a/src/frontend/css/stylesheets/general.scss b/src/frontend/css/stylesheets/general.scss index 05389be93..dc91e1c30 100644 --- a/src/frontend/css/stylesheets/general.scss +++ b/src/frontend/css/stylesheets/general.scss @@ -68,7 +68,8 @@ "form-group/select-widget/select-widget", "form-group/switch/switch", "form-group/textarea/textarea", - "form-group/input-group/input-group"; + "form-group/input-group/input-group", + "form-group/field-length/field-length"; @import "graph/graph"; @import "link/link"; diff --git a/src/frontend/js/lib/validation.js b/src/frontend/js/lib/validation.js index 18b111391..3845d4cb0 100644 --- a/src/frontend/js/lib/validation.js +++ b/src/frontend/js/lib/validation.js @@ -169,10 +169,10 @@ const validateRequiredFieldsetCheckboxes = (fieldset) => { return isValid; }; -const addErrorMessage = (field, name, id) => { +const addErrorMessage = (field, name, id, raw = false) => { const $errorDiv = $('