Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React async practitioner view #822

Closed
wants to merge 21 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"moment": "^2.24.0",
"prop-types": "^15.6.2",
"react": "^16.8.1",
"react-async": "^10.0.1",
"react-datepicker": "^2.8.0",
"react-dom": "^16.8.1",
"react-ga": "^2.7.0",
Expand Down
2 changes: 1 addition & 1 deletion src/App/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ const App = () => {
redirectPath={APP_CALLBACK_URL}
disableLoginProtection={DISABLE_LOGIN_PROTECTION}
exact={true}
path={`${ASSIGN_PLAN_URL}`}
path={ASSIGN_PLAN_URL}
component={ConnectedIRSAssignmentPlansList}
/>
<ConnectedPrivateRoute
Expand Down
59 changes: 59 additions & 0 deletions src/components/AsyncRenderer/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React, { PropsWithChildren } from 'react';
import { AsyncState, IfFulfilled, IfPending, PromiseFn, useAsync } from 'react-async';
import { PLEASE_PROVIDE_CUSTOM_COMPONENT_TO_RENDER } from '../../constants';
import Loading from '../page/Loading';

/** props for AsyncRender component */
export interface AsyncRendererProps<TData, TPromiseFunctionProps> {
data: TData[] /** interface describing data to be consumed by render component */;
promiseFn: PromiseFn<TData[]> /** react-async-type asynchronous function */;
promiseFnProps: TPromiseFunctionProps /** options to be passed to promiseFn */;
ifLoadingRender: (
state: AsyncState<TData[]>
) => React.ReactNode /** renderProp invoked while promise returned by promiseFn is pending */;
ifFulfilledRender: (
state: AsyncState<TData[]>
) => React.ReactNode /** renderProp invoked after promise returned by promiseFn is fulfilled */;
}

// tslint:disable-next-line: no-empty-destructuring
const defaultPromiseFn = async ({}) => [];

/** default props for AsyncRenderer */
const defaultAsyncRenderProps: AsyncRendererProps<any, any> = {
data: [],
ifFulfilledRender: () => <div>{PLEASE_PROVIDE_CUSTOM_COMPONENT_TO_RENDER}</div>,
ifLoadingRender: () => <Loading />,
promiseFn: defaultPromiseFn,
promiseFnProps: {},
};

/** helps Dry out trivial react-async rendering */
export const AsyncRenderer = <TData, TPromiseFunctionProps>({
data,
ifFulfilledRender,
ifLoadingRender,
promiseFn,
promiseFnProps,
}: PropsWithChildren<
AsyncRendererProps<TData, TPromiseFunctionProps>
> = defaultAsyncRenderProps) => {
const loadPractitionersState = useAsync<TData[]>(promiseFn, promiseFnProps);

React.useEffect(() => {
if (data.length > 0) {
loadPractitionersState.setData(data);
}
}, []);

return (
<>
<IfPending state={loadPractitionersState}>
{ifLoadingRender(loadPractitionersState)}
</IfPending>
<IfFulfilled state={loadPractitionersState}>
{ifFulfilledRender(loadPractitionersState)}
</IfFulfilled>
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`src/components/AsyncRenderer AsyncRenderer loads correctly: after Promise resolution 1`] = `"Promise Resolved with 200);"`;

exports[`src/components/AsyncRenderer AsyncRenderer loads correctly: before promise resolution 1`] = `"Promise Pending);"`;

exports[`src/components/AsyncRenderer Does not load when there is pre-populated data: after Promise resolution 1`] = `"Promise Resolved with 200);"`;

exports[`src/components/AsyncRenderer Does not load when there is pre-populated data: before promise resolution 1`] = `"Promise Resolved with 100);"`;
92 changes: 92 additions & 0 deletions src/components/AsyncRenderer/tests/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { mount, shallow } from 'enzyme';
import React from 'react';
import { AsyncState, PromiseFn } from 'react-async';
import { AsyncRenderer } from '..';

// tslint:disable-next-line: no-var-requires
const fetch = require('jest-fetch-mock');

/** hasPrePopulatedData is meant to represent when there is some data
* that the component can readily consume as a fetch request to get more data is
* pending.
*/
interface TestComponentProps {
hasPrePopulatedData: boolean;
}
const defaultTestComponentProps = {
hasPrePopulatedData: false,
};
const TestComponent = (props: TestComponentProps = defaultTestComponentProps) => {
interface PromiseFnProps {
otherNumber: number;
}
const samplePromiseFn: PromiseFn<number[]> = async (
{ otherNumber },
{ signal }: AbortController
) =>
fetch('https://someUrl.org', signal)
.then(() => [otherNumber])
.catch(() => [400]);

const asyncRendererProps = {
data: props.hasPrePopulatedData ? [100] : [],
ifFulfilledRender: ({ data }: AsyncState<number[]>) => <p>Promise Resolved with {data}</p>,
ifLoadingRender: () => <p>Promise Pending</p>,
promiseFn: samplePromiseFn,
promiseFnProps: { otherNumber: 200 },
};

return (
<div>
<AsyncRenderer<number, PromiseFnProps> {...asyncRendererProps} />
);
</div>
);
};

describe('src/components/AsyncRenderer', () => {
it('renders without crashing', () => {
// its a type thing too
interface PromiseFnProps {
otherNumber: number;
}
const samplePromiseFn: PromiseFn<number[]> = async ({ otherNumber }) => {
return [otherNumber];
};

const props = {
data: [1],
ifFulfilledRender: () => <p>Promise Resolved</p>,
ifLoadingRender: () => <p>Promise Pending</p>,
promiseFn: samplePromiseFn,
promiseFnProps: { otherNumber: 5 },
};

shallow(<AsyncRenderer<number, PromiseFnProps> {...props} />);
});

it('AsyncRenderer loads correctly', async () => {
fetch.once(JSON.stringify([200]));
const wrapper = mount(<TestComponent hasPrePopulatedData={false} />);
// before fetch is resolved; we should see content rendered by the ifPending render prop
expect(wrapper.text()).toMatchSnapshot('before promise resolution');
expect(wrapper.text().includes('Pending')).toEqual(true);
expect(wrapper.text().includes('Resolved')).toEqual(false);
await new Promise(resolve => setImmediate(resolve));
// upon flushing pending promises; we should now see content rendered by the ifFulfilled render prop
expect(wrapper.text()).toMatchSnapshot('after Promise resolution');
expect(wrapper.text().includes('Pending')).toEqual(false);
expect(wrapper.text().includes('Resolved')).toEqual(true);
});

it('Does not load when there is pre-populated data', async () => {
// this simulates a condition such as when there is already data in the store
fetch.once(JSON.stringify([200]));
const wrapper = mount(<TestComponent hasPrePopulatedData={true} />);
// before the promise is resolved we render the initial data which is a [100]
expect(wrapper.text()).toMatchSnapshot('before promise resolution');
await new Promise(resolve => setImmediate(resolve));
// after promise resolution we know render the fetched data which is [200]
expect(wrapper.text()).toMatchSnapshot('after Promise resolution');
});
});
1 change: 1 addition & 0 deletions src/configs/lang.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,3 +437,4 @@ export const FAILED_TO_EXTRACT_PLAN_RECORD = translate(
'FAILED_TO_EXTRACT_PLAN_RECORD',
'Failed to extract a plan record'
);
export const NO_DATA_TO_SHOW = translate('NO_DATA_TO_SHOW', 'No data to show');
3 changes: 3 additions & 0 deletions src/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,5 +120,8 @@ export const MOSQUITO_COLLECTION_CODE = 'Mosquito Collection';
export const PRACTITIONER_CODE = {
text: 'Community Health Worker',
};

/** Field to sort plans by */
export const SORT_BY_EFFECTIVE_PERIOD_START_FIELD = 'plan_effective_period_start';
export const PLEASE_PROVIDE_CUSTOM_COMPONENT_TO_RENDER =
'Please provide a custom component to be rendered';
13 changes: 10 additions & 3 deletions src/containers/pages/IRS/JurisdictionsReport/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import DrillDownTable, { hasChildrenFunc } from '@onaio/drill-down-table';
import reducerRegistry from '@onaio/redux-reducer-registry';
import superset, { SupersetFormData } from '@onaio/superset-connector';
import { get } from 'lodash';
import React, { SyntheticEvent, useEffect, useState } from 'react';
import React, { SyntheticEvent, useEffect, useRef, useState } from 'react';
import { Helmet } from 'react-helmet';
import { connect } from 'react-redux';
import { RouteComponentProps } from 'react-router';
Expand Down Expand Up @@ -66,6 +66,7 @@ const JurisdictionReport = (props: GenericJurisdictionProps & RouteComponentProp
(props.match && props.match.params && props.match.params.jurisdictionId) || null
);
const [loading, setLoading] = useState<boolean>(true);
const isMounted = useRef<boolean>(true);

let planId: string | null = null;
if (props.match && props.match.params && props.match.params.planId) {
Expand Down Expand Up @@ -103,14 +104,20 @@ const JurisdictionReport = (props: GenericJurisdictionProps & RouteComponentProp
);
});
} catch (e) {
// todo - handle error https://github.com/onaio/reveal-frontend/issues/300
displayError(e);
} finally {
setLoading(false);
if (isMounted.current) {
setLoading(false);
}
}
}

useEffect(() => {
isMounted.current = true;
loadData().catch(err => displayError(err));
return () => {
isMounted.current = false;
};
}, []);

useEffect(() => {
Expand Down
75 changes: 30 additions & 45 deletions src/containers/pages/IRS/assignments/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import ListView from '@onaio/list-view';
import reducerRegistry, { Registry } from '@onaio/redux-reducer-registry';
import React, { useEffect } from 'react';
import React from 'react';
import { Helmet } from 'react-helmet';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { Col, Row } from 'reactstrap';
import { Store } from 'redux';
import { AsyncRenderer, AsyncRendererProps } from '../../../../components/AsyncRenderer';
import HeaderBreadcrumb from '../../../../components/page/HeaderBreadcrumb/HeaderBreadcrumb';
import Loading from '../../../../components/page/Loading';
import {
Expand All @@ -22,12 +23,13 @@ import { planStatusDisplay } from '../../../../configs/settings';
import {
ASSIGN_PLAN_URL,
HOME_URL,
OPENSRP_PLANS,
PLAN_RECORD_BY_ID,
REPORT_IRS_PLAN_URL,
} from '../../../../constants';
import { displayError } from '../../../../helpers/errors';
import { extractPlanRecordResponseFromPlanPayload } from '../../../../helpers/utils';
import {
asyncGetPlanRecords,
AsyncGetPlanRecordsOptions,
} from '../../../../helpers/dataLoadingUtils/plansService';
import { OpenSRPService } from '../../../../services/opensrp';
import IRSPlansReducer, {
reducerName as IRSPlansReducerName,
Expand All @@ -37,7 +39,6 @@ import {
InterventionType,
makePlansArraySelector,
PlanRecord,
PlanRecordResponse,
PlanStatus,
} from '../../../../store/ducks/plans';

Expand All @@ -54,11 +55,9 @@ interface PlanAssignmentsListProps {
service: typeof OpenSRPService;
}

/** Simple component that loads plans and allows you to manage plan-jurisdiction-organization assignments */
/** Simple component that loads plans and allows you to manage plan-jurisdiciton-organization assignments */
const IRSAssignmentPlansList = (props: PlanAssignmentsListProps) => {
const { fetchPlans, plans, service } = props;

const OpenSrpPlanService = new service(OPENSRP_PLANS);
const { fetchPlans, plans } = props;

const pageTitle: string = `${ASSIGN_PLANS}`;

Expand All @@ -75,33 +74,6 @@ const IRSAssignmentPlansList = (props: PlanAssignmentsListProps) => {
],
};

/** async function to load the data */
async function loadData() {
try {
await OpenSrpPlanService.list()
.then(planResults => {
if (planResults) {
const planRecords: PlanRecordResponse[] =
planResults.map(extractPlanRecordResponseFromPlanPayload) || [];
return fetchPlans(planRecords);
}
})
.catch(err => {
displayError(err);
});
} catch (e) {
displayError(e);
}
}

useEffect(() => {
loadData().catch(error => displayError(error));
}, []);

if (plans.length < 1) {
return <Loading />;
}

const listViewProps = {
data: plans.map(planObj => {
return [
Expand All @@ -118,6 +90,27 @@ const IRSAssignmentPlansList = (props: PlanAssignmentsListProps) => {
tableClass: 'table table-bordered plans-list',
};

const asyncRendererProps: AsyncRendererProps<PlanRecord, AsyncGetPlanRecordsOptions> = {
data: plans,
ifFulfilledRender: () => (
<Row>
<Col>
{plans.length < 1 ? (
<span>{NO_PLANS_LOADED_MESSAGE}</span>
) : (
<ListView {...listViewProps} />
)}
Comment on lines +98 to +102
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to do this in the AsyncRenderer component?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would then make the component re-suable only when we want to show a table. Do you think its a reasonable constraint, coz anyway, I'd be happy to refactor the AsyncRenderer. @moshthepitt

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is a fair point @p-netm - However how do we make it such that we dont end up rewriting the same code in different components that want to display a list of items?

</Col>
</Row>
),
ifLoadingRender: () => <Loading />,
promiseFn: asyncGetPlanRecords,
promiseFnProps: {
fetchPlansCreator: fetchPlans,
service: props.service,
},
};

return (
<div>
<Helmet>
Expand All @@ -129,15 +122,7 @@ const IRSAssignmentPlansList = (props: PlanAssignmentsListProps) => {
<h3 className="mt-3 mb-3 page-title">{pageTitle}</h3>
</Col>
</Row>
<Row>
<Col>
{plans.length < 1 ? (
<span>{NO_PLANS_LOADED_MESSAGE}</span>
) : (
<ListView {...listViewProps} />
)}
</Col>
</Row>
<AsyncRenderer<PlanRecord, AsyncGetPlanRecordsOptions> {...asyncRendererProps} />
</div>
);
};
Expand Down
Loading