Skip to content

Commit

Permalink
SFD-128: Add error summary for validation errors (#118)
Browse files Browse the repository at this point in the history
* Add error summary component

* Move validation error messages to variable and add getter for errors

* Show error summary on submission of invalid form

* Enable submit button when form invalid

If the form is invalid the error summary will be shown.

* Focus error summary on invalid submission

* Do not update error summary until resubmission

* Style error summary

* Remove full stops from error messages

* Refactor error config

* Add dark mode styles

* Remove extra monthly active users inline error message

* Remove explicit typing from errorConfig

* Add unit test

* Refactor handleSubmit

* Delete empty css file

* Add class to center error text in error flex boxes

* Move type and constants to constants.ts
  • Loading branch information
jantoun-scottlogic authored Aug 5, 2024
1 parent a7a53ab commit f2cfaa3
Show file tree
Hide file tree
Showing 9 changed files with 182 additions and 64 deletions.
51 changes: 14 additions & 37 deletions src/app/carbon-estimator-form/carbon-estimator-form.component.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<form (ngSubmit)="handleSubmit()" [formGroup]="estimatorForm" class="tce-w-full tce-flex tce-flex-col tce-gap-6">
<div formGroupName="upstream">
@if (showErrorSummary) {
<error-summary [validationErrors]="validationErrors"></error-summary>
}

<ng-container *ngTemplateOutlet="sectionHeader; context: formContext.upstream"></ng-container>

<div class="tce-flex tce-flex-col tce-gap-4">
Expand All @@ -14,9 +18,9 @@
required
[attr.aria-describedby]="(headCount | invalidated) ? 'headCountError' : null" />
@if (headCount | invalidated) {
<div class="tce-error-box tce-flex tce-gap-1" aria-live="polite" id="headCountError">
<div class="tce-error-box tce-flex tce-items-center tce-gap-1" aria-live="polite" id="headCountError">
<span class="material-icons-outlined">error</span>
<p>The number of employees must be greater than 0.</p>
<p>{{ errorConfig.headCount.errorMessage }}</p>
</div>
}
</div>
Expand Down Expand Up @@ -81,9 +85,9 @@
required
[attr.aria-describedby]="(numberOfServers | invalidated) ? 'numberOfServersError' : null" />
@if (numberOfServers | invalidated) {
<div class="tce-error-box tce-flex tce-gap-1" aria-live="polite" id="numberOfServersError">
<div class="tce-error-box tce-flex tce-items-center tce-gap-1" aria-live="polite" id="numberOfServersError">
<span class="material-icons-outlined">error</span>
<p>The number of servers must be greater than or equal to 0.</p>
<p>{{ errorConfig.numberOfServers.errorMessage }}</p>
</div>
}
</div>
Expand Down Expand Up @@ -210,12 +214,12 @@
required
[attr.aria-describedby]="(monthlyActiveUsers | invalidated) ? 'monthlyActiveUsersError' : null" />
@if (monthlyActiveUsers | invalidated) {
<div class="tce-error-box tce-flex tce-gap-1" aria-live="polite" id="monthlyActiveUsersError">
<div
class="tce-error-box tce-flex tce-items-center tce-gap-1"
aria-live="polite"
id="monthlyActiveUsersError">
<span class="material-icons-outlined">error</span>
<p>
Monthly active users must be greater than 0. To specify no external users, use the
<a class="tce-underline" href="#noDownstream">checkbox</a> above.
</p>
<p>{{ errorConfig.monthlyActiveUsers.errorMessage }}</p>
</div>
}
</div>
Expand Down Expand Up @@ -258,35 +262,8 @@
<div>
<div class="tce-flex tce-gap-4 tce-justify-end">
<button class="tce-button-reset tce-px-3 tce-py-2" type="button" (click)="resetForm()">Reset</button>
<button
class="tce-button-calculate tce-px-3 tce-py-2"
[ngClass]="{ 'tce-opacity-50 tce-cursor-not-allowed': estimatorForm.invalid }"
type="submit"
[attr.aria-disabled]="estimatorForm.invalid"
[attr.aria-describedby]="(estimatorForm | invalidated) ? 'calculateDisabledMessage' : null">
Calculate
</button>
<button class="tce-button-calculate tce-px-3 tce-py-2" type="submit">Calculate</button>
</div>
@if (estimatorForm | invalidated) {
<div
class="tce-error-box tce-flex tce-gap-1 tce-justify-end tce-mt-2"
aria-live="polite"
id="calculateDisabledMessage">
<span class="material-icons-outlined">error</span>
<p>
Unable to calculate emissions because the following fields are invalid:
@if (headCount?.invalid) {
<a class="tce-underline" href="#headCount">number of employees</a>&nbsp;
}
@if (numberOfServers?.invalid) {
<a class="tce-underline" href="#numberOfServers">number of servers</a>&nbsp;
}
@if (monthlyActiveUsers?.invalid) {
<a class="tce-underline" href="#monthlyActiveUsers">monthly active users</a>
}
</p>
</div>
}
</div>

<ng-template
Expand Down
53 changes: 39 additions & 14 deletions src/app/carbon-estimator-form/carbon-estimator-form.component.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,22 @@
import { CommonModule, JsonPipe } from '@angular/common';
import { ChangeDetectorRef, Component, EventEmitter, OnInit, Output, input } from '@angular/core';
import { ChangeDetectorRef, Component, EventEmitter, 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 { costRanges, defaultValues, formContext, questionPanelConfig } from './carbon-estimator-form.constants';
import {
costRanges,
defaultValues,
formContext,
questionPanelConfig,
locationDescriptions,
ValidationError,
errorConfig,
} from './carbon-estimator-form.constants';
import { NoteComponent } from '../note/note.component';
import { CarbonEstimationService } from '../services/carbon-estimation.service';
import { ExpansionPanelComponent } from '../expansion-panel/expansion-panel.component';
import { FormatCostRangePipe } from '../pipes/format-cost-range.pipe';
import { InvalidatedPipe } from '../pipes/invalidated.pipe';

const locationDescriptions: Record<WorldLocation, string> = {
WORLD: 'Globally',
'NORTH AMERICA': 'in North America',
EUROPE: 'in Europe',
GBR: 'in the UK',
ASIA: 'in Asia',
AFRICA: 'in Africa',
OCEANIA: 'in Oceania',
'LATIN AMERICA AND CARIBBEAN': 'in Latin America or the Caribbean',
};
import { ErrorSummaryComponent } from '../error-summary/error-summary.component';

@Component({
selector: 'carbon-estimator-form',
Expand All @@ -34,6 +32,7 @@ const locationDescriptions: Record<WorldLocation, string> = {
ExpansionPanelComponent,
FormatCostRangePipe,
InvalidatedPipe,
ErrorSummaryComponent,
],
})
export class CarbonEstimatorFormComponent implements OnInit {
Expand All @@ -42,6 +41,8 @@ export class CarbonEstimatorFormComponent implements OnInit {
@Output() public formSubmit: EventEmitter<EstimatorValues> = new EventEmitter<EstimatorValues>();
@Output() public formReset: EventEmitter<void> = new EventEmitter();

@ViewChild(ErrorSummaryComponent) errorSummary?: ErrorSummaryComponent;

public estimatorForm!: FormGroup<EstimatorFormValues>;

public formContext = formContext;
Expand Down Expand Up @@ -69,6 +70,10 @@ export class CarbonEstimatorFormComponent implements OnInit {

public questionPanelConfig = questionPanelConfig;

public errorConfig = errorConfig;
public showErrorSummary = false;
public validationErrors: ValidationError[] = [];

constructor(
private formBuilder: FormBuilder,
private changeDetector: ChangeDetectorRef,
Expand Down Expand Up @@ -157,9 +162,14 @@ export class CarbonEstimatorFormComponent implements OnInit {
}

public handleSubmit() {
if (!this.estimatorForm.valid) {
if (this.estimatorForm.invalid) {
this.validationErrors = this.getValidationErrors();
this.showErrorSummary = true;
this.changeDetector.detectChanges();
this.errorSummary?.summary.nativeElement.focus();
return;
}
this.showErrorSummary = false;
const formValue = this.estimatorForm.getRawValue();
if (formValue.onPremise.serverLocation === 'unknown') {
formValue.onPremise.serverLocation = 'WORLD';
Expand Down Expand Up @@ -197,4 +207,19 @@ export class CarbonEstimatorFormComponent implements OnInit {
);
}
}

private getValidationErrors() {
const validationErrors: ValidationError[] = [];
if (this.headCount?.invalid) {
validationErrors.push(this.errorConfig.headCount);
}
if (this.numberOfServers?.invalid) {
validationErrors.push(this.errorConfig.numberOfServers);
}
if (this.monthlyActiveUsers?.invalid) {
validationErrors.push(this.errorConfig.monthlyActiveUsers);
}

return validationErrors;
}
}
32 changes: 32 additions & 0 deletions src/app/carbon-estimator-form/carbon-estimator-form.constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ExpansionPanelConfig } from '../expansion-panel/expansion-panel.constants';
import { CostRange, EstimatorValues } from '../types/carbon-estimator';
import { WorldLocation } from '../types/carbon-estimator';

export const costRanges: CostRange[] = [
{ min: 0, max: 1000 },
Expand Down Expand Up @@ -97,3 +98,34 @@ export const questionPanelConfig: ExpansionPanelConfig = {
buttonStyles: 'material-icons-outlined tce-text-base hover:tce-bg-slate-200 hover:tce-rounded',
contentContainerStyles: 'tce-px-3 tce-py-2 tce-bg-slate-100 tce-border tce-border-slate-400 tce-rounded tce-text-sm',
};

export const locationDescriptions: Record<WorldLocation, string> = {
WORLD: 'Globally',
'NORTH AMERICA': 'in North America',
EUROPE: 'in Europe',
GBR: 'in the UK',
ASIA: 'in Asia',
AFRICA: 'in Africa',
OCEANIA: 'in Oceania',
'LATIN AMERICA AND CARIBBEAN': 'in Latin America or the Caribbean',
};

export type ValidationError = {
inputId: string;
errorMessage: string;
};

export const errorConfig = {
headCount: {
inputId: 'headCount',
errorMessage: 'The number of employees must be greater than 0',
},
numberOfServers: {
inputId: 'numberOfServers',
errorMessage: 'The number of servers must be greater than or equal to 0',
},
monthlyActiveUsers: {
inputId: 'monthlyActiveUsers',
errorMessage: 'The number of monthly active users must be greater than 0',
},
};
10 changes: 10 additions & 0 deletions src/app/error-summary/error-summary.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<div #errorSummary tabindex="-1" class="tce-error-summary tce-border-4 tce-rounded tce-p-3 tce-mt-2 tce-mb-5">
<h2 class="tce-text-2xl"><strong>There is a problem</strong></h2>
<div class="tce-flex tce-flex-col tce-gap-1 tce-mt-1">
@for (error of validationErrors(); track $index) {
<a class="tce-error-summary-link tce-underline" href="#{{ error.inputId }}"
><strong>{{ error.errorMessage }}</strong></a
>
}
</div>
</div>
42 changes: 42 additions & 0 deletions src/app/error-summary/error-summary.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { ErrorSummaryComponent } from './error-summary.component';
import { ValidationError } from '../carbon-estimator-form/carbon-estimator-form.constants';

describe('ErrorSummaryComponent', () => {
let component: ErrorSummaryComponent;
let fixture: ComponentFixture<ErrorSummaryComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ErrorSummaryComponent],
}).compileComponents();

fixture = TestBed.createComponent(ErrorSummaryComponent);
component = fixture.componentInstance;

const validationErrors: ValidationError[] = [
{
inputId: 'input1',
errorMessage: 'Input 1 must be greater than 0',
},
{
inputId: 'input2',
errorMessage: 'Input 2 must be greater than 0',
},
];

fixture.componentRef.setInput('validationErrors', validationErrors);

fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});

it('should display validation error messages', () => {
expect(fixture.nativeElement.textContent).toContain('Input 1 must be greater than 0');
expect(fixture.nativeElement.textContent).toContain('Input 2 must be greater than 0');
});
});
13 changes: 13 additions & 0 deletions src/app/error-summary/error-summary.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Component, ElementRef, input, ViewChild } from '@angular/core';
import { ValidationError } from '../carbon-estimator-form/carbon-estimator-form.constants';

@Component({
selector: 'error-summary',
standalone: true,
imports: [],
templateUrl: './error-summary.component.html',
})
export class ErrorSummaryComponent {
validationErrors = input.required<ValidationError[]>();
@ViewChild('errorSummary') summary!: ElementRef<HTMLDivElement>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ <h1 class="tce-text-3xl tce-mb-6">Technology Carbon Estimator</h1>
aria-live="polite"
(closeEvent)="closeAssumptionsAndLimitation($event)"></assumptions-and-limitation>
} @else {
<div class="tce-flex tce-justify-end tce-pb-4 -tce-mt-2">
<div class="tce-flex tce-justify-end tce-mb-4 -tce-mt-2">
<button
#showAssumptionsLimitationButton
class="tce-button-assumptions tce-px-3 tce-py-2 tce-w-fit tce-self-end"
Expand Down
19 changes: 14 additions & 5 deletions src/package-styles.css
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
@media (prefers-color-scheme: dark) {
.apexcharts-legend-text {
@apply !tce-text-slate-50
@apply !tce-text-slate-50;
}

input, select {
@apply tce-text-slate-600
input,
select {
@apply tce-text-slate-600;
}

input.ng-invalid.ng-touched {
@apply tce-border-red-700
@apply tce-border-red-700;
}

.tce-error-box {
@apply tce-text-white tce-bg-red-700 tce-p-1 tce-rounded tce-border tce-border-white
@apply tce-text-white tce-bg-red-700 tce-p-1 tce-rounded tce-border tce-border-white;
}

.tce-error-summary {
@apply tce-text-white tce-bg-red-700 tce-border-white;
}

.tce-error-summary-link {
@apply tce-text-white;
}
}
24 changes: 17 additions & 7 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,33 @@
@tailwind utilities;

input.ng-invalid.ng-touched {
@apply tce-border-red-600 tce-border-2 tce-m-[-1px]
@apply tce-border-red-600 tce-border-2 tce-m-[-1px];
}

.tce-error-box {
@apply tce-text-red-600
@apply tce-text-red-600;
}

.tce-error-summary {
@apply tce-border-red-600;
}

.tce-error-summary-link {
@apply tce-text-red-600;
}

.tce-note {
@apply tce-bg-sky-200 tce-border-sky-400 tce-text-slate-800
@apply tce-bg-sky-200 tce-border-sky-400 tce-text-slate-800;
}

.tce-button-calculate, .tce-button-close {
@apply tce-bg-sky-800 tce-text-white hover:tce-bg-sky-900 tce-rounded
.tce-button-calculate,
.tce-button-close {
@apply tce-bg-sky-800 tce-text-white hover:tce-bg-sky-900 tce-rounded;
}

.tce-button-reset, .tce-button-assumptions {
@apply tce-bg-slate-200 tce-text-slate-800 hover:tce-bg-slate-300 tce-rounded
.tce-button-reset,
.tce-button-assumptions {
@apply tce-bg-slate-200 tce-text-slate-800 hover:tce-bg-slate-300 tce-rounded;
}


Expand Down

0 comments on commit f2cfaa3

Please sign in to comment.