Skip to content

Commit bb93e20

Browse files
committed
Initial implementation of format picking in the UI.
Still to do: - Skip the format selection step if it's not needed - Improve text on the format selection step - Use guessed types to preselect formats if we can
1 parent 472b6f1 commit bb93e20

12 files changed

Lines changed: 232 additions & 54 deletions

File tree

lib/importer/govuk-prototype-kit.config.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@
3030
"path": "/templates/mapping.html",
3131
"type": "nunjucks"
3232
},
33+
{
34+
"name": "Choose field formats",
35+
"path": "/templates/format.html",
36+
"type": "nunjucks"
37+
},
3338
{
3439
"name": "Review your data",
3540
"path": "/templates/review.html",

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
</div>
4747
{% endif %}
4848

49+
<input type="hidden" name="mappings-present" value="yes">
4950
<table class="govuk-table">
5051
{% if params.caption %}
5152
<caption class="govuk-table__caption govuk-table__caption--m">{{params.caption}}</caption>
@@ -67,7 +68,7 @@
6768
<th scope="row" class="govuk-table__header">{{ h.name }}</th>
6869
<td class="govuk-table__cell">{{ h.examples }}</td>
6970
<td class="govuk-table__cell govuk-table__cell--numeric">
70-
<select class="govuk-select" style="float: right;" name="{{h.index}}">
71+
<select class="govuk-select" style="float: right;" name="field-{{h.index}}">
7172
<option name=""></option>
7273
{% for field in fields %}
7374
<option value="{{field.name}}" {% if field.name == currentValue or (mappingsLen == 0 and field.name == h.name) %}selected{% endif %}>
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
{#
2+
dudkFormatPicker shows each column heading mapped to a field whose type has
3+
format options from the current spreadsheet, alongside a drop-down containing
4+
available formats. This allows the user to choose a format for
5+
each column that needs one, to select how the value mapping should be applied.
6+
7+
The resulting data to be submitted to the 'importerMapDataPath'
8+
will be a map of column indices to format names.
9+
10+
It accepts a data object which is taken from the prototype kit's
11+
session data which is made available on every page, and contains the
12+
data submitted from forms to the backend, and also the current
13+
data import session.
14+
#}
15+
16+
{% macro dudkFormatPicker(params) %}
17+
{% set fields = params.data['importer.session']['fields'] %}
18+
{% set mapping = params.data['importer.session']['mapping'] %}
19+
{% set headings = importerGetHeaders(params.data) %}
20+
{% set headingError = headings.error %}
21+
{% set error = importerError(params.data) %}
22+
23+
{% if headingError %}
24+
<p id="mapping-error" class="govuk-error-message">
25+
<span class="govuk-visually-hidden">Error:</span> {{ headingError.text }}
26+
</p>
27+
{% endif %}
28+
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 %}
47+
48+
<input type="hidden" name="formats-present" value="yes">
49+
<table class="govuk-table">
50+
{% if params.caption %}
51+
<caption class="govuk-table__caption govuk-table__caption--m">{{params.caption}}</caption>
52+
{% endif %}
53+
<thead class="govuk-table__head">
54+
<tr class="govuk-table__row">
55+
<th scope="col" class="govuk-table__header">{{params.columnTitle}}</th>
56+
<th scope="col" class="govuk-table__header">{{params.examplesTitle}}</th>
57+
<th scope="col" class="govuk-table__header" style="padding-left: 1.5em">{{params.fieldsTitle}}</th>
58+
</tr>
59+
</thead>
60+
<tbody class="govuk-table__body">
61+
{% set mappingsLen = mapping | length %}
62+
{% for h in headings.data %}
63+
{% set hIndex = loop.index0 %}
64+
{% set currentValue = importerErrorMappingData(error, hIndex) %}
65+
{% set possibleFormats = importerPossibleColumnFormats(params.data, hIndex) %}
66+
67+
<tr class="govuk-table__row" id="field-{{ h.name | slugify }}">
68+
<th scope="row" class="govuk-table__header">{{ h.name }}</th>
69+
<td class="govuk-table__cell">{{ h.examples }}</td>
70+
<td class="govuk-table__cell govuk-table__cell--numeric">
71+
{% if possibleFormats %}
72+
<select class="govuk-select" style="float: right;" name="format-{{h.index}}">
73+
<option name=""></option>
74+
{% for format in possibleFormats %}
75+
<option value="{{format.name}}" {% if format.name == currentValue %}selected{% endif %}>
76+
{{format.displayName}}
77+
</option>
78+
{% endfor %}
79+
</select>
80+
{% endif %}
81+
</td>
82+
</tr>
83+
{% endfor %}
84+
</tbody>
85+
</table>
86+
{% endmacro %}
87+

lib/importer/src/dudk/backend.js

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ const guesser = require('./types/guesser');
66
const store = require("./session_store")
77
const errorTypes = require('./util/errors');
88

9-
// An implementation of the interface described in https://struct.register-dynamics.co.uk/trac/wiki/DataImporter/API
9+
// An implementation of the interface described in
10+
// https://struct.register-dynamics.co.uk/trac/wiki/DataImporter/API
1011

1112
let sessionStore = function () {
1213
if (process.env.NODE_ENV == "development") {
@@ -53,14 +54,20 @@ exports.SessionSetFile = (sid, filename) => {
5354
filename: filename,
5455
wb: wb,
5556
sheetNames: Array.from(Object.keys(wb.Sheets)),
57+
jobs: new Map(),
58+
// State we store for the frontend (we don't do anything with it, just store it and spit it back on demand)
5659
headerRanges: {}, // A map from sheetname to the header range
5760
footerRanges: {}, // A map from sheetname to the footer range
58-
mappingRules: {},
59-
jobs: new Map(),
61+
mappingRules: {}, // A map from column index to attribute name
62+
formatChoices: {}, // A map from column index to format
6063
}
6164
sessionStore.set(sid, session);
6265
};
6366

67+
///
68+
/// Storage of UI state for the frontend
69+
///
70+
6471
exports.SessionGetMappingRules = (sid) => {
6572
let session = sessionStore.get(sid);
6673
return session.mappingRules;
@@ -70,11 +77,6 @@ exports.SessionSetMappingRules = (sid, rules) => {
7077
sessionStore.apply(sid, (s) => { s.mappingRules = rules })
7178
};
7279

73-
exports.SessionGetSheets = (sid) => {
74-
let session = sessionStore.get(sid);
75-
return session.sheetNames
76-
};
77-
7880
exports.SessionSetHeaderRange = (sid, range) => {
7981
assert(range != null, "Null range passed to SessionSetHeaderRange");
8082
assert(range.sheet != null, "Range with null sheet passed to SessionSetHeaderRange");
@@ -98,6 +100,24 @@ exports.SessionGetFooterRange = (sid, sheetName) => {
98100
return session.footerRanges[sheetName]
99101
};
100102

103+
exports.SessionGetFormatChoices = (sid) => {
104+
let session = sessionStore.get(sid);
105+
return session.formatChoices;
106+
};
107+
108+
exports.SessionSetFormatChoices = (sid, choices) => {
109+
sessionStore.apply(sid, (s) => { s.formatChoices = choices })
110+
};
111+
112+
///
113+
/// Information about the loaded input file
114+
///
115+
116+
exports.SessionGetSheets = (sid) => {
117+
let session = sessionStore.get(sid);
118+
return session.sheetNames
119+
};
120+
101121
function getDimensions(sid) {
102122
let session = sessionStore.get(sid);
103123
let sheetDimensions = new Map();
@@ -533,7 +553,7 @@ exports.SessionGuessTypes = (sid, range) => {
533553
// field "formats" that maps from format names (as returned by SessionGetTypes)
534554
// to objects with strings field "displayName" and "description".
535555
exports.SessionGetSupportedTypes = (sid) => {
536-
return types.supportedTypes;
556+
return attributeTypes.supportedTypes;
537557
};
538558

539559
// Given guesses as returned by SessionGuessTypes and a list of domain-model

lib/importer/src/dudk/sheets.js

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -253,10 +253,11 @@ exports.GetColumnValues = (sid, sheet, columnIndex, cellWidth = 30, count = 10)
253253
};
254254

255255

256-
// Convert source mapping (a map from column index -> attribute name) into a mapping for the backend
257-
// TODO: This should be in the front-end and we should set expectations for what the mapping should
258-
// look like when sending to the backend.
259-
const RewriteMapping = (mapping, fields) => {
256+
// Convert source mapping (a map from column index -> attribute name) and format
257+
// choices (a map from column index -> format) into a mapping for the backend
258+
// TODO: This should be in the front-end and we should set expectations for what
259+
// the mapping should look like when sending to the backend.
260+
const RewriteMapping = (mapping, formats, fields) => {
260261
let rewrittenMapping = new Map();
261262

262263
const attrTypes = {}
@@ -268,8 +269,7 @@ const RewriteMapping = (mapping, fields) => {
268269

269270
const f = fields.find((x) => x.name == attributeName)
270271
if (f) {
271-
// FIXME: Actually let the user pick a format, for types that need one. Hardcoding "false" for now.
272-
attrTypes[f.name] = attributeTypes.mapperForField(f, false)
272+
attrTypes[f.name] = attributeTypes.mapperForField(f, formats[columnIndex])
273273
}
274274
}
275275

@@ -282,15 +282,15 @@ const RewriteMapping = (mapping, fields) => {
282282
// Uses the session ID provided, which must contain a sheet name and a
283283
// mapping to perform the mapping of the data across the remaining
284284
// rows in the sheet to return an array of objects.
285-
exports.MapData = (sid, sheet, mapping, fields, previewLimit = DEFAULT_PREVIEW_LIMIT) => {
285+
exports.MapData = (sid, sheet, mapping, formats, fields, previewLimit = DEFAULT_PREVIEW_LIMIT) => {
286286
const hRange = backend.SessionGetHeaderRange(sid, sheet)
287287
const fRange = backend.SessionGetFooterRange(sid, sheet)
288288

289289
// Construct the range to be mapped - everything but the first row
290290
const rowRange = backend.SessionSuggestDataRange(sid, hRange, fRange);
291291

292292
// Convert source mapping (a map from column index -> attribute name) into a mapping for the backend
293-
let rewrittenMapping = RewriteMapping(mapping, fields)
293+
let rewrittenMapping = RewriteMapping(mapping, formats, fields)
294294

295295
// Apply the mapping
296296
const backendJid = backend.SessionPerformMappingJob(sid, rowRange, rewrittenMapping);

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,19 +74,19 @@ exports.mapperForField = (field,format) => {
7474
case "native":
7575
// Date with no format spec relies on pre-parsed date values
7676
// being provided by spreadsheet formats
77-
m = dates.MakeCombinedDateType([]);
77+
m = dates.makeCombinedDateType([]);
7878
break;
7979
case "ymd":
80-
m = dates.MakeCombinedDateType(['year','month','day']);
80+
m = dates.makeCombinedDateType(['year','month','day']);
8181
break;
8282
case "ydm":
83-
m = dates.MakeCombinedDateType(['year','day','month']);
83+
m = dates.makeCombinedDateType(['year','day','month']);
8484
break;
8585
case "dmy":
86-
m = dates.MakeCombinedDateType(['day','month','year']);
86+
m = dates.makeCombinedDateType(['day','month','year']);
8787
break;
8888
case "mdy":
89-
m = dates.MakeCombinedDateType(['month','day','year']);
89+
m = dates.makeCombinedDateType(['month','day','year']);
9090
break;
9191
default:
9292
throw new Error("Unknown date format", format);

lib/importer/src/functions.js

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11

22
const sheets_lib = require("./dudk/sheets.js");
33
const session_lib = require("./session.js");
4+
const backend_lib = require("./dudk/backend.js");
45

56
const IMPORTER_SESSION_KEY = "importer.session";
67
const IMPORTER_ERROR_KEY = "importer.error";
@@ -48,10 +49,39 @@ const importerErrorMappingData = (error, key) => {
4849
return ""
4950
}
5051

52+
// ABS FIXME: Do we need to remove a field- or mapping- prefix from k before parseInt?
5153
const m = new Map(Object.entries(error.data).map(([k, v]) => [parseInt(k), v]))
5254
return m.get(key) || ""
5355
}
5456

57+
//--------------------------------------------------------------------
58+
// As long as column->field mappings have been set up, this function will return
59+
// a list of possible formats for a specified column (by index). The return
60+
// value will be false if the column is mapped to a field whose type does not
61+
// require formats, otherwise it will be a list of objects with 'name' (internal
62+
// code), 'displayName' (human-facing name) and 'description' (longer description) fields.
63+
// --------------------------------------------------------------------
64+
const importerPossibleColumnFormats = (data, index) => {
65+
const session_data = data[IMPORTER_SESSION_KEY];
66+
const session = new session_lib.Session(session_data);
67+
const supportedTypes = backend_lib.SessionGetSupportedTypes(session.backendSid);
68+
const mappings = backend_lib.SessionGetMappingRules(session.backendSid);
69+
const columnField = mappings[index];
70+
const columnDef = session.fields.find((field) => field.name == columnField);
71+
const columnTypeName = columnDef.type;
72+
const columnType = supportedTypes.get(columnTypeName);
73+
if (columnType.formats) {
74+
const options = Array.from(columnType.formats.entries()).map((fmtEntry) => ({
75+
name: fmtEntry[0],
76+
displayName: fmtEntry[1].displayName,
77+
description: fmtEntry[1].description
78+
}));
79+
return options;
80+
} else {
81+
return false;
82+
}
83+
}
84+
5585
//--------------------------------------------------------------------
5686
// Allows a template to obtain `count` rows from the start of the data
5787
// range.
@@ -156,7 +186,7 @@ const importerMappedData = (data) => {
156186
const session_data = data[IMPORTER_SESSION_KEY];
157187
const session = new session_lib.Session(session_data)
158188

159-
const mapResults = sheets_lib.MapData(session.backendSid, session.sheet, session.mapping, session.fields);
189+
const mapResults = sheets_lib.MapData(session.backendSid, session.sheet, session.mapping, session.formats, session.fields);
160190
const headers = session.fields;
161191

162192
return {
@@ -186,7 +216,7 @@ const data_sum = (data, column) => {
186216
const session_data = data[IMPORTER_SESSION_KEY];
187217
const session = new session_lib.Session(session_data)
188218

189-
const mapResults = sheets_lib.MapData(session.backendSid, session.sheet, session.mapping, session.fields);
219+
const mapResults = sheets_lib.MapData(session.backendSid, session.sheet, session.mapping, session.formats, session.fields);
190220
const headers = session.fields;
191221

192222
const idx = headers.findIndex((x) => x.name == column)
@@ -206,7 +236,7 @@ const data_avg = (data, column) => {
206236
const session_data = data[IMPORTER_SESSION_KEY];
207237
const session = new session_lib.Session(session_data)
208238

209-
const mapResults = sheets_lib.MapData(session.backendSid, session.sheet, session.mapping, session.fields);
239+
const mapResults = sheets_lib.MapData(session.backendSid, session.sheet, session.mapping, session.formats, session.fields);
210240
const headers = session.fields;
211241

212242
const idx = headers.findIndex((x) => x.name == column)
@@ -247,6 +277,7 @@ const parseNumberFromString = (s) => {
247277
module.exports = {
248278
importerError,
249279
importerErrorMappingData,
280+
importerPossibleColumnFormats,
250281
importSheetPreview,
251282
importerGetRows,
252283
importerGetHeaders,
@@ -257,3 +288,9 @@ module.exports = {
257288
data_sum,
258289
data_avg
259290
}
291+
292+
/*
293+
Local Variables:
294+
js-indent-level: 4
295+
End:
296+
*/

0 commit comments

Comments
 (0)