diff --git a/projects/ngx-formentry/src/form-entry/directives/patient-identifier.directive.ts b/projects/ngx-formentry/src/form-entry/directives/patient-identifier.directive.ts new file mode 100644 index 00000000..a9730dbf --- /dev/null +++ b/projects/ngx-formentry/src/form-entry/directives/patient-identifier.directive.ts @@ -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 | Observable { + const identifier = control.value; + const currentPatientExistingIdentifier: Array = + 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 { + // 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; + }) + ); + } +} diff --git a/projects/ngx-formentry/src/form-entry/form-entry.module.ts b/projects/ngx-formentry/src/form-entry/form-entry.module.ts index 258cbc8d..924d8f83 100755 --- a/projects/ngx-formentry/src/form-entry/form-entry.module.ts +++ b/projects/ngx-formentry/src/form-entry/form-entry.module.ts @@ -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], @@ -78,7 +79,8 @@ import { PatientIdentifierAdapter } from './value-adapters/patient-identifier.ad HistoricalValueDirective, ErrorRendererComponent, TimeAgoPipe, - CollapseDirective + CollapseDirective, + PatientIdentifierValidatorDirective ], providers: [ UntypedFormBuilder, diff --git a/projects/ngx-formentry/src/form-entry/form-factory/validation.factory.ts b/projects/ngx-formentry/src/form-entry/form-factory/validation.factory.ts index 3e1d8ad1..e3ddc440 100644 --- a/projects/ngx-formentry/src/form-entry/form-factory/validation.factory.ts +++ b/projects/ngx-formentry/src/form-entry/form-factory/validation.factory.ts @@ -169,7 +169,6 @@ export class ValidationFactory { public errors(errors: any, question: QuestionBase): Array { const messages: Array = []; - for (const property in errors) { if (errors.hasOwnProperty(property)) { switch (property) { @@ -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; } } diff --git a/projects/ngx-formentry/src/form-entry/form-renderer/form-renderer.component.html b/projects/ngx-formentry/src/form-entry/form-renderer/form-renderer.component.html index b59b1861..612c71fd 100644 --- a/projects/ngx-formentry/src/form-entry/form-renderer/form-renderer.component.html +++ b/projects/ngx-formentry/src/form-entry/form-renderer/form-renderer.component.html @@ -365,6 +365,7 @@ ) { 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); @@ -58,6 +59,14 @@ export class PatientIdentifierAdapter { getPatientIdentifierNodes(rootNode: NodeBase): Array { const results: Array = []; 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; } diff --git a/projects/ngx-formentry/src/form-entry/value-adapters/types/index.ts b/projects/ngx-formentry/src/form-entry/value-adapters/types/index.ts new file mode 100644 index 00000000..e77879ad --- /dev/null +++ b/projects/ngx-formentry/src/form-entry/value-adapters/types/index.ts @@ -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 }>; +} diff --git a/src/app/adult-1.6.json b/src/app/adult-1.6.json index f52d697a..dc31e855 100644 --- a/src/app/adult-1.6.json +++ b/src/app/adult-1.6.json @@ -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": [] + } + ] } ] } @@ -3362,7 +3378,9 @@ "type": "obs", "hide": { "field": "tb_current", - "value": ["b8aa06ca-93c6-40ea-b144-c74f841926f4"] + "value": [ + "b8aa06ca-93c6-40ea-b144-c74f841926f4" + ] }, "id": "__ptxCzFD2s" } @@ -6991,7 +7009,9 @@ "type": "obs", "hide": { "field": "q26f", - "value": ["b8aa06ca-93c6-40ea-b144-c74f841926f4"] + "value": [ + "b8aa06ca-93c6-40ea-b144-c74f841926f4" + ] }, "id": "__Jywyp94Lw" } @@ -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": [ { @@ -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": [ { @@ -8178,4 +8217,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 215e7ac9..1f20495e 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -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; } @@ -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); @@ -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); diff --git a/src/translations/en.json b/src/translations/en.json index 8b3a6d00..74f72145 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -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}", @@ -56,4 +58,4 @@ "useValue": "Use Value", "weeks": "Weeks", "yearsAgo": " years ago" -} +} \ No newline at end of file