diff --git a/src/app/carbon-estimator-form/carbon-estimator-form.component.html b/src/app/carbon-estimator-form/carbon-estimator-form.component.html index 6d49815c..aa957e76 100644 --- a/src/app/carbon-estimator-form/carbon-estimator-form.component.html +++ b/src/app/carbon-estimator-form/carbon-estimator-form.component.html @@ -1,7 +1,7 @@
- @if (showErrorSummary) { - + @if (errorSummaryState.showErrorSummary) { + } @@ -145,7 +145,8 @@ class="tce-border tce-border-slate-400 tce-rounded disabled:tce-bg-gray-200 disabled:tce-text-slate-400 disabled:tce-cursor-not-allowed tce-px-3 tce-py-2" formControlName="monthlyCloudBill" name="monthlyCloudBill" - required> + required + [compareWith]="compareCostRanges"> @for (range of costRanges; track $index) { } diff --git a/src/app/carbon-estimator-form/carbon-estimator-form.component.spec.ts b/src/app/carbon-estimator-form/carbon-estimator-form.component.spec.ts index 921de7c9..6133f1bd 100644 --- a/src/app/carbon-estimator-form/carbon-estimator-form.component.spec.ts +++ b/src/app/carbon-estimator-form/carbon-estimator-form.component.spec.ts @@ -1,24 +1,43 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CarbonEstimatorFormComponent } from './carbon-estimator-form.component'; +import { StorageService } from '../services/storage.service'; + +class MockStorageService { + storage = new Map(); + + get(key: string): string | null { + return this.storage.get(key) ?? null; + } + + set(key: string, value: string): void { + this.storage.set(key, value); + } + + removeItem(key: string): void { + this.storage.delete(key); + } +} describe('CarbonEstimatorFormComponent', () => { let component: CarbonEstimatorFormComponent; let fixture: ComponentFixture; + let storageService: StorageService; beforeEach(async () => { await TestBed.configureTestingModule({ imports: [CarbonEstimatorFormComponent], + providers: [{ provide: StorageService, useClass: MockStorageService }], }).compileComponents(); fixture = TestBed.createComponent(CarbonEstimatorFormComponent); component = fixture.componentInstance; + storageService = TestBed.inject(StorageService); fixture.detectChanges(); }); it('should create component form in a valid state', () => { expect(component).toBeTruthy(); - component.ngOnInit(); expect(component.estimatorForm.valid).toBeTruthy(); }); @@ -32,14 +51,12 @@ describe('CarbonEstimatorFormComponent', () => { describe('Downstream', () => { it('should invalidate form when monthly active users are zero', () => { - component.ngOnInit(); component.estimatorForm.get('downstream.monthlyActiveUsers')?.setValue(0); fixture.detectChanges(); expect(component.estimatorForm.valid).toBeFalsy(); }); it('should validate form when downstream is excluded and monthly active users are zero', () => { - component.ngOnInit(); component.estimatorForm.get('downstream.monthlyActiveUsers')?.setValue(0); component.estimatorForm.get('downstream.noDownstream')?.setValue(true); fixture.detectChanges(); @@ -49,22 +66,35 @@ describe('CarbonEstimatorFormComponent', () => { describe('headCount()', () => { it('should not return null once the component is initialized', () => { - component.ngOnInit(); expect(component.headCount).not.toBeNull(); }); }); describe('numberOfServers()', () => { it('should not return null once the component is initialized', () => { - component.ngOnInit(); expect(component.numberOfServers).not.toBeNull(); }); }); describe('monthlyActiveUsers()', () => { it('should not return null once the component is initialized', () => { - component.ngOnInit(); expect(component.monthlyActiveUsers).not.toBeNull(); }); }); + + describe('form state', () => { + it('should store the form state when the component is destroyed', () => { + spyOn(storageService, 'set'); + component.ngOnDestroy(); + + expect(storageService.set).toHaveBeenCalled(); + }); + + it('should store the state when the page visibility changes', () => { + spyOn(storageService, 'set'); + document.dispatchEvent(new Event('visibilitychange')); + + expect(storageService.set).toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/carbon-estimator-form/carbon-estimator-form.component.ts b/src/app/carbon-estimator-form/carbon-estimator-form.component.ts index f3cc7e90..8c527c87 100644 --- a/src/app/carbon-estimator-form/carbon-estimator-form.component.ts +++ b/src/app/carbon-estimator-form/carbon-estimator-form.component.ts @@ -1,5 +1,15 @@ import { CommonModule, JsonPipe } from '@angular/common'; -import { ChangeDetectorRef, Component, EventEmitter, OnInit, Output, ViewChild, input } from '@angular/core'; +import { + ChangeDetectorRef, + Component, + EventEmitter, + HostListener, + OnDestroy, + OnInit, + Output, + ViewChild, + input, +} from '@angular/core'; import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { EstimatorFormValues, EstimatorValues, WorldLocation, locationArray } from '../types/carbon-estimator'; import { @@ -10,6 +20,8 @@ import { locationDescriptions, ValidationError, errorConfig, + ControlState, + ErrorSummaryState, } from './carbon-estimator-form.constants'; import { NoteComponent } from '../note/note.component'; import { CarbonEstimationService } from '../services/carbon-estimation.service'; @@ -18,6 +30,8 @@ import { FormatCostRangePipe } from '../pipes/format-cost-range.pipe'; import { InvalidatedPipe } from '../pipes/invalidated.pipe'; import { ErrorSummaryComponent } from '../error-summary/error-summary.component'; import { ExternalLinkDirective } from '../directives/external-link.directive'; +import { compareCostRanges } from '../utils/cost-range'; +import { FormStateService } from '../services/form-state.service'; @Component({ selector: 'carbon-estimator-form', @@ -37,7 +51,7 @@ import { ExternalLinkDirective } from '../directives/external-link.directive'; ExternalLinkDirective, ], }) -export class CarbonEstimatorFormComponent implements OnInit { +export class CarbonEstimatorFormComponent implements OnInit, OnDestroy { public formValue = input(); @Output() public formSubmit: EventEmitter = new EventEmitter(); @@ -45,6 +59,13 @@ export class CarbonEstimatorFormComponent implements OnInit { @ViewChild(ErrorSummaryComponent) errorSummary?: ErrorSummaryComponent; + // The visibilitychange event is fired in several scenarios including when the + // user navigates away from the page or switches app on mobile. + @HostListener('document:visibilitychange') + onVisibilityChange(): void { + this.storeFormState(); + } + public estimatorForm!: FormGroup; public formContext = formContext; @@ -73,13 +94,18 @@ export class CarbonEstimatorFormComponent implements OnInit { public questionPanelConfig = questionPanelConfig; public errorConfig = errorConfig; - public showErrorSummary = false; - public validationErrors: ValidationError[] = []; + public errorSummaryState: ErrorSummaryState = { + showErrorSummary: false, + validationErrors: [], + }; + + public compareCostRanges = compareCostRanges; constructor( private formBuilder: FormBuilder, private changeDetector: ChangeDetectorRef, - private estimationService: CarbonEstimationService + private estimationService: CarbonEstimationService, + private formStateService: FormStateService ) {} public ngOnInit() { @@ -161,12 +187,26 @@ export class CarbonEstimatorFormComponent implements OnInit { if (formValue !== undefined) { this.estimatorForm.setValue(formValue); } + + const storedFormState = this.getStoredFormState(); + + if (storedFormState) { + this.estimatorForm.setValue(storedFormState.formValue); + this.setControlStates(storedFormState.controlStates); + this.errorSummaryState = storedFormState.errorSummaryState; + } + } + + ngOnDestroy(): void { + this.storeFormState(); } public handleSubmit() { if (this.estimatorForm.invalid) { - this.validationErrors = this.getValidationErrors(); - this.showErrorSummary = true; + this.errorSummaryState = { + showErrorSummary: true, + validationErrors: this.getValidationErrors(), + }; this.changeDetector.detectChanges(); this.errorSummary?.summary.nativeElement.focus(); return; @@ -188,6 +228,7 @@ export class CarbonEstimatorFormComponent implements OnInit { public resetForm() { this.estimatorForm.reset(); this.resetValidationErrors(); + this.clearStoredFormState(); this.formReset.emit(); } @@ -227,7 +268,25 @@ export class CarbonEstimatorFormComponent implements OnInit { } private resetValidationErrors() { - this.validationErrors = []; - this.showErrorSummary = false; + this.errorSummaryState = { + showErrorSummary: false, + validationErrors: [], + }; + } + + private setControlStates(controlStates: Record) { + this.formStateService.setControlStates(this.estimatorForm, controlStates); + } + + private storeFormState() { + this.formStateService.storeFormState(this.estimatorForm, this.errorSummaryState); + } + + private getStoredFormState() { + return this.formStateService.getStoredFormState(); + } + + private clearStoredFormState() { + this.formStateService.clearStoredFormState(); } } diff --git a/src/app/carbon-estimator-form/carbon-estimator-form.constants.ts b/src/app/carbon-estimator-form/carbon-estimator-form.constants.ts index 3669403f..b656055d 100644 --- a/src/app/carbon-estimator-form/carbon-estimator-form.constants.ts +++ b/src/app/carbon-estimator-form/carbon-estimator-form.constants.ts @@ -1,6 +1,6 @@ +import { FormGroup } from '@angular/forms'; import { ExpansionPanelConfig } from '../expansion-panel/expansion-panel.constants'; -import { CostRange, EstimatorValues } from '../types/carbon-estimator'; -import { WorldLocation } from '../types/carbon-estimator'; +import { CostRange, EstimatorFormValues, EstimatorValues, WorldLocation } from '../types/carbon-estimator'; export const costRanges: CostRange[] = [ { min: 0, max: 1000 }, @@ -129,3 +129,21 @@ export const errorConfig = { errorMessage: 'The number of monthly active users must be greater than 0', }, }; + +export type EstimatorFormRawValue = ReturnType['getRawValue']>; + +export type ControlState = { + dirty: boolean; + touched: boolean; +}; + +export type ErrorSummaryState = { + showErrorSummary: boolean; + validationErrors: ValidationError[]; +}; + +export type FormState = { + formValue: EstimatorFormRawValue; + controlStates: Record; + errorSummaryState: ErrorSummaryState; +}; diff --git a/src/app/services/form-state.service.spec.ts b/src/app/services/form-state.service.spec.ts new file mode 100644 index 00000000..c2371798 --- /dev/null +++ b/src/app/services/form-state.service.spec.ts @@ -0,0 +1,182 @@ +import { TestBed } from '@angular/core/testing'; + +import { FormStateService } from './form-state.service'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { EstimatorFormValues, EstimatorValues, WorldLocation } from '../types/carbon-estimator'; +import { costRanges } from '../carbon-estimator-form/carbon-estimator-form.constants'; + +const formValues: EstimatorValues = { + upstream: { + headCount: 100, + desktopPercentage: 50, + employeeLocation: 'WORLD', + }, + onPremise: { + estimateServerCount: false, + serverLocation: 'WORLD', + numberOfServers: 10, + }, + cloud: { + noCloudServices: false, + cloudLocation: 'WORLD', + cloudPercentage: 50, + monthlyCloudBill: costRanges[0], + }, + downstream: { + noDownstream: false, + customerLocation: 'WORLD', + monthlyActiveUsers: 100, + mobilePercentage: 50, + purposeOfSite: 'average', + }, +}; + +const formBuilder = new FormBuilder(); + +describe('FormStateService', () => { + let service: FormStateService; + let estimatorForm: FormGroup; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(FormStateService); + estimatorForm = formBuilder.nonNullable.group({ + upstream: formBuilder.nonNullable.group({ + headCount: [formValues.upstream.headCount], + desktopPercentage: [formValues.upstream.desktopPercentage], + employeeLocation: [formValues.upstream.employeeLocation], + }), + onPremise: formBuilder.nonNullable.group({ + estimateServerCount: [formValues.onPremise.estimateServerCount], + serverLocation: [formValues.onPremise.serverLocation as WorldLocation | 'unknown'], + numberOfServers: [formValues.onPremise.numberOfServers], + }), + cloud: formBuilder.nonNullable.group({ + noCloudServices: [false], + cloudLocation: [formValues.cloud.cloudLocation as WorldLocation | 'unknown'], + cloudPercentage: [formValues.cloud.cloudPercentage], + monthlyCloudBill: [formValues.cloud.monthlyCloudBill], + }), + downstream: formBuilder.nonNullable.group({ + noDownstream: [false], + customerLocation: [formValues.downstream.customerLocation], + monthlyActiveUsers: [formValues.downstream.monthlyActiveUsers], + mobilePercentage: [formValues.downstream.mobilePercentage], + purposeOfSite: [formValues.downstream.purposeOfSite], + }), + }); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('getControlStates()', () => { + it('should return the states of controls that are marked as dirty or touched', () => { + estimatorForm.get('upstream.headCount')?.markAsDirty(); + estimatorForm.get('upstream.headCount')?.markAsTouched(); + estimatorForm.get('onPremise.serverLocation')?.markAsDirty(); + estimatorForm.get('cloud.cloudLocation')?.markAsTouched(); + + const controlStates = service.getControlStates(estimatorForm); + const expectedControlStates = { + 'upstream.headCount': { + dirty: true, + touched: true, + }, + 'onPremise.serverLocation': { + dirty: true, + touched: false, + }, + 'cloud.cloudLocation': { + dirty: false, + touched: true, + }, + }; + + expect(controlStates).toEqual(expectedControlStates); + }); + }); + + describe('setControlStates()', () => { + const singleControlTestCases = [ + { + controlName: 'upstream.headCount', + dirty: true, + touched: true, + }, + { + controlName: 'upstream.headCount', + dirty: true, + touched: false, + }, + { + controlName: 'upstream.headCount', + dirty: false, + touched: true, + }, + ]; + + singleControlTestCases.forEach(testCase => { + it(`should set the control state on the form when dirty = ${testCase.dirty} and touched = ${testCase.touched}`, () => { + const controlStates = { + [testCase.controlName]: { + dirty: testCase.dirty, + touched: testCase.touched, + }, + }; + + service.setControlStates(estimatorForm, controlStates); + const control = estimatorForm.get(testCase.controlName); + const controlState = { + dirty: control?.dirty, + touched: control?.touched, + }; + const expectedControlState = { + dirty: testCase.dirty, + touched: testCase.touched, + }; + + expect(controlState).toEqual(expectedControlState); + }); + }); + + it('should set all the control states when passed multiple control states', () => { + const controlStatesArg = { + 'upstream.headCount': { + dirty: true, + touched: true, + }, + 'onPremise.serverLocation': { + dirty: true, + touched: false, + }, + 'cloud.cloudLocation': { + dirty: false, + touched: true, + }, + }; + + service.setControlStates(estimatorForm, controlStatesArg); + + const controlStates = { + 'upstream.headCount': { + dirty: estimatorForm.get('upstream.headCount')?.dirty, + touched: estimatorForm.get('upstream.headCount')?.touched, + }, + 'onPremise.serverLocation': { + dirty: estimatorForm.get('onPremise.serverLocation')?.dirty, + touched: estimatorForm.get('onPremise.serverLocation')?.touched, + }, + 'cloud.cloudLocation': { + dirty: estimatorForm.get('cloud.cloudLocation')?.dirty, + touched: estimatorForm.get('cloud.cloudLocation')?.touched, + }, + }; + + const expectedControlStates = controlStatesArg; + + expect(controlStates).toEqual(expectedControlStates); + }); + }); +}); diff --git a/src/app/services/form-state.service.ts b/src/app/services/form-state.service.ts new file mode 100644 index 00000000..0139d5fd --- /dev/null +++ b/src/app/services/form-state.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from '@angular/core'; +import { StorageService } from './storage.service'; +import { FormGroup } from '@angular/forms'; +import { ControlState, ErrorSummaryState, FormState } from '../carbon-estimator-form/carbon-estimator-form.constants'; +import { EstimatorFormValues } from '../types/carbon-estimator'; + +@Injectable({ + providedIn: 'root', +}) +export class FormStateService { + constructor(private storageService: StorageService) {} + + getControlStates(estimatorForm: FormGroup) { + const controlStates: Record = {}; + + for (const [groupName, formGroup] of Object.entries(estimatorForm.controls)) { + for (const [controlName, control] of Object.entries(formGroup.controls)) { + if (control.dirty || control.touched) { + controlStates[`${groupName}.${controlName}`] = { + dirty: control.dirty, + touched: control.touched, + }; + } + } + } + + return controlStates; + } + + setControlStates(estimatorForm: FormGroup, controlStates: Record) { + for (const [controlKey, controlState] of Object.entries(controlStates)) { + const control = estimatorForm.get(controlKey); + if (controlState.dirty) { + control?.markAsDirty(); + } + if (controlState.touched) { + control?.markAsTouched(); + } + } + } + + storeFormState(estimatorForm: FormGroup, errorSummaryState: ErrorSummaryState) { + const formState: FormState = { + formValue: estimatorForm.getRawValue(), + controlStates: this.getControlStates(estimatorForm), + errorSummaryState, + }; + this.storageService.set('formState', JSON.stringify(formState)); + } + + getStoredFormState() { + const storedFormState = this.storageService.get('formState'); + return storedFormState ? (JSON.parse(storedFormState) as FormState) : null; + } + + clearStoredFormState() { + this.storageService.removeItem('formState'); + } +} diff --git a/src/app/services/storage.service.spec.ts b/src/app/services/storage.service.spec.ts new file mode 100644 index 00000000..e7fe5b53 --- /dev/null +++ b/src/app/services/storage.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { StorageService } from './storage.service'; + +describe('StorageService', () => { + let service: StorageService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(StorageService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/services/storage.service.ts b/src/app/services/storage.service.ts new file mode 100644 index 00000000..394596a3 --- /dev/null +++ b/src/app/services/storage.service.ts @@ -0,0 +1,25 @@ +import { Inject, Injectable, InjectionToken } from '@angular/core'; + +export const BROWSER_STORAGE = new InjectionToken('Browser Storage', { + providedIn: 'root', + factory: () => sessionStorage, +}); + +@Injectable({ + providedIn: 'root', +}) +export class StorageService { + constructor(@Inject(BROWSER_STORAGE) public storage: Storage) {} + + get(key: string): string | null { + return this.storage.getItem(key); + } + + set(key: string, value: string): void { + this.storage.setItem(key, value); + } + + removeItem(key: string): void { + this.storage.removeItem(key); + } +} diff --git a/src/app/utils/cost-range.spec.ts b/src/app/utils/cost-range.spec.ts new file mode 100644 index 00000000..a7f0fd05 --- /dev/null +++ b/src/app/utils/cost-range.spec.ts @@ -0,0 +1,43 @@ +import { CostRange } from '../types/carbon-estimator'; +import { compareCostRanges } from './cost-range'; + +describe('compareCostRanges', () => { + it('should return true when the cost ranges are the same', () => { + const range1: CostRange = { + min: 10, + max: 20, + }; + const range2: CostRange = { + min: 10, + max: 20, + }; + + expect(compareCostRanges(range1, range2)).toBeTrue(); + }); + + it('should return false when the cost ranges have different min values', () => { + const range1: CostRange = { + min: 5, + max: 20, + }; + const range2: CostRange = { + min: 10, + max: 20, + }; + + expect(compareCostRanges(range1, range2)).toBeFalse(); + }); + + it('should return false when the cost ranges have different max values', () => { + const range1: CostRange = { + min: 10, + max: 20, + }; + const range2: CostRange = { + min: 10, + max: 30, + }; + + expect(compareCostRanges(range1, range2)).toBeFalse(); + }); +}); diff --git a/src/app/utils/cost-range.ts b/src/app/utils/cost-range.ts new file mode 100644 index 00000000..a5d99232 --- /dev/null +++ b/src/app/utils/cost-range.ts @@ -0,0 +1,5 @@ +import { CostRange } from '../types/carbon-estimator'; + +export const compareCostRanges = (r1: CostRange, r2: CostRange) => { + return r1.min === r2.min && r1.max === r2.max; +};