Skip to content

Commit

Permalink
Merge pull request #341 from dhis2/feat/usemodelgist
Browse files Browse the repository at this point in the history
feat: data element list
  • Loading branch information
kabaros authored Jul 11, 2023
2 parents 4999068 + c4c507f commit 83c6a47
Show file tree
Hide file tree
Showing 40 changed files with 1,769 additions and 446 deletions.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@
"typescript": "^5.0.4"
},
"dependencies": {
"@dhis2/app-runtime": "^3.9.4",
"@dhis2/app-runtime": "^3.9.3",
"@dhis2/ui": "^8.13.10",
"use-query-params": "^2.2.1",
"react-router-dom": "^6.11.2",
"zustand": "^4.3.8"
}
Expand Down
13 changes: 10 additions & 3 deletions src/app/routes/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
RouteObject,
useParams,
} from 'react-router-dom'
import { QueryParamProvider } from 'use-query-params'
import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6'
import {
SECTIONS_MAP,
SCHEMA_SECTIONS,
Expand All @@ -24,7 +26,6 @@ import { CheckAuthorityForSection } from './CheckAuthorityForSection'
import { DefaultErrorRoute } from './DefaultErrorRoute'
import { LegacyAppRedirect } from './LegacyAppRedirect'
import { getSectionPath, routePaths } from './routePaths'

// This loads all the overview routes in the same chunk since they resolve to the same promise
// see https://reactrouter.com/en/main/route/lazy#multiple-routes-in-a-single-file
// Overviews are small, and the AllOverview would load all the other overviews anyway,
Expand Down Expand Up @@ -114,7 +115,14 @@ const schemaSectionRoutes = Object.values(SCHEMA_SECTIONS).map((section) => (
))

const routes = createRoutesFromElements(
<Route element={<Layout />} errorElement={<DefaultErrorRoute />}>
<Route
element={
<QueryParamProvider adapter={ReactRouter6Adapter}>
<Layout />
</QueryParamProvider>
}
errorElement={<DefaultErrorRoute />}
>
<Route
path="/"
element={<Navigate to={routePaths.overviewRoot} replace />}
Expand Down Expand Up @@ -143,7 +151,6 @@ const routes = createRoutesFromElements(
)

export const hashRouter = createHashRouter(routes)

export const ConfiguredRouter = () => {
return <RouterProvider router={hashRouter} />
}
1 change: 1 addition & 0 deletions src/components/index.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { Loader } from './loading'
export { HidePreventUnmount } from './HidePreventUnmount'
export * from './sectionList'
6 changes: 3 additions & 3 deletions src/components/loading/Loader.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import i18n from '@dhis2/d2-i18n'
import { CircularLoader, NoticeBox } from '@dhis2/ui'
import { NoticeBox } from '@dhis2/ui'
import React from 'react'
import { QueryResponse } from '../../types'
import styles from './Loader.module.css'
import { LoadingSpinner } from './LoadingSpinner'

interface LoaderProps {
children: React.ReactNode
Expand All @@ -11,7 +11,7 @@ interface LoaderProps {
}
export const Loader = ({ children, queryResponse, label }: LoaderProps) => {
if (queryResponse.loading) {
return <CircularLoader className={styles.loadingSpinner} />
return <LoadingSpinner />
}
if (queryResponse.error) {
const message = queryResponse.error?.message
Expand Down
16 changes: 16 additions & 0 deletions src/components/loading/LoadingSpinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { CircularLoader } from '@dhis2/ui'
import cx from 'classnames'
import React from 'react'
import styles from './Loader.module.css'

export const LoadingSpinner = ({
centered = true,
...rest
}: {
centered?: boolean
}) => (
<CircularLoader
{...rest}
className={cx(styles.loadingSpinner, { [styles.centered]: centered })}
/>
)
33 changes: 33 additions & 0 deletions src/components/sectionList/SectionList.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
.listHeaderNormal {
background-color: #fff;
width: 100%;
height: 24px;
display: flex;
align-items: center;
height: 48px;
padding: var(--spacers-dp8);
margin-top: var(--spacers-dp8);
gap: var(--spacers-dp8);
}

.listHeaderNormal a {
line-height: var(--spacers-dp16);
}

.listRow td {
padding-top: var(--spacers-dp8);
padding-bottom: var(--spacers-dp8);
}

.listActions {
display: flex;
gap: var(--spacers-dp8);
}

.listActions button {
padding: 0 2px !important;
}

.listEmpty {
text-align: center;
}
54 changes: 54 additions & 0 deletions src/components/sectionList/SectionList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import i18n from '@dhis2/d2-i18n'
import {
DataTable,
DataTableColumnHeader,
DataTableRow,
TableBody,
TableHead,
Checkbox,
} from '@dhis2/ui'
import React, { PropsWithChildren } from 'react'
import { CheckBoxOnChangeObject } from '../../types'
import { IdentifiableObject } from '../../types/generated'
import { SelectedColumns } from './types'

type SectionListProps<Model extends IdentifiableObject> = {
headerColumns: SelectedColumns<Model>
onSelectAll: (checked: boolean) => void
allSelected?: boolean
}

export const SectionList = <Model extends IdentifiableObject>({
allSelected,
headerColumns,
children,
onSelectAll,
}: PropsWithChildren<SectionListProps<Model>>) => {
return (
<DataTable>
<TableHead>
<DataTableRow>
<DataTableColumnHeader width="48px">
<Checkbox
checked={allSelected}
onChange={({ checked }: CheckBoxOnChangeObject) =>
onSelectAll(checked)
}
/>
</DataTableColumnHeader>
{headerColumns.map((headerColumn) => (
<DataTableColumnHeader
key={headerColumn.modelPropertyName}
>
{headerColumn.label}
</DataTableColumnHeader>
))}
<DataTableColumnHeader>
{i18n.t('Actions')}
</DataTableColumnHeader>
</DataTableRow>
</TableHead>
<TableBody>{children}</TableBody>
</DataTable>
)
}
13 changes: 13 additions & 0 deletions src/components/sectionList/SectionListLoader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { DataTableRow, DataTableCell } from '@dhis2/ui'
import React from 'react'
import { LoadingSpinner } from '../loading/LoadingSpinner'

export const SectionListLoader = () => {
return (
<DataTableRow>
<DataTableCell colSpan="100%">
<LoadingSpinner />
</DataTableCell>
</DataTableRow>
)
}
32 changes: 32 additions & 0 deletions src/components/sectionList/SectionListMessages.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import i18n from '@dhis2/d2-i18n'
import { DataTableRow, DataTableCell, NoticeBox } from '@dhis2/ui'
import React, { PropsWithChildren } from 'react'
import css from './SectionList.module.css'

export const SectionListMessage = ({ children }: PropsWithChildren) => {
return (
<DataTableRow>
<DataTableCell colSpan="100%">{children}</DataTableCell>
</DataTableRow>
)
}

export const SectionListEmpty = () => {
return (
<SectionListMessage>
<p className={css.listEmpty}>
{i18n.t("There aren't any items that match your filter.")}
</p>
</SectionListMessage>
)
}

export const SectionListError = () => {
return (
<SectionListMessage>
<NoticeBox error={true} title={i18n.t('An error occurred')}>
{i18n.t('An error occurred while loading the items.')}
</NoticeBox>
</SectionListMessage>
)
}
170 changes: 170 additions & 0 deletions src/components/sectionList/SectionListPagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { Pagination, DataTableRow, DataTableCell } from '@dhis2/ui'
import React, { useEffect, useCallback, useMemo } from 'react'
import {
useQueryParam,
NumericObjectParam,
withDefault,
} from 'use-query-params'
import { GistPaginator } from '../../lib/'
import { GistCollectionResponse } from '../../types/generated'

type SectionListPaginationProps = {
data: GistCollectionResponse | undefined
}

type PaginationQueryParams = {
page: number
pageSize: number
}

const defaultPaginationQueryParams = {
page: 1,
pageSize: 20,
}

const PAGE_SIZES = [5, 10, 20, 30, 40, 50, 75, 100]

const paginationQueryParams = withDefault(
NumericObjectParam,
defaultPaginationQueryParams
)

export const usePaginationQueryParams = () => {
const [params, setParams] = useQueryParam('pager', paginationQueryParams, {
removeDefaultsFromUrl: true,
})

return useMemo(
() => [validatePagerParams(params), setParams] as const,
[params, setParams]
)
}

const validatePagerParams = (
params: typeof paginationQueryParams.default
): PaginationQueryParams => {
if (!params) {
return defaultPaginationQueryParams
}
const isValid = Object.values(params).every(
(value) => value && !isNaN(value)
)
if (!isValid) {
return defaultPaginationQueryParams
}

const pageSize = params.pageSize as number
const page = params.page as number

// since pageSize can be changed in URL, find the closest valid pageSize
const validatedPageSize = PAGE_SIZES.reduce((prev, curr) =>
Math.abs(curr - pageSize) < Math.abs(prev - pageSize) ? curr : prev
)

return {
page,
pageSize: validatedPageSize,
}
}

function useUpdatePaginationParams(
data?: GistCollectionResponse
): GistPaginator {
const pager = data?.pager
const [, setParams] = usePaginationQueryParams()

const getNextPage = useCallback(() => {
if (!pager?.nextPage) {
return false
}
setParams((prevPager) => ({ ...prevPager, page: pager.page + 1 }))
return true
}, [pager, setParams])

const getPrevPage = useCallback(() => {
if (!pager?.prevPage) {
return false
}
setParams((prevPager) => ({ ...prevPager, page: pager.page - 1 }))
return true
}, [pager, setParams])

const goToPage = useCallback(
(page: number) => {
if (!pager?.pageCount || page > pager.pageCount) {
return false
}
setParams((prevPager) => ({ ...prevPager, page }))
return true
},
[pager, setParams]
)

const changePageSize = useCallback(
(pageSize: number) => {
setParams((prevPager) => ({ ...prevPager, pageSize: pageSize }))
return true
},
[setParams]
)

return {
getNextPage,
getPrevPage,
goToPage,
changePageSize,
pager,
}
}

/** clamps a number between min and max,
*resulting in a number between min and max (inclusive).
*/
const clamp = (value: number, min: number, max: number) =>
Math.max(min, Math.min(value, max))

export const SectionListPagination = ({ data }: SectionListPaginationProps) => {
const [paginationParams] = usePaginationQueryParams()
const pagination = useUpdatePaginationParams(data)

useEffect(() => {
// since page can be controlled by params
// do a refetch if page is out of bounds
const page = paginationParams.page

const clamped = clamp(page, 1, pagination.pager?.pageCount || 1)
if (page !== clamped) {
pagination.goToPage(clamped)
}
}, [pagination, paginationParams.page])

if (!pagination.pager?.total) {
return null
}

// Prevent out of bounds for page-selector
// note that this will make the UI-selector out of sync with the actual data
// but paginator throws error if page is out of bounds
// useEffect above will refetch last page - so this should only be for a very brief render
const page = clamp(
paginationParams.page,
1,
pagination.pager?.pageCount || 1
)

return (
<DataTableRow>
<DataTableCell colSpan="100%">
<Pagination
pageSizes={PAGE_SIZES.map((s) => s.toString())}
page={page}
pageSize={paginationParams.pageSize}
pageCount={pagination.pager?.pageCount}
total={pagination.pager?.total}
onPageSizeChange={pagination.changePageSize}
onPageChange={pagination.goToPage}
/>
</DataTableCell>
</DataTableRow>
)
}
Loading

0 comments on commit 83c6a47

Please sign in to comment.