Skip to content

Commit

Permalink
feat(Password): Move password requirements to a menu that opens on focus
Browse files Browse the repository at this point in the history
  • Loading branch information
geoffreykwan committed Oct 13, 2023
1 parent 2ef2e5b commit 4c7534a
Show file tree
Hide file tree
Showing 6 changed files with 92 additions and 43 deletions.
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
<ng-container [formGroup]="formGroup">
<div>
<mat-form-field appearance="fill" fxFlex>
<mat-form-field
[matMenuTriggerFor]="passwordRequirementsMenu"
#passwordRequirementsMenuTrigger="matMenuTrigger"
appearance="fill"
fxFlex
>
<mat-label>{{ passwordLabel }}</mat-label>
<input
matInput
type="password"
[name]="newPasswordFormControlName"
[formControlName]="newPasswordFormControlName"
(focus)="onNewPasswordFocus(passwordRequirementsMenuTrigger)"
(blur)="onNewPasswordBlur(passwordRequirementsMenuTrigger)"
required
/>
<mat-error
Expand All @@ -17,29 +24,33 @@
Password required
</mat-error>
</mat-form-field>
</div>
<password-strength-meter
[password]="newPasswordFormControl.value"
(strengthChange)="passwordStrengthChange($event)"
></password-strength-meter>
<div [ngSwitch]="passwordStrength" fxLayout="row">
<div class="password-strength-label" i18n>Password Strength:</div>
<ng-container *ngIf="!newPasswordFormControl.pristine">
<div *ngSwitchCase="0" class="very-weak" i18n>Very Weak</div>
<div *ngSwitchCase="1" class="weak" i18n>Weak</div>
<div *ngSwitchCase="2" class="good" i18n>Good</div>
<div *ngSwitchCase="3" class="strong" i18n>Strong</div>
<div *ngSwitchCase="4" class="very-strong" i18n>Very Strong</div>
</ng-container>
</div>
<div class="password-requirements-list">
<div class="password-requirements-label" i18n>Your password needs to:</div>
<password-requirement
*ngFor="let passwordRequirement of passwordRequirements"
[errorFieldName]="passwordRequirement.errorFieldName"
[passwordFormControl]="newPasswordFormControl"
[requirementText]="passwordRequirement.text"
></password-requirement>
<mat-menu #passwordRequirementsMenu="matMenu">
<div class="password-requirements-menu">
<password-strength-meter
[password]="newPasswordFormControl.value"
(strengthChange)="passwordStrengthChange($event)"
></password-strength-meter>
<div [ngSwitch]="passwordStrength" fxLayout="row">
<div class="password-strength-label" i18n>Password Strength:</div>
<ng-container *ngIf="!newPasswordFormControl.pristine">
<div *ngSwitchCase="0" class="very-weak" i18n>Very Weak</div>
<div *ngSwitchCase="1" class="weak" i18n>Weak</div>
<div *ngSwitchCase="2" class="good" i18n>Good</div>
<div *ngSwitchCase="3" class="strong" i18n>Strong</div>
<div *ngSwitchCase="4" class="very-strong" i18n>Very Strong</div>
</ng-container>
</div>
<div class="password-requirements-list">
<div class="password-requirements-label" i18n>Your password needs to:</div>
<password-requirement
*ngFor="let passwordRequirement of passwordRequirements"
[errorFieldName]="passwordRequirement.errorFieldName"
[passwordFormControl]="newPasswordFormControl"
[requirementText]="passwordRequirement.text"
></password-requirement>
</div>
</div>
</mat-menu>
</div>
<p>
<mat-form-field appearance="fill" fxFlex>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
.password-requirements-menu {
width: 300px;
margin: 20px;
}

::ng-deep .mat-mdc-menu-panel {
max-width: none !important;
}

.password-strength-label {
margin-right: 8px;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ import { NewPasswordAndConfirmHarness } from './new-password-and-confirm.harness
import { PasswordModule } from '../password.module';
import { PasswordErrors } from '../../domain/password/password-errors';
import { PasswordRequirementComponent } from '../password-requirement/password-requirement.component';
import { MatMenuModule } from '@angular/material/menu';
import { HarnessLoader } from '@angular/cdk/testing';

let component: NewPasswordAndConfirmComponent;
let fixture: ComponentFixture<NewPasswordAndConfirmComponent>;
let newPasswordAndConfirmHarness: NewPasswordAndConfirmHarness;
let rootLoader: HarnessLoader;

describe('NewPasswordAndConfirmComponent', () => {
beforeEach(async () => {
Expand All @@ -24,6 +27,7 @@ describe('NewPasswordAndConfirmComponent', () => {
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatMenuModule,
PasswordModule,
ReactiveFormsModule
]
Expand All @@ -36,6 +40,7 @@ describe('NewPasswordAndConfirmComponent', () => {
fixture,
NewPasswordAndConfirmHarness
);
rootLoader = TestbedHarnessEnvironment.documentRootLoader(fixture);
});
newPasswordValidation();
confirmPasswordValidation();
Expand Down Expand Up @@ -107,9 +112,13 @@ function passwordErrorCases(): void {
}

async function checkPasswordRequirements(passwordErrors: PasswordErrors): Promise<void> {
expect(await newPasswordAndConfirmHarness.isMissingLetter()).toBe(passwordErrors.missingLetter);
expect(await newPasswordAndConfirmHarness.isMissingNumber()).toBe(passwordErrors.missingNumber);
expect(await newPasswordAndConfirmHarness.isTooShort()).toBe(passwordErrors.tooShort);
expect(await newPasswordAndConfirmHarness.isMissingLetter(rootLoader)).toBe(
passwordErrors.missingLetter
);
expect(await newPasswordAndConfirmHarness.isMissingNumber(rootLoader)).toBe(
passwordErrors.missingNumber
);
expect(await newPasswordAndConfirmHarness.isTooShort(rootLoader)).toBe(passwordErrors.tooShort);
}

function confirmPasswordValidation() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ValidatorFn,
Validators
} from '@angular/forms';
import { MatMenuTrigger } from '@angular/material/menu';

@Component({
selector: 'new-password-and-confirm',
Expand Down Expand Up @@ -83,4 +84,18 @@ export class NewPasswordAndConfirmComponent implements OnInit {
protected passwordStrengthChange(value: number): void {
this.passwordStrength = value ? value : 0;
}

onNewPasswordFocus(menuTrigger: MatMenuTrigger): void {
// This setTimeout is required because sometimes when the user clicks on the input, it will
// trigger a blur and then a focus which can lead to the menu not opening. This makes sure that
// if a blur and a focus occur right after each other, the openMenu() will be called after the
// blur is complete.
setTimeout(() => {
menuTrigger.openMenu();
});
}

onNewPasswordBlur(menuTrigger: MatMenuTrigger): void {
menuTrigger.closeMenu();
}
}
Original file line number Diff line number Diff line change
@@ -1,44 +1,47 @@
import { ComponentHarness } from '@angular/cdk/testing';
import { ComponentHarness, HarnessLoader } from '@angular/cdk/testing';
import { MatInputHarness } from '@angular/material/input/testing';
import { MatErrorHarness } from '@angular/material/form-field/testing';
import { PasswordRequirementHarness } from '../password-requirement/password-requirement.harness';

export class NewPasswordAndConfirmHarness extends ComponentHarness {
static hostSelector = 'new-password-and-confirm';

protected getNewPasswordInput = this.locatorFor(
getNewPasswordInput = this.locatorFor(
MatInputHarness.with({ selector: 'input[name="newPassword"]' })
);
protected getConfirmNewPasswordInput = this.locatorFor(
getConfirmNewPasswordInput = this.locatorFor(
MatInputHarness.with({ selector: 'input[name="confirmNewPassword"]' })
);
protected getNewPasswordRequiredError = this.locatorForOptional(
getNewPasswordRequiredError = this.locatorForOptional(
MatErrorHarness.with({ selector: '.new-password-required-error' })
);
protected getConfirmNewPasswordRequiredError = this.locatorForOptional(
getConfirmNewPasswordRequiredError = this.locatorForOptional(
MatErrorHarness.with({ selector: '.confirm-new-password-required-error' })
);
protected getConfirmNewPasswordDoesNotMatchError = this.locatorForOptional(
getConfirmNewPasswordDoesNotMatchError = this.locatorForOptional(
MatErrorHarness.with({ selector: '.confirm-new-password-does-not-match-error' })
);
protected getPasswordRequirements = this.locatorForAll(PasswordRequirementHarness);
getPasswordRequirements = this.locatorForAll(PasswordRequirementHarness);

async isMissingLetter(): Promise<boolean> {
return this.isMissingRequirement('include a letter');
async isMissingLetter(rootLoader: HarnessLoader): Promise<boolean> {
return this.isMissingRequirement(rootLoader, 'include a letter');
}

async isMissingNumber(): Promise<boolean> {
return this.isMissingRequirement('include a number');
async isMissingNumber(rootLoader: HarnessLoader): Promise<boolean> {
return this.isMissingRequirement(rootLoader, 'include a number');
}

async isTooShort(): Promise<boolean> {
return this.isMissingRequirement('be at least 8 characters long');
async isTooShort(rootLoader: HarnessLoader): Promise<boolean> {
return this.isMissingRequirement(rootLoader, 'be at least 8 characters long');
}

private async isMissingRequirement(requirement: string): Promise<boolean> {
const passwordRequirement = await this.locatorFor(
private async isMissingRequirement(
rootLoader: HarnessLoader,
requirement: string
): Promise<boolean> {
const passwordRequirement = await rootLoader.getHarness(
PasswordRequirementHarness.with({ text: requirement })
)();
);
return await passwordRequirement.isFail();
}

Expand Down
2 changes: 2 additions & 0 deletions src/app/password/password.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { NewPasswordAndConfirmComponent } from './new-password-and-confirm/new-p
import { MatIconModule } from '@angular/material/icon';
import { PasswordStrengthMeterModule } from 'angular-password-strength-meter';
import { PasswordRequirementComponent } from './password-requirement/password-requirement.component';
import { MatMenuModule } from '@angular/material/menu';

@NgModule({
imports: [
Expand All @@ -17,6 +18,7 @@ import { PasswordRequirementComponent } from './password-requirement/password-re
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatMenuModule,
PasswordStrengthMeterModule.forRoot(),
ReactiveFormsModule
],
Expand Down

0 comments on commit 4c7534a

Please sign in to comment.