diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index d93cd18cbe7..a4f47d60341 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -6776,6 +6776,10 @@ } } }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, "dismiss": { "message": "Dismiss" }, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts index 02c4ae74fce..842c153692e 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts @@ -5,6 +5,7 @@ import { Router } from "@angular/router"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { BitValidators } from "@bitwarden/components"; import { ProjectView } from "../../models/view/project.view"; import { ProjectService } from "../../projects/project.service"; @@ -25,7 +26,10 @@ export interface ProjectOperation { }) export class ProjectDialogComponent implements OnInit { protected formGroup = new FormGroup({ - name: new FormControl("", [Validators.required]), + name: new FormControl("", { + validators: [Validators.required, BitValidators.trimValidator], + updateOn: "submit", + }), }); protected loading = false; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts index 64b75511ff4..ace5e5d2cab 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts @@ -8,6 +8,7 @@ import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Utils } from "@bitwarden/common/misc/utils"; +import { BitValidators } from "@bitwarden/components"; import { ProjectListView } from "../../models/view/project-list.view"; import { ProjectView } from "../../models/view/project.view"; @@ -36,11 +37,20 @@ export interface SecretOperation { }) export class SecretDialogComponent implements OnInit { protected formGroup = new FormGroup({ - name: new FormControl("", [Validators.required]), + name: new FormControl("", { + validators: [Validators.required, BitValidators.trimValidator], + updateOn: "submit", + }), value: new FormControl("", [Validators.required]), - notes: new FormControl(""), + notes: new FormControl("", { + validators: [BitValidators.trimValidator], + updateOn: "submit", + }), project: new FormControl("", [Validators.required]), - newProjectName: new FormControl(""), + newProjectName: new FormControl("", { + validators: [BitValidators.trimValidator], + updateOn: "submit", + }), }); private destroy$ = new Subject(); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-create-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-create-dialog.component.ts index 5a5e29f06bc..ba9f2e8f946 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-create-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-create-dialog.component.ts @@ -3,6 +3,8 @@ import { Component, Inject, OnInit } from "@angular/core"; import { FormControl, FormGroup, Validators } from "@angular/forms"; import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { BitValidators } from "@bitwarden/components"; import { ServiceAccountView } from "../../../models/view/service-account.view"; import { AccessTokenView } from "../../models/view/access-token.view"; @@ -20,7 +22,10 @@ export interface AccessTokenOperation { }) export class AccessTokenCreateDialogComponent implements OnInit { protected formGroup = new FormGroup({ - name: new FormControl("", [Validators.required, Validators.maxLength(80)]), + name: new FormControl("", { + validators: [Validators.required, Validators.maxLength(80), BitValidators.trimValidator], + updateOn: "submit", + }), expirationDateControl: new FormControl(null), }); protected loading = false; @@ -30,6 +35,7 @@ export class AccessTokenCreateDialogComponent implements OnInit { constructor( public dialogRef: DialogRef, @Inject(DIALOG_DATA) public data: AccessTokenOperation, + private i18nService: I18nService, private dialogService: DialogServiceAbstraction, private accessService: AccessService ) {} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts index 9ecf4ce1a10..fb0f56a8139 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts @@ -4,6 +4,7 @@ import { FormControl, FormGroup, Validators } from "@angular/forms"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { BitValidators } from "@bitwarden/components"; import { ServiceAccountView } from "../../models/view/service-account.view"; import { ServiceAccountService } from "../service-account.service"; @@ -23,9 +24,15 @@ export interface ServiceAccountOperation { templateUrl: "./service-account-dialog.component.html", }) export class ServiceAccountDialogComponent { - protected formGroup = new FormGroup({ - name: new FormControl("", [Validators.required]), - }); + protected formGroup = new FormGroup( + { + name: new FormControl("", { + validators: [Validators.required, BitValidators.trimValidator], + updateOn: "submit", + }), + }, + {} + ); protected loading = false; diff --git a/libs/components/src/form-field/bit-validators.stories.ts b/libs/components/src/form-field/bit-validators.stories.ts index 9e717a3510f..9af9aa096be 100644 --- a/libs/components/src/form-field/bit-validators.stories.ts +++ b/libs/components/src/form-field/bit-validators.stories.ts @@ -8,6 +8,7 @@ import { InputModule } from "../input/input.module"; import { I18nMockService } from "../utils/i18n-mock.service"; import { forbiddenCharacters } from "./bit-validators/forbidden-characters.validator"; +import { trimValidator } from "./bit-validators/trim.validator"; import { BitFormFieldComponent } from "./form-field.component"; import { FormFieldModule } from "./form-field.module"; @@ -24,6 +25,7 @@ export default { return new I18nMockService({ inputForbiddenCharacters: (chars) => `The following characters are not allowed: ${chars}`, + inputTrimValidator: "Input must not contain only whitespace.", }); }, }, @@ -56,3 +58,20 @@ export const ForbiddenCharacters: StoryObj = { template, }), }; + +export const TrimValidator: StoryObj = { + render: (args: BitFormFieldComponent) => ({ + props: { + formObj: new FormBuilder().group({ + name: [ + "", + { + updateOn: "submit", + validators: [trimValidator], + }, + ], + }), + }, + template, + }), +}; diff --git a/libs/components/src/form-field/bit-validators/index.ts b/libs/components/src/form-field/bit-validators/index.ts index 971649450ca..d70473962d3 100644 --- a/libs/components/src/form-field/bit-validators/index.ts +++ b/libs/components/src/form-field/bit-validators/index.ts @@ -1 +1,2 @@ export { forbiddenCharacters } from "./forbidden-characters.validator"; +export { trimValidator } from "./trim.validator"; diff --git a/libs/components/src/form-field/bit-validators/trim.validator.spec.ts b/libs/components/src/form-field/bit-validators/trim.validator.spec.ts new file mode 100644 index 00000000000..471f5396786 --- /dev/null +++ b/libs/components/src/form-field/bit-validators/trim.validator.spec.ts @@ -0,0 +1,61 @@ +import { FormControl } from "@angular/forms"; + +import { trimValidator as validate } from "./trim.validator"; + +describe("trimValidator", () => { + it("should not error when input is null", () => { + const input = createControl(null); + const errors = validate(input); + + expect(errors).toBe(null); + }); + + it("should not error when input is an empty string", () => { + const input = createControl(""); + const errors = validate(input); + + expect(errors).toBe(null); + }); + + it("should not error when input has no whitespace", () => { + const input = createControl("test value"); + const errors = validate(input); + + expect(errors).toBe(null); + }); + + it("should remove beginning whitespace", () => { + const input = createControl(" test value"); + const errors = validate(input); + + expect(errors).toBe(null); + expect(input.value).toBe("test value"); + }); + + it("should remove trailing whitespace", () => { + const input = createControl("test value "); + const errors = validate(input); + + expect(errors).toBe(null); + expect(input.value).toBe("test value"); + }); + + it("should remove beginning and trailing whitespace", () => { + const input = createControl(" test value "); + const errors = validate(input); + + expect(errors).toBe(null); + expect(input.value).toBe("test value"); + }); + + it("should error when input is just whitespace", () => { + const input = createControl(" "); + const errors = validate(input); + + expect(errors).toEqual({ trim: { message: "input is only whitespace" } }); + }); +}); + +function createControl(input: string) { + return new FormControl(input); +} diff --git a/libs/components/src/form-field/bit-validators/trim.validator.ts b/libs/components/src/form-field/bit-validators/trim.validator.ts new file mode 100644 index 00000000000..2256c19c000 --- /dev/null +++ b/libs/components/src/form-field/bit-validators/trim.validator.ts @@ -0,0 +1,27 @@ +import { AbstractControl, FormControl, ValidatorFn } from "@angular/forms"; + +/** + * Automatically trims FormControl value. Errors if value only contains whitespace. + * + * Should be used with `updateOn: "submit"` + */ +export const trimValidator: ValidatorFn = (control: AbstractControl) => { + if (!(control instanceof FormControl)) { + throw new Error("trimValidator only supports validating FormControls"); + } + const value = control.value; + if (value === null || value === undefined || value === "") { + return null; + } + if (!value.trim().length) { + return { + trim: { + message: "input is only whitespace", + }, + }; + } + if (value !== value.trim()) { + control.setValue(value.trim()); + } + return null; +}; diff --git a/libs/components/src/form-field/error.component.ts b/libs/components/src/form-field/error.component.ts index 0686987404a..24a23518871 100644 --- a/libs/components/src/form-field/error.component.ts +++ b/libs/components/src/form-field/error.component.ts @@ -38,6 +38,8 @@ export class BitErrorComponent { return this.i18nService.t("inputForbiddenCharacters", this.error[1]?.characters.join(", ")); case "multipleEmails": return this.i18nService.t("multipleInputEmails"); + case "trim": + return this.i18nService.t("inputTrimValidator"); default: // Attempt to show a custom error message. if (this.error[1]?.message) {