Skip to content

Commit

Permalink
Merge branch 'feat/model-multi-select-component' into feat/indicator-…
Browse files Browse the repository at this point in the history
…types-merge
  • Loading branch information
Birkbjo committed Dec 2, 2024
2 parents 0faa329 + 4bc8ec3 commit f46d74d
Show file tree
Hide file tree
Showing 10 changed files with 574 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
.invisibleOption {
display: none;
}

.loader {
height: 80px;
display: flex;
align-items: center;
justify-content: center;
padding-top: 20px;
overflow: hidden;
}

.error {
display: flex;
justify-content: center;
font-size: 14px;
padding: var(--spacers-dp12) 16px;
}

.errorInnerWrapper {
display: flex;
flex-direction: column;
justify-content: center;
}

.loadingErrorLabel {
color: var(--theme-error);
}

.errorRetryButton {
background: none;
padding: 0;
border: 0;
outline: 0;
text-decoration: underline;
cursor: pointer;
}

.multiSelect {
min-width: 150px;
}
.searchField {
display: flex;
gap: var(--spacers-dp8);
position: sticky;
top: 0;
padding: var(--spacers-dp16);
box-shadow: 0 0 4px rgba(0, 0, 0, 0.4);
background: var(--colors-white);
}

.searchInput {
flex-grow: 1;
}

.clearButton {
font-size: 14px;
flex-grow: 0;
background: none;
padding: 0;
border: 0;
outline: 0;
text-decoration: underline;
cursor: pointer;
}

.clearButton:hover {
color: var(--theme-valid);
text-decoration: none;
}
154 changes: 154 additions & 0 deletions src/components/SearchableMultiSelect/SearchableMultiSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import i18n from '@dhis2/d2-i18n'
import {
CircularLoader,
Input,
MultiSelect,
MultiSelectOption,
MultiSelectProps,
} from '@dhis2/ui'
import React, { forwardRef, useEffect, useState } from 'react'
import { useDebouncedState } from '../../lib'
import classes from './SearchableMultiSelect.module.css'

export interface Option {
value: string
label: string
}

type OwnProps = {
onEndReached?: () => void
onFilterChange: ({ value }: { value: string }) => void
onRetryClick: () => void
options: Option[]
showEndLoader?: boolean
error?: string
}

export type SearchableMultiSelectPropTypes = Omit<
MultiSelectProps,
keyof OwnProps
> &
OwnProps
export const SearchableMultiSelect = ({
disabled,
error,
dense,
loading,
placeholder,
prefix,
onBlur,
onChange,
onEndReached,
onFilterChange,
onFocus,
onRetryClick,
options,
selected,
showEndLoader,
}: SearchableMultiSelectPropTypes) => {
const [loadingSpinnerRef, setLoadingSpinnerRef] = useState<HTMLElement>()

const { liveValue: filter, setValue: setFilterValue } =
useDebouncedState<string>({
initialValue: '',
onSetDebouncedValue: (value: string) => onFilterChange({ value }),
})

useEffect(() => {
// We don't want to wait for intersections when loading as that can
// cause buggy behavior
if (loadingSpinnerRef && !loading) {
const observer = new IntersectionObserver(
(entries) => {
const [{ isIntersecting }] = entries

if (isIntersecting) {
onEndReached?.()
}
},
{ threshold: 0.8 }
)

observer.observe(loadingSpinnerRef)
return () => observer.disconnect()
}
}, [loadingSpinnerRef, loading, onEndReached])

return (
<MultiSelect
className={classes.multiSelect}
selected={selected}
disabled={disabled}
error={!!error}
onChange={onChange}
placeholder={placeholder}
prefix={prefix}
onBlur={onBlur}
onFocus={onFocus}
dense={dense}
clearable={selected && selected.length > 1}
>
<div className={classes.searchField}>
<div className={classes.searchInput}>
<Input
dense
initialFocus
value={filter}
onChange={({ value }) => setFilterValue(value ?? '')}
placeholder={i18n.t('Filter options')}
type="search"
/>
</div>
</div>

{options.map(({ value, label }) => (
<MultiSelectOption key={value} value={value} label={label} />
))}

{!error && !loading && showEndLoader && (
<Loader
ref={(ref) => {
if (!!ref && ref !== loadingSpinnerRef) {
setLoadingSpinnerRef(ref)
}
}}
/>
)}

{!error && loading && <Loader />}

{error && <Error msg={error} onRetryClick={onRetryClick} />}
</MultiSelect>
)
}

const Loader = forwardRef<HTMLDivElement, object>(function Loader(_, ref) {
return (
<div ref={ref} className={classes.loader}>
<CircularLoader />
</div>
)
})

function Error({
msg,
onRetryClick,
}: {
msg: string
onRetryClick: () => void
}) {
return (
<div className={classes.error}>
<div className={classes.errorInnerWrapper}>
<span className={classes.loadingErrorLabel}>{msg}</span>
<button
className={classes.errorRetryButton}
type="button"
onClick={onRetryClick}
>
{i18n.t('Retry')}
</button>
</div>
</div>
)
}
1 change: 1 addition & 0 deletions src/components/SearchableMultiSelect/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './SearchableMultiSelect'
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React, { useCallback, useMemo } from 'react'
import { DisplayableModel } from '../../../types/models'
import {
SearchableMultiSelect,
SearchableMultiSelectPropTypes,
} from '../../SearchableMultiSelect'

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

type OwnProps<TModel> = {
selected: TModel[]
available: TModel[]
onChange: ({ selected }: { selected: TModel[] }) => void
noValueOption?: { value: string; label: string }
}

export type BaseModelMultiSelectProps<TModel extends DisplayableModel> = Omit<
SearchableMultiSelectPropTypes,
keyof OwnProps<TModel> | 'options' | 'selected'
> &
OwnProps<TModel>

/* Simple wrapper component handle generic models with MultiSelect-component. */
export const BaseModelMultiSelect = <TModel extends DisplayableModel>({
available,
selected,
onChange,
noValueOption,
...searchableMultiSelectProps
}: BaseModelMultiSelectProps<TModel>) => {
const { allModelsMap, allSingleSelectOptions, selectedOptions } =
useMemo(() => {
const allModelsMap = new Map(available.map((o) => [o.id, o]))
// due to pagination, the selected models might not be in the available list, so add them
selected.forEach((s) => {
if (!allModelsMap.get(s.id)) {
allModelsMap.set(s.id, s)
}
})

const allSingleSelectOptions = Array.from(allModelsMap).map(
([, value]) => toDisplayOption(value)
)

const selectedOptions = selected.map((s) => s.id)
if (noValueOption) {
allSingleSelectOptions.unshift(noValueOption)
}

return {
allModelsMap,
allSingleSelectOptions,
selectedOptions,
}
}, [available, selected, noValueOption])

const handleOnChange = useCallback(
({ selected }: { selected: string[] }) => {
if (!selected) {
return
}
const selectedModels = selected
.map((s) => allModelsMap.get(s))
.filter((s) => !!s)

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

return (
<SearchableMultiSelect
{...searchableMultiSelectProps}
selected={selectedOptions}
options={allSingleSelectOptions}
onChange={handleOnChange}
/>
)
}
Loading

0 comments on commit f46d74d

Please sign in to comment.