Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SFD-171: Persist form state #121

Merged
merged 30 commits into from
Aug 13, 2024
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e909b97
Add storage service
jantoun-scottlogic Jul 31, 2024
f34c3cb
Store form data on value change
jantoun-scottlogic Jul 31, 2024
802b31a
Reduce number of form saves
jantoun-scottlogic Jul 31, 2024
6fc2043
Restore stored data after input sets data
jantoun-scottlogic Aug 1, 2024
1c45e04
Save raw form value
jantoun-scottlogic Aug 1, 2024
5aa6fc5
Add comparator for CostRange
jantoun-scottlogic Aug 1, 2024
5c2b588
Add method to get saved data
jantoun-scottlogic Aug 1, 2024
dbd8d29
Save control states
jantoun-scottlogic Aug 2, 2024
72f6622
Restore stored control states on init
jantoun-scottlogic Aug 2, 2024
11cb1be
Rename storage methods
jantoun-scottlogic Aug 2, 2024
8748ee7
Remove onInit calls from tests
jantoun-scottlogic Aug 2, 2024
7969e63
Merge branch 'main' into SFD-171-persist-form-state
jantoun-scottlogic Aug 5, 2024
7645c27
Mock storage service in tests
jantoun-scottlogic Aug 5, 2024
9911d9d
Add explicit return types to storage service methods
jantoun-scottlogic Aug 5, 2024
26c2bff
Merge branch 'main' into SFD-171-persist-form-state
jantoun-scottlogic Aug 5, 2024
44e66d8
Move HostListener to top of class
jantoun-scottlogic Aug 5, 2024
56da1df
Persist error summary state
jantoun-scottlogic Aug 5, 2024
67c1f79
Store state in single storage variable
jantoun-scottlogic Aug 6, 2024
9cfdf2d
Add setControlStates method
jantoun-scottlogic Aug 6, 2024
31b33bc
Clear stored state on form reset
jantoun-scottlogic Aug 6, 2024
83a80e3
Use Map in MockStorageService
jantoun-scottlogic Aug 6, 2024
1cb72f5
Refactor storeFormState
jantoun-scottlogic Aug 6, 2024
5c27f3d
Refactor error summary state
jantoun-scottlogic Aug 6, 2024
54572af
Add form state service
jantoun-scottlogic Aug 7, 2024
bdb7204
Add tests for control state methods
jantoun-scottlogic Aug 7, 2024
4b2cdaf
Use ?? operator in mock storage service
jantoun-scottlogic Aug 7, 2024
0171e48
Add tests for form storage
jantoun-scottlogic Aug 8, 2024
69679ef
Remove '' from beforeunload listener
jantoun-scottlogic Aug 8, 2024
6a5ab6d
Use visibilitychange instead of beforeunload
jantoun-scottlogic Aug 8, 2024
8dd21a1
Merge branch 'main' into SFD-171-persist-form-state
jantoun-scottlogic Aug 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
<option [ngValue]="range">{{ range | formatCostRange }}</option>
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
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<string, string>();

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;
Expand All @@ -9,6 +26,7 @@ describe('CarbonEstimatorFormComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CarbonEstimatorFormComponent],
providers: [{ provide: StorageService, useClass: MockStorageService }],
}).compileComponents();

fixture = TestBed.createComponent(CarbonEstimatorFormComponent);
Expand All @@ -18,7 +36,6 @@ describe('CarbonEstimatorFormComponent', () => {

it('should create component form in a valid state', () => {
expect(component).toBeTruthy();
component.ngOnInit();
jantoun-scottlogic marked this conversation as resolved.
Show resolved Hide resolved
expect(component.estimatorForm.valid).toBeTruthy();
});

Expand All @@ -32,14 +49,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();
Expand All @@ -49,21 +64,18 @@ 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();
});
});
Expand Down
96 changes: 93 additions & 3 deletions src/app/carbon-estimator-form/carbon-estimator-form.component.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -10,6 +20,9 @@ import {
locationDescriptions,
ValidationError,
errorConfig,
ControlState,
ErrorSummaryState,
FormState,
} from './carbon-estimator-form.constants';
import { NoteComponent } from '../note/note.component';
import { CarbonEstimationService } from '../services/carbon-estimation.service';
Expand All @@ -18,6 +31,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 { StorageService } from '../services/storage.service';
import { compareCostRanges } from '../utils/cost-range';

@Component({
selector: 'carbon-estimator-form',
Expand All @@ -37,14 +52,19 @@ import { ExternalLinkDirective } from '../directives/external-link.directive';
ExternalLinkDirective,
],
})
export class CarbonEstimatorFormComponent implements OnInit {
export class CarbonEstimatorFormComponent implements OnInit, OnDestroy {
public formValue = input<EstimatorValues>();

@Output() public formSubmit: EventEmitter<EstimatorValues> = new EventEmitter<EstimatorValues>();
@Output() public formReset: EventEmitter<void> = new EventEmitter();

@ViewChild(ErrorSummaryComponent) errorSummary?: ErrorSummaryComponent;

@HostListener('window:beforeunload', ['$event'])
onBeforeUnload(): void {
this.storeFormState();
}

public estimatorForm!: FormGroup<EstimatorFormValues>;

public formContext = formContext;
Expand Down Expand Up @@ -76,10 +96,13 @@ export class CarbonEstimatorFormComponent implements OnInit {
public showErrorSummary = false;
public validationErrors: ValidationError[] = [];

public compareCostRanges = compareCostRanges;

constructor(
private formBuilder: FormBuilder,
private changeDetector: ChangeDetectorRef,
private estimationService: CarbonEstimationService
private estimationService: CarbonEstimationService,
private storageService: StorageService
) {}

public ngOnInit() {
Expand Down Expand Up @@ -161,6 +184,19 @@ 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.showErrorSummary = storedFormState.errorSummaryState.showErrorSummary;
this.validationErrors = storedFormState.errorSummaryState.validationErrors;
}
}

ngOnDestroy(): void {
this.storeFormState();
}

public handleSubmit() {
Expand All @@ -187,6 +223,7 @@ export class CarbonEstimatorFormComponent implements OnInit {

public resetForm() {
this.estimatorForm.reset();
this.clearStoredFormState();
this.formReset.emit();
}

Expand Down Expand Up @@ -224,4 +261,57 @@ export class CarbonEstimatorFormComponent implements OnInit {

return validationErrors;
}

private getControlStates() {
const controlStates: Record<string, ControlState> = {};

for (const [groupName, formGroup] of Object.entries(this.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;
}

private setControlStates(controlStates: Record<string, ControlState>) {
for (const [controlKey, controlState] of Object.entries(controlStates)) {
const control = this.estimatorForm.get(controlKey);
if (controlState.dirty) {
control?.markAsDirty();
}
if (controlState.touched) {
control?.markAsTouched();
}
}
}

private storeFormState() {
const formValue = this.estimatorForm.getRawValue();
const controlStates = this.getControlStates();
const errorSummaryState: ErrorSummaryState = {
showErrorSummary: this.showErrorSummary,
validationErrors: this.validationErrors,
};
const formState: FormState = {
formValue,
controlStates,
errorSummaryState,
};
this.storageService.set('formState', JSON.stringify(formState));
}

private getStoredFormState() {
const storedFormState = this.storageService.get('formState');
return storedFormState ? (JSON.parse(storedFormState) as FormState) : null;
}

private clearStoredFormState() {
this.storageService.removeItem('formState');
}
}
22 changes: 20 additions & 2 deletions src/app/carbon-estimator-form/carbon-estimator-form.constants.ts
Original file line number Diff line number Diff line change
@@ -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 },
Expand Down Expand Up @@ -129,3 +129,21 @@ export const errorConfig = {
errorMessage: 'The number of monthly active users must be greater than 0',
},
};

export type EstimatorFormRawValue = ReturnType<FormGroup<EstimatorFormValues>['getRawValue']>;

export type ControlState = {
dirty: boolean;
touched: boolean;
};

export type ErrorSummaryState = {
showErrorSummary: boolean;
validationErrors: ValidationError[];
};

export type FormState = {
formValue: EstimatorFormRawValue;
controlStates: Record<string, ControlState>;
errorSummaryState: ErrorSummaryState;
};
16 changes: 16 additions & 0 deletions src/app/services/storage.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
25 changes: 25 additions & 0 deletions src/app/services/storage.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Inject, Injectable, InjectionToken } from '@angular/core';

export const BROWSER_STORAGE = new InjectionToken<Storage>('Browser Storage', {
jantoun-scottlogic marked this conversation as resolved.
Show resolved Hide resolved
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);
}
}
43 changes: 43 additions & 0 deletions src/app/utils/cost-range.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
5 changes: 5 additions & 0 deletions src/app/utils/cost-range.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading