diff --git a/web/src/components/Common/Checkbox.tsx b/web/src/components/Common/Checkbox.tsx index 8c51af6e..7627208d 100644 --- a/web/src/components/Common/Checkbox.tsx +++ b/web/src/components/Common/Checkbox.tsx @@ -1,5 +1,6 @@ -import React, { PropsWithChildren, ReactNode, useCallback } from 'react' -import { FormGroup as FormGroupBase, Input, Label as LabelBase } from 'reactstrap' +import React, { PropsWithChildren, ReactNode, useCallback, useEffect, useMemo, useRef } from 'react' +import type { StrictOmit } from 'ts-essentials' +import { FormGroup as FormGroupBase, Input, InputProps, Label as LabelBase } from 'reactstrap' import styled from 'styled-components' import type { SetterOrUpdater } from 'src/types' @@ -57,6 +58,54 @@ export function CheckboxWithIcon({ title, label, icon, checked, setChecked }: Ch ) } +export enum CheckboxState { + Checked, + Unchecked, + Indeterminate, +} + +export interface CheckboxIndeterminateProps extends StrictOmit { + state?: CheckboxState + onChange?(state: CheckboxState): void +} + +/** Checkbox with 3 states: checked, unchecked, indeterminate */ +export function CheckboxIndeterminate({ state, onChange, ...restProps }: CheckboxIndeterminateProps) { + const inputRef = useRef(null) + + const handleOnChange = useCallback(() => { + if (state === CheckboxState.Checked) { + return onChange?.(CheckboxState.Unchecked) + } + return onChange?.(CheckboxState.Checked) + }, [onChange, state]) + + useEffect(() => { + if (inputRef?.current) { + inputRef.current.indeterminate = state === CheckboxState.Indeterminate + } + }, [state]) + + const checked = useMemo(() => state === CheckboxState.Checked, [state]) + + return +} + +export interface CheckboxIndeterminateWithTextProps extends Omit { + label: string +} + +export function CheckboxIndeterminateWithText({ label, title, ...restProps }: CheckboxIndeterminateWithTextProps) { + return ( + + + + ) +} + const FormGroup = styled(FormGroupBase)` overflow-x: hidden; ` diff --git a/web/src/components/Sidebar/SidebarSectionGeography.tsx b/web/src/components/Sidebar/SidebarSectionGeography.tsx index 9e2fef38..873e2f85 100644 --- a/web/src/components/Sidebar/SidebarSectionGeography.tsx +++ b/web/src/components/Sidebar/SidebarSectionGeography.tsx @@ -1,20 +1,14 @@ -import { sortBy } from 'lodash-es' +import { isEmpty, sortBy } from 'lodash-es' import React, { useMemo } from 'react' import dynamic from 'next/dynamic' -import { useRecoilState, useSetRecoilState } from 'recoil' +import { useRecoilState } from 'recoil' import styled from 'styled-components' -import { Button, Col, Form, FormGroup, Row } from 'reactstrap' +import { Form } from 'reactstrap' import { useTranslationSafe } from 'src/helpers/useTranslationSafe' import { Pathogen, usePathogen, useRegionsDataQuery } from 'src/io/getData' import { fuzzySearchObj } from 'src/helpers/fuzzySearch' -import { - continentAtom, - countryAtom, - geographyDisableAllAtom, - geographyEnableAllAtom, - geographySearchTermAtom, -} from 'src/state/geography.state' -import { CheckboxWithIcon } from 'src/components/Common/Checkbox' +import { continentAtom, countryAtom, geographyEnableAllAtom, geographySearchTermAtom } from 'src/state/geography.state' +import { CheckboxIndeterminateWithText, CheckboxWithIcon } from 'src/components/Common/Checkbox' import { transliterate } from 'transliteration' const GeoIconCountry = dynamic(() => import('src/components/Common/GeoIconCountry').then((m) => m.GeoIconCountry)) @@ -61,12 +55,18 @@ export function SidebarSectionGeography({ pathogenName }: SidebarSectionGeograph }), ), ] - return sortBy(scored, ({ score }) => score).map(({ component }) => component) - }, [countries, pathogenName, regions, searchTerm, t]) + + const checkboxes = sortBy(scored, ({ score }) => score).map(({ component }) => component) + + if (isEmpty(searchTerm)) { + checkboxes.unshift() + } + + return checkboxes + }, [countries, pathogen, pathogenName, regions, searchTerm, t]) return ( -
{checkboxes}
) @@ -78,21 +78,14 @@ export interface GeographySelectAllProps { export function GeographySelectAll({ pathogen }: GeographySelectAllProps) { const { t } = useTranslationSafe() - const selectAll = useSetRecoilState(geographyEnableAllAtom(pathogen.name)) - const deselectAll = useSetRecoilState(geographyDisableAllAtom(pathogen.name)) + const [isAllEnabled, setIsAllEnabled] = useRecoilState(geographyEnableAllAtom(pathogen.name)) return ( - - - - - - - - + ) } diff --git a/web/src/components/Sidebar/SidebarSectionVariants.tsx b/web/src/components/Sidebar/SidebarSectionVariants.tsx index 8e5e0f5b..36629576 100644 --- a/web/src/components/Sidebar/SidebarSectionVariants.tsx +++ b/web/src/components/Sidebar/SidebarSectionVariants.tsx @@ -1,14 +1,15 @@ import React, { useMemo } from 'react' -import { Button, Col, Form, FormGroup, Row } from 'reactstrap' -import { useRecoilState, useSetRecoilState } from 'recoil' +import { Form } from 'reactstrap' +import { useRecoilState } from 'recoil' import styled from 'styled-components' import { ColoredBox } from 'src/components/Common/ColoredBox' import { fuzzySearch } from 'src/helpers/fuzzySearch' import { useTranslationSafe } from 'src/helpers/useTranslationSafe' import { Pathogen, usePathogen, useVariantsDataQuery, useVariantStyle } from 'src/io/getData' import { variantsSearchTermAtom } from 'src/state/geography.state' -import { variantAtom, variantsDisableAllAtom, variantsEnableAllAtom } from 'src/state/variants.state' -import { CheckboxWithIcon } from 'src/components/Common/Checkbox' +import { variantAtom, variantsEnableAllAtom } from 'src/state/variants.state' +import { CheckboxIndeterminateWithText, CheckboxWithIcon } from 'src/components/Common/Checkbox' +import { isEmpty } from 'lodash-es' const Container = styled.div` display: flex; @@ -23,16 +24,17 @@ export function SidebarSectionVariants({ pathogenName }: SidebarSectionVariantsP const pathogen = usePathogen(pathogenName) const { variants } = useVariantsDataQuery(pathogenName) const [searchTerm] = useRecoilState(variantsSearchTermAtom) - const checkboxes = useMemo( - () => - fuzzySearch(variants, searchTerm).map(({ item }) => ( - - )), - [pathogenName, searchTerm, variants], - ) + const checkboxes = useMemo(() => { + const checkboxes = fuzzySearch(variants, searchTerm).map(({ item }) => ( + + )) + if (isEmpty(searchTerm)) { + checkboxes.unshift() + } + return checkboxes + }, [pathogen, pathogenName, searchTerm, variants]) return ( -
{checkboxes}
) @@ -44,21 +46,14 @@ export interface VariantsSelectAllProps { export function VariantsSelectAll({ pathogen }: VariantsSelectAllProps) { const { t } = useTranslationSafe() - const selectAll = useSetRecoilState(variantsEnableAllAtom(pathogen.name)) - const deselectAll = useSetRecoilState(variantsDisableAllAtom(pathogen.name)) + const [isAllEnabled, setIsAllEnabled] = useRecoilState(variantsEnableAllAtom(pathogen.name)) return ( - - - - - - - - + ) } diff --git a/web/src/state/geography.state.ts b/web/src/state/geography.state.ts index f3135b55..774aa2c8 100644 --- a/web/src/state/geography.state.ts +++ b/web/src/state/geography.state.ts @@ -5,7 +5,7 @@ import { GeographyData } from 'src/io/getData' import { getDataRootUrl } from 'src/io/getDataRootUrl' import urljoin from 'url-join' import { isDefaultValue } from 'src/state/utils/isDefaultValue' -import { ErrorInternal } from 'src/helpers/ErrorInternal' +import { CheckboxState } from 'src/components/Common/Checkbox' const geographyAtom = atomFamily({ key: 'geographyAtom', @@ -91,33 +91,47 @@ export const countryAtom = selectorFamily(items: T[]) { + return items.every((item) => item.enabled) +} + +function isDisabledAll(items: T[]) { + return items.every((item) => !item.enabled) +} + function setEnabledAll(items: T[], enabled: boolean) { return items.map((item) => ({ ...item, enabled })) } -export const geographyEnableAllAtom = selectorFamily({ +export const geographyEnableAllAtom = selectorFamily({ key: 'geographyEnableAllAtom', - get() { - throw new ErrorInternal("Attempt to read from write-only atom: 'geographyEnableAllAtom'") - }, - set: + get: (region) => - ({ get, set }) => { - set(countriesAtom(region), setEnabledAll(get(countriesAtom(region)), true)) - set(continentsAtom(region), setEnabledAll(get(continentsAtom(region)), true)) + ({ get }) => { + const countries = get(countriesAtom(region)) + const regions = get(continentsAtom(region)) + const locations = [...regions, ...countries] + if (isEnabledAll(locations)) { + return CheckboxState.Checked + } + if (isDisabledAll(locations)) { + return CheckboxState.Unchecked + } + return CheckboxState.Indeterminate }, -}) - -export const geographyDisableAllAtom = selectorFamily({ - key: 'geographyDisableAllAtom', - get() { - throw new ErrorInternal("Attempt to read from write-only atom: 'geographyDisableAllAtom'") - }, set: (region) => - ({ get, set }) => { - set(countriesAtom(region), setEnabledAll(get(countriesAtom(region)), false)) - set(continentsAtom(region), setEnabledAll(get(continentsAtom(region)), false)) + ({ get, set, reset }, value) => { + if (isDefaultValue(value)) { + reset(countriesAtom(region)) + reset(continentsAtom(region)) + } else if (value === CheckboxState.Checked) { + set(countriesAtom(region), setEnabledAll(get(countriesAtom(region)), true)) + set(continentsAtom(region), setEnabledAll(get(continentsAtom(region)), true)) + } else if (value === CheckboxState.Unchecked) { + set(countriesAtom(region), setEnabledAll(get(countriesAtom(region)), false)) + set(continentsAtom(region), setEnabledAll(get(continentsAtom(region)), false)) + } }, }) diff --git a/web/src/state/variants.state.tsx b/web/src/state/variants.state.tsx index 35dd2da9..a270faa4 100644 --- a/web/src/state/variants.state.tsx +++ b/web/src/state/variants.state.tsx @@ -5,7 +5,7 @@ import { VariantsData } from 'src/io/getData' import { getDataRootUrl } from 'src/io/getDataRootUrl' import urljoin from 'url-join' import { isDefaultValue } from 'src/state/utils/isDefaultValue' -import { ErrorInternal } from 'src/helpers/ErrorInternal' +import { CheckboxState } from 'src/components/Common/Checkbox' const variantDataAtom = atomFamily({ key: 'variantDataAtom', @@ -52,30 +52,41 @@ export const variantAtom = selectorFamily(items: T[]) { + return items.every((item) => item.enabled) +} + +function isDisabledAll(items: T[]) { + return items.every((item) => !item.enabled) +} + function setEnabledAll(items: T[], enabled: boolean) { return items.map((item) => ({ ...item, enabled })) } -export const variantsEnableAllAtom = selectorFamily({ +export const variantsEnableAllAtom = selectorFamily({ key: 'variantsEnableAllAtom', - get() { - throw new ErrorInternal("Attempt to read from write-only atom: 'variantsEnableAllAtom'") - }, - set: + get: (region) => - ({ get, set }) => { - set(variantsAtom(region), setEnabledAll(get(variantsAtom(region)), true)) + ({ get }) => { + const variants = get(variantsAtom(region)) + if (isEnabledAll(variants)) { + return CheckboxState.Checked + } + if (isDisabledAll(variants)) { + return CheckboxState.Unchecked + } + return CheckboxState.Indeterminate }, -}) - -export const variantsDisableAllAtom = selectorFamily({ - key: 'variantsDisableAllAtom', - get() { - throw new ErrorInternal("Attempt to read from write-only atom: 'variantsDisableAllAtom'") - }, set: (region) => - ({ get, set }) => { - set(variantsAtom(region), setEnabledAll(get(variantsAtom(region)), false)) + ({ get, set, reset }, value) => { + if (isDefaultValue(value)) { + reset(variantsAtom(region)) + } else if (value === CheckboxState.Checked) { + set(variantsAtom(region), setEnabledAll(get(variantsAtom(region)), true)) + } else if (value === CheckboxState.Unchecked) { + set(variantsAtom(region), setEnabledAll(get(variantsAtom(region)), false)) + } }, })