FowApps/fow.ui

View on GitHub
src/components/atoms/SelectV3/Select.tsx

Summary

Maintainability
D
2 days
Test Coverage
F
0%
/* eslint-disable no-nested-ternary */
import React, {
    ChangeEvent,
    UIEventHandler,
    useCallback,
    useEffect,
    useRef,
    useState,
} from 'react';
import Lottie from 'react-lottie';
import useElementSize from '../../../hooks/useElementSize';
import debounce from '../../../utils/debounce';
import Dropdown from '../../molecules/Dropdown';
import Badge from '../Badge';
import Input from '../Input';
import Radio from '../Radio';
import {
    BadgeHolder,
    EmptyWrapper,
    InputWrapper,
    OptionsWrapper,
    SearchWrapper,
    Surface,
} from './styles';
import { empty } from '../../../assets/lotties/empty';
import Loader from '../Loader';
import Body from '../Typography/Body';
import Space from '../Space';
import Checkbox from '../Checkbox';
import Tooltip from '../Tooltip';

type OptionType = {
    value: any;
    label: string;
    disabled?: boolean;
    hidden?: boolean;
};

export interface SelectProps {
    width?: number;
    height?: number;
    options: OptionType[];
    closeAfterSelect?: boolean;
    placeholder?: string;
    allowClear?: boolean;
    onChange?: (value: any | any[], option?: OptionType | OptionType[]) => void;
    value?: any | any[];
    labelKey?: string;
    valueKey?: string;
    disabled?: boolean;
    allowSearch?: boolean;
    defaultValue?: any;
    onSearch?: (query: string) => void;
    notFoundText?: string;
    notFoundContent?: React.ReactNode;
    isLoading?: boolean;
    loadingText?: string;
    mode?: 'single' | 'multiple' | 'combobox';
    onScroll?: UIEventHandler<HTMLDivElement>;
    hasValidationError?: boolean;
    isSingleItemSelect?: boolean;
}

const Select = (props: React.PropsWithChildren<SelectProps>): JSX.Element => {
    const {
        width,
        height = 280,
        closeAfterSelect = false,
        placeholder,
        allowClear = true,
        labelKey = 'label',
        valueKey = 'value',
        options = [],
        onChange,
        value,
        defaultValue,
        disabled = false,
        allowSearch = true,
        onSearch,
        notFoundText = 'There were no results.',
        notFoundContent,
        isLoading = false,
        loadingText = 'Loading',
        mode = 'single',
        onScroll,
        hasValidationError,
        isSingleItemSelect = false,
    } = props;

    const isControlled = 'value' in props;
    const isSingle = mode === 'single';

    const searchInputRef = useRef<HTMLInputElement>(null);
    const radioGroupRef = useRef<HTMLDivElement>(null);
    const [ref, { width: elementWidth }] = useElementSize();

    const [internalSearchQuery, setInternalSearchQuery] = useState('');
    const [externalSearchQuery, setExternalSearchQuery] = useState('');

    const [internalValue, setInternalValue] = useState(value || defaultValue);
    const [selected, setSelected] = useState<any | any[]>();
    const [selecteds, setSelecteds] = useState<any[]>([]);

    const handleChangeRadio = (e: React.ChangeEvent<HTMLInputElement>) => {
        const { value: targetValue } = e.target;
        const option = options.find(
            (opt) => opt.value.toString() === targetValue,
        );
        // TODO: When value is number, e.target.value return string. Fix it.
        onChange?.(targetValue, option);
        setInternalValue(targetValue);
        if (!isControlled) {
            setSelected(option);
        }
    };

    const handleChangeCheckbox = (
        e: ChangeEvent<HTMLInputElement>,
        option: any,
    ) => {
        const val = e.target.value;
        // TODO: When value is number, e.target.value return string. Fix it.
        setInternalValue((currValues: any[] = []) => {
            let values: any[];
            if (currValues?.indexOf(val) === -1) {
                values = [val, ...currValues];
                if (!isControlled) {
                    setSelected(option);
                    setSelecteds((currSelecteds = []) => [
                        ...currSelecteds,
                        option,
                    ]);
                }
            } else {
                values = currValues.filter((currVal) => currVal !== val);
                if (!isControlled) {
                    setSelecteds((currSelecteds) => {
                        const filtered = currSelecteds?.filter(
                            (currSelected) => currSelected.value !== val,
                        );
                        setSelected(filtered[filtered.length - 1]);
                        return filtered;
                    });
                }
            }

            if (values.length === 0) {
                setSelected('');
            }

            onChange?.(values);
            return values;
        });
    };

    useEffect(() => {
        if (isControlled) {
            setInternalValue(value);
        }
        if (value || defaultValue) {
            if (isSingle) {
                const option = options.find(
                    (opt) => opt.value.toString() === value || defaultValue,
                );
                setSelected(option);
            } else {
                const lastItemValue = value?.[0] || defaultValue?.[0];
                const lastOption = options.find(
                    (opt) => lastItemValue === opt.value,
                );
                const selectedOptions = options.filter((opt) =>
                    (value || defaultValue || []).some(
                        (v: any) => opt.value === v,
                    ),
                );

                setSelected(lastOption);
                setSelecteds(selectedOptions);
            }
        }

        if (!value && isControlled) {
            setSelected(undefined);
            setSelecteds([]);
        }
    }, [defaultValue, isControlled, isSingle, options, value]);

    useEffect(() => {
        if (
            !!isSingleItemSelect &&
            options?.length === 1 &&
            !isLoading &&
            isSingle &&
            !value
        ) {
            const singleItemValue = options?.[0];
            onChange?.(singleItemValue?.value, singleItemValue);
            setInternalValue(singleItemValue?.value);
            if (!isControlled) {
                setSelected(singleItemValue);
            }
        }
    }, [isSingleItemSelect, isLoading]);

    const handleSearch = debounce(
        (query: string) => {
            if (onSearch) {
                onSearch?.(query);
                setExternalSearchQuery(query);
            } else {
                setInternalSearchQuery(query);
            }
        },
        onSearch ? 750 : 0,
    );

    const searchResultLength = options
        .filter((option) => !option.hidden)
        .filter((option) =>
            option.label
                ?.toLocaleLowerCase()
                ?.includes(internalSearchQuery?.toLocaleLowerCase()),
        ).length;

    const renderEmptyOrNotFoundState = () => {
        if (options.length === 0 || searchResultLength === 0) {
            return (
                <EmptyWrapper>
                    {notFoundContent || (
                        <Space
                            direction="vertical"
                            size="none"
                            align="center"
                            justify="center"
                            inline={false}>
                            <Lottie
                                width={183}
                                options={{
                                    loop: true,
                                    autoplay: true,
                                    animationData: empty,
                                }}
                            />
                            <div
                                style={{
                                    marginTop: '-2rem',
                                    marginBottom: '2rem',
                                }}>
                                <Body>{notFoundText}</Body>
                            </div>
                        </Space>
                    )}
                </EmptyWrapper>
            );
        }
        return null;
    };

    const handleAfterVisibilityChange = useCallback((visible) => {
        if (visible) {
            setTimeout(() => {
                searchInputRef.current?.focus();
                const checkedEl =
                    radioGroupRef.current?.querySelector('[checked]');
                if (checkedEl) {
                    checkedEl.scrollIntoView({
                        behavior: 'smooth',
                        block: 'center',
                    });
                }
            }, 250);
        }
    }, []);

    const content = useCallback(
        (close) =>
            !disabled && (
                <Surface
                    height={height}
                    onScroll={onScroll}
                    style={{ paddingTop: allowSearch ? 0 : '1.2rem' }}>
                    {allowSearch && (
                        <SearchWrapper>
                            <Input
                                ref={searchInputRef}
                                onChange={handleSearch}
                                value={
                                    externalSearchQuery || internalSearchQuery
                                }
                                prefixIcon="search"
                            />
                        </SearchWrapper>
                    )}
                    <OptionsWrapper allowSearch={allowSearch}>
                        {isLoading ? (
                            <Loader
                                isLoading={isLoading}
                                height={210}
                                text={loadingText}
                            />
                        ) : searchResultLength > 0 ? (
                            isSingle ? (
                                <Radio.Group
                                    ref={radioGroupRef}
                                    direction="vertical"
                                    defaultValue={value || internalValue}
                                    value={value || internalValue}
                                    onChange={(e) => {
                                        handleChangeRadio(e);
                                        if (closeAfterSelect || isSingle) {
                                            close();
                                        }
                                    }}>
                                    {options
                                        .filter((option) => !option.hidden)
                                        .filter((option) =>
                                            option.label
                                                ?.toLocaleLowerCase()
                                                ?.includes(
                                                    internalSearchQuery?.toLocaleLowerCase(),
                                                ),
                                        )
                                        .map((option) => (
                                            <Radio
                                                fluid
                                                key={option[valueKey]}
                                                value={option[valueKey]}
                                                label={option[labelKey]}
                                                disabled={option.disabled}
                                            />
                                        ))}
                                </Radio.Group>
                            ) : (
                                <Space
                                    direction="vertical"
                                    size="xsmall"
                                    align="start"
                                    justify="flex-start">
                                    {options
                                        .filter((option) => !option.hidden)
                                        .filter((option) =>
                                            option.label
                                                ?.toLocaleLowerCase()
                                                ?.includes(
                                                    internalSearchQuery?.toLocaleLowerCase(),
                                                ),
                                        )
                                        .map((option) => (
                                            <Checkbox
                                                checked={
                                                    !!(
                                                        value?.indexOf(
                                                            option.value,
                                                        ) > -1 ||
                                                        internalValue?.indexOf(
                                                            option.value,
                                                        ) > -1
                                                    )
                                                }
                                                onChange={(e) => {
                                                    handleChangeCheckbox(
                                                        e,
                                                        option,
                                                    );
                                                }}
                                                key={option[valueKey]}
                                                value={option[valueKey]}
                                                label={option[labelKey]}
                                                disabled={option.disabled}
                                            />
                                        ))}
                                </Space>
                            )
                        ) : (
                            renderEmptyOrNotFoundState()
                        )}
                    </OptionsWrapper>
                </Surface>
            ),
        [
            value,
            internalValue,
            options,
            isLoading,
            externalSearchQuery,
            internalSearchQuery,
        ],
    );

    return (
        <Dropdown
            disabled={disabled}
            ref={ref}
            transitionDuration={50}
            onAfterVisibleChange={handleAfterVisibilityChange}
            content={(close) => content(close)}
            width={width || elementWidth}>
            {({ isOpen }) => (
                <InputWrapper>
                    <Input
                        hasValidationError={hasValidationError}
                        disabled={disabled || isLoading}
                        value={selected?.label || ''}
                        placeholder={placeholder}
                        readOnly
                        style={{ cursor: disabled ? 'not-allowed' : 'pointer' }}
                        suffixIcon={isOpen ? 'chevron-up' : 'chevron-down'}
                    />
                    <BadgeHolder size="xsmall">
                        {!isSingle && internalValue?.length > 1 && (
                            <Tooltip
                                placement="bottom"
                                overlay={
                                    <Space
                                        align="start"
                                        direction="vertical"
                                        size="xxsmall"
                                        style={{
                                            width: 150,
                                            maxHeight: 150,
                                            overflowY: 'auto',
                                        }}>
                                        {selecteds
                                            .filter(
                                                (s) =>
                                                    s.value !== selected.value,
                                            )
                                            .map((s) => (
                                                <Body
                                                    color="white"
                                                    lineClamp={1}>
                                                    {s.label}
                                                </Body>
                                            ))}
                                    </Space>
                                }>
                                <Badge
                                    text={`+${(
                                        internalValue?.length - 1
                                    ).toString()}`}
                                    variant="outlined"
                                    size="medium"
                                    color="primary"
                                />
                            </Tooltip>
                        )}
                        {allowClear &&
                            (value?.length > 0 || internalValue?.length > 0) &&
                            !disabled && (
                                <Badge
                                    onClick={(e) => {
                                        e.stopPropagation();
                                        onChange?.(undefined);
                                        setInternalValue(undefined);
                                        setSelected(undefined);
                                        setSelecteds([]);
                                    }}
                                    text="x"
                                    variant="outlined"
                                    size="medium"
                                    color="primary"
                                    shape="circle"
                                />
                            )}
                    </BadgeHolder>
                </InputWrapper>
            )}
        </Dropdown>
    );
};

export default Select;