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');