fhirsmith 0.13.4 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -1
- package/README.md +16 -3
- package/config-template.json +81 -1
- package/library/cgroup-memory.js +113 -0
- package/library/folder-setup.js +8 -1
- package/library/languages.js +15 -0
- package/library/same-origin.js +62 -0
- package/nginx.md +399 -0
- package/package.json +3 -2
- package/publisher/git-remote.js +141 -0
- package/publisher/github-release.js +195 -0
- package/publisher/publisher-template.html +1 -0
- package/publisher/publisher.js +241 -56
- package/registry/registry.js +3 -1
- package/server.js +57 -15
- package/stats.js +19 -13
- package/testing/readme.md +144 -0
- package/testing/render.js +723 -0
- package/testing/search.js +378 -0
- package/testing/store.js +410 -0
- package/testing/testing-template.html +126 -0
- package/testing/testing.js +760 -0
- package/translations/Messages.properties +32 -0
- package/tx/closure/closure-store.js +396 -0
- package/tx/cs/cs-api.js +6 -1
- package/tx/cs/cs-rxnorm.js +35 -8
- package/tx/cs/cs-snomed.js +19 -69
- package/tx/html/codesystem-operations.liquid +177 -0
- package/tx/html/ecl-panel.liquid +93 -0
- package/tx/html/home-metrics.liquid +39 -76
- package/tx/html/tx-template.html +1 -0
- package/tx/library/conceptmap.js +22 -6
- package/tx/library/designations.js +12 -3
- package/tx/library/operation-outcome.js +3 -1
- package/tx/library/renderer.js +42 -5
- package/tx/operation-context.js +80 -17
- package/tx/provider.js +85 -0
- package/tx/sct/editions.js +70 -0
- package/tx/sct/expressions.js +128 -44
- package/tx/tx-html.js +426 -44
- package/tx/tx.js +117 -9
- package/tx/workers/cache-control.js +2 -2
- package/tx/workers/closure.js +483 -13
- package/tx/workers/expand.js +2 -2
- package/tx/workers/metadata.js +8 -3
- package/tx/workers/read.js +18 -3
- package/tx/workers/subsumes.js +18 -2
- package/tx/workers/translate.js +192 -21
- package/tx/workers/validate.js +70 -187
- package/tx/workers/worker.js +12 -35
- package/tx/xversion/xv-conceptmap.js +17 -8
- package/utilities/submit-test-report.js +150 -0
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
|
|
3
|
+
//
|
|
4
|
+
// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
|
|
5
|
+
//
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* HTML for the /testing module: the filterable list, the summary grid, and the
|
|
9
|
+
* best-effort rendering of a single report.
|
|
10
|
+
*
|
|
11
|
+
* Everything in a TestReport came from an anonymous sender, so every value goes through
|
|
12
|
+
* escape() and only http(s) URLs ever become links. The report's narrative (text.div)
|
|
13
|
+
* is deliberately not rendered - it is XHTML from a stranger. It is still visible, as
|
|
14
|
+
* text, in the raw JSON view.
|
|
15
|
+
*
|
|
16
|
+
* @module testing/render
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const escape = require('escape-html');
|
|
20
|
+
const { runLengthOf } = require('./store');
|
|
21
|
+
|
|
22
|
+
const RESULT_COLOURS = {
|
|
23
|
+
pass: '#1e7e34',
|
|
24
|
+
fail: '#c82333',
|
|
25
|
+
error: '#c82333',
|
|
26
|
+
warning: '#d39e00',
|
|
27
|
+
pending: '#6c757d',
|
|
28
|
+
skip: '#6c757d'
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const STYLE = `
|
|
32
|
+
<style>
|
|
33
|
+
.tr-badge { display: inline-block; padding: 1px 6px; border-radius: 3px; color: white; font-size: 85%; white-space: nowrap; }
|
|
34
|
+
.tr-table { font-size: 90%; }
|
|
35
|
+
.tr-table td, .tr-table th { vertical-align: top; }
|
|
36
|
+
.tr-uri { word-break: break-all; font-size: 90%; }
|
|
37
|
+
.tr-msg { white-space: pre-wrap; }
|
|
38
|
+
.tr-json { white-space: pre-wrap; word-break: break-all; font-size: 85%; background: #f8f9fa; padding: 8px; border: 1px solid #dee2e6; }
|
|
39
|
+
.tr-filter { display: flex; flex-wrap: wrap; gap: 4px 6px; align-items: center; font-size: 85%; }
|
|
40
|
+
.tr-filter .tr-line { display: flex; flex-wrap: wrap; gap: 4px 6px; align-items: center; width: 100%; }
|
|
41
|
+
.tr-filter input, .tr-filter select { font-size: 100%; padding: 1px 4px; height: auto; }
|
|
42
|
+
.tr-filter input[type=text] { width: 9em; }
|
|
43
|
+
.tr-filter input[type=number] { width: 4.5em; }
|
|
44
|
+
.tr-filter input[type=date] { width: 9.5em; }
|
|
45
|
+
.tr-filter .tr-lbl { color: #555; }
|
|
46
|
+
.tr-filter .tr-btn { font-size: 100%; padding: 1px 10px; line-height: 1.5; }
|
|
47
|
+
.tr-date { white-space: nowrap; }
|
|
48
|
+
/* operations and assertions; nested inside the striped tests table, so reset its colours */
|
|
49
|
+
table.tr-steps { margin-top: 6px; margin-bottom: 0; }
|
|
50
|
+
table.tr-steps > tbody > tr > td, table.tr-steps > tbody > tr > th { background-color: #fff !important; color: #333 !important; padding: 2px 6px; }
|
|
51
|
+
.tr-grid td { text-align: center; }
|
|
52
|
+
.tr-grid th.tr-col { font-size: 80%; word-break: break-all; min-width: 90px; }
|
|
53
|
+
</style>`;
|
|
54
|
+
|
|
55
|
+
function text(v) {
|
|
56
|
+
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
|
|
57
|
+
return String(v);
|
|
58
|
+
}
|
|
59
|
+
return '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function asArray(v) {
|
|
63
|
+
return Array.isArray(v) ? v : [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isObject(v) {
|
|
67
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A link if the value is an http(s) URL, otherwise just the escaped text. */
|
|
71
|
+
function uriLink(v, cls = 'tr-uri') {
|
|
72
|
+
const s = text(v);
|
|
73
|
+
if (!s) {
|
|
74
|
+
return '';
|
|
75
|
+
}
|
|
76
|
+
if (/^https?:\/\/[^\s"'<>]+$/i.test(s)) {
|
|
77
|
+
return `<a class="${cls}" href="${escape(s)}" rel="nofollow noopener">${escape(s)}</a>`;
|
|
78
|
+
}
|
|
79
|
+
return `<span class="${cls}">${escape(s)}</span>`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function badge(result) {
|
|
83
|
+
const r = text(result);
|
|
84
|
+
if (!r) {
|
|
85
|
+
return '';
|
|
86
|
+
}
|
|
87
|
+
const colour = RESULT_COLOURS[r] || '#495057';
|
|
88
|
+
return `<span class="tr-badge" style="background-color: ${colour}">${escape(r)}</span>`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function score(v) {
|
|
92
|
+
return typeof v === 'number' && Number.isFinite(v) ? escape(String(v)) : '';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Build a query string from a plain object, dropping empty values. */
|
|
96
|
+
function qs(params) {
|
|
97
|
+
const u = new URLSearchParams();
|
|
98
|
+
for (const [k, v] of Object.entries(params)) {
|
|
99
|
+
for (const x of (Array.isArray(v) ? v : [v])) {
|
|
100
|
+
if (typeof x === 'string' && x !== '') {
|
|
101
|
+
u.append(k, x);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const s = u.toString();
|
|
106
|
+
return s ? '?' + s : '';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A canonical URL (or participant uri), as its configured name where there is one: the
|
|
111
|
+
* name links to the configured link, the full URL is in the title, and any |version is
|
|
112
|
+
* shown after the name. Otherwise just the URL.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} url
|
|
115
|
+
* @param {Map<string, {name: string, link: string|null}>} [names] - by unversioned canonical
|
|
116
|
+
* @param {string} [version] - a version to show that is not part of the url (participant.version)
|
|
117
|
+
*/
|
|
118
|
+
function canonicalHtml(url, names, version) {
|
|
119
|
+
const s = text(url);
|
|
120
|
+
if (!s) {
|
|
121
|
+
return '';
|
|
122
|
+
}
|
|
123
|
+
const bar = s.indexOf('|');
|
|
124
|
+
const bare = bar < 0 ? s : s.substring(0, bar);
|
|
125
|
+
const ver = version || (bar < 0 ? '' : s.substring(bar + 1));
|
|
126
|
+
const known = names && names.get(bare);
|
|
127
|
+
let h;
|
|
128
|
+
if (known) {
|
|
129
|
+
h = known.link && /^https?:\/\/[^\s"'<>]+$/i.test(known.link)
|
|
130
|
+
? `<a href="${escape(known.link)}" title="${escape(s)}" rel="nofollow noopener">${escape(known.name)}</a>`
|
|
131
|
+
: `<span title="${escape(s)}">${escape(known.name)}</span>`;
|
|
132
|
+
return h + (ver ? ` <small>${escape(ver)}</small>` : '');
|
|
133
|
+
}
|
|
134
|
+
return uriLink(s) + (version ? ` <small>${escape(version)}</small>` : '');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function testScriptLabel(ts, names) {
|
|
138
|
+
return ts ? canonicalHtml(ts, names) : '<i>(no test script)</i>';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** "850 ms", "12.3 s", "4m 05s", "1h 02m" */
|
|
142
|
+
function runLength(ms) {
|
|
143
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms)) {
|
|
144
|
+
return '';
|
|
145
|
+
}
|
|
146
|
+
if (ms < 1000) {
|
|
147
|
+
return `${Math.round(ms)} ms`;
|
|
148
|
+
}
|
|
149
|
+
if (ms < 60000) {
|
|
150
|
+
return `${(ms / 1000).toFixed(1)} s`;
|
|
151
|
+
}
|
|
152
|
+
const secs = Math.round(ms / 1000);
|
|
153
|
+
if (secs < 3600) {
|
|
154
|
+
return `${Math.floor(secs / 60)}m ${String(secs % 60).padStart(2, '0')}s`;
|
|
155
|
+
}
|
|
156
|
+
const mins = Math.round(secs / 60);
|
|
157
|
+
return `${Math.floor(mins / 60)}h ${String(mins % 60).padStart(2, '0')}m`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The hidden field every POST form carries: the CSRF token (lusca checks it). */
|
|
161
|
+
function csrfField(token) {
|
|
162
|
+
return token ? `<input type="hidden" name="_csrf" value="${escape(token)}"/>` : '';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The line at the top of every page: who is logged in, and what they can get to.
|
|
167
|
+
* @param {{base: string, user?: Object, csrf?: string}} ctx
|
|
168
|
+
*/
|
|
169
|
+
function userBar(ctx) {
|
|
170
|
+
const base = escape(ctx.base);
|
|
171
|
+
const u = ctx.user;
|
|
172
|
+
if (!u) {
|
|
173
|
+
return `<div style="float: right; font-size: 85%"><a href="${base}/login">Login</a></div>`;
|
|
174
|
+
}
|
|
175
|
+
const parts = [`Logged in as <b>${escape(u.name)}</b>`];
|
|
176
|
+
if (u.canEditLinks) {
|
|
177
|
+
parts.push(`<a href="${base}/admin/links">Named links</a>`);
|
|
178
|
+
}
|
|
179
|
+
if (u.isAdmin) {
|
|
180
|
+
parts.push(`<a href="${base}/admin/users">Users</a>`);
|
|
181
|
+
}
|
|
182
|
+
parts.push(`<form method="post" action="${base}/logout" style="display: inline">${csrfField(ctx.csrf)}<button type="submit" class="btn btn-link" style="padding: 0; font-size: 100%; vertical-align: baseline">Logout</button></form>`);
|
|
183
|
+
return `<div style="float: right; font-size: 85%">${parts.join(' | ')}</div>`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---- the list ------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
// form fields that are not FHIR search parameters, and what they turn into
|
|
189
|
+
const HELPERS = {
|
|
190
|
+
'issued-from': ['issued', 'ge'],
|
|
191
|
+
'issued-to': ['issued', 'le'],
|
|
192
|
+
'received-from': ['_lastUpdated', 'ge'],
|
|
193
|
+
'received-to': ['_lastUpdated', 'le'],
|
|
194
|
+
'score-min': ['score', 'ge'],
|
|
195
|
+
'score-max': ['score', 'le']
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Turn the list page's query (FHIR parameters plus the form's helper fields) into a
|
|
200
|
+
* pure FHIR search query.
|
|
201
|
+
*/
|
|
202
|
+
function listQueryToSearch(query) {
|
|
203
|
+
const out = {};
|
|
204
|
+
const add = (k, v) => {
|
|
205
|
+
if (out[k] === undefined) {
|
|
206
|
+
out[k] = v;
|
|
207
|
+
} else {
|
|
208
|
+
out[k] = [].concat(out[k], v);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
for (const [k, v] of Object.entries(query || {})) {
|
|
212
|
+
if (HELPERS[k]) {
|
|
213
|
+
const [param, prefix] = HELPERS[k];
|
|
214
|
+
for (const x of (Array.isArray(v) ? v : [v])) {
|
|
215
|
+
if (typeof x === 'string' && x.trim() !== '') {
|
|
216
|
+
add(param, prefix + x.trim());
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
add(k, v);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function field(query, name) {
|
|
227
|
+
const v = query[name];
|
|
228
|
+
return typeof v === 'string' ? v : (Array.isArray(v) && typeof v[0] === 'string' ? v[0] : '');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function select(query, name, values, label, anyLabel) {
|
|
232
|
+
const cur = field(query, name);
|
|
233
|
+
let h = `<select id="f-${escape(name)}" name="${escape(name)}" title="${escape(label)}"><option value="">${escape(anyLabel || label + ': any')}</option>`;
|
|
234
|
+
for (const v of values) {
|
|
235
|
+
h += `<option value="${escape(v)}"${v === cur ? ' selected' : ''}>${escape(v)}</option>`;
|
|
236
|
+
}
|
|
237
|
+
return h + '</select>';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function input(query, name, label, type = 'text', placeholder = label) {
|
|
241
|
+
return `<input type="${type}" id="f-${escape(name)}" name="${escape(name)}" placeholder="${escape(placeholder)}" ` +
|
|
242
|
+
`title="${escape(label)}" aria-label="${escape(label)}" value="${escape(field(query, name))}"/>`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Just the date part of a FHIR date/dateTime/instant, as sent. */
|
|
246
|
+
function dateOnly(v) {
|
|
247
|
+
const s = text(v);
|
|
248
|
+
return /^\d{4}(-\d{2}(-\d{2})?)?/.test(s) ? s.match(/^\d{4}(-\d{2}(-\d{2})?)?/)[0] : s;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// [sort parameter, heading]; null parameter = not sortable. Participants are split by type
|
|
252
|
+
const LIST_COLUMNS = [
|
|
253
|
+
['name', 'Name'], ['result', 'Result'], ['score', 'Score'], [null, 'Tests'],
|
|
254
|
+
['tester', 'Tester'], ['testscript', 'Test Script'],
|
|
255
|
+
[null, 'Test Engine', 'test-engine'], [null, 'Client', 'client'], [null, 'Server', 'server'],
|
|
256
|
+
['issued', 'Issued'], [null, 'Run Length'], ['_lastUpdated', 'Received']
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* @param {Object} query - the page's own query (form fields included)
|
|
261
|
+
* @param {{total: number, rows: Object[]}} results
|
|
262
|
+
* @param {Object} search - the parsed search (for count/offset/sort)
|
|
263
|
+
* @param {{results: string[], base: string, names?: Map, user?: Object, showClient?: boolean}} options -
|
|
264
|
+
* base is the module's path, e.g. /testing; names the named links; showClient whether any
|
|
265
|
+
* report has a client participant
|
|
266
|
+
*/
|
|
267
|
+
function renderList(query, results, search, options) {
|
|
268
|
+
const base = escape(options.base);
|
|
269
|
+
const names = options.names;
|
|
270
|
+
let h = STYLE + userBar(options);
|
|
271
|
+
h += `<p><a href="${base}/summary">Summary grid</a> | <a href="${base}/metadata">CapabilityStatement</a>` +
|
|
272
|
+
' | FHIR API: <code>POST TestReport</code>, <code>GET TestReport?...</code></p>';
|
|
273
|
+
|
|
274
|
+
// filter form: two lines - text filters and drop-downs, then ranges and buttons
|
|
275
|
+
h += '<form method="get" class="tr-filter mb-3"><div class="tr-line">';
|
|
276
|
+
h += input(query, 'name:contains', 'Name contains', 'text', 'Name');
|
|
277
|
+
h += input(query, 'tester:contains', 'Tester contains', 'text', 'Tester');
|
|
278
|
+
h += input(query, 'testscript:below', 'Test script starts with', 'text', 'Test script');
|
|
279
|
+
h += input(query, 'participant:below', 'Participant starts with', 'text', 'Participant');
|
|
280
|
+
h += select(query, 'result', options.results, 'Result');
|
|
281
|
+
h += select(query, '_count', ['20', '50', '100', '200', '500'], 'Page size', 'Page: 50');
|
|
282
|
+
h += '</div><div class="tr-line">';
|
|
283
|
+
h += `<span class="tr-lbl">Score</span>${input(query, 'score-min', 'Score ≥', 'number', 'min')}-${input(query, 'score-max', 'Score ≤', 'number', 'max')}`;
|
|
284
|
+
h += `<span class="tr-lbl">Issued</span>${input(query, 'issued-from', 'Issued from', 'date')}-${input(query, 'issued-to', 'Issued to', 'date')}`;
|
|
285
|
+
h += `<span class="tr-lbl">Received</span>${input(query, 'received-from', 'Received from', 'date')}-${input(query, 'received-to', 'Received to', 'date')}`;
|
|
286
|
+
if (field(query, '_sort')) {
|
|
287
|
+
h += `<input type="hidden" name="_sort" value="${escape(field(query, '_sort'))}"/>`;
|
|
288
|
+
}
|
|
289
|
+
h += '<button type="submit" class="btn btn-primary tr-btn">Filter</button>' +
|
|
290
|
+
`<a class="btn btn-default tr-btn" href="${base}">Clear</a>`;
|
|
291
|
+
h += '</div></form>';
|
|
292
|
+
|
|
293
|
+
// anything the search couldn't use
|
|
294
|
+
const from = results.total === 0 ? 0 : search.offset + 1;
|
|
295
|
+
const to = search.offset + results.rows.length;
|
|
296
|
+
h += `<p>${from}-${to} of ${results.total} report${results.total === 1 ? '' : 's'}</p>`;
|
|
297
|
+
|
|
298
|
+
// the table; column headers sort
|
|
299
|
+
const curSort = field(query, '_sort');
|
|
300
|
+
h += '<table class="table table-sm table-striped tr-table"><tr>';
|
|
301
|
+
const columns = LIST_COLUMNS.filter(c => c[2] !== 'client' || options.showClient);
|
|
302
|
+
for (const [param, label] of columns) {
|
|
303
|
+
if (!param) {
|
|
304
|
+
h += `<th>${escape(label)}</th>`;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
let next = param;
|
|
308
|
+
let arrow = '';
|
|
309
|
+
if (curSort === param) {
|
|
310
|
+
next = '-' + param;
|
|
311
|
+
arrow = ' ▲';
|
|
312
|
+
} else if (curSort === '-' + param) {
|
|
313
|
+
arrow = ' ▼';
|
|
314
|
+
}
|
|
315
|
+
const link = qs({ ...query, _sort: next, _offset: '' });
|
|
316
|
+
h += `<th><a href="${escape(link || '?')}">${escape(label)}</a>${arrow}</th>`;
|
|
317
|
+
}
|
|
318
|
+
h += '</tr>';
|
|
319
|
+
for (const r of results.rows) {
|
|
320
|
+
h += '<tr>';
|
|
321
|
+
h += `<td><a href="${base}/TestReport/${encodeURIComponent(r.id)}">${escape(r.name)}</a></td>`;
|
|
322
|
+
h += `<td>${badge(r.result)}</td>`;
|
|
323
|
+
h += `<td>${score(r.score)}</td>`;
|
|
324
|
+
h += `<td>${typeof r.test_count === 'number' ? r.test_count : ''}</td>`;
|
|
325
|
+
h += `<td>${escape(r.tester)}</td>`;
|
|
326
|
+
h += `<td>${testScriptLabel(r.test_script, names)}</td>`;
|
|
327
|
+
for (const [, , type] of columns.filter(c => c[2])) {
|
|
328
|
+
// anything that is not a test engine or a client is taken to be the server
|
|
329
|
+
const ps = r.participants.filter(p => type === 'server'
|
|
330
|
+
? p.type !== 'test-engine' && p.type !== 'client' : p.type === type);
|
|
331
|
+
h += `<td>${ps.map(p => canonicalHtml(p.uri, names, p.version)).join('<br/>')}</td>`;
|
|
332
|
+
}
|
|
333
|
+
h += `<td class="tr-date" title="${escape(r.issued)}">${escape(dateOnly(r.issued))}</td>`;
|
|
334
|
+
h += `<td class="tr-date">${escape(runLength(r.run_ms))}</td>`;
|
|
335
|
+
h += `<td class="tr-date" title="${escape(r.received)}">${escape(dateOnly(r.received))}</td>`;
|
|
336
|
+
h += '</tr>';
|
|
337
|
+
}
|
|
338
|
+
h += '</table>';
|
|
339
|
+
|
|
340
|
+
// paging
|
|
341
|
+
const links = [];
|
|
342
|
+
if (search.offset > 0) {
|
|
343
|
+
links.push(`<a href="${escape(qs({ ...query, _offset: '' }) || '?')}">First</a>`);
|
|
344
|
+
links.push(`<a href="${escape(qs({ ...query, _offset: String(Math.max(0, search.offset - search.count)) }))}">Previous</a>`);
|
|
345
|
+
}
|
|
346
|
+
if (search.count > 0 && search.offset + search.count < results.total) {
|
|
347
|
+
links.push(`<a href="${escape(qs({ ...query, _offset: String(search.offset + search.count) }))}">Next</a>`);
|
|
348
|
+
const last = Math.floor((results.total - 1) / search.count) * search.count;
|
|
349
|
+
links.push(`<a href="${escape(qs({ ...query, _offset: String(last) }))}">Last</a>`);
|
|
350
|
+
}
|
|
351
|
+
if (links.length > 0) {
|
|
352
|
+
h += `<p>${links.join(' | ')}</p>`;
|
|
353
|
+
}
|
|
354
|
+
return h;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---- the summary grid ----------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* @param {Array} cells - from store.summary()
|
|
361
|
+
* @param {'participant'|'tester'} by
|
|
362
|
+
* @param {string} baseUrl
|
|
363
|
+
* @param {Array} testers - from store.testerCounts()
|
|
364
|
+
* @param {{names?: Map, user?: Object}} [ctx]
|
|
365
|
+
*/
|
|
366
|
+
function renderSummary(cells, by, baseUrl, testers = [], ctx = {}) {
|
|
367
|
+
const base = escape(baseUrl);
|
|
368
|
+
let h = STYLE + userBar({ ...ctx, base: baseUrl });
|
|
369
|
+
const other = by === 'tester' ? 'participant' : 'tester';
|
|
370
|
+
h += `<p><a href="${base}">All reports</a> | Columns are ${by === 'tester' ? 'testers' : 'participants'} ` +
|
|
371
|
+
`(<a href="${base}/summary?by=${other}">show by ${other}</a>${by === 'tester' ? '' : '; test engines are left out'}). Each cell is the latest report (by issued date) ` +
|
|
372
|
+
'for that test script; the run count links to all of them.</p>';
|
|
373
|
+
if (cells.length === 0) {
|
|
374
|
+
return h + '<p>No reports have been received.</p>';
|
|
375
|
+
}
|
|
376
|
+
return h + summaryGrid(cells, by, base, ctx.names) + testerTable(testers, base);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** How many reports have come from each tester. */
|
|
380
|
+
function testerTable(testers, base) {
|
|
381
|
+
if (testers.length === 0) {
|
|
382
|
+
return '';
|
|
383
|
+
}
|
|
384
|
+
let h = '<h3>Reports by Tester</h3><table class="table table-sm table-striped tr-table" style="width: auto">' +
|
|
385
|
+
`<tr><th>Tester</th><th>Reports</th><th>${badge('pass')}</th><th>${badge('fail')}</th><th>Other</th><th>Latest</th></tr>`;
|
|
386
|
+
for (const t of testers) {
|
|
387
|
+
h += `<tr><td><a href="${base}${escape(qs({ 'tester:exact': t.tester, _sort: '-_lastUpdated' }))}">${escape(t.tester)}</a></td>` +
|
|
388
|
+
`<td>${t.reports}</td><td>${t.pass}</td><td>${t.fail}</td><td>${t.other}</td>` +
|
|
389
|
+
`<td class="tr-date" title="${escape(t.latest)}">${escape(dateOnly(t.latest))}</td></tr>`;
|
|
390
|
+
}
|
|
391
|
+
return h + '</table>';
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function summaryGrid(cells, by, base, names) {
|
|
395
|
+
let h = '';
|
|
396
|
+
const cols = [...new Set(cells.map(c => c.col))].sort();
|
|
397
|
+
const scripts = [...new Set(cells.map(c => c.test_script))];
|
|
398
|
+
const at = new Map(cells.map(c => [c.test_script + '\u0000' + c.col, c]));
|
|
399
|
+
h += '<div style="overflow-x: auto"><table class="table table-sm table-bordered tr-table tr-grid"><tr><th>Test Script</th>';
|
|
400
|
+
for (const c of cols) {
|
|
401
|
+
h += `<th class="tr-col">${by === 'tester' ? escape(c) : canonicalHtml(c, names)}</th>`;
|
|
402
|
+
}
|
|
403
|
+
h += '</tr>';
|
|
404
|
+
for (const s of scripts) {
|
|
405
|
+
h += `<tr><th style="text-align: left">${testScriptLabel(s, names)}</th>`;
|
|
406
|
+
for (const c of cols) {
|
|
407
|
+
const cell = at.get(s + '\u0000' + c);
|
|
408
|
+
if (!cell) {
|
|
409
|
+
h += '<td></td>';
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
const filter = { [by === 'tester' ? 'tester:exact' : 'participant']: c };
|
|
413
|
+
if (s) {
|
|
414
|
+
filter.testscript = s;
|
|
415
|
+
} else {
|
|
416
|
+
filter['testscript:missing'] = 'true';
|
|
417
|
+
}
|
|
418
|
+
const sc = score(cell.score);
|
|
419
|
+
h += `<td><a href="${base}/TestReport/${encodeURIComponent(cell.id)}" title="${escape(cell.name)} - ${escape(dateOnly(cell.issued))}">${badge(cell.result)}</a>` +
|
|
420
|
+
(sc ? `<br/>${sc}` : '') +
|
|
421
|
+
`<br/><a href="${base}${escape(qs({ ...filter, _sort: '-issued' }))}" style="font-size: 80%">${cell.runs} run${cell.runs === 1 ? '' : 's'}</a></td>`;
|
|
422
|
+
}
|
|
423
|
+
h += '</tr>';
|
|
424
|
+
}
|
|
425
|
+
return h + '</table></div>';
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ---- a single report -----------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
// top level elements the report view shows, or deliberately leaves to the raw JSON
|
|
431
|
+
const HANDLED = new Set([
|
|
432
|
+
'resourceType', 'id', 'meta', 'text', 'identifier', 'name', 'status', 'testScript', 'result', 'score',
|
|
433
|
+
'tester', 'issued', 'participant', 'setup', 'test', 'teardown', 'log'
|
|
434
|
+
]);
|
|
435
|
+
|
|
436
|
+
function jsonBlock(v) {
|
|
437
|
+
return `<div class="tr-json">${escape(JSON.stringify(v, null, 2))}</div>`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function periodText(p) {
|
|
441
|
+
if (!isObject(p)) {
|
|
442
|
+
return '';
|
|
443
|
+
}
|
|
444
|
+
const start = text(p.start);
|
|
445
|
+
const end = text(p.end);
|
|
446
|
+
if (start && end) {
|
|
447
|
+
return `${escape(start)} – ${escape(end)}`;
|
|
448
|
+
}
|
|
449
|
+
return start ? escape(start) + ' –' : (end ? '– ' + escape(end) : '');
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* A test's result. test.result where there is one; otherwise - reports in the R5 shape
|
|
454
|
+
* have none - the worst result among its actions, so that those still show something.
|
|
455
|
+
*/
|
|
456
|
+
const RESULT_RANK = ['error', 'fail', 'warning', 'pending', 'pass', 'skip'];
|
|
457
|
+
function testResult(t) {
|
|
458
|
+
if (text(t.result)) {
|
|
459
|
+
return text(t.result);
|
|
460
|
+
}
|
|
461
|
+
let best = null;
|
|
462
|
+
for (const action of asArray(t.action)) {
|
|
463
|
+
for (const kind of ['operation', 'assert']) {
|
|
464
|
+
const r = isObject(action) && isObject(action[kind]) ? text(action[kind].result) : '';
|
|
465
|
+
if (r) {
|
|
466
|
+
const rank = RESULT_RANK.indexOf(r) < 0 ? RESULT_RANK.length : RESULT_RANK.indexOf(r);
|
|
467
|
+
if (best === null || rank < best.rank) {
|
|
468
|
+
best = { r, rank };
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return best ? best.r : '';
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* The operations and assertions in a list of actions, in order, each as
|
|
478
|
+
* {type: 'operation'|'assertion', message, detail, result}.
|
|
479
|
+
*/
|
|
480
|
+
function actionSteps(actions) {
|
|
481
|
+
const steps = [];
|
|
482
|
+
for (const action of asArray(actions)) {
|
|
483
|
+
if (!isObject(action)) {
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
for (const [kind, type] of [['operation', 'operation'], ['assert', 'assertion']]) {
|
|
487
|
+
const a = action[kind];
|
|
488
|
+
if (isObject(a)) {
|
|
489
|
+
steps.push({ type, message: text(a.message), detail: text(a.detail), result: text(a.result) });
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return steps;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** A table of operations and assertions: type, message, details link, result. */
|
|
497
|
+
function actionsTable(actions) {
|
|
498
|
+
const steps = actionSteps(actions);
|
|
499
|
+
if (steps.length === 0) {
|
|
500
|
+
return '';
|
|
501
|
+
}
|
|
502
|
+
let h = '<table class="table table-sm table-bordered tr-table tr-steps"><tr><th>Type</th><th>Message</th><th>Details</th><th>Result</th></tr>';
|
|
503
|
+
for (const st of steps) {
|
|
504
|
+
const details = /^https?:\/\/[^\s"'<>]+$/i.test(st.detail)
|
|
505
|
+
? `<a href="${escape(st.detail)}" rel="nofollow noopener">details</a>`
|
|
506
|
+
: (st.detail ? `<span title="${escape(st.detail)}">details</span>` : '');
|
|
507
|
+
h += `<tr><td>${st.type}</td><td class="tr-msg">${escape(st.message)}</td><td>${details}</td><td>${badge(st.result)}</td></tr>`;
|
|
508
|
+
}
|
|
509
|
+
return h + '</table>';
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Whether a test's actions are worth a table: more than one operation or assertion, or a
|
|
514
|
+
* single one that says more than just its result.
|
|
515
|
+
*/
|
|
516
|
+
function hasActionDetail(t) {
|
|
517
|
+
const steps = actionSteps(t.action);
|
|
518
|
+
return steps.length > 1 || (steps.length === 1 && !!(steps[0].message || steps[0].detail));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function identifierText(ids) {
|
|
522
|
+
return (Array.isArray(ids) ? ids : [ids]).filter(isObject).map(i =>
|
|
523
|
+
(text(i.system) ? escape(text(i.system)) + ' | ' : '') + escape(text(i.value))).join('<br/>');
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* @param {Object} report - the stored TestReport
|
|
528
|
+
* @param {string} baseUrl
|
|
529
|
+
* @param {{names?: Map, user?: Object, csrf?: string}} [ctx]
|
|
530
|
+
*/
|
|
531
|
+
function renderReport(report, baseUrl, ctx = {}) {
|
|
532
|
+
const base = escape(baseUrl);
|
|
533
|
+
const names = ctx.names;
|
|
534
|
+
let h = STYLE + userBar({ ...ctx, base: baseUrl });
|
|
535
|
+
h += `<p><a href="${base}">All reports</a> | <a href="${base}/summary">Summary grid</a> | ` +
|
|
536
|
+
`<a href="${base}/TestReport/${encodeURIComponent(report.id)}?_format=json">JSON</a> | <a href="#raw">Raw JSON below</a></p>`;
|
|
537
|
+
if (ctx.user && ctx.user.canDeleteReports) {
|
|
538
|
+
h += `<form method="post" action="${base}/TestReport/${encodeURIComponent(report.id)}/delete" ` +
|
|
539
|
+
'onsubmit="return confirm(\'Delete this report?\')" style="margin-bottom: 10px">' + csrfField(ctx.csrf) +
|
|
540
|
+
'<button type="submit" class="btn btn-danger tr-btn" style="font-size: 85%; padding: 1px 10px">Delete this report</button></form>';
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// header
|
|
544
|
+
const ts = report.testScript;
|
|
545
|
+
let tsHtml;
|
|
546
|
+
if (typeof ts === 'string') {
|
|
547
|
+
tsHtml = canonicalHtml(ts, names);
|
|
548
|
+
} else if (isObject(ts)) {
|
|
549
|
+
tsHtml = canonicalHtml(ts.reference, names) + (text(ts.display) ? ' (' + escape(ts.display) + ')' : '');
|
|
550
|
+
} else {
|
|
551
|
+
tsHtml = '';
|
|
552
|
+
}
|
|
553
|
+
const rows = [
|
|
554
|
+
['Name', escape(text(report.name))],
|
|
555
|
+
['Status', escape(text(report.status))],
|
|
556
|
+
['Result', badge(report.result)],
|
|
557
|
+
['Score', score(report.score)],
|
|
558
|
+
['Tester', escape(text(report.tester))],
|
|
559
|
+
['Test Script', tsHtml],
|
|
560
|
+
['Issued', escape(text(report.issued))],
|
|
561
|
+
['Run Length', escape(runLength(runLengthOf(report)))],
|
|
562
|
+
['Received', escape(text(report.meta && report.meta.lastUpdated))],
|
|
563
|
+
['Identifier', report.identifier ? identifierText(report.identifier) : '']
|
|
564
|
+
];
|
|
565
|
+
h += '<table class="table table-sm tr-table" style="width: auto">';
|
|
566
|
+
for (const [k, v] of rows) {
|
|
567
|
+
if (v) {
|
|
568
|
+
h += `<tr><th>${escape(k)}</th><td>${v}</td></tr>`;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
const tests = asArray(report.test).filter(isObject);
|
|
572
|
+
if (tests.length > 0) {
|
|
573
|
+
const tally = {};
|
|
574
|
+
for (const t of tests) {
|
|
575
|
+
const r = testResult(t) || '(none)';
|
|
576
|
+
tally[r] = (tally[r] || 0) + 1;
|
|
577
|
+
}
|
|
578
|
+
h += '<tr><th>Tests</th><td>' + Object.keys(tally).sort().map(k => `${badge(k)} ${tally[k]}`).join(' ') + '</td></tr>';
|
|
579
|
+
}
|
|
580
|
+
h += '</table>';
|
|
581
|
+
|
|
582
|
+
// participants
|
|
583
|
+
h += '<h3>Participants</h3><table class="table table-sm tr-table" style="width: auto"><tr><th>Type</th><th>URI</th><th>Version</th><th>Display</th></tr>';
|
|
584
|
+
for (const p of asArray(report.participant)) {
|
|
585
|
+
if (isObject(p)) {
|
|
586
|
+
h += `<tr><td>${escape(text(p.type))}</td><td>${canonicalHtml(p.uri, names)}</td><td>${escape(text(p.version))}</td><td>${escape(text(p.display))}</td></tr>`;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
h += '</table>';
|
|
590
|
+
|
|
591
|
+
// setup, then the tests (one row each, with their operations and assertions under the
|
|
592
|
+
// description when there is more to them than a single result), then teardown. The log
|
|
593
|
+
// is left to the raw JSON
|
|
594
|
+
if (isObject(report.setup) && actionSteps(report.setup.action).length > 0) {
|
|
595
|
+
h += '<h3>Setup</h3>' + actionsTable(report.setup.action);
|
|
596
|
+
}
|
|
597
|
+
if (tests.length > 0) {
|
|
598
|
+
const detailed = tests.map(t => hasActionDetail(t));
|
|
599
|
+
const hasDesc = tests.some((t, i) => text(t.description) || detailed[i]);
|
|
600
|
+
const hasPeriod = tests.some(t => periodText(t.period));
|
|
601
|
+
h += '<h3>Tests</h3><table class="table table-sm table-striped tr-table"><tr><th>Name</th>' +
|
|
602
|
+
(hasDesc ? '<th>Description</th>' : '') + '<th>Result</th>' + (hasPeriod ? '<th>Period</th>' : '') + '</tr>';
|
|
603
|
+
tests.forEach((t, i) => {
|
|
604
|
+
h += `<tr><td>${escape(text(t.name))}</td>` +
|
|
605
|
+
(hasDesc ? `<td><div class="tr-msg">${escape(text(t.description))}</div>${detailed[i] ? actionsTable(t.action) : ''}</td>` : '') +
|
|
606
|
+
`<td>${badge(testResult(t))}</td>` +
|
|
607
|
+
(hasPeriod ? `<td>${periodText(t.period)}</td>` : '') + '</tr>';
|
|
608
|
+
});
|
|
609
|
+
h += '</table>';
|
|
610
|
+
}
|
|
611
|
+
if (isObject(report.teardown) && actionSteps(report.teardown.action).length > 0) {
|
|
612
|
+
h += '<h3>Teardown</h3>' + actionsTable(report.teardown.action);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// anything else
|
|
616
|
+
const others = Object.keys(report).filter(k => !HANDLED.has(k));
|
|
617
|
+
if (others.length > 0) {
|
|
618
|
+
h += '<h3>Other Content</h3><table class="table table-sm tr-table"><tr><th>Element</th><th>Value</th></tr>';
|
|
619
|
+
for (const k of others) {
|
|
620
|
+
h += `<tr><td>${escape(k)}</td><td>${jsonBlock(report[k])}</td></tr>`;
|
|
621
|
+
}
|
|
622
|
+
h += '</table>';
|
|
623
|
+
}
|
|
624
|
+
if (isObject(report.text)) {
|
|
625
|
+
h += '<p><i>The report has a narrative; it is not displayed here, but is in the raw JSON.</i></p>';
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
h += '<h3 id="raw">Raw JSON</h3>' + jsonBlock(report);
|
|
629
|
+
return h;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// ---- login and administration --------------------------------------------------------
|
|
633
|
+
|
|
634
|
+
function renderLogin(baseUrl, error, csrf) {
|
|
635
|
+
const base = escape(baseUrl);
|
|
636
|
+
let h = STYLE;
|
|
637
|
+
if (error) {
|
|
638
|
+
h += `<div class="alert alert-danger">${escape(error)}</div>`;
|
|
639
|
+
}
|
|
640
|
+
h += `<form method="post" action="${base}/login" style="max-width: 360px">${csrfField(csrf)}` +
|
|
641
|
+
'<div class="mb-3"><label for="login" class="form-label">Username</label>' +
|
|
642
|
+
'<input type="text" class="form-control" id="login" name="login" required autocomplete="username"/></div>' +
|
|
643
|
+
'<div class="mb-3"><label for="password" class="form-label">Password</label>' +
|
|
644
|
+
'<input type="password" class="form-control" id="password" name="password" required autocomplete="current-password"/></div>' +
|
|
645
|
+
'<button type="submit" class="btn btn-primary">Login</button></form>';
|
|
646
|
+
return h;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* The named links: canonical URLs that are shown by name.
|
|
651
|
+
* @param {Array} links - from store.links()
|
|
652
|
+
* @param {{base: string, user: Object}} ctx
|
|
653
|
+
*/
|
|
654
|
+
function renderLinks(links, ctx, message) {
|
|
655
|
+
const base = escape(ctx.base);
|
|
656
|
+
let h = STYLE + userBar(ctx);
|
|
657
|
+
h += `<p><a href="${base}">All reports</a> | <a href="${base}/summary">Summary grid</a></p>`;
|
|
658
|
+
h += '<p>Where a test script or participant URL (ignoring any <code>|version</code>) matches a canonical here, ' +
|
|
659
|
+
'the name is shown instead, linking to the link.</p>';
|
|
660
|
+
if (message) {
|
|
661
|
+
h += `<div class="alert alert-warning">${escape(message)}</div>`;
|
|
662
|
+
}
|
|
663
|
+
h += '<table class="table table-sm tr-table"><tr><th>Canonical</th><th>Name</th><th>Link</th><th></th></tr>';
|
|
664
|
+
const row = (l) => {
|
|
665
|
+
const action = l ? `${base}/admin/links/${l.id}` : `${base}/admin/links`;
|
|
666
|
+
const form = l ? `link-${l.id}` : 'link-new';
|
|
667
|
+
return `<tr><td><input form="${form}" type="text" name="canonical" required class="form-control" style="font-size: 85%" value="${escape(l ? l.canonical : '')}" placeholder="http://..."/></td>` +
|
|
668
|
+
`<td><input form="${form}" type="text" name="name" required class="form-control" style="font-size: 85%" value="${escape(l ? l.name : '')}"/></td>` +
|
|
669
|
+
`<td><input form="${form}" type="url" name="link" class="form-control" style="font-size: 85%" value="${escape(l ? l.link || '' : '')}" placeholder="https://..."/></td>` +
|
|
670
|
+
`<td style="white-space: nowrap"><form id="${form}" method="post" action="${action}" style="display: inline">${csrfField(ctx.csrf)}` +
|
|
671
|
+
`<button type="submit" class="btn btn-primary tr-btn" style="font-size: 85%; padding: 1px 10px">${l ? 'Save' : 'Add'}</button></form>` +
|
|
672
|
+
(l ? ` <form method="post" action="${action}/delete" style="display: inline" onsubmit="return confirm('Delete this link?')">${csrfField(ctx.csrf)}` +
|
|
673
|
+
'<button type="submit" class="btn btn-default tr-btn" style="font-size: 85%; padding: 1px 10px">Delete</button></form>' : '') +
|
|
674
|
+
'</td></tr>';
|
|
675
|
+
};
|
|
676
|
+
for (const l of links) {
|
|
677
|
+
h += row(l);
|
|
678
|
+
}
|
|
679
|
+
h += row(null);
|
|
680
|
+
return h + '</table>';
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* User administration (the administrator only).
|
|
685
|
+
* @param {Array} users - from store.users()
|
|
686
|
+
* @param {{base: string, user: Object}} ctx
|
|
687
|
+
*/
|
|
688
|
+
function renderUsers(users, ctx, message) {
|
|
689
|
+
const base = escape(ctx.base);
|
|
690
|
+
let h = STYLE + userBar(ctx);
|
|
691
|
+
h += `<p><a href="${base}">All reports</a> | <a href="${base}/admin/links">Named links</a></p>`;
|
|
692
|
+
h += '<p>The administrator (login <code>admin</code>, password in the configuration) can do everything. ' +
|
|
693
|
+
'Other users get the rights ticked here. Leave the password blank to keep the current one.</p>';
|
|
694
|
+
if (message) {
|
|
695
|
+
h += `<div class="alert alert-warning">${escape(message)}</div>`;
|
|
696
|
+
}
|
|
697
|
+
h += '<table class="table table-sm tr-table"><tr><th>Login</th><th>Name</th><th>Password</th><th>Edit named links</th><th>Delete reports</th><th></th></tr>';
|
|
698
|
+
const cb = (form, name, on) => `<input form="${form}" type="checkbox" name="${name}" value="1"${on ? ' checked' : ''}/>`;
|
|
699
|
+
for (const u of users) {
|
|
700
|
+
const form = `user-${u.id}`;
|
|
701
|
+
const action = `${base}/admin/users/${u.id}`;
|
|
702
|
+
h += `<tr><td>${escape(u.login)}</td>` +
|
|
703
|
+
`<td><input form="${form}" type="text" name="name" required class="form-control" style="font-size: 85%" value="${escape(u.name)}"/></td>` +
|
|
704
|
+
`<td><input form="${form}" type="password" name="password" class="form-control" style="font-size: 85%" autocomplete="new-password" placeholder="(unchanged)"/></td>` +
|
|
705
|
+
`<td>${cb(form, 'editLinks', u.can_edit_links)}</td><td>${cb(form, 'deleteReports', u.can_delete_reports)}</td>` +
|
|
706
|
+
`<td style="white-space: nowrap"><form id="${form}" method="post" action="${action}" style="display: inline">${csrfField(ctx.csrf)}` +
|
|
707
|
+
'<button type="submit" class="btn btn-primary tr-btn" style="font-size: 85%; padding: 1px 10px">Save</button></form> ' +
|
|
708
|
+
`<form method="post" action="${action}/delete" style="display: inline" onsubmit="return confirm('Delete this user?')">${csrfField(ctx.csrf)}` +
|
|
709
|
+
'<button type="submit" class="btn btn-default tr-btn" style="font-size: 85%; padding: 1px 10px">Delete</button></form></td></tr>';
|
|
710
|
+
}
|
|
711
|
+
h += `<tr><td><input form="user-new" type="text" name="login" required class="form-control" style="font-size: 85%" autocomplete="off"/></td>` +
|
|
712
|
+
'<td><input form="user-new" type="text" name="name" required class="form-control" style="font-size: 85%"/></td>' +
|
|
713
|
+
'<td><input form="user-new" type="password" name="password" required class="form-control" style="font-size: 85%" autocomplete="new-password"/></td>' +
|
|
714
|
+
`<td>${cb('user-new', 'editLinks', false)}</td><td>${cb('user-new', 'deleteReports', false)}</td>` +
|
|
715
|
+
`<td><form id="user-new" method="post" action="${base}/admin/users" style="display: inline">${csrfField(ctx.csrf)}` +
|
|
716
|
+
'<button type="submit" class="btn btn-primary tr-btn" style="font-size: 85%; padding: 1px 10px">Add</button></form></td></tr>';
|
|
717
|
+
return h + '</table>';
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
module.exports = {
|
|
721
|
+
renderList, renderSummary, renderReport, renderLogin, renderLinks, renderUsers,
|
|
722
|
+
listQueryToSearch, uriLink, badge, canonicalHtml, runLength
|
|
723
|
+
};
|