diff --git a/src/components/SearchableMultiSelect/SearchableMultiSelect.module.css b/src/components/SearchableMultiSelect/SearchableMultiSelect.module.css new file mode 100644 index 00000000..d78ac769 --- /dev/null +++ b/src/components/SearchableMultiSelect/SearchableMultiSelect.module.css @@ -0,0 +1,68 @@ +.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; +} + +.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; +} diff --git a/src/components/SearchableMultiSelect/SearchableMultiSelect.tsx b/src/components/SearchableMultiSelect/SearchableMultiSelect.tsx new file mode 100644 index 00000000..8c4c108a --- /dev/null +++ b/src/components/SearchableMultiSelect/SearchableMultiSelect.tsx @@ -0,0 +1,160 @@ +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() + + const { liveValue: filter, setValue: setFilterValue } = + useDebouncedState({ + 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 ( + 1} + > +
+
+ setFilterValue(value ?? '')} + placeholder={i18n.t('Filter options')} + /> +
+ +
+ + {options.map(({ value, label }) => ( + + ))} + + {!error && !loading && showEndLoader && ( + { + if (!!ref && ref !== loadingSpinnerRef) { + setLoadingSpinnerRef(ref) + } + }} + /> + )} + + {!error && loading && } + + {error && } +
+ ) +} + +const Loader = forwardRef(function Loader(_, ref) { + return ( +
+ +
+ ) +}) + +function Error({ + msg, + onRetryClick, +}: { + msg: string + onRetryClick: () => void +}) { + return ( +
+
+ {msg} + +
+
+ ) +} diff --git a/src/components/SearchableMultiSelect/index.ts b/src/components/SearchableMultiSelect/index.ts new file mode 100644 index 00000000..216835ed --- /dev/null +++ b/src/components/SearchableMultiSelect/index.ts @@ -0,0 +1 @@ +export * from './SearchableMultiSelect' diff --git a/src/components/metadataFormControls/ModelMultiSelect/BaseModelMultiSelect.tsx b/src/components/metadataFormControls/ModelMultiSelect/BaseModelMultiSelect.tsx new file mode 100644 index 00000000..eddb113d --- /dev/null +++ b/src/components/metadataFormControls/ModelMultiSelect/BaseModelMultiSelect.tsx @@ -0,0 +1,83 @@ +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 = { + selected: TModel[] + available: TModel[] + onChange: ({ selected }: { selected: TModel[] }) => void + noValueOption?: { value: string; label: string } +} + +export type BaseModelMultiSelectProps = Omit< + SearchableMultiSelectPropTypes, + keyof OwnProps | 'options' | 'selected' +> & + OwnProps + +/* Simple wrapper component handle generic models with MultiSelect-component. */ +export const BaseModelMultiSelect = ({ + available, + selected, + onChange, + noValueOption, + ...searchableMultiSelectProps +}: BaseModelMultiSelectProps) => { + 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 ( + + ) +} diff --git a/src/components/metadataFormControls/ModelMultiSelect/ModelMultiSelect.tsx b/src/components/metadataFormControls/ModelMultiSelect/ModelMultiSelect.tsx new file mode 100644 index 00000000..547e2f6d --- /dev/null +++ b/src/components/metadataFormControls/ModelMultiSelect/ModelMultiSelect.tsx @@ -0,0 +1,137 @@ +import React, { useMemo, useRef, useState } from 'react' +import { useInfiniteQuery, useQuery } from 'react-query' +import { useDebouncedCallback } from 'use-debounce' +import { useBoundResourceQueryFn } from '../../../lib/query/useBoundQueryFn' +import { PlainResourceQuery } from '../../../types' +import { PagedResponse } from '../../../types/generated' +import { DisplayableModel } from '../../../types/models' +import { + BaseModelMultiSelect, + BaseModelMultiSelectProps, +} from './BaseModelMultiSelect' + +type Response = PagedResponse + +const defaultQuery = { + params: { + order: 'displayName:asc', + fields: ['id', 'displayName'], + pageSize: 10, + }, +} satisfies Omit + +export type ModelMultiSelectProps = Omit< + BaseModelMultiSelectProps, + | 'available' + | 'onFilterChange' + | 'onRetryClick' + | 'onEndReached' + | 'showEndLoader' + | 'loading' + | 'error' + | 'selected' +> & { + query: Omit + onFilterChange?: (value: string) => void + select?: (value: TModel[]) => TModel[] + selected: TModel[] | string[] | undefined +} + +export const ModelMultiSelect = ({ + selected = [], + query, + select, + ...baseModelSingleSelectProps +}: ModelMultiSelectProps) => { + const queryFn = useBoundResourceQueryFn() + // keep select in ref, so we dont recompute for inline selects + const selectRef = useRef(select) + const [searchTerm, setSearchTerm] = useState('') + const searchFilter = `identifiable:token:${searchTerm}` + const filter: string[] = searchTerm ? [searchFilter] : [] + const params = query.params + + const queryObject = { + ...query, + params: { + ...defaultQuery.params, + ...params, + filter: filter.concat(params?.filter || []), + }, + } + const modelName = query.resource + + const queryResult = useInfiniteQuery({ + queryKey: [queryObject] as const, + queryFn: queryFn>, + keepPreviousData: true, + getNextPageParam: (lastPage) => + lastPage.pager.nextPage ? lastPage.pager.page + 1 : undefined, + getPreviousPageParam: (firstPage) => + firstPage.pager.prevPage ? firstPage.pager.page - 1 : undefined, + }) + + const allDataMap = useMemo(() => { + const flatData = + queryResult.data?.pages.flatMap((page) => page[modelName]) ?? [] + if (selectRef.current) { + return selectRef.current(flatData) + } + return flatData + }, [queryResult.data, modelName]) + + const selectedWithoutData = selected.filter( + (s) => allDataMap.find((d) => d.id === s) === undefined + ) + + const selectedQuery = useQuery({ + queryKey: [ + { + resource: modelName, + params: { filter: [`id:in:[${selected?.join()}]`] }, + }, + ] as const, + queryFn: queryFn>, + enabled: + typeof selected?.[0] === 'string' && selectedWithoutData.length > 0, + }) + + const resolvedSelected = useMemo(() => { + if (selectedQuery.data) { + return selectedQuery.data[modelName] + } + if (selectedWithoutData.length === 0) { + return selected.map( + (s) => allDataMap.find((d) => d.id === s) as TModel + ) + } + return selected as TModel[] + }, [ + selectedQuery.data, + selected, + modelName, + allDataMap, + selectedWithoutData.length, + ]) + console.log({ resolvedSelected, selected }) + const handleFilterChange = useDebouncedCallback(({ value }) => { + if (value != undefined) { + setSearchTerm(value) + } + baseModelSingleSelectProps.onFilterChange?.(value) + }, 250) + + return ( + + ) +} diff --git a/src/components/metadataFormControls/ModelMultiSelect/ModelMultiSelectField.tsx b/src/components/metadataFormControls/ModelMultiSelect/ModelMultiSelectField.tsx new file mode 100644 index 00000000..f10d8cb1 --- /dev/null +++ b/src/components/metadataFormControls/ModelMultiSelect/ModelMultiSelectField.tsx @@ -0,0 +1,59 @@ +import { Field } from '@dhis2/ui' +import React from 'react' +import { useField } from 'react-final-form' +import { PlainResourceQuery } from '../../../types' +import { DisplayableModel } from '../../../types/models' +import { ModelMultiSelectProps, ModelMultiSelect } from './ModelMultiSelect' + +type OwnProps = { + name: string + query: PlainResourceQuery + label?: string + placeholder?: string + helpText?: string + required?: boolean + onChange?: ModelMultiSelectProps['onChange'] +} + +type ModelMultiSelectFieldProps = Omit< + ModelMultiSelectProps, + 'selected' | 'onChange' +> & + OwnProps + +export function ModelMultiSelectField({ + name, + query, + label, + helpText, + required, + onChange, + ...modelSingleSelectProps +}: ModelMultiSelectFieldProps) { + const { input, meta } = useField(name, { + validateFields: [], + }) + + return ( + + + {...modelSingleSelectProps} + selected={input.value} + onChange={(selected) => { + input.onChange(selected) + input.onBlur() + onChange?.(selected) + }} + query={query} + /> + + ) +} diff --git a/src/components/metadataFormControls/ModelMultiSelect/index.ts b/src/components/metadataFormControls/ModelMultiSelect/index.ts new file mode 100644 index 00000000..41803719 --- /dev/null +++ b/src/components/metadataFormControls/ModelMultiSelect/index.ts @@ -0,0 +1 @@ +export * from './ModelMultiSelect' diff --git a/src/components/metadataFormControls/ModelMultiSelect/useRefreshMultiSelect.tsx b/src/components/metadataFormControls/ModelMultiSelect/useRefreshMultiSelect.tsx new file mode 100644 index 00000000..d1b53384 --- /dev/null +++ b/src/components/metadataFormControls/ModelMultiSelect/useRefreshMultiSelect.tsx @@ -0,0 +1,20 @@ +import { useCallback } from 'react' +import { useQueryClient, InvalidateQueryFilters } from 'react-query' +import { PlainResourceQuery } from '../../../types' + +export const useRefreshModelSingleSelect = ( + query: Omit +) => { + const queryClient = useQueryClient() + + return useCallback( + (invalidateFilters?: InvalidateQueryFilters) => { + console.log('invalidate', query) + queryClient.invalidateQueries({ + queryKey: [query], + // ...invalidateFilters, + }) + }, + [queryClient, query] + ) +} diff --git a/src/types/query.ts b/src/types/query.ts index 4cf166b4..26887911 100644 --- a/src/types/query.ts +++ b/src/types/query.ts @@ -10,7 +10,7 @@ type QueryParams = { pageSize?: number page?: number fields?: string | string[] - filter: string | string[] + filter?: string | string[] [key: string]: unknown }