Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(feat) Dynamic Offline Data for Forms | Offline Support for Concept Labels #710

Merged
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export class FeWrapperComponent implements OnInit, OnDestroy {

public form: Form;
public loadingError?: string;
public labelMap: {};
public labelMap: {} = {};
public formState: FormState = 'initial';
public language: string = (window as any).i18next.language.substring(0, 2).toLowerCase();

Expand Down Expand Up @@ -66,22 +66,21 @@ export class FeWrapperComponent implements OnInit, OnDestroy {
.pipe(
take(1),
map((createFormParams) => this.formCreationService.initAndCreateForm(createFormParams)),
mergeMap((form) => {
const unlabeledConcepts = FormSchemaService.getUnlabeledConceptIdentifiersFromSchema(form.schema);
return this.conceptService
.searchBulkConceptByUUID(unlabeledConcepts, this.language)
.pipe(map((concepts) => ({ form, concepts })));
}),
)
.subscribe(
(form) => {
({ form, concepts }) => {
this.formState = 'ready';
this.form = form;

const unlabeledConcepts = this.formSchemaService.getUnlabeledConcepts(this.form);
// Fetch concept labels from server
unlabeledConcepts.length > 0
? this.conceptService.searchBulkConceptByUUID(unlabeledConcepts, this.language).subscribe((conceptData) => {
this.labelMap = {};
conceptData.forEach((concept: any) => {
this.labelMap[concept.extId] = concept.display;
});
})
: (this.labelMap = []);
this.labelMap = concepts.reduce((acc, current) => {
acc[current.extId] = current.display;
return acc;
}, {});
},
(err) => {
// TODO: Improve error handling.
Expand Down
143 changes: 53 additions & 90 deletions packages/esm-form-entry-app/src/app/form-schema/form-schema.service.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { Injectable } from '@angular/core';
import { ReplaySubject, Observable, Subject } from 'rxjs';
import { concat, first } from 'rxjs/operators';
import { ReplaySubject, Observable, Subject, of, forkJoin } from 'rxjs';
import { concat, first, map, take, tap } from 'rxjs/operators';

// import { FormSchemaCompiler } from 'ngx-openmrs-formentry';
import { FormResourceService } from '../openmrs-api/form-resource.service';
import { FormSchemaCompiler } from '@ampath-kenya/ngx-formentry';
import { LocalStorageService } from '../local-storage/local-storage.service';
import { FormSchema } from '../types';
import { Form } from '@ampath-kenya/ngx-formentry/form-entry/form-factory/form';
import { FormSchema, Questions } from '../types';

@Injectable()
export class FormSchemaService {
Expand All @@ -17,49 +15,31 @@ export class FormSchemaService {
private formSchemaCompiler: FormSchemaCompiler,
) {}

public getFormSchemaByUuid(formUuid: string, cached: boolean = true): ReplaySubject<FormSchema> {
const formSchema: ReplaySubject<any> = new ReplaySubject(1);
const cachedCompiledSchema: any = this.getCachedCompiledSchemaByUuid(formUuid);
if (cachedCompiledSchema && cached === true) {
formSchema.next(cachedCompiledSchema);
} else {
this.getFormSchemaByUuidFromServer(formUuid).subscribe(
(unCompiledSchema: any) => {
const form: any = unCompiledSchema.form;
const referencedComponents: any = unCompiledSchema.referencedComponents;
// add from metadata to the uncompiled schema
this.formsResourceService.getFormMetaDataByUuid(formUuid).subscribe(
(formMetadataObject: any) => {
formMetadataObject.pages = form.pages || [];
formMetadataObject.referencedForms = form.referencedForms || [];
formMetadataObject.processor = form.processor;
// compile schema
const compiledSchema: any = this.formSchemaCompiler.compileFormSchema(
formMetadataObject,
referencedComponents,
);
// now cache the compiled schema
this.cacheCompiledSchemaByUuid(formUuid, compiledSchema);
// return the compiled schema
formSchema.next(compiledSchema);
},
(err) => {
console.error(err);
formSchema.error(err);
},
);
},
(err) => {
console.error(err);
formSchema.error(err);
},
);
public getFormSchemaByUuid(formUuid: string, cached: boolean = true): Observable<FormSchema> {
const cachedCompiledSchema = this.getCachedCompiledSchemaByUuid(formUuid);
if (cachedCompiledSchema && cached) {
return of(cachedCompiledSchema);
}
return formSchema;
}

public getUnlabeledConcepts(form: Form): any {
return this.traverseForUnlabeledConcepts(form.rootNode);
return forkJoin({
unCompiledSchema: this.getFormSchemaByUuidFromServer(formUuid).pipe(take(1)),
formMetadataObject: this.formsResourceService.getFormMetaDataByUuid(formUuid).pipe(take(1)),
}).pipe(
map(({ unCompiledSchema, formMetadataObject }) => {
const formSchema: any = unCompiledSchema.form;
const referencedComponents: any = unCompiledSchema.referencedComponents;

formMetadataObject.pages = formSchema.pages || [];
formMetadataObject.referencedForms = formSchema.referencedForms || [];
formMetadataObject.processor = formSchema.processor;

return this.formSchemaCompiler.compileFormSchema(
formMetadataObject,
referencedComponents,
) as unknown as FormSchema;
}),
tap((compiledSchema) => this.cacheCompiledSchemaByUuid(formUuid, compiledSchema)),
);
}

private getCachedCompiledSchemaByUuid(formUuid: string): any {
Expand All @@ -70,7 +50,7 @@ export class FormSchemaService {
this.localStorage.setObject(formUuid, schema);
}

private getFormSchemaByUuidFromServer(formUuid: string): ReplaySubject<object> {
private getFormSchemaByUuidFromServer(formUuid: string) {
const formSchema: ReplaySubject<any> = new ReplaySubject(1);
this.fetchFormSchemaUsingFormMetadata(formUuid).subscribe(
(schema: any) => {
Expand Down Expand Up @@ -193,53 +173,36 @@ export class FormSchemaService {
return formUuids;
}

private traverseForUnlabeledConcepts(o, type?) {
let concepts = [];
if (o.children) {
if (o.children instanceof Array) {
const returned = this.traverseRepeatingGroupForUnlabeledConcepts(o.children);
return returned;
}
if (o.children instanceof Object) {
for (const key in o.children) {
if (o.children.hasOwnProperty(key)) {
const question = o.children[key].question;
switch (question.renderingType) {
case 'page':
case 'section':
case 'group':
const childrenConcepts = this.traverseForUnlabeledConcepts(o.children[key]);
concepts = concepts.concat(childrenConcepts);
break;
case 'repeating':
const repeatingConcepts = this.traverseRepeatingGroupForUnlabeledConcepts(o.children[key].children);
concepts = concepts.concat(repeatingConcepts);
break;
default:
if (!question.label && question.extras.questionOptions?.concept) {
concepts.push(question.extras.questionOptions.concept);
}
if (question.extras.questionOptions.answers) {
question.extras.questionOptions.answers.forEach((answer) => {
if (!answer.label) {
concepts.push(answer.concept);
}
});
}
break;
}
public static getUnlabeledConceptIdentifiersFromSchema(form: FormSchema): Array<string> {
const results = new Set<string>();
const walkQuestions = (questions: Array<Questions>) => {
for (const question of questions) {
if (typeof question.concept === 'string') {
results.add(question.concept);
}

if (typeof question.questionOptions?.concept === 'string') {
results.add(question.questionOptions.concept);
}

for (const answer of question.questionOptions?.answers ?? []) {
if (typeof answer.concept === 'string') {
results.add(answer.concept);
}
}

if (Array.isArray(question.questions)) {
walkQuestions(question.questions);
}
}
}
return concepts;
}
};

private traverseRepeatingGroupForUnlabeledConcepts(nodes) {
const toReturn = [];
for (const node of nodes) {
toReturn.push(this.traverseForUnlabeledConcepts(node));
for (const page of form.pages ?? []) {
for (const section of page.sections ?? []) {
walkQuestions(section.questions ?? []);
}
}
return toReturn;

return [...results];
}
}
97 changes: 95 additions & 2 deletions packages/esm-form-entry-app/src/app/offline/caching.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { messageOmrsServiceWorker, openmrsFetch, subscribePrecacheStaticDependencies } from '@openmrs/esm-framework';
import { FormSchemaCompiler } from '@ampath-kenya/ngx-formentry';
import {
makeUrl,
messageOmrsServiceWorker,
omrsOfflineCachingStrategyHttpHeaderName,
openmrsFetch,
setupDynamicOfflineDataHandler,
subscribePrecacheStaticDependencies,
} from '@openmrs/esm-framework';
import escapeRegExp from 'lodash-es/escapeRegExp';
import { FormSchemaService } from '../form-schema/form-schema.service';
import { FormEncounter, FormSchema } from '../types';

export function setupOfflineDataSourcePrecaching() {
export function setupStaticDataOfflinePrecaching() {
subscribePrecacheStaticDependencies(async () => {
const urlsToCache = [
'/ws/rest/v1/location?q=&v=custom:(uuid,display)',
Expand All @@ -18,3 +29,85 @@ export function setupOfflineDataSourcePrecaching() {
);
});
}

export function setupDynamicOfflineFormDataHandler() {
setupDynamicOfflineDataHandler({
id: 'esm-form-entry-app:form',
type: 'form',
displayName: 'Form entry',
async isSynced(identifier) {
const expectedUrls = await getCacheableFormUrls(identifier);
const absoluteExpectedUrls = expectedUrls.map((url) => window.origin + makeUrl(url));
const cache = await caches.open('omrs-spa-cache-v1');
const keys = (await cache.keys()).map((key) => key.url);
return absoluteExpectedUrls.every((url) => keys.includes(url));
},
async sync(identifier) {
const urlsToCache = await getCacheableFormUrls(identifier);
const cacheResults = await Promise.allSettled(
urlsToCache.map(async (urlToCache) => {
await messageOmrsServiceWorker({
type: 'registerDynamicRoute',
pattern: escapeRegExp(urlToCache),
strategy: 'network-first',
});

await openmrsFetch(urlToCache, {
headers: {
[omrsOfflineCachingStrategyHttpHeaderName]: 'network-first',
},
});
}),
);

if (cacheResults.some((x) => x.status === 'rejected')) {
throw new Error(`Some form data could not be properly downloaded. (Form UUID: ${identifier})`);
}
},
});
}

async function getCacheableFormUrls(formUuid: string) {
const getFormRes = await openmrsFetch<FormEncounter>(`/ws/rest/v1/form/${formUuid}?v=full`);
const form = getFormRes.data;
const getClobdataRes = await openmrsFetch(`/ws/rest/v1/clobdata/${form.resources[0].valueReference}?v=full`);
const clobdata = getClobdataRes.data;

if (!form || !clobdata) {
throw new Error(`The form data could not be loaded from the server. (Form UUID: ${formUuid})`);
}

const formSchemaCompiler = new FormSchemaCompiler();
const formSchema = formSchemaCompiler.compileFormSchema(
{
...form,
pages: clobdata.pages ?? [],
referencedForms: clobdata.referencedForms ?? [],
processor: clobdata.processor,
},
[],
) as FormSchema;

const conceptLang = (window as any).i18next?.language?.substring(0, 2).toLowerCase() || 'en';
manuelroemer marked this conversation as resolved.
Show resolved Hide resolved
const requiredConceptIdentifiers = FormSchemaService.getUnlabeledConceptIdentifiersFromSchema(formSchema);
const conceptUrls = requiredConceptIdentifiers.map(
(identifier) => `/ws/rest/v1/concept/${identifier}?v=full&lang=${conceptLang}`,
);

return [
// Required by:
// - https://github.com/openmrs/openmrs-esm-patient-chart/blob/415790e1ad9b8bdbd1201958d21a06fa93ec7237/packages/esm-form-entry-app/src/app/openmrs-api/form-resource.service.ts#L21
// - https://github.com/openmrs/openmrs-esm-patient-chart/blob/415790e1ad9b8bdbd1201958d21a06fa93ec7237/packages/esm-form-entry-app/src/app/form-schema/form-schema.service.ts#L31
// - https://github.com/openmrs/openmrs-esm-patient-chart/blob/415790e1ad9b8bdbd1201958d21a06fa93ec7237/packages/esm-form-entry-app/src/app/form-schema/form-schema.service.ts#L164
`/ws/rest/v1/form/${formUuid}?v=full`,

// Required by:
// - https://github.com/openmrs/openmrs-esm-patient-chart/blob/415790e1ad9b8bdbd1201958d21a06fa93ec7237/packages/esm-form-entry-app/src/app/openmrs-api/form-resource.service.ts#L10
// - https://github.com/openmrs/openmrs-esm-patient-chart/blob/415790e1ad9b8bdbd1201958d21a06fa93ec7237/packages/esm-form-entry-app/src/app/form-schema/form-schema.service.ts#L167
`/ws/rest/v1/clobdata/${form.resources[0].valueReference}?v=full`,

// Required by:
// - https://github.com/openmrs/openmrs-esm-patient-chart/blob/cb020d4083f564fcda8864dff2897bc3fb9cc8a5/packages/esm-form-entry-app/src/app/services/concept.service.ts#L23
...conceptUrls,
].filter(Boolean);
}
15 changes: 4 additions & 11 deletions packages/esm-form-entry-app/src/app/offline/sync.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { deleteSynchronizationItem, getOfflineDb, queueSynchronizationItem, SyncItem } from '@openmrs/esm-framework';
import { Encounter, EncounterCreate, Person, PersonUpdate } from '../types';
import { getFullSynchronizationItems, queueSynchronizationItem, SyncItem } from '@openmrs/esm-framework';
import { Encounter, EncounterCreate, PersonUpdate } from '../types';

// General note:
// The synchronization handler which actually synchronizes the queued items has been moved to `esm-patient-forms-app`.
Expand Down Expand Up @@ -35,13 +35,6 @@ export async function queuePatientFormSyncItem(content: PatientFormSyncItemConte
export async function findQueuedPatientFormSyncItemByContentId(
id: string,
): Promise<SyncItem<PatientFormSyncItemContent> | undefined> {
// TODO: This direct sync queue interaction should ideally not happen.
// We should add a dedicated API to esm-offline here instead.
// What's missing: A function like `getSynchronizationItems` which returns the *full* item,
// not just `SyncItem.content`. Then we can leverage the existing API to delete the previous item.
return (
await getOfflineDb()
.syncQueue.filter((syncItem) => syncItem.type === patientFormSyncItem && syncItem.content._id === id)
.toArray()
)[0];
const syncItems = await getFullSynchronizationItems<PatientFormSyncItemContent>(patientFormSyncItem);
return syncItems.find((item) => item.content._id === id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,20 @@ import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';

import { WindowRef } from '../window-ref';
import { ReplaySubject, Observable } from 'rxjs';
import { Observable } from 'rxjs';

@Injectable()
export class FormResourceService {
constructor(private http: HttpClient, private windowRef: WindowRef) {}
public getFormClobDataByUuid(uuid: string, v: string = null): Observable<any> {
let url = this.windowRef.openmrsRestBase + 'clobdata';
url += '/' + uuid;

const url = `${this.windowRef.openmrsRestBase}clobdata/${uuid}`;
const params: HttpParams = new HttpParams().set('v', v && v.length > 0 ? v : 'full');

return this.http.get(url, {
params,
});
return this.http.get(url, { params });
}

public getFormMetaDataByUuid(uuid: string, v: string = null): Observable<any> {
let url = this.windowRef.openmrsRestBase + 'form';
url += '/' + uuid;

const url = `${this.windowRef.openmrsRestBase}form/${uuid}`;
const params: HttpParams = new HttpParams().set('v', v && v.length > 0 ? v : 'full');

return this.http.get(url, {
params,
});
return this.http.get(url, { params });
}
}
Loading