Skip to content

Commit

Permalink
feat: add 'select all' checkbox in the sidebar
Browse files Browse the repository at this point in the history
  • Loading branch information
ivan-aksamentov committed Feb 23, 2023
1 parent 09b1b34 commit 617b3ae
Show file tree
Hide file tree
Showing 5 changed files with 154 additions and 92 deletions.
53 changes: 51 additions & 2 deletions web/src/components/Common/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -57,6 +58,54 @@ export function CheckboxWithIcon({ title, label, icon, checked, setChecked }: Ch
)
}

export enum CheckboxState {
Checked,
Unchecked,
Indeterminate,
}

export interface CheckboxIndeterminateProps extends StrictOmit<InputProps, 'onChange' | 'checked'> {
state?: CheckboxState
onChange?(state: CheckboxState): void
}

/** Checkbox with 3 states: checked, unchecked, indeterminate */
export function CheckboxIndeterminate({ state, onChange, ...restProps }: CheckboxIndeterminateProps) {
const inputRef = useRef<HTMLInputElement>(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 <Input {...restProps} type="checkbox" innerRef={inputRef} checked={checked} onChange={handleOnChange} />
}

export interface CheckboxIndeterminateWithTextProps extends Omit<CheckboxIndeterminateProps, 'children'> {
label: string
}

export function CheckboxIndeterminateWithText({ label, title, ...restProps }: CheckboxIndeterminateWithTextProps) {
return (
<FormGroup check title={title}>
<Label check>
<CheckboxIndeterminate {...restProps} />
<CheckboxText>{label}</CheckboxText>
</Label>
</FormGroup>
)
}

const FormGroup = styled(FormGroupBase)`
overflow-x: hidden;
`
Expand Down
49 changes: 21 additions & 28 deletions web/src/components/Sidebar/SidebarSectionGeography.tsx
Original file line number Diff line number Diff line change
@@ -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))
Expand Down Expand Up @@ -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(<GeographySelectAll key="GeographySelectAll" pathogen={pathogen} />)
}

return checkboxes
}, [countries, pathogen, pathogenName, regions, searchTerm, t])

return (
<Container>
<GeographySelectAll pathogen={pathogen} />
<Form>{checkboxes}</Form>
</Container>
)
Expand All @@ -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 (
<Row noGutters>
<Col className="d-flex">
<FormGroup className="flex-grow-0 mx-auto">
<Button type="button" color="link" onClick={selectAll}>
{t('Select all')}
</Button>
<Button type="button" color="link" onClick={deselectAll}>
{t('Deselect all')}
</Button>
</FormGroup>
</Col>
</Row>
<CheckboxIndeterminateWithText
label={t('Select all')}
title={t('Select all')}
state={isAllEnabled}
onChange={setIsAllEnabled}
/>
)
}

Expand Down
47 changes: 21 additions & 26 deletions web/src/components/Sidebar/SidebarSectionVariants.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 }) => (
<VariantsCheckbox pathogenName={pathogenName} key={item} variant={item} />
)),
[pathogenName, searchTerm, variants],
)
const checkboxes = useMemo(() => {
const checkboxes = fuzzySearch(variants, searchTerm).map(({ item }) => (
<VariantsCheckbox pathogenName={pathogenName} key={item} variant={item} />
))
if (isEmpty(searchTerm)) {
checkboxes.unshift(<VariantsSelectAll key="VariantsSelectAll" pathogen={pathogen} />)
}
return checkboxes
}, [pathogen, pathogenName, searchTerm, variants])
return (
<Container>
<VariantsSelectAll pathogen={pathogen} />
<Form>{checkboxes}</Form>
</Container>
)
Expand All @@ -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 (
<Row noGutters>
<Col className="d-flex">
<FormGroup className="flex-grow-0 mx-auto">
<Button type="button" color="link" onClick={selectAll}>
{t('Select all')}
</Button>
<Button type="button" color="link" onClick={deselectAll}>
{t('Deselect all')}
</Button>
</FormGroup>
</Col>
</Row>
<CheckboxIndeterminateWithText
label={t('Select all')}
title={t('Select all')}
state={isAllEnabled}
onChange={setIsAllEnabled}
/>
)
}

Expand Down
52 changes: 33 additions & 19 deletions web/src/state/geography.state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GeographyData, string>({
key: 'geographyAtom',
Expand Down Expand Up @@ -91,33 +91,47 @@ export const countryAtom = selectorFamily<boolean, { pathogen: string; country:
},
})

function isEnabledAll<T extends { enabled: boolean }>(items: T[]) {
return items.every((item) => item.enabled)
}

function isDisabledAll<T extends { enabled: boolean }>(items: T[]) {
return items.every((item) => !item.enabled)
}

function setEnabledAll<T extends { enabled: boolean }>(items: T[], enabled: boolean) {
return items.map((item) => ({ ...item, enabled }))
}

export const geographyEnableAllAtom = selectorFamily<unknown, string>({
export const geographyEnableAllAtom = selectorFamily<CheckboxState, string>({
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<unknown, string>({
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))
}
},
})

Expand Down
45 changes: 28 additions & 17 deletions web/src/state/variants.state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<VariantsData, string>({
key: 'variantDataAtom',
Expand Down Expand Up @@ -52,30 +52,41 @@ export const variantAtom = selectorFamily<boolean, { pathogen: string; variant:
},
})

function isEnabledAll<T extends { enabled: boolean }>(items: T[]) {
return items.every((item) => item.enabled)
}

function isDisabledAll<T extends { enabled: boolean }>(items: T[]) {
return items.every((item) => !item.enabled)
}

function setEnabledAll<T extends { enabled: boolean }>(items: T[], enabled: boolean) {
return items.map((item) => ({ ...item, enabled }))
}

export const variantsEnableAllAtom = selectorFamily<unknown, string>({
export const variantsEnableAllAtom = selectorFamily<CheckboxState, string>({
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<unknown, string>({
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))
}
},
})

1 comment on commit 617b3ae

@vercel
Copy link

@vercel vercel bot commented on 617b3ae Feb 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

flu-frequencies – ./

flu-frequencies-git-web-neherlab.vercel.app
flu-frequencies.vercel.app
flu-frequencies-neherlab.vercel.app

Please sign in to comment.