fhirsmith 0.13.3 → 0.14.0
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 +47 -1
- package/README.md +48 -4
- package/config-template.json +70 -1
- package/library/cgroup-memory.js +113 -0
- package/library/folder-setup.js +8 -1
- package/nginx.md +391 -0
- package/package.json +3 -2
- package/publisher/git-remote.js +141 -0
- package/publisher/github-release.js +195 -0
- package/publisher/publisher.js +146 -14
- package/server.js +51 -14
- package/stats.js +19 -13
- package/testing/readme.md +112 -0
- package/testing/render.js +482 -0
- package/testing/search.js +378 -0
- package/testing/store.js +266 -0
- package/testing/testing-template.html +126 -0
- package/testing/testing.js +486 -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 +142 -27
- 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/extensions.js +2 -2
- package/tx/library/operation-outcome.js +3 -1
- package/tx/library/renderer.js +42 -5
- package/tx/operation-context.js +111 -15
- package/tx/provider.js +85 -0
- package/tx/sct/expressions.js +128 -44
- package/tx/sct/text-index.js +419 -0
- 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 +20 -0
- package/tx/workers/metadata.js +29 -4
- package/tx/workers/read.js +18 -3
- package/tx/workers/subsumes.js +18 -2
- package/tx/workers/translate.js +167 -20
- package/tx/workers/validate.js +61 -182
- package/tx/workers/worker.js +9 -0
- package/tx/xversion/xv-conceptmap.js +17 -8
- package/utilities/submit-test-report.js +150 -0
package/tx/tx-html.js
CHANGED
|
@@ -20,6 +20,7 @@ const {ParametersXML} = require("./xml/parameters-xml");
|
|
|
20
20
|
const {OperationOutcomeXML} = require("./xml/operationoutcome-xml");
|
|
21
21
|
const {debugLog} = require("./operation-context");
|
|
22
22
|
const {InvalidError} = require("./library/errors");
|
|
23
|
+
const {VALID_FILTER_OPS} = require("./library/renderer");
|
|
23
24
|
|
|
24
25
|
const txHtmlLog = Logger.getInstance().child({ module: 'tx-html' });
|
|
25
26
|
|
|
@@ -50,6 +51,51 @@ const SORT_OPTIONS = ['', 'id', 'url', 'version', 'date', 'name', 'vurl'];
|
|
|
50
51
|
|
|
51
52
|
const ELEMENT_OPTIONS = ['id', 'url', 'version', 'name', 'title', 'status', 'date', 'publisher', 'description'];
|
|
52
53
|
|
|
54
|
+
// Marks a value set that exists only for the request that carried it - the ones the
|
|
55
|
+
// operations tab on a CodeSystem builds and posts to $expand. Nobody else ever sees it,
|
|
56
|
+
// so rendering its uuid url and its status tells the user nothing; what they want is the
|
|
57
|
+
// expansion, and the filters it came from. This is FHIRsmith's own extension, in
|
|
58
|
+
// FHIRsmith's own namespace: it says something about how this server was asked to do
|
|
59
|
+
// something, not about terminology, so it does not belong in the FHIR tools namespace.
|
|
60
|
+
const SNOMED_URI = 'http://snomed.info/sct';
|
|
61
|
+
|
|
62
|
+
const TRANSIENT_VALUESET = 'http://healthintersections.com.au/fhirsmith/StructureDefinition/valueset-transient';
|
|
63
|
+
|
|
64
|
+
const SEVERITY_ALERT_CLASS = {
|
|
65
|
+
fatal: 'alert-danger',
|
|
66
|
+
error: 'alert-danger',
|
|
67
|
+
warning: 'alert-warning',
|
|
68
|
+
information: 'alert-info',
|
|
69
|
+
success: 'alert-info'
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const SEVERITY_LABEL = {
|
|
73
|
+
fatal: 'Fatal',
|
|
74
|
+
error: 'Error',
|
|
75
|
+
warning: 'Warning',
|
|
76
|
+
information: 'Information',
|
|
77
|
+
success: 'Success'
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// ValueSet.compose.include.filter.op, in R5 spec order - the operations offered
|
|
81
|
+
// by the $filter form on the CodeSystem operations tab
|
|
82
|
+
const FILTER_OPS = [...VALID_FILTER_OPS];
|
|
83
|
+
|
|
84
|
+
// Filter/property names defined by the base specification, valid for any code system
|
|
85
|
+
const BASE_FILTER_PROPERTIES = ['code', 'designation', 'concept', 'status', 'inactive', 'regex'];
|
|
86
|
+
|
|
87
|
+
// Filter/property names defined by particular code systems. Any property or filter
|
|
88
|
+
// declared in the CodeSystem resource itself is added to these at render time
|
|
89
|
+
const SYSTEM_FILTER_PROPERTIES = {
|
|
90
|
+
'http://www.ama-assn.org/go/cpt': ['modifier', 'modified', 'kind', 'orthopox', 'telemedicine', 'code'],
|
|
91
|
+
'http://loinc.org': ['STATUS', 'COMPONENT', 'PROPERTY', 'TIME_ASPCT', 'SYSTEM', 'SCALE_TYP', 'METHOD_TYP',
|
|
92
|
+
'CLASS', 'CONSUMER_NAME', 'CLASSTYPE', 'ORDER_OBS', 'DOCUMENT_SECTION', 'copyright'],
|
|
93
|
+
'http://www.nlm.nih.gov/research/umls/rxnorm': ['STY', 'SAB', 'TTY'],
|
|
94
|
+
'http://snomed.info/sct': ['constraint', 'expressions', 'effectiveTime', 'inactive', 'moduleId',
|
|
95
|
+
'normalForm', 'normalFormTerse', 'semanticTag', 'sufficientlyDefined'],
|
|
96
|
+
'http://unitsofmeasure.org': ['property', 'canonical']
|
|
97
|
+
};
|
|
98
|
+
|
|
53
99
|
/**
|
|
54
100
|
* Load the TX HTML template
|
|
55
101
|
*/
|
|
@@ -86,6 +132,14 @@ function acceptsHtml(req) {
|
|
|
86
132
|
return _fmt.includes('text/html');
|
|
87
133
|
}
|
|
88
134
|
|
|
135
|
+
/** Whether this resource is one of the throwaway value sets described at TRANSIENT_VALUESET. */
|
|
136
|
+
function isTransientValueSet(json) {
|
|
137
|
+
if (!json || json.resourceType !== 'ValueSet') {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
return (json.extension || []).some((e) => e.url === TRANSIENT_VALUESET && e.valueBoolean !== false);
|
|
141
|
+
}
|
|
142
|
+
|
|
89
143
|
// Whether this server publishes its library source YAML. Off unless the operator turns
|
|
90
144
|
// modules.tx.publishLibrarySource on, and it gates both the route and the nav item, so a
|
|
91
145
|
// server that does not publish it has no link to a 404.
|
|
@@ -253,6 +307,10 @@ class TxHtmlRenderer {
|
|
|
253
307
|
return `${severity.charAt(0).toUpperCase() + severity.slice(1)}`;
|
|
254
308
|
}
|
|
255
309
|
|
|
310
|
+
if (isTransientValueSet(json)) {
|
|
311
|
+
return 'Expansion';
|
|
312
|
+
}
|
|
313
|
+
|
|
256
314
|
if (json.id) {
|
|
257
315
|
return `${pfx} ${json.id}`;
|
|
258
316
|
}
|
|
@@ -403,7 +461,7 @@ class TxHtmlRenderer {
|
|
|
403
461
|
|
|
404
462
|
for (const factory of uniqueFactories) {
|
|
405
463
|
html += '<tr>';
|
|
406
|
-
html += `<td>${
|
|
464
|
+
html += `<td>${this.factoryLink(factory)}</td>`;
|
|
407
465
|
html += `<td>${escape(factory.system())}</td>`;
|
|
408
466
|
html += `<td>${escape(factory.version() || '-')}</td>`;
|
|
409
467
|
html += `<td>${factory.useCount ? factory.useCount() : '-'}</td>`;
|
|
@@ -416,6 +474,80 @@ class TxHtmlRenderer {
|
|
|
416
474
|
return html;
|
|
417
475
|
}
|
|
418
476
|
|
|
477
|
+
/**
|
|
478
|
+
* The name of a special code system, linked to its page where there is one.
|
|
479
|
+
*
|
|
480
|
+
* These are the code systems the server implements natively rather than loading as a
|
|
481
|
+
* resource, so they have no id of their own in the resource space. They are still
|
|
482
|
+
* readable: read.js serves them under the factory's id with an "x-" prefix, which is
|
|
483
|
+
* also the id search.js puts on the placeholder it synthesises. A factory whose id() is
|
|
484
|
+
* null - SNOMED, when its version is not one of the recognised edition URIs - has no
|
|
485
|
+
* page to link to, so the name is left as text rather than pointing at a 404.
|
|
486
|
+
*
|
|
487
|
+
* @param {Object} factory a CodeSystemFactoryProvider
|
|
488
|
+
* @returns {string} the cell content
|
|
489
|
+
*/
|
|
490
|
+
factoryLink(factory) {
|
|
491
|
+
const name = escape(factory.name());
|
|
492
|
+
let id;
|
|
493
|
+
try {
|
|
494
|
+
id = factory.id();
|
|
495
|
+
} catch {
|
|
496
|
+
id = null;
|
|
497
|
+
}
|
|
498
|
+
if (!id) {
|
|
499
|
+
return name;
|
|
500
|
+
}
|
|
501
|
+
return `<a href="${escape(this.path)}/CodeSystem/x-${encodeURIComponent(id)}">${name}</a>`;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* The SNOMED CT editions this endpoint has loaded: the default one first, because it is
|
|
506
|
+
* what the rest of the server uses when no version is given, then the others by name.
|
|
507
|
+
* The factory map is keyed several ways for the same factory - by system, by
|
|
508
|
+
* system|version and by system|major.minor - so it has to be deduplicated.
|
|
509
|
+
*
|
|
510
|
+
* @param {Object} provider the endpoint's provider
|
|
511
|
+
* @returns {{value: string, label: string, isDefault: boolean}[]}
|
|
512
|
+
*/
|
|
513
|
+
snomedVersions(provider) {
|
|
514
|
+
const byVersion = new Map();
|
|
515
|
+
const dflt = provider.codeSystemFactories.get(SNOMED_URI);
|
|
516
|
+
for (const factory of provider.codeSystemFactories.values()) {
|
|
517
|
+
if (factory.system() !== SNOMED_URI) {
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
const version = factory.version();
|
|
521
|
+
if (!version || byVersion.has(version)) {
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
byVersion.set(version, {
|
|
525
|
+
value: version,
|
|
526
|
+
label: factory.describeVersion(version),
|
|
527
|
+
isDefault: factory === dflt
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
const versions = [...byVersion.values()];
|
|
531
|
+
versions.sort((a, b) => (a.isDefault === b.isDefault ? a.label.localeCompare(b.label) : (a.isDefault ? -1 : 1)));
|
|
532
|
+
return versions;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* The ECL panel at <endpoint>/ecl.
|
|
537
|
+
*/
|
|
538
|
+
async buildEclPage(req) {
|
|
539
|
+
const versions = this.snomedVersions(req.txProvider);
|
|
540
|
+
return await this.liquid.renderFile('ecl-panel', {
|
|
541
|
+
eclId: this.generateResourceId(),
|
|
542
|
+
system: escape(SNOMED_URI),
|
|
543
|
+
transientExtension: escape(TRANSIENT_VALUESET),
|
|
544
|
+
expandUrl: escape(this.path + '/ValueSet/$expand?_format=html/fragment&includeDefinition=true'),
|
|
545
|
+
versionOptions: versions.map((v) =>
|
|
546
|
+
`<option value="${escape(v.value)}">${escape(v.label)}${v.isDefault ? ' (default)' : ''}</option>`).join(''),
|
|
547
|
+
hasVersions: versions.length > 0
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
419
551
|
/**
|
|
420
552
|
* Main render - determines what to render based on resource type
|
|
421
553
|
*/
|
|
@@ -478,55 +610,127 @@ class TxHtmlRenderer {
|
|
|
478
610
|
* Render Parameters resource
|
|
479
611
|
*/
|
|
480
612
|
async renderParameters(json) {
|
|
613
|
+
const codes = await this.parametersCodeContext(json);
|
|
481
614
|
let html = '<table class="table grid">';
|
|
482
615
|
html += '<thead><tr><th>Name</th><th>Value</th></tr></thead>';
|
|
483
616
|
html += '<tbody>';
|
|
484
617
|
|
|
485
618
|
if (json.parameter && Array.isArray(json.parameter)) {
|
|
486
619
|
for (const param of json.parameter) {
|
|
487
|
-
html += await this.renderParameter(param);
|
|
620
|
+
html += await this.renderParameter(param, codes);
|
|
488
621
|
}
|
|
489
622
|
}
|
|
490
623
|
|
|
491
624
|
html += '</tbody></table>';
|
|
625
|
+
html += this.renderJsonSource(json);
|
|
492
626
|
|
|
493
|
-
|
|
627
|
+
return html;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* The "Show JSON Source" disclosure: the resource as it went on the wire, for
|
|
632
|
+
* anything the HTML rendering summarises or leaves out.
|
|
633
|
+
*/
|
|
634
|
+
renderJsonSource(json) {
|
|
494
635
|
const resourceId = this.generateResourceId();
|
|
495
|
-
html
|
|
636
|
+
let html = '<div class="json-source">';
|
|
496
637
|
html += `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="toggleJsonSource('${resourceId}')">`;
|
|
497
638
|
html += 'Show JSON Source</button>';
|
|
498
639
|
html += `<div id="${resourceId}" class="json-content" style="display: none; margin-top: 10px;">`;
|
|
499
640
|
html += `<pre>${escape(JSON.stringify(json, null, 2))}</pre>`;
|
|
500
641
|
html += '</div>';
|
|
501
642
|
html += '</div>';
|
|
502
|
-
|
|
503
643
|
return html;
|
|
504
644
|
}
|
|
505
645
|
|
|
506
646
|
/**
|
|
507
647
|
* Render a single parameter row
|
|
508
648
|
*/
|
|
509
|
-
async renderParameter(param) {
|
|
649
|
+
async renderParameter(param, codes) {
|
|
510
650
|
let html = '<tr>';
|
|
511
651
|
html += `<td>${escape(param.name || '')}</td>`;
|
|
512
652
|
html += '<td>';
|
|
513
|
-
html += await this.renderParameterValue(param);
|
|
653
|
+
html += await this.renderParameterValue(param, codes, null);
|
|
514
654
|
html += '</td>';
|
|
515
655
|
html += '</tr>';
|
|
516
656
|
return html;
|
|
517
657
|
}
|
|
518
658
|
|
|
659
|
+
/**
|
|
660
|
+
* What a Parameters resource says about the code system its codes belong to.
|
|
661
|
+
*
|
|
662
|
+
* $lookup answers about one concept, and names the code system it is in - the system
|
|
663
|
+
* and version parameters - so a property that came back as a code can be linked back
|
|
664
|
+
* into it. Building a checker here means the code system is built once for the page
|
|
665
|
+
* rather than once per property.
|
|
666
|
+
*
|
|
667
|
+
* @param {Object} json a Parameters resource
|
|
668
|
+
* @returns {Promise<{system: string, version: string|null, exists: function}|null>}
|
|
669
|
+
*/
|
|
670
|
+
async parametersCodeContext(json) {
|
|
671
|
+
const value = (name) => (json.parameter || []).find((p) => p.name === name);
|
|
672
|
+
const system = value('system')?.valueUri || value('system')?.valueString;
|
|
673
|
+
if (!system || !this.renderer?.linkResolver?.codeChecker || !this.renderer.opContext) {
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
const version = value('version')?.valueString || null;
|
|
677
|
+
try {
|
|
678
|
+
const exists = await this.renderer.linkResolver.codeChecker(this.renderer.opContext, system, version);
|
|
679
|
+
return exists ? { system, version, exists } : null;
|
|
680
|
+
} catch (e) {
|
|
681
|
+
// A link is a courtesy; failing to work out whether to offer one is not a reason
|
|
682
|
+
// to fail the page.
|
|
683
|
+
debugLog(e);
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* A code in a $lookup response, linked to $lookup on it where that will work.
|
|
690
|
+
*
|
|
691
|
+
* Which values are links follows from the output alone. A Coding says which code system
|
|
692
|
+
* it is in. A code does not - but CodeSystem.property.type of 'code' is defined as a
|
|
693
|
+
* concept in the same code system, so a property's value part that came back as a code
|
|
694
|
+
* belongs to the system this response is about. Nothing else does: the code part of a
|
|
695
|
+
* property is the property's name, and a designation's language and status are codes
|
|
696
|
+
* from elsewhere entirely.
|
|
697
|
+
*
|
|
698
|
+
* Even then the code is checked before it is linked, because a provider can declare a
|
|
699
|
+
* property as a code and put something else in it.
|
|
700
|
+
*/
|
|
701
|
+
async renderPropertyCode(code, codes) {
|
|
702
|
+
const plain = `<code>${escape(code)}</code>`;
|
|
703
|
+
if (!codes) {
|
|
704
|
+
return plain;
|
|
705
|
+
}
|
|
706
|
+
let display;
|
|
707
|
+
try {
|
|
708
|
+
display = await codes.exists(code);
|
|
709
|
+
} catch (e) {
|
|
710
|
+
debugLog(e);
|
|
711
|
+
return plain;
|
|
712
|
+
}
|
|
713
|
+
if (display === null || display === undefined) {
|
|
714
|
+
return plain;
|
|
715
|
+
}
|
|
716
|
+
const link = this.renderer.linkResolver.lookupLink(codes.system, codes.version, code);
|
|
717
|
+
if (!link) {
|
|
718
|
+
return plain;
|
|
719
|
+
}
|
|
720
|
+
return `<a href="${escape(link)}"${display ? ` title="${escape(display)}"` : ''}>${plain}</a>`;
|
|
721
|
+
}
|
|
722
|
+
|
|
519
723
|
/**
|
|
520
724
|
* Render the value portion of a parameter
|
|
521
725
|
*/
|
|
522
|
-
async renderParameterValue(param) {
|
|
726
|
+
async renderParameterValue(param, codes, parentName) {
|
|
523
727
|
// Check for parts (nested parameters)
|
|
524
728
|
if (param.part && Array.isArray(param.part)) {
|
|
525
729
|
let html = '<ul>';
|
|
526
730
|
for (const part of param.part) {
|
|
527
731
|
html += '<li>';
|
|
528
732
|
html += `<strong>${escape(part.name || '')}:</strong> `;
|
|
529
|
-
html += await this.renderParameterValue(part);
|
|
733
|
+
html += await this.renderParameterValue(part, codes, param.name);
|
|
530
734
|
html += '</li>';
|
|
531
735
|
}
|
|
532
736
|
html += '</ul>';
|
|
@@ -540,10 +744,10 @@ class TxHtmlRenderer {
|
|
|
540
744
|
|
|
541
745
|
// Check for complex datatypes
|
|
542
746
|
if (param.valueCoding) {
|
|
543
|
-
return this.renderCoding(param.valueCoding);
|
|
747
|
+
return await this.renderCoding(param.valueCoding, codes);
|
|
544
748
|
}
|
|
545
749
|
if (param.valueCodeableConcept) {
|
|
546
|
-
return this.renderCodeableConcept(param.valueCodeableConcept);
|
|
750
|
+
return await this.renderCodeableConcept(param.valueCodeableConcept, codes);
|
|
547
751
|
}
|
|
548
752
|
if (param.valueQuantity) {
|
|
549
753
|
return this.renderQuantity(param.valueQuantity);
|
|
@@ -581,6 +785,9 @@ class TxHtmlRenderer {
|
|
|
581
785
|
return escape(param.valueCanonical);
|
|
582
786
|
}
|
|
583
787
|
if (param.valueCode !== undefined) {
|
|
788
|
+
if (param.name === 'value' && ['property', 'subproperty'].includes(parentName)) {
|
|
789
|
+
return await this.renderPropertyCode(param.valueCode, codes);
|
|
790
|
+
}
|
|
584
791
|
return `<code>${escape(param.valueCode)}</code>`;
|
|
585
792
|
}
|
|
586
793
|
if (param.valueId !== undefined) {
|
|
@@ -629,7 +836,7 @@ class TxHtmlRenderer {
|
|
|
629
836
|
/**
|
|
630
837
|
* Render Coding datatype
|
|
631
838
|
*/
|
|
632
|
-
async renderCoding(coding) {
|
|
839
|
+
async renderCoding(coding, codes) {
|
|
633
840
|
if (!coding) return '';
|
|
634
841
|
|
|
635
842
|
let parts = [];
|
|
@@ -637,7 +844,14 @@ class TxHtmlRenderer {
|
|
|
637
844
|
parts.push(escape(coding.system));
|
|
638
845
|
}
|
|
639
846
|
if (coding.code) {
|
|
640
|
-
|
|
847
|
+
// A Coding says which code system it is in, so it can be linked - but only when
|
|
848
|
+
// that is the code system this page already built a checker for. Building one per
|
|
849
|
+
// coding would mean building a code system per row.
|
|
850
|
+
const sameSystem = codes && coding.system === codes.system
|
|
851
|
+
&& (!coding.version || !codes.version || coding.version === codes.version);
|
|
852
|
+
parts.push(sameSystem
|
|
853
|
+
? await this.renderPropertyCode(coding.code, codes)
|
|
854
|
+
: `<code>${escape(coding.code)}</code>`);
|
|
641
855
|
}
|
|
642
856
|
if (coding.display) {
|
|
643
857
|
parts.push(`"${escape(coding.display)}"`);
|
|
@@ -652,7 +866,7 @@ class TxHtmlRenderer {
|
|
|
652
866
|
/**
|
|
653
867
|
* Render CodeableConcept datatype
|
|
654
868
|
*/
|
|
655
|
-
async renderCodeableConcept(cc) {
|
|
869
|
+
async renderCodeableConcept(cc, codes) {
|
|
656
870
|
if (!cc) return '';
|
|
657
871
|
|
|
658
872
|
let html = '';
|
|
@@ -665,7 +879,7 @@ class TxHtmlRenderer {
|
|
|
665
879
|
if (cc.text) html += '<br/>';
|
|
666
880
|
html += '<ul style="margin: 0; padding-left: 20px;">';
|
|
667
881
|
for (const coding of cc.coding) {
|
|
668
|
-
html += `<li>${this.renderCoding(coding)}</li>`;
|
|
882
|
+
html += `<li>${await this.renderCoding(coding, codes)}</li>`;
|
|
669
883
|
}
|
|
670
884
|
html += '</ul>';
|
|
671
885
|
}
|
|
@@ -787,7 +1001,7 @@ class TxHtmlRenderer {
|
|
|
787
1001
|
html += this.tab(_fmt && _fmt == 'html/json', 'JSON', json.resourceType, 'html/json', json.id);
|
|
788
1002
|
html += this.tab(_fmt && _fmt == 'html/xml', 'XML', json.resourceType, 'html/xml', json.id);
|
|
789
1003
|
html += this.tab(_fmt && _fmt == 'html/narrative', 'Original Narrative', json.resourceType, 'html/narrative', json.id);
|
|
790
|
-
html += this.tab(_fmt && _fmt == 'html/ops', '
|
|
1004
|
+
html += this.tab(_fmt && _fmt == 'html/ops', 'Operations', json.resourceType, 'html/ops', json.id);
|
|
791
1005
|
html += `</ul>`;
|
|
792
1006
|
|
|
793
1007
|
if (!_fmt || _fmt == 'html') {
|
|
@@ -803,7 +1017,13 @@ class TxHtmlRenderer {
|
|
|
803
1017
|
opsId: this.generateResourceId(),
|
|
804
1018
|
vcSystemId: this.generateResourceId(),
|
|
805
1019
|
inferSystemId: this.generateResourceId(),
|
|
806
|
-
|
|
1020
|
+
searchId: this.generateResourceId(),
|
|
1021
|
+
filterId: this.generateResourceId(),
|
|
1022
|
+
url: escape(json.url || ''),
|
|
1023
|
+
systemVersion: escape(json.version || ''),
|
|
1024
|
+
expandUrl: escape(this.path + '/ValueSet/$expand?_format=html'),
|
|
1025
|
+
filterOpOptions: FILTER_OPS.map((op) => `<option value="${escape(op)}">${escape(op)}</option>`).join(''),
|
|
1026
|
+
filterPropertyOptions: this.buildFilterPropertyNames(json).map((n) => `<option value="${escape(n)}"></option>`).join('')
|
|
807
1027
|
});
|
|
808
1028
|
}
|
|
809
1029
|
|
|
@@ -823,6 +1043,9 @@ class TxHtmlRenderer {
|
|
|
823
1043
|
* Render ValueSet resource
|
|
824
1044
|
*/
|
|
825
1045
|
async renderValueSet(json, inBundle, _fmt, op, exp) {
|
|
1046
|
+
if (isTransientValueSet(json) && json.expansion) {
|
|
1047
|
+
return await this.renderTransientExpansion(json, _fmt === 'html/fragment');
|
|
1048
|
+
}
|
|
826
1049
|
if (inBundle || op) {
|
|
827
1050
|
return await this.renderResourceWithNarrative(json, await this.renderer.renderValueSet(json));
|
|
828
1051
|
} else {
|
|
@@ -862,6 +1085,113 @@ class TxHtmlRenderer {
|
|
|
862
1085
|
}
|
|
863
1086
|
}
|
|
864
1087
|
|
|
1088
|
+
/**
|
|
1089
|
+
* Render the expansion of a value set that only ever existed for this request.
|
|
1090
|
+
*
|
|
1091
|
+
* The usual value set summary is all metadata - defining url, status, expansion
|
|
1092
|
+
* identifier - and every one of those is a uuid this server minted a moment ago and
|
|
1093
|
+
* will never use again, so it says nothing to anyone. What is worth saying is what was
|
|
1094
|
+
* expanded and how, so that is what leads: the code system, the text searched for if
|
|
1095
|
+
* there was one, and, when the expansion came from a filter, the compose that produced
|
|
1096
|
+
* it - as JSON, because a filter is read by people who work in JSON.
|
|
1097
|
+
*
|
|
1098
|
+
* None of that when the rendering is a fragment, though. A fragment goes inside a page
|
|
1099
|
+
* that has just been used to ask the question - the ECL panel still has the expression
|
|
1100
|
+
* and the edition on screen - so restating them, and warning that a SNOMED expansion is
|
|
1101
|
+
* not closed, is telling the user what they are looking at. There the expansion alone
|
|
1102
|
+
* is the answer.
|
|
1103
|
+
*
|
|
1104
|
+
* @param {Object} json the expanded value set
|
|
1105
|
+
* @param {boolean} embedded whether this is going inside a page that already has the context
|
|
1106
|
+
*/
|
|
1107
|
+
async renderTransientExpansion(json, embedded) {
|
|
1108
|
+
const include = json.compose?.include?.[0] || {};
|
|
1109
|
+
const filter = (json.expansion.parameter || []).find((p) => p.name === 'filter')?.valueString;
|
|
1110
|
+
|
|
1111
|
+
let html = '<div class="narrative">';
|
|
1112
|
+
|
|
1113
|
+
if (!embedded) {
|
|
1114
|
+
html += '<p>An expansion of a value set built for this request alone - this server does '
|
|
1115
|
+
+ 'not hold it, and it has no URL of its own.</p>';
|
|
1116
|
+
|
|
1117
|
+
html += '<table class="grid">';
|
|
1118
|
+
if (include.system) {
|
|
1119
|
+
html += `<tr><td><b>Code System</b></td><td>${escape(include.system)}</td></tr>`;
|
|
1120
|
+
}
|
|
1121
|
+
if (include.version) {
|
|
1122
|
+
html += `<tr><td><b>Version</b></td><td>${escape(include.version)}</td></tr>`;
|
|
1123
|
+
}
|
|
1124
|
+
if (filter) {
|
|
1125
|
+
html += `<tr><td><b>Text Search</b></td><td>${escape(filter)}</td></tr>`;
|
|
1126
|
+
}
|
|
1127
|
+
html += '</table>';
|
|
1128
|
+
|
|
1129
|
+
if ((include.filter || []).length > 0) {
|
|
1130
|
+
html += '<h3>Filters</h3>';
|
|
1131
|
+
html += `<pre>${escape(JSON.stringify(json.compose, null, 2))}</pre>`;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// The expansion, without the two identifiers that are noise here: the value set's own
|
|
1136
|
+
// url and the expansion identifier. Everything else - timestamp, total, the
|
|
1137
|
+
// parameters the server echoed back - is real information about this request.
|
|
1138
|
+
// compose stays: renderVSExpansion never renders it, but it is where the version each
|
|
1139
|
+
// code system was expanded at can be read from, for the $lookup links on the codes.
|
|
1140
|
+
const shown = structuredClone(json);
|
|
1141
|
+
delete shown.url;
|
|
1142
|
+
delete shown.expansion.identifier;
|
|
1143
|
+
|
|
1144
|
+
if (embedded) {
|
|
1145
|
+
// The unclosed warning and the list of code systems the expansion drew on say the
|
|
1146
|
+
// same thing the page already said. A too-costly warning is not in that class - it
|
|
1147
|
+
// means the answer on screen is not the whole answer - so it stays.
|
|
1148
|
+
shown.expansion.extension = (shown.expansion.extension || [])
|
|
1149
|
+
.filter((e) => !e.url.startsWith('http://hl7.org/fhir/StructureDefinition/valueset-unclosed'));
|
|
1150
|
+
shown.expansion.parameter = (shown.expansion.parameter || [])
|
|
1151
|
+
.filter((p) => !['used-codesystem', 'used-valueset', 'used-supplement'].includes(p.name));
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
if (embedded) {
|
|
1155
|
+
html += this.expansionCount(json.expansion);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
html += await this.renderer.renderVSExpansion(shown, !embedded);
|
|
1159
|
+
html += '</div>';
|
|
1160
|
+
html += this.renderJsonSource(json);
|
|
1161
|
+
return html;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* How many concepts an expansion found, as one line.
|
|
1166
|
+
*
|
|
1167
|
+
* On a page of its own this is in the expansion properties table; in a fragment that
|
|
1168
|
+
* table is gone, and the count is the one thing there that the reader cannot work out by
|
|
1169
|
+
* looking - it is usually the answer they were after. total is the size of the whole
|
|
1170
|
+
* expansion, which is not what is on screen when a page was asked for, and some paths
|
|
1171
|
+
* report no total at all, so both are said when they differ.
|
|
1172
|
+
*
|
|
1173
|
+
* @param {Object} expansion the ValueSet.expansion
|
|
1174
|
+
* @returns {string} a paragraph, or nothing when the expansion is empty (the renderer
|
|
1175
|
+
* says so itself in that case)
|
|
1176
|
+
*/
|
|
1177
|
+
expansionCount(expansion) {
|
|
1178
|
+
const concepts = (list) => (list || []).reduce((n, c) => n + 1 + concepts(c.contains), 0);
|
|
1179
|
+
const plural = (n) => `${n} concept${n === 1 ? '' : 's'}`;
|
|
1180
|
+
|
|
1181
|
+
const shown = concepts(expansion.contains);
|
|
1182
|
+
const total = typeof expansion.total === 'number' ? expansion.total : null;
|
|
1183
|
+
if (total === null && shown === 0) {
|
|
1184
|
+
return '';
|
|
1185
|
+
}
|
|
1186
|
+
if (total === null) {
|
|
1187
|
+
return `<p><b>${plural(shown)}</b></p>`;
|
|
1188
|
+
}
|
|
1189
|
+
if (total === 0) {
|
|
1190
|
+
return '';
|
|
1191
|
+
}
|
|
1192
|
+
return `<p><b>${plural(total)}</b>${total === shown ? '' : ` (${shown} shown)`}</p>`;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
865
1195
|
/**
|
|
866
1196
|
* Render ConceptMap resource
|
|
867
1197
|
*/
|
|
@@ -927,41 +1257,68 @@ class TxHtmlRenderer {
|
|
|
927
1257
|
}
|
|
928
1258
|
|
|
929
1259
|
/**
|
|
930
|
-
* Render OperationOutcome resource
|
|
1260
|
+
* Render OperationOutcome resource.
|
|
1261
|
+
*
|
|
1262
|
+
* details.text is the human account of the problem; diagnostics is server-specific
|
|
1263
|
+
* detail, most often the operation's timing trace. So the text leads, and the
|
|
1264
|
+
* diagnostics are kept but folded away - rendering diagnostics first (as this did)
|
|
1265
|
+
* turns a perfectly good error message into a row of milliseconds.
|
|
931
1266
|
*/
|
|
932
1267
|
async renderOperationOutcome(json) {
|
|
933
1268
|
let html = '<div class="operation-outcome">';
|
|
934
|
-
html += `<h4>OperationOutcome</h4>`;
|
|
935
|
-
|
|
936
|
-
if (json.issue && Array.isArray(json.issue)) {
|
|
937
|
-
for (const issue of json.issue) {
|
|
938
|
-
html += '<div class="alert ';
|
|
939
|
-
|
|
940
|
-
// Determine alert style based on this issue's severity
|
|
941
|
-
const severity = issue.severity || 'information';
|
|
942
|
-
switch (severity) {
|
|
943
|
-
case 'error':
|
|
944
|
-
case 'fatal':
|
|
945
|
-
html += 'alert-danger';
|
|
946
|
-
break;
|
|
947
|
-
case 'warning':
|
|
948
|
-
html += 'alert-warning';
|
|
949
|
-
break;
|
|
950
|
-
case 'information':
|
|
951
|
-
html += 'alert-info';
|
|
952
|
-
break;
|
|
953
|
-
default:
|
|
954
|
-
html += 'alert-secondary';
|
|
955
|
-
}
|
|
956
1269
|
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1270
|
+
for (const issue of json.issue || []) {
|
|
1271
|
+
const severity = issue.severity || 'information';
|
|
1272
|
+
html += `<div class="alert ${SEVERITY_ALERT_CLASS[severity] || 'alert-secondary'}">`;
|
|
1273
|
+
html += `<strong>${escape(SEVERITY_LABEL[severity] || severity)}</strong>: `;
|
|
1274
|
+
|
|
1275
|
+
const text = issue.details?.text || issue.diagnostics;
|
|
1276
|
+
html += text ? escape(text) : '<em>(this issue carries no message)</em>';
|
|
1277
|
+
|
|
1278
|
+
// The issue-type and the tx-issue-type, which are usually - but not always - the
|
|
1279
|
+
// same word, plus the message id if the issue names one. Shown because they are
|
|
1280
|
+
// what a client actually branches on.
|
|
1281
|
+
const codes = [];
|
|
1282
|
+
if (issue.code) {
|
|
1283
|
+
codes.push(issue.code);
|
|
1284
|
+
}
|
|
1285
|
+
for (const coding of issue.details?.coding || []) {
|
|
1286
|
+
if (coding.code && !codes.includes(coding.code)) {
|
|
1287
|
+
codes.push(coding.code);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
const msgId = (issue.extension || []).find((e) =>
|
|
1291
|
+
e.url === 'http://hl7.org/fhir/StructureDefinition/operationoutcome-message-id')?.valueString;
|
|
1292
|
+
if (msgId) {
|
|
1293
|
+
codes.push(msgId);
|
|
1294
|
+
}
|
|
1295
|
+
const where = [...(issue.expression || []), ...(issue.location || [])];
|
|
1296
|
+
if (codes.length > 0 || where.length > 0) {
|
|
1297
|
+
html += '<div style="margin-top: 6px; font-size: 90%;">';
|
|
1298
|
+
html += codes.map((c) => `<code>${escape(c)}</code>`).join(' ');
|
|
1299
|
+
if (where.length > 0) {
|
|
1300
|
+
html += (codes.length > 0 ? ' at ' : 'at ') + where.map((w) => `<code>${escape(w)}</code>`).join(', ');
|
|
1301
|
+
}
|
|
961
1302
|
html += '</div>';
|
|
962
1303
|
}
|
|
1304
|
+
|
|
1305
|
+
// Not when it is already the message - an issue with nothing but diagnostics
|
|
1306
|
+
// should not say the same sentence twice.
|
|
1307
|
+
if (issue.diagnostics && issue.diagnostics !== text) {
|
|
1308
|
+
html += '<details style="margin-top: 6px; font-size: 90%;">';
|
|
1309
|
+
html += '<summary>Server diagnostics</summary>';
|
|
1310
|
+
html += `<pre style="margin-top: 6px;">${escape(issue.diagnostics)}</pre>`;
|
|
1311
|
+
html += '</details>';
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
html += '</div>';
|
|
963
1315
|
}
|
|
964
1316
|
|
|
1317
|
+
if (!json.issue || json.issue.length === 0) {
|
|
1318
|
+
html += '<div class="alert alert-secondary"><em>(no issues)</em></div>';
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
html += this.renderJsonSource(json);
|
|
965
1322
|
html += '</div>';
|
|
966
1323
|
return html;
|
|
967
1324
|
}
|
|
@@ -1391,6 +1748,31 @@ class TxHtmlRenderer {
|
|
|
1391
1748
|
let
|
|
1392
1749
|
resourceIdCounter = 0;
|
|
1393
1750
|
|
|
1751
|
+
/**
|
|
1752
|
+
* The property/filter names offered by the $filter form for a code system: the names
|
|
1753
|
+
* the base specification defines for any code system, then any names known for this
|
|
1754
|
+
* particular code system, then anything the CodeSystem resource declares itself.
|
|
1755
|
+
* @param {Object} json the CodeSystem resource
|
|
1756
|
+
* @returns {string[]} names, in that order, without duplicates
|
|
1757
|
+
*/
|
|
1758
|
+
buildFilterPropertyNames(json) {
|
|
1759
|
+
const names = new Set(BASE_FILTER_PROPERTIES);
|
|
1760
|
+
for (const name of SYSTEM_FILTER_PROPERTIES[json.url] || []) {
|
|
1761
|
+
names.add(name);
|
|
1762
|
+
}
|
|
1763
|
+
for (const prop of json.property || []) {
|
|
1764
|
+
if (prop.code) {
|
|
1765
|
+
names.add(prop.code);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
for (const filter of json.filter || []) {
|
|
1769
|
+
if (filter.code) {
|
|
1770
|
+
names.add(filter.code);
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
return [...names];
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1394
1776
|
generateResourceId() {
|
|
1395
1777
|
return 'resource_' + (++this.resourceIdCounter);
|
|
1396
1778
|
}
|