Skip to content

Commit bd04119

Browse files
authored
fix(WEB-1041): render identifier images after refresh (#3730)
1 parent e3cb18e commit bd04119

4 files changed

Lines changed: 233 additions & 20 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* Copyright since 2025 Mifos Initiative
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
7+
*/
8+
9+
import { TestBed } from '@angular/core/testing';
10+
import { ActivatedRoute } from '@angular/router';
11+
import { MatDialog } from '@angular/material/dialog';
12+
import { TranslateModule } from '@ngx-translate/core';
13+
import { of } from 'rxjs';
14+
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
15+
16+
import { ClientsService } from '../../clients.service';
17+
import { AuthenticationService } from 'app/core/authentication/authentication.service';
18+
import { DocumentPreviewService } from 'app/shared/services/document-preview.service';
19+
import { IdentitiesTabComponent } from './identities-tab.component';
20+
21+
describe('IdentitiesTabComponent', () => {
22+
let component: IdentitiesTabComponent;
23+
let clientsService: jest.Mocked<ClientsService>;
24+
let documentPreviewService: jest.Mocked<DocumentPreviewService>;
25+
let markForCheck: jest.Mock;
26+
27+
beforeEach(async () => {
28+
clientsService = {
29+
downloadClientIdentificationDocument: jest.fn(() => of(new Blob(['image'], { type: 'image/png' })))
30+
} as any;
31+
documentPreviewService = {
32+
isPreviewable: jest.fn(() => true),
33+
resolvePreviewUrl: jest.fn((document: any, downloadFn: any) => {
34+
downloadFn(document);
35+
return Promise.resolve({ url: `blob:${document.id}`, type: 'image' });
36+
}),
37+
release: jest.fn()
38+
} as any;
39+
40+
await TestBed.configureTestingModule({
41+
imports: [
42+
IdentitiesTabComponent,
43+
TranslateModule.forRoot()
44+
],
45+
providers: [
46+
{ provide: ClientsService, useValue: clientsService },
47+
{
48+
provide: AuthenticationService,
49+
useValue: { getCredentials: jest.fn(() => ({ permissions: ['ALL_FUNCTIONS'] })) }
50+
},
51+
{ provide: DocumentPreviewService, useValue: documentPreviewService },
52+
{ provide: MatDialog, useValue: { open: jest.fn() } },
53+
{
54+
provide: ActivatedRoute,
55+
useValue: {
56+
parent: {
57+
snapshot: {
58+
paramMap: {
59+
get: jest.fn(() => 'client-99')
60+
}
61+
}
62+
},
63+
data: of({ clientIdentities: [], clientIdentifierTemplate: { allowedDocumentTypes: [] } })
64+
}
65+
}
66+
]
67+
}).compileComponents();
68+
69+
const fixture = TestBed.createComponent(IdentitiesTabComponent);
70+
component = fixture.componentInstance;
71+
markForCheck = jest.fn();
72+
(component as any).changeDetectorRef = { markForCheck };
73+
});
74+
75+
it('uses document parentEntityId when resolving a thumbnail', async () => {
76+
(component as any).setThumbnail({
77+
id: 'doc-1',
78+
parentEntityId: 'identifier-1',
79+
fileName: 'front.png'
80+
});
81+
82+
await Promise.resolve();
83+
84+
expect(clientsService.downloadClientIdentificationDocument).toHaveBeenCalledWith('identifier-1', 'doc-1');
85+
expect(clientsService.downloadClientIdentificationDocument).not.toHaveBeenCalledWith('client-99', 'doc-1');
86+
expect(component.previewThumbnails).toEqual({ 'doc-1': 'blob:doc-1' });
87+
expect(markForCheck).toHaveBeenCalledTimes(1);
88+
});
89+
90+
it('falls back to identity id instead of client id when document parentEntityId is absent', async () => {
91+
(component as any).setThumbnail(
92+
{
93+
id: 'doc-2',
94+
fileName: 'back.png'
95+
},
96+
{ id: 'identifier-2' }
97+
);
98+
99+
await Promise.resolve();
100+
101+
expect(clientsService.downloadClientIdentificationDocument).toHaveBeenCalledWith('identifier-2', 'doc-2');
102+
expect(clientsService.downloadClientIdentificationDocument).not.toHaveBeenCalledWith('client-99', 'doc-2');
103+
expect(component.previewThumbnails).toEqual({ 'doc-2': 'blob:doc-2' });
104+
expect(markForCheck).toHaveBeenCalledTimes(1);
105+
});
106+
});

src/app/clients/clients-view/identities-tab/identities-tab.component.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
/** Angular Imports */
1010
import {
1111
ChangeDetectionStrategy,
12+
ChangeDetectorRef,
1213
Component,
1314
DestroyRef,
1415
ElementRef,
@@ -86,6 +87,7 @@ export class IdentitiesTabComponent implements OnDestroy {
8687
private clientService = inject(ClientsService);
8788
private translateService = inject(TranslateService);
8889
private documentPreviewService = inject(DocumentPreviewService);
90+
private changeDetectorRef = inject(ChangeDetectorRef);
8991

9092
private destroyRef = inject(DestroyRef);
9193

@@ -284,7 +286,7 @@ export class IdentitiesTabComponent implements OnDestroy {
284286
this.clientService.downloadClientIdentificationDocument(doc.parentEntityId || identity.id, doc.id)
285287
);
286288
if (preview.type === 'image') {
287-
this.previewThumbnails[doc.id] = preview.url;
289+
this.setPreviewThumbnail(doc.id, preview.url);
288290
}
289291
items.push({
290292
src: preview.url,
@@ -343,28 +345,40 @@ export class IdentitiesTabComponent implements OnDestroy {
343345
}
344346
}
345347

346-
private setThumbnail(document: any): void {
348+
private setThumbnail(document: any, identity?: any): void {
347349
if (!this.documentPreviewService.isPreviewable(document)) {
348350
return;
349351
}
352+
const identifierId = document.parentEntityId || identity?.id;
353+
if (!identifierId) {
354+
return;
355+
}
350356
this.documentPreviewService
351357
.resolvePreviewUrl(document, () =>
352-
this.clientService.downloadClientIdentificationDocument(document.parentEntityId || this.clientId, document.id)
358+
this.clientService.downloadClientIdentificationDocument(identifierId, document.id)
353359
)
354360
.then((preview) => {
355361
if (preview.type === 'image') {
356-
this.previewThumbnails[document.id] = preview.url;
362+
this.setPreviewThumbnail(document.id, preview.url);
357363
}
358364
})
359365
.catch((): void => undefined);
360366
}
361367

368+
private setPreviewThumbnail(documentId: string, thumbnailUrl: string): void {
369+
this.previewThumbnails = {
370+
...this.previewThumbnails,
371+
[documentId]: thumbnailUrl
372+
};
373+
this.changeDetectorRef.markForCheck();
374+
}
375+
362376
private prefetchThumbnails(): void {
363377
if (!Array.isArray(this.clientIdentities)) {
364378
return;
365379
}
366380
this.clientIdentities.forEach((identity: any) => {
367-
identity.documents?.forEach((doc: any) => this.setThumbnail(doc));
381+
identity.documents?.forEach((doc: any) => this.setThumbnail(doc, identity));
368382
});
369383
}
370384
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* Copyright since 2025 Mifos Initiative
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
7+
*/
8+
9+
import { TestBed } from '@angular/core/testing';
10+
import { ActivatedRouteSnapshot } from '@angular/router';
11+
import { of, Subject } from 'rxjs';
12+
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
13+
14+
import { ClientsService } from '../clients.service';
15+
import { ClientIdentitiesResolver } from './client-identities.resolver';
16+
17+
describe('ClientIdentitiesResolver', () => {
18+
let resolver: ClientIdentitiesResolver;
19+
let clientsService: jest.Mocked<ClientsService>;
20+
21+
const route = {
22+
parent: {
23+
paramMap: {
24+
get: jest.fn(() => '11')
25+
}
26+
}
27+
} as unknown as ActivatedRouteSnapshot;
28+
29+
beforeEach(() => {
30+
clientsService = {
31+
getClientIdentifiers: jest.fn(),
32+
getClientIdentificationDocuments: jest.fn()
33+
} as any;
34+
35+
TestBed.configureTestingModule({
36+
providers: [
37+
ClientIdentitiesResolver,
38+
{ provide: ClientsService, useValue: clientsService }
39+
]
40+
});
41+
42+
resolver = TestBed.inject(ClientIdentitiesResolver);
43+
});
44+
45+
it('emits identities only after their documents have loaded', () => {
46+
const firstDocuments$ = new Subject<any[]>();
47+
const secondDocuments$ = new Subject<any[]>();
48+
const emitted: any[] = [];
49+
50+
clientsService.getClientIdentifiers.mockReturnValue(
51+
of([
52+
{ id: 101, documentKey: 'A-101' },
53+
{ id: 202, documentKey: 'B-202' }
54+
])
55+
);
56+
clientsService.getClientIdentificationDocuments.mockImplementation((identifierId: any) =>
57+
identifierId === 101 ? firstDocuments$ : secondDocuments$
58+
);
59+
60+
resolver.resolve(route).subscribe((identities) => emitted.push(identities));
61+
62+
expect(clientsService.getClientIdentifiers).toHaveBeenCalledWith('11');
63+
expect(clientsService.getClientIdentificationDocuments).toHaveBeenCalledWith(101);
64+
expect(clientsService.getClientIdentificationDocuments).toHaveBeenCalledWith(202);
65+
expect(emitted).toEqual([]);
66+
67+
firstDocuments$.next([{ id: 1, name: 'front.png' }]);
68+
firstDocuments$.complete();
69+
expect(emitted).toEqual([]);
70+
71+
secondDocuments$.next([{ id: 2, name: 'back.png' }]);
72+
secondDocuments$.complete();
73+
74+
expect(emitted).toEqual([
75+
[
76+
{ id: 101, documentKey: 'A-101', documents: [{ id: 1, name: 'front.png' }] },
77+
{ id: 202, documentKey: 'B-202', documents: [{ id: 2, name: 'back.png' }] }
78+
]
79+
]);
80+
});
81+
82+
it('emits an empty array without requesting documents when there are no identities', () => {
83+
const emitted: any[] = [];
84+
clientsService.getClientIdentifiers.mockReturnValue(of([]));
85+
86+
resolver.resolve(route).subscribe((identities) => emitted.push(identities));
87+
88+
expect(emitted).toEqual([[]]);
89+
expect(clientsService.getClientIdentificationDocuments).not.toHaveBeenCalled();
90+
});
91+
});

src/app/clients/common-resolvers/client-identities.resolver.ts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import { Injectable, inject } from '@angular/core';
1111
import { ActivatedRouteSnapshot } from '@angular/router';
1212

1313
/** rxjs Imports */
14-
import { Observable, forkJoin, from } from 'rxjs';
15-
import { map } from 'rxjs/operators';
14+
import { Observable, forkJoin, of } from 'rxjs';
15+
import { map, switchMap } from 'rxjs/operators';
1616

1717
/** Custom Services */
1818
import { ClientsService } from '../clients.service';
@@ -30,20 +30,22 @@ export class ClientIdentitiesResolver {
3030
*/
3131
resolve(route: ActivatedRouteSnapshot): Observable<any> {
3232
const clientId = route.parent.paramMap.get('clientId');
33-
let identitiesData: any;
3433
return this.clientsService.getClientIdentifiers(clientId).pipe(
35-
map((identities: any) => {
36-
identitiesData = identities;
37-
const docObservable: Observable<any>[] = [];
38-
identities.forEach((identity: any) => {
39-
docObservable.push(this.clientsService.getClientIdentificationDocuments(identity.id));
40-
});
41-
forkJoin(docObservable).subscribe((documents) => {
42-
documents.forEach((document, index) => {
43-
identitiesData[index].documents = document;
44-
});
45-
});
46-
return identitiesData;
34+
switchMap((identities: any[]) => {
35+
if (!identities?.length) {
36+
return of(identities || []);
37+
}
38+
39+
return forkJoin(
40+
identities.map((identity: any) =>
41+
this.clientsService.getClientIdentificationDocuments(identity.id).pipe(
42+
map((documents: any[]) => ({
43+
...identity,
44+
documents: documents || []
45+
}))
46+
)
47+
)
48+
);
4749
})
4850
);
4951
}

0 commit comments

Comments
 (0)