Skip to content

Commit 148ca5b

Browse files
User defined tables (#345)
* merge changes from OwainJones-patch-1 into new branch recreated from main (#317) * Update backend.js Updating for tables * Added for table testing * Some minor updates to testing * Some updates to tables_test and added a metadata file * Installed our locally "vendored" patched copy of sheet.js and used it to get defined tables Excel.js only offered an async API to load files, so we couldn't use it :'-( # Conflicts: # lib/importer/package.json # lib/importer/src/dudk/backend.js * Licence compliance requirements for SheetJS fork * Added html and nkj files, as well as the associated functions in function.js and sheet.js for selecting user defined tables. * Added njk and html files as well as functions in functions.js and sheets.js to select and preview tables. * A range of edits to fix errros that campe up when testing the table selector and to extract the header row automatically * Several tweaks to code to get table previews to work. * Add files via upload * Delete backend.js * Delete sheets.js * Delete functions.js * Add files via upload * Add files via upload * Add files via upload * Add files via upload --------- Co-authored-by: Alaric Snell-Pym <alaric@register-dynamics.co.uk> * Delete table_selector.njk * Updates for tables attempting to pass multiple urls to route to * Minor updates * Edits to ensure that header identification in tables is working correctly, and to ensure we preserve the new header range. * Added a get and set table function, updated index.js, and various other functions to accomodate tables into the header and footer pages * Added some tweaks to review page to accomodate tables, moved the check for footer rows in SessionPerformMappingJob from if(row) to its parent for loop, as we were having an issue where an empty row in the table footer (i.e. the row immediately below the table) was giving an error. * Tidied up code removing several console.debug lines that were used for debugging * Some updates to how we handle table header and use it in the final mapData step so that we can import data without visible headers * Quick comit to ensure any minor local changes are accounted for * Changes to fix table preview for no table and to fix merge issues --------- Co-authored-by: Alaric Snell-Pym <alaric@register-dynamics.co.uk>
1 parent 6b73a57 commit 148ca5b

25 files changed

+31672
-46
lines changed

fixtures/tables_test.xlsx

642 KB
Binary file not shown.

fixtures/tables_test_metadata.csv

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Table,Sheet,Name,Range ,Columns
2+
table1,Standard,Soldier,A1:I21,Level; Vitality; Attack; Defence; Fortitude; Reflex; Will; Feats granted; Feat
3+
table2,Standard,Scout,A23:I43,Level; Vitality; Attack; Defence; Fortitude; Reflex; Will; Feats granted; Feat
4+
table3,Standard,Scoundrel,A45:I65,Level; Vitality; Attack; Defence; Fortitude; Reflex; Will; Feats granted; Feat
5+
table4,Jedi,Guardian,A1:K21,Level; Vitality; Force Points; Attack; Defence; Fortitude; Reflex; Will; Feats granted; Feat; Force Powers
6+
table5,Jedi ,Sentinel,A23:K23,Level; Vitality; Force Points; Attack; Defence; Fortitude; Reflex; Will; Feats granted; Feat; Force Powers
7+
table6,Jedi ,Consular,A45:K65,Level; Vitality; Force Points; Attack; Defence; Fortitude; Reflex; Will; Feats granted; Feat; Force Powers
8+
,,,,
9+
There is also a third sheet called 'No tables' with no tables in it just a block of data,,,,

fixtures/tables_test_metadata.xlsx

8.91 KB
Binary file not shown.

lib/importer/assets/js/sheet_selector.js

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,18 @@ window.addEventListener("load", () => {
77
return;
88
}
99

10-
let index = parseInt(elem.dataset.previewIndex ?? -1);
11-
if (index < 0) {
12-
return;
13-
}
14-
1510
previews.forEach((p) => {
1611
p.classList.remove("selected");
1712
p.ariaHidden = true;
1813
});
14+
15+
let index = parseInt(elem.dataset.previewIndex ?? -1);
16+
17+
if (index < 0) {
18+
return;
19+
}
20+
21+
1922
previews[index].classList.add("selected");
2023
previews[index].ariaHidden = false;
2124
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
"path": "/templates/select_sheet.html",
1616
"type": "nunjucks"
1717
},
18+
{
19+
"name": "Select a table",
20+
"path": "/templates/select_table.html",
21+
"type": "nunjucks"
22+
},
1823
{
1924
"name": "Select header rows",
2025
"path": "/templates/select_header_row.html",
@@ -64,6 +69,10 @@
6469
"macroName": "importerSheetSelector",
6570
"importFrom": "importer/macros/sheet_selector.njk"
6671
},
72+
{
73+
"macroName": "importerTableSelector",
74+
"importFrom": "importer/macros/table_selector.njk"
75+
},
6776
{
6877
"macroName": "importerFieldMapper",
6978
"importFrom": "importer/macros/field_mapper.njk"
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
2+
{#
3+
importerTableSelector generates a radio button list which allows the
4+
user to choose which table is to be used (if any).
5+
6+
The list of table names is retrieved from the current spreadsheet
7+
being uploaded.
8+
9+
It accepts a data object which is taken from the prototype kit's
10+
session data which is made available on every page, and contains the
11+
data submitted from forms to the backend, and also the current
12+
data import session.
13+
14+
legend is the text that should be used for the legend part of the
15+
radio buttons, if none is supplied, then a legend is not added.
16+
#}
17+
{% from "importer/macros/table_view.njk" import tableView %}
18+
19+
{% macro importerTableSelector(data, legend) %}
20+
{% set selectedTable = data['importer.session'].table %}
21+
{% set tables = importTablePreview(data) %}
22+
{% set tableRowIndex = tables.length + 2 %}
23+
{% set error = importerError(data) %}
24+
25+
<div class="govuk-form {% if error %}govuk-form-group--error{% endif %}">
26+
<div class="govuk-form-group ">
27+
{% if legend %}
28+
<legend class="govuk-fieldset__legend govuk-fieldset__legend--l">
29+
<h1 class="govuk-fieldset__heading">
30+
{{ legend }}
31+
</h1>
32+
</legend>
33+
{% endif %}
34+
35+
{% if error %}
36+
<p id="upload-error" class="govuk-error-message">
37+
<span class="govuk-visually-hidden">Error:</span> {{ error.text }}
38+
</p>
39+
{% endif %}
40+
41+
<div class="govuk-radios rd-sheet-preview" data-module="govuk-radios">
42+
{% for table in tables %}
43+
<div class="govuk-radios__item">
44+
<input class="govuk-radios__input" id="{{table.name}}" name="table" type="radio" value="{{table.name}}" {% if selectedTable==table.name %}checked="checked"{% endif %} data-preview-index="{{loop.index0}}">
45+
<label class="govuk-label govuk-radios__label" for="{{table.name}}">
46+
{{table.name}}
47+
</label>
48+
</div> <!-- .govuk-radios__item -->
49+
{% endfor %}
50+
<div class="govuk-radios__divider">or</div>
51+
<div class="govuk-radios__item">
52+
<input class="govuk-radios__input" id="no_table" name="table" type="radio" value="no_table" data-preview-index=-1>
53+
<label class="govuk-label govuk-radios__label" for="no_table">
54+
I don't want to use a predefined table
55+
</label>
56+
</div> <!-- .govuk-radios__item -->
57+
</div>
58+
59+
<div class="rd-sheet-selector-previews">
60+
{% for table in tables %}
61+
<div class="hidden">
62+
{% if table.data.rows == null %}
63+
<div class="govuk-body">
64+
Table '{{ table.name }}' is empty
65+
</div>
66+
{% else %}
67+
{% set caption = importerGetTableCaption(data, "First", 10, sheet.name, table.name) %}
68+
{{ tableView({
69+
caption: caption,
70+
showHeaders: true,
71+
headers: table.data.headers,
72+
showRowNumbers: false,
73+
rows: table.data.rows,
74+
moreRowsCount: 0
75+
} )}}
76+
77+
78+
{% endif %}
79+
</div>
80+
{% endfor %}
81+
</div>
82+
</div>
83+
{% endmacro %}

lib/importer/package-lock.json

Lines changed: 1 addition & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/importer/package.json

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,15 @@
2020
"license": "ISC",
2121
"description": "The Data Upload Design Kit makes it easier for users to upload data about many things at once by uploading a file without the need for hard-coded Excel spreadsheet templates. Designed to work with the GOV.UK Prototype Kit, it makes it easier for service designers and developers to work with spreadsheets.",
2222
"dependencies": {
23-
"multer": "2.0.1",
24-
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz",
25-
"fs-extra": "11.3.0"
23+
"fs-extra": "11.3.0",
24+
"multer": "2.0.1"
2625
},
2726
"devDependencies": {
2827
"@eslint/js": "^9.32.0",
2928
"eslint": "^9.32.0",
3029
"eslint-plugin-jest": "^29.0.1",
3130
"globals": "^16.3.0",
32-
"mock-fs": "^5.5.0",
33-
"jest": "^30.0.5"
31+
"jest": "^30.0.5",
32+
"mock-fs": "^5.5.0"
3433
}
3534
}

lib/importer/src/dudk/backend.js

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const xlsx = require("xlsx");
1+
const xlsx = require("./sheetjs/xlsx");
22
const crypto = require("crypto");
33
const assert = require('node:assert').strict;
44
const attributeTypes = require('./types/attribute-types');
@@ -17,6 +17,8 @@ let sessionStore = function () {
1717
}();
1818

1919
let dimensionsCache = new Map();
20+
let tablesCache = new Map();
21+
let tableRangesCache = new Map();
2022

2123
// Maximum number of rows/records to return in one go. While we're all
2224
// in-memory, this merely restricts how much we allocate in one go for a shallow
@@ -45,6 +47,8 @@ exports.CreateSession = () => {
4547
/// Session Interface
4648
///
4749

50+
51+
4852
// Set the filename of the file for an import session.
4953
exports.SessionSetFile = (sid, filename) => {
5054
const wb = xlsx.readFile(filename, { dense: true, cellStyles: true, cellDates: true, raw: true });
@@ -75,6 +79,11 @@ exports.SessionGetSheets = (sid) => {
7579
return session.sheetNames
7680
};
7781

82+
exports.SessionGetTables = (sid, sheetName) => {
83+
let session = sessionStore.get(sid);
84+
return session.tables[sheetName]
85+
};
86+
7887
exports.SessionSetHeaderRange = (sid, range) => {
7988
assert(range != null, "Null range passed to SessionSetHeaderRange");
8089
assert(range.sheet != null, "Range with null sheet passed to SessionSetHeaderRange");
@@ -159,6 +168,16 @@ exports.SessionGetInputDimensions = (sid) => {
159168
return getDimensions(sid);
160169
};
161170

171+
exports.SessionGetInputTables = (sid) => {
172+
assert(sessionStore.has(sid));
173+
return getTables(sid);
174+
};
175+
176+
exports.SessionGetInputTableRanges = (sid, sheet) => {
177+
assert(sessionStore.has(sid));
178+
return getTableRanges(sid, sheet);
179+
};
180+
162181
function randInRange(min, max) {
163182
return Math.floor(Math.random() * (max - min + 1) + min);
164183
}
@@ -375,6 +394,69 @@ function cellsToSamples(row) {
375394
return row.map(cellToSample);
376395
}
377396

397+
function getTables(sid) {
398+
let session = sessionStore.get(sid);
399+
let sheetTables = new Map();
400+
401+
let cached = tablesCache.get(sid);
402+
if (cached) {
403+
return cached
404+
}
405+
Object.keys(session.wb.Sheets).forEach((sheetName) => {
406+
const sheet = session.wb.Sheets[sheetName];
407+
408+
if(sheet["!tables"]) {
409+
const tables = sheet["!tables"];
410+
sheetTables.set(sheetName, {
411+
tableCount: tables.length,
412+
tableNames: tables.map((t) => t.name)
413+
})
414+
}
415+
})
416+
417+
tablesCache.set(sid, { sheetTables: sheetTables })
418+
419+
return sheetTables;
420+
}
421+
422+
function decodeRange(rangeStr) {
423+
const parts = xlsx.utils.decode_range(rangeStr);
424+
return {
425+
start: {
426+
row: parts.s.r,
427+
column: parts.s.c
428+
},
429+
end: {
430+
row: parts.e.r,
431+
column: parts.e.c
432+
}
433+
};
434+
}
435+
436+
function getTableRanges(sid, sheetName) {
437+
let session = sessionStore.get(sid);
438+
let tableRanges = new Map();
439+
440+
//let cached = tableRangesCache.get(sid);
441+
//if (cached) {
442+
// return cached
443+
//}
444+
const sheet = session.wb.Sheets[sheetName];
445+
if (sheet["!tables"]) {
446+
sheet["!tables"].forEach((table) => {
447+
tableRanges.set(table.name, {
448+
displayName: table.displayname,
449+
range: decodeRange(table.ref),
450+
columns: table.columns
451+
});
452+
});
453+
}
454+
455+
//tableRangesCache.set(sid, tableRanges)
456+
457+
return tableRanges;
458+
}
459+
378460
// Returns a sample of rows in a range. range is of the form {sheet: 'Foo', start:{row: X, column: Y}, end:{row: X, column: Y}}.
379461

380462
// Returns three arrays - one with startCount rows from the top of the range,
@@ -635,6 +717,8 @@ exports.SessionDelete = (sid) => {
635717
assert(sessionStore.get(sid), `No such session ${sid} when deleting session`);
636718
sessionStore.delete(sid);
637719
dimensionsCache.delete(sid);
720+
tablesCache.delete(sid);
721+
tableRangesCache.delete(sid);
638722
}
639723

640724

@@ -701,13 +785,13 @@ exports.SessionPerformMappingJob = (sid, range, mapping, includeErrorRow = false
701785
let row = getMergedRow(data, merges, rowIdx);
702786
let recordCells = {};
703787
let foundSomeValues = false;
788+
// Ensure we exclude footer rows from the validation as they may not
789+
// have values in them.
790+
if (rowIdx == range.end.row && footerRange) {
791+
continue;
792+
}
704793

705794
if (row) {
706-
// Ensure we exclude footer rows from the validation as they may not
707-
// have the same number of columns as the header row.
708-
if (rowIdx == range.end.row && footerRange) {
709-
continue;
710-
}
711795

712796
if (row.length < expectedColumnCount) {
713797
warnings.push({row: rowIdx, field: "", type: errorTypes.ValidationError.ShortRow({ expected: expectedColumnCount, actual: row.length})});

0 commit comments

Comments
 (0)