fhirsmith 0.17.0 → 0.18.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 +83 -0
- package/README.md +80 -13
- package/config-template.json +106 -1
- package/ips/ai-client.js +263 -0
- package/ips/composer.js +805 -0
- package/ips/editor-schema.js +289 -0
- package/ips/editor.js +290 -0
- package/ips/fhirpath-ips.js +397 -0
- package/ips/ips-codeable-concepts.md +1742 -0
- package/ips/ips-template.html +125 -0
- package/ips/ips-valuesets.js +159 -0
- package/ips/ips.js +1736 -0
- package/ips/membership.js +179 -0
- package/ips/pipeline.js +1012 -0
- package/ips/presenter.js +974 -0
- package/ips/profile-editor.js +578 -0
- package/ips/profiles.js +506 -0
- package/ips/readme.md +460 -0
- package/ips/render.js +1482 -0
- package/ips/search-params.js +83 -0
- package/ips/search.js +667 -0
- package/ips/share.js +178 -0
- package/ips/store.js +650 -0
- package/ips/terminology.js +395 -0
- package/ips/tx-client.js +202 -0
- package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
- package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
- package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
- package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
- package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
- package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
- package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
- package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
- package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
- package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
- package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
- package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
- package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-medication.json +50 -0
- package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
- package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
- package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
- package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
- package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
- package/ips/valuesets/ValueSet-ips-route.json +46 -0
- package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
- package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
- package/ips/valuesets/elements.json +44 -0
- package/library/daily-report.js +422 -0
- package/library/fhir-search-values.js +138 -0
- package/library/html-server.js +32 -23
- package/library/mailer.js +77 -0
- package/library/narrative-check.js +21 -2
- package/library/npm-audit.js +25 -0
- package/library/package-manager.js +4 -2
- package/library/sqlite-session-store.js +29 -1
- package/package.json +7 -2
- package/packages/packages.js +34 -3
- package/publisher/publisher.js +29 -0
- package/registry/auth-store.js +366 -0
- package/registry/auth.js +872 -0
- package/registry/crawler.js +41 -1
- package/registry/documentation/api.md +162 -0
- package/registry/documentation/configuration.md +93 -0
- package/registry/documentation/documents.md +74 -0
- package/registry/documentation/internals.md +151 -0
- package/registry/documentation/readme.md +100 -0
- package/registry/documentation/security.md +199 -0
- package/registry/documents.js +309 -0
- package/registry/openapi.yaml +30 -0
- package/registry/registry-template.html +1 -1
- package/registry/registry.js +230 -44
- package/registry/uptime.js +154 -0
- package/server.js +105 -44
- package/shl/shl.js +64 -9
- package/stats-db.js +93 -0
- package/stats.js +158 -1
- package/testing/search.js +2 -108
- package/testing/store.js +5 -0
- package/testing/testing.js +20 -0
- package/token/token-template.html +5 -4
- package/token/token.js +17 -10
- package/translations/Messages.properties +9 -0
- package/tx/README.md +52 -0
- package/tx/cs/cs-api.js +90 -14
- package/tx/cs/cs-cs.js +56 -1
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +418 -0
- package/tx/cs/cs-loinc.js +47 -25
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +918 -55
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/library.js +98 -4
- package/tx/openapi-operations.js +4 -0
- package/tx/params.js +8 -1
- package/tx/provider.js +16 -4
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/syndication/syndication-feed.js +218 -0
- package/tx/syndication/syndication-store.js +415 -0
- package/tx/tx-html.js +15 -1
- package/tx/tx.fhir.org.yml +2 -3
- package/tx/tx.js +144 -2
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/expand.js +33 -6
- package/tx/workers/lookup.js +61 -6
- package/tx/workers/subsumes.js +1 -1
- package/tx/workers/translate.js +166 -15
- package/tx/workers/validate.js +128 -6
- package/utilities/make-sct-test-extension.js +264 -0
- package/xig/xig.js +32 -0
- package/registry/readme.md +0 -124
|
@@ -0,0 +1,289 @@
|
|
|
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
|
+
* What the IPS editor edits: the patient and the document's header, and the sections, each
|
|
9
|
+
* with the fields of the resource it holds - the ones the presenter renders, with coded
|
|
10
|
+
* fields bound to the value sets the terminology check holds them to (ips/valuesets/), so
|
|
11
|
+
* the editor can look codes up. One declaration drives the form in the browser
|
|
12
|
+
* (editor.js), the building of the document from what was entered, and the reading of a
|
|
13
|
+
* document back into the form (composer.js).
|
|
14
|
+
*
|
|
15
|
+
* A field is { name, label, type, ... }:
|
|
16
|
+
* text, textarea, date, datetime strings
|
|
17
|
+
* select one of options [{ code, display }]; `system` makes it a
|
|
18
|
+
* CodeableConcept, else it is a code
|
|
19
|
+
* code a coding looked up in `valueSet` (an id in ips/valuesets/),
|
|
20
|
+
* or typed as text; { system, code, display, text }
|
|
21
|
+
* quantity { value, unit }
|
|
22
|
+
* and `path` says where it goes in the resource: an element name, with [x] for a choice
|
|
23
|
+
* (onset[x] takes the type from the field: date -> onsetDateTime; the composer decides) and
|
|
24
|
+
* a.b for nesting. A section's `subs` are repeating parts of the resource (reactions,
|
|
25
|
+
* components...), each with fields of its own and a `path` to the repeating element.
|
|
26
|
+
*
|
|
27
|
+
* @module ips/editor-schema
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const VS = 'http://healthintersections.com.au/fhirsmith/ValueSet/';
|
|
31
|
+
const SCT = 'http://snomed.info/sct';
|
|
32
|
+
const LOINC = 'http://loinc.org';
|
|
33
|
+
const THO = 'http://terminology.hl7.org/CodeSystem/';
|
|
34
|
+
|
|
35
|
+
const EMPTY_REASONS = [
|
|
36
|
+
{ code: 'nilknown', display: 'Nil known' },
|
|
37
|
+
{ code: 'notasked', display: 'Not asked' },
|
|
38
|
+
{ code: 'withheld', display: 'Information withheld' },
|
|
39
|
+
{ code: 'unavailable', display: 'Unavailable' },
|
|
40
|
+
{ code: 'notstarted', display: 'Not started' },
|
|
41
|
+
{ code: 'closed', display: 'Closed' }
|
|
42
|
+
];
|
|
43
|
+
const EMPTY_REASON_SYSTEM = THO + 'list-empty-reason';
|
|
44
|
+
|
|
45
|
+
const CLINICAL_STATUS = { system: THO + 'condition-clinical', options: [
|
|
46
|
+
{ code: 'active', display: 'Active' }, { code: 'recurrence', display: 'Recurrence' }, { code: 'relapse', display: 'Relapse' },
|
|
47
|
+
{ code: 'inactive', display: 'Inactive' }, { code: 'remission', display: 'Remission' }, { code: 'resolved', display: 'Resolved' }
|
|
48
|
+
] };
|
|
49
|
+
const VERIFICATION_STATUS = { system: THO + 'condition-ver-status', options: [
|
|
50
|
+
{ code: 'unconfirmed', display: 'Unconfirmed' }, { code: 'provisional', display: 'Provisional' }, { code: 'differential', display: 'Differential' },
|
|
51
|
+
{ code: 'confirmed', display: 'Confirmed' }, { code: 'refuted', display: 'Refuted' }, { code: 'entered-in-error', display: 'Entered in error' }
|
|
52
|
+
] };
|
|
53
|
+
const ALLERGY_CLINICAL = { system: THO + 'allergyintolerance-clinical', options: [
|
|
54
|
+
{ code: 'active', display: 'Active' }, { code: 'inactive', display: 'Inactive' }, { code: 'resolved', display: 'Resolved' }
|
|
55
|
+
] };
|
|
56
|
+
const ALLERGY_VERIFICATION = { system: THO + 'allergyintolerance-verification', options: [
|
|
57
|
+
{ code: 'unconfirmed', display: 'Unconfirmed' }, { code: 'confirmed', display: 'Confirmed' }, { code: 'refuted', display: 'Refuted' }, { code: 'entered-in-error', display: 'Entered in error' }
|
|
58
|
+
] };
|
|
59
|
+
const SEVERITY = [{ code: 'mild', display: 'Mild' }, { code: 'moderate', display: 'Moderate' }, { code: 'severe', display: 'Severe' }];
|
|
60
|
+
|
|
61
|
+
const sel = (name, label, options, extra = {}) => ({ name, label, type: 'select', options, ...extra });
|
|
62
|
+
const code = (name, label, valueSet, extra = {}) => ({ name, label, type: 'code', valueSet, ...extra });
|
|
63
|
+
const text = (name, label, extra = {}) => ({ name, label, type: 'text', ...extra });
|
|
64
|
+
const date = (name, label, extra = {}) => ({ name, label, type: 'date', ...extra });
|
|
65
|
+
const note = { name: 'note', label: 'Note', type: 'textarea', path: 'note' };
|
|
66
|
+
|
|
67
|
+
/** The patient, and the document's header. */
|
|
68
|
+
const HEADER = {
|
|
69
|
+
patient: [
|
|
70
|
+
text('family', 'Family name', { required: true }),
|
|
71
|
+
text('given', 'Given names'),
|
|
72
|
+
sel('gender', 'Gender', [{ code: 'male', display: 'Male' }, { code: 'female', display: 'Female' }, { code: 'other', display: 'Other' }, { code: 'unknown', display: 'Unknown' }]),
|
|
73
|
+
date('birthDate', 'Date of birth'),
|
|
74
|
+
text('identifierSystem', 'Identifier system', { placeholder: 'urn:oid:... or a URL' }),
|
|
75
|
+
text('identifier', 'Identifier'),
|
|
76
|
+
text('phone', 'Phone'),
|
|
77
|
+
text('email', 'Email'),
|
|
78
|
+
text('address', 'Address', { placeholder: 'line, city, postcode, country' })
|
|
79
|
+
],
|
|
80
|
+
document: [
|
|
81
|
+
text('title', 'Title', { placeholder: 'Patient Summary as of ...' }),
|
|
82
|
+
text('author', 'Author', { placeholder: 'the person or organization writing it', required: true }),
|
|
83
|
+
sel('authorType', 'Author is', [{ code: 'Practitioner', display: 'a practitioner' }, { code: 'Organization', display: 'an organization' }]),
|
|
84
|
+
text('custodian', 'Custodian', { placeholder: 'the organization that holds the record' }),
|
|
85
|
+
date('date', 'Date')
|
|
86
|
+
]
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** The sections, in the IPS order. */
|
|
90
|
+
const SECTIONS = [
|
|
91
|
+
{
|
|
92
|
+
key: 'problems', code: '11450-4', title: 'Problem List', required: true, resourceType: 'Condition', label: 'Problem',
|
|
93
|
+
fields: [
|
|
94
|
+
code('code', 'Problem', 'ips-condition-code', { path: 'code', required: true }),
|
|
95
|
+
sel('clinicalStatus', 'Clinical status', CLINICAL_STATUS.options, { system: CLINICAL_STATUS.system, path: 'clinicalStatus', default: 'active' }),
|
|
96
|
+
sel('verificationStatus', 'Verification', VERIFICATION_STATUS.options, { system: VERIFICATION_STATUS.system, path: 'verificationStatus' }),
|
|
97
|
+
code('severity', 'Severity', 'ips-condition-severity', { path: 'severity' }),
|
|
98
|
+
date('onset', 'Onset', { path: 'onset[x]' }),
|
|
99
|
+
date('abatement', 'Abatement', { path: 'abatement[x]' }),
|
|
100
|
+
code('bodySite', 'Body site', 'ips-body-site', { path: 'bodySite' }),
|
|
101
|
+
note
|
|
102
|
+
],
|
|
103
|
+
fixed: { category: [{ coding: [{ system: THO + 'condition-category', code: 'problem-list-item', display: 'Problem List Item' }] }] }
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
key: 'allergies', code: '48765-2', title: 'Allergies and Intolerances', required: true, resourceType: 'AllergyIntolerance', label: 'Allergy / intolerance',
|
|
107
|
+
fields: [
|
|
108
|
+
code('code', 'Allergy / intolerance', 'ips-allergy-code', { path: 'code', required: true }),
|
|
109
|
+
sel('type', 'Type', [{ code: 'allergy', display: 'Allergy' }, { code: 'intolerance', display: 'Intolerance' }], { path: 'type' }),
|
|
110
|
+
sel('category', 'Category', [{ code: 'food', display: 'Food' }, { code: 'medication', display: 'Medication' }, { code: 'environment', display: 'Environment' }, { code: 'biologic', display: 'Biologic' }], { path: 'category', list: true }),
|
|
111
|
+
sel('criticality', 'Criticality', [{ code: 'low', display: 'Low' }, { code: 'high', display: 'High' }, { code: 'unable-to-assess', display: 'Unable to assess' }], { path: 'criticality' }),
|
|
112
|
+
sel('clinicalStatus', 'Clinical status', ALLERGY_CLINICAL.options, { system: ALLERGY_CLINICAL.system, path: 'clinicalStatus', default: 'active' }),
|
|
113
|
+
sel('verificationStatus', 'Verification', ALLERGY_VERIFICATION.options, { system: ALLERGY_VERIFICATION.system, path: 'verificationStatus' }),
|
|
114
|
+
date('onset', 'Onset', { path: 'onset[x]' }),
|
|
115
|
+
note
|
|
116
|
+
],
|
|
117
|
+
subs: [{
|
|
118
|
+
name: 'reactions', label: 'Reactions', path: 'reaction',
|
|
119
|
+
fields: [
|
|
120
|
+
code('manifestation', 'Manifestation', 'ips-allergy-manifestation', { path: 'manifestation', list: true, required: true }),
|
|
121
|
+
sel('severity', 'Severity', SEVERITY, { path: 'severity' }),
|
|
122
|
+
date('onset', 'Onset', { path: 'onset' }),
|
|
123
|
+
text('description', 'Description', { path: 'description' })
|
|
124
|
+
]
|
|
125
|
+
}]
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
key: 'medications', code: '10160-0', title: 'Medication Summary', required: true, resourceType: 'MedicationStatement', label: 'Medication',
|
|
129
|
+
fields: [
|
|
130
|
+
code('medication', 'Medication', 'ips-medication', { path: 'medication[x]', required: true }),
|
|
131
|
+
sel('status', 'Status', [{ code: 'active', display: 'Active' }, { code: 'completed', display: 'Completed' }, { code: 'intended', display: 'Intended' }, { code: 'stopped', display: 'Stopped' }, { code: 'on-hold', display: 'On hold' }, { code: 'not-taken', display: 'Not taken' }], { path: 'status', default: 'active', required: true }),
|
|
132
|
+
date('start', 'From', { path: 'effectivePeriod.start' }),
|
|
133
|
+
date('end', 'Until', { path: 'effectivePeriod.end' }),
|
|
134
|
+
code('reason', 'Reason', 'ips-clinical-finding-or-none', { path: 'reasonCode', list: true }),
|
|
135
|
+
text('dosage', 'Dosage', { path: 'dosage.text', placeholder: 'e.g. 1 tablet twice daily' }),
|
|
136
|
+
code('route', 'Route', 'ips-route', { path: 'dosage.route' }),
|
|
137
|
+
note
|
|
138
|
+
]
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
key: 'immunizations', code: '11369-6', title: 'Immunizations', resourceType: 'Immunization', label: 'Immunization',
|
|
142
|
+
fields: [
|
|
143
|
+
code('vaccine', 'Vaccine', 'ips-vaccine', { path: 'vaccineCode', required: true }),
|
|
144
|
+
sel('status', 'Status', [{ code: 'completed', display: 'Completed' }, { code: 'not-done', display: 'Not done' }, { code: 'entered-in-error', display: 'Entered in error' }], { path: 'status', default: 'completed', required: true }),
|
|
145
|
+
date('date', 'Date', { path: 'occurrence[x]', required: true }),
|
|
146
|
+
code('targetDisease', 'Target disease', 'ips-target-disease', { path: 'protocolApplied.targetDisease', list: true }),
|
|
147
|
+
text('doseNumber', 'Dose number', { path: 'protocolApplied.doseNumber[x]' }),
|
|
148
|
+
code('route', 'Route', 'ips-route', { path: 'route' }),
|
|
149
|
+
code('site', 'Site', 'ips-body-site', { path: 'site' }),
|
|
150
|
+
text('lotNumber', 'Lot number', { path: 'lotNumber' }),
|
|
151
|
+
note
|
|
152
|
+
]
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
key: 'procedures', code: '47519-4', title: 'History of Procedures', resourceType: 'Procedure', label: 'Procedure',
|
|
156
|
+
fields: [
|
|
157
|
+
code('code', 'Procedure', 'ips-procedure', { path: 'code', required: true }),
|
|
158
|
+
sel('status', 'Status', [{ code: 'completed', display: 'Completed' }, { code: 'in-progress', display: 'In progress' }, { code: 'not-done', display: 'Not done' }, { code: 'stopped', display: 'Stopped' }], { path: 'status', default: 'completed', required: true }),
|
|
159
|
+
date('performed', 'Performed', { path: 'performed[x]' }),
|
|
160
|
+
code('bodySite', 'Body site', 'ips-body-site', { path: 'bodySite', list: true }),
|
|
161
|
+
code('reason', 'Reason', 'ips-procedure-reason', { path: 'reasonCode', list: true }),
|
|
162
|
+
code('outcome', 'Outcome', 'ips-procedure-outcome', { path: 'outcome' }),
|
|
163
|
+
note
|
|
164
|
+
]
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
key: 'devices', code: '46264-8', title: 'Medical Devices', resourceType: 'DeviceUseStatement', label: 'Device',
|
|
168
|
+
fields: [
|
|
169
|
+
text('device', 'Device', { path: '$device.deviceName.name', required: true }),
|
|
170
|
+
text('deviceType', 'Type', { path: '$device.type', as: 'codeable', placeholder: 'as text' }),
|
|
171
|
+
sel('status', 'Status', [{ code: 'active', display: 'Active' }, { code: 'completed', display: 'Completed' }, { code: 'intended', display: 'Intended' }, { code: 'stopped', display: 'Stopped' }], { path: 'status', default: 'active', required: true }),
|
|
172
|
+
date('start', 'From', { path: 'timingPeriod.start' }),
|
|
173
|
+
date('end', 'Until', { path: 'timingPeriod.end' }),
|
|
174
|
+
code('bodySite', 'Body site', 'ips-body-site', { path: 'bodySite' }),
|
|
175
|
+
note
|
|
176
|
+
]
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
key: 'results', code: '30954-2', title: 'Results', resourceType: 'Observation', label: 'Result',
|
|
180
|
+
fields: [
|
|
181
|
+
code('code', 'Test', 'ips-observation-code', { path: 'code', required: true }),
|
|
182
|
+
{ name: 'value', label: 'Value', type: 'quantity', path: 'value[x]' },
|
|
183
|
+
text('valueText', 'Value (text)', { path: 'value[x]', placeholder: 'when the result is not a number' }),
|
|
184
|
+
code('interpretation', 'Interpretation', 'ips-observation-interpretation', { path: 'interpretation', list: true }),
|
|
185
|
+
date('date', 'Date', { path: 'effective[x]' }),
|
|
186
|
+
text('referenceRange', 'Reference range', { path: 'referenceRange.text' }),
|
|
187
|
+
note
|
|
188
|
+
],
|
|
189
|
+
fixed: { status: 'final', category: [{ coding: [{ system: THO + 'observation-category', code: 'laboratory', display: 'Laboratory' }] }] }
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
key: 'vitals', code: '8716-3', title: 'Vital Signs', resourceType: 'Observation', label: 'Vital sign',
|
|
193
|
+
fields: [
|
|
194
|
+
sel('code', 'Vital sign', [
|
|
195
|
+
{ code: '85354-9', display: 'Blood pressure', units: 'mm[Hg]', components: [{ code: '8480-6', display: 'Systolic blood pressure' }, { code: '8462-4', display: 'Diastolic blood pressure' }] },
|
|
196
|
+
{ code: '8867-4', display: 'Heart rate', units: '/min' }, { code: '9279-1', display: 'Respiratory rate', units: '/min' },
|
|
197
|
+
{ code: '8310-5', display: 'Body temperature', units: 'Cel' }, { code: '2708-6', display: 'Oxygen saturation', units: '%' },
|
|
198
|
+
{ code: '29463-7', display: 'Body weight', units: 'kg' }, { code: '8302-2', display: 'Body height', units: 'cm' },
|
|
199
|
+
{ code: '39156-5', display: 'Body mass index', units: 'kg/m2' }, { code: '9843-4', display: 'Head circumference', units: 'cm' }
|
|
200
|
+
], { system: LOINC, path: 'code', required: true }),
|
|
201
|
+
{ name: 'value', label: 'Value', type: 'quantity', path: 'value[x]', hint: 'for blood pressure, systolic' },
|
|
202
|
+
{ name: 'value2', label: 'Diastolic', type: 'quantity', path: '$diastolic', hint: 'blood pressure only' },
|
|
203
|
+
date('date', 'Date', { path: 'effective[x]', required: true }),
|
|
204
|
+
note
|
|
205
|
+
],
|
|
206
|
+
fixed: { status: 'final', category: [{ coding: [{ system: THO + 'observation-category', code: 'vital-signs', display: 'Vital Signs' }] }] }
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
key: 'pastProblems', code: '11348-0', title: 'History of Past Problems', resourceType: 'Condition', label: 'Past problem',
|
|
210
|
+
fields: [
|
|
211
|
+
code('code', 'Problem', 'ips-condition-code', { path: 'code', required: true }),
|
|
212
|
+
sel('clinicalStatus', 'Clinical status', CLINICAL_STATUS.options, { system: CLINICAL_STATUS.system, path: 'clinicalStatus', default: 'resolved' }),
|
|
213
|
+
sel('verificationStatus', 'Verification', VERIFICATION_STATUS.options, { system: VERIFICATION_STATUS.system, path: 'verificationStatus' }),
|
|
214
|
+
date('onset', 'Onset', { path: 'onset[x]' }),
|
|
215
|
+
date('abatement', 'Abatement', { path: 'abatement[x]' }),
|
|
216
|
+
note
|
|
217
|
+
]
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
key: 'pregnancy', code: '10162-6', title: 'Pregnancy History', resourceType: 'Observation', label: 'Pregnancy',
|
|
221
|
+
fields: [
|
|
222
|
+
sel('code', 'Observation', [{ code: '82810-3', display: 'Pregnancy status' }, { code: '11778-8', display: 'Estimated date of delivery' }, { code: '11636-8', display: 'Births, live (reported)' }], { system: LOINC, path: 'code', required: true }),
|
|
223
|
+
sel('status', 'Pregnancy status', [{ code: '77386006', display: 'Pregnant' }, { code: '60001007', display: 'Not pregnant' }, { code: '102874004', display: 'Possible pregnancy' }], { system: SCT, path: 'value[x]', hint: 'for pregnancy status' }),
|
|
224
|
+
date('valueDate', 'Date (EDD)', { path: 'value[x]', hint: 'for the estimated date of delivery' }),
|
|
225
|
+
{ name: 'value', label: 'Number', type: 'quantity', path: 'value[x]', hint: 'for a count' },
|
|
226
|
+
date('date', 'Date', { path: 'effective[x]' }),
|
|
227
|
+
note
|
|
228
|
+
],
|
|
229
|
+
fixed: { status: 'final', category: [{ coding: [{ system: THO + 'observation-category', code: 'social-history', display: 'Social History' }] }] }
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
key: 'social', code: '29762-2', title: 'Social History', resourceType: 'Observation', label: 'Social history',
|
|
233
|
+
fields: [
|
|
234
|
+
sel('code', 'Observation', [{ code: '72166-2', display: 'Tobacco smoking status' }, { code: '74013-4', display: 'Alcoholic drinks per week' }, { code: '11331-4', display: 'History of alcohol use' }], { system: LOINC, path: 'code', required: true }),
|
|
235
|
+
sel('smoking', 'Smoking status', [{ code: '449868002', display: 'Current every day smoker' }, { code: '428041000124106', display: 'Current some day smoker' }, { code: '8517006', display: 'Ex-smoker' }, { code: '266919005', display: 'Never smoked tobacco' }, { code: '77176002', display: 'Smoker' }, { code: '266927001', display: 'Tobacco smoking consumption unknown' }], { system: SCT, path: 'value[x]', hint: 'for smoking status' }),
|
|
236
|
+
{ name: 'value', label: 'Amount', type: 'quantity', path: 'value[x]', hint: 'for drinks per week' },
|
|
237
|
+
text('valueText', 'Value (text)', { path: 'value[x]' }),
|
|
238
|
+
date('date', 'Date', { path: 'effective[x]' }),
|
|
239
|
+
note
|
|
240
|
+
],
|
|
241
|
+
fixed: { status: 'final', category: [{ coding: [{ system: THO + 'observation-category', code: 'social-history', display: 'Social History' }] }] }
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
key: 'functional', code: '47420-5', title: 'Functional Status', resourceType: 'Condition', label: 'Functional status',
|
|
245
|
+
fields: [
|
|
246
|
+
code('code', 'Condition', 'ips-condition-code', { path: 'code', required: true }),
|
|
247
|
+
sel('clinicalStatus', 'Clinical status', CLINICAL_STATUS.options, { system: CLINICAL_STATUS.system, path: 'clinicalStatus', default: 'active' }),
|
|
248
|
+
date('onset', 'Onset', { path: 'onset[x]' }),
|
|
249
|
+
note
|
|
250
|
+
]
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
key: 'planOfCare', code: '18776-5', title: 'Plan of Care', resourceType: 'CarePlan', label: 'Plan',
|
|
254
|
+
fields: [
|
|
255
|
+
text('title', 'Title', { path: 'title', required: true }),
|
|
256
|
+
{ name: 'description', label: 'Description', type: 'textarea', path: 'description' },
|
|
257
|
+
sel('status', 'Status', [{ code: 'active', display: 'Active' }, { code: 'draft', display: 'Draft' }, { code: 'on-hold', display: 'On hold' }, { code: 'completed', display: 'Completed' }], { path: 'status', default: 'active', required: true }),
|
|
258
|
+
date('start', 'From', { path: 'period.start' }),
|
|
259
|
+
date('end', 'Until', { path: 'period.end' })
|
|
260
|
+
],
|
|
261
|
+
fixed: { intent: 'plan' }
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
key: 'directives', code: '42348-3', title: 'Advance Directives', resourceType: 'Consent', label: 'Directive',
|
|
265
|
+
fields: [
|
|
266
|
+
text('category', 'Directive', { path: 'category', as: 'codeable', required: true, placeholder: 'e.g. Do not resuscitate' }),
|
|
267
|
+
sel('status', 'Status', [{ code: 'active', display: 'Active' }, { code: 'inactive', display: 'Inactive' }, { code: 'draft', display: 'Draft' }], { path: 'status', default: 'active', required: true }),
|
|
268
|
+
date('date', 'Date', { path: 'dateTime' }),
|
|
269
|
+
{ name: 'description', label: 'Details', type: 'textarea', path: '$narrative' }
|
|
270
|
+
],
|
|
271
|
+
fixed: { scope: { coding: [{ system: THO + 'consentscope', code: 'adr', display: 'Advanced Care Directive' }] } }
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
key: 'alerts', code: '104605-1', title: 'Alerts', resourceType: 'Flag', label: 'Alert',
|
|
275
|
+
fields: [
|
|
276
|
+
text('code', 'Alert', { path: 'code', as: 'codeable', required: true }),
|
|
277
|
+
sel('status', 'Status', [{ code: 'active', display: 'Active' }, { code: 'inactive', display: 'Inactive' }], { path: 'status', default: 'active', required: true }),
|
|
278
|
+
date('start', 'From', { path: 'period.start' }),
|
|
279
|
+
date('end', 'Until', { path: 'period.end' })
|
|
280
|
+
]
|
|
281
|
+
}
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
/** The schema as the browser gets it. */
|
|
285
|
+
function forBrowser() {
|
|
286
|
+
return { header: HEADER, sections: SECTIONS, emptyReasons: EMPTY_REASONS };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
module.exports = { HEADER, SECTIONS, EMPTY_REASONS, EMPTY_REASON_SYSTEM, VS, SCT, LOINC, forBrowser };
|
package/ips/editor.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
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
|
+
* The IPS editor, in the browser. The page gives it window.IPS_EDITOR: the schema
|
|
9
|
+
* (ips/editor-schema.js, as JSON), a model to start from (empty, or an existing document's),
|
|
10
|
+
* and the URLs it works with. The model is the editor's state: the form is drawn from it, the
|
|
11
|
+
* inputs write into it, and it is kept in localStorage so a reload doesn't lose it; submitting
|
|
12
|
+
* posts it to the server, which builds the document (ips/composer.js).
|
|
13
|
+
*/
|
|
14
|
+
(function () {
|
|
15
|
+
'use strict';
|
|
16
|
+
var cfg = window.IPS_EDITOR;
|
|
17
|
+
var schema = cfg.schema;
|
|
18
|
+
var root = document.getElementById('ips-editor');
|
|
19
|
+
var draftKey = 'ips-editor:' + cfg.project;
|
|
20
|
+
var model = cfg.model || loadDraft() || emptyModel();
|
|
21
|
+
var open = {};
|
|
22
|
+
var dirty = false;
|
|
23
|
+
|
|
24
|
+
function emptyModel() {
|
|
25
|
+
var m = { patient: {}, document: {}, sections: {} };
|
|
26
|
+
schema.sections.forEach(function (s) { m.sections[s.key] = { mode: s.required ? 'data' : 'omit', rows: s.required ? [{}] : [] }; });
|
|
27
|
+
return m;
|
|
28
|
+
}
|
|
29
|
+
function loadDraft() {
|
|
30
|
+
try { var d = localStorage.getItem(draftKey); return d ? JSON.parse(d) : null; } catch (e) { return null; }
|
|
31
|
+
}
|
|
32
|
+
function saveDraft() {
|
|
33
|
+
dirty = true;
|
|
34
|
+
try { localStorage.setItem(draftKey, JSON.stringify(model)); } catch (e) { /* no storage: the page still works */ }
|
|
35
|
+
}
|
|
36
|
+
function clearDraft() {
|
|
37
|
+
try { localStorage.removeItem(draftKey); } catch (e) { /* nothing to do */ }
|
|
38
|
+
}
|
|
39
|
+
function el(tag, attrs, children) {
|
|
40
|
+
var e = document.createElement(tag);
|
|
41
|
+
Object.keys(attrs || {}).forEach(function (k) {
|
|
42
|
+
if (k === 'class') { e.className = attrs[k]; } else if (k === 'text') { e.textContent = attrs[k]; } else if (k.indexOf('on') === 0) { e.addEventListener(k.substring(2), attrs[k]); } else if (attrs[k] !== undefined && attrs[k] !== null && attrs[k] !== false) { e.setAttribute(k, attrs[k] === true ? '' : attrs[k]); }
|
|
43
|
+
});
|
|
44
|
+
(children || []).forEach(function (c) { if (c) { e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); } });
|
|
45
|
+
return e;
|
|
46
|
+
}
|
|
47
|
+
function section(key) { return model.sections[key] || (model.sections[key] = { mode: 'omit', rows: [] }); }
|
|
48
|
+
|
|
49
|
+
// ---- inputs ----
|
|
50
|
+
|
|
51
|
+
function textInput(obj, name, field, extra) {
|
|
52
|
+
var input = el(field.type === 'textarea' ? 'textarea' : 'input', Object.assign({ class: 'form-control ips-ed-input', placeholder: field.placeholder || (field.type === 'date' ? 'YYYY-MM-DD' : ''), rows: field.type === 'textarea' ? 3 : undefined }, extra || {}));
|
|
53
|
+
if (field.type !== 'textarea') { input.type = 'text'; }
|
|
54
|
+
input.value = obj[name] || '';
|
|
55
|
+
input.addEventListener('input', function () { obj[name] = input.value; saveDraft(); });
|
|
56
|
+
return input;
|
|
57
|
+
}
|
|
58
|
+
function selectInput(obj, name, field) {
|
|
59
|
+
var select = el('select', { class: 'form-control ips-ed-input' }, [el('option', { value: '', text: '' })]);
|
|
60
|
+
field.options.forEach(function (o) { select.appendChild(el('option', { value: o.code, text: o.display })); });
|
|
61
|
+
if (field.list) {
|
|
62
|
+
select.multiple = true;
|
|
63
|
+
select.size = Math.min(4, field.options.length);
|
|
64
|
+
var current = Array.isArray(obj[name]) ? obj[name] : (obj[name] ? [obj[name]] : []);
|
|
65
|
+
Array.prototype.forEach.call(select.options, function (o) { o.selected = current.indexOf(o.value) >= 0; });
|
|
66
|
+
select.addEventListener('change', function () { obj[name] = Array.prototype.filter.call(select.options, function (o) { return o.selected && o.value; }).map(function (o) { return o.value; }); saveDraft(); });
|
|
67
|
+
} else {
|
|
68
|
+
if (obj[name] === undefined && field.default) { obj[name] = field.default; }
|
|
69
|
+
select.value = obj[name] || '';
|
|
70
|
+
select.addEventListener('change', function () { obj[name] = select.value; saveDraft(); });
|
|
71
|
+
}
|
|
72
|
+
return select;
|
|
73
|
+
}
|
|
74
|
+
function quantityInput(obj, name) {
|
|
75
|
+
var q = obj[name] || (obj[name] = {});
|
|
76
|
+
var value = el('input', { type: 'text', class: 'form-control ips-ed-input ips-ed-qty', placeholder: 'value' });
|
|
77
|
+
value.value = q.value === undefined || q.value === null ? '' : q.value;
|
|
78
|
+
value.addEventListener('input', function () { q.value = value.value; saveDraft(); });
|
|
79
|
+
var unit = el('input', { type: 'text', class: 'form-control ips-ed-input ips-ed-unit', placeholder: 'unit' });
|
|
80
|
+
unit.value = q.unit || '';
|
|
81
|
+
unit.addEventListener('input', function () { q.unit = unit.value; saveDraft(); });
|
|
82
|
+
return el('span', { class: 'ips-ed-quantity' }, [value, ' ', unit]);
|
|
83
|
+
}
|
|
84
|
+
var lookupTimer = null;
|
|
85
|
+
function codeInput(obj, name, field) {
|
|
86
|
+
// one coding, or a list of them (shown as chips, with an input to add more)
|
|
87
|
+
var wrap = el('div', { class: 'ips-ed-code' });
|
|
88
|
+
var chips = el('div', { class: 'ips-ed-chips' });
|
|
89
|
+
var input = el('input', { type: 'text', class: 'form-control ips-ed-input', placeholder: field.list ? 'type to look up, or enter text' : 'type to look up, or enter text', autocomplete: 'off' });
|
|
90
|
+
var list = el('div', { class: 'ips-ed-suggestions', hidden: true });
|
|
91
|
+
var single = !field.list;
|
|
92
|
+
function current() { return single ? (obj[name] || null) : (Array.isArray(obj[name]) ? obj[name] : (obj[name] = [])); }
|
|
93
|
+
function label(c) { return c.display || c.text || c.code || ''; }
|
|
94
|
+
function drawChips() {
|
|
95
|
+
chips.innerHTML = '';
|
|
96
|
+
var items = single ? (current() ? [current()] : []) : current();
|
|
97
|
+
items.forEach(function (c, i) {
|
|
98
|
+
var chip = el('span', { class: 'ips-ed-chip' + (c.code ? ' ips-ed-chip-coded' : ''), title: c.code ? c.system + '|' + c.code : 'text only' }, [label(c), ' ', el('a', { href: '#', class: 'ips-ed-chip-x', onclick: function (e) { e.preventDefault(); if (single) { obj[name] = null; } else { current().splice(i, 1); } saveDraft(); drawChips(); input.hidden = false; input.focus(); } }, ['×'])]);
|
|
99
|
+
chips.appendChild(chip);
|
|
100
|
+
});
|
|
101
|
+
input.hidden = single && !!current();
|
|
102
|
+
}
|
|
103
|
+
function choose(c) {
|
|
104
|
+
if (single) { obj[name] = c; } else { current().push(c); }
|
|
105
|
+
input.value = '';
|
|
106
|
+
list.hidden = true;
|
|
107
|
+
saveDraft();
|
|
108
|
+
drawChips();
|
|
109
|
+
}
|
|
110
|
+
function commitText() {
|
|
111
|
+
var t = input.value.trim();
|
|
112
|
+
if (t) { choose({ text: t }); }
|
|
113
|
+
}
|
|
114
|
+
input.addEventListener('input', function () {
|
|
115
|
+
var q = input.value.trim();
|
|
116
|
+
clearTimeout(lookupTimer);
|
|
117
|
+
if (q.length < 2 || !cfg.urls.lookup) { list.hidden = true; return; }
|
|
118
|
+
lookupTimer = setTimeout(function () {
|
|
119
|
+
fetch(cfg.urls.lookup + '?vs=' + encodeURIComponent(field.valueSet) + '&q=' + encodeURIComponent(q), { headers: { Accept: 'application/json' }, credentials: 'same-origin' })
|
|
120
|
+
.then(function (r) { return r.json(); })
|
|
121
|
+
.then(function (j) {
|
|
122
|
+
list.innerHTML = '';
|
|
123
|
+
var found = j && j.matches ? j.matches : [];
|
|
124
|
+
found.forEach(function (c) {
|
|
125
|
+
list.appendChild(el('div', { class: 'ips-ed-suggestion', onmousedown: function (e) { e.preventDefault(); choose({ system: c.system, code: c.code, display: c.display }); } }, [c.display || c.code, ' ', el('small', { text: c.code })]));
|
|
126
|
+
});
|
|
127
|
+
list.appendChild(el('div', { class: 'ips-ed-suggestion ips-ed-suggestion-text', onmousedown: function (e) { e.preventDefault(); commitText(); } }, [found.length ? 'Use "' + q + '" as text' : (j && j.error ? j.error + ' - use "' + q + '" as text' : 'Nothing found - use "' + q + '" as text')]));
|
|
128
|
+
list.hidden = false;
|
|
129
|
+
})
|
|
130
|
+
.catch(function () { list.hidden = true; });
|
|
131
|
+
}, 250);
|
|
132
|
+
});
|
|
133
|
+
input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); commitText(); } if (e.key === 'Escape') { list.hidden = true; } });
|
|
134
|
+
input.addEventListener('blur', function () { setTimeout(function () { list.hidden = true; if (input.value.trim()) { commitText(); } }, 150); });
|
|
135
|
+
wrap.appendChild(chips);
|
|
136
|
+
wrap.appendChild(input);
|
|
137
|
+
wrap.appendChild(list);
|
|
138
|
+
drawChips();
|
|
139
|
+
return wrap;
|
|
140
|
+
}
|
|
141
|
+
function fieldInput(obj, field) {
|
|
142
|
+
switch (field.type) {
|
|
143
|
+
case 'select': return selectInput(obj, field.name, field);
|
|
144
|
+
case 'quantity': return quantityInput(obj, field.name);
|
|
145
|
+
case 'code': return codeInput(obj, field.name, field);
|
|
146
|
+
default: return textInput(obj, field.name, field);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function fieldRow(obj, field) {
|
|
150
|
+
return el('div', { class: 'ips-ed-field' + (field.required ? ' ips-ed-required' : '') }, [
|
|
151
|
+
el('label', { text: field.label + (field.required ? ' *' : '') }),
|
|
152
|
+
fieldInput(obj, field),
|
|
153
|
+
field.hint ? el('div', { class: 'ips-small ips-ed-hint', text: field.hint }) : null
|
|
154
|
+
]);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---- the parts of the page ----
|
|
158
|
+
|
|
159
|
+
function headerPanel() {
|
|
160
|
+
var p = model.patient || (model.patient = {});
|
|
161
|
+
var d = model.document || (model.document = {});
|
|
162
|
+
return el('div', { class: 'ips-ed-panel ips-ed-header' }, [
|
|
163
|
+
el('h3', { text: 'Patient' }),
|
|
164
|
+
el('div', { class: 'ips-ed-grid' }, schema.header.patient.map(function (f) { return fieldRow(p, f); })),
|
|
165
|
+
el('h3', { text: 'Document' }),
|
|
166
|
+
el('div', { class: 'ips-ed-grid' }, schema.header.document.map(function (f) { return fieldRow(d, f); }))
|
|
167
|
+
]);
|
|
168
|
+
}
|
|
169
|
+
function rowPanel(def, s, row, index) {
|
|
170
|
+
var panel = el('div', { class: 'ips-ed-row' });
|
|
171
|
+
panel.appendChild(el('div', { class: 'ips-ed-row-head' }, [
|
|
172
|
+
el('b', { text: def.label + ' ' + (index + 1) }),
|
|
173
|
+
el('button', { type: 'button', class: 'btn btn-link ips-ed-remove', onclick: function () { s.rows.splice(index, 1); saveDraft(); draw(); } }, ['remove'])
|
|
174
|
+
]));
|
|
175
|
+
panel.appendChild(el('div', { class: 'ips-ed-grid' }, def.fields.map(function (f) { return fieldRow(row, f); })));
|
|
176
|
+
(def.subs || []).forEach(function (sub) {
|
|
177
|
+
var items = row[sub.name] || (row[sub.name] = []);
|
|
178
|
+
var subPanel = el('div', { class: 'ips-ed-sub' }, [el('div', { class: 'ips-ed-sub-head', text: sub.label })]);
|
|
179
|
+
items.forEach(function (item, j) {
|
|
180
|
+
subPanel.appendChild(el('div', { class: 'ips-ed-sub-row' }, [
|
|
181
|
+
el('div', { class: 'ips-ed-grid' }, sub.fields.map(function (f) { return fieldRow(item, f); })),
|
|
182
|
+
el('button', { type: 'button', class: 'btn btn-link ips-ed-remove', onclick: function () { items.splice(j, 1); saveDraft(); draw(); } }, ['remove'])
|
|
183
|
+
]));
|
|
184
|
+
});
|
|
185
|
+
subPanel.appendChild(el('button', { type: 'button', class: 'btn btn-default btn-xs', onclick: function () { items.push({}); saveDraft(); draw(); } }, ['Add ' + sub.label.toLowerCase().replace(/s$/, '')]));
|
|
186
|
+
panel.appendChild(subPanel);
|
|
187
|
+
});
|
|
188
|
+
return panel;
|
|
189
|
+
}
|
|
190
|
+
function sectionPanel(def) {
|
|
191
|
+
var s = section(def.key);
|
|
192
|
+
if (!s.mode) { s.mode = def.required ? 'data' : 'omit'; }
|
|
193
|
+
var isOpen = open[def.key] !== undefined ? open[def.key] : (def.required || s.mode !== 'omit');
|
|
194
|
+
var summary = s.mode === 'data' ? ((s.rows || []).length + ' ' + (s.rows && s.rows.length === 1 ? 'entry' : 'entries')) : s.mode === 'narrative' ? 'narrative' : s.mode === 'empty' ? 'empty' + (s.emptyReason ? ' - ' + (schema.emptyReasons.filter(function (r) { return r.code === s.emptyReason; })[0] || {}).display : '') : 'not included';
|
|
195
|
+
var head = el('div', { class: 'ips-ed-section-head', onclick: function () { open[def.key] = !isOpen; draw(); } }, [
|
|
196
|
+
el('span', { class: 'ips-ed-caret', text: isOpen ? '▾' : '▸' }), ' ',
|
|
197
|
+
el('b', { text: def.title }), def.required ? el('span', { class: 'ips-small', text: ' (required)' }) : null,
|
|
198
|
+
el('span', { class: 'ips-ed-summary ips-small', text: ' - ' + summary })
|
|
199
|
+
]);
|
|
200
|
+
var body = el('div', { class: 'ips-ed-section-body', hidden: !isOpen });
|
|
201
|
+
var modes = [['data', 'Entries'], ['narrative', 'Narrative'], ['empty', 'Empty, because']];
|
|
202
|
+
if (!def.required) { modes.push(['omit', 'Not included']); }
|
|
203
|
+
var modeBar = el('div', { class: 'ips-ed-modes' }, modes.map(function (m) {
|
|
204
|
+
var id = 'mode-' + def.key + '-' + m[0];
|
|
205
|
+
var radio = el('input', { type: 'radio', name: 'mode-' + def.key, id: id, value: m[0] });
|
|
206
|
+
radio.checked = s.mode === m[0];
|
|
207
|
+
radio.addEventListener('change', function () { s.mode = m[0]; if (m[0] === 'data' && !(s.rows || []).length) { s.rows = [{}]; } saveDraft(); draw(); });
|
|
208
|
+
return el('label', { for: id, class: 'ips-ed-mode' }, [radio, ' ' + m[1]]);
|
|
209
|
+
}));
|
|
210
|
+
body.appendChild(modeBar);
|
|
211
|
+
if (s.mode === 'data') {
|
|
212
|
+
var rows = s.rows || (s.rows = []);
|
|
213
|
+
rows.forEach(function (row, i) { body.appendChild(rowPanel(def, s, row, i)); });
|
|
214
|
+
body.appendChild(el('button', { type: 'button', class: 'btn btn-default btn-sm', onclick: function () { rows.push({}); saveDraft(); draw(); } }, ['Add ' + def.label.toLowerCase()]));
|
|
215
|
+
if (s.partial) { body.appendChild(el('p', { class: 'ips-small text-warning', text: 'Some of this section\'s entries in the original document are of a kind this editor cannot hold; they are not here.' })); }
|
|
216
|
+
} else if (s.mode === 'narrative') {
|
|
217
|
+
var ta = el('textarea', { class: 'form-control ips-ed-input', rows: 6, placeholder: 'What this section would say; a blank line starts a new paragraph' });
|
|
218
|
+
ta.value = s.narrative || '';
|
|
219
|
+
ta.addEventListener('input', function () { s.narrative = ta.value; saveDraft(); });
|
|
220
|
+
body.appendChild(ta);
|
|
221
|
+
} else if (s.mode === 'empty') {
|
|
222
|
+
var sel = el('select', { class: 'form-control ips-ed-input', style: 'max-width: 20em' }, [el('option', { value: '', text: '' })]);
|
|
223
|
+
schema.emptyReasons.forEach(function (r) { sel.appendChild(el('option', { value: r.code, text: r.display })); });
|
|
224
|
+
sel.value = s.emptyReason || '';
|
|
225
|
+
sel.addEventListener('change', function () { s.emptyReason = sel.value; saveDraft(); });
|
|
226
|
+
body.appendChild(sel);
|
|
227
|
+
}
|
|
228
|
+
return el('div', { class: 'ips-ed-section' + (def.required ? ' ips-ed-section-required' : '') }, [head, body]);
|
|
229
|
+
}
|
|
230
|
+
function actions() {
|
|
231
|
+
var messages = el('div', { class: 'ips-ed-messages' });
|
|
232
|
+
var busy = false;
|
|
233
|
+
function send(action) {
|
|
234
|
+
if (busy) { return; }
|
|
235
|
+
busy = true;
|
|
236
|
+
messages.innerHTML = '<p><i>' + (action === 'download' ? 'Building the document...' : 'Submitting...') + '</i></p>';
|
|
237
|
+
var body = { action: action, model: model };
|
|
238
|
+
var tokenInput = root.querySelector('[name=token]');
|
|
239
|
+
if (tokenInput) { body.token = tokenInput.value; }
|
|
240
|
+
fetch(cfg.urls.compose, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: action === 'download' ? 'application/fhir+json' : 'application/json' }, body: JSON.stringify(body), credentials: 'same-origin' })
|
|
241
|
+
.then(function (r) {
|
|
242
|
+
if (action === 'download' && r.ok) {
|
|
243
|
+
return r.blob().then(function (b) {
|
|
244
|
+
var a = el('a', { href: URL.createObjectURL(b), download: 'ips.json' });
|
|
245
|
+
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
|
246
|
+
messages.innerHTML = '<p class="text-success">The document was built; it is downloading.</p>';
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return r.json().then(function (j) {
|
|
250
|
+
if (!r.ok) {
|
|
251
|
+
var problems = j.problems && j.problems.length ? j.problems : [j.error || ('HTTP ' + r.status)];
|
|
252
|
+
messages.innerHTML = '<div class="alert alert-danger"><b>Not ' + (action === 'download' ? 'built' : 'submitted') + ':</b><ul>' + problems.map(function (p) { return '<li>' + escapeHtml(p) + '</li>'; }).join('') + '</ul></div>';
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
clearDraft();
|
|
256
|
+
dirty = false;
|
|
257
|
+
messages.innerHTML = '<p class="text-success">Submitted as document ' + escapeHtml(String(j.id)) + '. Opening it...</p>';
|
|
258
|
+
window.location.href = j.url;
|
|
259
|
+
});
|
|
260
|
+
})
|
|
261
|
+
.catch(function (e) { messages.innerHTML = '<div class="alert alert-danger">' + escapeHtml(e.message) + '</div>'; })
|
|
262
|
+
.finally(function () { busy = false; });
|
|
263
|
+
}
|
|
264
|
+
var parts = [];
|
|
265
|
+
if (cfg.needsToken) {
|
|
266
|
+
parts.push(el('div', { class: 'ips-ed-field' }, [el('label', { text: 'Submission token *' }), el('input', { type: 'password', name: 'token', class: 'form-control ips-ed-input', autocomplete: 'off' })]));
|
|
267
|
+
}
|
|
268
|
+
parts.push(el('p', {}, [
|
|
269
|
+
el('button', { type: 'button', class: 'btn btn-primary', onclick: function () { send('submit'); } }, ['Submit to ' + cfg.projectName]), ' ',
|
|
270
|
+
el('button', { type: 'button', class: 'btn btn-default', onclick: function () { send('download'); } }, ['Download JSON']), ' ',
|
|
271
|
+
el('button', { type: 'button', class: 'btn btn-link', onclick: function () { if (window.confirm('Clear everything entered?')) { model = emptyModel(); open = {}; clearDraft(); draw(); } } }, ['Start again'])
|
|
272
|
+
]));
|
|
273
|
+
parts.push(messages);
|
|
274
|
+
return el('div', { class: 'ips-ed-actions' }, parts);
|
|
275
|
+
}
|
|
276
|
+
function escapeHtml(s) { return String(s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); }
|
|
277
|
+
|
|
278
|
+
function draw() {
|
|
279
|
+
var scroll = window.scrollY;
|
|
280
|
+
root.innerHTML = '';
|
|
281
|
+
root.appendChild(headerPanel());
|
|
282
|
+
root.appendChild(el('h3', { text: 'Sections' }));
|
|
283
|
+
root.appendChild(el('p', { class: 'ips-small', text: 'The sections in the IPS order; the three the IPS requires are open. For each, enter its entries, write its narrative, or say why it is empty.' }));
|
|
284
|
+
schema.sections.forEach(function (def) { root.appendChild(sectionPanel(def)); });
|
|
285
|
+
root.appendChild(actions());
|
|
286
|
+
window.scrollTo(0, scroll);
|
|
287
|
+
}
|
|
288
|
+
window.addEventListener('beforeunload', function (e) { if (dirty) { e.preventDefault(); e.returnValue = ''; } });
|
|
289
|
+
draw();
|
|
290
|
+
})();
|