Skip to content

Commit d801ac0

Browse files
authored
Customisable error messages (#299)
Error messages are currently generated by the backend validation functions, but are not able to be customised by our users. This PR now returns error objects back to the client (prototype kit templates) so that they can choose what the error messages should contain if the language used by default is not suitable. These errors look like: { variant: '' // The error generated, field: '' // The field that generated the error type: '' // The expected type of the field value: '' // The value found in the field where the error occurred } These properties for each error can be used in the templates, but the value field is not useful here as the errors are grouped by 'variant' and 'field' so that they appear together. Currently it requires users to update both the error messages and the warning messages until we have more clarity on which 'errors' are in each category. It is possible to use the error 'variant' (type of error), the 'type' code (the expected type of that field) and the 'field' name in error messages, but the 'value' field is the value where the error was found, and this is not useful when errors are grouped together. In future, we may with to move the error message customisation into the dudk itself so that it can be used by non-prototype kit users, and to re-instate backend support for customisable error messages, you can revert the commits 966afd6 and ee6f49d
1 parent a4f0c77 commit d801ac0

15 files changed

Lines changed: 341 additions & 167 deletions

File tree

‎lib/importer/nunjucks/importer/macros/table_view.njk‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ Renders a table view using the provided parameters to determine what is being sh
2929
{% for rowObj in params.rows %}
3030
<tr class="govuk-table__row {% if importerRowHasHeader(rowObj) %}
3131
validation-error{%endif%}">
32+
33+
3234
{% if params.showRowNumbers %}
3335
<th scope="row" class="rowIndex">{{ rowObj.index }}</th>
3436
{% endif %}

‎lib/importer/src/dudk/backend.js‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const assert = require('node:assert').strict;
44
const attributeTypes = require('./types/attribute-types');
55
const guesser = require('./types/guesser');
66
const store = require("./session_store")
7+
const errorTypes = require('./util/errors');
78

89
// An implementation of the interface described in https://struct.register-dynamics.co.uk/trac/wiki/DataImporter/API
910

@@ -625,6 +626,7 @@ exports.SessionDelete = (sid) => {
625626
dimensionsCache.delete(sid);
626627
}
627628

629+
628630
class JobResult {
629631
constructor(records, errors, warnings) {
630632
this.records = records;
@@ -697,7 +699,7 @@ exports.SessionPerformMappingJob = (sid, range, mapping, includeErrorRow = false
697699
}
698700

699701
if (row.length < expectedColumnCount) {
700-
warnings.push({row: rowIdx, field: "", message: "Row has fewer columns than expected, wanted " + expectedColumnCount + " but found " + row.length})
702+
warnings.push({row: rowIdx, field: "", type: errorTypes.ValidationError.ShortRow({ expected: expectedColumnCount, actual: row.length})});
701703
}
702704

703705
attrMap.forEach((element) => {
@@ -736,7 +738,7 @@ exports.SessionPerformMappingJob = (sid, range, mapping, includeErrorRow = false
736738
const result = attrType(inputVal, {name: attr});
737739

738740
result.warnings.forEach((text) => {
739-
warnings.push({ row: rowIdx, field: attr, message: text });
741+
warnings.push({ row: rowIdx, field: attr, type: text });
740742
});
741743

742744
if (result.valid) {
@@ -749,9 +751,9 @@ exports.SessionPerformMappingJob = (sid, range, mapping, includeErrorRow = false
749751
result.errors.forEach((text) => {
750752
rowErrorCount += 1;
751753
if (includeErrorRow) {
752-
errors.push({ row: rowIdx, field: attr, message: text, data: row });
754+
errors.push({ row: rowIdx, field: attr, type: text, data: row });
753755
} else {
754-
errors.push({ row: rowIdx, field: attr, message: text });
756+
errors.push({ row: rowIdx, field: attr, type: text });
755757
}
756758

757759
});
@@ -762,7 +764,7 @@ exports.SessionPerformMappingJob = (sid, range, mapping, includeErrorRow = false
762764
records.push(mappedRecord);
763765
}
764766
} else {
765-
warnings.push({ row: rowIdx, message: "Row is empty" })
767+
warnings.push({ row: rowIdx, type: errorTypes.ValidationError.EmptyRow({}) })
766768
}
767769
}
768770

‎lib/importer/src/dudk/backend.test.js‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -571,15 +571,15 @@ test('validation', () => {
571571
});
572572

573573
const warnings = backend.JobGetWarnings(sid, jid); // Weight is optional so errors here become warnings
574-
expect(warnings).toStrictEqual([
575-
{ "row": 4, "message": 'Row is empty' },
576-
{ "row": 8, "field": "Weight", "message": "This is not a valid number" }]);
574+
expect(warnings).toMatchObject([
575+
{ "row": 4, "type": {variant: "EmptyRow"} },
576+
{ "row": 8, "field": "Weight", "type": {variant: "IncorrectType"} }]);
577577

578578
const errors = backend.JobGetErrors(sid, jid);
579-
expect(errors).toStrictEqual([
580-
{ "row": 6, "field": 'Age', "message": "This is not a valid number" },
581-
{ "row": 7, "field": 'Name', "message": "'Name' must be supplied" },
582-
{ "row": 7, "field": 'Age', "message": "This is not a valid number" }
579+
expect(errors).toMatchObject([
580+
{ "row": 6, "field": 'Age', "type": { variant: "IncorrectType"} },
581+
{ "row": 7, "field": 'Name', "type": { variant: "FieldRequired"} },
582+
{ "row": 7, "field": 'Age', "type": { variant: "IncorrectType"} }
583583
]);
584584

585585
const allRecords = backend.JobGetRecords(sid, jid, 0, 3);

‎lib/importer/src/dudk/sheets.js‎

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -318,14 +318,14 @@ exports.MapData = (sid, sheet, mapping, fields, previewLimit = DEFAULT_PREVIEW_L
318318

319319
// Remaps the errors/warnings structure from
320320
// [
321-
// { row: [{index:1, row:[]}], field: "A", message: "An error occurred" },
322-
// { row: [{index:1, row:[]}], field: "B", message: "An error occurred" },
323-
// { row: [{index:2, row:[]}], field: "B", message: "An error occurred" }
321+
// { row: [{index:1, row:[]}], field: "A", type: {variant: ..., value: ..., type: ...} },
322+
// { row: [{index:1, row:[]}], field: "B", type: {variant: ..., value: ..., type: ...} },
323+
// { row: [{index:2, row:[]}], field: "B", type: {variant: ..., value: ..., type: ...} }
324324
// ]
325325
// to
326326
// [
327327
// {
328-
// message: "An error occurred",
328+
// type: {variant: ..., value: ..., type: ...},
329329
// meta: {first: 1, count: 2}
330330
// fields: [ "A", "B"] },
331331
// rows: [[{index:1, row:[]}], [{index:2, row:[]}]] },
@@ -346,14 +346,17 @@ const remapErrorStructure = (errors, row_data, mappingIndices, headerRangeOffset
346346
return row.some(cell => cell && cell.error);
347347
}
348348

349-
350349
for (const error of errors) {
351-
if (!messageMap.has(error.message)) {
352-
messageMap.set(error.message, new Map());
350+
const key = `${error.type.variant}-${error.field}`;
351+
352+
if (!messageMap.has(key)) {
353+
messageMap.set(key, new Map());
353354
}
354355

355-
const errorObj = messageMap.get(error.message);
356-
errorObj.message = error.message;
356+
const errorObj = messageMap.get(key);
357+
errorObj.type = error.type;
358+
errorObj.type.field = error.field;
359+
357360
if( !errorObj.fields) {
358361
errorObj.fields = new Set();
359362
}
@@ -370,11 +373,13 @@ const remapErrorStructure = (errors, row_data, mappingIndices, headerRangeOffset
370373
}
371374

372375
if (row_data && Array.isArray(row_data) && realErrorIndex < row_data.length ) {
373-
374376
let previousRow = structuredClone(row_data[realErrorIndex - 1]);
375377
let currentRow = structuredClone(row_data[realErrorIndex]);
376378
let nextRow = structuredClone(row_data[realErrorIndex + 1]);
377379

380+
// currentRow is the error and so should have the error flag
381+
currentRow.error = true;
382+
378383
if (previousRow) {
379384
if (!rowHasError(errorObj.rows.get(previousRow.index)?.row)) {
380385
errorObj.rows.set(previousRow.index, previousRow);
@@ -400,12 +405,13 @@ const remapErrorStructure = (errors, row_data, mappingIndices, headerRangeOffset
400405
}
401406

402407
// Convert the nested maps/sets to the desired array structure
403-
return Array.from(messageMap.entries().map(([message, errorObj]) => {
408+
return Array.from(messageMap.values().map((errorObj) => {
404409
// Some rows may not have any errors because they are for surrounding context,
405410
// so we need to filter them out for the meta info
406-
const errorRows = Array.from(errorObj.rows.values()).filter(rowObj => rowObj.row.some(element=>element && element.error) );
411+
const errorRows = Array.from(errorObj.rows.values()).filter(rowObj => rowObj.error);
412+
407413
return {
408-
message: message,
414+
type: errorObj.type,
409415
meta: {
410416
first: errorRows[0]?.index || 0,
411417
count: errorRows?.length || 0

‎lib/importer/src/dudk/sheets.test.js‎

Lines changed: 39 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ test('trailing columns', () => {
2525
describe('remapErrorStructure', () => {
2626
test('groups errors by message, collects rows', () => {
2727
const errors = [
28-
{ row: 1, field: "A", message: "Error 1" },
29-
{ row: 1, field: "B", message: "Error 2" },
30-
{ row: 2, field: "B", message: "Error 2" },
31-
{ row: 3, field: "A", message: "Error 1" },
28+
{ row: 1, field: "A", type: {variant: "IncorrectType"} },
29+
{ row: 1, field: "B", type: {variant: "IncorrectType"} },
30+
{ row: 2, field: "B", type: {variant: "IncorrectType"} },
31+
{ row: 3, field: "A", type: {variant: "IncorrectType"} },
3232
];
3333
// Adjusted expected result to match grouping by error, then by field
3434
const row_data = [
@@ -38,30 +38,32 @@ describe('remapErrorStructure', () => {
3838
const result = sheets_lib.RemapErrorStructure(errors, row_data, RewrittenMapping);
3939
expect(result).toEqual([
4040
{
41-
message: "Error 1",
41+
type: {variant: "IncorrectType", field: "A"},
4242
fields: [ "A"],
43-
meta: { first: 1, count: 2 },
43+
// Only 1 error (in the two rows) because A appears twice but in
44+
// rows 1 and 3 and here we have rows 1 and 2
45+
meta: { first: 1, count: 1 },
4446
rows: [
45-
{index:1, row:[{value:"0", error: true},{value:"1", error: true}]},
47+
{index:1, error: true, row:[{value:"0", error: true},{value:"1", error: true}]},
4648
{index:2, row:[{value:"2", error: true}, {value:"3", error: true}]}
4749
]
4850
},
4951
{
50-
message: "Error 2",
52+
type: {variant: "IncorrectType", field: "B"},
5153
fields: ["B"],
5254
meta: { first: 1, count: 2 },
5355
rows: [
54-
{index:1, row: [{value:"0", error: true},{value:"1", error: true}]},
55-
{index:2, row: [{value:"2", error: true}, {value:"3", error: true}]}
56+
{index:1, error: true, row: [{value:"0", error: true},{value:"1", error: true}]},
57+
{index:2, error: true, row: [{value:"2", error: true}, {value:"3", error: true}]}
5658
]
5759
}
5860
]);
5961
});
6062

6163
test('with row_data, includes adjacent rows', () => {
6264
const errors = [
63-
{ row: 1, field: "A", message: "Error 1" },
64-
{ row: 2, field: "A", message: "Error 1" },
65+
{ row: 1, field: "A", type: "Error 1" },
66+
{ row: 2, field: "A", type: "Error 1" },
6567
];
6668
const row_data = [
6769
{index: 1, row: [{value:"0", error: true},{value:"1"}]},
@@ -70,21 +72,21 @@ describe('remapErrorStructure', () => {
7072
const result = sheets_lib.RemapErrorStructure(errors, row_data, RewrittenMapping);
7173
expect(result).toEqual([
7274
{
73-
message: "Error 1",
75+
type: "Error 1",
7476
fields: ["A"],
7577
meta: {first: 1, count: 2},
7678
rows: [
77-
{index: 1, row: [{value:"0", error: true},{value:"1"}]},
78-
{index: 2, row: [{value:"2", error: true}, {value:"3"}]}
79+
{index: 1, error: true, row: [{value:"0", error: true},{value:"1"}]},
80+
{index: 2, error: true, row: [{value:"2", error: true}, {value:"3"}]}
7981
]
8082
}
8183
]);
8284
});
8385

8486
test('does not add out-of-bounds adjacent rows', () => {
8587
const errors = [
86-
{ row: 1, field: "A", message: "Error 1" },
87-
{ row: 3, field: "A", message: "Error 1" },
88+
{ row: 1, field: "A", type: "Error 1" },
89+
{ row: 3, field: "A", type: "Error 1" },
8890
];
8991
const row_data = [
9092
{index: 1, row: [{value:"0", error: true},{value:"1"}]},
@@ -96,11 +98,11 @@ describe('remapErrorStructure', () => {
9698
// For row 3: 2, 3
9799
expect(result).toEqual([
98100
{
99-
message: "Error 1",
101+
type: "Error 1",
100102
fields: ["A"],
101103
meta: { first: 1, count: 1 },
102104
rows: [
103-
{index: 1, row: [{value:"0", error: true},{value:"1"}]},
105+
{index: 1, error: true, row: [{value:"0", error: true},{value:"1"}]},
104106
{index: 2, row: [{value:"2"},{value:"3"}]}
105107
]
106108
}
@@ -115,23 +117,23 @@ describe('remapErrorStructure', () => {
115117

116118
test('ignores errors with missing row in row_data', () => {
117119
const errors = [
118-
{ row: 5, field: "A", message: "Error 1" }
120+
{ row: 5, field: "A", type: "Error 1" }
119121
];
120122
const row_data = [
121123
{index: 1, row: [{value:"0"},{value:"1"}]},
122124
{index: 2, row: [{value:"2"}, {value:"3"}]}
123125
];
124126
const result = sheets_lib.RemapErrorStructure(errors, row_data, RewrittenMapping);
125127
expect(result).toEqual([
126-
{ message: "Error 1", meta: { count: 0, first: 0}, fields: ["A"], rows: [] }
128+
{ type: "Error 1", meta: { count: 0, first: 0}, fields: ["A"], rows: [] }
127129
]);
128130
});
129131

130132
test('remapping errors - missing fields', () => {
131133
const errors = [
132-
{ row: 2, field: 'Salary', message: "'Salary' must be supplied" },
133-
{ row: 3, field: 'Salary', message: "'Salary' must be supplied" },
134-
{ row: 5, field: 'Salary', message: "'Salary' must be supplied" }
134+
{ row: 2, field: 'Salary', type: { variant: "FieldRequired" } },
135+
{ row: 3, field: 'Salary', type: { variant: "FieldRequired" } },
136+
{ row: 5, field: 'Salary', type: { variant: "FieldRequired" } }
135137
] ;
136138
const row_data =
137139
[
@@ -157,16 +159,16 @@ describe('remapErrorStructure', () => {
157159
const result = sheets_lib.RemapErrorStructure(errors, row_data, mappingIndices, headerRangeOffset);
158160
expect(result).toEqual([
159161
{
160-
message: "'Salary' must be supplied",
162+
type: {variant: "FieldRequired", field: "Salary"},
161163
fields: ['Salary'],
162164
meta: { first: 3, count: 3 },
163165
rows: [
164166
// errors in index 3, 5 and 6
165167
{ index: 2, row: [{value: "2"}, {value: "Mr"}, {value: "Odie"}, {value: "Arbuckle"}, {value: "2025-02-01"}, {value: "123" }, {value: "12%"}, {value: "2025-04-01"}]},
166-
{ index: 3, row: [{ value: "1" }, { value: "Mr" }, { value: "Jon" }, { value: "Arbuckle" }, { value: "2025-01-01" }, { value: " ", error: true}, undefined, { value: "2025-04-01" }] },
167-
{ index: 4, row: [{ value: "1" }, { value: "Mr" }, { value: "Jon" }, { value: "Arbuckle" }, { value: "2025-01-01" }, { value: " ", error: true}, undefined, { value: "2025-04-01" }] },
168+
{ index: 3, error: true, row: [{ value: "1" }, { value: "Mr" }, { value: "Jon" }, { value: "Arbuckle" }, { value: "2025-01-01" }, { value: " ", error: true}, undefined, { value: "2025-04-01" }] },
169+
{ index: 4, error: true, row: [{ value: "1" }, { value: "Mr" }, { value: "Jon" }, { value: "Arbuckle" }, { value: "2025-01-01" }, { value: " ", error: true}, undefined, { value: "2025-04-01" }] },
168170
{ index: 5, row: [{value: "2"}, {value: "Mr"}, {value: "Odie"}, {value: "Arbuckle"}, {value: "2025-02-01"}, {value: "123"}, {value: "12%"}, {value: "2025-04-01"}]},
169-
{ index: 6, row: [{ value: "1" }, { value: "Mr" }, { value: "Jon" }, { value: "Arbuckle" }, { value: "2025-01-01" }, { value: " ", error: true}, undefined, { value: "2025-04-01" }] },
171+
{ index: 6, error: true, row: [{ value: "1" }, { value: "Mr" }, { value: "Jon" }, { value: "Arbuckle" }, { value: "2025-01-01" }, { value: " ", error: true}, undefined, { value: "2025-04-01" }] },
170172
{ index: 7, row: [{value: "2"}, {value: "Mr"}, {value: "Odie"}, {value: "Arbuckle"}, {value: "2025-02-01"}, {value: "123"}, {value: "12%"}, {value: "2025-04-01"}]}
171173
]
172174
}
@@ -176,12 +178,12 @@ describe('remapErrorStructure', () => {
176178

177179
test('remapping errors - multiple errors', () => {
178180
const errors = [
179-
{ row: 3, field: 'Salary', message: "'Salary' must be supplied" },
180-
{ row: 4, field: 'Salary', message: 'This is not a valid number' },
181-
{ row: 5, field: 'Salary', message: "'Salary' must be supplied" },
182-
{ row: 6, field: 'Salary', message: 'This is not a valid number' },
183-
{ row: 7, field: 'Salary', message: "'Salary' must be supplied" },
184-
{ row: 8, field: 'Salary', message: "'Salary' must be supplied" }
181+
{ row: 3, field: 'Salary', type: {variant: "FieldRequired"} },
182+
{ row: 4, field: 'Salary', type: {variant: "IncorrectType"} },
183+
{ row: 5, field: 'Salary', type: {variant: "FieldRequired"} },
184+
{ row: 6, field: 'Salary', type: {variant: "IncorrectType"} },
185+
{ row: 7, field: 'Salary', type: {variant: "FieldRequired"} },
186+
{ row: 8, field: 'Salary', type: {variant: "FieldRequired"} }
185187
] ;
186188
const row_data = [
187189
{ index: 4, row: [
@@ -207,9 +209,9 @@ describe('remapErrorStructure', () => {
207209
const headerRangeOffset = 3 ;
208210

209211
const result = sheets_lib.RemapErrorStructure(errors, row_data, mappingIndices, headerRangeOffset);
210-
expect(result).toEqual([
212+
expect(result).toMatchObject([
211213
{
212-
message: "'Salary' must be supplied",
214+
type: { variant: "FieldRequired" },
213215
meta: { first: 4, count: 4 },
214216
fields: [ 'Salary' ],
215217
rows:[
@@ -222,7 +224,7 @@ describe('remapErrorStructure', () => {
222224
]
223225
},
224226
{
225-
message: 'This is not a valid number',
227+
type: { variant: "IncorrectType" },
226228
meta: { first: 5, count: 2 },
227229
fields: [ 'Salary' ],
228230
rows: [

‎lib/importer/src/dudk/types/attribute-types.js‎

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

77
const ati = require('./attribute-type-internals');
8+
const errors = require('../util/errors')
89

910
// Create an optional version of an existing type, that allows empty strings or
1011
// undefined values and maps then to the empty mapping, and converts any validation errors to warnings
@@ -32,7 +33,7 @@ exports.requiredType = (baseType, field) => {
3233
return baseType(inputValue);
3334
}
3435
else {
35-
return ati.failedMapping([], [`'${field.name}' must be supplied`]);
36+
return ati.failedMapping([], [errors.ValidationError.FieldRequired({value: inputValue?.v, type: field.type})]);
3637
}
3738
};
3839
}
@@ -46,7 +47,7 @@ exports.basicStringType = (inputCell) => {
4647
exports.basicNumberType = (inputCell) => {
4748
const result = parseFloat(inputCell.v);
4849
if (isNaN(result)) {
49-
return ati.failedMapping([], ["This is not a valid number"]);
50+
return ati.failedMapping([], [errors.ValidationError.IncorrectType({ value: inputCell.v, type: "number"})]);
5051
} else {
5152
return ati.successfulMapping(result);
5253
}

0 commit comments

Comments
 (0)