Skip to content

Commit a8f0b42

Browse files
committed
Merge branch 'LF-1118-use-long-common-name' into 'master'
Use LONG_COMMON_NAME in LOINC imports See merge request lfor/formbuilder!166
2 parents 29877a0 + 8b6af0b commit a8f0b42

13 files changed

Lines changed: 750 additions & 350 deletions

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
This project follows [Semantic Versioning](http://semver.org/).
44

5-
## [12.1.0] 2026-07-20
5+
## [12.1.0] 2026-07-21
66
### Added
7+
- When importing questions from LOINC, give option to the user to pick LOINC's LONG_COMMON_NAME/SHORTNAME/CONSUMER as text
8+
field, instead of its 'text' field.
9+
710
- The preview dialog now warns when the questionnaire uses an answer value set but
811
does not specify a preferred terminology server. The rendered preview loads answer
912
lists using a default terminology server, but that default is not saved in the

package-lock.json

Lines changed: 203 additions & 223 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ import { EnableWhenComponent } from './lib/widgets/enable-when/enable-when.compo
7979
import { QuantityUnitComponent } from './lib/widgets/quantity-unit/quantity-unit.component';
8080
import { EwValidateDirective } from './lib/directives/ew-validate.directive';
8181
import {NodeDialogComponent} from './item/node-dialog.component';
82+
import {AddLoincItemDialogComponent} from './item/add-loinc-item-dialog.component';
8283
import { NumberComponent } from './lib/widgets/number/number.component';
8384
import { IntegerDirective } from './lib/directives/integer.directive';
8485
import { PositiveIntegerComponent } from './lib/widgets/positive-integer/positive-integer.component';
@@ -150,6 +151,7 @@ import {IsDisabledPipe} from "./lib/pipes/is-disabled.pipe";
150151
ObservationLinkPeriodComponent,
151152
QuantityUnitComponent,
152153
NodeDialogComponent,
154+
AddLoincItemDialogComponent,
153155
NumberComponent,
154156
AnswerValueSetComponent,
155157
TextAreaComponent,

src/app/base-page/base-page.component.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,8 @@ export class BasePageComponent implements OnInit, OnDestroy {
541541
/**
542542
* Call back to auto complete search.
543543
* @param term$ - Search term
544+
* @return An observable emitting the list of matching LOINC forms, or an
545+
* empty list when the search term has fewer than two characters.
544546
*/
545547
acSearch = (term$: Observable<string>): Observable<AutoCompleteResult []> => {
546548
return term$.pipe(
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<div class="modal-header bg-primary">
2+
<h4 class="modal-title text-white" id="modal-basic-title">Add LOINC item</h4>
3+
<button type="button" class="btn-close btn-close-white" aria-label="Close" (click)="activeModal.dismiss('Cross click')">
4+
</button>
5+
</div>
6+
<div class="modal-body">
7+
<form>
8+
<div class="form-group">
9+
<label for="loincTypeSelect1" class="align-middle" id="loincItemRadioGroupLabel">Select LOINC item type:</label>
10+
<div class="btn-group" id="loincTypeSelect1" role="radiogroup" aria-labelledby="loincItemRadioGroupLabel">
11+
@for (opt of loincTypeOpts; track opt.value) {
12+
<label class="btn">
13+
<input type="radio" name="loincType"
14+
[value]="opt.value"
15+
[ngModel]="loincType"
16+
(ngModelChange)="onLoincTypeChange($event)"
17+
[ngModelOptions]="{standalone: true}"> {{ opt.display }}
18+
</label>
19+
}
20+
</div>
21+
<label class="" for="acSearchBoxId">Search for a LOINC item:</label>
22+
<input id="acSearchBoxId" type="text" class="form-control form-control-sm"
23+
[(ngModel)]="loincItem"
24+
[ngModelOptions]="{standalone: true}"
25+
[ngbTypeahead]="acSearch"
26+
[resultFormatter]="formatter"
27+
[inputFormatter]="formatter"
28+
(selectItem)="onSelectLoincItem($event.item)"
29+
[editable]='false' />
30+
@if(loincType === LoincItemType.QUESTION && loincItem && Object.keys(loincItemDisplayTexts).length > 0) {
31+
<label for="loincItemDisplayTextGroup" id="loincItemDisplayTextGroupLabel" class="pt-3">Select display text:</label>
32+
<div class="" id="loincItemDisplayTextGroup" role="radiogroup" aria-labelledby="loincItemDisplayTextGroupLabel">
33+
@for (opt of Object.keys(loincItemDisplayTexts); track opt) {
34+
@if(loincItemDisplayTexts[opt]) {
35+
<div class="form-check ms-2">
36+
<label class="form-check-label">
37+
<input type="radio" name="loincDisplayText" class="form-check-input"
38+
[value]="opt"
39+
[ngModelOptions]="{standalone: true}"
40+
[(ngModel)]="selectedDisplayField">{{ loincItemDisplayTexts[opt] }}
41+
</label>
42+
</div>
43+
}
44+
}
45+
</div>
46+
}
47+
</div>
48+
</form>
49+
</div>
50+
<div class="modal-footer">
51+
<button type="button" class="btn btn-primary" (click)="activeModal.dismiss(false)">Cancel</button>
52+
<button type="button" class="btn btn-primary" [disabled]="!canAddLoincItem" (click)="onAddLoincItem()">Add</button>
53+
</div>
54+
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
3+
import { AddLoincItemDialogComponent } from './add-loinc-item-dialog.component';
4+
import { CommonTestingModule, runOnPushChangeDetection } from '../testing/common-testing.module';
5+
import { AutoCompleteLoincItem, LoincItemType } from '../services/fetch.service';
6+
7+
describe('AddLoincItemDialogComponent', () => {
8+
let component: AddLoincItemDialogComponent;
9+
let fixture: ComponentFixture<AddLoincItemDialogComponent>;
10+
11+
// A LOINC panel search result.
12+
const panelItem: AutoCompleteLoincItem = {
13+
LOINC_NUM: '34565-2',
14+
text: 'Vital signs, weight and height panel'
15+
};
16+
17+
// A LOINC question search result with multiple distinct display-text candidates.
18+
const questionItem: AutoCompleteLoincItem = {
19+
LOINC_NUM: '18833-4',
20+
text: 'First Body weight',
21+
COMPONENT: 'Body weight',
22+
LONG_COMMON_NAME: 'First Body weight measured',
23+
SHORTNAME: 'Body wt'
24+
};
25+
26+
CommonTestingModule.setUpTestBed(AddLoincItemDialogComponent);
27+
28+
beforeEach(() => {
29+
fixture = TestBed.createComponent(AddLoincItemDialogComponent);
30+
component = fixture.componentInstance;
31+
fixture.detectChanges();
32+
});
33+
34+
it('should create with Panel as the default type and no selection', () => {
35+
expect(component).toBeTruthy();
36+
expect(component.loincType).toBe(LoincItemType.PANEL);
37+
expect(component.loincItem).toBeUndefined();
38+
});
39+
40+
it('should preserve the selected Panel item when toggling item types back and forth', () => {
41+
component.onSelectLoincItem(panelItem);
42+
expect(component.loincItem).toBe(panelItem);
43+
44+
// Switching to Question must not carry over the Panel selection.
45+
component.onLoincTypeChange(LoincItemType.QUESTION);
46+
expect(component.loincType).toBe(LoincItemType.QUESTION);
47+
expect(component.loincItem).toBeFalsy();
48+
49+
// Switching back to Panel must restore the previously selected panel.
50+
component.onLoincTypeChange(LoincItemType.PANEL);
51+
expect(component.loincType).toBe(LoincItemType.PANEL);
52+
expect(component.loincItem).toBe(panelItem);
53+
});
54+
55+
it('should preserve the Question selection, its display texts and chosen display field across toggles', () => {
56+
component.onLoincTypeChange(LoincItemType.QUESTION);
57+
component.onSelectLoincItem(questionItem);
58+
59+
expect(component.loincItem).toBe(questionItem);
60+
expect(Object.keys(component.loincItemDisplayTexts).length).toBeGreaterThan(1);
61+
62+
// Simulate the user picking a non-default display field.
63+
component.selectedDisplayField = 'COMPONENT';
64+
65+
// Toggle to Panel: the question-specific state should clear from the active bindings.
66+
component.onLoincTypeChange(LoincItemType.PANEL);
67+
expect(component.loincItem).toBeFalsy();
68+
expect(component.loincItemDisplayTexts).toEqual({});
69+
expect(component.selectedDisplayField).toBe('text');
70+
71+
// Toggle back to Question: the full question selection should be restored.
72+
component.onLoincTypeChange(LoincItemType.QUESTION);
73+
expect(component.loincItem).toBe(questionItem);
74+
expect(component.selectedDisplayField).toBe('COMPONENT');
75+
expect(component.loincItemDisplayTexts).toEqual({
76+
text: 'First Body weight',
77+
COMPONENT: 'Body weight',
78+
LONG_COMMON_NAME: 'First Body weight measured',
79+
SHORTNAME: 'Body wt'
80+
});
81+
});
82+
83+
it('should keep independent selections for Panel and Question at the same time', () => {
84+
// Select a panel under the default Panel type.
85+
component.onSelectLoincItem(panelItem);
86+
87+
// Switch to Question and select a question.
88+
component.onLoincTypeChange(LoincItemType.QUESTION);
89+
component.onSelectLoincItem(questionItem);
90+
91+
// Each type retains its own selection when toggled.
92+
component.onLoincTypeChange(LoincItemType.PANEL);
93+
expect(component.loincItem).toBe(panelItem);
94+
95+
component.onLoincTypeChange(LoincItemType.QUESTION);
96+
expect(component.loincItem).toBe(questionItem);
97+
});
98+
99+
it('should be a no-op when the selected type does not change', () => {
100+
component.onSelectLoincItem(panelItem);
101+
102+
component.onLoincTypeChange(LoincItemType.PANEL);
103+
104+
expect(component.loincType).toBe(LoincItemType.PANEL);
105+
expect(component.loincItem).toBe(panelItem);
106+
});
107+
108+
it('should update the selected type and preserve the selection when the radio group changes', async () => {
109+
component.onSelectLoincItem(panelItem);
110+
const onChangeSpy = spyOn(component, 'onLoincTypeChange').and.callThrough();
111+
112+
const radios: HTMLInputElement[] =
113+
Array.from(fixture.nativeElement.querySelectorAll('input[type="radio"][name="loincType"]'));
114+
expect(radios.length).toBe(2);
115+
116+
// Index 1 corresponds to the 'Question' option (order matches loincTypeOpts).
117+
radios[1].click();
118+
await runOnPushChangeDetection(fixture);
119+
120+
expect(onChangeSpy).toHaveBeenCalledWith(LoincItemType.QUESTION);
121+
expect(component.loincType).toBe(LoincItemType.QUESTION);
122+
expect(component.loincItem).toBeFalsy();
123+
124+
// Switching back to Panel restores the previously selected panel.
125+
radios[0].click();
126+
await runOnPushChangeDetection(fixture);
127+
128+
expect(component.loincType).toBe(LoincItemType.PANEL);
129+
expect(component.loincItem).toBe(panelItem);
130+
});
131+
132+
describe('Add button enablement', () => {
133+
// Locate the dialog's Add button in the rendered footer.
134+
const getAddButton = (): HTMLButtonElement =>
135+
(Array.from(fixture.nativeElement.querySelectorAll('.modal-footer button')) as HTMLButtonElement[])
136+
.find((btn) => btn.textContent?.trim() === 'Add') as HTMLButtonElement;
137+
138+
it('should report canAddLoincItem based on whether an item is selected', () => {
139+
expect(component.canAddLoincItem).toBeFalse();
140+
141+
component.onSelectLoincItem(panelItem);
142+
expect(component.canAddLoincItem).toBeTrue();
143+
});
144+
145+
it('should disable the Add button until a LOINC item is selected', async () => {
146+
const addButton = getAddButton();
147+
expect(addButton).toBeTruthy();
148+
expect(addButton.disabled).toBeTrue();
149+
150+
// Selecting an item enables the button.
151+
component.onSelectLoincItem(panelItem);
152+
await runOnPushChangeDetection(fixture);
153+
expect(getAddButton().disabled).toBeFalse();
154+
});
155+
156+
it('should disable the Add button again after switching to a type with no selection', async () => {
157+
component.onSelectLoincItem(panelItem);
158+
await runOnPushChangeDetection(fixture);
159+
expect(getAddButton().disabled).toBeFalse();
160+
161+
// Switching to Question (no prior selection) should disable it again.
162+
component.onLoincTypeChange(LoincItemType.QUESTION);
163+
await runOnPushChangeDetection(fixture);
164+
expect(getAddButton().disabled).toBeTrue();
165+
166+
// Switching back to Panel restores the selection and re-enables it.
167+
component.onLoincTypeChange(LoincItemType.PANEL);
168+
await runOnPushChangeDetection(fixture);
169+
expect(getAddButton().disabled).toBeFalse();
170+
});
171+
});
172+
});
173+
174+

0 commit comments

Comments
 (0)