Skip to content

Commit 6dd45ba

Browse files
authored
Apply validation functions to mapping. (#192)
Use the attribute-types to ensure that rows are validated according to the type and required fields when performing the validation. This should generate errors and warnings for the review page. In addition to these changes, the warning/errors are defined such that validation failures are errors, but structural errors (such as missing rows, or mismatched row lengths) are recorded as warnings. For errors, it is now possible to request the row data in code for any errors, which in future will allow the review page to show exactly where the errors occur. To achieve this the column index will need to be available to the frontend in the same way that the row index is, currently there's no simple way to map the column name where the error occurred to the index in the error data (see #199) Also resolves #191 as it now shows an error summary when a required field is not used in the mapping step.
1 parent 361851b commit 6dd45ba

16 files changed

Lines changed: 793 additions & 217 deletions

File tree

fixtures/validation-struct-err.csv

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Employee number,Title,First name,Surname,Employment start date,Salary,Contribution percentage,Payment date
2+
1,Mr,Jon,Arbuckle,2025-01-01,,,2025-04-01
3+
,,,,,,,,
4+
,,,,,,,,
5+
2,Mr,Odie,Arbuckle,2025-02-01,123,12%,2025-04-01

fixtures/validation-test.csv

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Employee number,Title,First name,Surname,Employment start date,Salary,Contribution percentage,Payment date
2+
1,Mr,Jon,Arbuckle,2025-01-01,,,2025-04-01
3+
2,Mr,Odie,Arbuckle,2025-02-01,123,12%,2025-04-01

lib/importer/nunjucks/importer/macros/field_mapper.njk

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,33 @@
1717
{% macro importerFieldMapper(data, caption='', columnTitle='Column', examplesTitle='Example values', fieldsTitle='Fields') %}
1818
{% set fields = data['importer.session']['fields'] %}
1919
{% set headings = importerGetHeaders(data) %}
20-
{% set error = headings.error %}
20+
{% set headingError = headings.error %}
21+
{% set error = importerError(data) %}
2122

22-
{% if error %}
23+
{% if headingError %}
2324
<p id="mapping-error" class="govuk-error-message">
24-
<span class="govuk-visually-hidden">Error:</span> {{ error.text }}
25+
<span class="govuk-visually-hidden">Error:</span> {{ headingError.text }}
2526
</p>
2627
{% endif %}
2728

29+
{% if error %}
30+
<div class="govuk-error-summary" data-module="govuk-error-summary">
31+
<div role="alert">
32+
<h2 class="govuk-error-summary__title">
33+
{{ error.text }}
34+
</h2>
35+
<div class="govuk-error-summary__body">
36+
<ul class="govuk-list govuk-error-summary__list">
37+
{% for e in error.extra %}
38+
<li>
39+
<a href="#field-{{ e | slugify }}">{{ e }}</a>
40+
</li>
41+
{% endfor %}
42+
</ul>
43+
</div>
44+
</div>
45+
</div>
46+
{% endif %}
2847

2948
<table class="govuk-table">
3049
{% if caption %}
@@ -39,14 +58,17 @@
3958
</thead>
4059
<tbody class="govuk-table__body">
4160
{% for h in headings.data %}
42-
<tr class="govuk-table__row">
61+
{% set hIndex = loop.index0 %}
62+
{% set currentValue = importerErrorMappingData(error, hIndex) %}
63+
64+
<tr class="govuk-table__row" id="field-{{ h.name | slugify }}">
4365
<th scope="row" class="govuk-table__header">{{ h.name }}</th>
4466
<td class="govuk-table__cell">{{ h.examples }}</td>
4567
<td class="govuk-table__cell govuk-table__cell--numeric">
4668
<select class="govuk-select" style="float: right;" name="{{h.index}}">
4769
<option name=""></option>
4870
{% for field in fields %}
49-
<option value="{{field.name}}">{{field.name}}</option>
71+
<option value="{{field.name}}" {% if field.name == currentValue %}selected{% endif%}>{{field.name}}</option>
5072
{% endfor %}
5173
</select>
5274
</td>

lib/importer/src/attribute-types.js

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,13 @@
55
// An attribute type is a function from an input value to a result.
66

77
class AttributeMappingResult {
8-
constructor(value, warnings, errors) {
8+
constructor(value, errors) {
99
this.value = value;
10-
this.warnings = warnings || [];
11-
this.errors = errors;
10+
this.errors = errors || [];
1211
}
1312

1413
get valid() {
15-
return !this.errors;
14+
return this.errors.length == 0;
1615
}
1716

1817
get empty() {
@@ -22,20 +21,20 @@ class AttributeMappingResult {
2221

2322
// These helpers define the three kinds of results:
2423

25-
function emptyMapping(warnings) {
26-
return new AttributeMappingResult(undefined, warnings);
24+
function emptyMapping(errors) {
25+
return new AttributeMappingResult(undefined, errors);
2726
}
2827

29-
function successfulMapping(outputValue, warnings) {
30-
return new AttributeMappingResult(outputValue, warnings);
28+
function successfulMapping(outputValue, errors) {
29+
return new AttributeMappingResult(outputValue, errors);
3130
}
3231

33-
function failedMapping(warnings, errors) {
34-
return new AttributeMappingResult(undefined, warnings, errors);
32+
function failedMapping(errors) {
33+
return new AttributeMappingResult(undefined, errors);
3534
}
3635

3736
// Create an optional version of an existing type, that allows empty strings or
38-
// undefined values and maps then to "undefined", and converts any validation errors to warnings
37+
// undefined values and maps then to "undefined"
3938
exports.optionalType = (baseType) => {
4039
return (inputValue) => {
4140
if (inputValue !== undefined && inputValue != "") {
@@ -44,7 +43,7 @@ exports.optionalType = (baseType) => {
4443
return result;
4544
} else {
4645
// Convert any errors into warnings, as this is an optional field
47-
return emptyMapping(result.warnings.concat(result.errors));
46+
return emptyMapping(result.errors);
4847
}
4948
}
5049
else {
@@ -60,7 +59,7 @@ exports.requiredType = (baseType) => {
6059
return baseType(inputValue);
6160
}
6261
else {
63-
return failedMapping([], ["A value must be provided"]);
62+
return failedMapping(["A value must be provided"]);
6463
}
6564
};
6665
}
@@ -74,19 +73,28 @@ exports.basicStringType = (inputValue) => {
7473
exports.basicNumberType = (inputValue) => {
7574
const result = parseFloat(inputValue);
7675
if (isNaN(result)) {
77-
return failedMapping([], ["This is not a valid number"]);
76+
return failedMapping(["This is not a valid number"]);
7877
} else {
7978
return successfulMapping(result);
8079
}
8180
};
8281

83-
exports.typeFromName = (name) => {
84-
if (!name) return null
82+
exports.mapperForField = (field) => {
83+
if (!field) return null
8584

86-
switch (name.toLowerCase()) {
85+
let m = exports.basicStringType;
86+
87+
switch (field.type.toLowerCase()) {
8788
case "number":
88-
return exports.basicNumberType
89+
m = exports.basicNumberType
90+
break
8991
default:
90-
return exports.basicStringType
92+
m = exports.basicStringType
93+
}
94+
95+
if (field.required) {
96+
return exports.requiredType(m)
9197
}
98+
99+
return exports.optionalType(m)
92100
}

lib/importer/src/attribute-types.test.js

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,21 @@ test('required basic string', () => {
66

77
let r = t(undefined);
88
expect(r.valid).toBeFalsy();
9-
expect(r.warnings).toMatchObject([]);
109
expect(r.errors).toMatchObject(["A value must be provided"]);
1110

1211
r = t("");
1312
expect(r.valid).toBeFalsy();
14-
expect(r.warnings).toMatchObject([]);
1513
expect(r.errors).toMatchObject(["A value must be provided"]);
1614

1715
r = t("foo");
1816
expect(r.valid).toBeTruthy();
1917
expect(r.value).toEqual("foo");
20-
expect(r.warnings).toMatchObject([]);
18+
expect(r.errors).toMatchObject([]);
2119

2220
r = t(123);
2321
expect(r.valid).toBeTruthy();
2422
expect(r.value).toEqual("123");
25-
expect(r.warnings).toMatchObject([]);
23+
expect(r.errors).toMatchObject([]);
2624
});
2725

2826
test('optional basic string', () => {
@@ -31,50 +29,49 @@ test('optional basic string', () => {
3129

3230
let r = t(undefined);
3331
expect(r.valid).toBeTruthy();
34-
expect(r.warnings).toMatchObject([]);
32+
expect(r.errors).toMatchObject([]);
3533
expect(r.value).toEqual(undefined);
3634

3735
r = t("");
3836
expect(r.valid).toBeTruthy();
39-
expect(r.warnings).toMatchObject([]);
37+
expect(r.errors).toMatchObject([]);
4038
expect(r.value).toEqual(undefined);
4139

4240
r = t("foo");
4341
expect(r.valid).toBeTruthy();
4442
expect(r.value).toEqual("foo");
45-
expect(r.warnings).toMatchObject([]);
43+
expect(r.errors).toMatchObject([]);
4644

4745
r = t(123);
4846
expect(r.valid).toBeTruthy();
4947
expect(r.value).toEqual("123");
50-
expect(r.warnings).toMatchObject([]);
48+
expect(r.errors).toMatchObject([]);
5149
});
5250

5351
test('required basic number', () => {
5452
const t = attributeTypes.requiredType(attributeTypes.basicNumberType);
5553

5654
let r = t("foo");
5755
expect(r.valid).toBeFalsy();
58-
expect(r.warnings).toMatchObject([]);
5956
expect(r.errors).toMatchObject(["This is not a valid number"]);
6057

6158
r = t(123);
6259
expect(r.valid).toBeTruthy();
6360
expect(r.value).toEqual(123);
64-
expect(r.warnings).toMatchObject([]);
61+
expect(r.errors).toMatchObject([]);
6562
});
6663

6764
test('optional basic number', () => {
6865
// Also tests that optionalType converts errors into warnings
6966
const t = attributeTypes.optionalType(attributeTypes.basicNumberType);
7067

7168
let r = t("foo");
72-
expect(r.valid).toBeTruthy();
69+
expect(r.valid).toBeFalsy();
7370
expect(r.value).toBeUndefined();
74-
expect(r.warnings).toMatchObject(["This is not a valid number"]);
71+
expect(r.errors).toMatchObject(["This is not a valid number"]);
7572

7673
r = t(123);
7774
expect(r.valid).toBeTruthy();
7875
expect(r.value).toEqual(123);
79-
expect(r.warnings).toMatchObject([]);
76+
expect(r.errors).toMatchObject([]);
8077
});

lib/importer/src/backend.js

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -481,7 +481,7 @@ function mapCellValue(cell) {
481481
return cell.v;
482482
}
483483

484-
exports.SessionPerformMappingJob = (sid, range, mapping) => {
484+
exports.SessionPerformMappingJob = (sid, range, mapping, includeErrorRow = false) => {
485485
assert(sessionStore.get(sid));
486486
assert(sessionStore.get(sid).wb.Sheets[range.sheet]);
487487
assert(range.end.row >= range.start.row);
@@ -499,13 +499,26 @@ exports.SessionPerformMappingJob = (sid, range, mapping) => {
499499
const attrMap = Object.entries(mapping.attributeMappings);
500500
const attrTypes = mapping.attributeTypes || {};
501501

502+
503+
// TODO: Replace this with a map/sum once range is a real type.
504+
let expectedColumnCount = 0;
505+
for (let rowIdx = range.start.row; rowIdx <= range.end.row; rowIdx++) {
506+
let row = getMergedRow(data, merges, rowIdx);
507+
if (row && row.length > expectedColumnCount) expectedColumnCount = row.length;
508+
}
509+
502510
for (let rowIdx = range.start.row; rowIdx <= range.end.row; rowIdx++) {
503511
let row = getMergedRow(data, merges, rowIdx);
504512
let record = {};
505513
let foundSomeValues = false;
506514
let rowWarnings = [];
507515
let rowErrors = [];
516+
508517
if (row) {
518+
if (row.length < expectedColumnCount) {
519+
rowWarnings.push("Row has fewer columns than expected, wanted " + expectedColumnCount + " but found " + + row.length)
520+
}
521+
509522
attrMap.forEach((element) => {
510523
const [attr, m] = element;
511524

@@ -518,6 +531,10 @@ exports.SessionPerformMappingJob = (sid, range, mapping) => {
518531
if (cell && cell.v) {
519532
record[attr] = mapCellValue(cell);
520533
foundSomeValues = true;
534+
} else {
535+
// Ensure we add every cell so that validation can fail those without
536+
// values (where they are required).
537+
record[attr] = undefined
521538
}
522539
}
523540
});
@@ -533,10 +550,6 @@ exports.SessionPerformMappingJob = (sid, range, mapping) => {
533550

534551
const result = attrType(inputVal);
535552

536-
result.warnings.forEach((text) => {
537-
rowWarnings.push([attr, text]);
538-
});
539-
540553
if (result.valid) {
541554
// Succeeded, but maybe an empty result
542555
if (result.value !== undefined) {
@@ -545,19 +558,27 @@ exports.SessionPerformMappingJob = (sid, range, mapping) => {
545558
} else {
546559
// Failed
547560
result.errors.forEach((text) => {
548-
rowErrors.push([attr, text]);
561+
if (includeErrorRow) {
562+
rowErrors.push({ row: rowIdx, field: attr, message: text, data: row.map((x) => mapCellValue(x)) });
563+
} else {
564+
rowErrors.push({ row: rowIdx, field: attr, message: text });
565+
}
566+
549567
});
550568
}
551569
});
552570

553571
if (rowErrors.length == 0) {
554572
records.push(mappedRecord);
555573
}
574+
} else {
575+
rowWarnings.push("Row is empty")
556576
}
557577

558578
if (rowWarnings.length > 0) {
559579
warnings.set(rowIdx, rowWarnings);
560580
}
581+
561582
if (rowErrors.length > 0) {
562583
errors.set(rowIdx, rowErrors);
563584
}
@@ -596,7 +617,6 @@ exports.JobGetWarnings = (jid) => {
596617
exports.JobGetErrors = (jid) => {
597618
assert(jobStore.has(jid));
598619
let job = jobStore.get(jid);
599-
600620
return job.errors;
601621
}
602622

0 commit comments

Comments
 (0)