From 63b4b831a2ec1627d879874df66b3ae7c0953e68 Mon Sep 17 00:00:00 2001 From: "Bernard K." Date: Wed, 13 May 2026 01:26:34 +0300 Subject: [PATCH] feat(#10662): implement contact profile photo display - add ContactPhotoComponent - replace the resource-icon span in contacts-content with mm-contact-photo - expose photo_field on contact-type config - use photo as default photoField --- .../contact-types-utils/src/index.d.ts | 1 + .../contacts/contact-attachments.wdio-spec.js | 38 +++- .../default/contacts/contacts.wdio.page.js | 4 + webapp/src/css/inbox.less | 9 + .../contact-photo.component.html | 5 + .../contact-photo/contact-photo.component.ts | 84 +++++++ .../contacts/contacts-content.component.html | 6 +- .../contacts/contacts-content.component.ts | 2 + .../contact-photo.component.spec.ts | 206 ++++++++++++++++++ .../contacts-content.component.spec.ts | 21 ++ 10 files changed, 374 insertions(+), 2 deletions(-) create mode 100644 webapp/src/ts/components/contact-photo/contact-photo.component.html create mode 100644 webapp/src/ts/components/contact-photo/contact-photo.component.ts create mode 100644 webapp/tests/karma/ts/components/contact-photo.component.spec.ts diff --git a/shared-libs/contact-types-utils/src/index.d.ts b/shared-libs/contact-types-utils/src/index.d.ts index 96f3a3fbcc8..d37d2d78292 100644 --- a/shared-libs/contact-types-utils/src/index.d.ts +++ b/shared-libs/contact-types-utils/src/index.d.ts @@ -11,6 +11,7 @@ export interface ContactType { readonly edit_form?: string, readonly count_visits?: boolean, readonly person?: boolean, + readonly photo_field?: string, [key: string]: unknown; } diff --git a/tests/e2e/default/contacts/contact-attachments.wdio-spec.js b/tests/e2e/default/contacts/contact-attachments.wdio-spec.js index 0cb104e711d..5cfe5e05569 100644 --- a/tests/e2e/default/contacts/contact-attachments.wdio-spec.js +++ b/tests/e2e/default/contacts/contact-attachments.wdio-spec.js @@ -34,7 +34,8 @@ describe('Contact form attachments', () => { icon: 'medic-person', create_form: 'form:contact:person_with_attachments:create', edit_form: 'form:contact:person_with_attachments:edit', - person: true + person: true, + photo_field: 'photo' }; const translations = { @@ -324,4 +325,39 @@ describe('Contact form attachments', () => { expect(contactAfter._attachments[newAttachmentName].length, 'Replaced attachment should have a valid size') .to.be.greaterThan(0); }); + + describe('Displaying the profile photo', () => { + it('should render the photo in the profile header when a contact has one', async () => { + const contactName = 'Display Photo Person'; + + await commonPage.goToPeople(healthCenter._id); + await commonPage.clickFastActionFAB({ actionId: personWithAttachmentsType.id }); + await commonEnketoPage.setInputValue('Full name', contactName); + await commonEnketoPage.addFileInputValue('Photo', photoPngPath); + await genericForm.submitForm(); + await commonPage.waitForPageLoaded(); + await contactPage.waitForContactLoaded(); + + const photo = await contactPage.getContactCardPhoto(); + await photo.waitForDisplayed(); + const src = await photo.getAttribute('src'); + expect(src).to.match(/^blob:/); + }); + + it('should fall back to the type icon when a contact has no photo', async () => { + const contactName = 'No Photo Person'; + + await commonPage.goToPeople(healthCenter._id); + await commonPage.clickFastActionFAB({ actionId: personWithAttachmentsType.id }); + await commonEnketoPage.setInputValue('Full name', contactName); + await genericForm.submitForm(); + await commonPage.waitForPageLoaded(); + await contactPage.waitForContactLoaded(); + + const photo = await contactPage.getContactCardPhoto(); + expect(await photo.isExisting()).to.be.false; + const fallback = await $('.card .heading mm-contact-photo span .resource-icon'); + await fallback.waitForDisplayed(); + }); + }); }); diff --git a/tests/page-objects/default/contacts/contacts.wdio.page.js b/tests/page-objects/default/contacts/contacts.wdio.page.js index a469e3fa9fe..6581e8c8243 100644 --- a/tests/page-objects/default/contacts/contacts.wdio.page.js +++ b/tests/page-objects/default/contacts/contacts.wdio.page.js @@ -32,6 +32,7 @@ const rightPanelSelectors = { const contactCardSelectors = { contactCardName: () => $('h2[test-id="contact-name"]'), contactCardIcon: (name) => $(`.card .heading .resource-icon[title="medic-${name}"]`), + contactCardPhoto: () => $('.card .heading mm-contact-photo img.contact-photo'), contactSummaryContainer: () => $('#contact_summary'), contactMedicID: () => $('#contact_summary .cell.patient_id > div > p:not(.summary_label)'), contactDeceasedStatus: () => $('div[test-id="deceased-title"]'), @@ -397,6 +398,8 @@ const getCurrentContactId = async () => { return currentUrl.slice(contactBaseUrl.length); }; +const getContactCardPhoto = () => contactCardSelectors.contactCardPhoto(); + const getContactListLoadingStatus = async () => { await leftPanelSelectors.contactListLoadingStatus().waitForDisplayed(); return await leftPanelSelectors.contactListLoadingStatus().getText(); @@ -491,6 +494,7 @@ module.exports = { deletePerson, allContactsList, openReport, + getContactCardPhoto, getContactCardTitle, getContactInfoName, getContactMedicID, diff --git a/webapp/src/css/inbox.less b/webapp/src/css/inbox.less index ad3b5e8ca5c..bcd91204863 100644 --- a/webapp/src/css/inbox.less +++ b/webapp/src/css/inbox.less @@ -580,6 +580,15 @@ mm-analytics-filters { max-width: 100%; } +mm-contact-photo { + display: contents; + + img.contact-photo { + border-radius: 50%; + object-fit: cover; + } +} + .contacts { .fa-star.primary { color: @contacts-primary-color; diff --git a/webapp/src/ts/components/contact-photo/contact-photo.component.html b/webapp/src/ts/components/contact-photo/contact-photo.component.html new file mode 100644 index 00000000000..661c4a4f78c --- /dev/null +++ b/webapp/src/ts/components/contact-photo/contact-photo.component.html @@ -0,0 +1,5 @@ +
+ + diff --git a/webapp/src/ts/components/contact-photo/contact-photo.component.ts b/webapp/src/ts/components/contact-photo/contact-photo.component.ts new file mode 100644 index 00000000000..cdfb548fe57 --- /dev/null +++ b/webapp/src/ts/components/contact-photo/contact-photo.component.ts @@ -0,0 +1,84 @@ +import { Component, Input, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; +import { NgIf } from '@angular/common'; +import { TranslatePipe } from '@ngx-translate/core'; + +import { DbService } from '@mm-services/db.service'; +import { ResourceIconPipe } from '@mm-pipes/resource-icon.pipe'; + +const USER_FILE_ATTACHMENT_PREFIX = 'user-file-'; +const DEFAULT_PHOTO_FIELD = 'photo'; + +@Component({ + selector: 'mm-contact-photo', + templateUrl: './contact-photo.component.html', + imports: [NgIf, ResourceIconPipe, TranslatePipe] +}) +export class ContactPhotoComponent implements OnChanges, OnDestroy { + @Input() doc?: { _id?: string; _attachments?: Record; [field: string]: any }; + @Input() docId?: string; + @Input() photoField?: string; + @Input() fallbackIcon?: string; + + loading = false; + objectUrl?: string; + + constructor( + private readonly dbService: DbService, + ) {} + + ngOnChanges(changes: SimpleChanges) { + if (changes.doc || changes.docId || changes.photoField) { + this.revoke(); + return this.load(); + } + } + + ngOnDestroy() { + this.revoke(); + } + + private async load() { + const doc = await this.resolveDoc(); + const field = this.photoField || DEFAULT_PHOTO_FIELD; + const photo = doc?.[field]; + if (!doc?._id || !photo) { + return; + } + const attachmentName = `${USER_FILE_ATTACHMENT_PREFIX}${photo}`; + if (doc._attachments?.[attachmentName]) { + await this.fetchObjectUrl(doc._id, attachmentName); + } + } + + private resolveDoc() { + if (this.doc) { + return Promise.resolve(this.doc); + } + if (this.docId) { + return this.dbService.get().get(this.docId); + } + return Promise.resolve(null); + } + + private async fetchObjectUrl(docId: string, attachmentName: string) { + this.loading = true; + try { + const blob = await this.dbService.get().getAttachment(docId, attachmentName); + this.objectUrl = (window.URL || window.webkitURL).createObjectURL(blob); + } catch (err) { + if ((err as { status?: number })?.status !== 404) { + throw err; + } + console.warn(`ContactPhotoComponent: attachment ${attachmentName} missing on ${docId}`); + } finally { + this.loading = false; + } + } + + private revoke() { + if (this.objectUrl) { + (window.URL || window.webkitURL).revokeObjectURL(this.objectUrl); + this.objectUrl = undefined; + } + } +} diff --git a/webapp/src/ts/modules/contacts/contacts-content.component.html b/webapp/src/ts/modules/contacts/contacts-content.component.html index 8f49ee381b2..fc8b133e27a 100644 --- a/webapp/src/ts/modules/contacts/contacts-content.component.html +++ b/webapp/src/ts/modules/contacts/contacts-content.component.html @@ -24,7 +24,11 @@
- + +

{{selectedContact?.doc?.name}}

{{'contact.deceased.title' | translate}}
diff --git a/webapp/src/ts/modules/contacts/contacts-content.component.ts b/webapp/src/ts/modules/contacts/contacts-content.component.ts index 21c643f2f60..0affc0fca95 100644 --- a/webapp/src/ts/modules/contacts/contacts-content.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-content.component.ts @@ -27,6 +27,7 @@ import { ErrorLogComponent } from '@mm-components/error-log/error-log.component' import { FastActionButtonComponent } from '@mm-components/fast-action-button/fast-action-button.component'; import { TranslateDirective, TranslatePipe } from '@ngx-translate/core'; import { AuthDirective } from '@mm-directives/auth.directive'; +import { ContactPhotoComponent } from '@mm-components/contact-photo/contact-photo.component'; import { ContentRowListItemComponent } from '@mm-components/content-row-list-item/content-row-list-item.component'; import { ResourceIconPipe } from '@mm-pipes/resource-icon.pipe'; import { SummaryPipe } from '@mm-pipes/message.pipe'; @@ -47,6 +48,7 @@ import { NgFor, TranslateDirective, AuthDirective, + ContactPhotoComponent, ContentRowListItemComponent, RouterLink, TranslatePipe, diff --git a/webapp/tests/karma/ts/components/contact-photo.component.spec.ts b/webapp/tests/karma/ts/components/contact-photo.component.spec.ts new file mode 100644 index 00000000000..fd6ce23f482 --- /dev/null +++ b/webapp/tests/karma/ts/components/contact-photo.component.spec.ts @@ -0,0 +1,206 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateFakeLoader, TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import sinon from 'sinon'; +import { expect } from 'chai'; + +import { ContactPhotoComponent } from '@mm-components/contact-photo/contact-photo.component'; +import { DbService } from '@mm-services/db.service'; +import { ResourceIconsService } from '@mm-services/resource-icons.service'; + +describe('ContactPhotoComponent', () => { + let component: ContactPhotoComponent; + let fixture: ComponentFixture; + let getAttachment; + let get; + let createObjectURL; + let revokeObjectURL; + let originalCreate; + let originalRevoke; + let resourceIconsService; + + const photoBlob = new Blob(['photo-bytes'], { type: 'image/jpeg' }); + + beforeEach(() => { + getAttachment = sinon.stub().resolves(photoBlob); + get = sinon.stub(); + const dbService = { + get: () => ({ getAttachment, get }), + }; + resourceIconsService = { + getImg: sinon.stub().returns(''), + }; + + createObjectURL = sinon.stub().returns('blob:fake-url'); + revokeObjectURL = sinon.stub(); + originalCreate = window.URL.createObjectURL; + originalRevoke = window.URL.revokeObjectURL; + window.URL.createObjectURL = createObjectURL; + window.URL.revokeObjectURL = revokeObjectURL; + + return TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ loader: { provide: TranslateLoader, useClass: TranslateFakeLoader } }), + ContactPhotoComponent, + ], + providers: [ + { provide: DbService, useValue: dbService }, + { provide: ResourceIconsService, useValue: resourceIconsService }, + ], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(ContactPhotoComponent); + component = fixture.componentInstance; + }); + }); + + afterEach(() => { + window.URL.createObjectURL = originalCreate; + window.URL.revokeObjectURL = originalRevoke; + sinon.restore(); + }); + + const setDoc = async (doc) => { + component.doc = doc; + await component.ngOnChanges({ + doc: { currentValue: doc, previousValue: undefined, firstChange: true, isFirstChange: () => true }, + } as any); + fixture.detectChanges(); + }; + + it('loads blob when doc has matching photo and attachment stub', async () => { + await setDoc({ + _id: 'c-1', + photo: 'amina.jpg', + _attachments: { 'user-file-amina.jpg': { content_type: 'image/jpeg', stub: true } }, + }); + + expect(getAttachment.calledOnceWithExactly('c-1', 'user-file-amina.jpg')).to.be.true; + expect(createObjectURL.calledOnceWith(photoBlob)).to.be.true; + expect(component.objectUrl).to.exist; + }); + + it('renders no img and skips fetch when doc has no photo field', async () => { + await setDoc({ _id: 'c-1', _attachments: {} }); + + expect(getAttachment.called).to.be.false; + expect(component.objectUrl).to.be.undefined; + }); + + it('skips fetch when photo field set but no matching attachment stub', async () => { + await setDoc({ _id: 'c-1', photo: 'amina.jpg', _attachments: {} }); + + expect(getAttachment.called).to.be.false; + expect(component.objectUrl).to.be.undefined; + }); + + it('falls back silently when getAttachment rejects with 404', async () => { + const warn = sinon.stub(console, 'warn'); + getAttachment.rejects({ status: 404 }); + + await setDoc({ + _id: 'c-1', + photo: 'amina.jpg', + _attachments: { 'user-file-amina.jpg': { stub: true } }, + }); + + expect(component.objectUrl).to.be.undefined; + expect(warn.calledOnce).to.be.true; + }); + + it('rethrows getAttachment errors other than 404', async () => { + getAttachment.rejects({ status: 500 }); + + component.doc = { + _id: 'c-1', + photo: 'amina.jpg', + _attachments: { 'user-file-amina.jpg': { stub: true } }, + }; + let err; + try { + await component.ngOnChanges({ + doc: { currentValue: component.doc, previousValue: undefined, firstChange: true, isFirstChange: () => true }, + } as any); + } catch (e) { + err = e; + } + expect(err).to.deep.include({ status: 500 }); + }); + + it('defaults photoField to "photo" when input is unbound or undefined', async () => { + await setDoc({ + _id: 'c-1', + photo: 'amina.jpg', + _attachments: { 'user-file-amina.jpg': { stub: true } }, + }); + + expect(getAttachment.calledWith('c-1', 'user-file-amina.jpg')).to.be.true; + + getAttachment.resetHistory(); + component.photoField = undefined; + await setDoc({ + _id: 'c-2', + photo: 'bob.jpg', + _attachments: { 'user-file-bob.jpg': { stub: true } }, + }); + expect(getAttachment.calledWith('c-2', 'user-file-bob.jpg')).to.be.true; + }); + + it('honours an explicit photoField override', async () => { + component.photoField = 'picture'; + await setDoc({ + _id: 'c-1', + picture: 'amina.jpg', + _attachments: { 'user-file-amina.jpg': { stub: true } }, + }); + + expect(getAttachment.calledOnceWith('c-1', 'user-file-amina.jpg')).to.be.true; + }); + + it('falls back to docId fetch when doc input is not provided', async () => { + get.resolves({ + _id: 'c-1', + photo: 'amina.jpg', + _attachments: { 'user-file-amina.jpg': { stub: true } }, + }); + component.docId = 'c-1'; + await component.ngOnChanges({ + docId: { currentValue: 'c-1', previousValue: undefined, firstChange: true, isFirstChange: () => true }, + } as any); + + expect(get.calledOnceWith('c-1')).to.be.true; + expect(getAttachment.calledOnceWith('c-1', 'user-file-amina.jpg')).to.be.true; + }); + + it('revokes prior object URL and re-fetches on doc input change', async () => { + await setDoc({ + _id: 'c-1', + photo: 'amina.jpg', + _attachments: { 'user-file-amina.jpg': { stub: true } }, + }); + expect(createObjectURL.callCount).to.equal(1); + + createObjectURL.returns('blob:fake-url-2'); + await setDoc({ + _id: 'c-2', + photo: 'bob.jpg', + _attachments: { 'user-file-bob.jpg': { stub: true } }, + }); + + expect(revokeObjectURL.calledOnceWith('blob:fake-url')).to.be.true; + expect(createObjectURL.callCount).to.equal(2); + expect(getAttachment.calledWith('c-2', 'user-file-bob.jpg')).to.be.true; + }); + + it('revokes the object URL on destroy', async () => { + await setDoc({ + _id: 'c-1', + photo: 'amina.jpg', + _attachments: { 'user-file-amina.jpg': { stub: true } }, + }); + + component.ngOnDestroy(); + + expect(revokeObjectURL.calledOnceWith('blob:fake-url')).to.be.true; + }); +}); diff --git a/webapp/tests/karma/ts/modules/contacts/contacts-content.component.spec.ts b/webapp/tests/karma/ts/modules/contacts/contacts-content.component.spec.ts index 19741028e38..f035d8a6c22 100644 --- a/webapp/tests/karma/ts/modules/contacts/contacts-content.component.spec.ts +++ b/webapp/tests/karma/ts/modules/contacts/contacts-content.component.spec.ts @@ -12,6 +12,7 @@ import { ContactsActions } from '@mm-actions/contacts'; import { Selectors } from '@mm-selectors/index'; import { ResourceIconPipe } from '@mm-pipes/resource-icon.pipe'; import { ResourceIconsService } from '@mm-services/resource-icons.service'; +import { DbService } from '@mm-services/db.service'; import { ChangesService } from '@mm-services/changes.service'; import { ContactChangeFilterService } from '@mm-services/contact-change-filter.service'; import { XmlFormsService } from '@mm-services/xml-forms.service'; @@ -128,6 +129,9 @@ describe('Contacts content component', () => { { provide: Router, useValue: router }, { provide: ResourceIconPipe, useValue: { transform: sinon.stub() } }, { provide: ResourceIconsService, useValue: { getImg: sinon.stub() } }, + { provide: DbService, useValue: { + get: () => ({ getAttachment: sinon.stub().resolves(), get: sinon.stub().resolves({}) }) + } }, { provide: ContactChangeFilterService, useValue: contactChangeFilterService }, { provide: ChangesService, useValue: changesService }, { provide: ChangesService, useValue: changesService }, @@ -165,6 +169,23 @@ describe('Contacts content component', () => { expect(component).to.exist; }); + it('renders mm-contact-photo in the profile heading wired to the selected contact', () => { + selectedContact.doc = { _id: 'c-1', name: 'Amina' }; + selectedContact.type = { icon: 'medic-person', photo_field: 'face_pic' }; + store.overrideSelector(Selectors.getSelectedContact, selectedContact); + fixture.detectChanges(); + + const heading = fixture.nativeElement.querySelector('.row.heading mm-contact-photo'); + expect(heading).to.exist; + + const photoComponent = fixture.debugElement + .query(el => el.nativeElement === heading) + .componentInstance; + expect(photoComponent.photoField).to.equal('face_pic'); + expect(photoComponent.fallbackIcon).to.equal('medic-person'); + expect(photoComponent.doc).to.deep.equal(selectedContact.doc); + }); + it('ngOnDestroy() should unsubscribe from observables and reset state', () => { const unsubscribeSpy = sinon.spy(component.subscriptions, 'unsubscribe'); const clearSelectionStub = sinon.stub(ContactsActions.prototype, 'clearSelection');