Skip to content

Commit

Permalink
(feat) : Add patient identifier validator
Browse files Browse the repository at this point in the history
  • Loading branch information
donaldkibet committed Jan 13, 2025
1 parent 50e4df0 commit afcf39e
Show file tree
Hide file tree
Showing 10 changed files with 225 additions and 12 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { Directive, Input } from '@angular/core';
import {
AbstractControl,
AsyncValidator,
NG_ASYNC_VALIDATORS,
ValidationErrors
} from '@angular/forms';
import { Observable, of } from 'rxjs';
import {
catchError,
debounceTime,
distinctUntilChanged,
map,
switchMap
} from 'rxjs/operators';
import { HttpClient, HttpParams } from '@angular/common/http';
import { LeafNode } from '../form-factory/form-node';
import { Identifier } from '../value-adapters/types';
import { Messages } from '../utils/messages';

@Directive({
selector: '[ofePatientIdentifierValidator]',
providers: [
{
provide: NG_ASYNC_VALIDATORS,
useExisting: PatientIdentifierValidatorDirective,
multi: true
}
]
})
export class PatientIdentifierValidatorDirective implements AsyncValidator {
@Input('ofePatientIdentifierValidator') node: LeafNode;
@Input() currentPatientId?: string;

constructor(private http: HttpClient) {}

validate(
control: AbstractControl
): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> {
const identifier = control.value;
const currentPatientExistingIdentifier: Array<Identifier> =
this.node.form.valueProcessingInfo?.patientIdentifiers ?? [];

if (
currentPatientExistingIdentifier.some(
(id) => id.identifier === identifier
)
) {
// Disable the control, since the identifier is already assigned to the patient
control.disable();
return of(null);
}

// If the identifier is less than 3 characters, no need to validate
if (!identifier || identifier.length < 3) {
return of(null);
}

return of(identifier).pipe(
debounceTime(500),
distinctUntilChanged(),
switchMap((id) => this.validateIdentifier(id)),
map((response) => {
if (
response.isAssigned &&
response.patientId !== this.currentPatientId
) {
return {
identifierTaken: {
message: Messages.identifierTaken
}
};
}
return null;
}),
catchError(() =>
of({
identifierError: {
message: Messages.identifierError
}
})
)
);
}

private validateIdentifier(identifier: string): Observable<any> {
// For testing purposes, change openmrsBase to the OpenMRS server you are using
const baseUrl = window?.['openmrsBase'] + '/ws/rest/v1' + '/';
const apiUrl = `${baseUrl}patient`;

const params = new HttpParams().set('q', identifier);

return this.http.get(apiUrl, { params }).pipe(
map((response: any) => {
const results = response.results || [];
return {
isAssigned: results.length > 0,
patientId: results.length > 0 ? results[0].uuid : undefined
};
}),
catchError((error) => {
console.error('Error searching for patient:', error);
throw error;
})
);
}
}
4 changes: 3 additions & 1 deletion projects/ngx-formentry/src/form-entry/form-entry.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { CustomControlWrapperModule } from '../components/custom-control-wrapper
import { CustomComponentWrapperModule } from '../components/custom-component-wrapper/custom-component-wrapper..module';
import { TranslateModule } from '@ngx-translate/core';
import { PatientIdentifierAdapter } from './value-adapters/patient-identifier.adapter';
import { PatientIdentifierValidatorDirective } from './directives/patient-identifier.directive';

@NgModule({
schemas: [CUSTOM_ELEMENTS_SCHEMA],
Expand Down Expand Up @@ -78,7 +79,8 @@ import { PatientIdentifierAdapter } from './value-adapters/patient-identifier.ad
HistoricalValueDirective,
ErrorRendererComponent,
TimeAgoPipe,
CollapseDirective
CollapseDirective,
PatientIdentifierValidatorDirective
],
providers: [
UntypedFormBuilder,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,6 @@ export class ValidationFactory {

public errors(errors: any, question: QuestionBase): Array<string> {
const messages: Array<string> = [];

for (const property in errors) {
if (errors.hasOwnProperty(property)) {
switch (property) {
Expand Down Expand Up @@ -235,6 +234,19 @@ export class ValidationFactory {
break;
case 'conditional_answered':
messages.push(errors['conditional_answered'].message);
case 'identifierTaken':
messages.push(
this.translate
.instant('identifierTaken')
.replace('{identifierTaken}', errors?.identifierTaken?.message)
);
break;
case 'identifierError':
messages.push(
this.translate
.instant('identifierError')
.replace('{identifierError}', errors?.identifierError?.message)
);
break;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@
</div>

<input
[ofePatientIdentifierValidator]="node"
[theme]="theme"
class="cds--text-input"
ofeTextInput
Expand Down
4 changes: 4 additions & 0 deletions projects/ngx-formentry/src/form-entry/utils/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,8 @@ export class Messages {
'Please enter a value greater than or equal to {min}';

public static readonly disallowDecimals = 'Decimals values are not allowed';
public static readonly identifierTaken =
'This identifier is already assigned to another patient';
public static readonly identifierError =
'An error occurred while validating the identifier';
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';

import { NodeBase, GroupNode, ArrayNode } from '../form-factory/form-node';
import { Form } from '../form-factory';
import { Identifier } from './types';

@Injectable()
export class PatientIdentifierAdapter {
Expand Down Expand Up @@ -29,13 +30,13 @@ export class PatientIdentifierAdapter {
return payload;
}

populateForm(form: Form, payload) {
populateForm(form: Form, payload: Array<Identifier>) {
this.populateNode(form.rootNode, payload);
}

populateNode(rootNode: NodeBase, payload) {
if (!Array.isArray(payload)) {
throw new Error('Expected an array of patient identfiers');
throw new Error('Expected an array of patient identifiers');
}

const nodes = this.getPatientIdentifierNodes(rootNode);
Expand All @@ -58,6 +59,14 @@ export class PatientIdentifierAdapter {
getPatientIdentifierNodes(rootNode: NodeBase): Array<NodeBase> {
const results: Array<NodeBase> = [];
this.getPatientIdentifierTypeNodes(rootNode, results);

results.forEach((node) => {
if (!node.question.extras?.questionOptions?.identifierType) {
console.warn(
`Patient identifier node "${node.question.extras.label}" is missing required identifierType property`
);
}
});
return results;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export interface Identifier {
uuid?: string;
identifier: string;
identifierType: OpenmrsResource;
location: OpenmrsResource;
}

interface OpenmrsResource {
display: string;
uuid: string;
links?: Array<{ rel: string; uri: string }>;
}
49 changes: 44 additions & 5 deletions src/app/adult-1.6.json
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,22 @@
"hide": {
"hideWhenExpression": "nhif !== 'a899e0ac-1350-11df-a1f1-0026b9348838'"
}
},
{
"label": "Patient Identifier",
"isExpanded": "true",
"questions": [
{
"label": "Unique Patient Number",
"id": "uniquePatientNumber",
"questionOptions": {
"rendering": "text",
"identifierType": "dfacd928-0370-4315-99d7-6ec1c9f7ae76"
},
"type": "patientIdentifier",
"validators": []
}
]
}
]
}
Expand Down Expand Up @@ -3362,7 +3378,9 @@
"type": "obs",
"hide": {
"field": "tb_current",
"value": ["b8aa06ca-93c6-40ea-b144-c74f841926f4"]
"value": [
"b8aa06ca-93c6-40ea-b144-c74f841926f4"
]
},
"id": "__ptxCzFD2s"
}
Expand Down Expand Up @@ -6991,7 +7009,9 @@
"type": "obs",
"hide": {
"field": "q26f",
"value": ["b8aa06ca-93c6-40ea-b144-c74f841926f4"]
"value": [
"b8aa06ca-93c6-40ea-b144-c74f841926f4"
]
},
"id": "__Jywyp94Lw"
}
Expand Down Expand Up @@ -8012,7 +8032,16 @@
"questionOptions": {
"concept": "318a5e8b-218c-4f66-9106-cd581dec1f95",
"rendering": "date",
"weeksList": [2, 4, 6, 8, 12, 16, 24, 36]
"weeksList": [
2,
4,
6,
8,
12,
16,
24,
36
]
},
"validators": [
{
Expand Down Expand Up @@ -8042,7 +8071,17 @@
"questionOptions": {
"concept": "a8a666ba-1350-11df-a1f1-0026b9348838",
"rendering": "date",
"weeksList": [2, 4, 6, 8, 12, 16, 20, 24, 36]
"weeksList": [
2,
4,
6,
8,
12,
16,
20,
24,
36
]
},
"validators": [
{
Expand Down Expand Up @@ -8178,4 +8217,4 @@
]
}
]
}
}
29 changes: 27 additions & 2 deletions src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export class AppComponent implements OnInit {
private http: HttpClient,
private translate: TranslateService,
private personAttributeAdapter: PersonAttribuAdapter,
private patientIdenfierAdapter: PatientIdentifierAdapter
private patientIdentifierAdapter: PatientIdentifierAdapter
) {
this.schema = adultReturnVisitForm;
}
Expand Down Expand Up @@ -170,6 +170,27 @@ export class AppComponent implements OnInit {
this.form.rootNode.control.markAsDirty();
}

// populate identifiers
const patientIdentifiers = [
{
uuid: 'ad8da7d9-d760-4b43-80d6-a9a1429c3432',
identifierType: {
uuid: 'dfacd928-0370-4315-99d7-6ec1c9f7ae76',
display: 'OpenMRS ID'
},
identifier: 'M4X79',
location: {
uuid: '7537b643-6196-4472-a53c-b11f43efc067',
display: 'Wema Centre Medical Clinic'
}
}
];

this.patientIdentifierAdapter.populateForm(this.form, patientIdentifiers);
this.form.valueProcessingInfo = {
patientIdentifiers: patientIdentifiers
};

// Alternative is to set individually for obs and orders as show below
// // Set obs
// this.obsValueAdapater.populateForm(this.form, adultReturnVisitFormObs.obs);
Expand Down Expand Up @@ -391,7 +412,11 @@ export class AppComponent implements OnInit {
// let ordersPayload = this.orderAdaptor.generateFormPayload(this.form);

// generate patient identifiers
//const patientIdenfitiers = this.patientIdenfierAdapter.generateFormPayload(this.form,this.form.valueProcessingInfo['locationUuid']);
const patientIdentifiers = this.patientIdentifierAdapter.generateFormPayload(
this.form,
this.form.valueProcessingInfo['locationUuid']
);
console.log(JSON.stringify(patientIdentifiers, null, 2));
} else {
this.form.showErrors = true;
this.form.markInvalidControls(this.form.rootNode);
Expand Down
4 changes: 3 additions & 1 deletion src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
"futureDateRestriction": "Dates in the future are not allowed",
"hoursAgo": " hours ago",
"invalidDate": "Invalid date. Please select a valid date",
"identifierTaken": "This identifier is already assigned to another patient",
"identifierError": "An error occurred while validating the identifier",
"loadingComponent": "Loading component...",
"max": "Please enter a value less than or equal to {max}",
"maxDate": "Please select a date that is on or before {maxDate}",
Expand Down Expand Up @@ -56,4 +58,4 @@
"useValue": "Use Value",
"weeks": "Weeks",
"yearsAgo": " years ago"
}
}

0 comments on commit afcf39e

Please sign in to comment.