Skip to content

Commit 9571e05

Browse files
authored
JN-537 adding api wrapper utils (#535)
1 parent 5502960 commit 9571e05

File tree

15 files changed

+173
-178
lines changed

15 files changed

+173
-178
lines changed

ui-admin/src/api/api-utils.test.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import React, { useState } from 'react'
2+
import { useLoadingEffect } from './api-utils'
3+
import { render, screen, waitFor } from '@testing-library/react'
4+
5+
const LoadingTestComponent = () => {
6+
const [loadedList, setLoadedList] = useState<string[]>([])
7+
const { isLoading } = useLoadingEffect(async () => {
8+
const response = await Promise.resolve(['item1'])
9+
setLoadedList(response)
10+
})
11+
return <div>
12+
{!isLoading && <ul>
13+
{loadedList.map(item => <li key={item}>{item}</li>)}
14+
</ul>}
15+
{isLoading && <span>LOADING</span>}
16+
</div>
17+
}
18+
19+
describe('useLoadingEffect handles loading state', () => {
20+
it('manages isLoading', async () => {
21+
render(<LoadingTestComponent/>)
22+
expect(screen.getByText('LOADING')).toBeInTheDocument()
23+
await waitFor(() => expect(screen.getByText('item1')).toBeInTheDocument())
24+
})
25+
})

ui-admin/src/api/api-utils.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import React, { useEffect, useState } from 'react'
2+
import { failureNotification } from 'util/notifications'
3+
import { Store } from 'react-notifications-component'
4+
5+
export type ApiErrorResponse = {
6+
message: string,
7+
statusCode: number
8+
}
9+
10+
const errorSuffix = 'If this error persists, please contact [email protected]'
11+
12+
/**
13+
* performs default error message alerting if an error occurs during an API request.
14+
* shows a specific error message if the error is auth-related.
15+
*/
16+
export const defaultApiErrorHandle = (error: ApiErrorResponse,
17+
errorHeader = 'An unexpected error occurred. ') => {
18+
if (error.statusCode === 401 || error.statusCode === 403) {
19+
Store.addNotification(failureNotification(<div>
20+
<div>{errorHeader}</div>
21+
<div>Request could not be authorized
22+
-- you may need to log in again </div>
23+
<div>{errorSuffix}</div>
24+
</div>
25+
))
26+
} else {
27+
Store.addNotification(failureNotification(<div>
28+
<div>{errorHeader}</div>
29+
<div>{error.message}</div>
30+
<div>{errorSuffix}</div>
31+
</div>
32+
))
33+
}
34+
}
35+
36+
/**
37+
* utility effect for components that want to load something from the API on first render.
38+
* returns loading and error state, as well as a function that can be called to reload.
39+
*/
40+
export const useLoadingEffect = (loadingFunc: () => Promise<unknown>,
41+
deps: unknown[] = [], customErrorMsg?: string) => {
42+
const [isLoading, setIsLoading] = useState(true)
43+
const [isError, setIsError] = useState(false)
44+
const reload = () => doApiLoad(loadingFunc, { setIsError, customErrorMsg, setIsLoading })
45+
46+
useEffect(() => {
47+
reload()
48+
}, deps)
49+
return { isLoading, isError, reload }
50+
}
51+
52+
/**
53+
* utility function for wrapping an Api call in loading and error handling
54+
*/
55+
export const doApiLoad = async (loadingFunc: () => Promise<unknown>,
56+
opts: {
57+
setIsLoading?: (isLoading: boolean) => void,
58+
setIsError?: (isError: boolean) => void,
59+
customErrorMsg?: string
60+
} = {}) => {
61+
if (opts.setIsLoading) { opts.setIsLoading(true) }
62+
try {
63+
await loadingFunc()
64+
if (opts.setIsError) { opts.setIsError(false) }
65+
} catch (e) {
66+
defaultApiErrorHandle(e as ApiErrorResponse, opts.customErrorMsg)
67+
if (opts.setIsError) { opts.setIsError(true) }
68+
}
69+
if (opts.setIsLoading) { opts.setIsLoading(false) }
70+
}

ui-admin/src/api/api.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ export default {
349349
if (response.ok) {
350350
return obj
351351
}
352-
return Promise.reject(response)
352+
return Promise.reject(obj)
353353
},
354354

355355
async processResponse(response: Response) {

ui-admin/src/portal/MailingListView.tsx

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useState } from 'react'
1+
import React, { useState } from 'react'
22
import Api, { MailingListContact, PortalEnvironment } from 'api/api'
33
import { LoadedPortalContextT } from './PortalProvider'
44
import LoadingSpinner from 'util/LoadingSpinner'
@@ -16,13 +16,13 @@ import { escapeCsvValue, saveBlobAsDownload } from 'util/downloadUtils'
1616
import { failureNotification, successNotification } from '../util/notifications'
1717
import { Store } from 'react-notifications-component'
1818
import Modal from 'react-bootstrap/Modal'
19+
import { useLoadingEffect } from '../api/api-utils'
1920

2021

2122
/** show the mailing list in table */
2223
export default function MailingListView({ portalContext, portalEnv }:
2324
{portalContext: LoadedPortalContextT, portalEnv: PortalEnvironment}) {
2425
const [contacts, setContacts] = useState<MailingListContact[]>([])
25-
const [isLoading, setIsLoading] = useState(true)
2626
const [sorting, setSorting] = React.useState<SortingState>([])
2727
const [rowSelection, setRowSelection] = React.useState<Record<string, boolean>>({})
2828
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
@@ -83,17 +83,10 @@ export default function MailingListView({ portalContext, portalEnv }:
8383
}
8484
const numSelected = Object.keys(rowSelection).length
8585

86-
const loadMailingList = () => {
87-
setIsLoading(true)
88-
setRowSelection({})
89-
Api.fetchMailingList(portalContext.portal.shortcode, portalEnv.environmentName).then(result => {
90-
setContacts(result)
91-
setIsLoading(false)
92-
}).catch((e: Error) => {
93-
alert(`error loading mailing list ${ e}`)
94-
setIsLoading(false)
95-
})
96-
}
86+
const { isLoading, reload } = useLoadingEffect(async () => {
87+
const result = await Api.fetchMailingList(portalContext.portal.shortcode, portalEnv.environmentName)
88+
setContacts(result)
89+
}, [portalContext.portal.shortcode, portalEnv.environmentName])
9790

9891
const performDelete = async () => {
9992
const contactsSelected = Object.keys(rowSelection)
@@ -111,14 +104,10 @@ export default function MailingListView({ portalContext, portalEnv }:
111104
} catch {
112105
Store.addNotification(failureNotification('Error: some entries could not be removed'))
113106
}
114-
loadMailingList() // just reload the whole thing to be safe
107+
reload() // just reload the whole thing to be safe
115108
setShowDeleteConfirm(false)
116109
}
117110

118-
useEffect(() => {
119-
loadMailingList()
120-
}, [portalContext.portal.shortcode, portalEnv.environmentName])
121-
122111
return <div className="container p-3">
123112
<h1 className="h4">Mailing list </h1>
124113
<LoadingSpinner isLoading={isLoading}>

ui-admin/src/study/kits/KitEnrolleeSelection.tsx

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useState } from 'react'
1+
import React, { useState } from 'react'
22
import _keyBy from 'lodash/keyBy'
33
import _mapValues from 'lodash/mapValues'
44
import { Link } from 'react-router-dom'
@@ -18,6 +18,7 @@ import { basicTableLayout, checkboxColumnCell, ColumnVisibilityControl, Indeterm
1818
import LoadingSpinner from 'util/LoadingSpinner'
1919
import { instantToDateString } from 'util/timeUtils'
2020
import RequestKitModal from '../participants/RequestKitModal'
21+
import { useLoadingEffect } from 'api/api-utils'
2122

2223
type EnrolleeRow = Enrollee & {
2324
taskCompletionStatus: Record<string, boolean>
@@ -28,7 +29,6 @@ type EnrolleeRow = Enrollee & {
2829
*/
2930
export default function KitEnrolleeSelection({ studyEnvContext }: { studyEnvContext: StudyEnvContextT }) {
3031
const { portal, study, currentEnv, currentEnvPath } = studyEnvContext
31-
const [isLoading, setIsLoading] = useState(true)
3232
const [enrollees, setEnrollees] = useState<EnrolleeRow[]>([])
3333
const [sorting, setSorting] = React.useState<SortingState>([
3434
{ id: 'createdAt', desc: true },
@@ -44,8 +44,8 @@ export default function KitEnrolleeSelection({ studyEnvContext }: { studyEnvCont
4444
])
4545
const [showRequestKitModal, setShowRequestKitModal] = useState(false)
4646

47-
const loadEnrollees = async () => {
48-
setIsLoading(true)
47+
48+
const { isLoading, reload } = useLoadingEffect(async () => {
4949
const enrollees = await Api.fetchEnrolleesWithKits(
5050
portal.shortcode, study.shortcode, currentEnv.environmentName)
5151
const enrolleeRows = enrollees.map(enrollee => {
@@ -57,11 +57,6 @@ export default function KitEnrolleeSelection({ studyEnvContext }: { studyEnvCont
5757
return { ...enrollee, taskCompletionStatus }
5858
})
5959
setEnrollees(enrolleeRows)
60-
setIsLoading(false)
61-
}
62-
63-
useEffect(() => {
64-
loadEnrollees()
6560
}, [studyEnvContext.study.shortcode, studyEnvContext.currentEnv.environmentName])
6661

6762
const onSubmit = async (kitType: string) => {
@@ -74,7 +69,7 @@ export default function KitEnrolleeSelection({ studyEnvContext }: { studyEnvCont
7469
portal.shortcode, study.shortcode, currentEnv.environmentName, enrolleesSelected, kitType)
7570

7671
setShowRequestKitModal(false)
77-
loadEnrollees()
72+
reload()
7873
}
7974

8075
const numSelected = Object.keys(rowSelection).length

ui-admin/src/study/kits/KitList.tsx

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useState } from 'react'
1+
import React, { useState } from 'react'
22
import _capitalize from 'lodash/capitalize'
33
import _fromPairs from 'lodash/fromPairs'
44
import _groupBy from 'lodash/groupBy'
@@ -13,6 +13,7 @@ import { StudyEnvContextT } from 'study/StudyEnvironmentRouter'
1313
import LoadingSpinner from 'util/LoadingSpinner'
1414
import { basicTableLayout, ColumnVisibilityControl } from 'util/tableUtils'
1515
import { instantToDateString, isoToInstant } from 'util/timeUtils'
16+
import { useLoadingEffect } from '../../api/api-utils'
1617

1718
type KitStatusTabConfig = {
1819
status: string,
@@ -101,19 +102,11 @@ const pepperStatusToHumanStatus = (pepperStatus?: PepperKitStatus): string => {
101102
/** Loads sample kits for a study and shows them as a list. */
102103
export default function KitList({ studyEnvContext }: { studyEnvContext: StudyEnvContextT }) {
103104
const { portal, study, currentEnv } = studyEnvContext
104-
const [isLoading, setIsLoading] = useState(true)
105105
const [kits, setKits] = useState<KitRequest[]>([])
106106

107-
const loadKits = async () => {
108-
setIsLoading(true)
109-
const kits = await Api.fetchKitsByStudyEnvironment(
110-
portal.shortcode, study.shortcode, currentEnv.environmentName)
107+
const { isLoading } = useLoadingEffect(async () => {
108+
const kits= await Api.fetchKitsByStudyEnvironment(portal.shortcode, study.shortcode, currentEnv.environmentName)
111109
setKits(kits)
112-
setIsLoading(false)
113-
}
114-
115-
useEffect(() => {
116-
loadKits()
117110
}, [studyEnvContext.study.shortcode, studyEnvContext.currentEnv.environmentName])
118111

119112
const kitsByStatus = _groupBy(kits, kit => {

ui-admin/src/study/metrics/MetricGraph.tsx

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1-
import React, { useEffect, useState } from 'react'
1+
import React, { useState } from 'react'
22
import { StudyEnvContextT } from '../StudyEnvironmentRouter'
3-
import Api, { BasicMetricDatum } from '../../api/api'
4-
import { Store } from 'react-notifications-component'
5-
import { failureNotification } from '../../util/notifications'
6-
import LoadingSpinner from '../../util/LoadingSpinner'
3+
import Api, { BasicMetricDatum } from 'api/api'
4+
import LoadingSpinner from 'util/LoadingSpinner'
75
import { cloneDeep } from 'lodash'
86
import { MetricInfo } from './StudyEnvMetricsView'
97
import Plot from 'react-plotly.js'
10-
import { instantToDefaultString } from '../../util/timeUtils'
8+
import { instantToDefaultString } from 'util/timeUtils'
9+
import { useLoadingEffect } from 'api/api-utils'
1110

1211
const EXPORT_DELIMITER = '\t'
1312

@@ -19,17 +18,12 @@ export default function MetricGraph({ studyEnvContext, metricInfo }: {studyEnvCo
1918
metricInfo: MetricInfo}) {
2019
const [metricData, setMetricData] = useState<BasicMetricDatum[] | null>(null)
2120
const [plotlyTraces, setPlotlyTraces] = useState<PlotlyTimeTrace[] | null>(null)
22-
const [isLoading, setIsLoading] = useState(true)
23-
useEffect(() => {
24-
setIsLoading(true)
25-
Api.fetchMetric(studyEnvContext.portal.shortcode, studyEnvContext.study.shortcode,
26-
studyEnvContext.currentEnv.environmentName, metricInfo.name).then(result => {
27-
setPlotlyTraces(makePlotlyTraces(result))
28-
setMetricData(result)
29-
setIsLoading(false)
30-
}).catch(e => {
31-
Store.addNotification(failureNotification(e.message))
32-
})
21+
22+
const { isLoading } = useLoadingEffect(async () => {
23+
const result = await Api.fetchMetric(studyEnvContext.portal.shortcode, studyEnvContext.study.shortcode,
24+
studyEnvContext.currentEnv.environmentName, metricInfo.name)
25+
setPlotlyTraces(makePlotlyTraces(result))
26+
setMetricData(result)
3327
}, [metricInfo.name, studyEnvContext.study.shortcode, studyEnvContext.currentEnv.environmentName])
3428

3529
const copyRawData = () => {
Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1-
import React, { useEffect, useState } from 'react'
1+
import React, { useState } from 'react'
22
import { Link, useParams } from 'react-router-dom'
33
import { StudyParams } from '../../StudyRouter'
44
import Api, { Enrollee } from 'api/api'
5-
import { Store } from 'react-notifications-component'
6-
import { failureNotification } from 'util/notifications'
75
import { StudyEnvContextT } from '../../StudyEnvironmentRouter'
86
import LoadingSpinner from 'util/LoadingSpinner'
97
import EnrolleeView from './EnrolleeView'
108
import { NavBreadcrumb } from 'navbar/AdminNavbar'
9+
import { useLoadingEffect } from 'api/api-utils'
1110

1211
export type EnrolleeParams = StudyParams & {
1312
enrolleeShortcode: string,
@@ -21,26 +20,19 @@ export default function EnrolleeLoader({ studyEnvContext }: {studyEnvContext: St
2120
const params = useParams<EnrolleeParams>()
2221
const enrolleeShortcode = params.enrolleeShortcode as string
2322
const [enrollee, setEnrollee] = useState<Enrollee | null>(null)
24-
const [isLoading, setIsLoading] = useState(true)
2523

26-
const loadEnrollee = () => {
27-
Api.getEnrollee(portal.shortcode, study.shortcode, currentEnv.environmentName, enrolleeShortcode).then(result => {
28-
setEnrollee(result)
29-
setIsLoading(false)
30-
}).catch(() => {
31-
Store.addNotification(failureNotification(`Error loading participants`))
32-
})
33-
}
34-
35-
useEffect(() => {
36-
loadEnrollee()
24+
const { isLoading, reload } = useLoadingEffect(async () => {
25+
const result = await Api.getEnrollee(
26+
portal.shortcode, study.shortcode, currentEnv.environmentName, enrolleeShortcode
27+
)
28+
setEnrollee(result)
3729
}, [enrolleeShortcode])
3830

3931
return <LoadingSpinner isLoading={isLoading}>
4032
<NavBreadcrumb value={enrollee?.shortcode || ''}>
4133
<Link to={`${currentEnvPath}/participants/${enrolleeShortcode}`}>
4234
{enrollee?.shortcode}</Link>
4335
</NavBreadcrumb>
44-
<EnrolleeView enrollee={enrollee as Enrollee} studyEnvContext={studyEnvContext} onUpdate={loadEnrollee}/>
36+
<EnrolleeView enrollee={enrollee as Enrollee} studyEnvContext={studyEnvContext} onUpdate={reload}/>
4537
</LoadingSpinner>
4638
}

0 commit comments

Comments
 (0)