diff --git a/jest.config.js b/jest.config.js index ddb3746c7..15e33d0f2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -16,7 +16,7 @@ module.exports = { transformIgnorePatterns: [ 'node_modules/(?!(@helsenorge/toolkit|@helsenorge/core-utils|@helsenorge/designsystem-react)/)', ], - setupFiles: ['./setupTests'], + setupFiles: ['./setupTests', 'jest-canvas-mock'], setupFilesAfterEnv: ['/jest-setup.ts'], roots: ['packages/', 'app'], moduleNameMapper: { diff --git a/package.json b/package.json index 10c3a561e..0c68050c5 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "husky": "^8.0.0", "i18next-parser": "^5.4.0", "jest": "27.5.1", + "jest-canvas-mock": "^2.5.2", "jest-environment-jsdom-sixteen": "^2.0.0", "jest-fetch-mock": "^3.0.3", "jest-haste-map": "^27.4.6", diff --git a/packages/fhir-group-management/package.json b/packages/fhir-group-management/package.json index 22c631218..859e438f4 100644 --- a/packages/fhir-group-management/package.json +++ b/packages/fhir-group-management/package.json @@ -31,6 +31,7 @@ "author": "OpenSRP Engineering", "license": "Apache-2.0", "dependencies": { + "@ant-design/icons": "^4.7.0", "@opensrp/notifications": "^0.0.5", "@opensrp/pkg-config": "^0.0.9", "@opensrp/rbac": "workspace:^", @@ -46,6 +47,7 @@ }, "peerDependencies": { "@opensrp/i18n": "^0.0.1", - "react": "17.0.0" + "react": "17.0.0", + "react-query": "^3.15.1" } } diff --git a/packages/fhir-group-management/src/components/BaseComponents/BaseGroupsListView/index.tsx b/packages/fhir-group-management/src/components/BaseComponents/BaseGroupsListView/index.tsx index bfa8e2d60..897d88c2f 100644 --- a/packages/fhir-group-management/src/components/BaseComponents/BaseGroupsListView/index.tsx +++ b/packages/fhir-group-management/src/components/BaseComponents/BaseGroupsListView/index.tsx @@ -19,15 +19,23 @@ import { useTranslation } from '../../../mls'; import { TFunction } from '@opensrp/i18n'; import { RbacCheck } from '@opensrp/rbac'; -export type TableData = ReturnType & Record; +export type DefaultTableData = ReturnType & Record; -export type BaseListViewProps = Partial> & { +export type ExtendableTableData = Pick< + ReturnType, + 'id' | 'name' | 'active' | 'identifier' | 'lastUpdated' +>; + +export type BaseListViewProps = Partial< + Pick +> & { fhirBaseURL: string; getColumns: (t: TFunction) => Column[]; extraQueryFilters?: Record; createButtonLabel: string; createButtonUrl?: string; pageTitle: string; + generateTableData?: (groups: IGroup) => TableData; viewDetailsRender?: (fhirBaseURL: string, resourceId?: string) => ReactNode; }; @@ -37,7 +45,9 @@ export type BaseListViewProps = Partial { +export function BaseListView( + props: BaseListViewProps +) { const { fhirBaseURL, extraQueryFilters, @@ -46,6 +56,7 @@ export const BaseListView = (props: BaseListViewProps) => { createButtonUrl, keyValueMapperRenderProp, pageTitle, + generateTableData = parseGroup, viewDetailsRender, } = props; @@ -73,9 +84,9 @@ export const BaseListView = (props: BaseListViewProps) => { const tableData = (data?.records ?? []).map((org: IGroup, index: number) => { return { - ...parseGroup(org), + ...generateTableData(org), key: `${index}`, - }; + } as TableData; }); const columns = getColumns(t); @@ -118,4 +129,4 @@ export const BaseListView = (props: BaseListViewProps) => { ); -}; +} diff --git a/packages/fhir-group-management/src/components/CommodityAddEdit/Default/index.tsx b/packages/fhir-group-management/src/components/CommodityAddEdit/Default/index.tsx new file mode 100644 index 000000000..f22762e3b --- /dev/null +++ b/packages/fhir-group-management/src/components/CommodityAddEdit/Default/index.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { CommodityForm } from '../../ProductForm'; +import { useParams } from 'react-router'; +import { + accountabilityPeriod, + appropriateUsage, + availability, + condition, + groupResourceType, + isAttractiveItem, + LIST_COMMODITY_URL, + materialNumber, + productImage, +} from '../../../constants'; +import { Spin } from 'antd'; +import { PageHeader } from '@opensrp/react-utils'; +import { useQuery } from 'react-query'; +import { FHIRServiceClass, BrokenPage } from '@opensrp/react-utils'; +import { IGroup } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IGroup'; +import { + generateGroupPayload, + getGroupFormFields, + postPutGroup, + updateListReferencesFactory, + validationRulesFactory, +} from './utils'; +import { useTranslation } from '../../../mls'; + +export interface GroupAddEditProps { + fhirBaseURL: string; + listId: string; +} + +export interface RouteParams { + id?: string; +} + +export const CommodityAddEdit = (props: GroupAddEditProps) => { + const { fhirBaseURL: fhirBaseUrl, listId } = props; + + const { id: resourceId } = useParams(); + const { t } = useTranslation(); + + const groupQuery = useQuery( + [groupResourceType, resourceId], + async () => + new FHIRServiceClass(fhirBaseUrl, groupResourceType).read(resourceId as string), + { + enabled: !!resourceId, + } + ); + + if (!groupQuery.isIdle && groupQuery.isLoading) { + return ; + } + + if (groupQuery.error && !groupQuery.data) { + return ; + } + + const initialValues = getGroupFormFields(groupQuery.data); + + const pageTitle = groupQuery.data + ? t('Edit Commodity | {{name}}', { name: groupQuery.data.name ?? '' }) + : t('Create Commodity'); + + const postSuccess = updateListReferencesFactory(fhirBaseUrl, listId); + + return ( +
+ + {pageTitle} + + +
+
+
+ ); +}; diff --git a/packages/fhir-group-management/src/components/CommodityAddEdit/Default/tests/__snapshots__/index.test.tsx.snap b/packages/fhir-group-management/src/components/CommodityAddEdit/Default/tests/__snapshots__/index.test.tsx.snap new file mode 100644 index 000000000..735b6d9fa --- /dev/null +++ b/packages/fhir-group-management/src/components/CommodityAddEdit/Default/tests/__snapshots__/index.test.tsx.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly: active radio button 1`] = ` + +`; + +exports[`renders correctly: disabled radio button 1`] = ` + +`; diff --git a/packages/fhir-group-management/src/components/CommodityAddEdit/tests/fixtures.ts b/packages/fhir-group-management/src/components/CommodityAddEdit/Default/tests/fixtures.ts similarity index 75% rename from packages/fhir-group-management/src/components/CommodityAddEdit/tests/fixtures.ts rename to packages/fhir-group-management/src/components/CommodityAddEdit/Default/tests/fixtures.ts index d59e49150..d91b3b169 100644 --- a/packages/fhir-group-management/src/components/CommodityAddEdit/tests/fixtures.ts +++ b/packages/fhir-group-management/src/components/CommodityAddEdit/Default/tests/fixtures.ts @@ -116,6 +116,20 @@ export const newList = { entry: [], }; +export const editedList = { + resourceType: 'List', + id: 'list-resource-id', + identifier: [{ use: 'official', value: 'list-resource-id' }], + status: 'current', + mode: 'working', + title: 'Supply Chain commodities', + code: { + coding: [{ system: 'http://ona.io', code: 'supply-chain', display: 'Supply Chain Commodity' }], + text: 'Supply Chain Commodity', + }, + entry: [{ item: { reference: 'Group/123' } }], +}; + export const createdCommodity1 = { code: { coding: [{ system: 'http://snomed.info/sct', code: '386452003', display: 'Supply management' }], @@ -140,3 +154,29 @@ export const createdCommodity1 = { }, ], }; + +export const editedCommodity1 = { + resourceType: 'Group', + id: '567ec5f2-db90-4fac-b578-6e07df3f48de', + identifier: [{ value: '43245245336', use: 'official' }], + active: true, + type: 'device', + actual: false, + code: { + coding: [{ system: 'http://snomed.info/sct', code: '386452003', display: 'Supply management' }], + }, + name: 'Paracetamol 100mg TabletsDettol', + characteristic: [ + { + code: { + coding: [ + { system: 'http://snomed.info/sct', code: '767524001', display: 'Unit of measure' }, + ], + }, + valueCodeableConcept: { + coding: [{ system: 'http://snomed.info/sct', code: '767525000', display: 'Unit' }], + text: 'Bottles', + }, + }, + ], +}; diff --git a/packages/fhir-group-management/src/components/CommodityAddEdit/Default/tests/index.test.tsx b/packages/fhir-group-management/src/components/CommodityAddEdit/Default/tests/index.test.tsx new file mode 100644 index 000000000..578c7abd1 --- /dev/null +++ b/packages/fhir-group-management/src/components/CommodityAddEdit/Default/tests/index.test.tsx @@ -0,0 +1,522 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable @typescript-eslint/naming-convention */ +import React from 'react'; +import { Route, Router, Switch } from 'react-router'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { CommodityAddEdit } from '..'; +import { Provider } from 'react-redux'; +import { store } from '@opensrp/store'; +import nock from 'nock'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { waitForElementToBeRemoved } from '@testing-library/dom'; +import { createMemoryHistory } from 'history'; +import { authenticateUser } from '@onaio/session-reducer'; +import { commodity1, createdCommodity, editedCommodity1, editedList, newList } from './fixtures'; +import { groupResourceType, listResourceType, unitOfMeasure } from '../../../../constants'; +import userEvent from '@testing-library/user-event'; +import * as notifications from '@opensrp/notifications'; +import flushPromises from 'flush-promises'; + +jest.mock('@opensrp/notifications', () => ({ + __esModule: true, + ...Object.assign({}, jest.requireActual('@opensrp/notifications')), +})); + +jest.mock('fhirclient', () => { + return jest.requireActual('fhirclient/lib/entry/browser'); +}); + +const mockv4 = '9b782015-8392-4847-b48c-50c11638656b'; +jest.mock('uuid', () => { + const actual = jest.requireActual('uuid'); + return { + ...actual, + v4: () => mockv4, + }; +}); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + cacheTime: 0, + }, + }, +}); + +const listResId = 'list-resource-id'; +const props = { + fhirBaseURL: 'http://test.server.org', + listId: listResId, +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const AppWrapper = (props: any) => { + return ( + + + + + + + + + + + + + ); +}; + +afterEach(() => { + cleanup(); + nock.cleanAll(); + jest.resetAllMocks(); +}); + +beforeAll(() => { + nock.disableNetConnect(); + store.dispatch( + authenticateUser( + true, + { + email: 'bob@example.com', + name: 'Bobbie', + username: 'RobertBaratheon', + }, + { api_token: 'hunter2', oAuth2Data: { access_token: 'sometoken', state: 'abcde' } } + ) + ); +}); + +afterAll(() => { + nock.enableNetConnect(); +}); + +it('renders correctly', async () => { + const history = createMemoryHistory(); + history.push('/add'); + + render( + + + + ); + + await waitFor(() => { + screen.getByText('Create Commodity'); + // id, + screen.getByLabelText('Commodity Id'); + // identifier + screen.getByLabelText('Identifier'); + // name + screen.getByPlaceholderText('Name'); + // active + // active + screen.getByText('Select Commodity status'); + const activeRadioBtn = document.querySelector('#active input[value="true"]'); + const disabledRadioBtn = document.querySelector('#active input[value="false"]'); + expect(activeRadioBtn).toMatchSnapshot('active radio button'); + expect(disabledRadioBtn).toMatchSnapshot('disabled radio button'); + // type + screen.getByLabelText('Select Commodity Type'); + // unit of measure + screen.getByLabelText('Select the unit of measure'); + // submit btn + screen.getByRole('button', { + name: /Save/i, + }); + // cancel btn. + screen.getByRole('button', { + name: /Cancel/i, + }); + }); +}); + +it('form validation works', async () => { + const history = createMemoryHistory(); + history.push('/add'); + + render( + + + + ); + + await waitFor(() => { + screen.getByText('Create Commodity'); + }); + + const submitBtn = screen.getByRole('button', { + name: /Save/i, + }); + + fireEvent.click(submitBtn); + + await waitFor(() => { + const atLeastOneError = document.querySelector('.ant-form-item-explain-error'); + expect(atLeastOneError).toBeInTheDocument(); + }); + + const errorNodes = [...document.querySelectorAll('.ant-form-item-explain-error')]; + const errorMsgs = errorNodes.map((node) => node.textContent); + + expect(errorMsgs).toEqual(['Required', "'type' is required", "'unitOfMeasure' is required"]); +}); + +it('submits new group', async () => { + const history = createMemoryHistory(); + history.push(`/add`); + + const successNoticeMock = jest + .spyOn(notifications, 'sendSuccessNotification') + .mockImplementation(() => undefined); + + const errorNoticeMock = jest + .spyOn(notifications, 'sendErrorNotification') + .mockImplementation(() => undefined); + + nock(props.fhirBaseURL) + .get(`/${listResourceType}/${props.listId}`) + .reply(200, newList) + .put(`/${listResourceType}/${props.listId}`, editedList) + .reply(201, {}) + .persist(); + + nock(props.fhirBaseURL) + .put(`/${groupResourceType}/9b782015-8392-4847-b48c-50c11638656b`, createdCommodity) + .reply(200, { ...createdCommodity, id: '123' }) + .persist(); + + render( + + + + ); + + await waitFor(() => { + screen.getByText('Create Commodity'); + }); + + const nameField = screen.getByPlaceholderText('Name'); + userEvent.type(nameField, 'Dettol'); + + // simulate value selection for type + const groupTypeSelectConfig = { + selectId: 'type', + searchOptionText: 'dev', + fullOptionText: 'Device', + beforeFilterOptions: ['Medication', 'Device', 'Substance'], + afterFilterOptions: ['Device'], + }; + fillSearchableSelect(groupTypeSelectConfig); + // unit of measure + // simulate value selection for type + const unitMeasureSelectConfig = { + selectId: unitOfMeasure, + searchOptionText: 'bot', + fullOptionText: 'Bottles', + beforeFilterOptions: [ + 'Pieces', + 'Tablets', + 'Ampoules', + 'Strips', + 'Cycles', + 'Bottles', + 'Test kits', + 'Sachets', + 'Straps', + ], + afterFilterOptions: ['Bottles'], + }; + fillSearchableSelect(unitMeasureSelectConfig); + + fireEvent.click(screen.getByRole('button', { name: /Save/i })); + + await waitFor(() => { + expect(errorNoticeMock).not.toHaveBeenCalled(); + expect(successNoticeMock.mock.calls).toEqual([['Commodity updated successfully']]); + }); + + expect(nock.isDone()).toBeTruthy(); +}); + +it('edits resource', async () => { + const history = createMemoryHistory(); + history.push(`/add/${commodity1.id}`); + + const errorNoticeMock = jest + .spyOn(notifications, 'sendErrorNotification') + .mockImplementation(() => undefined); + + nock(props.fhirBaseURL).get(`/${groupResourceType}/${commodity1.id}`).reply(200, commodity1); + + nock(props.fhirBaseURL) + .put(`/${groupResourceType}/${commodity1.id}`, editedCommodity1) + .replyWithError('Failed to update Commodity') + .persist(); + + render( + + + + ); + + await waitFor(() => { + screen.getByText('Edit Commodity | Paracetamol 100mg Tablets'); + }); + + const nameField = screen.getByPlaceholderText('Name'); + userEvent.type(nameField, 'Dettol'); + + // simulate value selection for type + const groupTypeSelectConfig = { + selectId: 'type', + searchOptionText: 'dev', + fullOptionText: 'Device', + beforeFilterOptions: ['Medication', 'Device', 'Substance'], + afterFilterOptions: ['Device'], + }; + fillSearchableSelect(groupTypeSelectConfig); + // unit of measure + // simulate value selection for type + const unitMeasureSelectConfig = { + selectId: unitOfMeasure, + searchOptionText: 'bot', + fullOptionText: 'Bottles', + beforeFilterOptions: [ + 'Pieces', + 'Tablets', + 'Ampoules', + 'Strips', + 'Cycles', + 'Bottles', + 'Test kits', + 'Sachets', + 'Straps', + ], + afterFilterOptions: ['Bottles'], + }; + fillSearchableSelect(unitMeasureSelectConfig); + + userEvent.click(screen.getByRole('button', { name: /Save/i })); + + await waitFor(() => { + expect(errorNoticeMock.mock.calls).toEqual([ + [ + `request to http://test.server.org/Group/${commodity1.id} failed, reason: Failed to update Commodity`, + ], + ]); + }); + + expect(nock.isDone()).toBeTruthy(); +}); + +test('cancel handler is called on cancel', async () => { + const history = createMemoryHistory(); + history.push(`/add`); + + render( + + + + ); + + // test view is loaded. + screen.getByText('Create Commodity'); + const cancelBtn = screen.getByRole('button', { name: /Cancel/ }); + + fireEvent.click(cancelBtn); + expect(history.location.pathname).toEqual('/commodity/list'); +}); + +test('data loading problem', async () => { + const history = createMemoryHistory(); + history.push(`/add/${commodity1.id}`); + + nock(props.fhirBaseURL) + .get(`/${groupResourceType}/${commodity1.id}`) + .replyWithError('something aweful happened'); + + render( + + + + ); + + await waitForElementToBeRemoved(document.querySelector('.ant-spin')); + + // errors out + expect(screen.getByText(/something aweful happened/)).toBeInTheDocument(); +}); + +test('#1116 adds new resources to list', async () => { + const history = createMemoryHistory(); + history.push('/add'); + + nock(props.fhirBaseURL) + .put(`/${groupResourceType}/${mockv4}`, createdCommodity) + .reply(200, { ...createdCommodity, id: '123' }) + .persist(); + + nock(props.fhirBaseURL).get(`/${listResourceType}/${listResId}`).reply(200, newList).persist(); + + render( + + + + ); + + const successNoticeMock = jest + .spyOn(notifications, 'sendSuccessNotification') + .mockImplementation(() => undefined); + + // simulate name change + const nameInput = document.querySelector('input#name')!; + userEvent.type(nameInput, 'Dettol'); + + // simulate value selection for type + const typeInput = document.querySelector('input#type')!; + userEvent.click(typeInput); + const deviceTitle = document.querySelector('[title="Device"]')!; + fireEvent.click(deviceTitle); + + // simulate unit measure value + const unitOfMeasureInput = document.querySelector('input#unitOfMeasure')!; + userEvent.click(unitOfMeasureInput); + const bottlesOption = document.querySelector('[title="Bottles"]')!; + fireEvent.click(bottlesOption); + + const submitButton = document.querySelector('#submit-button')!; + userEvent.click(submitButton); + + await waitFor(() => { + expect(successNoticeMock.mock.calls).toEqual([['Commodity updated successfully']]); + }); + expect(nock.isDone()).toBeTruthy(); +}); + +test('#1116 adding new group but list does not exist', async () => { + const history = createMemoryHistory(); + history.push('/add'); + + nock(props.fhirBaseURL) + .put(`/${groupResourceType}/${mockv4}`, createdCommodity) + .reply(200, { ...createdCommodity }) + .persist(); + + nock(props.fhirBaseURL) + .get(`/${listResourceType}/${listResId}`) + .reply(404, { + resourceType: 'OperationOutcome', + text: { + status: 'generated', + div: '

Operation Outcome

\n\t\t\t\n\t\t
ERROR[]
HAPI-2001: Resource List/ea15c35a-8e8c-47ce-8122-c347cefa1b4d is not known
\n\t
', + }, + issue: [ + { + severity: 'error', + code: 'processing', + diagnostics: `HAPI-2001: Resource List/${listResId} is not known`, + }, + ], + }); + + const updatedList = { + ...newList, + entry: [{ item: { reference: `${groupResourceType}/${mockv4}` } }], + }; + + nock(props.fhirBaseURL) + .put(`/${listResourceType}/${listResId}`, newList) + .reply(200, newList) + .persist(); + + nock(props.fhirBaseURL) + .put(`/${listResourceType}/${listResId}`, updatedList) + .reply(200, updatedList) + .persist(); + + render( + + + + ); + + const successNoticeMock = jest + .spyOn(notifications, 'sendSuccessNotification') + .mockImplementation(() => undefined); + + // simulate name change + const nameInput = document.querySelector('input#name')!; + userEvent.type(nameInput, 'Dettol'); + + // simulate value selection for type + const typeInput = document.querySelector('input#type')!; + userEvent.click(typeInput); + const deviceTitle = document.querySelector('[title="Device"]')!; + fireEvent.click(deviceTitle); + + // simulate unit measure value + const unitOfMeasureInput = document.querySelector('input#unitOfMeasure')!; + userEvent.click(unitOfMeasureInput); + const bottlesOption = document.querySelector('[title="Bottles"]')!; + fireEvent.click(bottlesOption); + + const submitButton = document.querySelector('#submit-button')!; + userEvent.click(submitButton); + + await waitFor(() => { + expect(successNoticeMock.mock.calls).toEqual([['Commodity updated successfully']]); + expect(nock.isDone()).toBeTruthy(); + }); + + await flushPromises(); +}); + +export interface SearchableSelectValues { + selectId: string; + searchOptionText: string; + fullOptionText: string; + beforeFilterOptions: string[]; + afterFilterOptions: string[]; +} + +/** + * @param searchableSelectOptions options + */ +export function fillSearchableSelect(searchableSelectOptions: SearchableSelectValues) { + const { selectId, fullOptionText, searchOptionText, beforeFilterOptions, afterFilterOptions } = + searchableSelectOptions; + + // simulate value selection for type + const selectComponent = document.querySelector(`input#${selectId}`)!; + fireEvent.mouseDown(selectComponent); + + const optionTexts = [ + ...document.querySelectorAll( + `#${selectId}_list+div.rc-virtual-list .ant-select-item-option-content` + ), + ].map((option) => { + return option.textContent; + }); + + expect(optionTexts).toHaveLength(beforeFilterOptions.length); + expect(optionTexts).toEqual(beforeFilterOptions); + + // filter searching through members works + userEvent.type(selectComponent, searchOptionText); + + // options after search + const afterFilterOptionTexts = [ + ...document.querySelectorAll( + `#${selectId}_list+div.rc-virtual-list .ant-select-item-option-content` + ), + ].map((option) => { + return option.textContent; + }); + + expect(afterFilterOptionTexts).toEqual(afterFilterOptions); + + fireEvent.click(document.querySelector(`[title="${fullOptionText}"]`)!); +} diff --git a/packages/fhir-group-management/src/components/CommodityAddEdit/utils.ts b/packages/fhir-group-management/src/components/CommodityAddEdit/Default/utils.ts similarity index 75% rename from packages/fhir-group-management/src/components/CommodityAddEdit/utils.ts rename to packages/fhir-group-management/src/components/CommodityAddEdit/Default/utils.ts index 3679fa82c..2ade8eb21 100644 --- a/packages/fhir-group-management/src/components/CommodityAddEdit/utils.ts +++ b/packages/fhir-group-management/src/components/CommodityAddEdit/Default/utils.ts @@ -4,7 +4,7 @@ import { Rule } from 'rc-field-form/lib/interface'; import { v4 } from 'uuid'; import { getObjLike, IdentifierUseCodes, FHIRServiceClass } from '@opensrp/react-utils'; import { Identifier } from '@smile-cdr/fhirts/dist/FHIR-R4/classes/identifier'; -import { capitalize, cloneDeep, get, set, values } from 'lodash'; +import { cloneDeep, get, set } from 'lodash'; import { active, groupResourceType, @@ -14,41 +14,16 @@ import { unitOfMeasure, name, listResourceType, -} from '../../constants'; +} from '../../../constants'; import type { TFunction } from '@opensrp/i18n'; import { characteristicUnitMeasureCode, getUnitMeasureCharacteristic, snomedCodeSystem, supplyMgSnomedCode, -} from '../../helpers/utils'; - -export enum UnitOfMeasure { - Pieces = 'Pieces', - Tablets = 'Tablets', - Ampoules = 'Ampoules', - Strips = 'Strips', - Cycles = 'Cycles', - Bottles = 'Bottles', - TestKits = 'Test kits', - Sachets = 'Sachets', - Straps = 'Straps', -} - -export enum TypeOfGroup { - Medication = 'medication', - Decive = 'device', -} - -export interface GroupFormFields { - [id]?: string; - [identifier]?: string; - [active]?: boolean; - [name]?: string; - [type]?: string; - [unitOfMeasure]?: IGroup['type']; - initialObject?: IGroup; -} +} from '../../../helpers/utils'; +import { TypeOfGroup, UnitOfMeasure } from '../../ProductForm/utils'; +import { GroupFormFields } from '../../ProductForm/types'; export const defaultCharacteristic = { code: { @@ -71,17 +46,21 @@ export const defaultCode = { * * @param t - the translator function */ -export const validationRulesFactory = (t: TFunction) => ({ - [id]: [{ type: 'string' }] as Rule[], - [identifier]: [{ type: 'string' }] as Rule[], - [name]: [ - { type: 'string', message: t('Must be a valid string') }, - { required: true, message: t('Required') }, - ] as Rule[], - [active]: [{ type: 'boolean' }, { required: true, message: t('Required') }] as Rule[], - [type]: [{ type: 'enum', enum: Object.values(TypeOfGroup), required: true }] as Rule[], - [unitOfMeasure]: [{ type: 'enum', enum: Object.values(UnitOfMeasure), required: true }] as Rule[], -}); +export const validationRulesFactory = (t: TFunction) => { + return { + [id]: [{ type: 'string' }] as Rule[], + [identifier]: [{ type: 'string' }] as Rule[], + [name]: [ + { type: 'string', message: t('Must be a valid string') }, + { required: true, message: t('Required') }, + ] as Rule[], + [active]: [{ type: 'boolean' }, { required: true, message: t('Required') }] as Rule[], + [type]: [{ type: 'enum', enum: Object.values(TypeOfGroup), required: true }] as Rule[], + [unitOfMeasure]: [ + { type: 'enum', enum: Object.values(UnitOfMeasure), required: true }, + ] as Rule[], + }; +}; /** * Converts group resource to initial values @@ -176,47 +155,6 @@ export const generateGroupPayload = ( return payload; }; -export interface SelectOption { - value: string; - label: string; -} - -/** - * get select options for group types - * - */ -export const getGroupTypeOptions = () => { - return values(TypeOfGroup).map((group) => { - return { - value: group, - label: capitalize(group), - }; - }); -}; - -/** - * get select options for group units of measure - * - */ -export const getUnitOfMeasureOptions = () => { - return values(UnitOfMeasure).map((measure) => { - return { - value: measure, - label: capitalize(measure), - }; - }); -}; - -/** - * filter select options - * - * @param inputValue search term - * @param option select option to filter against - */ -export const groupSelectfilterFunction = (inputValue: string, option?: SelectOption) => { - return !!option?.label.toLowerCase().includes(inputValue.toLowerCase()); -}; - /** * either posts or puts a group resource payload to fhir server * diff --git a/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/index.tsx b/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/index.tsx new file mode 100644 index 000000000..4b7391d49 --- /dev/null +++ b/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/index.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { CommodityForm } from '../../ProductForm'; +import { useParams } from 'react-router'; +import { LIST_COMMODITY_URL, unitOfMeasure } from '../../../constants'; +import { Spin } from 'antd'; +import { PageHeader } from '@opensrp/react-utils'; +import { BrokenPage } from '@opensrp/react-utils'; +import { IGroup } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IGroup'; +import { + EusmGroupFormFields, + generateGroupPayload, + getGroupFormFields, + postPutBinary, + postPutGroup, + updateListReferencesFactory, + validationRulesFactory, +} from './utils'; +import { useTranslation } from '../../../mls'; +import { useGetGroupAndBinary } from '../../../helpers/utils'; +import { IBinary } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IBinary'; + +export interface GroupAddEditProps { + fhirBaseURL: string; + listId: string; +} + +export interface RouteParams { + id?: string; +} + +export const CommodityAddEdit = (props: GroupAddEditProps) => { + const { fhirBaseURL: fhirBaseUrl, listId } = props; + + const { id: resourceId } = useParams(); + const { t } = useTranslation(); + + const { groupQuery, binaryQuery } = useGetGroupAndBinary(fhirBaseUrl, resourceId); + + // TODO - Had to include binaryQuery loading status since the antd form upload widget + // does not update when the component updates initial values. + if ( + (!groupQuery.isIdle && groupQuery.isLoading) || + (!binaryQuery.isIdle && binaryQuery.isLoading) + ) { + return ; + } + + if (groupQuery.error && !groupQuery.data) { + return ; + } + const initialValues = getGroupFormFields(groupQuery.data, binaryQuery.data); + + const pageTitle = groupQuery.data + ? t('Edit Commodity | {{name}}', { name: groupQuery.data.name ?? '' }) + : t('Create Commodity'); + + const postSuccess = updateListReferencesFactory(fhirBaseUrl, listId, binaryQuery.data); + + return ( +
+ + {pageTitle} + + +
+ + hidden={[unitOfMeasure]} + fhirBaseUrl={fhirBaseUrl} + initialValues={initialValues} + cancelUrl={LIST_COMMODITY_URL} + successUrl={LIST_COMMODITY_URL} + postSuccess={postSuccess} + validationRulesFactory={validationRulesFactory} + mutationEffect={async (initialValues, values) => { + const { group, binary, binaryChanged } = await generateGroupPayload( + values, + initialValues + ); + + let binaryResponse; + if (binary) { + binaryResponse = await postPutBinary(fhirBaseUrl, binary); + } + const groupResponse = await postPutGroup(fhirBaseUrl, group); + return { group: groupResponse, binary: binaryResponse, binaryChanged }; + }} + /> +
+
+ ); +}; diff --git a/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/tests/__snapshots__/index.test.tsx.snap b/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/tests/__snapshots__/index.test.tsx.snap new file mode 100644 index 000000000..1e452ca27 --- /dev/null +++ b/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/tests/__snapshots__/index.test.tsx.snap @@ -0,0 +1,53 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly: A catch all 1`] = `"Create CommodityCommodity IdIdentifierEnter Commodity nameMaterial numberSelect Commodity statusActiveDisabledSelect Commodity TypeSelect Commodity typeSelect the unit of measureSelect the unit of measureAttractive item?yesnoIs it there?Is it in good condition?Is it being used appropriately?Accountability period (in months)Photo of the productUploadsaveCancel"`; + +exports[`renders correctly: active radio button 1`] = ` + +`; + +exports[`renders correctly: active radio button 2`] = ` + +`; + +exports[`renders correctly: attractive item no radio button 1`] = ` + +`; + +exports[`renders correctly: attractive item yes radio button 1`] = ` + +`; + +exports[`renders correctly: disabled radio button 1`] = ` + +`; + +exports[`renders correctly: disabled radio button 2`] = ` + +`; 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 new file mode 100644 index 000000000..24a8c475f --- /dev/null +++ b/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/tests/fixtures.ts @@ -0,0 +1,436 @@ +import { IGroup } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IGroup'; + +export const commodity1 = { + 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://snomed.info/sct', + code: '23435363', + display: 'Attractive Item code', + }, + ], + }, + valueBoolean: false, + }, + { + code: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '34536373', + display: 'Is it there code', + }, + ], + }, + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '34536373-1', + display: 'Value entered on the It is there code', + }, + ], + text: 'yes', + }, + }, + { + code: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '45647484', + display: 'Is it in good condition? (optional)', + }, + ], + }, + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '45647484-1', + display: 'Value entered on the Is it in good condition? (optional)', + }, + ], + text: 'Yes, no tears, and inocuated', + }, + }, + { + code: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '56758595', + display: 'Is it being used appropriately? (optional)', + }, + ], + }, + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + 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://snomed.info/sct', + code: '67869606', + display: 'Accountability period (in months)', + }, + ], + }, + valueQuantity: { + value: 12, + }, + }, + { + code: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '1231415', + display: 'Product Image code', + }, + ], + }, + valueReference: { + reference: 'Binary/24d55827-fbd8-4b86-a47a-2f5b4598c515', + }, + }, + ], +} as IGroup; + +export const editedCommodity1 = { + resourceType: 'Group', + id: '52cffa51-fa81-49aa-9944-5b45d9e4c117', + identifier: [ + { use: 'secondary', value: '606109db-5632-48c5-8710-b726e1b3addf' }, + { use: 'official', value: 'Bed nets' }, + ], + 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://snomed.info/sct', code: '23435363', display: 'Attractive Item code' }, + ], + }, + valueBoolean: true, + }, + { + code: { + coding: [ + { system: 'http://snomed.info/sct', code: '34536373', display: 'Is it there code' }, + ], + }, + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '34536373-1', + display: 'Value entered on the It is there code', + }, + ], + text: 'could be better', + }, + }, + { + code: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '45647484', + display: 'Is it in good condition? (optional)', + }, + ], + }, + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '45647484-1', + display: 'Value entered on the Is it in good condition? (optional)', + }, + ], + text: 'as good as it can be', + }, + }, + { + code: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '56758595', + display: 'Is it being used appropriately? (optional)', + }, + ], + }, + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '56758595-1', + display: 'Value entered on the Is it being used appropriately? (optional)', + }, + ], + text: 'Define appropriately used.', + }, + }, + { + code: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '67869606', + display: 'Accountability period (in months)', + }, + ], + }, + valueQuantity: { value: 12 }, + }, + { + code: { + coding: [ + { system: 'http://snomed.info/sct', code: '1231415', display: 'Product Image code' }, + ], + }, + valueReference: { reference: 'Binary/9b782015-8392-4847-b48c-50c11638656b' }, + }, + ], +}; + +export const binary1 = { + resourceType: 'Binary', + id: '24d55827-fbd8-4b86-a47a-2f5b4598c515', + contentType: 'image/jpg', + data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', +}; + +export const editedBinary1 = { + id: '9b782015-8392-4847-b48c-50c11638656b', + resourceType: 'Binary', + contentType: 'image/png', + data: 'aGVsbG8=', +}; + +export const editedCommodity = { + resourceType: 'Group', + id: '567ec5f2-db90-4fac-b578-6e07df3f48de', + identifier: [{ value: '43245245336', use: 'official' }], + active: false, + type: 'medication', + actual: false, + code: { + coding: [{ system: 'http://snomed.info/sct', code: '386452003', display: 'Supply management' }], + }, + name: 'Dettol Strips', + characteristic: [ + { + code: { + coding: [ + { system: 'http://snomed.info/sct', code: '767524001', display: 'Unit of measure' }, + ], + }, + valueCodeableConcept: { + coding: [{ system: 'http://snomed.info/sct', code: '767525000', display: 'Unit' }], + text: 'Strips', + }, + }, + ], +}; + +export const newList = { + resourceType: 'List', + id: 'list-resource-id', + identifier: [{ use: 'official', value: 'list-resource-id' }], + status: 'current', + mode: 'working', + title: 'Supply Chain commodities', + code: { + coding: [{ system: 'http://ona.io', code: 'supply-chain', display: 'Supply Chain Commodity' }], + text: 'Supply Chain Commodity', + }, + entry: [], +}; + +export const listEdited1 = { + resourceType: 'List', + id: 'list-resource-id', + identifier: [{ use: 'official', value: 'list-resource-id' }], + status: 'current', + mode: 'working', + title: 'Supply Chain commodities', + code: { + coding: [{ system: 'http://ona.io', code: 'supply-chain', display: 'Supply Chain Commodity' }], + text: 'Supply Chain Commodity', + }, + entry: [ + { item: { reference: 'Binary/9b782015-8392-4847-b48c-50c11638656b' } }, + { item: { reference: 'Group/9b782015-8392-4847-b48c-50c11638656b' } }, + ], +}; + +export const editResourceList = { + resourceType: 'List', + id: 'list-resource-id', + identifier: [{ use: 'official', value: 'list-resource-id' }], + status: 'current', + mode: 'working', + title: 'Supply Chain commodities', + code: { + coding: [{ system: 'http://ona.io', code: 'supply-chain', display: 'Supply Chain Commodity' }], + text: 'Supply Chain Commodity', + }, + entry: [ + { item: { reference: 'Binary/9b782015-8392-4847-b48c-50c11638656b' } }, + { item: { reference: 'Group/9b782015-8392-4847-b48c-50c11638656b' } }, + { item: { reference: 'Binary/9b782015-8392-4847-b48c-50c11638656b' } }, + ], +}; + +export const createdCommodity = { + code: { + coding: [{ system: 'http://snomed.info/sct', code: '386452003', display: 'Supply management' }], + }, + resourceType: 'Group', + active: true, + name: 'Dettol', + id: '9b782015-8392-4847-b48c-50c11638656b', + identifier: [{ value: 'SKU001', use: 'official' }], + type: 'substance', + characteristic: [ + { + code: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '67869606', + display: 'Accountability period (in months)', + }, + ], + }, + valueQuantity: { value: 12 }, + }, + { + code: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '56758595', + display: 'Is it being used appropriately? (optional)', + }, + ], + }, + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '56758595-1', + display: 'Value entered on the Is it being used appropriately? (optional)', + }, + ], + text: 'Define appropriately used.', + }, + }, + { + code: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '45647484', + display: 'Is it in good condition? (optional)', + }, + ], + }, + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '45647484-1', + display: 'Value entered on the Is it in good condition? (optional)', + }, + ], + text: 'as good as it can be', + }, + }, + { + code: { + coding: [ + { system: 'http://snomed.info/sct', code: '34536373', display: 'Is it there code' }, + ], + }, + valueCodeableConcept: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '34536373-1', + display: 'Value entered on the It is there code', + }, + ], + text: 'adimika', + }, + }, + { + code: { + coding: [ + { system: 'http://snomed.info/sct', code: '23435363', display: 'Attractive Item code' }, + ], + }, + valueBoolean: true, + }, + { + code: { + coding: [ + { system: 'http://snomed.info/sct', code: '1231415', display: 'Product Image code' }, + ], + }, + valueReference: { reference: 'Binary/9b782015-8392-4847-b48c-50c11638656b' }, + }, + ], +}; + +export const createdBinary = { + id: '9b782015-8392-4847-b48c-50c11638656b', + resourceType: 'Binary', + contentType: 'image/png', + data: 'aGVsbG8=', +}; 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 new file mode 100644 index 000000000..8477fc966 --- /dev/null +++ b/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/tests/index.test.tsx @@ -0,0 +1,493 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable @typescript-eslint/naming-convention */ +import React from 'react'; +import { Route, Router, Switch } from 'react-router'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { CommodityAddEdit } from '..'; +import { Provider } from 'react-redux'; +import { store } from '@opensrp/store'; +import nock from 'nock'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { waitForElementToBeRemoved } from '@testing-library/dom'; +import { createMemoryHistory } from 'history'; +import { authenticateUser } from '@onaio/session-reducer'; +import { + binary1, + commodity1, + createdBinary, + createdCommodity, + editedBinary1, + editedCommodity1, + listEdited1, + newList, +} from './fixtures'; +import { binaryResourceType, groupResourceType, listResourceType } from '../../../../constants'; +import userEvent from '@testing-library/user-event'; +import * as notifications from '@opensrp/notifications'; +import { fillSearchableSelect } from '../../Default/tests/index.test'; +import { photoUploadCharacteristicCode } from '../../../../helpers/utils'; +import { cloneDeep } from 'lodash'; + +jest.mock('@opensrp/notifications', () => ({ + __esModule: true, + ...Object.assign({}, jest.requireActual('@opensrp/notifications')), +})); + +jest.mock('fhirclient', () => { + return jest.requireActual('fhirclient/lib/entry/browser'); +}); + +const mockv4 = '9b782015-8392-4847-b48c-50c11638656b'; +jest.mock('uuid', () => { + const actual = jest.requireActual('uuid'); + return { + ...actual, + v4: () => mockv4, + }; +}); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + cacheTime: 0, + }, + }, +}); + +const listResId = 'list-resource-id'; +const productImage = new File(['hello'], 'product.png', { type: 'image/png' }); +const props = { + fhirBaseURL: 'http://test.server.org', + listId: listResId, +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const AppWrapper = (props: any) => { + return ( + + + + + + + + + + + + + ); +}; + +afterEach(() => { + cleanup(); + nock.cleanAll(); + jest.resetAllMocks(); +}); + +beforeAll(() => { + nock.disableNetConnect(); + store.dispatch( + authenticateUser( + true, + { + email: 'bob@example.com', + name: 'Bobbie', + username: 'RobertBaratheon', + }, + { api_token: 'hunter2', oAuth2Data: { access_token: 'sometoken', state: 'abcde' } } + ) + ); +}); + +afterAll(() => { + nock.enableNetConnect(); +}); + +test('renders correctly', async () => { + const history = createMemoryHistory(); + history.push('/add'); + + render( + + + + ); + + await waitFor(() => { + screen.getByText('Create Commodity'); + // id, + screen.getByLabelText('Commodity Id'); + // identifier + screen.getByLabelText('Identifier'); + // name + screen.getByLabelText('Enter Commodity name'); + // active + screen.getByText('Select Commodity status'); + const activeRadioBtn = document.querySelector('#active input[value="true"]'); + const disabledRadioBtn = document.querySelector('#active input[value="false"]'); + expect(activeRadioBtn).toMatchSnapshot('active radio button'); + expect(disabledRadioBtn).toMatchSnapshot('disabled radio button'); + + // type + screen.getByLabelText('Select Commodity Type'); + // material number + screen.getByLabelText('Material number'); + // attractive item + screen.getByText('Attractive item?'); + const yesRadioBtn = document.querySelector('#isAttractiveItem input[value="true"]'); + const noRadioBtn = document.querySelector('#isAttractiveItem input[value="false"]'); + expect(yesRadioBtn).toMatchSnapshot('attractive item yes radio button'); + expect(noRadioBtn).toMatchSnapshot('attractive item no radio button'); + + // availability + screen.getByLabelText('Is it there?'); + // condition + screen.getByLabelText('Is it in good condition?'); + // appropriate usage + screen.getByLabelText('Is it being used appropriately?'); + // accountability + screen.getByLabelText('Accountability period (in months)'); + screen.getByLabelText('Photo of the product'); + // productImage + // submit btn + screen.getByRole('button', { + name: /Save/i, + }); + // cancel btn. + screen.getByRole('button', { + name: /Cancel/i, + }); + }); + + expect(document.querySelector('body')?.textContent).toMatchSnapshot('A catch all'); +}); + +// TODO - do form validation +test('form validation works', async () => { + const history = createMemoryHistory(); + history.push('/add'); + + render( + + + + ); + + await waitFor(() => { + screen.getByText('Create Commodity'); + }); + + const submitBtn = screen.getByRole('button', { + name: /Save/i, + }); + + fireEvent.click(submitBtn); + + await waitFor(() => { + const atLeastOneError = document.querySelector('.ant-form-item-explain-error'); + expect(atLeastOneError).toBeInTheDocument(); + }); + + const errorNodes = [...document.querySelectorAll('.ant-form-item-explain-error')]; + const errorMsgs = errorNodes.map((node) => node.textContent); + + expect(errorMsgs).toEqual(['Required', 'Required', "'type' is required", 'Required']); +}); + +it('can create new commodity', async () => { + const history = createMemoryHistory(); + history.push(`/add`); + + const successNoticeMock = jest + .spyOn(notifications, 'sendSuccessNotification') + .mockImplementation(() => undefined); + + const errorNoticeMock = jest + .spyOn(notifications, 'sendErrorNotification') + .mockImplementation(() => undefined); + + nock(props.fhirBaseURL) + .put(`/${binaryResourceType}/9b782015-8392-4847-b48c-50c11638656b`, createdBinary) + .reply(200, createdBinary) + .persist(); + + nock(props.fhirBaseURL) + .get(`/${listResourceType}/${props.listId}`) + .reply(200, newList) + .put(`/${listResourceType}/${props.listId}`, listEdited1) + .reply(201, {}) + .persist(); + + nock(props.fhirBaseURL) + .put(`/${groupResourceType}/9b782015-8392-4847-b48c-50c11638656b`, createdCommodity) + .reply(200, createdCommodity) + .persist(); + + render( + + + + ); + + await waitFor(() => { + screen.getByText('Create Commodity'); + }); + + const nameField = screen.getByPlaceholderText('Name'); + userEvent.type(nameField, 'Dettol'); + + const materialNumber = screen.getByLabelText('Material number'); + userEvent.type(materialNumber, 'SKU001'); + + // set attractive item + const yesRadioBtn = document.querySelector('#isAttractiveItem input[value="true"]')!; + fireEvent.click(yesRadioBtn); + + const availabilityField = screen.getByLabelText('Is it there?'); + userEvent.type(availabilityField, 'adimika'); + + const conditionField = screen.getByLabelText('Is it in good condition?'); + userEvent.type(conditionField, 'as good as it can be'); + + const appropriateUsageField = screen.getByLabelText('Is it being used appropriately?'); + userEvent.type(appropriateUsageField, 'Define appropriately used.'); + + const accountabilityField = screen.getByLabelText('Accountability period (in months)'); + userEvent.type(accountabilityField, '12'); + + const productUploadField = screen.getByLabelText('Photo of the product'); + userEvent.upload(productUploadField, productImage); + + // simulate value selection for type + const groupTypeSelectConfig = { + selectId: 'type', + searchOptionText: 'sub', + fullOptionText: 'Substance', + beforeFilterOptions: ['Medication', 'Device', 'Substance'], + afterFilterOptions: ['Substance'], + }; + fillSearchableSelect(groupTypeSelectConfig); + + fireEvent.click(screen.getByRole('button', { name: /Save/i })); + + await waitFor(() => { + expect(errorNoticeMock).not.toHaveBeenCalled(); + expect(successNoticeMock.mock.calls).toEqual([['Commodity updated successfully']]); + }); + + expect(nock.isDone()).toBeTruthy(); +}); + +it('edits resource', async () => { + const history = createMemoryHistory(); + history.push(`/add/${commodity1.id}`); + + const successNoticeMock = jest + .spyOn(notifications, 'sendSuccessNotification') + .mockImplementation(() => undefined); + + const errorNoticeMock = jest + .spyOn(notifications, 'sendErrorNotification') + .mockImplementation(() => undefined); + + nock(props.fhirBaseURL) + .get(`/${groupResourceType}/${commodity1.id}`) + .reply(200, commodity1) + .persist(); + + nock(props.fhirBaseURL).get(`/${binaryResourceType}/${binary1.id}`).reply(200, binary1).persist(); + + nock(props.fhirBaseURL) + .put(`/${binaryResourceType}/${mockv4}`, editedBinary1) + .reply(200, editedBinary1) + .persist(); + + nock(props.fhirBaseURL) + .put(`/${groupResourceType}/${editedCommodity1.id}`, editedCommodity1) + .reply(201, editedCommodity1) + .persist(); + + const withEditEntriesList = { + ...newList, + entry: [ + { + item: { + reference: `${binaryResourceType}/${binary1.id}`, + }, + }, + { + item: { + reference: `${groupResourceType}/${commodity1.id}`, + }, + }, + ], + }; + + const withEditEntriesListResponse = { + ...newList, + entry: [ + { item: { reference: 'Group/52cffa51-fa81-49aa-9944-5b45d9e4c117' } }, + { item: { reference: 'Binary/9b782015-8392-4847-b48c-50c11638656b' } }, + ], + }; + + // list resource + nock(props.fhirBaseURL) + .get(`/${listResourceType}/${props.listId}`) + .reply(200, withEditEntriesList) + .put(`/${listResourceType}/${props.listId}`, withEditEntriesListResponse) + .reply(201, withEditEntriesListResponse) + .persist(); + + render( + + + + ); + + await waitFor(() => { + screen.getByText('Edit Commodity | Bed nets'); + }); + + const materialNumberField = screen.getByLabelText('Material number'); + userEvent.clear(materialNumberField); + userEvent.type(materialNumberField, 'Bed nets'); + + // set attractive item + const yesRadioBtn = document.querySelector('#isAttractiveItem input[value="true"]')!; + fireEvent.click(yesRadioBtn); + + const availabilityField = screen.getByLabelText('Is it there?'); + userEvent.clear(availabilityField); + userEvent.type(availabilityField, 'could be better'); + + const conditionField = screen.getByLabelText('Is it in good condition?'); + userEvent.clear(conditionField); + userEvent.type(conditionField, 'as good as it can be'); + + const appropriateUsageField = screen.getByLabelText('Is it being used appropriately?'); + userEvent.clear(appropriateUsageField); + userEvent.type(appropriateUsageField, 'Define appropriately used.'); + + const accountabilityField = screen.getByLabelText('Accountability period (in months)'); + userEvent.clear(accountabilityField); + userEvent.type(accountabilityField, '12'); + + const productUploadField = screen.getByLabelText('Photo of the product'); + userEvent.upload(productUploadField, productImage); + + userEvent.click(screen.getByRole('button', { name: /Save/i })); + + await waitFor(() => { + expect(successNoticeMock.mock.calls).toEqual([['Commodity updated successfully']]); + expect(errorNoticeMock.mock.calls).toEqual([]); + }); + + expect(nock.pendingMocks()).toEqual([]); + expect(nock.isDone()).toBeTruthy(); +}); + +it('can remove product image', async () => { + const history = createMemoryHistory(); + history.push(`/add/${commodity1.id}`); + + const successNoticeMock = jest + .spyOn(notifications, 'sendSuccessNotification') + .mockImplementation(() => undefined); + + const errorNoticeMock = jest + .spyOn(notifications, 'sendErrorNotification') + .mockImplementation(() => undefined); + + nock(props.fhirBaseURL) + .get(`/${groupResourceType}/${commodity1.id}`) + .reply(200, commodity1) + .persist(); + + nock(props.fhirBaseURL).get(`/${binaryResourceType}/${binary1.id}`).reply(200, binary1).persist(); + + 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) + .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}`, commodityLessImage) + .reply(200, commodityLessImage) + .persist(); + + render( + + + + ); + + await waitFor(() => { + screen.getByText('Edit Commodity | Bed nets'); + }); + + const removeFileIcon = screen.getByTitle('Remove file'); + userEvent.click(removeFileIcon); + + userEvent.click(screen.getByRole('button', { name: /Save/i })); + + await waitFor(() => { + expect(successNoticeMock.mock.calls).toEqual([['Commodity updated successfully']]); + expect(errorNoticeMock.mock.calls).toEqual([]); + }); + + expect(nock.pendingMocks()).toEqual([]); +}); + +test('cancel handler is called on cancel', async () => { + const history = createMemoryHistory(); + history.push(`/add`); + + render( + + + + ); + + // test view is loaded. + screen.getByText('Create Commodity'); + const cancelBtn = screen.getByRole('button', { name: /Cancel/ }); + + fireEvent.click(cancelBtn); + expect(history.location.pathname).toEqual('/commodity/list'); +}); + +test('data loading problem', async () => { + const history = createMemoryHistory(); + history.push(`/add/${commodity1.id}`); + + nock(props.fhirBaseURL) + .get(`/${groupResourceType}/${commodity1.id}`) + .replyWithError('something aweful happened'); + + render( + + + + ); + + await waitForElementToBeRemoved(document.querySelector('.ant-spin')); + + // errors out + expect(screen.getByText(/something aweful happened/)).toBeInTheDocument(); +}); diff --git a/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/utils.ts b/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/utils.ts new file mode 100644 index 000000000..cd40eb293 --- /dev/null +++ b/packages/fhir-group-management/src/components/CommodityAddEdit/Eusm/utils.ts @@ -0,0 +1,638 @@ +import { IGroup } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IGroup'; +import { IList } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IList'; +import { Rule } from 'rc-field-form/lib/interface'; +import { v4 } from 'uuid'; +import { getObjLike, IdentifierUseCodes, FHIRServiceClass } from '@opensrp/react-utils'; +import { Identifier } from '@smile-cdr/fhirts/dist/FHIR-R4/classes/identifier'; +import { cloneDeep, get } from 'lodash'; +import { + active, + groupResourceType, + id, + identifier, + type, + unitOfMeasure, + name, + listResourceType, + accountabilityPeriod, + appropriateUsage, + condition, + availability, + isAttractiveItem, + binaryResourceType, + materialNumber, +} from '../../../constants'; +import type { TFunction } from '@opensrp/i18n'; +import { + accountabilityCharacteristic, + accountabilityCharacteristicCode, + accountabilityCharacteristicCoding, + appropriateUsageCharacteristic, + appropriateUsageCharacteristicCode, + appropriateUsageCharacteristicCoding, + attractiveCharacteristic, + attractiveCharacteristicCode, + attractiveCharacteristicCoding, + availabilityCharacteristic, + availabilityCharacteristicCode, + availabilityCharacteristicCoding, + characteristicUnitMeasureCode, + conditionCharacteristic, + conditionCharacteristicCode, + conditionCharacteristicCoding, + getCharacteristicWithCoding, + photoUploadCharacteristicCode, + snomedCodeSystem, + supplyMgSnomedCode, + unitOfMeasureCharacteristicCoding, + unitOfMeasureCharacteristic, +} from '../../../helpers/utils'; +import { TypeOfGroup } from '../../ProductForm/utils'; +import { GroupFormFields } from '../../ProductForm/types'; +import { GroupCharacteristic } from '@smile-cdr/fhirts/dist/FHIR-R4/classes/groupCharacteristic'; +import { IBinary } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IBinary'; +import { UploadFile } from 'antd'; +import { Coding } from '@smile-cdr/fhirts/dist/FHIR-R4/classes/coding'; + +export type EusmGroupFormFields = GroupFormFields<{ group: IGroup; binary?: IBinary }>; + +export const defaultCharacteristic = { + code: { + coding: [ + { system: snomedCodeSystem, code: characteristicUnitMeasureCode, display: 'Unit of measure' }, + ], + }, + valueCodeableConcept: { + coding: [{ system: snomedCodeSystem, code: '767525000', display: 'Unit' }], + text: undefined, + }, +}; + +export const defaultCode = { + coding: [{ system: snomedCodeSystem, code: supplyMgSnomedCode, display: 'Supply management' }], +}; + +/** + * factory for validation rules for GroupForm component + * + * @param t - the translator function + */ +export const validationRulesFactory = (t: TFunction) => { + return { + [id]: [{ type: 'string' }] as Rule[], + [identifier]: [{ type: 'string' }] as Rule[], + [materialNumber]: [ + { type: 'string', message: t('Must be a valid string') }, + { required: true, message: t('Required') }, + ] as Rule[], + [name]: [ + { type: 'string', message: t('Must be a valid string') }, + { required: true, message: t('Required') }, + ] as Rule[], + [active]: [{ type: 'boolean' }, { required: true, message: t('Required') }] as Rule[], + [type]: [{ type: 'enum', enum: Object.values(TypeOfGroup), required: true }] as Rule[], + [isAttractiveItem]: [{ type: 'boolean' }] as Rule[], + [availability]: [{ type: 'string' }, { required: true, message: t('Required') }] as Rule[], + [condition]: [{ type: 'string' }] as Rule[], + [appropriateUsage]: [{ type: 'string' }] as Rule[], + [accountabilityPeriod]: [{ type: 'number' }] as Rule[], + }; +}; + +/** + * @param characteristic - group characteristic + */ +function getValueFromCharacteristic(characteristic: GroupCharacteristic) { + if (characteristic['valueCodeableConcept']) { + return characteristic.valueCodeableConcept.text; + } + if (characteristic['valueBoolean']) { + return characteristic.valueBoolean; + } + if (characteristic['valueQuantity']) { + return characteristic.valueQuantity.value; + } + if (characteristic['valueReference']) { + return characteristic.valueReference.reference; + } +} + +/** + * Generates a group's characteristic payload + * + * @param existingCharacteristic - characteristic that exist for the group + * @param values - form filled values. + */ +function generateCharacteristicPayload( + existingCharacteristic: GroupCharacteristic[], + values: EusmGroupFormFields +) { + const knownCodes = [ + accountabilityCharacteristicCoding, + appropriateUsageCharacteristicCoding, + conditionCharacteristicCoding, + availabilityCharacteristicCoding, + attractiveCharacteristicCoding, + unitOfMeasureCharacteristicCoding, + ] as Coding[]; + const newCharacteristics = cloneDeep(existingCharacteristic); + for (const coding of knownCodes) { + updateCharacteristicForCode(newCharacteristics, coding, values); + } + return newCharacteristics; +} + +/** + * Updates the existing characteristics with the form field values. + * + * @param existingCharacteristics - array of existing characteristics to be mutably updated + * @param codingCode - coding code of interest + * @param values - form filled values + */ +function updateCharacteristicForCode( + existingCharacteristics: GroupCharacteristic[], + codingCode: Coding, + values: EusmGroupFormFields +) { + const checkCharacteristic = getCharacteristicWithCoding(existingCharacteristics, codingCode); + + switch (codingCode.code) { + case characteristicUnitMeasureCode: { + const { unitOfMeasure } = values; + if (unitOfMeasure === undefined) { + return; + } + if (checkCharacteristic) { + (checkCharacteristic.valueCodeableConcept ?? {}).text = unitOfMeasure; + } else + existingCharacteristics.push({ + ...unitOfMeasureCharacteristic, + valueCodeableConcept: { + ...unitOfMeasureCharacteristic.valueCodeableConcept, + text: unitOfMeasure, + }, + }); + break; + } + case accountabilityCharacteristicCode: { + const { accountabilityPeriod } = values; + if (accountabilityPeriod === undefined) { + return; + } + if (checkCharacteristic) { + (checkCharacteristic.valueQuantity ?? {}).value = accountabilityPeriod; + } else { + existingCharacteristics.push({ + ...accountabilityCharacteristic, + valueQuantity: { + value: accountabilityPeriod, + }, + }); + } + break; + } + case appropriateUsageCharacteristicCode: { + const { appropriateUsage } = values; + if (appropriateUsage === undefined) { + return; + } + if (checkCharacteristic) { + (checkCharacteristic.valueCodeableConcept ?? {}).text = appropriateUsage; + } else + existingCharacteristics.push({ + ...appropriateUsageCharacteristic, + valueCodeableConcept: { + ...appropriateUsageCharacteristic.valueCodeableConcept, + text: appropriateUsage, + }, + }); + break; + } + case conditionCharacteristicCode: { + const { condition } = values; + if (condition === undefined) { + return; + } + if (checkCharacteristic) { + (checkCharacteristic.valueCodeableConcept ?? {}).text = condition; + } else + existingCharacteristics.push({ + ...conditionCharacteristic, + valueCodeableConcept: { + ...conditionCharacteristic.valueCodeableConcept, + text: condition, + }, + }); + break; + } + case availabilityCharacteristicCode: { + const { availability } = values; + if (availability === undefined) { + return; + } + if (checkCharacteristic) { + (checkCharacteristic.valueCodeableConcept ?? {}).text = availability; + } else + existingCharacteristics.push({ + ...availabilityCharacteristic, + valueCodeableConcept: { + ...availabilityCharacteristic.valueCodeableConcept, + text: availability, + }, + }); + break; + } + case attractiveCharacteristicCode: { + const { isAttractiveItem } = values; + if (isAttractiveItem === undefined) { + return; + } + if (checkCharacteristic) { + checkCharacteristic.valueBoolean = isAttractiveItem; + } else + existingCharacteristics.push({ + ...attractiveCharacteristic, + valueBoolean: isAttractiveItem, + }); + break; + } + } +} + +/** + * Converts a file object to a base 64 string + * + * @param file - file object + */ +function fileToBase64(file?: File): Promise { + if (!file) { + return new Promise((r) => r(undefined)); + } + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = function () { + const base64String = (reader.result as string).split(',')[1]; + resolve(base64String); + }; + + reader.onerror = function (error) { + reject(error); + }; + + reader.readAsDataURL(file); + }); +} + +/** + * generates the binary payload for uploaded image + * + * @param values - current form field values + * @param initialValues - initial form field values. + */ +export async function getProductImagePayload( + values: EusmGroupFormFields, + initialValues: EusmGroupFormFields +) { + const initialImage = initialValues.productImage?.[0]?.originFileObj; + const currentImage = values.productImage?.[0]?.originFileObj; + // TODO - replace btoa with typed arrays. + const currentImageb64 = await fileToBase64(currentImage); + const initialImageb64 = await fileToBase64(initialImage); + + if (currentImageb64 === initialImageb64) { + // This could mean it was not added or removed. + return { + changed: false, + }; + } else if (currentImage === undefined) { + return { + changed: true, + }; + } else { + const id = v4(); + const payload: IBinary = { + id, + resourceType: binaryResourceType, + contentType: currentImage.type, + data: currentImageb64, + }; + return { + changed: true, + payload, + }; + } +} + +/** + * Converts group resource to initial values consumable by productForm + * + * @param obj - the group resource + * @param binary - binary associated with the group + */ +export const getGroupFormFields = (obj?: IGroup, binary?: IBinary): EusmGroupFormFields => { + if (!obj) { + return { initialObject: { group: { code: defaultCode } }, active: true } as EusmGroupFormFields; + } + const { id, name, active, identifier, type } = obj; + + const identifierObj = getObjLike(identifier, 'use', IdentifierUseCodes.OFFICIAL) as Identifier[]; + + const formFieldsFromCharacteristics: Record = {}; + for (const characteristic of obj.characteristic ?? []) { + const characteristicCoding = characteristic.code.coding ?? []; + for (const coding of characteristicCoding) { + // if we have a code hit in one of the codings. + const codingSystem = coding.system?.toLowerCase(); + const codingCode = coding.code; + if (codingSystem === snomedCodeSystem) { + if (codingCode === characteristicUnitMeasureCode) { + const val = getValueFromCharacteristic(characteristic); + formFieldsFromCharacteristics[unitOfMeasure] = val; + } + if (codingCode === accountabilityCharacteristicCode) { + const val = getValueFromCharacteristic(characteristic); + formFieldsFromCharacteristics[accountabilityPeriod] = val; + } + if (codingCode === appropriateUsageCharacteristicCode) { + const val = getValueFromCharacteristic(characteristic); + formFieldsFromCharacteristics[appropriateUsage] = val; + } + if (codingCode === conditionCharacteristicCode) { + const val = getValueFromCharacteristic(characteristic); + formFieldsFromCharacteristics[condition] = val; + } + if (codingCode === availabilityCharacteristicCode) { + const val = getValueFromCharacteristic(characteristic); + formFieldsFromCharacteristics[availability] = val; + } + if (codingCode === attractiveCharacteristicCode) { + const val = getValueFromCharacteristic(characteristic); + formFieldsFromCharacteristics[isAttractiveItem] = val; + } + } + } + } + + let productImageFromUrl; + + if (binary?.data && binary.contentType) { + productImageFromUrl = base64ToFile(binary.data, binary.contentType); + productImageFromUrl = [{ uid: binary.id, originFileObj: productImageFromUrl } as UploadFile]; + } + + const formFields: EusmGroupFormFields = { + initialObject: { group: obj, binary }, + id, + identifier: get(identifierObj, '0.value'), + materialNumber: get(identifierObj, '0.value'), + active, + name, + type, + ...formFieldsFromCharacteristics, + productImage: productImageFromUrl, + }; + + return formFields; +}; + +// process photo url +/** + * Converts a base 64 string to a File object + * + * @param base64String - b64 encoded string + * @param mimeType - mime type for the file + */ +function base64ToFile(base64String: string, mimeType: string) { + const byteCharacters = atob(base64String); + const byteNumbers = new Array(byteCharacters.length); + + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + + const byteArray = new Uint8Array(byteNumbers); + const blob = new Blob([byteArray], { type: mimeType }); + + return new File([blob], '', { type: mimeType }); +} + +/** + * Regenerates group payload from form values + * + * @param values - form values + * @param initialValues - initial form values + */ +export const generateGroupPayload = async ( + values: EusmGroupFormFields, + initialValues: EusmGroupFormFields +): Promise<{ group: IGroup; binary?: IBinary; binaryChanged: boolean }> => { + const { id, materialNumber, active, name, type } = values; + const { initialObject } = initialValues; + const initialGroupObject = initialValues.initialObject?.group; + let payload: IGroup = { + resourceType: groupResourceType, + active: !!active, + }; + // preserve resource details that we are not interested in editing. + if (initialObject) { + const { meta, ...rest } = initialGroupObject ?? {}; + payload = { + ...rest, + ...payload, + }; + } + + if (name) { + payload.name = name; + } + if (id) { + payload.id = id; + } else { + payload.id = v4(); + } + + // process identifiers + const existingIdentifiers = initialGroupObject?.identifier ?? []; + const newIdentifiers = existingIdentifiers.filter( + (identifier) => identifier.use !== IdentifierUseCodes.OFFICIAL + ); + if (materialNumber) { + newIdentifiers.push({ + use: IdentifierUseCodes.OFFICIAL, + value: materialNumber, + }); + } + if (newIdentifiers.length) { + payload.identifier = newIdentifiers; + } + + if (type) { + payload.type = type as TypeOfGroup; + } + + const existingCharacteristics = initialGroupObject?.characteristic ?? []; + + let newCharacteristics = generateCharacteristicPayload(existingCharacteristics, values); + + // image characteristic + const { changed, payload: binaryPayload } = await getProductImagePayload(values, initialValues); + + if (changed) { + newCharacteristics = newCharacteristics.filter( + (stic) => + (stic.code.coding ?? []) + .map((coding) => coding.code) + .indexOf(photoUploadCharacteristicCode) < 0 + ); + if (binaryPayload) { + // remove current binary. means should also be removed in list. + const binaryResourceUrl = `${binaryResourceType}/${binaryPayload.id}`; + const productImageCharacteristic = { + code: { + coding: [ + { + system: 'http://snomed.info/sct', + code: '1231415', + display: 'Product Image code', + }, + ], + }, + valueReference: { + reference: binaryResourceUrl, + }, + }; + newCharacteristics = [...newCharacteristics, productImageCharacteristic]; + } + } + + payload.characteristic = newCharacteristics; + + return { group: payload, binary: binaryPayload, binaryChanged: changed }; +}; + +/** + * either posts or puts a group resource payload to fhir server + * + * @param baseUrl - server base url + * @param payload - the organization payload + */ +export const postPutGroup = (baseUrl: string, payload: IGroup) => { + const serve = new FHIRServiceClass(baseUrl, groupResourceType); + return serve.update(payload); +}; + +/** + * either posts or puts a group resource payload to fhir server + * + * @param baseUrl - server base url + * @param payload - the organization payload + */ +export const postPutBinary = (baseUrl: string, payload: IBinary) => { + const serve = new FHIRServiceClass(baseUrl, binaryResourceType); + return serve.update(payload); +}; + +/** + * Gets list resource for given id, create it if it does not exist + * + * @param baseUrl - api base url + * @param listId - list id + */ +export async function getOrCreateList(baseUrl: string, listId: string) { + const serve = new FHIRServiceClass(baseUrl, listResourceType); + return serve.read(listId).catch((err) => { + if (err.statusCode === 404) { + const listResource = createSupplyManagementList(listId); + return serve.update(listResource); + } + throw err; + }); +} + +/** + * @param baseUrl - the api base url + * @param listId - list resource id to add the group to + * @param initialBinary - initial binary object associated with group + */ +export const updateListReferencesFactory = + (baseUrl: string, listId: string, initialBinary?: IBinary) => + async ( + formResponses: { group: IGroup; binary?: IBinary; binaryChanged: boolean }, + editingGroup: boolean + ) => { + const { group, binary, binaryChanged } = formResponses; + + if (editingGroup && !binaryChanged) { + return; + } + + const commoditiesListResource = await getOrCreateList(baseUrl, listId); + const payload = cloneDeep(commoditiesListResource); + + let existingEntries = payload.entry ?? []; + + if (binaryChanged) { + if (initialBinary) { + // we are removing a reference in the list resource. + const toRemoveBinaryRef = `${binaryResourceType}/${initialBinary.id}`; + existingEntries = existingEntries.filter( + (entry) => entry.item.reference !== toRemoveBinaryRef + ); + } + if (binary) { + const toAddBinaryRef = `${binaryResourceType}/${binary.id}`; + existingEntries.push({ + item: { + reference: toAddBinaryRef, + }, + }); + } + } + if (!editingGroup) { + existingEntries.push({ + item: { + reference: `${groupResourceType}/${group.id}`, + }, + }); + } + if (existingEntries.length) { + payload.entry = existingEntries; + } + + const serve = new FHIRServiceClass(baseUrl, listResourceType); + return serve.update(payload); + }; + +/** + * Creates a very specific list resource that will curate a set of commodities to be used on the client. + * This is so that the list resource can then be used when configuring the fhir mobile client + * + * @param id - externally defined id that will be the id of the new list resource + */ +export function createSupplyManagementList(id: string): IList { + return { + resourceType: listResourceType, + id: id, + identifier: [ + { + use: IdentifierUseCodes.OFFICIAL, + value: id, + }, + ], + status: 'current', + mode: 'working', + title: 'Supply Chain commodities', + code: { + coding: [ + { + system: 'http://ona.io', + code: 'supply-chain', + display: 'Supply Chain Commodity', + }, + ], + text: 'Supply Chain Commodity', + }, + entry: [], + }; +} diff --git a/packages/fhir-group-management/src/components/CommodityAddEdit/Form.tsx b/packages/fhir-group-management/src/components/CommodityAddEdit/Form.tsx deleted file mode 100644 index e6bcd135a..000000000 --- a/packages/fhir-group-management/src/components/CommodityAddEdit/Form.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import React from 'react'; -import { Select, Button, Form, Radio, Input, Space } from 'antd'; -import { - active, - name, - id, - identifier, - type, - unitOfMeasure, - groupResourceType, -} from '../../constants'; -import { - sendSuccessNotification, - sendErrorNotification, - sendInfoNotification, -} from '@opensrp/notifications'; -import { useQueryClient, useMutation } from 'react-query'; -import { formItemLayout, tailLayout } from '@opensrp/react-utils'; -import { useHistory } from 'react-router'; -import { - generateGroupPayload, - getGroupTypeOptions, - getUnitOfMeasureOptions, - GroupFormFields, - groupSelectfilterFunction, - postPutGroup, - SelectOption, - validationRulesFactory, -} from './utils'; -import { SelectProps } from 'antd/lib/select'; -import { useTranslation } from '../../mls'; -import { IGroup } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IGroup'; - -const { Item: FormItem } = Form; - -export interface GroupFormProps { - fhirBaseUrl: string; - initialValues: GroupFormFields; - disabled: string[]; - cancelUrl?: string; - successUrl?: string; - postSuccess?: (commodity: IGroup, edited: boolean) => Promise; -} - -const defaultProps = { - initialValues: {}, - disabled: [], -}; - -const CommodityForm = (props: GroupFormProps) => { - const { fhirBaseUrl, initialValues, disabled, cancelUrl, successUrl, postSuccess } = props; - - const queryClient = useQueryClient(); - const history = useHistory(); - const { t } = useTranslation(); - const goTo = (url = '#') => history.push(url); - - const { mutate, isLoading } = useMutation( - (values: GroupFormFields) => { - const payload = generateGroupPayload(values, initialValues); - return postPutGroup(fhirBaseUrl, payload); - }, - { - onError: (err: Error) => { - sendErrorNotification(err.message); - }, - onSuccess: async (createdGroup) => { - sendSuccessNotification(t('Commodity updated successfully')); - const isEdit = !!initialValues.id; - await postSuccess?.(createdGroup, isEdit).catch((err) => { - sendErrorNotification(err.message); - }); - queryClient.refetchQueries([groupResourceType]).catch(() => { - sendInfoNotification(t('Failed to refresh data, please refresh the page')); - }); - goTo(successUrl); - }, - } - ); - - const statusOptions = [ - { label: t('Active'), value: true }, - { label: t('Disabled'), value: false }, - ]; - - const unitsOfMEasureOptions = getUnitOfMeasureOptions(); - const typeOptions = getGroupTypeOptions(); - - const validationRules = validationRulesFactory(t); - - return ( -
{ - mutate(values); - }} - initialValues={initialValues} - > - - - - - - - - - - - - - - - - - - - - - - - - - - -
- ); -}; - -CommodityForm.defaultProps = defaultProps; - -export { CommodityForm }; diff --git a/packages/fhir-group-management/src/components/CommodityAddEdit/index.tsx b/packages/fhir-group-management/src/components/CommodityAddEdit/index.tsx index a974051db..4e22d3bff 100644 --- a/packages/fhir-group-management/src/components/CommodityAddEdit/index.tsx +++ b/packages/fhir-group-management/src/components/CommodityAddEdit/index.tsx @@ -1,71 +1,14 @@ +import { getConfig } from '@opensrp/pkg-config'; +import { CommodityAddEdit as EusmCommodityAddEdit } from './Eusm'; +import { CommodityAddEdit as DefaultCommodityAddEdit, GroupAddEditProps } from './Default'; import React from 'react'; -import { Helmet } from 'react-helmet'; -import { CommodityForm } from './Form'; -import { useParams } from 'react-router'; -import { groupResourceType, LIST_COMMODITY_URL } from '../../constants'; -import { Spin } from 'antd'; -import { PageHeader } from '@opensrp/react-utils'; -import { useQuery } from 'react-query'; -import { FHIRServiceClass, BrokenPage } from '@opensrp/react-utils'; -import { IGroup } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IGroup'; -import { getGroupFormFields, updateListReferencesFactory } from './utils'; -import { useTranslation } from '../../mls'; - -export interface GroupAddEditProps { - fhirBaseURL: string; - listId: string; -} - -export interface RouteParams { - id?: string; -} export const CommodityAddEdit = (props: GroupAddEditProps) => { - const { fhirBaseURL: fhirBaseUrl, listId } = props; - - const { id: resourceId } = useParams(); - const { t } = useTranslation(); - - const groupQuery = useQuery( - [groupResourceType, resourceId], - async () => - new FHIRServiceClass(fhirBaseUrl, groupResourceType).read(resourceId as string), - { - enabled: !!resourceId, - } - ); - - if (!groupQuery.isIdle && groupQuery.isLoading) { - return ; - } + const projectCode = getConfig('projectCode'); - if (groupQuery.error && !groupQuery.data) { - return ; + if (projectCode === 'eusm') { + return ; + } else { + return ; } - - const initialValues = getGroupFormFields(groupQuery.data); - - const pageTitle = groupQuery.data - ? t('Edit Commodity | {{name}}', { name: groupQuery.data.name ?? '' }) - : t('Create Commodity'); - - const postSuccess = updateListReferencesFactory(fhirBaseUrl, listId); - - return ( -
- - {pageTitle} - - -
- -
-
- ); }; diff --git a/packages/fhir-group-management/src/components/CommodityAddEdit/tests/Form.test.tsx b/packages/fhir-group-management/src/components/CommodityAddEdit/tests/Form.test.tsx deleted file mode 100644 index fa870fc64..000000000 --- a/packages/fhir-group-management/src/components/CommodityAddEdit/tests/Form.test.tsx +++ /dev/null @@ -1,387 +0,0 @@ -import { store } from '@opensrp/store'; -import { mount } from 'enzyme'; -import toJson from 'enzyme-to-json'; -import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { Router } from 'react-router'; -import { CommodityForm } from '../Form'; -import { createBrowserHistory } from 'history'; -import { authenticateUser } from '@onaio/session-reducer'; -import nock from 'nock'; -import { QueryClientProvider, QueryClient } from 'react-query'; -import { cleanup, fireEvent, waitFor } from '@testing-library/react'; -import flushPromises from 'flush-promises'; -import { groupResourceType } from '../../../constants'; -import { commodity1, createdCommodity, editedCommodity } from './fixtures'; -import { getGroupFormFields } from '../utils'; -import userEvents from '@testing-library/user-event'; -import * as notifications from '@opensrp/notifications'; - -jest.mock('@opensrp/notifications', () => ({ - __esModule: true, - ...Object.assign({}, jest.requireActual('@opensrp/notifications')), -})); - -const history = createBrowserHistory(); - -jest.mock('fhirclient', () => { - return jest.requireActual('fhirclient/lib/entry/browser'); -}); - -jest.mock('uuid', () => { - const actual = jest.requireActual('uuid'); - return { - ...actual, - v4: () => '9b782015-8392-4847-b48c-50c11638656b', - }; -}); - -describe('Health care form', () => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - cacheTime: 0, - }, - }, - }); - - const AppWrapper = (props: { children: React.ReactNode }) => { - return ( - - {props.children}; - - ); - }; - - const formProps = { - fhirBaseUrl: 'http://test.server.org', - initialValues: getGroupFormFields(), - }; - - beforeAll(() => { - nock.disableNetConnect(); - store.dispatch( - authenticateUser( - true, - { - email: 'bob@example.com', - name: 'Bobbie', - username: 'RobertBaratheon', - }, - { api_token: 'hunter2', oAuth2Data: { access_token: 'sometoken', state: 'abcde' } } - ) - ); - }); - - afterAll(() => { - nock.enableNetConnect(); - }); - - afterEach(() => { - nock.cleanAll(); - cleanup(); - jest.resetAllMocks(); - }); - - it('renders correctly', async () => { - const div = document.createElement('div'); - document.body.appendChild(div); - - const wrapper = mount( - - - , - { attachTo: div } - ); - - await act(async () => { - await flushPromises(); - wrapper.update(); - }); - - expect(toJson(wrapper.find('#id .ant-form-item label'))).toMatchSnapshot('id label'); - expect(toJson(wrapper.find('#id .ant-form-item input'))).toMatchSnapshot('id field'); - - expect(toJson(wrapper.find('#identifier .ant-form-item label'))).toMatchSnapshot( - 'identifier label' - ); - expect(toJson(wrapper.find('#identifier .ant-form-item input'))).toMatchSnapshot( - 'identifier field' - ); - - expect(toJson(wrapper.find('#name .ant-form-item label'))).toMatchSnapshot('name label'); - expect(toJson(wrapper.find('#name .ant-form-item input'))).toMatchSnapshot('name field'); - - expect(toJson(wrapper.find('#active .ant-form-item label').first())).toMatchSnapshot( - 'active label' - ); - expect(toJson(wrapper.find('#active .ant-form-item input'))).toMatchSnapshot('active field'); - - expect(toJson(wrapper.find('#type .ant-form-item label').first())).toMatchSnapshot( - 'type label' - ); - expect(toJson(wrapper.find('#type .ant-form-item input#type'))).toMatchSnapshot('type field'); - - expect(toJson(wrapper.find('#unitOfMeasure .ant-form-item label').first())).toMatchSnapshot( - 'unit of measure label' - ); - expect(toJson(wrapper.find('#unitOfMeasure .ant-form-item input'))).toMatchSnapshot( - 'unit of measure field' - ); - - expect(toJson(wrapper.find('#submit-button button'))).toMatchSnapshot('submit button'); - expect(toJson(wrapper.find('#cancel-button button'))).toMatchSnapshot('cancel button'); - - wrapper.find('button#cancel-button').simulate('click'); - wrapper.unmount(); - }); - - it('form validation works', async () => { - const div = document.createElement('div'); - document.body.appendChild(div); - - const wrapper = mount( - - - , - { attachTo: div } - ); - - wrapper.find('form').simulate('submit'); - - await act(async () => { - await flushPromises(); - }); - wrapper.update(); - - await waitFor(() => { - const atLeastOneError = document.querySelector('.ant-form-item-explain-error'); - expect(atLeastOneError).toBeInTheDocument(); - }); - - // name is required and has no default - expect(wrapper.find('#name .ant-form-item').text()).toMatchInlineSnapshot( - `"Enter Commodity nameRequired"` - ); - - // status has no - expect(wrapper.find('#active .ant-form-item').text()).toMatchInlineSnapshot( - `"Select Commodity statusActiveDisabled"` - ); - - // required - expect(wrapper.find('#type .ant-form-item').text()).toMatchInlineSnapshot( - `"Select Commodity TypeSelect Commodity type'type' is required"` - ); - - // required - expect(wrapper.find('#unitOfMeasure .ant-form-item').text()).toMatchInlineSnapshot( - `"Select the unit of measureSelect the unit of measure'unitOfMeasure' is required"` - ); - - wrapper.unmount(); - }); - - it('submits new group', async () => { - const container = document.createElement('div'); - document.body.appendChild(container); - - const successNoticeMock = jest - .spyOn(notifications, 'sendSuccessNotification') - .mockImplementation(() => undefined); - - const someMockURL = '/someURL'; - - nock(formProps.fhirBaseUrl) - .put(`/${groupResourceType}/9b782015-8392-4847-b48c-50c11638656b`, createdCommodity) - .reply(200, { ...createdCommodity, id: '123' }) - .persist(); - - const wrapper = mount( - - - , - { attachTo: container } - ); - - // simulate active change - wrapper - .find('#active .ant-form-item input') - .first() - .simulate('change', { - target: { checked: true }, - }); - - // simulate name change - wrapper - .find('#name .ant-form-item input') - .simulate('change', { target: { name: 'name', value: 'Dettol' } }); - - // simulate value selection for type - wrapper.find('input#type').simulate('mousedown'); - - const optionTexts = [ - ...document.querySelectorAll( - '#type_list+div.rc-virtual-list .ant-select-item-option-content' - ), - ].map((option) => { - return option.textContent; - }); - - expect(toJson(wrapper.find('#type .ant-form-item'))).toMatchSnapshot('asd'); - - expect(optionTexts).toHaveLength(2); - expect(optionTexts).toEqual(['Medication', 'Device']); - - // filter searching through members works - await userEvents.type(document.querySelector('input#type'), 'dev'); - - // options after search - const afterFilterOptionTexts = [ - ...document.querySelectorAll( - '#type_list+div.rc-virtual-list .ant-select-item-option-content' - ), - ].map((option) => { - return option.textContent; - }); - - expect(afterFilterOptionTexts).toHaveLength(1); - expect(afterFilterOptionTexts).toEqual(['Device']); - - fireEvent.click(document.querySelector('[title="Device"]')); - - // unit of measure - // simulate value selection for members - wrapper.find('input#unitOfMeasure').simulate('mousedown'); - - const measureUnitOptions = [ - ...document.querySelectorAll( - '#unitOfMeasure_list+div.rc-virtual-list .ant-select-item-option-content' - ), - ].map((option) => { - return option.textContent; - }); - - expect(measureUnitOptions).toHaveLength(9); - expect(measureUnitOptions).toEqual([ - 'Pieces', - 'Tablets', - 'Ampoules', - 'Strips', - 'Cycles', - 'Bottles', - 'Test kits', - 'Sachets', - 'Straps', - ]); - - fireEvent.click(document.querySelector('[title="Bottles"]')); - - await flushPromises(); - wrapper.update(); - - wrapper.find('form').simulate('submit'); - - await waitFor(() => { - expect(successNoticeMock.mock.calls).toEqual([['Commodity updated successfully']]); - }); - - expect(nock.isDone()).toBeTruthy(); - wrapper.unmount(); - }); - - it('cancel handler is called on cancel', async () => { - const container = document.createElement('div'); - document.body.appendChild(container); - - const cancelUrl = '/canceled'; - - const wrapper = mount( - - - , - - { attachTo: container } - ); - - await act(async () => { - await flushPromises(); - wrapper.update(); - }); - - wrapper.find('button#cancel-button').simulate('click'); - wrapper.update(); - - expect(history.location.pathname).toEqual('/canceled'); - wrapper.unmount(); - }); - - it('edits resource', async () => { - const container = document.createElement('div'); - document.body.appendChild(container); - - const errorNoticeMock = jest - .spyOn(notifications, 'sendErrorNotification') - .mockImplementation(() => undefined); - - nock(formProps.fhirBaseUrl) - .put(`/${groupResourceType}/${commodity1.id}`, editedCommodity) - .replyWithError('Failed to update Commodity') - .persist(); - - const initialValues = getGroupFormFields(commodity1); - const localProps = { - ...formProps, - initialValues, - }; - - const wrapper = mount( - - - , - { attachTo: container } - ); - - // simulate name change - wrapper - .find('#name .ant-form-item input') - .simulate('change', { target: { name: 'name', value: 'Dettol Strips' } }); - - // simulate active check to be disabled - wrapper - .find('#active .ant-form-item input') - .last() - .simulate('change', { - target: { checked: true }, - }); - - // simulate value selection for members - wrapper.find('#unitOfMeasure .ant-form-item input').simulate('mousedown'); - // check options - document - .querySelectorAll('#unitOfMeasure .ant-select-item ant-select-item-option') - .forEach((option) => { - expect(option).toMatchSnapshot('organizations option'); - }); - - fireEvent.click(document.querySelector('[title="Strips"]')); - - await flushPromises(); - wrapper.update(); - - wrapper.find('form').simulate('submit'); - - await waitFor(() => { - expect(errorNoticeMock.mock.calls).toEqual([ - [ - `request to http://test.server.org/Group/${commodity1.id} failed, reason: Failed to update Commodity`, - ], - ]); - }); - - expect(nock.isDone()).toBeTruthy(); - - wrapper.unmount(); - }); -}); diff --git a/packages/fhir-group-management/src/components/CommodityAddEdit/tests/__snapshots__/Form.test.tsx.snap b/packages/fhir-group-management/src/components/CommodityAddEdit/tests/__snapshots__/Form.test.tsx.snap deleted file mode 100644 index 2e72baec6..000000000 --- a/packages/fhir-group-management/src/components/CommodityAddEdit/tests/__snapshots__/Form.test.tsx.snap +++ /dev/null @@ -1,1899 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Health care form renders correctly: active field 1`] = ` -Array [ - , - , -] -`; - -exports[`Health care form renders correctly: active label 1`] = ` - -`; - -exports[`Health care form renders correctly: cancel button 1`] = ` - -`; - -exports[`Health care form renders correctly: id field 1`] = ` - -`; - -exports[`Health care form renders correctly: id label 1`] = ` - -`; - -exports[`Health care form renders correctly: identifier field 1`] = ` - -`; - -exports[`Health care form renders correctly: identifier label 1`] = ` - -`; - -exports[`Health care form renders correctly: name field 1`] = ` - -`; - -exports[`Health care form renders correctly: name label 1`] = ` - -`; - -exports[`Health care form renders correctly: submit button 1`] = ` - -`; - -exports[`Health care form renders correctly: type field 1`] = ` - -`; - -exports[`Health care form renders correctly: type label 1`] = ` - -`; - -exports[`Health care form renders correctly: unit of measure field 1`] = ` - -`; - -exports[`Health care form renders correctly: unit of measure label 1`] = ` - -`; - -exports[`Health care form submits new group: asd 1`] = ` -
- - -
- - - -
- -
- -
- - - -
-
-
- - } - > - - - - - - Select Commodity type - -
, - } - } - dropdownClassName="css-dev-only-do-not-override-k7429z" - dropdownMatchSelectWidth={true} - emptyOptions={false} - id="type" - inputElement={null} - inputIcon={[Function]} - notFoundContent={ - - } - omitDomProps={ - Array [ - "inputValue", - ] - } - onDisplayValuesChange={[Function]} - onRemove={[Function]} - onSearch={[Function]} - onSearchSplit={[Function]} - onSearchSubmit={[Function]} - onToggleOpen={[Function]} - open={true} - placeholder="Select Commodity type" - placement="bottomLeft" - prefixCls="ant-select" - removeIcon={} - searchValue="" - showArrow={true} - showSearch={true} - tokenWithEnter={false} - transitionName="ant-slide-up" - values={Array []} - > -
- } - disabled={false} - displayValues={Array []} - domRef={ - Object { - "current":
- - - - - Select Commodity type - -
, - } - } - dropdownClassName="css-dev-only-do-not-override-k7429z" - dropdownMatchSelectWidth={true} - emptyOptions={false} - id="type" - inputElement={null} - inputIcon={[Function]} - inputRef={ - Object { - "current": , - } - } - notFoundContent={ - - } - omitDomProps={ - Array [ - "inputValue", - ] - } - onDisplayValuesChange={[Function]} - onInputChange={[Function]} - onInputCompositionEnd={[Function]} - onInputCompositionStart={[Function]} - onInputKeyDown={[Function]} - onInputMouseDown={[Function]} - onInputPaste={[Function]} - onRemove={[Function]} - onSearch={[Function]} - onSearchSplit={[Function]} - onSearchSubmit={[Function]} - onToggleOpen={[Function]} - open={true} - placeholder="Select Commodity type" - placement="bottomLeft" - prefixCls="ant-select" - removeIcon={} - searchValue="" - showArrow={true} - showSearch={true} - tokenWithEnter={false} - transitionName="ant-slide-up" - values={Array []} - > - - - - - - - Select Commodity type - -
-
- - - - - - - -
- } - portal={ - Object { - "$$typeof": Symbol(react.forward_ref), - "render": [Function], - } - } - prefixCls="ant-select-dropdown" - ready={false} - style={ - Object { - "minWidth": 0, - "width": 0, - } - } - target={ -
- - - - - Select Commodity type - -
- } - targetHeight={0} - targetWidth={0} - > - - -
-
-
-
- medication -
-
- device -
-
-
-
-
-
-
-
- Medication -
-
-
-
- Device -
-
-
-
-
- -
-
-
- } - > - - - - - - -
- -
- -
-
- medication -
-
- device -
-
- -
-
- -
- - - -
- -
-
- Medication -
- - - -
-
- -
-
- Device -
- - - -
-
-
-
-
-
-
-
-
- -
-
-
- -
- - -
- -
- - - - - - - - - - - - - - - - - - - - - - - -
- - - - -
-
-
- - - - - -`; diff --git a/packages/fhir-group-management/src/components/CommodityAddEdit/tests/__snapshots__/index.test.tsx.snap b/packages/fhir-group-management/src/components/CommodityAddEdit/tests/__snapshots__/index.test.tsx.snap deleted file mode 100644 index e8809beda..000000000 --- a/packages/fhir-group-management/src/components/CommodityAddEdit/tests/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,59 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`renders correctly for edit resource: name field 1`] = ` - -`; - -exports[`renders correctly for edit resource: type field 1`] = ` - -`; - -exports[`renders correctly for new resource: name field 1`] = ` - -`; - -exports[`renders correctly for new resource: type field 1`] = ` - -`; diff --git a/packages/fhir-group-management/src/components/CommodityAddEdit/tests/index.test.tsx b/packages/fhir-group-management/src/components/CommodityAddEdit/tests/index.test.tsx deleted file mode 100644 index a8fd586d5..000000000 --- a/packages/fhir-group-management/src/components/CommodityAddEdit/tests/index.test.tsx +++ /dev/null @@ -1,272 +0,0 @@ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ -/* eslint-disable @typescript-eslint/naming-convention */ -import React from 'react'; -import { Route, Router, Switch } from 'react-router'; -import { QueryClient, QueryClientProvider } from 'react-query'; -import { CommodityAddEdit } from '..'; -import { Provider } from 'react-redux'; -import { store } from '@opensrp/store'; -import nock from 'nock'; -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { waitForElementToBeRemoved } from '@testing-library/dom'; -import { createMemoryHistory } from 'history'; -import { authenticateUser } from '@onaio/session-reducer'; -import { commodity1, createdCommodity, newList } from './fixtures'; -import { groupResourceType, listResourceType } from '../../../constants'; -import userEvent from '@testing-library/user-event'; -import * as notifications from '@opensrp/notifications'; -import flushPromises from 'flush-promises'; - -jest.mock('@opensrp/notifications', () => ({ - __esModule: true, - ...Object.assign({}, jest.requireActual('@opensrp/notifications')), -})); - -jest.mock('fhirclient', () => { - return jest.requireActual('fhirclient/lib/entry/browser'); -}); - -const mockv4 = '9b782015-8392-4847-b48c-50c11638656b'; -jest.mock('uuid', () => { - const actual = jest.requireActual('uuid'); - return { - ...actual, - v4: () => mockv4, - }; -}); - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - cacheTime: 0, - }, - }, -}); - -const listResId = 'list-resource-id'; -const props = { - fhirBaseURL: 'http://test.server.org', - listId: listResId, -}; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const AppWrapper = (props: any) => { - return ( - - - - - - - - - - - - - ); -}; - -afterEach(() => { - cleanup(); - nock.cleanAll(); - jest.resetAllMocks(); -}); - -beforeAll(() => { - nock.disableNetConnect(); - store.dispatch( - authenticateUser( - true, - { - email: 'bob@example.com', - name: 'Bobbie', - username: 'RobertBaratheon', - }, - { api_token: 'hunter2', oAuth2Data: { access_token: 'sometoken', state: 'abcde' } } - ) - ); -}); - -afterAll(() => { - nock.enableNetConnect(); -}); - -test('renders correctly for new resource', async () => { - const history = createMemoryHistory(); - history.push('/add'); - - render( - - - - ); - - // some small but inconclusive proof that the form rendered - expect(screen.getByLabelText(/name/i)).toMatchSnapshot('name field'); - expect(screen.getByLabelText(/type/i)).toMatchSnapshot('type field'); -}); - -test('renders correctly for edit resource', async () => { - const history = createMemoryHistory(); - history.push(`/add/${commodity1.id}`); - - nock(props.fhirBaseURL).get(`/${groupResourceType}/${commodity1.id}`).reply(200, commodity1); - - render( - - - - ); - - await waitForElementToBeRemoved(document.querySelector('.ant-spin')); - - // some small but inconclusive proof that the form rendered and has some initial values - expect(screen.getByLabelText(/name/i)).toMatchSnapshot('name field'); - expect(screen.getByLabelText(/type/i)).toMatchSnapshot('type field'); -}); - -test('data loading problem', async () => { - const history = createMemoryHistory(); - history.push(`/add/${commodity1.id}`); - - nock(props.fhirBaseURL) - .get(`/${groupResourceType}/${commodity1.id}`) - .replyWithError('something aweful happened'); - - render( - - - - ); - - await waitForElementToBeRemoved(document.querySelector('.ant-spin')); - - // errors out - expect(screen.getByText(/something aweful happened/)).toBeInTheDocument(); -}); - -test('#1116 adds new resources to list', async () => { - const history = createMemoryHistory(); - history.push('/add'); - - nock(props.fhirBaseURL) - .put(`/${groupResourceType}/${mockv4}`, createdCommodity) - .reply(200, { ...createdCommodity, id: '123' }) - .persist(); - - nock(props.fhirBaseURL).get(`/${listResourceType}/${listResId}`).reply(200, newList).persist(); - - render( - - - - ); - - const successNoticeMock = jest - .spyOn(notifications, 'sendSuccessNotification') - .mockImplementation(() => undefined); - - // simulate name change - const nameInput = document.querySelector('input#name')!; - userEvent.type(nameInput, 'Dettol'); - - // simulate value selection for type - const typeInput = document.querySelector('input#type')!; - userEvent.click(typeInput); - const deviceTitle = document.querySelector('[title="Device"]')!; - fireEvent.click(deviceTitle); - - // simulate unit measure value - const unitOfMeasureInput = document.querySelector('input#unitOfMeasure')!; - userEvent.click(unitOfMeasureInput); - const bottlesOption = document.querySelector('[title="Bottles"]')!; - fireEvent.click(bottlesOption); - - const submitButton = document.querySelector('#submit-button')!; - userEvent.click(submitButton); - - await waitFor(() => { - expect(successNoticeMock.mock.calls).toEqual([['Commodity updated successfully']]); - }); - expect(nock.isDone()).toBeTruthy(); -}); - -test('#1116 adding new group but list does not exist', async () => { - const history = createMemoryHistory(); - history.push('/add'); - - nock(props.fhirBaseURL) - .put(`/${groupResourceType}/${mockv4}`, createdCommodity) - .reply(200, { ...createdCommodity }) - .persist(); - - nock(props.fhirBaseURL) - .get(`/${listResourceType}/${listResId}`) - .reply(404, { - resourceType: 'OperationOutcome', - text: { - status: 'generated', - div: '

Operation Outcome

\n\t\t\t\n\t\t
ERROR[]
HAPI-2001: Resource List/ea15c35a-8e8c-47ce-8122-c347cefa1b4d is not known
\n\t
', - }, - issue: [ - { - severity: 'error', - code: 'processing', - diagnostics: `HAPI-2001: Resource List/${listResId} is not known`, - }, - ], - }); - - const updatedList = { - ...newList, - entry: [{ item: { reference: `${groupResourceType}/${mockv4}` } }], - }; - - nock(props.fhirBaseURL) - .put(`/${listResourceType}/${listResId}`, newList) - .reply(200, newList) - .persist(); - - nock(props.fhirBaseURL) - .put(`/${listResourceType}/${listResId}`, updatedList) - .reply(200, updatedList) - .persist(); - - render( - - - - ); - - const successNoticeMock = jest - .spyOn(notifications, 'sendSuccessNotification') - .mockImplementation(() => undefined); - - // simulate name change - const nameInput = document.querySelector('input#name')!; - userEvent.type(nameInput, 'Dettol'); - - // simulate value selection for type - const typeInput = document.querySelector('input#type')!; - userEvent.click(typeInput); - const deviceTitle = document.querySelector('[title="Device"]')!; - fireEvent.click(deviceTitle); - - // simulate unit measure value - const unitOfMeasureInput = document.querySelector('input#unitOfMeasure')!; - userEvent.click(unitOfMeasureInput); - const bottlesOption = document.querySelector('[title="Bottles"]')!; - fireEvent.click(bottlesOption); - - const submitButton = document.querySelector('#submit-button')!; - userEvent.click(submitButton); - - await waitFor(() => { - expect(successNoticeMock.mock.calls).toEqual([['Commodity updated successfully']]); - expect(nock.isDone()).toBeTruthy(); - }); - - await flushPromises(); -}); diff --git a/packages/fhir-group-management/src/components/CommodityList/Default/List.tsx b/packages/fhir-group-management/src/components/CommodityList/Default/List.tsx index b78be4bb7..1a1ec0932 100644 --- a/packages/fhir-group-management/src/components/CommodityList/Default/List.tsx +++ b/packages/fhir-group-management/src/components/CommodityList/Default/List.tsx @@ -8,7 +8,7 @@ import { useTranslation } from '../../../mls'; import { BaseListView, BaseListViewProps, - TableData, + DefaultTableData, } from '../../BaseComponents/BaseGroupsListView'; import { TFunction } from '@opensrp/i18n'; import { @@ -81,7 +81,7 @@ export const DefaultCommodityList = (props: GroupListProps) => { const { addParam } = useSearchParams(); const userRole = useUserRole(); - const getItems = (record: TableData): MenuProps['items'] => { + const getItems = (record: DefaultTableData): MenuProps['items'] => { return [ { key: '1', @@ -154,7 +154,7 @@ export const DefaultCommodityList = (props: GroupListProps) => { title: t('Actions'), width: '10%', // eslint-disable-next-line react/display-name - render: (_: unknown, record: TableData) => ( + render: (_: unknown, record: DefaultTableData) => ( <> diff --git a/packages/fhir-group-management/src/components/CommodityList/Eusm/List.tsx b/packages/fhir-group-management/src/components/CommodityList/Eusm/List.tsx index c8259f10c..1ec63db7d 100644 --- a/packages/fhir-group-management/src/components/CommodityList/Eusm/List.tsx +++ b/packages/fhir-group-management/src/components/CommodityList/Eusm/List.tsx @@ -4,22 +4,21 @@ import { MoreOutlined } from '@ant-design/icons'; import { ADD_EDIT_COMMODITY_URL } from '../../../constants'; import { Link } from 'react-router-dom'; import { useTranslation } from '../../../mls'; -import { - BaseListView, - BaseListViewProps, - TableData, -} from '../../BaseComponents/BaseGroupsListView'; +import { BaseListView, BaseListViewProps } from '../../BaseComponents/BaseGroupsListView'; import { TFunction } from '@opensrp/i18n'; import { useSearchParams, viewDetailsQuery } from '@opensrp/react-utils'; import { supplyMgSnomedCode, snomedCodeSystem } from '../../../helpers/utils'; import { RbacCheck, useUserRole } from '@opensrp/rbac'; -import { ViewDetailsWrapper } from './ViewDetails'; +import { ViewDetailsWrapper, parseEusmCommodity } from './ViewDetails'; +import { IGroup } from '@smile-cdr/fhirts/dist/FHIR-R4/interfaces/IGroup'; interface GroupListProps { fhirBaseURL: string; listId: string; // commodities are added to list resource with this id } +type TableData = ReturnType; + /** * Shows the list of all group and there details * @@ -69,8 +68,8 @@ export const EusmCommodityList = (props: GroupListProps) => { }, { title: t('Attractive Item'), - dataIndex: 'attractiveItem' as const, - key: 'attractiveItem' as const, + dataIndex: 'attractive' as const, + key: 'attractive' as const, render: (value: boolean) =>
{value ? t('Yes') : t('No')}
, }, { @@ -98,7 +97,6 @@ export const EusmCommodityList = (props: GroupListProps) => {
- { }, ]; - const baseListViewProps: BaseListViewProps = { + const baseListViewProps: BaseListViewProps = { getColumns: getColumns, createButtonLabel: t('Add Commodity'), createButtonUrl: ADD_EDIT_COMMODITY_URL, fhirBaseURL, + generateTableData: (group: IGroup) => parseEusmCommodity(group), pageTitle: t('Commodity List'), extraQueryFilters: { code: `${snomedCodeSystem}|${supplyMgSnomedCode}`, diff --git a/packages/fhir-group-management/src/components/CommodityList/Eusm/ViewDetails.tsx b/packages/fhir-group-management/src/components/CommodityList/Eusm/ViewDetails.tsx index f74d0d6fe..013983e49 100644 --- a/packages/fhir-group-management/src/components/CommodityList/Eusm/ViewDetails.tsx +++ b/packages/fhir-group-management/src/components/CommodityList/Eusm/ViewDetails.tsx @@ -46,7 +46,7 @@ export const EusmViewDetails = (props: EusmViewDetailsProps) => { const { groupQuery: { data, isLoading, error }, binaryQuery, - } = useGetGroupAndBinary(resourceId, fhirBaseURL); + } = useGetGroupAndBinary(fhirBaseURL, resourceId); if (isLoading) { return ; @@ -75,7 +75,7 @@ export const EusmViewDetails = (props: EusmViewDetailsProps) => { [t('Material Number')]: identifier, [t('Name')]: name, [t('Active')]: active ? t('Active') : t('Disabled'), - [t('Attractive item')]: attractive, + [t('Attractive item')]: attractive ? t('Yes') : t('No'), [t('Is it there')]: availability, [t('Is it in good condition')]: condition, [t('Is it being used appropriately')]: appropriateUsage, diff --git a/packages/fhir-group-management/src/components/CommodityList/Eusm/tests/__snapshots__/list.test.tsx.snap b/packages/fhir-group-management/src/components/CommodityList/Eusm/tests/__snapshots__/list.test.tsx.snap index e2a18376b..fde7a23ee 100644 --- a/packages/fhir-group-management/src/components/CommodityList/Eusm/tests/__snapshots__/list.test.tsx.snap +++ b/packages/fhir-group-management/src/components/CommodityList/Eusm/tests/__snapshots__/list.test.tsx.snap @@ -101,6 +101,31 @@ exports[`renders correctly when listing resources 6`] = ` `; exports[`renders correctly when listing resources 7`] = ` +
+
+ + Attractive item + +
+
+ + No + +
+
+`; + +exports[`renders correctly when listing resources 8`] = `
@@ -125,7 +150,7 @@ exports[`renders correctly when listing resources 7`] = `
`; -exports[`renders correctly when listing resources 8`] = ` +exports[`renders correctly when listing resources 9`] = `
@@ -150,7 +175,7 @@ exports[`renders correctly when listing resources 8`] = `
`; -exports[`renders correctly when listing resources 9`] = ` +exports[`renders correctly when listing resources 10`] = `
@@ -175,7 +200,7 @@ exports[`renders correctly when listing resources 9`] = `
`; -exports[`renders correctly when listing resources 10`] = ` +exports[`renders correctly when listing resources 11`] = `
@@ -269,10 +294,6 @@ exports[`renders correctly when listing resources: table row 1 page 1 6`] = ` class="ant-divider css-dev-only-do-not-override-k7429z ant-divider-vertical" role="separator" /> -