@totalsoft/rocket-ui
Version:
A set of reusable and composable React components built on top of Material UI core for developing fast and friendly web applications interfaces.
269 lines • 14.6 kB
JavaScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { Chip, createFilterOptions, Autocomplete as MuiAutocomplete, TextField } from '@mui/material';
import { both, concat, defaultTo, eqBy, has, identity, map, pipe, prop } from 'ramda';
import { convertValueToOption, extractFirstValue, internalLabel, internalValue } from './utils';
import Option from './Option';
import { useTrackVisibility } from 'react-intersection-observer-hook';
import LinearProgress from '../../feedback/LinearProgress';
import { emptyArray, emptyString } from '../../utils/constants';
import debounce from 'lodash/debounce';
const baseFilter = createFilterOptions();
const Autocomplete = ({ options = [], getOptionLabel, valueKey = 'id', labelKey = 'name', isMultiSelection = false, withCheckboxes = false, isClearable = false, creatable = false, createdLabel = 'Add', onChange, loadingText = React.createElement(LinearProgress, null), loading, loadOptions, open, onOpen, onClose, onInputChange, inputValue, debouncedBy = 500, renderOption, isPaginated,
// ---------------- input field props ----------------
label, placeholder, error, helperText, required, isSearchable = true, inputTextFieldProps,
// ---------------------------------------------------
...rest }) => {
/**
* handle both string and function from valueKey and labelKey.
*/
const getValue = useMemo(() => (valueKey instanceof Function ? valueKey : prop(valueKey)), [valueKey]);
const getLabel = useMemo(() => (labelKey instanceof Function ? labelKey : prop(labelKey)), [labelKey]);
/**
* Handle the internal options to aid lazy loading.
*/
const [internalOptions, setInternalOptions] = useState(emptyArray);
const allOptions = useRef(emptyArray);
allOptions.current = concat(options, internalOptions);
/**
* Handle get option value.
* Handle valueKey and labelKey as functions.
* Show internal label if it's called from renderOption and internal value if it's called from the input field.
*/
const handleGetOptionLabel = useCallback(
/**
* Second parameter is a flag to show the label or the value
* The input field will never ask for the label as it is a custom convention.
* Only handleRenderOption will ask for the label.
*/
(option, showLabel) => {
if (getOptionLabel)
return getOptionLabel(option);
const label = showLabel ? internalLabel : internalValue;
const convertedOption = convertValueToOption(option, allOptions.current, extractFirstValue([getValue, internalValue, identity]));
return extractFirstValue([getLabel, getValue, label, identity], convertedOption);
}, [getLabel, getOptionLabel, getValue]);
/**
* Implementing loadOptions requiring the following internal handling:
* - loading state
* - open state
* - options
* - input change
*/
const [internalLoading, setInternalLoading] = useState(false);
const [internalOpen, setInternalOpen] = useState(false);
const [internalInputValue, setInternalInputValue] = useState(inputValue || emptyString);
const [ref, { isVisible }] = useTrackVisibility();
const [loadMore, setLoadMore] = useState(false);
const [nextPageData, setNextPageData] = useState(null);
useEffect(() => {
setInternalInputValue(defaultTo(emptyString, inputValue));
}, [inputValue]);
useEffect(() => {
if (isVisible)
setInternalLoading(true);
}, [isVisible]);
const handleOpen = useCallback((event) => {
if (onOpen)
onOpen(event);
setInternalOpen(true);
if (loadOptions)
setInternalLoading(true);
}, [loadOptions, onOpen]);
const handleClose = useCallback((event, reason) => {
if (onClose)
onClose(event, reason);
setInternalOpen(false);
if (loadOptions) {
setInternalLoading(false);
setInternalInputValue(emptyString);
setInternalOptions(emptyArray);
setLoadMore(false);
setNextPageData(null);
}
}, [loadOptions, onClose]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleInputChange = useCallback(debounce((event, value, reason) => {
if (onInputChange)
onInputChange(event, value, reason);
// Only update internalInputValue if it's from user input, not from selection reset
if (reason === 'selectOption') {
setInternalOpen(false);
return;
}
if (reason === 'reset')
return;
setInternalInputValue(value);
if (loadOptions && (open || internalOpen)) {
setInternalOptions(emptyArray);
setInternalLoading(true);
setLoadMore(false);
setNextPageData(null);
}
}, debouncedBy), [debouncedBy, internalOpen, loadOptions, onInputChange, open]);
useEffect(() => {
if (!internalLoading || !loadOptions || !(open || internalOpen))
return;
const abortController = new AbortController();
loadOptions(internalInputValue, allOptions.current, nextPageData, abortController.signal)
.then((result) => {
if (abortController.signal.aborted)
return;
const newOptions = isPaginated
? result?.loadedOptions
: result;
const hasMoreData = isPaginated ? result?.more : false;
const nextPageData = isPaginated ? result?.additional : null;
setInternalLoading(false);
setInternalOptions(oldOptions => concat(oldOptions, newOptions));
setLoadMore(hasMoreData);
setNextPageData(nextPageData);
})
.catch(error => {
if (error instanceof DOMException && error.name === 'AbortError')
console.warn(error);
else {
console.error(error);
setInternalLoading(false);
}
});
return () => {
abortController.abort(new DOMException(`Aborted by Rocket UI for: "${internalInputValue}". New LoadOption was issued!`, 'AbortError'));
};
}, [internalInputValue, internalLoading, internalOpen, isPaginated, loadOptions, nextPageData, open]);
const handleRenderOption = useCallback(
/**
* props: React.HTMLAttributes<HTMLLIElement> & { key: any },
option: T,
state: AutocompleteRenderOptionState,
ownerState: AutocompleteOwnerState<T, Multiple, DisableClearable, FreeSolo, ChipComponent>
*/
(liProps, option, state, ownerState) => {
/**
* Display the loading text and attach a reference to monitor the visibility of this option.
* This should be the last option in the list. And it should only be added from the internal mechanism.
* The visibility will be used to trigger the loadOptions function.
*/
if (has('__internalShowLoadingOption', option) && option.__internalShowLoadingOption) {
return (React.createElement(Option, { ref: ref, key: liProps.key, label: loadingText, liProps: liProps, selected: false, withCheckboxes: false, option: option }));
}
/**
* This is the default option rendering that can handle new created options.
* If the option has __internalDisplay and __internalInputValue we will render it as a new created option.
* Else we will use the default rendering or the custom rendering if it's provided.
*/
const hasInternalDisplay = both(has('__internalDisplay'), pipe(prop('__internalDisplay'), Boolean));
const hasInternalInputValue = both(has('__internalInputValue'), pipe(prop('__internalInputValue'), Boolean));
if ((hasInternalDisplay(option) && hasInternalInputValue(option)) || !renderOption) {
return (React.createElement(Option, { key: extractFirstValue([getValue, internalValue, identity], option), label: handleGetOptionLabel(option, true), liProps: liProps, selected: state.selected, withCheckboxes: withCheckboxes, option: option }));
}
return renderOption(liProps, option, state, ownerState);
}, [getValue, handleGetOptionLabel, loadingText, ref, renderOption, withCheckboxes]);
const handleFilterOptions = useCallback((options, state) => {
const debouncedState = { ...state, inputValue: internalInputValue };
const result = baseFilter(options, debouncedState);
if (creatable) {
/**
* There is no way to know what kind of type is "option".
* We will make our own convention where the new added option will have the following shape:
* { __internalDisplay: `${createdLabel} "inputValue"`, __internalInputValue: inputValue }
* This way we can distinguish between the options that were already in the list and the new added ones.
* We will use __internalDisplay to display the new added option and __internalInputValue to get the actual value.
* On the onChange event we will send the __internalInputValue as the value and the reason will be "createOption".
*/
const contender = debouncedState.inputValue;
const exactMatch = result.find(option => handleGetOptionLabel(option) === contender);
if (contender && !exactMatch)
result.push({ __internalDisplay: `${createdLabel} "${contender}"`, __internalInputValue: contender });
}
if (isPaginated && loadMore) {
/**
* For paginated loading we will add a special option at the end of the list.
* This option will be used to trigger the loadOptions function.
* Our convention for this option will be:
* { __internalShowLoadingOption: true, isDisabled: true }
*/
result.push({ __internalShowLoadingOption: true, isDisabled: true });
}
return result;
}, [creatable, createdLabel, handleGetOptionLabel, internalInputValue, isPaginated, loadMore]);
/**
* Because of our convention for disabled options we need to handle the isOptionEqualToValue.
*/
const handleOptionEqualToValue = useCallback((option, value) => {
const equalFn = extractFirstValue([getValue, internalValue, identity]);
return eqBy(equalFn, option, value);
}, [getValue]);
/**
* Handle change event to switch event and value.
* Handle the internal convention for the new added options.
*/
const handleChange = useCallback((event, value, reason, details) => {
if (onChange) {
let calcReason = reason;
const checkInternalNewOption = both(has('__internalInputValue'), has('__internalDisplay'));
const transformValue = (v) => checkInternalNewOption(v) ? ((calcReason = 'createOption'), v.__internalInputValue) : v;
const newValue = (isMultiSelection ? map(transformValue) : transformValue)(value);
onChange(newValue, event, calcReason, details);
}
}, [isMultiSelection, onChange]);
/**
* Handle disabled chips.
*/
const handleRenderTags = useCallback((value, getTagProps, ownerState) => {
return value.map((option, index) => {
const convertedOption = convertValueToOption(option, allOptions.current, extractFirstValue([getValue, internalValue, identity]));
const { key, ...tagProps } = getTagProps({ index });
const isDisabled = has('isDisabled', convertedOption) && Boolean(convertedOption.isDisabled);
return (React.createElement(Chip, { key: key, label: handleGetOptionLabel(convertedOption, true), ...tagProps, disabled: isDisabled || ownerState.disabled }));
});
}, [getValue, handleGetOptionLabel]);
/**
* Handle the default input field.
*/
const handleRenderInput = useCallback((params) => (React.createElement(TextField, { ...params, label: label, placeholder: placeholder, error: error, helperText: helperText, required: required, ...inputTextFieldProps, slotProps: { htmlInput: { ...params.inputProps, readOnly: !isSearchable } } })), [error, helperText, inputTextFieldProps, isSearchable, label, placeholder, required]);
/**
* Our component should not propagate the click event to the parent.
*/
const handleStopPropagation = useCallback((e) => {
e.stopPropagation();
}, []);
return (
/**
* Our component should not propagate the click event to the parent.
*/
React.createElement(MuiAutocomplete, { forcePopupIcon: true, clearOnBlur: true, selectOnFocus: true, handleHomeEndKeys: true, autoHighlight: true, renderInput: handleRenderInput, options: allOptions.current, getOptionLabel: handleGetOptionLabel, multiple: isMultiSelection, disableCloseOnSelect: isMultiSelection, disableClearable: !isClearable, renderOption: handleRenderOption, freeSolo: creatable, filterOptions: handleFilterOptions, onChange: handleChange, loadingText: loadingText, loading: loading || internalLoading, open: open || internalOpen, onOpen: handleOpen, onClose: handleClose, onInputChange: handleInputChange, renderTags: handleRenderTags, isOptionEqualToValue: handleOptionEqualToValue, onClick: handleStopPropagation, ...rest }));
};
Autocomplete.propTypes = {
options: PropTypes.array,
getOptionLabel: PropTypes.func,
valueKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
labelKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
isMultiSelection: PropTypes.bool,
withCheckboxes: PropTypes.bool,
isClearable: PropTypes.bool,
creatable: PropTypes.bool,
createdLabel: PropTypes.string,
onChange: PropTypes.func,
loadingText: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
loading: PropTypes.bool,
loadOptions: PropTypes.func,
open: PropTypes.bool,
onOpen: PropTypes.func,
onClose: PropTypes.func,
onInputChange: PropTypes.func,
debouncedBy: PropTypes.number,
renderOption: PropTypes.func,
isPaginated: PropTypes.bool,
// ---------------- input field props ----------------
label: PropTypes.string,
placeholder: PropTypes.string,
error: PropTypes.bool,
helperText: PropTypes.string,
required: PropTypes.bool,
isSearchable: PropTypes.bool,
inputTextFieldProps: PropTypes.object
// ---------------------------------------------------
};
export default Autocomplete;
//# sourceMappingURL=Autocomplete.js.map