Skip to content

Commit

Permalink
refactor(modelTransfer): simplify and fix refresh list crash
Browse files Browse the repository at this point in the history
  • Loading branch information
Birkbjo committed Oct 23, 2024
1 parent 0aee9aa commit 97fe289
Show file tree
Hide file tree
Showing 4 changed files with 164 additions and 148 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Transfer, TransferProps } from '@dhis2/ui'
import React, { useCallback, useMemo } from 'react'
import { DisplayableModel } from '../../../types/models'

const toDisplayOption = (model: DisplayableModel) => ({
value: model.id,
label: model.displayName,
})

type OwnProps<TModel> = {
selected: TModel[]
available: TModel[]
onChange: ({ selected }: { selected: TModel[] }) => void
}

export type BaseModelTransferProps<TModel> = Omit<
TransferProps,
keyof OwnProps<TModel> | 'options' | 'selected'
> &
OwnProps<TModel>

/* Simple wrapper component handle generic models with Transfer-component. */
export const BaseModelTransfer = <TModel extends DisplayableModel>({
available,
selected,
onChange,
...transferProps
}: BaseModelTransferProps<TModel>) => {
const { allModelsMap, allTransferOptions } = useMemo(() => {
const allModels = selected.concat(available)
const allModelsMap = new Map(allModels.map((o) => [o.id, o]))
const allTransferOptions = allModels.map(toDisplayOption)
return {
allModelsMap,
allTransferOptions,
}
}, [available, selected])

const selectedTransferValues = useMemo(
() => selected.map((s) => s.id),
[selected]
)

const handleOnChange = useCallback(
({ selected }: { selected: string[] }) => {
// map the selected ids to the full model
// loop through selected to keep order
const selectedModels = selected
.map((id) => allModelsMap.get(id))
.filter((model) => !!model)

onChange({
selected: selectedModels,
})
},
[onChange, allModelsMap]
)

return (
<Transfer
{...transferProps}
selected={selectedTransferValues}
options={allTransferOptions}
onChange={handleOnChange}
/>
)
}
164 changes: 81 additions & 83 deletions src/components/metadataFormControls/ModelTransfer/ModelTransfer.tsx
Original file line number Diff line number Diff line change
@@ -1,56 +1,44 @@
import { Transfer, TransferProps } from '@dhis2/ui'
import React, {
forwardRef,
RefAttributes,
useImperativeHandle,
useMemo,
useState,
} from 'react'
import i18n from '@dhis2/d2-i18n'
import { Button, ButtonStrip } from '@dhis2/ui'
import React, { useMemo, useState } from 'react'
import { useInfiniteQuery } from 'react-query'
import { useHref } from 'react-router-dom'
import { useDebouncedCallback } from 'use-debounce'
import { getSectionNewPath } from '../../../lib'
import { useBoundResourceQueryFn } from '../../../lib/query/useBoundQueryFn'
import { PlainResourceQuery } from '../../../types'
import { PagedResponse } from '../../../types/generated'
import { DisplayableModel } from '../../../types/models'
import { LinkButton } from '../../LinkButton'
import { BaseModelTransfer, BaseModelTransferProps } from './BaseModelTransfer'
import css from './ModelTransfer.module.css'

type Response<Model> = PagedResponse<Model, string>

export type DisplayableModel = {
id: string
displayName: string
}

const defaultQuery = {
params: {
order: 'displayName:asc',
fields: ['id', 'displayName'],
},
}

const toDisplayOption = (model: DisplayableModel) => ({
value: model.id,
label: model.displayName,
})

type OwnProps<TModel> = {
selected: TModel[]
onChange: ({ selected }: { selected: TModel[] }) => void
query: PlainResourceQuery
export type ModelTranferProps<TModel extends DisplayableModel> = Omit<
BaseModelTransferProps<TModel>,
'available' | 'filterable'
> & {
query: Omit<PlainResourceQuery, 'id'>
}
type ModelTransferProps<TModel> = Omit<
TransferProps,
| keyof OwnProps<TModel>
| 'options'
| 'selected'
| 'filterable'
| 'onFilterChange'
> &
OwnProps<TModel>

type ImperativeRef = { refetch: () => void }

const BaseModelTransfer = <TModel extends DisplayableModel>(
{ selected, onChange, query, ...transferProps }: ModelTransferProps<TModel>,
ref: React.Ref<ImperativeRef>
) => {
export const ModelTransfer = <TModel extends DisplayableModel>({
selected,
query,
leftHeader,
rightHeader,
leftFooter,
filterPlaceholder,
filterPlaceholderPicked,
...baseModelTransferProps
}: ModelTranferProps<TModel>) => {
const queryFn = useBoundResourceQueryFn()
const [searchTerm, setSearchTerm] = useState('')

Expand All @@ -67,6 +55,7 @@ const BaseModelTransfer = <TModel extends DisplayableModel>(
},
}
const modelName = query.resource
const newLink = useHref(`/${getSectionNewPath(modelName)}`)

const queryResult = useInfiniteQuery({
queryKey: [queryObject] as const,
Expand All @@ -77,64 +66,73 @@ const BaseModelTransfer = <TModel extends DisplayableModel>(
getPreviousPageParam: (firstPage) =>
firstPage.pager.prevPage ? firstPage.pager.page - 1 : undefined,
})

useImperativeHandle(ref, () => ({
refetch: () => queryResult.refetch(),
}))

const allDataMap = useMemo(
() =>
new Map(
queryResult.data?.pages.flatMap((page) =>
page[modelName].map((d) => [d.id, d] as const)
)
),
() => queryResult.data?.pages.flatMap((page) => page[modelName]) ?? [],
[queryResult.data, modelName]
)

const selectedOptions = selected.map(toDisplayOption)
const loadedOptions = Array.from(allDataMap.values()).map(toDisplayOption)
// always include selected options
const allOptions = selectedOptions.concat(loadedOptions || [])

const handleOnChange = ({ selected }: { selected: string[] }) => {
// map the selected ids to the full model
// loop through selected to keep order
const selectedModels = selected
.map((id) => allDataMap.get(id))
.filter((model): model is TModel => !!model)

onChange({
selected: selectedModels,
})
}
const handleFilterChange = useDebouncedCallback(({ value }) => {
if (value != undefined) {
setSearchTerm(value)
}
}, 250)

return (
<Transfer
filterablePicked={true}
loadingPicked={false}
loading={queryResult.isFetching}
{...transferProps}
options={allOptions}
<BaseModelTransfer
enableOrderChange
height={'350px'}
optionsWidth="500px"
selectedWidth="500px"
filterable
searchTerm={searchTerm}
filterablePicked
onEndReached={queryResult.fetchNextPage}
onFilterChange={({ value }) =>
value !== undefined && setSearchTerm(value)
available={allDataMap}
selected={selected}
onFilterChange={handleFilterChange}
filterPlaceholder={
filterPlaceholder || i18n.t('Filter available items')
}
filterPlaceholderPicked={
filterPlaceholderPicked || i18n.t('Filter selected items')
}
leftHeader={<TransferHeader>{leftHeader}</TransferHeader>}
rightHeader={<TransferHeader>{rightHeader}</TransferHeader>}
leftFooter={
leftFooter ?? (
<DefaultTransferLeftFooter
onRefreshClick={queryResult.refetch}
newLink={newLink}
/>
)
}
selected={selectedOptions.map((o) => o.value)}
onChange={handleOnChange}
{...baseModelTransferProps}
/>
)
}

// this is needed to support generics with ref-forwarding
interface ModelTransferWithForwardedRef
extends React.FC<ModelTransferProps<DisplayableModel>> {
<TModel extends DisplayableModel>(
props: ModelTransferProps<TModel> & RefAttributes<ImperativeRef>
): React.ReactNode
const TransferHeader = ({ children }: { children: React.ReactNode }) => {
if (typeof children === 'string') {
return <div className={css.modelTransferHeader}>{children}</div>
}
return <>{children}</>
}

export const ModelTransfer: ModelTransferWithForwardedRef =
forwardRef(BaseModelTransfer)
const DefaultTransferLeftFooter = ({
onRefreshClick,
newLink,
}: {
onRefreshClick: () => void
newLink: string
}) => {
return (
<ButtonStrip className={css.modelTransferFooter}>
<Button small onClick={onRefreshClick}>
{i18n.t('Refresh list')}
</Button>

<LinkButton small href={newLink} target="_blank">
{i18n.t('Add new')}
</LinkButton>
</ButtonStrip>
)
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import i18n from '@dhis2/d2-i18n'
import { Button, ButtonStrip, Field, TransferProps } from '@dhis2/ui'
import React, { useRef } from 'react'
import { Field, TransferProps } from '@dhis2/ui'
import React from 'react'
import { useField } from 'react-final-form'
import { useHref } from 'react-router'
import { DisplayableModel, ModelTransfer } from '../../../components'
import { getSectionNewPath } from '../../../lib'
import { ModelTransfer } from '../../../components'
import { PlainResourceQuery } from '../../../types'
import { LinkButton } from '../../LinkButton'
import { DisplayableModel } from '../../../types/models'
import css from './ModelTransfer.module.css'

// this currently does not need a generic, because the value of the field is not passed
Expand All @@ -30,25 +27,17 @@ export function ModelTransferField({
name,
query,
label,
rightHeader,
leftHeader,
rightFooter,
rightHeader,
leftFooter,
rightFooter,
filterPlaceholder,
filterPlaceholderPicked,
}: ModelTransferFieldProps) {
const modelName = query.resource
const { input, meta } = useField<DisplayableModel[]>(name, {
multiple: true,
validateFields: [],
})
const newLink = useHref(`/${getSectionNewPath(modelName)}`)

const modelTransferHandle = useRef({
refetch: () => {
throw new Error('Not initialized')
},
})

return (
<Field
Expand All @@ -60,62 +49,19 @@ export function ModelTransferField({
className={css.moduleTransferField}
>
<ModelTransfer
ref={modelTransferHandle}
filterPlaceholder={
filterPlaceholder || i18n.t('Filter available items')
}
filterPlaceholderPicked={
filterPlaceholderPicked || i18n.t('Filter selected items')
}
selected={input.value}
onChange={({ selected }) => {
input.onChange(selected)
input.onBlur()
}}
leftHeader={<TransferHeader>{leftHeader}</TransferHeader>}
rightHeader={<TransferHeader>{rightHeader}</TransferHeader>}
leftFooter={
leftFooter ?? (
<DefaultTransferLeftFooter
onRefreshClick={modelTransferHandle.current.refetch}
newLink={newLink}
/>
)
}
leftHeader={leftHeader}
rightHeader={rightHeader}
leftFooter={leftFooter}
rightFooter={rightFooter}
filterPlaceholder={filterPlaceholder}
filterPlaceholderPicked={filterPlaceholderPicked}
query={query}
height={'350px'}
optionsWidth="500px"
selectedWidth="500px"
enableOrderChange={true}
/>
</Field>
)
}

const TransferHeader = ({ children }: { children: React.ReactNode }) => {
if (typeof children === 'string') {
return <div className={css.modelTransferHeader}>{children}</div>
}
return <>{children}</>
}

const DefaultTransferLeftFooter = ({
onRefreshClick,
newLink,
}: {
onRefreshClick: () => void
newLink: string
}) => {
return (
<ButtonStrip className={css.modelTransferFooter}>
<Button small onClick={onRefreshClick}>
{i18n.t('Refresh list')}
</Button>

<LinkButton small href={newLink} target="_blank">
{i18n.t('Add new')}
</LinkButton>
</ButtonStrip>
)
}
5 changes: 5 additions & 0 deletions src/types/models.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
// generated by https://github.com/Birkbjo/dhis2-open-api-ts
export type * from './generated'
export * from './systemSettings'

export type DisplayableModel = {
id: string
displayName: string
}

0 comments on commit 97fe289

Please sign in to comment.