diff --git a/packages/fhir-group-management/package.json b/packages/fhir-group-management/package.json index 3a9b1c998..144705da6 100644 --- a/packages/fhir-group-management/package.json +++ b/packages/fhir-group-management/package.json @@ -38,6 +38,7 @@ "@opensrp/rbac": "workspace:^", "@opensrp/react-utils": "^0.0.12", "@opensrp/reducer-factory": "^0.0.13", + "browser-image-compression": "^2.0.2", "fhirclient": "^2.3.11", "uuid": "^8.3.1" }, diff --git a/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/index.tsx b/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/index.tsx index 94dfb28b4..cc25e04e8 100644 --- a/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/index.tsx +++ b/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/index.tsx @@ -87,6 +87,7 @@ export const CommodityAddEdit = (props: GroupAddEditProps) => { ); let binaryResponse; + if (binary) { binaryResponse = await postPutBinary(fhirBaseUrl, binary); } diff --git a/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/tests/fixtures.ts b/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/tests/fixtures.ts index e2b01dba9..e9790f6fe 100644 --- a/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/tests/fixtures.ts +++ b/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/tests/fixtures.ts @@ -245,7 +245,7 @@ export const editedCommodity1 = { }, ], }, - valueReference: { reference: 'Binary/9b782015-8392-4847-b48c-50c11638656b' }, + valueReference: { reference: 'Binary/24d55827-fbd8-4b86-a47a-2f5b4598c515' }, }, ], }; @@ -258,10 +258,10 @@ export const binary1 = { }; export const editedBinary1 = { - id: '9b782015-8392-4847-b48c-50c11638656b', + id: binary1.id, resourceType: 'Binary', - contentType: 'image/png', - data: 'aGVsbG8=', + contentType: 'image/webp', + data: 'aGw=', }; export const editedCommodity = { @@ -459,111 +459,6 @@ export const createdCommodity = { export const createdBinary = { id: '9b782015-8392-4847-b48c-50c11638656b', resourceType: 'Binary', - contentType: 'image/png', - data: 'aGVsbG8=', -}; - -export const removedImageCommodity = { - resourceType: 'Group', - id: '52cffa51-fa81-49aa-9944-5b45d9e4c117', - identifier: [ - { use: 'secondary', value: '606109db-5632-48c5-8710-b726e1b3addf' }, - { use: 'official', value: '52cffa51-fa81-49aa-9944-5b45d9e4c117' }, - ], - active: true, - type: 'substance', - actual: false, - code: { - coding: [{ system: 'http://snomed.info/sct', code: '386452003', display: 'Supply management' }], - }, - name: 'Bed nets', - characteristic: [ - { - code: { - coding: [ - { - system: 'http://smartregister.org/codes', - code: '23435363', - display: 'Attractive Item code', - }, - ], - }, - valueBoolean: true, - }, - { - code: { - coding: [ - { - system: 'http://smartregister.org/codes', - code: '34536373', - display: 'Is it there code', - }, - ], - }, - valueCodeableConcept: { - coding: [ - { - system: 'http://smartregister.org/codes', - code: '34536373-1', - display: 'Value entered on the It is there code', - }, - ], - text: 'yes', - }, - }, - { - code: { - coding: [ - { - system: 'http://smartregister.org/codes', - code: '45647484', - display: 'Is it in good condition? (optional)', - }, - ], - }, - valueCodeableConcept: { - coding: [ - { - system: 'http://smartregister.org/codes', - code: '45647484-1', - display: 'Value entered on the Is it in good condition? (optional)', - }, - ], - text: 'Yes, no tears, and inocuated', - }, - }, - { - code: { - coding: [ - { - system: 'http://smartregister.org/codes', - code: '56758595', - display: 'Is it being used appropriately? (optional)', - }, - ], - }, - valueCodeableConcept: { - coding: [ - { - system: 'http://smartregister.org/codes', - code: '56758595-1', - display: 'Value entered on the Is it being used appropriately? (optional)', - }, - ], - text: 'Hanged at correct height and covers averagely sized beds', - }, - }, - { - code: { - coding: [ - { - system: 'http://smartregister.org/codes', - code: '67869606', - display: 'Accountability period (in months)', - }, - ], - }, - valueQuantity: { value: 12 }, - }, - ], + contentType: 'image/webp', + data: 'aGw=', }; diff --git a/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/tests/index.test.tsx b/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/tests/index.test.tsx index 285da448b..8ccaed1ba 100644 --- a/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/tests/index.test.tsx +++ b/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/tests/index.test.tsx @@ -20,15 +20,26 @@ import { editedCommodity1, listEdited1, newList, - removedImageCommodity, } from './fixtures'; import { binaryResourceType, groupResourceType, listResourceType } from '../../../../constants'; import userEvent from '@testing-library/user-event'; import * as notifications from '@opensrp/notifications'; -import { photoUploadCharacteristicCode } from '../../../../helpers/utils'; import { cloneDeep } from 'lodash'; import { RoleContext } from '@opensrp/rbac'; import { superUserRole } from '@opensrp/react-utils'; +import imageCompression from 'browser-image-compression'; + +// TODO - hack product image validation breaks, with silent error, disregarding it for now. +jest.mock('../utils', () => { + const validationRules = jest.requireActual('../utils').validationRulesFactory((x) => x); + delete validationRules['productImage']; + return { + ...Object.assign({}, jest.requireActual('../utils')), + validationRulesFactory: () => validationRules, + }; +}); + +jest.mock('browser-image-compression', () => jest.fn()); jest.mock('@opensrp/notifications', () => ({ __esModule: true, @@ -59,6 +70,7 @@ const queryClient = new QueryClient({ const listResId = 'list-resource-id'; const productImage = new File(['hello'], 'product.png', { type: 'image/png' }); +const mockBlob = new File(['hl'], 'product.webp', { type: 'image/webp' }); const props = { fhirBaseURL: 'http://test.server.org', listId: listResId, @@ -204,6 +216,8 @@ it('can create new commodity', async () => { const history = createMemoryHistory(); history.push(`/add`); + imageCompression.mockResolvedValue(mockBlob); + const successNoticeMock = jest .spyOn(notifications, 'sendSuccessNotification') .mockImplementation(() => undefined); @@ -282,6 +296,8 @@ it('edits resource', async () => { const history = createMemoryHistory(); history.push(`/add/${commodity1.id}`); + imageCompression.mockResolvedValue(mockBlob); + const successNoticeMock = jest .spyOn(notifications, 'sendSuccessNotification') .mockImplementation(() => undefined); @@ -298,7 +314,7 @@ it('edits resource', async () => { nock(props.fhirBaseURL).get(`/${binaryResourceType}/${binary1.id}`).reply(200, binary1).persist(); nock(props.fhirBaseURL) - .put(`/${binaryResourceType}/${mockv4}`, editedBinary1) + .put(`/${binaryResourceType}/${binary1.id}`, editedBinary1) .reply(200, editedBinary1) .persist(); @@ -327,7 +343,7 @@ it('edits resource', async () => { ...newList, entry: [ { item: { reference: 'Group/52cffa51-fa81-49aa-9944-5b45d9e4c117' } }, - { item: { reference: 'Binary/9b782015-8392-4847-b48c-50c11638656b' } }, + { item: { reference: `Binary/${binary1.id}` } }, ], }; @@ -399,6 +415,8 @@ it('can remove product image', async () => { const history = createMemoryHistory(); history.push(`/add/${commodity1.id}`); + imageCompression.mockResolvedValue(mockBlob); + const successNoticeMock = jest .spyOn(notifications, 'sendSuccessNotification') .mockImplementation(() => undefined); @@ -413,28 +431,33 @@ it('can remove product image', async () => { .persist(); nock(props.fhirBaseURL).get(`/${binaryResourceType}/${binary1.id}`).reply(200, binary1).persist(); + const binaryPayload = { + id: binary1.id, + resourceType: 'Binary', + }; + nock(props.fhirBaseURL) + .put(`/${binaryResourceType}/${binary1.id}`, binaryPayload) + .reply(200, binaryPayload) + .persist(); + + const newList = { + ...cloneDeep(listEdited1), + entry: [ + { item: { reference: 'Group/9b782015-8392-4847-b48c-50c11638656b' } }, + { item: { reference: 'Binary/24d55827-fbd8-4b86-a47a-2f5b4598c515' } }, + ], + }; - const imageLessList = cloneDeep(listEdited1); - imageLessList.entry = imageLessList.entry.filter( - (entry) => entry.item.reference !== `${binaryResourceType}/${binary1.id}` - ); nock(props.fhirBaseURL) .get(`/${listResourceType}/${props.listId}`) - .reply(200, listEdited1) - .put(`/${listResourceType}/${props.listId}`, imageLessList) - .reply(201, imageLessList) + .reply(200, newList) + .put(`/${listResourceType}/${props.listId}`, newList) + .reply(201, newList) .persist(); - const commodityLessImage = cloneDeep(commodity1); - commodityLessImage.characteristic = (commodity1.characteristic ?? []).filter( - (stic) => - (stic.code.coding ?? []).map((coding) => coding.code).indexOf(photoUploadCharacteristicCode) < - 0 - ); - nock(props.fhirBaseURL) - .put(`/${groupResourceType}/${commodity1.id}`, removedImageCommodity) - .reply(200, removedImageCommodity) + .put(`/${groupResourceType}/${commodity1.id}`, commodity1) + .reply(200, commodity1) .persist(); render( @@ -447,9 +470,6 @@ it('can remove product image', async () => { screen.getByText('Edit commodity | Bed nets'); }); - const attractiveYes = screen.getByRole('radio', { name: /yes/i }); - userEvent.click(attractiveYes); - const removeFileIcon = screen.getByTitle('Remove file'); userEvent.click(removeFileIcon); @@ -457,7 +477,11 @@ it('can remove product image', async () => { await waitFor(() => { expect(successNoticeMock.mock.calls).toEqual([['Commodity updated successfully']]); + }); + + await waitFor(() => { expect(errorNoticeMock.mock.calls).toEqual([]); + expect(successNoticeMock.mock.calls).toEqual([['Commodity updated successfully']]); }); expect(nock.pendingMocks()).toEqual([]); diff --git a/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/utils.ts b/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/utils.ts index 9d4e22f4c..8c86c8153 100644 --- a/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/utils.ts +++ b/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/utils.ts @@ -52,6 +52,8 @@ import { UploadFile } from 'antd'; import { Coding } from '@smile-cdr/fhirts/dist/FHIR-R4/classes/coding'; import { R4GroupTypeCodes } from '@opensrp/fhir-helpers'; import { defaultValidationRulesFactory } from '../../ProductForm/utils'; +import { RcFile } from 'antd/es/upload'; +import imageCompression from 'browser-image-compression'; export type EusmGroupFormFields = GroupFormFields<{ group: IGroup; binary?: IBinary }>; @@ -101,17 +103,17 @@ export const validationRulesFactory = (t: TFunction) => { * @param characteristic - group characteristic */ function getValueFromCharacteristic(characteristic: GroupCharacteristic) { - if (characteristic['valueCodeableConcept']) { - return characteristic.valueCodeableConcept.text; + if (Object.prototype.hasOwnProperty.call(characteristic, 'valueCodeableConcept')) { + return characteristic.valueCodeableConcept?.text; } - if (characteristic['valueBoolean']) { + if (Object.prototype.hasOwnProperty.call(characteristic, 'valueBoolean')) { return characteristic.valueBoolean; } - if (characteristic['valueQuantity']) { - return characteristic.valueQuantity.value; + if (Object.prototype.hasOwnProperty.call(characteristic, 'valueQuantity')) { + return characteristic.valueQuantity?.value; } - if (characteristic['valueReference']) { - return characteristic.valueReference.reference; + if (Object.prototype.hasOwnProperty.call(characteristic, 'valueReference')) { + return characteristic.valueReference?.reference; } } @@ -282,6 +284,25 @@ function fileToBase64(file?: File): Promise { }); } +/** + * Compresses images before uploading, images is scaled down and + * converted to webp format + * + * @param file - image file to be downscaled + */ +export async function compressImage(file: RcFile | undefined) { + if (!file) return; + const options = { + maxSizeMB: 1, + maxWidthOrHeight: 1920, + fileType: 'image/webp', + }; + + const compressedBlob = await imageCompression(file, options); + + return compressedBlob; +} + /** * generates the binary payload for uploaded image * @@ -298,22 +319,33 @@ export async function getProductImagePayload( const currentImageb64 = await fileToBase64(currentImage); const initialImageb64 = await fileToBase64(initialImage); + const scaledDownImage = await compressImage(currentImage); + const scaledDownCurrentImageb64 = await fileToBase64(scaledDownImage); + if (currentImageb64 === initialImageb64) { - // This could mean it was not added or removed. + // This means there was no change to the product field return { changed: false, }; - } else if (currentImage === undefined) { + } + if (currentImage === undefined) { + const id = initialValues.productImage?.[0]?.uid ?? v4(); + const payload: IBinary = { + id, + resourceType: binaryResourceType, + }; return { changed: true, + payload, }; } else { - const id = v4(); + // use initial images id for binary resource essentially editing existing binary resource. + const id = initialValues.productImage?.[0]?.uid ?? v4(); const payload: IBinary = { id, resourceType: binaryResourceType, - contentType: currentImage.type, - data: currentImageb64, + contentType: scaledDownImage?.type, + data: scaledDownCurrentImageb64, }; return { changed: true, diff --git a/packages/fhir-group-management/src/components/ProductForm/index.tsx b/packages/fhir-group-management/src/components/ProductForm/index.tsx index 2f4dba13e..30ac1ef5d 100644 --- a/packages/fhir-group-management/src/components/ProductForm/index.tsx +++ b/packages/fhir-group-management/src/components/ProductForm/index.tsx @@ -278,6 +278,7 @@ function CommodityForm< {({ getFieldValue }) => { return (