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 = $('
') let $span = $(``) - $span.text(`${name} is a required field.`) + $span.text(raw ? name : `${name} is a required field.`) $errorDiv.html($span) field.append($errorDiv) } @@ -193,6 +193,19 @@ const validateInput = (field) => { removeErrorMessage($(field)) + const maxEl = field.find('[data-max]'); + if(maxEl && maxEl.length) { + const maxLen = maxEl.data('max'); + const fieldLen = maxEl.val().length; + if(fieldLen > maxLen) { + maxEl.attr('aria-invalid', true) + addErrorMessage(field, `${strFieldName} must be ${maxLen} characters or less`, strID, true); + field.addClass('invalid'); + field.closest('.fieldset--required').addClass('fieldset--invalid'); + return false; + } + } + if (($inputEl.val() === '') && (!isHiddenDependentField(field))) { $inputEl.attr('aria-invalid', true) addErrorMessage(field, strFieldName, strID) diff --git a/src/frontend/js/site.js b/src/frontend/js/site.js index e93f46bbc..7780c57f8 100644 --- a/src/frontend/js/site.js +++ b/src/frontend/js/site.js @@ -45,6 +45,7 @@ import SelectAllComponent from "components/select-all"; import HelpView from "components/help-view"; import PeopleFilterComponent from "components/form-group/people-filter"; import handleActions from "util/actionsHandler"; +import FieldLengthComponent from "components/form-group/field-length"; // Register them registerComponent(AddTableModalComponent); @@ -84,6 +85,7 @@ registerComponent(SelectAllComponent); registerComponent(HelpView); registerComponent(PeopleFilterComponent); registerComponent(AutosaveComponent); +registerComponent(FieldLengthComponent); // Initialize all components at some point initializeRegisteredComponents(document.body); diff --git a/views/edit.tt b/views/edit.tt index e99bceac4..7ac1db93b 100755 --- a/views/edit.tt +++ b/views/edit.tt @@ -1025,7 +1025,8 @@ popover_body = col.helptext_html filter = "html" is_required = field_is_required - is_readonly = readonly; + is_readonly = readonly + max_length = col.max_length; ELSE; INCLUDE fields/textarea.tt id = col.id @@ -1039,7 +1040,8 @@ is_required = field_is_required is_readonly = readonly hide_group = 1 - rows = 10; + rows = 10 + max_length = col.max_length; END; ELSE; field_input_type = "text"; @@ -1079,7 +1081,8 @@ filter = "html" is_required = field_is_required is_readonly = readonly - type = field_input_type; + type = field_input_type + max_length = col.max_length; ELSE; INCLUDE fields/input.tt id = col.id @@ -1096,7 +1099,8 @@ is_required = field_is_required is_readonly = readonly hide_group = 1 - type = field_input_type; + type = field_input_type + max_length = col.max_length; END; diff --git a/views/fields/input.tt b/views/fields/input.tt index 688b70521..ea827fb2a 100644 --- a/views/fields/input.tt +++ b/views/fields/input.tt @@ -64,6 +64,7 @@ readonly[% END %][% IF is_required %] required aria-required="true"[% END %] + [% IF max_length %] data-max="[% max_length %]"[% END %] >
diff --git a/views/fields/multi_field.tt b/views/fields/multi_field.tt index 40c86f5e6..51ae32b98 100644 --- a/views/fields/multi_field.tt +++ b/views/fields/multi_field.tt @@ -96,7 +96,8 @@ is_readonly = is_readonly hide_group = 1 multi_value_style = 1 - rows = 10; + rows = 10 + max_length = max_length; ELSE; INCLUDE fields/input.tt id = id @@ -112,7 +113,8 @@ is_required = is_required is_readonly = is_readonly hide_group = 1 - type = field_input_type; + type = field_input_type + max_length = max_length; END; %]