import clsx from 'clsx' import {toKey} from 'common/parsing' import {nullifyEmpty} from 'common/util/array' import {useEffect, useMemo, useState} from 'react' import {Button} from 'web/components/buttons/button' import {Row} from 'web/components/layout/row' import {Checkbox} from 'web/components/widgets/checkbox' import {Input} from 'web/components/widgets/input' import {useT} from 'web/lib/locale' export const MultiCheckbox = (props: { // Map of label -> value choices: {[key: string]: string} // Selected values (should match the "value" side of choices) selected: string[] onChange: (selected: string[] | null) => void className?: string optionsClassName?: string // If provided, enables adding a new option and should persist it (e.g. to DB) // Return value can be: // - string: the stored value for the new option; label will be the input text // - { key, value }: explicit label (key) and stored value // - null/undefined to indicate failure/cancellation addOption?: (label: string) => string | {key: string; value: string} | null | undefined addPlaceholder?: string translationPrefix?: string }) => { const { choices, selected, onChange, className, optionsClassName, addOption, addPlaceholder, translationPrefix, } = props // Keep a local merged copy to allow optimistic adds while remaining in sync with props const [localChoices, setLocalChoices] = useState<{[key: string]: string}>(choices) useEffect(() => { setLocalChoices((prev) => { // If incoming choices changed, merge them with any locally added that still don't collide // Props should be source of truth on conflicts return {...prev, ...choices} }) }, [choices]) const entries = useMemo(() => Object.entries(localChoices), [localChoices]) // Add-new option state const [newLabel, setNewLabel] = useState('') const [adding, setAdding] = useState(false) const [error, setError] = useState(null) const t = useT() const translateOption = (key: string, value: string) => { if (!translationPrefix) return key return t(`${translationPrefix}.${toKey(value)}`, key) } // Filter visible options while typing a new option (case-insensitive label match) const filteredEntries = useMemo(() => { if (!addOption) return entries let q = newLabel.trim() q = translateOption(q, q).toLowerCase() if (!q) return entries return entries.filter(([key, value]) => translateOption(key, value).toLowerCase().includes(q)) }, [addOption, entries, newLabel]) const submitAdd = async () => { if (!addOption) return const label = newLabel.trim() setError(null) if (!label) { setError(t('multi-checkbox.enter_value', 'Please enter a value.')) return } // prevent duplicate by label or by value already selected const existingEntry = Object.entries(localChoices).find( ([key, value]) => translateOption(key, value).toLowerCase() === translateOption(label, label).toLowerCase(), ) if (existingEntry) { const [_, existingValue] = existingEntry if (!selected.includes(existingValue)) { onChange([...selected, existingValue]) } setNewLabel('') return } setAdding(true) try { const result = addOption(label) if (!result) { setError(t('multi-checkbox.could_not_add', 'Could not add option.')) setAdding(false) return } const {key, value} = typeof result === 'string' ? {key: label, value: result} : result setLocalChoices((prev) => ({...prev, [key]: value})) // auto-select newly added option if not already selected if (!selected.includes(value)) onChange([...selected, value]) setNewLabel('') } catch (_e) { setError(t('multi-checkbox.add_failed', 'Failed to add option.')) } finally { setAdding(false) } } return (
{addOption && ( ) => { setNewLabel(e.target.value) setError(null) }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() submitAdd() } }} className="h-10" /> {error && {error}} )} {filteredEntries.map(([key, value]) => ( { if (checked) { onChange([...selected, value]) } else { onChange(nullifyEmpty(selected.filter((s) => s !== value))) } }} /> ))} {addOption && newLabel.trim() && filteredEntries.length === 0 && (
{t('multi-checkbox.no_matching_options', 'No matching options, feel free to add it.')}
)}
) }