Skip to content

Commit

Permalink
3370 - Select Component - Add toggleAll prop (#3737)
Browse files Browse the repository at this point in the history
* 3370 - Select Component - Add toggleAll prop

* 3370 - Fix deepscan

* 3370 - Move MultiSelectOption component to its own file

* 3370 - Add separator

* 3370 - Update useToggleAllConfig hook

* 3370 - Set optionComponent from hook

---------

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
  • Loading branch information
yaguzmang and mergify[bot] authored Apr 18, 2024
1 parent 51263d8 commit 497a1d7
Show file tree
Hide file tree
Showing 12 changed files with 194 additions and 15 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
@import 'src/client/style/partials';

.select__toggleAllOption-checkbox {
accent-color: $ui-accent;
margin-right: $spacing-xxs;
vertical-align: middle;

&:checked {
background-color: $ui-accent;
border: none;
}
}

.select__toggleAllOption-label {
vertical-align: middle;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import './MultiSelectOption.scss'
import React, { useEffect, useRef } from 'react'
import { components, OptionProps } from 'react-select'

import Hr from 'client/components/Hr'
import { Option } from 'client/components/Inputs/Select'

import { useMultiSelectOptionConfig } from './hooks/useMultiSelectOptionConfig'

export const MultiSelectOption: React.FC<OptionProps<Option>> = (props) => {
const { data, label } = props

const { checked, isInputIndeterminate, isSelectAllOption } = useMultiSelectOptionConfig(props)

const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (inputRef.current) {
inputRef.current.indeterminate = isInputIndeterminate
}
}, [isInputIndeterminate])

return (
<>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<components.Option {...props}>
<input
key={`${data.value}-${isInputIndeterminate}`}
ref={inputRef}
checked={checked}
className="select__toggleAllOption-checkbox"
onChange={() => undefined}
type="checkbox"
/>
<span className="select__toggleAllOption-label">{label}</span>
</components.Option>
{isSelectAllOption && <Hr />}
</>
)
}

export default MultiSelectOption
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useMemo } from 'react'
import { OptionProps } from 'react-select'

import { Option, OptionsGroup, selectAllOptionValue } from 'client/components/Inputs/Select/types'

type Props = OptionProps<Option>

type Returned = {
checked: boolean
isInputIndeterminate: boolean
isSelectAllOption: boolean
}

export const useMultiSelectOptionConfig = (props: Props): Returned => {
const { data, isSelected, options, selectProps } = props

const isSelectAllOption = data.value === selectAllOptionValue
const allOptionsCount = useMemo<number>(() => {
if (!isSelectAllOption) return 0
return options.reduce((acc, optionOrGroup) => {
if (Object.hasOwn(optionOrGroup, 'options')) {
const group = optionOrGroup as OptionsGroup
return acc + group.options.length
}
return acc + 1
}, 0)
}, [isSelectAllOption, options])

return useMemo<Returned>(() => {
const selectedValuesLength = Array.isArray(selectProps.value) ? selectProps.value.length : 0
const allSelected = selectedValuesLength === allOptionsCount - 1
const checked = isSelected || (isSelectAllOption && allSelected)

const isInputIndeterminate = isSelectAllOption && !allSelected && selectedValuesLength > 0

return { checked, isInputIndeterminate, isSelectAllOption }
}, [allOptionsCount, isSelectAllOption, isSelected, selectProps.value])
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { MultiSelectOption } from './MultiSelectOption'
1 change: 1 addition & 0 deletions src/client/components/Inputs/Select/Indicators/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { ClearIndicator, DropdownIndicator, IndicatorsContainer } from './Indicators'
export { MultiSelectOption } from './MultiSelectOption'
8 changes: 6 additions & 2 deletions src/client/components/Inputs/Select/Select.scss
Original file line number Diff line number Diff line change
Expand Up @@ -135,15 +135,19 @@ div.select__option {
padding: $spacing-xxs;
white-space: break-spaces;

&.isFocused {
&.isFocused,
&.isMulti.isFocused {
background-color: lighten($ui-accent-light-extra, 3%);
cursor: pointer;
}

&.isSelected {
background-color: transparent;
color: $text-body;
cursor: unset;
font-weight: 600;

&:not(.isMulti) {
cursor: unset;
}
}
}
26 changes: 21 additions & 5 deletions src/client/components/Inputs/Select/Select.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,35 @@
import './Select.scss'
import React from 'react'
import ReactSelect from 'react-select'
import ReactSelect, { GroupBase, SelectComponentsConfig } from 'react-select'

import classNames from 'classnames'

import { ClearIndicator, DropdownIndicator, IndicatorsContainer } from 'client/components/Inputs/Select/Indicators'

import { useOnChange } from './hooks/useOnChange'
import { useToggleAllConfig } from './hooks/useToggleAllConfig'
import { useValue } from './hooks/useValue'
import { SelectProps } from './types'

const Select: React.FC<SelectProps> = (props) => {
const { classNames: classes, disabled, isClearable, isMulti, options, placeholder } = props
const { classNames: classes, disabled, isClearable, isMulti, options, placeholder, toggleAll } = props

const value = useValue(props)
const onChange = useOnChange(props)

const components: Partial<SelectComponentsConfig<unknown, boolean, GroupBase<unknown>>> = {
ClearIndicator,
DropdownIndicator,
IndicatorsContainer,
IndicatorSeparator: null,
}

const { optionComponent, options: augmentedOptions } = useToggleAllConfig({ isMulti, options, toggleAll, value })

if (optionComponent) {
components.Option = optionComponent
}

return (
<ReactSelect
classNames={{
Expand All @@ -29,21 +43,23 @@ const Select: React.FC<SelectProps> = (props) => {
multiValue: ({ isDisabled }) => classNames('select__multiValue', { isDisabled }),
multiValueLabel: ({ isDisabled }) => classNames('select__multiValueLabel', { isDisabled }),
multiValueRemove: ({ isDisabled }) => classNames('select__multiValueRemove', { isDisabled }),
option: ({ isFocused, isSelected }) => classNames('select__option', { isFocused, isSelected }),
option: ({ isFocused, isMulti, isSelected }) =>
classNames('select__option', { isFocused, isMulti, isSelected }),
placeholder: () => `select__placeholder`,
singleValue: () => 'select__singleValue',
valueContainer: () => 'select__valueContainer',
}}
closeMenuOnSelect={!isMulti}
components={{ ClearIndicator, DropdownIndicator, IndicatorsContainer, IndicatorSeparator: null }}
components={components}
hideSelectedOptions={false}
isClearable={isClearable}
isDisabled={disabled}
isMulti={isMulti}
isSearchable
menuPlacement="auto"
menuPosition="fixed"
onChange={onChange}
options={options}
options={augmentedOptions}
placeholder={placeholder ?? ''}
value={value}
/>
Expand Down
31 changes: 25 additions & 6 deletions src/client/components/Inputs/Select/hooks/useOnChange.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,39 @@
import { useCallback } from 'react'

import { Option, SelectProps } from 'client/components/Inputs/Select/types'
import { Option, OptionsGroup, selectAllOptionValue, SelectProps } from 'client/components/Inputs/Select/types'

type Returned = (option?: Option | Array<Option>) => void

export const useOnChange = (props: SelectProps): Returned => {
const { isMulti, onChange } = props
const { isMulti, onChange, options: selectOptions, toggleAll } = props

return useCallback<Returned>(
(option?: Option | Array<Option>) => {
if (isMulti && Array.isArray(option)) {
onChange(option.map(({ value }) => value))
} else {
onChange((option as Option)?.value ?? null)
const selectedValues = option.map(({ value }) => value)
if (!toggleAll) return onChange(selectedValues)

const includesSelectAll = selectedValues.includes(selectAllOptionValue)
// Update with only selected values (excluding "Select All")
if (!includesSelectAll) return onChange(selectedValues)

// If "(Un) Select All" is toggled while some items are selected, deselect all
if (selectedValues.length > 1) return onChange([])

// If "Select All" is toggled with no selection, select all original options
if (selectedValues.length === 1) {
const allValues = selectOptions.flatMap((optionOrGroup) => {
if (Object.hasOwn(optionOrGroup, 'options')) {
return (optionOrGroup as OptionsGroup).options.map(({ value }) => value)
}
return (optionOrGroup as Option).value
})
return onChange(allValues)
}
}
// Handle Single-Select
return onChange((option as Option)?.value ?? null)
},
[isMulti, onChange]
[isMulti, onChange, selectOptions, toggleAll]
)
}
38 changes: 38 additions & 0 deletions src/client/components/Inputs/Select/hooks/useToggleAllConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'

import { MultiSelectOption } from 'client/components/Inputs/Select/Indicators'
import { OptionsOrGroups, selectAllOptionValue } from 'client/components/Inputs/Select/types'

import { ValueSelect } from './useValue'

type Props = {
isMulti: boolean
options: OptionsOrGroups
toggleAll: boolean
value: ValueSelect
}

type Returned = {
optionComponent: React.FC | undefined
options: OptionsOrGroups
}

export const useToggleAllConfig = (props: Props): Returned => {
const { isMulti, options, toggleAll, value } = props
const { t } = useTranslation()

return useMemo<Returned>(() => {
if (!isMulti) return { optionComponent: undefined, options }

const optionComponent = MultiSelectOption

if (!toggleAll) return { optionComponent, options }

const selectAllOption = {
value: selectAllOptionValue,
label: Array.isArray(value) && value.length === 0 ? t('common.selectAll') : t('common.unselectAll'),
}
return { optionComponent, options: [selectAllOption, ...options] }
}, [isMulti, options, t, toggleAll, value])
}
2 changes: 1 addition & 1 deletion src/client/components/Inputs/Select/hooks/useValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useMemo } from 'react'

import { Option, OptionsGroup, SelectProps } from 'client/components/Inputs/Select/types'

type ValueSelect = Option | Array<Option> | null
export type ValueSelect = Option | Array<Option> | null

export const useValue = (props: SelectProps): ValueSelect => {
const { isMulti, options, value: valueInput } = props
Expand Down
3 changes: 3 additions & 0 deletions src/client/components/Inputs/Select/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,8 @@ export type SelectProps = SelectBaseProps &
disabled?: boolean
onChange: (value: string | Array<string> | null) => void
options: OptionsOrGroups
toggleAll?: boolean
value?: ValueInput
}

export const selectAllOptionValue = '*'
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ const VariablesSelect: React.FC<Props> = (props) => {
onChange('variables', value)
}

return <Select isMulti disabled={disabled} value={dataSource.variables} onChange={_onChange} options={options} />
return (
<Select disabled={disabled} isMulti onChange={_onChange} options={options} toggleAll value={dataSource.variables} />
)
}

const Variables: React.FC<Props & { lastRow: boolean }> = (props) => {
Expand Down

0 comments on commit 497a1d7

Please sign in to comment.