Skip to content

Commit

Permalink
refactor(filters): refactor filters - add validation and array support
Browse files Browse the repository at this point in the history
  • Loading branch information
Birkbjo committed Jan 24, 2024
1 parent c132fd6 commit 6431fb9
Show file tree
Hide file tree
Showing 18 changed files with 365 additions and 125 deletions.
121 changes: 10 additions & 111 deletions src/components/sectionList/SectionListPagination.tsx
Original file line number Diff line number Diff line change
@@ -1,122 +1,21 @@
import { Pagination, DataTableRow, DataTableCell } from '@dhis2/ui'
import React, { useEffect, useCallback, useMemo } from 'react'
import React, { useEffect } from 'react'
import {
useQueryParam,
NumericObjectParam,
withDefault,
} from 'use-query-params'
import { Pager } from '../../types/generated'

type SectionListPaginationProps = {
pager: Pager | undefined
}

export 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,
}
}

type Paginator = {
changePageSize: (pageSize: number) => boolean
getPrevPage: () => boolean
goToPage: (page: number) => boolean
pager?: Pager
}

function useUpdatePaginationParams(pager?: Pager): Paginator {
const [, setParams] = usePaginationQueryParams()

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 {
getPrevPage,
goToPage,
changePageSize,
pager,
}
}

usePaginationQueryParams,
useUpdatePaginationParams,
PAGE_SIZES,
} from '../../lib'
import type { Pager } from '../../types/generated'
/** 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))

type SectionListPaginationProps = {
pager: Pager | undefined
}

export const SectionListPagination = ({
pager,
}: SectionListPaginationProps) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import i18n from '@dhis2/d2-i18n'
import { SingleSelect, SingleSelectOption } from '@dhis2/ui'
import React from 'react'
import { FilterKey, useSectionListFilter } from '../../../lib'
import { SelectOnChangeObject } from '../../../types'
import css from './Filters.module.css'
import { useSectionListFilter } from './useSectionListFilter'

type ConstantSelectionFilterProps = {
label: string
constants: Record<string, string>
filterKey: string
filterKey: FilterKey
filterable?: boolean
}

Expand All @@ -19,13 +19,14 @@ export const ConstantSelectionFilter = ({
filterable,
}: ConstantSelectionFilterProps) => {
const [filter, setFilter] = useSectionListFilter(filterKey)

return (
<SingleSelect
className={css.constantSelectionFilter}
onChange={({ selected }: SelectOnChangeObject) => {
setFilter(selected)
setFilter(selected ? [selected] : undefined)
}}
selected={filter}
selected={Array.isArray(filter) ? filter[0] : filter}
placeholder={label}
dense
filterable={filterable}
Expand Down
2 changes: 1 addition & 1 deletion src/components/sectionList/filters/FilterWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import i18n from '@dhis2/d2-i18n'
import { Button } from '@dhis2/ui'
import React from 'react'
import { useSectionListFilters } from './../../../lib'
import css from './Filters.module.css'
import { IdentifiableFilter } from './IdentifiableFilter'
import { useSectionListFilters } from './useSectionListFilter'

type FilterWrapperProps = React.PropsWithChildren

Expand Down
8 changes: 5 additions & 3 deletions src/components/sectionList/filters/IdentifiableFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import i18n from '@dhis2/d2-i18n'
import { Input, InputEventPayload } from '@dhis2/ui'
import React, { useEffect, useState } from 'react'
import { useDebounce } from '../../../lib'
import { InputOnChangeObject } from '../../../types'
import {
useDebounce,
IDENTIFIABLE_KEY,
useSectionListFilter,
} from '../../../lib'
import css from './Filters.module.css'
import { IDENTIFIABLE_KEY, useSectionListFilter } from './useSectionListFilter'

export const IdentifiableFilter = () => {
const [filter, setFilter] = useSectionListFilter(IDENTIFIABLE_KEY)
Expand Down
3 changes: 2 additions & 1 deletion src/lib/constants/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './sectionListViewsConfig'
export * from './sections'
export * from './translatedModelConstants'
export * from './translatedModelProperties'
export * from './sectionListView'

export const IDENTIFIABLE_KEY = 'identifiable'
3 changes: 1 addition & 2 deletions src/lib/constants/sectionListView/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
export * from './viewConfigResolver'
export * from './sectionListViewFilterKeys'
// export * from './sectionListViewsConfig'
export * from './sectionListViewsConfig'
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import i18n from '@dhis2/d2-i18n'
import type { ConfigurableFilterKey } from './sectionListViewFilterKeys'
import type { ConfigurableFilterKey } from '../../sectionList/filters/'

export interface ModelPropertyDescriptor {
label: string
Expand Down
1 change: 1 addition & 0 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type { ModelSchemas, Schema } from './useLoadApp'
export * from './errors'
export * from './user'
export * from './sections'
export * from './sectionList'
export * from './useDebounce'
export * from './routeUtils'
export * from './date'
Expand Down
14 changes: 14 additions & 0 deletions src/lib/sectionList/filters/customParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import {
DelimitedArrayParam,
encodeDelimitedArray,
decodeDelimitedArray,
} from 'use-query-params'

// default is "_" which breaks constants (delimited by _)
const ARRAY_ENTRY_SEPERATOR = ','

export const CustomDelimitedArrayParam: typeof DelimitedArrayParam = {
encode: (arr) => encodeDelimitedArray(arr, ARRAY_ENTRY_SEPERATOR),

decode: (str) => decodeDelimitedArray(str, ARRAY_ENTRY_SEPERATOR),
}
51 changes: 51 additions & 0 deletions src/lib/sectionList/filters/filterConfig.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { StringParam } from 'use-query-params'
import { z } from 'zod'
import { DataElement } from '../../../types/generated'
import { IDENTIFIABLE_KEY } from '../../constants'
import { isValidUid } from '../../models'
import { CustomDelimitedArrayParam } from './customParams'

const zodArrayIds = z.array(z.string().refine((val) => isValidUid(val)))

/* Zod schema for validation of the decoded params */
export const filterParamsSchema = z
.object({
[IDENTIFIABLE_KEY]: z.string(),
aggregationType: z.array(z.nativeEnum(DataElement.aggregationType)),
domainType: z.array(z.nativeEnum(DataElement.domainType)),
valueType: z.array(z.string()),
dataSet: zodArrayIds,
})
.partial()

/* useQueryParams config-map object
Mapping each filter to a config object that handles encoding/decoding */
export const filterQueryParamType = {
[IDENTIFIABLE_KEY]: StringParam,
aggregationType: CustomDelimitedArrayParam,
domainType: CustomDelimitedArrayParam,
valueType: CustomDelimitedArrayParam,
dataSet: CustomDelimitedArrayParam,
} as const satisfies QueryParamsConfigMap

export const validFilterKeys = Object.keys(filterQueryParamType)

export type ParsedFilterParams = z.infer<typeof filterParamsSchema>

type MapZodTypeToQueryParamConfig<TZodResultType> =
TZodResultType extends string
? typeof StringParam
: typeof CustomDelimitedArrayParam

/* Type is just used to verify that the ParamType-config matches the zod schema
Eg. that a value that is a string in zod-schema also uses StringParam for encode/decode */
type QueryParamsConfigMap = {
[key in keyof ParsedFilterParams]-?: MapZodTypeToQueryParamConfig<
ParsedFilterParams[key]
>
}

export type FilterKey = keyof ParsedFilterParams
// Identifiable is not configurable, and is always shown in the list
export type ConfigurableFilterKey = Exclude<FilterKey, typeof IDENTIFIABLE_KEY>
export type FilterKeys = FilterKey[]
2 changes: 2 additions & 0 deletions src/lib/sectionList/filters/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './filterConfig'
export * from './useSectionListFilters'
46 changes: 46 additions & 0 deletions src/lib/sectionList/filters/parseFiltersToQueryParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { SECTIONS_MAP, Section } from '../../constants'
import { FilterKey, ParsedFilterParams } from './filterConfig'

type AllValues = ParsedFilterParams[keyof ParsedFilterParams]

const defaultFilter = (key: FilterKey, value: AllValues): string => {
const isArray = Array.isArray(value)
const valuesString = isArray ? `[${value.join(',')}]` : value?.toString()
const operator = isArray ? 'in' : 'eq'
return `${key}:${operator}:${valuesString}`
}

const getQueryParamForFilter = (
key: FilterKey,
value: AllValues,
section?: Section
): string => {
if (!value) {
return ''
}
if (key === 'identifiable') {
return `identifiable:token:${value}`
}
if (key === 'dataSet') {
const v = value as string[]
if (section?.name === SECTIONS_MAP.dataElement.name) {
return `dataSetElements.dataSet.id:in:[${v.join(',')}]`
}
}
return defaultFilter(key, value)
}

export const parseFiltersToQueryParams = (
filters: ParsedFilterParams,
section?: Section
): string[] => {
const queryFilters: string[] = []
for (const [key, value] of Object.entries(filters)) {
if (!value) {
continue
}
const filter = getQueryParamForFilter(key as FilterKey, value, section)
queryFilters.push(filter)
}
return queryFilters
}
17 changes: 17 additions & 0 deletions src/lib/sectionList/filters/useFilterQueryParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useMemo } from 'react'
import {
useModelSectionHandleOrThrow,
useSectionHandle,
} from './../../routeUtils/useSectionHandle'
import { ParsedFilterParams } from './filtersQueryParamSimple'

Check failure on line 6 in src/lib/sectionList/filters/useFilterQueryParams.ts

View workflow job for this annotation

GitHub Actions / lint

Unable to resolve path to module './filtersQueryParamSimple'
import { parseFiltersToQueryParams } from './parseFiltersToQueryParams'
import { useSectionListFilters } from './useSectionListFilters'

export const useFilterQueryParams = (): string[] => {
const [filters] = useSectionListFilters()
const section = useSectionHandle()

return useMemo(() => {
return parseFiltersToQueryParams(filters, section)
}, [filters, section])
}
Loading

0 comments on commit 6431fb9

Please sign in to comment.