Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions shared-libs/contact-types-utils/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
38 changes: 37 additions & 1 deletion tests/e2e/default/contacts/contact-attachments.wdio-spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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();
});
});
});
4 changes: 4 additions & 0 deletions tests/page-objects/default/contacts/contacts.wdio.page.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"]'),
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -491,6 +494,7 @@ module.exports = {
deletePerson,
allContactsList,
openReport,
getContactCardPhoto,
getContactCardTitle,
getContactInfoName,
getContactMedicID,
Expand Down
9 changes: 9 additions & 0 deletions webapp/src/css/inbox.less
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<div class="loader" *ngIf="loading"></div>
<img *ngIf="!loading && objectUrl" class="contact-photo" [src]="objectUrl"
[alt]="doc?.name || ('contact.profile' | translate)"/>
<span *ngIf="!loading && !objectUrl && fallbackIcon"
[innerHTML]="fallbackIcon | resourceIcon"></span>
84 changes: 84 additions & 0 deletions webapp/src/ts/components/contact-photo/contact-photo.component.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>; [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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@
<div class="body meta">
<div class="card">
<div class="row heading" [ngClass]="{'deceased': selectedContact?.doc?.date_of_death, 'muted':selectedContact?.doc?.muted }">
<span [innerHTML]="selectedContact?.type?.icon | resourceIcon"></span>
<mm-contact-photo
[doc]="selectedContact?.doc"
[photoField]="selectedContact?.type?.photo_field"
[fallbackIcon]="selectedContact?.type?.icon">
</mm-contact-photo>
<div class="heading-content">
<h2 [attr.test-id]="'contact-name'">{{selectedContact?.doc?.name}}</h2>
<div [attr.test-id]="'deceased-title'" *ngIf="selectedContact?.doc?.date_of_death">{{'contact.deceased.title' | translate}}</div>
Expand Down
2 changes: 2 additions & 0 deletions webapp/src/ts/modules/contacts/contacts-content.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -47,6 +48,7 @@ import {
NgFor,
TranslateDirective,
AuthDirective,
ContactPhotoComponent,
ContentRowListItemComponent,
RouterLink,
TranslatePipe,
Expand Down
Loading