diff --git a/.env.sample b/.env.sample index 7c1644d53f..52dd143c94 100644 --- a/.env.sample +++ b/.env.sample @@ -12,6 +12,7 @@ REACT_APP_ENABLE_USERS=true REACT_APP_ENABLE_ASSIGN=true REACT_APP_ENABLE_ABOUT=true REACT_APP_ENABLE_TEAMS=true +REACT_APP_ENABLE_MDA_POINT=true REACT_APP_ENABLE_PRACTITIONERS=true REACT_APP_DISABLE_LOGIN_PROTECTION=false REACT_APP_SUPERSET_API_BASE=https://superset.reveal-stage.smartregister.org/ # notice the ending / diff --git a/package.json b/package.json index 8f6d1400b0..aefadc037a 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@onaio/formik-effect": "^0.0.1", "@onaio/gatekeeper": "^0.0.18", "@onaio/google-analytics": "^0.0.2", - "@onaio/list-view": "^0.0.1", + "@onaio/list-view": "^0.0.3", "@onaio/redux-reducer-registry": "^0.0.9", "@onaio/session-reducer": "^0.0.10", "@onaio/superset-connector": "^0.0.13", diff --git a/src/App/App.tsx b/src/App/App.tsx index a5bbaadb2b..ee4049a708 100644 --- a/src/App/App.tsx +++ b/src/App/App.tsx @@ -40,6 +40,7 @@ import { BACKEND_CALLBACK_PATH, BACKEND_CALLBACK_URL, BACKEND_LOGIN_URL, + CLIENTS_LIST_URL, CREATE_ORGANIZATION_URL, CREATE_PRACTITIONER_URL, DRAFT_IRS_PLAN_URL, @@ -83,6 +84,7 @@ import ConnectedIRSAssignmentPlansList from '../containers/pages/IRS/assignments import ConnectedJurisdictionReport from '../containers/pages/IRS/JurisdictionsReport'; import ConnectedIRSReportingMap from '../containers/pages/IRS/Map'; import ConnectedIRSPlansList from '../containers/pages/IRS/plans'; +import ConnectedClientListView from '../containers/pages/MDAPoint/ClientListView'; import ConnectedMdaPointJurisdictionReport from '../containers/pages/MDAPoint/jurisdictionsReport'; import ConnectedMDAPointPlansList from '../containers/pages/MDAPoint/plans'; import ConnectedAssignPractitioner from '../containers/pages/OrganizationViews/AssignPractitioners'; @@ -365,6 +367,14 @@ const App = (props: AppProps) => { path={`${SINGLE_ORGANIZATION_URL}/:id`} component={ConnectedSingleOrgView} /> + {/* Student listing page */} + {/* Practitioner listing page */} { + const { initialValues, downloadFile, eventValue } = props; + return ( +
+ + +

+ {ENABLE_MDA_POINT ? EXPORT_STUDENT_LIST : EXPORT_CLIENT_LIST} +

+ {/* Download Form goes here */} + {EXPORT_BASED_ON_GEOGRAPHICAL_REGION} + { + // tslint:disable-next-line: no-floating-promises + downloadFile( + OPENSRP_TEMPLATE_ENDPOINT, + `${values.jurisdictions.name}.csv`, + OPENSRP_UPLOAD_ENDPOINT, + { + event_name: eventValue, + location_id: values.jurisdictions.id, + } + ); + }} + validationSchema={JurisdictionSchema} + > + {({ errors }) => ( +
+ + +   +
+ +
+ + {errors.jurisdictions && ( + + {LOCATION_ERROR_MESSAGE} + + )} + { + + } +
+ +
+ )} +
+ +
+
+ ); +}; +const defaultProps: ExportFormProps = { + downloadFile: handleDownload, + eventValue: OPENSRP_EVENT_PARAM_VALUE, + initialValues: defaultInitialValues, +}; +ExportForm.defaultProps = defaultProps; +export default ExportForm; diff --git a/src/components/forms/ExportForm/tests/__snapshots__/index.test.tsx.snap b/src/components/forms/ExportForm/tests/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..434f3338c6 --- /dev/null +++ b/src/components/forms/ExportForm/tests/__snapshots__/index.test.tsx.snap @@ -0,0 +1,62 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/forms/ExportForm renders correctly: jurisdiction-select form label 1`] = ` + +`; + +exports[`components/forms/ExportForm renders correctly: submit button 1`] = `null`; + +exports[`components/forms/ExportForm renders jurisdictions fields correctly: jurisdictions.id field 1`] = `null`; + +exports[`components/forms/ExportForm renders jurisdictions fields correctly: jurisdictions.id label 1`] = ` + +`; + +exports[`components/forms/ExportForm renders jurisdictions fields correctly: jurisdictions.name field 1`] = ` + +`; diff --git a/src/components/forms/ExportForm/tests/index.test.tsx b/src/components/forms/ExportForm/tests/index.test.tsx new file mode 100644 index 0000000000..7b39579eba --- /dev/null +++ b/src/components/forms/ExportForm/tests/index.test.tsx @@ -0,0 +1,120 @@ +import { getOpenSRPUserInfo } from '@onaio/gatekeeper'; +import { authenticateUser } from '@onaio/session-reducer'; +import { mount, shallow } from 'enzyme'; +import toJson from 'enzyme-to-json'; +import React from 'react'; +import ExportForm from '..'; +import { OpenSRPAPIResponse } from '../../../../services/opensrp/tests/fixtures/session'; +import store from '../../../../store'; +import * as fixtures from '../../PlanForm/tests/fixtures'; + +/* tslint:disable-next-line no-var-requires */ +const fetch = require('jest-fetch-mock'); + +jest.mock('../../../../configs/env'); + +describe('components/forms/ExportForm', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders without crashing', () => { + shallow(); + }); + + it('renders correctly', () => { + fetch.mockResponseOnce(fixtures.jurisdictionLevel0JSON); + const wrapper = mount(); + + expect(toJson(wrapper.find('Label'))).toMatchSnapshot('jurisdiction-select form label'); + expect(toJson(wrapper.find('#ExportForm-submit-button button'))).toMatchSnapshot( + 'submit button' + ); + expect(wrapper.find('#jurisdictions-select-container')).toEqual({}); + expect(wrapper.find('#jurisdictions-display-container').length).toEqual(0); + wrapper.unmount(); + }); + it('renders jurisdictions fields correctly', () => { + fetch.mockResponseOnce(fixtures.jurisdictionLevel0JSON); + const wrapper = mount(); + + expect(toJson(wrapper.find({ for: `jurisdictions-1-id` }))).toMatchSnapshot( + `jurisdictions.id label` + ); + expect(toJson(wrapper.find(`#jurisdictions-1-id input`))).toMatchSnapshot( + `jurisdictions.id field` + ); + expect(wrapper.find({ for: `jurisdictions-1-name` }).length).toEqual(0); + expect(toJson(wrapper.find(`#jurisdictions-name input`))).toMatchSnapshot( + `jurisdictions.name field` + ); + // ensure there is only one options so far + expect(wrapper.find(`#jurisdictions-1-id input`).length).toEqual(0); + // there is no button to remove jurisdictions + expect(wrapper.find(`.removeJurisdiction`).length).toEqual(0); + // there is no button to add more jurisdictions + expect(wrapper.find(`.addJurisdiction`).length).toEqual(0); + wrapper.unmount(); + }); + it('Form validation works', async () => { + const wrapper = mount(); + + // no errors are initially shown + expect( + (wrapper + .find('FieldInner') + .first() + .props() as any).formik.errors + ).toEqual({}); + + wrapper.find('form').simulate('submit'); + + await new Promise(resolve => setImmediate(resolve)); + wrapper.update(); + + // we now have some errors + expect( + (wrapper + .find('FieldInner') + .first() + .props() as any).formik.errors + ).toEqual({ + jurisdictions: { + id: 'Required', + }, + }); + expect(wrapper.find('small.jurisdictions-error').text()).toEqual('Please select location'); + }); + it('Export Form submission works', async () => { + // ensure that we are logged in so that we can get the OpenSRP token from Redux + const { authenticated, user, extraData } = getOpenSRPUserInfo(OpenSRPAPIResponse); + store.dispatch(authenticateUser(authenticated, user, extraData)); + + fetch.mockResponseOnce(JSON.stringify({}), { status: 201 }); + + const wrapper = mount(); + + // set jurisdiction id + (wrapper + .find('FieldInner') + .first() + .props() as any).formik.setFieldValue('jurisdictions.id', '1337'); + // set jurisdiction name + wrapper + .find('input[name="jurisdictions.name"]') + .simulate('change', { target: { name: 'jurisdictions.name', value: 'Onyx' } }); + + wrapper.find('form').simulate('submit'); + await new Promise(resolve => setImmediate(resolve)); + wrapper.update(); + + // no errors are initially shown + expect( + (wrapper + .find('FieldInner') + .first() + .props() as any).formik.errors + ).toEqual({}); + // Todo test the api get once form is submitted + }); +}); diff --git a/src/components/forms/JurisdictionSelect/index.tsx b/src/components/forms/JurisdictionSelect/index.tsx index 83f7c3b9b7..460c169b33 100644 --- a/src/components/forms/JurisdictionSelect/index.tsx +++ b/src/components/forms/JurisdictionSelect/index.tsx @@ -1,5 +1,5 @@ import { Dictionary } from '@onaio/utils/dist/types/types'; -import { FieldProps } from 'formik'; +import { FieldConfig, FieldProps, FormikProps } from 'formik'; import React, { useState } from 'react'; import AsyncSelect, { Props as AsyncSelectProps } from 'react-select/async'; import { SELECT } from '../../../configs/lang'; @@ -24,7 +24,7 @@ interface JurisdictionOption { } /** react-select Option */ -interface SelectOption { +export interface SelectOption { label: string; value: string; } @@ -35,47 +35,371 @@ export interface JurisdictionSelectProps extends AsyncSelectPr cascadingSelect: boolean /** should we have a cascading select or not */; params: URLParams /** extra URL params to send to OpenSRP */; serviceClass: typeof OpenSRPService /** the OpenSRP service */; - promiseOptions: any; + promiseOptions: ( + service: OpenSRPService, + parameters: Dictionary, + hierarchy: SelectOption[], + jurisdictionStatus: boolean, + setFinalLocation: (value: boolean) => void, + setJurisdictionParam: (value: boolean) => void, + handleLoadOptionsPayload: ( + jurisdictionApiPayload: JurisdictionOption[], + jurisdictionStatus: boolean, + setFinalLocation: (value: boolean) => void, + hierarchy: SelectOption[], + setJurisdictionParam: (value: boolean) => void, + resolve: any + ) => JurisdictionOption[] // Handles jurisdiction payload from location endpoint + ) => Promise; // Todo: Add a more specific type + handleChange: ( + params: Dictionary, + isJurisdiction: boolean, + service: OpenSRPService, + option: SelectOption, + hierarchy: SelectOption[], + cascadingSelect: boolean, + lowestLocation: boolean, + loadLocations: boolean, + setSelectShouldMenuOpen: (value: boolean) => void, + setSelectParentId: (value: string) => void, + setSelectHierarchy: (value: SelectOption[]) => void, + setCloseMenuOnSelect: (value: boolean) => void, + setSelectIsJurisdiction: (value: boolean) => void, + setSelectLowestLocation: (value: boolean) => void, + labelFieldName: string, + form: FormikProps, + field: FieldConfig, + handleChangeWithOptions: ( + optionVal: SelectOption, + newParamsToUse: Dictionary, + service: OpenSRPService, + hierarchy: SelectOption[], + cascadingSelect: boolean, + lowestLocation: boolean, + loadLocations: boolean, + setSelectShouldMenuOpen: (value: boolean) => void, + setSelectParentId: (value: string) => void, + setSelectHierarchy: (value: SelectOption[]) => void, + setSelectCloseMenuOnSelect: (value: boolean) => void, + setSelectIsJurisdiction: (value: boolean) => void, + labelFieldName: string, + form: FormikProps, + field: FieldConfig + ) => void, // Handles select changes with options selected + handleChangeWithoutOptions: ( + setSelectShouldMenuOpen: (value: boolean) => void, + setSelectParentId: (value: string) => void, + setSelectHierarchy: (value: SelectOption[]) => void, + setSelectCloseMenuOnSelect: (value: boolean) => void, + setSelectIsJurisdiction: (value: boolean) => void, + setSelectLowestLocation: (value: boolean) => void, + form: FormikProps, + field: FieldConfig + ) => void // Handles select changes with no options selected + ) => void; // Async select onchange callback } /** - * Loads options from opensrp - * @param service Opensrp service class + * + * @param jurisdictionSelectApiPayload payload from location api + * @param jurisdictionStatus flag that determines what is to be loaded location or jurisdiction + * @param setSelectFinalLocation sets if we are at location level + * @param hierarchy Drill down hierarchy list + * @param setSelectJurisdictionParam sets jurisdictionparam value on state + * @param resolve Promise resolve + */ +export const handleLoadOptionsPayload = ( + jurisdictionSelectApiPayload: JurisdictionOption[], + jurisdictionStatus: boolean, + setSelectFinalLocation: (value: boolean) => void, + hierarchy: SelectOption[], + setSelectJurisdictionParam: (value: boolean) => void, + resolve: any // unfortunately we have to set the type of option as any (for now) +) => { + /** Check if payload has no name property then use id instead + * If there is no location return no options + */ + if (!jurisdictionStatus) { + setSelectFinalLocation(true); + if (jurisdictionSelectApiPayload.length >= 1 && !jurisdictionStatus) { + const locationOptions = jurisdictionSelectApiPayload.map(item => { + return { + label: item.properties.name ? item.properties.name : item.id, + value: item.id, + }; + }); + if (hierarchy.length > 0) { + const labels = hierarchy.map(j => j.label).join(' > '); + setSelectJurisdictionParam(true); + return resolve([ + { + label: labels, + options: locationOptions, + }, + ]); + } + } else if (!jurisdictionSelectApiPayload.length) { + setSelectJurisdictionParam(true); + return resolve([]); + } + } + const options = jurisdictionSelectApiPayload.map(item => { + return { label: item.properties.name, value: item.id }; + }); + if (hierarchy.length > 0) { + const labels = hierarchy.map(j => j.label).join(' > '); + return resolve([ + { + label: labels, + options, + }, + ]); + } + resolve(options); +}; +/** + * Loads options from OpenSRP + * @param service OpenSRP service class * @param paramsToUse params to be used when making the call * @param hierarchy async select order */ export const promiseOptions = ( service: OpenSRPService, paramsToUse: Dictionary, - hierarchy: SelectOption[] + hierarchy: SelectOption[], + jurisdictionStatus: boolean, + setFinalLocation: (value: boolean) => void, + setJurisdictionParam: (value: boolean) => void, + handleSelectLoadOptionsPayload: ( + jurisdictionApiPayload: JurisdictionOption[], + jurisdictionStatus: boolean, + setFinalLocation: (value: boolean) => void, + hierarchy: SelectOption[], + setJurisdictionParam: (value: boolean) => void, + resolve: any + ) => JurisdictionOption[] ) => // tslint:disable-next-line:no-inferred-empty-object-type - new Promise((resolve, reject) => + new Promise((resolve, reject) => { service - .list(paramsToUse) - .then((e: JurisdictionOption[]) => { - const options = e.map(item => { - return { label: item.properties.name, value: item.id }; - }); - if (hierarchy.length > 0) { - const labels = hierarchy.map(j => j.label).join(' > '); - resolve([ - { - label: labels, - options, - }, - ]); - } - resolve(options); - }) - .catch(error => { - reject(`Opensrp service Error ${error}`); + .list({ ...paramsToUse, is_jurisdiction: jurisdictionStatus }) + .then((jurisdictionApiPayload: JurisdictionOption[]) => { + handleSelectLoadOptionsPayload( + jurisdictionApiPayload, + jurisdictionStatus, + setFinalLocation, + hierarchy, + setJurisdictionParam, + resolve + ); }) - ); + .catch((error: Error) => { + reject(`OpenSRP service Error ${error}`); + }); + }); + +/** + * Handles async select onchange with options + * @param optionVal Selected options + * @param newParamsToUse Params to use for the next api call + * @param service OpenSRPService + * @param hierarchy Drill down hierarchy list + * @param cascadingSelect Toggles async select cascade option true/false + * @param lowestLocation props that informs if we are at the lowest level + * @param loadLocations Ownprop that allows drilling down to location level + * @param setSelectShouldMenuOpen Controls opening asyncselect menu + * @param setSelectParentId Sets parent id to state + * @param setSelectHierarchy Sets select Hierarchy to state + * @param setSelectCloseMenuOnSelect Controls closing asyncselect menu + * @param setSelectIsJurisdiction Sets isJurisdiction value to state + * @param labelFieldName async select label field + * @param form Formik form Object + * @param field Formik field config + */ +export const handleChangeWithOptions = ( + optionVal: SelectOption, + newParamsToUse: Dictionary, + service: OpenSRPService, + hierarchy: SelectOption[], + cascadingSelect: boolean, + lowestLocation: boolean, + loadLocations: boolean, + setSelectShouldMenuOpen: (value: boolean) => void, + setSelectParentId: (value: string) => void, + setSelectHierarchy: (value: SelectOption[]) => void, + setSelectCloseMenuOnSelect: (value: boolean) => void, + setSelectIsJurisdiction: (value: boolean) => void, + labelFieldName: string, + form: FormikProps, + field: FieldConfig +) => { + service + .list(newParamsToUse) + .then((e: JurisdictionOption[]) => { + setSelectShouldMenuOpen(true); + if (e.length > 0 && cascadingSelect === true) { + setSelectParentId(optionVal.value); + + hierarchy.push(optionVal); + setSelectHierarchy(hierarchy); + + setSelectCloseMenuOnSelect(false); + } else if (!e.length && loadLocations && !lowestLocation) { + setSelectIsJurisdiction(false); + setSelectParentId(optionVal.value); + hierarchy.push(optionVal); + setSelectHierarchy(hierarchy); + setSelectCloseMenuOnSelect(false); + } else { + // set the Formik field value + if (form && field) { + form.setFieldValue(field.name, optionVal.value); + form.setFieldTouched(field.name, true); + if (labelFieldName) { + form.setFieldValue(labelFieldName, optionVal.label); /** dirty hack */ + form.setFieldTouched(labelFieldName, true); /** dirty hack */ + } + } + setSelectCloseMenuOnSelect(true); + setSelectShouldMenuOpen(false); + } + }) + .catch((error: Error) => displayError(error)); +}; +/** + * Handles async select onchange with no options + * @param setSelectShouldMenuOpen Controls opening asyncselect menu + * @param setSelectParentId Sets parent id to state + * @param setSelectHierarchy sets drill down hierarchy to state + * @param setSelectCloseMenuOnSelect Controls closing asyncselect menu + * @param setSelectIsJurisdiction Sets isJurisdiction value to state + * @param setSelectLowestLocation Sets lowest location value to state + * @param form Formik form Object + * @param field Formik field config + */ +export const handleChangeWithoutOptions = ( + setSelectShouldMenuOpen: (value: boolean) => void, + setSelectParentId: (value: string) => void, + setSelectHierarchy: (value: SelectOption[]) => void, + setSelectCloseMenuOnSelect: (value: boolean) => void, + setSelectIsJurisdiction: (value: boolean) => void, + setSelectLowestLocation: (value: boolean) => void, + form: FormikProps, + field: FieldConfig +) => { + // most probably the select element was reset, so we reset the state vars + setSelectParentId(''); + setSelectHierarchy([]); + setSelectShouldMenuOpen(false); + setSelectCloseMenuOnSelect(false); + setSelectLowestLocation(false); + setSelectIsJurisdiction(true); + // set the Formik field value + if (form && field) { + form.setFieldValue(field.name, ''); + } +}; +/** + * onChange callback + * unfortunately we have to set the type of option as any (for now) + */ +export const handleChange = ( + params: Dictionary, + isJurisdiction: boolean, + service: OpenSRPService, + option: SelectOption, + hierarchy: SelectOption[], + cascadingSelect: boolean, + lowestLocation: boolean, + loadLocations: boolean, + setSelectShouldMenuOpen: (value: boolean) => void, + setSelectParentId: (value: string) => void, + setSelectHierarchy: (value: SelectOption[]) => void, + setSelectCloseMenuOnSelect: (value: boolean) => void, + setSelectIsJurisdiction: (value: boolean) => void, + setSelectLowestLocation: (value: boolean) => void, + labelFieldName: string, + form: FormikProps, + field: FieldConfig, + handleSelectChangeWithOptions: ( + OptionVal: SelectOption, + newParamsToUse: Dictionary, + service: OpenSRPService, + hierarchy: SelectOption[], + cascadingSelect: boolean, + lowestLocation: boolean, + loadLocations: boolean, + setSelectShouldMenuOpen: (value: boolean) => void, + setSelectParentId: (value: string) => void, + setSelectHierarchy: (value: SelectOption[]) => void, + setSelectCloseMenuOnSelect: (value: boolean) => void, + setSelectIsJurisdiction: (value: boolean) => void, + labelFieldName: string, + form: FormikProps, + field: FieldConfig + ) => void, + handleSelectChangeWithoutOptions: ( + setSelectShouldMenuOpen: (value: boolean) => void, + setSelectParentId: (value: string) => void, + setSelectHierarchy: (value: SelectOption[]) => void, + setSelectCloseMenuOnSelect: (value: boolean) => void, + setSelectIsJurisdiction: (value: boolean) => void, + setSelectLowestLocation: (value: boolean) => void, + form: FormikProps, + field: FieldConfig + ) => void +) => { + const optionVal = option as { label: string; value: string }; + + if (optionVal && optionVal.value) { + // we are going to check if the current option has children + // and if it does, we set it as the new parentId + + const newParamsToUse = { + ...params, + is_jurisdiction: isJurisdiction, + properties_filter: getFilterParams({ parentId: optionVal.value }), + }; + + handleSelectChangeWithOptions( + optionVal, + newParamsToUse, + service, + hierarchy, + cascadingSelect, + lowestLocation, + loadLocations, + setSelectShouldMenuOpen, + setSelectParentId, + setSelectHierarchy, + setSelectCloseMenuOnSelect, + setSelectIsJurisdiction, + labelFieldName, + form, + field + ); + } else { + // most probably the select element was reset, so we reset the state vars + handleSelectChangeWithoutOptions( + setSelectShouldMenuOpen, + setSelectParentId, + setSelectHierarchy, + setSelectCloseMenuOnSelect, + setSelectIsJurisdiction, + setSelectLowestLocation, + form, + field + ); + } +}; /** default props for JurisdictionSelect */ export const defaultProps: Partial = { apiEndpoint: 'location/findByProperties', cascadingSelect: true, + handleChange, + handleChangeWithOptions, + handleChangeWithoutOptions, + handleLoadOptionsPayload, params: { is_jurisdiction: true, return_geometry: false, @@ -90,12 +414,22 @@ export const defaultProps: Partial = { * This is simply a Higher Order Component that wraps around AsyncSelect */ const JurisdictionSelect = (props: JurisdictionSelectProps & FieldProps) => { - const { apiEndpoint, cascadingSelect, field, form, labelFieldName, params, serviceClass } = props; - + const { + loadLocations, + apiEndpoint, + cascadingSelect, + field, + form, + labelFieldName, + params, + serviceClass, + } = props; const [parentId, setParentId] = useState(''); const [hierarchy, setHierarchy] = useState([]); const [shouldMenuOpen, setShouldMenuOpen] = useState(false); const [closeMenuOnSelect, setCloseMenuOnSelect] = useState(false); + const [isJurisdiction, setIsJurisdiction] = useState(true); + const [lowestLocation, setLowestLocation] = useState(false); const service = new serviceClass(apiEndpoint); const propertiesToFilter = { @@ -104,67 +438,48 @@ const JurisdictionSelect = (props: JurisdictionSelectProps & FieldProps) => { }; const paramsToUse = { ...params, + is_jurisdiction: isJurisdiction, ...(Object.keys(propertiesToFilter).length > 0 && { properties_filter: getFilterParams(propertiesToFilter), }), }; const wrapperPromiseOptions: () => Promise<() => {}> = async () => { - return await props.promiseOptions(service, paramsToUse, hierarchy); + return await props.promiseOptions( + service, + paramsToUse, + hierarchy, + isJurisdiction, + setLowestLocation, + setIsJurisdiction, + props.handleLoadOptionsPayload + ); }; /** * onChange callback * unfortunately we have to set the type of option as any (for now) */ - const handleChange = () => (option: any) => { - const optionVal = option as { label: string; value: string }; - if (optionVal && optionVal.value) { - // we are going to check if the current option has children - // and if it does, we set it as the new parentId - - const newParamsToUse = { - ...params, - properties_filter: getFilterParams({ parentId: optionVal.value }), - }; - service - .list(newParamsToUse) - .then(e => { - setShouldMenuOpen(true); - if (e.length > 0 && cascadingSelect === true) { - setParentId(optionVal.value); - - hierarchy.push(optionVal); - setHierarchy(hierarchy); - - setCloseMenuOnSelect(false); - } else { - // set the Formik field value - if (form && field) { - form.setFieldValue(field.name, optionVal.value); - form.setFieldTouched(field.name, true); - if (labelFieldName) { - form.setFieldValue(labelFieldName, optionVal.label); /** dirty hack */ - form.setFieldTouched(labelFieldName, true); /** dirty hack */ - } - } - - setCloseMenuOnSelect(true); - setShouldMenuOpen(false); - } - }) - .catch(error => displayError(error)); - } else { - // most probably the select element was reset, so we reset the state vars - setParentId(''); - setHierarchy([]); - setShouldMenuOpen(false); - setCloseMenuOnSelect(false); - // set the Formik field value - if (form && field) { - form.setFieldValue(field.name, ''); - } - } - }; - + const wrapperHandleChange = () => (option: any) => + props.handleChange( + params, + isJurisdiction, + service, + option, + hierarchy, + cascadingSelect, + lowestLocation, + loadLocations, + setShouldMenuOpen, + setParentId, + setHierarchy, + setCloseMenuOnSelect, + setIsJurisdiction, + setLowestLocation, + labelFieldName, + form, + field, + props.handleChangeWithOptions, + props.handleChangeWithoutOptions + ); return ( { placeholder={props.placeholder ? props.placeholder : SELECT} noOptionsMessage={reactSelectNoOptionsText} aria-label={props['aria-label'] ? props['aria-label'] : SELECT} - onChange={handleChange()} + onChange={wrapperHandleChange()} defaultOptions={true} loadOptions={wrapperPromiseOptions} isClearable={true} diff --git a/src/components/forms/JurisdictionSelect/tests/__snapshots__/index.test.tsx.snap b/src/components/forms/JurisdictionSelect/tests/__snapshots__/index.test.tsx.snap index 45c351b1ca..81b4ce302a 100644 --- a/src/components/forms/JurisdictionSelect/tests/__snapshots__/index.test.tsx.snap +++ b/src/components/forms/JurisdictionSelect/tests/__snapshots__/index.test.tsx.snap @@ -1,6 +1,31 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`components/forms/JurisdictionSelect handleChange works correctly: Jurisdiction Menu 1`] = ` +exports[`components/forms/JurisdictionSelect drills down to locationlevel: Jurisdiction Menu 1`] = ` +
+
+
+ Siavonga +
+
+ Sinda +
+
+
+`; + +exports[`components/forms/JurisdictionSelect handleChange works with options correctly: Jurisdiction Menu 1`] = `
@@ -25,6 +50,22 @@ exports[`components/forms/JurisdictionSelect handleChange works correctly: Juris
`; +exports[`components/forms/JurisdictionSelect handleChange works without options correctly: Jurisdiction Menu No Options 1`] = ` +
+
+
+ No Options +
+
+
+`; + exports[`components/forms/JurisdictionSelect renders select options correctly: Jurisdiction Select Props 1`] = ` Object { "apiEndpoint": "location/findByProperties", @@ -44,6 +85,10 @@ Object { "defaultProps": Object { "apiEndpoint": "location/findByProperties", "cascadingSelect": true, + "handleChange": [Function], + "handleChangeWithOptions": [Function], + "handleChangeWithoutOptions": [Function], + "handleLoadOptionsPayload": [Function], "params": Object { "is_jurisdiction": true, "return_geometry": false, @@ -56,6 +101,10 @@ Object { "formatGroupLabel": [Function], "getOptionLabel": [Function], "getOptionValue": [Function], + "handleChange": [Function], + "handleChangeWithOptions": [Function], + "handleChangeWithoutOptions": [Function], + "handleLoadOptionsPayload": [Function], "inputValue": "", "isClearable": true, "isDisabled": false, @@ -111,6 +160,10 @@ Object { "defaultProps": Object { "apiEndpoint": "location/findByProperties", "cascadingSelect": true, + "handleChange": [Function], + "handleChangeWithOptions": [Function], + "handleChangeWithoutOptions": [Function], + "handleLoadOptionsPayload": [Function], "params": Object { "is_jurisdiction": true, "return_geometry": false, @@ -119,6 +172,10 @@ Object { "serviceClass": [Function], }, "filterOption": null, + "handleChange": [Function], + "handleChangeWithOptions": [Function], + "handleChangeWithoutOptions": [Function], + "handleLoadOptionsPayload": [Function], "isClearable": true, "isLoading": false, "loadOptions": [Function], diff --git a/src/components/forms/JurisdictionSelect/tests/index.test.tsx b/src/components/forms/JurisdictionSelect/tests/index.test.tsx index df2941c8cd..04da2c97a0 100644 --- a/src/components/forms/JurisdictionSelect/tests/index.test.tsx +++ b/src/components/forms/JurisdictionSelect/tests/index.test.tsx @@ -3,7 +3,11 @@ import { mount, shallow } from 'enzyme'; import flushPromises from 'flush-promises'; import React from 'react'; import JurisdictionSelect from '..'; -import defaultProps from '..'; +import defaultProps, { + handleChange as handleChangeHandler, + handleChangeWithOptions as handleChangeWithOptionsHandler, + handleChangeWithoutOptions as handleChangeWithoutOptionsHandler, +} from '..'; import { OpenSRPService } from '../../../../services/opensrp'; jest.mock('../../../../configs/env'); @@ -37,12 +41,21 @@ describe('components/forms/JurisdictionSelect', () => { }, ]; - const promiseOptions = jest.fn().mockImplementation(async () => { + const handleChange = jest.fn(handleChangeHandler); + const handleChangeWithOptions = jest.fn(handleChangeWithOptionsHandler); + const handleChangeWithoutOptions = jest.fn(handleChangeWithoutOptionsHandler); + const handleLoadOptionsPayload = jest.fn().mockImplementation(() => { return options; }); + /** Not certain this is the best way to mock promiseOptions and handleLoadOptionsPayLoad */ + const promiseOptions = jest.fn(handleLoadOptionsPayload); const props = { apiEndpoint: 'location/findByProperties', cascadingSelect: true, + handleChange, + handleChangeWithOptions, + handleChangeWithoutOptions, + handleLoadOptionsPayload, params: { is_jurisdiction: true, return_geometry: false, @@ -63,7 +76,7 @@ describe('components/forms/JurisdictionSelect', () => { expect((wrapper.find('Select').props() as any).options).toEqual(options); expect(promiseOptions).toHaveBeenCalledTimes(1); }); - it('handleChange works correctly', async () => { + it('handleChange works with options correctly', async () => { const options = [ { label: 'Siavonga', @@ -75,7 +88,10 @@ describe('components/forms/JurisdictionSelect', () => { }, ]; - const promiseOptions = jest.fn().mockImplementation(async () => { + const handleChange = jest.fn(handleChangeHandler); + const handleChangeWithOptions = jest.fn(handleChangeWithOptionsHandler); + const handleChangeWithoutOptions = jest.fn(handleChangeWithoutOptionsHandler); + const handleLoadOptionsPayload = jest.fn().mockImplementation(() => { return options; }); const mockedOpenSRPservice = jest.fn().mockImplementation(() => { @@ -85,9 +101,15 @@ describe('components/forms/JurisdictionSelect', () => { }, }; }); + /** Not certain this is the best way to mock promiseOptions and handleLoadOptionsPayLoad */ + const promiseOptions = jest.fn(handleLoadOptionsPayload); const props = { apiEndpoint: 'location/findByProperties', cascadingSelect: true, + handleChange, + handleChangeWithOptions, + handleChangeWithoutOptions, + handleLoadOptionsPayload, params: { is_jurisdiction: true, return_geometry: false, @@ -108,6 +130,8 @@ describe('components/forms/JurisdictionSelect', () => { fireEvent.keyDown(inputValue, { key: 'ArrowDown', code: 40 }); expect(container.querySelector('.jurisdiction__menu')).toMatchSnapshot('Jurisdiction Menu'); fireEvent.click(getByText('Sinda')); + expect(handleChange).toBeCalledTimes(1); + expect(handleChangeWithOptions).toBeCalledTimes(1); } expect( (container.querySelector('.jurisdiction__single-value') as HTMLElement).innerHTML @@ -116,11 +140,60 @@ describe('components/forms/JurisdictionSelect', () => { fireEvent.focus(inputValue as any); fireEvent.keyDown(inputValue as any, { key: 'ArrowDown', code: 40 }); fireEvent.click(getByText('Siavonga')); + expect(handleChange).toBeCalled(); + expect(handleChangeWithOptions).toBeCalled(); expect( (container.querySelector('.jurisdiction__single-value') as HTMLElement).innerHTML ).toEqual('Siavonga'); expect(mockedOpenSRPservice).toBeCalledTimes(1); }); + it('handleChange works without options correctly', async () => { + const handleChange = jest.fn(handleChangeHandler); + const handleChangeWithOptions = jest.fn(handleChangeWithOptionsHandler); + const handleChangeWithoutOptions = jest.fn(handleChangeWithoutOptionsHandler); + const handleLoadOptionsPayload = jest.fn().mockImplementation(() => { + return []; + }); + const mockedOpenSRPservice = jest.fn().mockImplementation(() => { + return { + list: () => { + return Promise.resolve([]); + }, + }; + }); + /** Not certain this is the best way to mock promiseOptions and handleLoadOptionsPayLoad */ + const promiseOptions = jest.fn(handleLoadOptionsPayload); + const props = { + apiEndpoint: 'location/findByProperties', + cascadingSelect: true, + handleChange, + handleChangeWithOptions, + handleChangeWithoutOptions, + handleLoadOptionsPayload, + params: { + is_jurisdiction: true, + return_geometry: false, + }, + promiseOptions, + serviceClass: mockedOpenSRPservice, + }; + const { container, getByText } = render(); + await flushPromises(); + expect(mockedOpenSRPservice).toBeCalledTimes(1); + expect(promiseOptions).toHaveBeenCalledTimes(1); + const placeholder = getByText('Select'); + expect(placeholder).toBeTruthy(); + const inputValue = container.querySelector('input'); + expect(inputValue).not.toBeNull(); + fireEvent.focus(inputValue as any); + fireEvent.keyDown(inputValue as any, { key: 'ArrowDown', code: 40 }); + expect(container.querySelector('.jurisdiction__menu')).toMatchSnapshot( + 'Jurisdiction Menu No Options' + ); + expect(container.querySelector('.jurisdiction__single-value') as HTMLElement).toEqual(null); + expect(mockedOpenSRPservice).toBeCalledTimes(1); + }); + it('renders select options correctly', () => { const wrapper = mount(); @@ -134,4 +207,74 @@ describe('components/forms/JurisdictionSelect', () => { ).toMatchSnapshot('Jurisdiction Select Props'); expect(wrapper.find('.jurisdiction__indicator').length).toBe(4); }); + it('drills down to locationlevel', async () => { + const options = [ + { + label: 'Siavonga', + value: '3953', + }, + { + label: 'Sinda', + value: '2941', + }, + ]; + + const handleChange = jest.fn(handleChangeHandler); + const handleChangeWithOptions = jest.fn(handleChangeWithOptionsHandler); + const handleChangeWithoutOptions = jest.fn(handleChangeWithoutOptionsHandler); + const handleLoadOptionsPayload = jest.fn().mockImplementation(() => { + return options; + }); + const mockedOpenSRPservice = jest.fn().mockImplementation(() => { + return { + list: () => { + return Promise.resolve([]); + }, + }; + }); + /** Not certain this is the best way to mock promiseOptions and handleLoadOptionsPayLoad */ + const promiseOptions = jest.fn(handleLoadOptionsPayload); + const props = { + apiEndpoint: 'location/findByProperties', + cascadingSelect: true, + handleChange, + handleChangeWithOptions, + handleChangeWithoutOptions, + handleLoadOptionsPayload, + loadLocations: true, + params: { + is_jurisdiction: true, + return_geometry: false, + }, + promiseOptions, + serviceClass: mockedOpenSRPservice, + }; + const { container, getByText } = render(); + await flushPromises(); + expect(mockedOpenSRPservice).toBeCalledTimes(1); + expect(promiseOptions).toHaveBeenCalledTimes(1); + const placeholder = getByText('Select'); + expect(placeholder).toBeTruthy(); + const inputValue = container.querySelector('input'); + if (inputValue) { + fireEvent.focus(inputValue); + fireEvent.keyDown(inputValue, { key: 'ArrowDown', code: 40 }); + expect(container.querySelector('.jurisdiction__menu')).toMatchSnapshot('Jurisdiction Menu'); + fireEvent.click(getByText('Sinda')); + await flushPromises(); + } + // At initial load JurisdictionStatus should be true + expect(promiseOptions.mock.calls[0][3]).toEqual(true); + /** + * Since we are returning [] on mockedopensrpservice jurisdiction status should be false + * This sets is_jurisdiction param to false + */ + expect(promiseOptions.mock.calls[1][3]).toEqual(false); + /** promiseOptions is called on initial load and after making the selection */ + expect(promiseOptions).toHaveBeenCalledTimes(2); + /** Opensrpservice is called onload, twice when promiseOptions is called and on handlechange */ + expect(mockedOpenSRPservice).toHaveBeenCalledTimes(4); + /** Called after firing select event */ + expect(handleChange).toBeCalledTimes(1); + }); }); diff --git a/src/components/forms/LocationSelect/index.tsx b/src/components/forms/LocationSelect/index.tsx new file mode 100644 index 0000000000..6832148c6d --- /dev/null +++ b/src/components/forms/LocationSelect/index.tsx @@ -0,0 +1,11 @@ +import { FieldProps } from 'formik'; +import React from 'react'; +import JurisdictionSelect, { JurisdictionSelectProps } from '../JurisdictionSelect'; +const LocationSelect = (props: JurisdictionSelectProps & FieldProps) => { + const newProps = { + ...props, + loadLocations: true, + }; + return ; +}; +export default LocationSelect; diff --git a/src/components/forms/LocationSelect/tests/__snapshots__/index.test.tsx.snap b/src/components/forms/LocationSelect/tests/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..3f9016143e --- /dev/null +++ b/src/components/forms/LocationSelect/tests/__snapshots__/index.test.tsx.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/forms/LocationSelect renders jurisdictionselect with right props: Jurisdiction Select Props 1`] = ` +Object { + "apiEndpoint": "location/findByProperties", + "cascadingSelect": true, + "handleChange": [Function], + "handleChangeWithOptions": [Function], + "handleChangeWithoutOptions": [Function], + "handleLoadOptionsPayload": [Function], + "loadLocations": true, + "params": Object { + "is_jurisdiction": true, + "return_geometry": false, + }, + "promiseOptions": [Function], + "serviceClass": [Function], +} +`; diff --git a/src/components/forms/LocationSelect/tests/index.test.tsx b/src/components/forms/LocationSelect/tests/index.test.tsx new file mode 100644 index 0000000000..850a997bcb --- /dev/null +++ b/src/components/forms/LocationSelect/tests/index.test.tsx @@ -0,0 +1,19 @@ +import { mount, shallow } from 'enzyme'; +import React from 'react'; +import LocationSelect from '..'; +import { defaultProps } from '../../JurisdictionSelect'; +describe('components/forms/LocationSelect', () => { + it('renders without crashing', () => { + shallow(); + mount(); + }); + it('renders jurisdictionselect with right props', () => { + const props = { + ...defaultProps, + loadLocations: true, + }; + + const wrapper = mount(); + expect(wrapper.children().props()).toMatchSnapshot('Jurisdiction Select Props'); + }); +}); diff --git a/src/components/forms/SimpleOrgSelect/index.tsx b/src/components/forms/SimpleOrgSelect/index.tsx new file mode 100644 index 0000000000..b49a42e2bf --- /dev/null +++ b/src/components/forms/SimpleOrgSelect/index.tsx @@ -0,0 +1,95 @@ +import { Dictionary } from '@onaio/utils'; +import { FieldProps } from 'formik'; +import React from 'react'; +import AsyncSelect, { Props as AsyncSelectProps } from 'react-select/async'; +import { SELECT } from '../../../configs/lang'; +import { OPENSRP_ORGANIZATION_ENDPOINT } from '../../../constants'; +import { reactSelectNoOptionsText } from '../../../helpers/utils'; +import { OpenSRPService, URLParams } from '../../../services/opensrp'; +import { Organization } from '../../../store/ducks/opensrp/organizations'; +import { SelectOption } from '../JurisdictionSelect'; + +/** SimpleOrgSelect props */ +export interface SimpleOrgSelectProps extends AsyncSelectProps { + apiEndpoint: string /** the OpenSRP API endpoint */; + locationId: string /** selected location identifier */; + params: URLParams /** extra URL params to send to OpenSRP */; + serviceClass: typeof OpenSRPService /** the OpenSRP service */; + promiseOptions: ( + serice: OpenSRPService, + parameters: Dictionary + ) => Promise<() => {}> | Promise; // Todo: Add a a more specific type +} + +export const promiseOptions = (service: OpenSRPService, parameters: Dictionary) => + // tslint:disable-next-line:no-inferred-empty-object-type + new Promise((resolve, reject) => { + service + .list(parameters) + .then((response: Organization[]) => { + const options = response.map(item => { + return { label: item.name, value: item.identifier }; + }); + resolve(options); + }) + .catch((err: Error) => { + reject(`Opensrp service Error ${err}`); + }); + }); + +/** default props for SimpleOrgSelect */ +export const defaultProps: Partial = { + apiEndpoint: OPENSRP_ORGANIZATION_ENDPOINT, + promiseOptions, + serviceClass: OpenSRPService, +}; +/** Async react select Hoc that loads organizations at location level as options */ +const SimpleOrgSelect = (props: SimpleOrgSelectProps & FieldProps) => { + const { apiEndpoint, field, form, serviceClass, locationId } = props; + const service = new serviceClass(apiEndpoint); + const params = { + location_id: locationId, + }; + const wrapperPromiseOptions: () => Promise<() => {}> = async () => { + return await props.promiseOptions(service, params); + }; + + /** + * onChange callback + * unfortunately we have to set the type of option as any (for now) + */ + const handleChange = () => (option: any) => { + const optionVal = option as { label: string; value: string }; + if (optionVal && optionVal.value) { + if (form && field) { + form.setFieldValue(field.name, optionVal.value); + form.setFieldTouched(field.name, true); + } + } + }; + + return ( +
+ 'async'} + name={field ? field.name : 'organization'} + bsSize="lg" + loadOptions={wrapperPromiseOptions} + placeholder={props.placeholder ? props.placeholder : SELECT} + noOptionsMessage={reactSelectNoOptionsText} + aria-label={props['aria-label'] ? props['aria-label'] : SELECT} + onChange={handleChange()} + defaultOptions={true} + isClearable={true} + cacheOptions={true} + classNamePrefix="organization" + {...props} + /> +
+ ); +}; + +SimpleOrgSelect.defaultProps = defaultProps; + +export default SimpleOrgSelect; diff --git a/src/components/forms/SimpleOrgSelect/tests/__snapshots__/index.test.tsx.snap b/src/components/forms/SimpleOrgSelect/tests/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..8b56d3fdf3 --- /dev/null +++ b/src/components/forms/SimpleOrgSelect/tests/__snapshots__/index.test.tsx.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/forms/SimpleOrgSelect renders select options correctly: SimpleOrgSelect Select Props 1`] = ` +Object { + "apiEndpoint": "organization", + "aria-label": "Select", + "bsSize": "lg", + "cacheOptions": true, + "classNamePrefix": "organization", + "defaultInputValue": "", + "defaultMenuIsOpen": false, + "defaultOptions": true, + "defaultProps": Object { + "apiEndpoint": "organization", + "promiseOptions": [Function], + "serviceClass": [Function], + }, + "defaultValue": null, + "filterOption": null, + "isClearable": true, + "isLoading": true, + "name": "organization", + "noOptionsMessage": [Function], + "onChange": [Function], + "onInputChange": [Function], + "options": Array [], + "placeholder": "Select", + "promiseOptions": [Function], + "serviceClass": [Function], +} +`; + +exports[`components/forms/SimpleOrgSelect renders select options correctly: SimpleOrgSelect Select Props ownProps 1`] = ` +Object { + "children": , +} +`; diff --git a/src/components/forms/SimpleOrgSelect/tests/index.test.tsx b/src/components/forms/SimpleOrgSelect/tests/index.test.tsx new file mode 100644 index 0000000000..9e3c30ab4c --- /dev/null +++ b/src/components/forms/SimpleOrgSelect/tests/index.test.tsx @@ -0,0 +1,28 @@ +import { mount, shallow } from 'enzyme'; +import React from 'react'; +import SimpleOrgSelect from '..'; +import defaultProps from '..'; + +describe('components/forms/SimpleOrgSelect', () => { + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + + it('renders without crashing', () => { + shallow(); + mount(); + }); + it('renders select options correctly', () => { + const wrapper = mount(); + + expect(wrapper.children().props()).toMatchSnapshot('SimpleOrgSelect Select Props ownProps'); + expect( + wrapper + .children() + .children() + .children() + .props() + ).toMatchSnapshot('SimpleOrgSelect Select Props'); + }); +}); diff --git a/src/components/page/Header/index.tsx b/src/components/page/Header/index.tsx index 477316b0ed..211b106919 100644 --- a/src/components/page/Header/index.tsx +++ b/src/components/page/Header/index.tsx @@ -17,6 +17,7 @@ import { import logo from '../../../assets/images/logo.png'; import { BACKEND_ACTIVE, + CLIENT_LABEL, ENABLE_ABOUT, ENABLE_ASSIGN, ENABLE_FI, @@ -31,6 +32,7 @@ import { ABOUT, ADMIN, ASSIGN, + CLIENTS_TITLE, FOCUS_INVESTIGATION, HOME, IRS_REPORTING_TITLE, @@ -43,11 +45,13 @@ import { PLANS, PRACTITIONERS, SIGN_OUT, + STUDENTS_TITLE, USERS, } from '../../../configs/lang'; import { ASSIGN_PLAN_URL, BACKEND_LOGIN_URL, + CLIENTS_LIST_URL, FI_URL, INTERVENTION_IRS_DRAFTS_URL, INTERVENTION_IRS_URL, @@ -207,7 +211,7 @@ export class HeaderComponent extends React.Component { )} - {(ENABLE_TEAMS || ENABLE_PRACTITIONERS || ENABLE_USERS) && ( + {(ENABLE_TEAMS || ENABLE_PRACTITIONERS || ENABLE_USERS || ENABLE_MDA_POINT) && ( {ADMIN} @@ -242,6 +246,19 @@ export class HeaderComponent extends React.Component { )} + {ENABLE_MDA_POINT && ( +
+ + + {CLIENT_LABEL === STUDENTS_TITLE ? STUDENTS_TITLE : CLIENTS_TITLE} + + +
+ )}
)} diff --git a/src/configs/env.ts b/src/configs/env.ts index 6c3021631f..d2a1b4b1dc 100644 --- a/src/configs/env.ts +++ b/src/configs/env.ts @@ -48,6 +48,10 @@ export type ENABLE_TEAMS = typeof ENABLE_TEAMS; export const ENABLE_MDA_POINT = process.env.REACT_APP_ENABLE_MDA_POINT === 'true'; export type ENABLE_MDA_POINT = typeof ENABLE_MDA_POINT; +/** Do you want to enable the MDA Point plan features? */ +export const CLIENT_LABEL = process.env.REACT_APP_CLIENT_LABEL || 'client'; +export type CLIENT_LABEL = typeof CLIENT_LABEL; + /** Do you want to disable login protection? */ export const DISABLE_LOGIN_PROTECTION = process.env.REACT_APP_DISABLE_LOGIN_PROTECTION === 'true'; export type DISABLE_LOGIN_PROTECTION = typeof DISABLE_LOGIN_PROTECTION; diff --git a/src/configs/lang.ts b/src/configs/lang.ts index b732a6a050..e8a5a6cf2b 100644 --- a/src/configs/lang.ts +++ b/src/configs/lang.ts @@ -265,6 +265,14 @@ export const ORGANIZATION_EDITED_SUCCESSFULLY = translate( 'ORGANIZATION_EDITED_SUCCESSFULLY', `Team edited successfully.` ); +export const FILE_UPLOADED_SUCCESSFULLY = translate( + 'FILE_UPLOADED_SUCCESSFULLY', + `File uploaded successfully.` +); +export const FILE_UPLOAD_FAILED = translate( + 'FILE_UPLOAD_FAILED', + `File upload failed please try again.` +); export const ORGANIZATION_CREATED_SUCCESSFULLY = translate( 'ORGANIZATION_CREATED_SUCCESSFULLY', `Team created successfully` @@ -468,6 +476,43 @@ export const TEAM_ASSIGNEMENT_SUCCESSFUL = translate( 'TEAM_ASSIGNEMENT_SUCCESSFUL', 'Team(s) assignment updated successfully' ); +export const UPLOADED_STUDENT_LISTS = translate('UPLOADED_STUDENT_LISTS', 'Uploaded Students List'); +export const EXPORT_STUDENT_LIST = translate('EXPORT_STUDENT_LIST', 'Export Student List'); +export const ADD_NEW_CSV = translate('ADD_NEW_CSV', 'Add New CSV'); +export const CLIENTS_TITLE = translate('CLIENTS_TITLE', 'Clients'); +export const STUDENTS_TITLE = translate('STUDENTS_TITLE', 'Students'); +export const UPLOADED_CLIENT_LISTS = translate('UPLOADED_CLIENT_LISTS', 'Uploaded Clients List'); +export const EXPORT_CLIENT_LIST = translate('EXPORT_CLIENT_LIST', 'Export Client List'); +export const RESET = translate('RESET', 'Reset'); +export const EXPORT_BASED_ON_GEOGRAPHICAL_REGION = translate( + 'EXPORT_BASED_ON_GEOGRAPHICAL_REGION', + 'Export Country based on Geographical level!' +); +export const LOCATION_ERROR_MESSAGE = translate( + 'SELECT_LOCATION_ERROR_MESSAGE', + 'Please select location' +); +export const DOWNLOAD = translate('DOWNLOAD', 'Download'); +export const FILE_NAME = translate('FILE_NAME', 'File Name'); +export const OWNER = translate('OWNER', 'Owner'); +export const UPLOAD_DATE = translate('UPLOAD_DATE', 'Upload Date'); +export const UPLOAD_FILE = translate('UPLOAD_FILE', 'Upload File'); +export const MODAL_BUTTON_CLASS = translate( + 'MODAL_BUTTON_CLASS', + 'focus-investigation btn btn-primary float-right mt-0' +); +export const SUBMIT = translate('SUBMIT', 'Submit'); +export const CLIENT_UPLOAD_FORM = translate('CLIENT_UPLOAD_FORM', 'Client Upload Form'); +export const FILE_SUBMISSION_READY = translate('FILE_SUBMISSION_READY', 'File is ready to submit'); +export const LOADING = translate('LOADING', 'loading...'); +export const GEOGRAPHICAL_REGION_TO_INCLUDE = translate( + 'GEOGRAPHICAL_REGION_TO_INCLUDE', + 'Geographical level to include' +); +export const ASSIGN_TEAM_TO_SCHOOL = translate( + 'ASSIGN_TEAM_TO_SCHOOL', + 'Assign team to this school' +); export const MDA_POINT_REPORTING_TITLE = translate( 'MDA_POINT_REPORTING_TITLE', diff --git a/src/configs/strings/en.json b/src/configs/strings/en.json index 5c8c76989f..53a59fb24f 100644 --- a/src/configs/strings/en.json +++ b/src/configs/strings/en.json @@ -947,6 +947,34 @@ "message": "Age", "description": "the length of time that a person has lived or a thing has existed" }, + "CLIENTS_TITLE": { + "message": "Clients", + "description": "title: the children receiving MDA" + }, + "CLIENTS_LISTS": { + "message": "Uploaded Client Lists", + "description": "Upload Client List to opensrp" + }, + "EXPORT_CLIENT_LIST": { + "message": "Export Client List", + "description": "button text: to download a CSV of clients per jurisdiction" + }, + "STUDENTS_TITLE": { + "message": "Students", + "description": "title: the children receiving MDA" + }, + "UPLOADED_STUDENT_LISTS": { + "message": "Uploaded Student Lists", + "description": "Upload Student List to opensrp" + }, + "EXPORT_STUDENT_LIST": { + "message": "Export Student List", + "description": "button text: to download a CSV of students per jurisdiction" + }, + "ADD_NEW_CSV": { + "message": "Add New CSV", + "description": "button text: upload a new CSV file to the server" + }, "NO_OPTIONS": { "message": "No Options", "description": "prompt shown when a search yields no results" diff --git a/src/constants.tsx b/src/constants.tsx index 5880a59d6c..1e1934026f 100644 --- a/src/constants.tsx +++ b/src/constants.tsx @@ -50,6 +50,7 @@ export const PLAN_INTERVENTION_TYPE = 'plan_intervention_type'; export const TWO_HUNDRED_PX = '200px'; export const PLAN_RECORD_BY_ID = 'planRecordsById'; export const MAPBOXGL_POPUP = '.mapboxgl-popup'; +export const TABLE_BORDERED_CLASS = 'table table-bordered'; // internal urls export const BACKEND_LOGIN_URL = '/fe/login'; @@ -87,6 +88,9 @@ export const REPORT = 'report'; export const BACKEND_CALLBACK_URL = '/fe/oauth/callback/opensrp'; export const BACKEND_CALLBACK_PATH = '/fe/oauth/callback/:id'; export const REACT_CALLBACK_PATH = '/oauth/callback/:id'; +export const CLIENTS_LIST_URL = '/clients'; +export const UPLOAD_CLIENT_CSV_URL = '/clients/upload'; +export const GO_BACK_TEXT = 'Go Back'; // OpenSRP API strings export const OPENSRP_PRACTITIONER_ENDPOINT = 'practitioner'; @@ -104,6 +108,14 @@ export const OPENSRP_ADD_PRACTITIONER_ROLE_ENDPOINT = 'practitionerRole/add'; export const OPENSRP_USERS_ENDPOINT = 'user'; export const OPENSRP_FIND_EVENTS_ENDPOINT = 'event/findById'; export const OPENSRP_LOCATIONS_BY_PLAN = 'plans/findLocationNames'; +export const OPENSRP_FILE_UPLOAD_HISTORY_ENDPOINT = 'upload/history'; +export const OPENSRP_EVENT_PARAM_VALUE = 'Child Registration'; +export const OPENSRP_UPLOAD_ENDPOINT = 'upload'; +export const OPENSRP_UPLOAD_DOWNLOAD_ENDPOINT = 'upload/download'; +export const EVENT_NAME_PARAM = 'event_name'; +export const LOCATION_ID_PARAM = 'location_id'; +export const TEAM_ID_PARAM = 'team_id'; +export const OPENSRP_TEMPLATE_ENDPOINT = 'template'; // colors export const GREEN = 'Green'; diff --git a/src/containers/pages/MDAPoint/ClientListView/helpers/serviceHooks.ts b/src/containers/pages/MDAPoint/ClientListView/helpers/serviceHooks.ts new file mode 100644 index 0000000000..2b91e3b38a --- /dev/null +++ b/src/containers/pages/MDAPoint/ClientListView/helpers/serviceHooks.ts @@ -0,0 +1,93 @@ +import { Dictionary } from '@onaio/utils'; +import { toast } from 'react-toastify'; +import { OPENSRP_API_BASE_URL } from '../../../../../configs/env'; +import { FILE_UPLOADED_SUCCESSFULLY } from '../../../../../configs/lang'; +import { + OPENSRP_FILE_UPLOAD_HISTORY_ENDPOINT, + OPENSRP_UPLOAD_ENDPOINT, +} from '../../../../../constants'; +import { growl } from '../../../../../helpers/utils'; +import { OpenSRPService } from '../../../../../services/opensrp'; +import store from '../../../../../store'; +import { fetchFiles, File } from '../../../../../store/ducks/opensrp/clientfiles'; +import { getAccessToken } from '../../../../../store/selectors'; + +/** loads and persists to store files data from upload/history endpoint + */ +export const loadFiles = async ( + fetchFileAction = fetchFiles, + serviceClass: any = OpenSRPService +) => { + const serve = new serviceClass(OPENSRP_FILE_UPLOAD_HISTORY_ENDPOINT); + serve + .list() + .then((response: File[]) => { + store.dispatch(fetchFileAction(response, true)); + }) + .catch((err: Error) => { + growl(err.message, { type: toast.TYPE.ERROR }); + }); +}; +/** + * Posts uploaded file to opensrp + * Todo: + * Investigate why opensrp service fails to upload file + * @param data uploaded formdata payload + * @param setStateIfDone set state to trigger redirect on upload + */ +export const postUploadedFile = async ( + data: any, + setStateIfDone: () => void, + setFormSubmitstate: () => void, + params?: string +) => { + const bearer = `Bearer ${getAccessToken(store.getState())}`; + await fetch(`${OPENSRP_API_BASE_URL}${OPENSRP_UPLOAD_ENDPOINT}/${params}`, { + body: data, + headers: { + Authorization: bearer, + }, + method: 'POST', + }) + .then(response => response.json()) + .then(async () => { + growl(FILE_UPLOADED_SUCCESSFULLY, { + onClose: () => setStateIfDone(), + type: toast.TYPE.SUCCESS, + }); + await loadFiles(); + setFormSubmitstate(); + }) + .catch(err => { + growl(err.message, { type: toast.TYPE.ERROR }); + }); +}; +/** + * Handles file downloads from server + * @param {string} id csv identifier + * @param {string} name file name + */ +export const handleDownload = async ( + id: string, + name: string, + endpoint: string, + params?: Dictionary, + serviceClass: any = OpenSRPService +) => { + const downloadService = new serviceClass(endpoint); + downloadService + .readFile(id, params) + .then((res: typeof Blob) => { + const url = window.URL.createObjectURL(res); + const a = document.createElement('a'); + document.body.appendChild(a); + a.href = url; + a.download = name; + a.click(); + + window.URL.revokeObjectURL(url); + }) + .catch((err: any) => { + growl(err.message, { type: toast.TYPE.ERROR }); + }); +}; diff --git a/src/containers/pages/MDAPoint/ClientListView/helpers/tests/serviceHooks.test.tsx b/src/containers/pages/MDAPoint/ClientListView/helpers/tests/serviceHooks.test.tsx new file mode 100644 index 0000000000..c36fd02d77 --- /dev/null +++ b/src/containers/pages/MDAPoint/ClientListView/helpers/tests/serviceHooks.test.tsx @@ -0,0 +1,87 @@ +/** tests for Organization page views api calling functions */ +import flushPromises from 'flush-promises'; +import { + OPENSRP_FILE_UPLOAD_HISTORY_ENDPOINT, + OPENSRP_UPLOAD_DOWNLOAD_ENDPOINT, +} from '../../../../../../constants'; +import * as fixtures from '../../tests/fixtures'; +import { handleDownload, loadFiles, postUploadedFile } from '../serviceHooks'; + +describe('src/containers/pages/ClientListView/helpers/servicehooks', () => { + it('loadOrganization works correctly', async () => { + const mockList = jest.fn(async () => fixtures.files); + const mockActionCreator = jest.fn(); + const mockClass = jest.fn().mockImplementation(() => { + return { + list: mockList, + }; + }); + + loadFiles(mockActionCreator, mockClass).catch(e => { + throw e; + }); + await flushPromises(); + + // calls the correct endpoint + expect(mockClass).toHaveBeenCalledWith(OPENSRP_FILE_UPLOAD_HISTORY_ENDPOINT); + + // Uses the correct service method + expect(mockList).toHaveBeenCalled(); + + // calls action creator correctly. + expect(mockActionCreator).toHaveBeenCalledWith(fixtures.files, true); + }); + + it('loadOrgPractitioners works correctly', async () => { + const mockSuccessResponse = {}; + const mockJsonPromise = Promise.resolve(mockSuccessResponse); + const mockFetchPromise = Promise.resolve({ + json: () => mockJsonPromise, + } as any); + jest.spyOn(global, 'fetch').mockImplementation(() => mockFetchPromise); + const setStateIfDone = jest.fn(); + const setFormSubmitstate = jest.fn(); + const file = new File(['student'], 'student.csv', { + type: 'text/csv', + }); + // tslint:disable-next-line: no-floating-promises + postUploadedFile(file, setStateIfDone, setFormSubmitstate); + await flushPromises(); + // makes a fetch for upload and another when loadingfiles + expect(global.fetch).toHaveBeenCalledTimes(2); + /** The postUploadFile make two fetch responses + * 1. post to upload file (has no param hence upload/undefined) + * 2. get from history api + * Haven't been able to run the param assertions for the two cases successfully + * Here are the two calls + * 1. "https://reveal-stage.smartregister.org/opensrp/rest/upload/undefined", {"body": {}, "headers": {"Authorization": "Bearer null"}, "method": "POST"} + * 2. "https://reveal-stage.smartregister.org/opensrp/rest/upload/history", {"headers": {"accept": "application/json", "authorization": "Bearer null", "content-type": "application/json;charset=UTF-8"}, "method": "GET"} + */ + global.fetch.mockClear(); + }); + + it('handleDownload works correctly', async () => { + const mockReadFile = jest.fn( + async () => new Blob(['greetings', 'hello'], { type: 'text/csv' }) + ); + const mockClass = jest.fn().mockImplementation(() => { + return { + readFile: mockReadFile, + }; + }); + + handleDownload('123', 'student', OPENSRP_UPLOAD_DOWNLOAD_ENDPOINT, undefined, mockClass).catch( + e => { + throw e; + } + ); + await flushPromises(); + + // calls the correct endpoint + expect(mockClass).toHaveBeenCalledWith('upload/download'); + + // Uses the correct service method + expect(mockReadFile).toHaveBeenCalledTimes(1); + global.fetch.mockClear(); + }); +}); diff --git a/src/containers/pages/MDAPoint/ClientListView/index.tsx b/src/containers/pages/MDAPoint/ClientListView/index.tsx new file mode 100644 index 0000000000..4661a91432 --- /dev/null +++ b/src/containers/pages/MDAPoint/ClientListView/index.tsx @@ -0,0 +1,167 @@ +import ListView from '@onaio/list-view'; +import reducerRegistry from '@onaio/redux-reducer-registry'; +import React, { ReactNode } from 'react'; +import Helmet from 'react-helmet'; +import { connect } from 'react-redux'; +import { RouteComponentProps } from 'react-router'; +import { Col, Row } from 'reactstrap'; +import { Store } from 'redux'; +import { ExportForm } from '../../../../components/forms/ExportForm'; +import LinkAsButton from '../../../../components/LinkAsButton'; +import HeaderBreadcrumb, { + BreadCrumbProps, +} from '../../../../components/page/HeaderBreadcrumb/HeaderBreadcrumb'; +import Loading from '../../../../components/page/Loading/index'; +import { CLIENT_LABEL } from '../../../../configs/env'; +import { + ADD_NEW_CSV, + CLIENTS_TITLE, + DOWNLOAD, + FILE_NAME, + HOME, + OWNER, + STUDENTS_TITLE, + UPLOAD_DATE, + UPLOADED_CLIENT_LISTS, + UPLOADED_STUDENT_LISTS, +} from '../../../../configs/lang'; +import { + CLIENTS_LIST_URL, + HOME_URL, + OPENSRP_UPLOAD_DOWNLOAD_ENDPOINT, + TABLE_BORDERED_CLASS, + UPLOAD_CLIENT_CSV_URL, +} from '../../../../constants'; +import { displayError } from '../../../../helpers/errors'; +import { OpenSRPService } from '../../../../services/opensrp'; +import { fetchFiles, File } from '../../../../store/ducks/opensrp/clientfiles/index'; +import filesReducer, { + getFilesArray, + reducerName as filesReducerName, +} from '../../../../store/ducks/opensrp/clientfiles/index'; +import { ClientUpload } from '../ClientUpload'; +import { handleDownload, loadFiles } from './helpers/serviceHooks'; +/** register the plans reducer */ +reducerRegistry.register(filesReducerName, filesReducer); +/** interface to describe props for ClientListView component */ +export interface ClientListViewProps { + fetchFilesActionCreator: typeof fetchFiles; + files: File[] | null; + serviceClass: typeof OpenSRPService; + clientLabel: string; +} +/** default props for ClientListView component */ +export const defaultClientListViewProps: ClientListViewProps = { + clientLabel: CLIENT_LABEL, + fetchFilesActionCreator: fetchFiles, + files: null, + serviceClass: OpenSRPService, +}; +/** + * Builds list view table data + * @param {File[] } rowData file data coming from opensrp/history endpoint + */ +export const buildListViewData: (rowData: File[]) => ReactNode[][] | undefined = rowData => { + return rowData.map((row: File, key: number) => { + const { url, fileName } = row; + return [ +

+ {fileName}   + {/* tslint:disable-next-line jsx-no-lambda */} + handleDownload(url, fileName, OPENSRP_UPLOAD_DOWNLOAD_ENDPOINT)}> + {`(${DOWNLOAD})`} + +

, + row.providerID, + row.uploadDate, + ]; + }); +}; + +export const ClientListView = (props: ClientListViewProps & RouteComponentProps) => { + const { location, files, clientLabel } = props; + React.useEffect(() => { + if (!(files && files.length)) { + /** + * Fetch files incase the files are not available e.g when page is refreshed + */ + loadFiles().catch(err => displayError(err)); + } + /** + * We do not need to re-run since this effect doesn't depend on any values from api yet + */ + }, []); + /** Overide renderRows to render html inside td */ + let listViewProps; + if (files && files.length) { + listViewProps = { + data: buildListViewData(files), + headerItems: [FILE_NAME, OWNER, UPLOAD_DATE], + tableClass: TABLE_BORDERED_CLASS, + }; + } + /** Load Modal once we hit this route */ + if (location.pathname === UPLOAD_CLIENT_CSV_URL) { + return ; + } + /** props to pass to the headerBreadCrumb */ + const breadcrumbProps: BreadCrumbProps = { + currentPage: { + label: clientLabel === STUDENTS_TITLE ? STUDENTS_TITLE : CLIENTS_TITLE, + url: CLIENTS_LIST_URL, + }, + pages: [], + }; + const homePage = { + label: `${HOME}`, + url: `${HOME_URL}`, + }; + breadcrumbProps.pages = [homePage]; + + // props for the link displayed as button: used to add new practitioner + const csvUploadButtonProps = { + text: ADD_NEW_CSV, + to: UPLOAD_CLIENT_CSV_URL, + }; + return ( +
+ + {clientLabel === STUDENTS_TITLE ? STUDENTS_TITLE : CLIENTS_TITLE} + + + + +

+ {clientLabel === STUDENTS_TITLE ? UPLOADED_STUDENT_LISTS : UPLOADED_CLIENT_LISTS} +

+ + + + +
+ + + {listViewProps && files && files.length ? : } + + +
+ +
+ ); +}; +ClientListView.defaultProps = defaultClientListViewProps; + +/** maps props to state via selectors */ +const mapStateToProps = (state: Partial) => { + const files = getFilesArray(state); + return { + files, + }; +}; + +const mapDispatchToProps = { + fetchFilesActionCreator: fetchFiles, +}; + +const ConnectedClientListView = connect(mapStateToProps, mapDispatchToProps)(ClientListView); +export default ConnectedClientListView; diff --git a/src/containers/pages/MDAPoint/ClientListView/tests/__snapshots__/index.test.tsx.snap b/src/containers/pages/MDAPoint/ClientListView/tests/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..9a81167b90 --- /dev/null +++ b/src/containers/pages/MDAPoint/ClientListView/tests/__snapshots__/index.test.tsx.snap @@ -0,0 +1,109 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`containers/pages/MDAPoints/ClientListView works with the Redux store 1`] = ` +Object { + "children": Array [ + + + + + , + + + + Barush_school.csv +   + + (Download) + +

, + "Mowshaqs", + "4/05/2020", + ] + } + /> + + , + ], + "className": "table table-bordered", +} +`; + +exports[`containers/pages/MDAPoints/ClientListView works with the Redux store 2`] = ` +Object { + "children": + + , + "className": "listview-thead", +} +`; + +exports[`containers/pages/MDAPoints/ClientListView works with the Redux store 3`] = ` +Object { + "children": Array [ + + + Barush_school.csv +   + + (Download) + +

, + "Mowshaqs", + "4/05/2020", + ] + } + /> + , + ], + "className": "listview-tbody", +} +`; diff --git a/src/containers/pages/MDAPoint/ClientListView/tests/fixtures.ts b/src/containers/pages/MDAPoint/ClientListView/tests/fixtures.ts new file mode 100644 index 0000000000..45eade6c52 --- /dev/null +++ b/src/containers/pages/MDAPoint/ClientListView/tests/fixtures.ts @@ -0,0 +1,85 @@ +export const ClientListViewprops = { + computedMatch: { + isExact: true, + params: {}, + path: '/students', + url: '/students', + }, + exact: false, + files: [ + { + fileName: 'Barush_school.csv', + identifier: '123', + providerID: 'Mowshaqs', + uploadDate: '4/05/2020', + url: + 'data:image/png;base64,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', + }, + { + fileName: 'Gicandi_school.csv', + identifier: '1234', + providerID: 'Mowshaqs', + uploadDate: '4/05/2020', + url: + 'https://user-images.githubusercontent.com/12836913/81139056-3be46680-8f19-11ea-92f8-fb1ab7877626.png', + }, + { + fileName: 'Junior_school.csv', + identifier: '12345', + providerID: 'Mowshaqs', + uploadDate: '4/05/2020', + url: + 'https://user-images.githubusercontent.com/12836913/81139056-3be46680-8f19-11ea-92f8-fb1ab7877626.png', + }, + ], + history: { + action: 'PUSH', + length: 8, + location: { + hash: '', + key: 'qk8q6x', + pathname: '/students', + search: '', + }, + }, + location: { + hash: '', + key: 'qk8q6x', + pathname: '/students', + search: '', + }, + match: { + isExact: true, + params: {}, + path: '/students', + url: '/students', + }, + path: '/students', +}; + +export const files = [ + { + fileName: 'Barush_school.csv', + identifier: '123', + providerID: 'Mowshaqs', + uploadDate: '4/05/2020', + url: + 'data:image/png;base64,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', + }, + { + fileName: 'Gicandi_school.csv', + identifier: '1234', + providerID: 'Mowshaqs', + uploadDate: '4/05/2020', + url: + 'https://user-images.githubusercontent.com/12836913/81139056-3be46680-8f19-11ea-92f8-fb1ab7877626.png', + }, + { + fileName: 'Junior_school.csv', + identifier: '12345', + providerID: 'Mowshaqs', + uploadDate: '4/05/2020', + url: + 'https://user-images.githubusercontent.com/12836913/81139056-3be46680-8f19-11ea-92f8-fb1ab7877626.png', + }, +]; diff --git a/src/containers/pages/MDAPoint/ClientListView/tests/index.test.tsx b/src/containers/pages/MDAPoint/ClientListView/tests/index.test.tsx new file mode 100644 index 0000000000..6b1ef383be --- /dev/null +++ b/src/containers/pages/MDAPoint/ClientListView/tests/index.test.tsx @@ -0,0 +1,165 @@ +import reducerRegistry from '@onaio/redux-reducer-registry'; +import { mount, shallow } from 'enzyme'; +import { createBrowserHistory } from 'history'; +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { Provider } from 'react-redux'; +import { Router } from 'react-router'; +import ConnectedClientListView, { ClientListView } from '../'; +import { STUDENTS_TITLE } from '../../../../../configs/lang'; +import store from '../../../../../store'; +import reducer, { + fetchFiles, + reducerName, + removeFilesAction, +} from '../../../../../store/ducks/opensrp/clientfiles'; +import * as fixtures from './fixtures'; +reducerRegistry.register(reducerName, reducer); +const history = createBrowserHistory(); +jest.mock('../../../../../configs/env'); + +describe('containers/pages/MDAPoints/ClientListView', () => { + beforeEach(() => { + jest.resetAllMocks(); + store.dispatch(removeFilesAction); + }); + + it('renders without crashing', () => { + const mock: any = jest.fn(); + const props = { + fetchFilesActionCreator: jest.fn(), + files: [fixtures.files[0]], + history, + location: mock, + match: mock, + }; + shallow( + + + + ); + }); + + it('renders without crashing for null files', () => { + const mock: any = jest.fn(); + const props = { + fetchFilesActionCreator: jest.fn(), + files: null, + history, + location: mock, + match: mock, + }; + shallow( + + + + ); + }); + + it('renders ClientListView correctly $ changes page title', () => { + const mock: any = jest.fn(); + // mock.mockImplementation(() => Promise.resolve(fixtures.plans)); + const props = { + clientLabel: 'Students', + fetchFilesActionCreator: jest.fn(), + files: fixtures.files, + history, + location: mock, + match: mock, + }; + const wrapper = mount( + + + + ); + const helmet = Helmet.peek(); + expect(helmet.title).toEqual(STUDENTS_TITLE); + expect(wrapper.find(ClientListView).props().files).toEqual(fixtures.ClientListViewprops.files); + expect(wrapper.find('HeaderBreadcrumb').length).toEqual(1); + expect(wrapper.find('HeaderBreadcrumb').props()).toEqual({ + currentPage: { + label: 'Students', + url: '/clients', + }, + pages: [ + { + label: 'Home', + url: '/', + }, + ], + }); + wrapper.unmount(); + }); + + it('renders ClientListView correctly for null files', () => { + const mock: any = jest.fn(); + // mock.mockImplementation(() => Promise.resolve(fixtures.plans)); + const props = { + clientLabel: 'Students', + fetchFilesActionCreator: jest.fn(), + files: null, + history, + location: mock, + match: mock, + }; + const wrapper = mount( + + + + ); + expect(wrapper.find(ClientListView).props().files).toEqual(null); + expect(wrapper.find('HeaderBreadcrumb').length).toEqual(1); + expect(wrapper.find('.listview-thead').length).toEqual(0); + expect(wrapper.find('HeaderBreadcrumb').props()).toEqual({ + currentPage: { + label: 'Students', + url: '/clients', + }, + pages: [ + { + label: 'Home', + url: '/', + }, + ], + }); + wrapper.unmount(); + }); + + it('works with the Redux store', () => { + store.dispatch(fetchFiles([fixtures.files[0]])); + const mock: any = jest.fn(); + // mock.mockImplementation(() => Promise.resolve(fixtures.plans)); + const props = { + history, + location: mock, + match: mock, + }; + const wrapper = mount( + + + + + + ); + wrapper.update(); + expect( + wrapper + .find('.table') + .at(0) + .props() + ).toMatchSnapshot(); + expect( + wrapper + .find('.listview-thead') + .at(0) + .props() + ).toMatchSnapshot(); + expect( + wrapper + .find('.listview-tbody') + .at(0) + .props() + ).toMatchSnapshot(); + wrapper.unmount(); + }); +}); diff --git a/src/containers/pages/MDAPoint/ClientUpload/index.tsx b/src/containers/pages/MDAPoint/ClientUpload/index.tsx new file mode 100644 index 0000000000..0b8b5c1a82 --- /dev/null +++ b/src/containers/pages/MDAPoint/ClientUpload/index.tsx @@ -0,0 +1,190 @@ +import { Dictionary } from '@onaio/utils'; +import { ErrorMessage, Field, Formik } from 'formik'; +import React, { useState } from 'react'; +import { Redirect } from 'react-router'; +import { Button, Form, FormGroup, Input, Label, Modal, ModalBody, ModalHeader } from 'reactstrap'; +import * as Yup from 'yup'; +import LocationSelect from '../../../../components/forms/LocationSelect'; +import SimpleOrgSelect from '../../../../components/forms/SimpleOrgSelect'; +import LinkAsButton from '../../../../components/LinkAsButton'; +import { + ASSIGN_TEAM_TO_SCHOOL, + CLIENT_UPLOAD_FORM, + GEOGRAPHICAL_REGION_TO_INCLUDE, + LOCATION_ERROR_MESSAGE, + MODAL_BUTTON_CLASS, + REQUIRED, + SUBMIT, + UPLOAD_FILE, +} from '../../../../configs/lang'; +import { + CLIENTS_LIST_URL, + EVENT_NAME_PARAM, + GO_BACK_TEXT, + LOCATION_ID_PARAM, + OPENSRP_EVENT_PARAM_VALUE, + TEAM_ID_PARAM, +} from '../../../../constants'; +import { postUploadedFile } from '../ClientListView/helpers/serviceHooks'; +import UploadStatus from '../ClientUploadStatus/'; +/** Yup client upload validation schema */ +export const uploadValidationSchema = Yup.object().shape({ + file: Yup.mixed().required(), + jurisdictions: Yup.object().shape({ + id: Yup.string().required(REQUIRED), + name: Yup.string(), + }), + team: Yup.string(), +}); +/** Default formik values */ +const defaultInitialValues: UploadFormField = { + file: null, + jurisdictions: { + id: '', + name: '', + }, + team: '', +}; +/** interface to describe upload form fields */ +export interface UploadFormField { + file: Blob | null; + jurisdictions: Dictionary; + team: string; +} +export interface ClientUploadProps { + eventValue: string; + initialValues: UploadFormField; + fileUploadService: typeof postUploadedFile; + fileType: string; + formFieldStyles: Dictionary; +} +export const ClientUpload = (props: ClientUploadProps) => { + const [selectedFile, setSelectedFile] = useState(null); + const [ifDoneHere, setIfDoneHere] = useState(false); + const { eventValue, initialValues, fileUploadService, formFieldStyles, fileType } = props; + const closeUploadModal = { + classNameProp: MODAL_BUTTON_CLASS, + text: GO_BACK_TEXT, + to: CLIENTS_LIST_URL, + }; + if (ifDoneHere) { + return ; + } + const setStateIfDone = () => { + setIfDoneHere(true); + }; + return ( +
+ + {CLIENT_UPLOAD_FORM} + + { + const setSubmittingStatus = () => setSubmitting(false); + const data = new FormData(); + data.append('file', selectedFile); + const uploadParams = `?${EVENT_NAME_PARAM}=${eventValue}&${LOCATION_ID_PARAM}=${values.jurisdictions.id}&${TEAM_ID_PARAM}=${values.team}`; + await fileUploadService(data, setStateIfDone, setSubmittingStatus, uploadParams); + }} + > + {({ values, setFieldValue, handleSubmit, errors, isSubmitting }) => ( +
+ + +   +
+ +
+ + {errors.jurisdictions && ( + + {LOCATION_ERROR_MESSAGE} + + )} + { + + } +
+ {values && values.jurisdictions && values.jurisdictions.id && ( + + +
+ +
+
+ )} + + + ) => { + setSelectedFile(event.target && event.target.files && event.target.files[0]); + setFieldValue( + 'file', + event && event.target && event.target.files && event.target.files[0] + ); + }} + /> + + {values.file && } + + {errors && errors.file ? ( + {errors.file} + ) : null} +
+
+ + +
+ + )} +
+
+
+
+ ); +}; +export const defaultProps: ClientUploadProps = { + eventValue: OPENSRP_EVENT_PARAM_VALUE, + fileType: '.csv', + fileUploadService: postUploadedFile, + formFieldStyles: { display: 'inline-block', width: '24rem' }, + initialValues: defaultInitialValues, +}; +ClientUpload.defaultProps = defaultProps; +export default ClientUpload; diff --git a/src/containers/pages/MDAPoint/ClientUpload/tests/__snapshots__/index.test.tsx.snap b/src/containers/pages/MDAPoint/ClientUpload/tests/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..2a3661fd20 --- /dev/null +++ b/src/containers/pages/MDAPoint/ClientUpload/tests/__snapshots__/index.test.tsx.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/ClientUpload renders props correctly: ClientUpload Select Props ownProps 1`] = ` +Object { + "eventValue": "Child Registration", + "fileType": ".csv", + "fileUploadService": [Function], + "formFieldStyles": Object { + "display": "inline-block", + "width": "24rem", + }, + "initialValues": Object { + "file": null, + "jurisdictions": Object { + "id": "", + "name": "", + }, + "team": "", + }, +} +`; diff --git a/src/containers/pages/MDAPoint/ClientUpload/tests/index.test.tsx b/src/containers/pages/MDAPoint/ClientUpload/tests/index.test.tsx new file mode 100644 index 0000000000..a773d1b3a2 --- /dev/null +++ b/src/containers/pages/MDAPoint/ClientUpload/tests/index.test.tsx @@ -0,0 +1,27 @@ +import { cleanup } from '@testing-library/react'; +import { mount, shallow } from 'enzyme'; +import { createBrowserHistory } from 'history'; +import React from 'react'; +import { Router } from 'react-router'; +import ClientUpload, { defaultProps } from '..'; +const history = createBrowserHistory(); + +describe('components/ClientUpload', () => { + afterEach(cleanup); + it('renders without crashing', () => { + shallow(); + }); + + it('renders props correctly', () => { + const props = { + ...defaultProps, + }; + const wrapper = mount( + + + + ); + expect(wrapper.children().props()).toMatchSnapshot('ClientUpload Select Props ownProps'); + wrapper.unmount(); + }); +}); diff --git a/src/containers/pages/MDAPoint/ClientUploadStatus/index.tsx b/src/containers/pages/MDAPoint/ClientUploadStatus/index.tsx new file mode 100644 index 0000000000..59849c5d7e --- /dev/null +++ b/src/containers/pages/MDAPoint/ClientUploadStatus/index.tsx @@ -0,0 +1,39 @@ +import React, { useState } from 'react'; +import { FILE_SUBMISSION_READY, LOADING } from '../../../../configs/lang'; +/** + * UploadStatus Shows status of selected file on upload + * Will come in very handy when uploading huge csv files + * Todo include more specific type for props + */ +export interface UploadStatusProps { + uploadFile: Blob; +} +const UploadStatus = (props: UploadStatusProps) => { + const [loading, setLoading] = useState(false); + React.useEffect(() => { + if (!props.uploadFile) { + return; + } + setLoading(true); + const reader = new FileReader(); + /** + * once selectedfile load setloading to false + */ + reader.onloadend = () => { + setLoading(false); + }; + reader.readAsDataURL(props.uploadFile); + }, [props.uploadFile]); + + const { uploadFile } = props; + if (!uploadFile) { + return null; + } + + if (loading) { + return

{LOADING}

; + } + + return

{FILE_SUBMISSION_READY}

; +}; +export default UploadStatus; diff --git a/src/containers/pages/MDAPoint/ClientUploadStatus/tests/__snapshots__/index.test.tsx.snap b/src/containers/pages/MDAPoint/ClientUploadStatus/tests/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..46ac940992 --- /dev/null +++ b/src/containers/pages/MDAPoint/ClientUploadStatus/tests/__snapshots__/index.test.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/ClientUploadStatus Matches snapshot 1`] = ` + +

+ loading... +

+
+`; diff --git a/src/containers/pages/MDAPoint/ClientUploadStatus/tests/index.test.tsx b/src/containers/pages/MDAPoint/ClientUploadStatus/tests/index.test.tsx new file mode 100644 index 0000000000..70d534bd4e --- /dev/null +++ b/src/containers/pages/MDAPoint/ClientUploadStatus/tests/index.test.tsx @@ -0,0 +1,35 @@ +import { mount, shallow } from 'enzyme'; +import toJson from 'enzyme-to-json'; +import flushPromises from 'flush-promises'; +import React from 'react'; +import UploadStatus from '..'; + +describe('components/ClientUploadStatus', () => { + const file = new File(['student'], 'student.csv', { + type: 'text/csv', + }); + it('renders without crashing', () => { + shallow(); + }); + + it('Matches snapshot', async () => { + const props = { + uploadFile: file, + }; + const wrapper = mount(); + await flushPromises(); + wrapper.update(); + expect(toJson(wrapper.find('UploadStatus'))).toMatchSnapshot(); + wrapper.unmount(); + }); + it('Calls filereader', async () => { + const props = { + uploadFile: file, + }; + const readAsDataURLSpy = jest.spyOn(FileReader.prototype, 'readAsDataURL'); + const wrapper = mount(); + await flushPromises(); + wrapper.update(); + expect(readAsDataURLSpy).toBeCalledTimes(1); + }); +}); diff --git a/src/services/opensrp/index.ts b/src/services/opensrp/index.ts index 91479d0b46..f79c572fb0 100644 --- a/src/services/opensrp/index.ts +++ b/src/services/opensrp/index.ts @@ -70,4 +70,20 @@ export class OpenSRPService extends OpenSRPServiceWeb { ) { super(baseURL, endpoint, getPayload); } + + public async readFile( + id: string | number, + params: URLParams | null = null, + method: HTTPMethod = 'GET' + ): Promise<{}> { + const url = OpenSRPService.getURL(`${this.generalURL}/${id}`, params); + const response = await fetch(url, this.getOptions(this.signal, method)); + + if (!response.ok) { + throw new Error( + `OpenSRPService read on ${this.endpoint} failed, HTTP status ${response.status}` + ); + } + return await response.blob(); + } } diff --git a/src/store/ducks/opensrp/clientfiles/index.ts b/src/store/ducks/opensrp/clientfiles/index.ts new file mode 100644 index 0000000000..33305afd9b --- /dev/null +++ b/src/store/ducks/opensrp/clientfiles/index.ts @@ -0,0 +1,122 @@ +import { keyBy, values } from 'lodash'; +import { AnyAction, Store } from 'redux'; +import SeamlessImmutable from 'seamless-immutable'; + +/** The reducer name */ +export const reducerName = 'clientfiles'; + +/** Interface for file json object */ +export interface File { + identifier: string; // the unique identifier of the file + fileLength?: number; // the length of the file (lines / rows) + fileName: string; // the name of the file + fileSize?: string; // the of the file + uploadDate: string; // the date of the latest file update + providerID: string; // the username of the file creator + url: string; // download location of the file +} + +// actions + +/** action type for fetching clients */ +export const FILES_FETCHED = 'opensrp/reducer/clients/FILES_FETCHED'; +/** action type for removing clients */ +export const REMOVE_FILES = 'opensrp/reducer/clients/REMOVE_FILES'; + +/** interface action to add Files to store */ +export interface FetchFilesAction extends AnyAction { + overwrite: boolean; + filesById: { [key: string]: File }; + type: typeof FILES_FETCHED; +} + +/** Interface for removeFilesAction */ +export interface RemoveFilesAction extends AnyAction { + filesById: {}; + type: typeof REMOVE_FILES; +} + +/** Create type for file reducer actions */ +export type FileActionTypes = FetchFilesAction | RemoveFilesAction | AnyAction; + +// action Creators + +/** Fetch files action creator + * @param {File[]} clientsList - clients array to add to store + * @param {boolean} overwrite - whether to replace the records in store for clients + * @return {FetchFileAction} - an action to add clients to redux store + */ +export const fetchFiles = ( + filesList: File[] = [], + overwrite: boolean = false +): FetchFilesAction => ({ + filesById: keyBy(filesList, (file: File) => file.identifier), + overwrite, + type: FILES_FETCHED, +}); + +// actions + +/** removeFilesAction action */ +export const removeFilesAction = { + filesById: {}, + type: REMOVE_FILES, +}; + +// The reducer + +/** interface for files state in redux store */ +export interface FileState { + filesById: { [key: string]: File } | {}; +} + +/** Create an immutable Files state */ +export type ImmutableFileState = FileState & SeamlessImmutable.ImmutableObject; + +/** initial practitioners-state state */ +export const initialState: ImmutableFileState = SeamlessImmutable({ + filesById: {}, +}); + +/** the Files reducer function */ +export default function reducer( + state: ImmutableFileState = initialState, + action: FileActionTypes +): ImmutableFileState { + switch (action.type) { + case FILES_FETCHED: + const filesToPut = action.overwrite + ? { ...action.filesById } + : { ...state.filesById, ...action.filesById }; + return SeamlessImmutable({ + ...state, + filesById: filesToPut, + }); + case REMOVE_FILES: { + return SeamlessImmutable({ + ...state, + filesById: action.filesById, + }); + } + default: + return state; + } +} + +// Selectors + +/** Get all Files keyed by File.identifier + * @param {Partial} state - Portion of the store + * @returns {[key: string]: File} filesById + */ +export const getFilesById = (state: Partial): { [key: string]: File } => { + return (state as any)[reducerName].filesById; +}; + +/** Get all Files as an array of File objects + * @param {Partial} state - the redux store + * @returns {File[]} - an array of File objects + */ +export const getFilesArray = (state: Partial): File[] => { + return values(getFilesById(state)); +}; diff --git a/src/store/ducks/opensrp/clientfiles/tests/fixtures.ts b/src/store/ducks/opensrp/clientfiles/tests/fixtures.ts new file mode 100644 index 0000000000..725c3e34b8 --- /dev/null +++ b/src/store/ducks/opensrp/clientfiles/tests/fixtures.ts @@ -0,0 +1,45 @@ +export const uploadedStudentsLists = [ + { + fileLength: 45, + fileName: 'Gicandi_school.csv', + fileSize: '234KB', + identifier: '1234', + lastUpdated: '4/05/2020', + owner: 'Mowshaqs', + url: + 'https://user-images.githubusercontent.com/12836913/81139056-3be46680-8f19-11ea-92f8-fb1ab7877626.png', + }, + { + fileLength: 45, + fileName: 'Junior_school.csv', + fileSize: '234KB', + identifier: '12345', + lastUpdated: '4/05/2020', + owner: 'Mowshaqs', + url: + 'https://user-images.githubusercontent.com/12836913/81139056-3be46680-8f19-11ea-92f8-fb1ab7877626.png', + }, + { + fileLength: 45, + fileName: 'Barush_school.csv', + fileSize: '234KB', + identifier: '123', + lastUpdated: '4/05/2020', + owner: 'Mowshaqs', + url: + 'data:image/png;base64,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', + }, +]; + +export const uploadedStudentsLists1 = [ + { + fileLength: 767, + fileName: 'Mt View.csv', + fileSize: '234KB', + identifier: '123456', + lastUpdated: '4/05/2020', + owner: 'Mowshaqs', + url: + 'https://user-images.githubusercontent.com/12836913/81139056-3be46680-8f19-11ea-92f8-fb1ab7877626.png', + }, +]; diff --git a/src/store/ducks/opensrp/clientfiles/tests/index.test.ts b/src/store/ducks/opensrp/clientfiles/tests/index.test.ts new file mode 100644 index 0000000000..ff09bdb0a1 --- /dev/null +++ b/src/store/ducks/opensrp/clientfiles/tests/index.test.ts @@ -0,0 +1,98 @@ +/** Test file for the practitioners ducks module */ +import reducerRegistry from '@onaio/redux-reducer-registry'; +import { FlushThunks } from 'redux-testkit'; +import reducer, { + fetchFiles, + getFilesArray, + getFilesById, + reducerName, + removeFilesAction, +} from '..'; +import store from '../../../..'; +// import { extractEvent, extractEvents, friendlyDate } from '../utils'; +import { uploadedStudentsLists, uploadedStudentsLists1 } from './fixtures'; + +reducerRegistry.register(reducerName, reducer); + +describe('reducers/files.reducer.FetchFilesAction', () => { + let flushThunks; + + beforeEach(() => { + flushThunks = FlushThunks.createMiddleware(); + jest.resetAllMocks(); + store.dispatch(removeFilesAction); + }); + + it('selectors work for empty initialState', () => { + expect(getFilesById(store.getState())).toEqual({}); + expect(getFilesArray(store.getState())).toEqual([]); + }); + + it('fetches files correctly', () => { + store.dispatch(fetchFiles([uploadedStudentsLists[0]])); + expect(getFilesById(store.getState())).toEqual({ + '1234': { + fileLength: 45, + fileName: 'Gicandi_school.csv', + fileSize: '234KB', + identifier: '1234', + lastUpdated: '4/05/2020', + owner: 'Mowshaqs', + url: + 'https://user-images.githubusercontent.com/12836913/81139056-3be46680-8f19-11ea-92f8-fb1ab7877626.png', + }, + }); + }); + + it('removes Files correctly', () => { + store.dispatch(fetchFiles(uploadedStudentsLists)); + let numberOfClients = getFilesArray(store.getState()).length; + expect(numberOfClients).toEqual(3); + + store.dispatch(removeFilesAction); + numberOfClients = getFilesArray(store.getState()).length; + expect(numberOfClients).toEqual(0); + }); + + it('dispatches Files correctly on non-empty state', () => { + store.dispatch(fetchFiles([uploadedStudentsLists[0]])); + let clients = getFilesById(store.getState()); + expect(clients).toEqual({ + '1234': { + fileLength: 45, + fileName: 'Gicandi_school.csv', + fileSize: '234KB', + identifier: '1234', + lastUpdated: '4/05/2020', + owner: 'Mowshaqs', + url: + 'https://user-images.githubusercontent.com/12836913/81139056-3be46680-8f19-11ea-92f8-fb1ab7877626.png', + }, + }); + + store.dispatch(fetchFiles(uploadedStudentsLists1)); + clients = getFilesById(store.getState()); + expect(clients).toEqual({ + '1234': { + fileLength: 45, + fileName: 'Gicandi_school.csv', + fileSize: '234KB', + identifier: '1234', + lastUpdated: '4/05/2020', + owner: 'Mowshaqs', + url: + 'https://user-images.githubusercontent.com/12836913/81139056-3be46680-8f19-11ea-92f8-fb1ab7877626.png', + }, + '123456': { + fileLength: 767, + fileName: 'Mt View.csv', + fileSize: '234KB', + identifier: '123456', + lastUpdated: '4/05/2020', + owner: 'Mowshaqs', + url: + 'https://user-images.githubusercontent.com/12836913/81139056-3be46680-8f19-11ea-92f8-fb1ab7877626.png', + }, + }); + }); +}); diff --git a/src/store/ducks/opensrp/clients/index.ts b/src/store/ducks/opensrp/clients/index.ts new file mode 100644 index 0000000000..dd71ec3f95 --- /dev/null +++ b/src/store/ducks/opensrp/clients/index.ts @@ -0,0 +1,146 @@ +import { keyBy, values } from 'lodash'; +import { AnyAction, Store } from 'redux'; +import SeamlessImmutable from 'seamless-immutable'; + +/** The reducer name */ +export const reducerName = 'client'; + +/** Interface for client json object */ +export interface Client { + age: number; // the age of the client (for reporting) + identifier: string; // the baseEntityId of the client + groupIdentifier: string; // the location id of the distribution point +} + +// actions + +/** action type for fetching clients */ +export const CLIENTS_FETCHED = 'opensrp/reducer/clients/CLIENTS_FETCHED'; +/** action type for removing clients */ +export const REMOVE_CLIENTS = 'opensrp/reducer/clients/REMOVE_CLIENTS'; + +/** interface action to add Clients to store */ +export interface FetchClientsAction extends AnyAction { + overwrite: boolean; + clientsById: { [key: string]: Client }; + type: typeof CLIENTS_FETCHED; +} + +/** Interface for removeClientsAction */ +export interface RemoveClientsAction extends AnyAction { + clientsById: {}; + type: typeof REMOVE_CLIENTS; +} + +/** Create type for clients reducer actions */ +export type ClientActionTypes = FetchClientsAction | RemoveClientsAction | AnyAction; + +// action Creators + +/** Fetch clients action creator + * @param {Practitioner []} clientsList - clients array to add to store + * @param {Client[]} clientsList - clients array to add to store + * @param {boolean} overwrite - whether to replace the records in store for clients + * @return {FetchClientsAction} - an action to add clients to redux store + */ +export const fetchClients = ( + clientsList: Client[] = [], + overwrite: boolean = false +): FetchClientsAction => ({ + clientsById: keyBy(clientsList, (client: Client) => client.identifier), + overwrite, + type: CLIENTS_FETCHED, +}); + +// actions + +/** removeClientsAction action */ +export const removeClientsAction = { + clientsById: {}, + type: REMOVE_CLIENTS, +}; + +// The reducer + +/** interface for clients state in redux store */ +export interface ClientState { + clientsById: { [key: string]: Client } | {}; +} + +/** Create an immutable clients state */ +export type ImmutableClientState = ClientState & SeamlessImmutable.ImmutableObject; + +/** initial practitioners-state state */ +export const initialState: ImmutableClientState = SeamlessImmutable({ + clientsById: {}, +}); + +/** the clients reducer function */ +export default function reducer( + state: ImmutableClientState = initialState, + action: ClientActionTypes +): ImmutableClientState { + switch (action.type) { + case CLIENTS_FETCHED: + const clientsToPut = action.overwrite + ? { ...action.clientsById } + : { ...state.clientsById, ...action.clientsById }; + return SeamlessImmutable({ + ...state, + clientsById: clientsToPut, + }); + case REMOVE_CLIENTS: { + return SeamlessImmutable({ + ...state, + clientsById: action.clientsById, + }); + } + default: + return state; + } +} + +// Selectors + +/** Get all Clients keyed by Client.identifier + * @param {Partial} state - Portion of the store + * @returns {[key: string]: Client} clientsById + */ +export const getClientsById = (state: Partial): { [key: string]: Client } => { + return (state as any)[reducerName].clientsById; +}; + +/** Get all clients as an array of Client objects + * @param {Partial} state - the redux store + * @returns {Client[]} - an array of Client objects + */ +export const getClientsArray = (state: Partial): Client[] => { + return values(getClientsById(state)); +}; + +/** Get clients per site as an array of Client objects + * @param {Partial} state - the redux store + * @param {string} groupId - the site ID of the filter by + * @returns {Client[]} - an array of Client objects + */ +export const getClientsArrayByGroupId = (state: Partial, groupId: string): Client[] => { + return getClientsArray(state).filter((client: Client) => client.groupIdentifier === groupId); +}; + +/** Get clients per site as a an object: clientsByGroupId + * @param {Partial} state - the redux store + * @param {string} groupId - the site ID of the filter by + * @returns {{[key: string]: Client[]}} clientsByGroupId + */ +export const getClientsByGroupId = ( + state: Partial, + groupId: string[] +): { [key: string]: Client[] } => { + const clientsByGroupId: { [key: string]: Client[] } = {}; + + for (let i = 0; i < getClientsArray(state).length; i += 1) { + clientsByGroupId[groupId[i]] = getClientsArrayByGroupId(state, groupId[i]); + } + + return clientsByGroupId; +}; diff --git a/yarn.lock b/yarn.lock index 46ed94d504..47fb8fa5af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1681,10 +1681,10 @@ memoize-one "^5.1.1" react-ga "^2.7.0" -"@onaio/list-view@^0.0.1": - version "0.0.1" - resolved "https://registry.yarnpkg.com/@onaio/list-view/-/list-view-0.0.1.tgz#575f48f8a167437974c3ea2a37c4996190ca1331" - integrity sha512-/U5RNGIVXYTp5ecn6I/C/FErzAlYVCUAoYARyojdJjCdgo1245s/kc8R4f3VT/fztf14mQpwh1FNS+5qIjETKA== +"@onaio/list-view@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@onaio/list-view/-/list-view-0.0.3.tgz#18cb78a0721fd30d331c9c267d242d838e58f623" + integrity sha512-vY8//UU2ZIuslzrFJC6NwnymZHh2+9EG7hwo87dlaX6p2bmZcYr44uzchNvFwNvW3C3xx3GV4LWkpU+WO95LCw== dependencies: "@onaio/element-map" "^0.0.5"