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-128: Add error summary for validation errors #118

Merged
merged 17 commits into from
Aug 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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">
jmain-scottlogic marked this conversation as resolved.
Show resolved Hide resolved
<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>
jmain-scottlogic marked this conversation as resolved.
Show resolved Hide resolved
<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,21 +3,31 @@
@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;
}