DocSpace-buildtools/packages/asc-web-common/components/AdvancedSelector/sub-components/Selector.js

589 lines
15 KiB
JavaScript

import React, { useRef, useState, useEffect, useCallback } from "react";
import PropTypes from "prop-types";
import Header from "./Header";
import Search from "./Search";
import Group from "./Group";
import Option from "./Option";
import Footer from "./Footer";
import { FixedSizeList as List } from "react-window";
import InfiniteLoader from "react-window-infinite-loader";
import AutoSizer from "react-virtualized-auto-sizer";
import ReactTooltip from "react-tooltip";
import Avatar from "@appserver/components/avatar";
import Checkbox from "@appserver/components/checkbox";
import Text from "@appserver/components/text";
import Tooltip from "@appserver/components/tooltip";
import CustomScrollbarsVirtualList from "@appserver/components/scrollbar/custom-scrollbars-virtual-list";
import StyledSelector from "./StyledSelector";
const convertGroups = (items) => {
if (!items) return [];
const wrappedGroups = items.map(convertGroup);
return wrappedGroups;
};
const convertGroup = (group) => {
return {
key: group.key,
label: `${group.label} (${group.total})`,
total: group.total,
selected: 0,
};
};
const getCurrentGroup = (items) => {
const currentGroup = items.length > 0 ? items[0] : {};
return currentGroup;
};
const Selector = (props) => {
const {
groups,
isDisabled,
isMultiSelect,
hasNextPage,
options,
isNextPageLoading,
loadNextPage,
selectedOptions,
selectedGroups,
searchPlaceHolderLabel,
emptySearchOptionsLabel,
emptyOptionsLabel,
loadingLabel,
onSelect,
getOptionTooltipContent,
onSearchChanged,
onGroupChanged,
size,
allowGroupSelection,
embeddedComponent,
showCounter,
onArrowClick,
headerLabel,
} = props;
const listOptionsRef = useRef(null);
useEffect(() => {
Object.keys(currentGroup).length === 0 &&
setCurrentGroup(getCurrentGroup(convertGroups(groups)));
resetCache();
}, [searchValue, currentGroup, hasNextPage]);
const [selectedOptionList, setSelectedOptionList] = useState(
selectedOptions || []
);
const [selectedGroupList, setSelectedGroupList] = useState(
selectedGroups || []
);
const [searchValue, setSearchValue] = useState("");
const [currentGroup, setCurrentGroup] = useState(
getCurrentGroup(convertGroups(groups))
);
const [groupHeader, setGroupHeader] = useState(null);
useEffect(() => {
if (groups.length === 1) setGroupHeader(groups[0]);
}, [groups]);
// Every row is loaded except for our loading indicator row.
const isItemLoaded = useCallback(
(index) => {
return !hasNextPage || index < options.length;
},
[hasNextPage, options]
);
const onOptionChange = useCallback(
(index, isChecked) => {
const option = options[index];
const newSelected = !isChecked
? [option, ...selectedOptionList]
: selectedOptionList.filter((el) => el.key !== option.key);
setSelectedOptionList(newSelected);
if (!option.groups) return;
const newSelectedGroups = [];
const removedSelectedGroups = [];
if (isChecked) {
option.groups.forEach((g) => {
let index = selectedGroupList.findIndex((sg) => sg.key === g);
if (index > -1) {
// exists
const selectedGroup = selectedGroupList[index];
const newSelected = selectedGroup.selected + 1;
newSelectedGroups.push(
Object.assign({}, selectedGroup, {
selected: newSelected,
})
);
} else {
index = groups.findIndex((sg) => sg.key === g);
if (index < 0) return;
const notSelectedGroup = convertGroup(groups[index]);
newSelectedGroups.push(
Object.assign({}, notSelectedGroup, {
selected: 1,
})
);
}
});
} else {
option.groups.forEach((g) => {
let index = selectedGroupList.findIndex((sg) => sg.key === g);
if (index > -1) {
// exists
const selectedGroup = selectedGroupList[index];
const newSelected = selectedGroup.selected - 1;
if (newSelected > 0) {
newSelectedGroups.push(
Object.assign({}, selectedGroup, {
selected: newSelected,
})
);
} else {
removedSelectedGroups.push(
Object.assign({}, selectedGroup, {
selected: newSelected,
})
);
}
}
});
}
selectedGroupList.forEach((g) => {
const indexNew = newSelectedGroups.findIndex((sg) => sg.key === g.key);
if (indexNew === -1) {
const indexRemoved = removedSelectedGroups.findIndex(
(sg) => sg.key === g.key
);
if (indexRemoved === -1) {
newSelectedGroups.push(g);
}
}
});
setSelectedGroupList(newSelectedGroups);
},
[options, selectedOptionList, groups, selectedGroupList]
);
const resetCache = useCallback(() => {
if (listOptionsRef && listOptionsRef.current) {
listOptionsRef.current.resetloadMoreItemsCache(true);
}
}, [listOptionsRef]);
const onSearchChange = useCallback(
(value) => {
setSearchValue(value);
onSearchChanged && onSearchChanged(value);
},
[onSearchChanged]
);
const onSearchReset = useCallback(() => {
onSearchChanged && onSearchChange("");
}, [onSearchChanged]);
const isOptionChecked = useCallback(
(option) => {
const checked =
selectedOptionList.findIndex((el) => el.key === option.key) > -1 ||
(option.groups &&
option.groups.filter((gKey) => {
const selectedGroup = selectedGroupList.find(
(sg) => sg.key === gKey
);
if (!selectedGroup) return false;
return selectedGroup.total === selectedGroup.selected;
}).length > 0);
return checked;
},
[selectedOptionList, selectedGroupList]
);
const onSelectOptions = (items) => {
onSelect && onSelect(items);
};
const onAddClick = useCallback(() => {
onSelectOptions(selectedOptionList);
}, [selectedOptionList]);
const onLinkClick = useCallback(
(index) => {
const option = options[index];
if (!option) return;
onSelectOptions([option]);
},
[options]
);
// Render an item or a loading indicator.
// eslint-disable-next-line react/prop-types
const renderOption = useCallback(
({ index, style }) => {
const isLoaded = isItemLoaded(index);
if (!isLoaded) {
return <Option isLoader={true} loadingLabel={loadingLabel} />;
}
const option = options[index];
const isChecked = isOptionChecked(option);
ReactTooltip.rebuild();
return (
<Option
index={index}
style={style}
{...option}
isChecked={isChecked}
onOptionChange={onOptionChange}
onLinkClick={onLinkClick}
isMultiSelect={isMultiSelect}
/>
);
},
[
isItemLoaded,
loadingLabel,
options,
isOptionChecked,
isMultiSelect,
onOptionChange,
onLinkClick,
getOptionTooltipContent,
isMultiSelect,
onOptionChange,
onLinkClick,
]
);
const hasSelected = useCallback(() => {
return selectedOptionList.length > 0 || selectedGroupList.length > 0;
}, [selectedOptionList, selectedGroupList]);
// If there are more items to be loaded then add an extra row to hold a loading indicator.
const itemCount = hasNextPage ? options.length + 1 : options.length;
// Only load 1 page of items at a time.
// Pass an empty callback to InfiniteLoader in case it asks us to load more than once.
const loadMoreItems = useCallback(
(startIndex) => {
if (isNextPageLoading) return;
const options = {
startIndex: startIndex || 0,
searchValue: searchValue,
currentGroup: currentGroup ? currentGroup.key : null,
};
loadNextPage && loadNextPage(options);
},
[isNextPageLoading, searchValue, currentGroup, options]
);
const getGroupSelectedOptions = useCallback(
(group) => {
const selectedGroup = selectedOptionList.filter(
(o) => o.groups && o.groups.indexOf(group) > -1
);
if (group === "all") {
selectedGroup.push(...selectedOptionList);
}
return selectedGroup;
},
[selectedOptionList]
);
const onGroupClick = useCallback(
(index) => {
const group = groups[index];
setGroupHeader({ ...group });
onGroupChanged && onGroupChanged(group);
setCurrentGroup(group);
},
[groups, onGroupChanged]
);
const renderGroup = useCallback(
({ index, style }) => {
const group = groups[index];
const selectedOption = getGroupSelectedOptions(group.id);
const isIndeterminate = selectedOption.length > 0;
let label = group.label;
if (isMultiSelect && selectedOption.length > 0) {
label = `${group.label} (${selectedOption.length})`;
}
return (
<Group
style={style}
index={index}
isIndeterminate={isIndeterminate}
isChecked={false}
groupLabel={label}
{...group}
onGroupClick={onGroupClick}
isMultiSelect={isMultiSelect}
/>
);
},
[
isMultiSelect,
groups,
currentGroup,
selectedGroupList,
selectedOptionList,
onGroupClick,
getGroupSelectedOptions,
]
);
const renderGroupsList = useCallback(() => {
if (groups.length === 0) {
return <Option isLoader={true} loadingLabel={loadingLabel} />;
}
return (
<AutoSizer>
{({ width, height }) => (
<List
className="options_list"
height={height - 8}
width={width + 8}
itemCount={groups.length}
itemSize={48}
outerElementType={CustomScrollbarsVirtualList}
>
{renderGroup}
</List>
)}
</AutoSizer>
);
}, [
isMultiSelect,
groups,
selectedOptionList,
getGroupSelectedOptions,
loadingLabel,
]);
const renderGroupHeader = useCallback(() => {
const selectedOption = getGroupSelectedOptions(groupHeader.id);
const isIndeterminate = selectedOption.length > 0;
let label = groupHeader.label;
if (isMultiSelect && selectedOption.length > 0) {
label = `${groupHeader.label} (${selectedOption.length})`;
}
return (
<>
<div className="row-option row-header">
<div className="option-info">
<Avatar
className="option-avatar"
role="user"
size="min"
source={groupHeader.avatarUrl}
userName={groupHeader.label}
/>
<Text
className="option-text option-text__header"
truncate={true}
noSelect={true}
fontSize="14px"
>
{label}
</Text>
</div>
{isMultiSelect && (
<Checkbox
isIndeterminate={isIndeterminate}
className="option-checkbox"
/>
)}
</div>
<div className="option-separator"></div>
</>
);
}, [isMultiSelect, groupHeader, selectedOptionList, getGroupSelectedOptions]);
const renderOptionList = React.useCallback(() => {
return (
<AutoSizer>
{({ width, height }) => (
<InfiniteLoader
ref={listOptionsRef}
isItemLoaded={isItemLoaded}
itemCount={itemCount}
loadMoreItems={loadMoreItems}
>
{({ onItemsRendered, ref }) => (
<List
className="options_list"
height={height - 25}
itemCount={itemCount}
itemSize={48}
onItemsRendered={onItemsRendered}
ref={ref}
width={width + 8}
outerElementType={CustomScrollbarsVirtualList}
>
{renderOption}
</List>
)}
</InfiniteLoader>
)}
</AutoSizer>
);
}, [listOptionsRef, isItemLoaded, itemCount, loadMoreItems, renderOption]);
const onArrowClickAction = useCallback(() => {
if (groupHeader && groups.length !== 1) {
setGroupHeader(null);
onGroupChanged && onGroupChanged([]);
setCurrentGroup([]);
return;
}
onArrowClick && onArrowClick();
}, [groups, groupHeader, onArrowClick, onGroupChanged]);
return (
<StyledSelector
isMultiSelect={isMultiSelect}
hasSelected={hasSelected()}
className="selector-wrapper"
>
<Header
headerLabel={headerLabel}
onArrowClickAction={onArrowClickAction}
/>
<div
style={{ width: "320px", height: "100%" }}
className="column-options"
size={size}
>
<Search
isDisabled={isDisabled}
placeholder={searchPlaceHolderLabel}
value={searchValue}
onChange={onSearchChange}
onClearSearch={onSearchReset}
/>
<div style={{ width: "100%", height: "100%" }} className="body-options">
{!groupHeader && !searchValue && groups ? (
renderGroupsList()
) : (
<>
{!searchValue && renderGroupHeader()}
{!hasNextPage && itemCount === 0 ? (
<div className="row-option">
<Text>
{!searchValue ? emptyOptionsLabel : emptySearchOptionsLabel}
</Text>
</div>
) : (
renderOptionList()
)}
</>
)}
{getOptionTooltipContent && (
<Tooltip
id="user"
offsetRight={90}
getContent={getOptionTooltipContent}
/>
)}
</div>
</div>
<Footer
className="footer"
selectButtonLabel={headerLabel}
showCounter={showCounter}
isDisabled={isDisabled}
isVisible={isMultiSelect && hasSelected()}
onClick={onAddClick}
embeddedComponent={embeddedComponent}
selectedLength={selectedOptionList.length}
/>
</StyledSelector>
);
};
Selector.propTypes = {
options: PropTypes.array,
groups: PropTypes.array,
hasNextPage: PropTypes.bool,
isNextPageLoading: PropTypes.bool,
loadNextPage: PropTypes.func,
isDisabled: PropTypes.bool,
isMultiSelect: PropTypes.bool,
allowGroupSelection: PropTypes.bool,
selectButtonLabel: PropTypes.string,
selectAllLabel: PropTypes.string,
searchPlaceHolderLabel: PropTypes.string,
groupsHeaderLabel: PropTypes.string,
emptySearchOptionsLabel: PropTypes.string,
emptyOptionsLabel: PropTypes.string,
loadingLabel: PropTypes.string,
size: PropTypes.oneOf(["compact", "full"]),
selectedOptions: PropTypes.array,
selectedGroups: PropTypes.array,
onSelect: PropTypes.func,
onSearchChanged: PropTypes.func,
onGroupChanged: PropTypes.func,
getOptionTooltipContent: PropTypes.func,
embeddedComponent: PropTypes.any,
};
Selector.defaultProps = {
size: "full",
};
export default Selector;