You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

302 lines
8.9 KiB

  1. "use client";
  2. import * as React from "react";
  3. import List from "@mui/material/List";
  4. import ListItem from "@mui/material/ListItem";
  5. import ListItemText from "@mui/material/ListItemText";
  6. import ListItemIcon from "@mui/material/ListItemIcon";
  7. import Checkbox from "@mui/material/Checkbox";
  8. import IconButton from "@mui/material/Fab";
  9. import Divider from "@mui/material/Divider";
  10. import ChevronLeft from "@mui/icons-material/ChevronLeft";
  11. import ChevronRight from "@mui/icons-material/ChevronRight";
  12. import intersection from "lodash/intersection";
  13. import differenceBy from "lodash/differenceBy";
  14. import Stack from "@mui/material/Stack";
  15. import Paper from "@mui/material/Paper";
  16. import Typography from "@mui/material/Typography";
  17. import ListSubheader from "@mui/material/ListSubheader";
  18. import groupBy from "lodash/groupBy";
  19. import uniqBy from "lodash/uniqBy";
  20. import { useTranslation } from "react-i18next";
  21. import union from "lodash/union";
  22. import intersectionBy from "lodash/intersectionBy";
  23. export interface LabelGroup {
  24. id: number;
  25. name: string;
  26. }
  27. export interface LabelWithId {
  28. id: number;
  29. label: string;
  30. group?: LabelGroup;
  31. }
  32. export interface TransferListProps {
  33. allItems: LabelWithId[];
  34. selectedItems: LabelWithId[];
  35. onChange: (selectedItems: LabelWithId[]) => void;
  36. allItemsLabel: string;
  37. selectedItemsLabel: string;
  38. }
  39. interface ItemListProps {
  40. items: LabelWithId[];
  41. checkedItems: LabelWithId[];
  42. label: string;
  43. handleToggleAll: (
  44. items: LabelWithId[],
  45. checkedItems: LabelWithId[],
  46. ) => React.MouseEventHandler;
  47. handleToggleAllInGroup: (
  48. groupItems: LabelWithId[],
  49. checkedItems: LabelWithId[],
  50. ) => React.MouseEventHandler;
  51. handleToggle: (item: LabelWithId) => React.MouseEventHandler;
  52. }
  53. const ItemList: React.FC<ItemListProps> = ({
  54. items,
  55. checkedItems,
  56. label,
  57. handleToggle,
  58. handleToggleAll,
  59. handleToggleAllInGroup,
  60. }) => {
  61. const { t } = useTranslation();
  62. const groups: LabelGroup[] = uniqBy(
  63. [
  64. ...items.reduce<LabelGroup[]>((acc, item) => {
  65. return item.group ? [...acc, item.group] : acc;
  66. }, []),
  67. // Items with no group
  68. { id: 0, name: t("Ungrouped") },
  69. ],
  70. "id",
  71. );
  72. const groupedItems = groupBy(items, (item) => item.group?.id ?? 0);
  73. return (
  74. <Paper sx={{ width: "100%" }} variant="outlined">
  75. <List
  76. sx={{
  77. height: 400,
  78. bgcolor: "background.paper",
  79. overflow: "auto",
  80. }}
  81. disablePadding
  82. dense
  83. component="ul"
  84. subheader={
  85. <ListSubheader
  86. sx={{ zIndex: 2 }}
  87. disableGutters
  88. component="li"
  89. onClick={handleToggleAll(items, checkedItems)}
  90. >
  91. <Stack direction="row" paddingY={1} paddingX={2}>
  92. <ListItemIcon>
  93. <Checkbox
  94. checked={
  95. checkedItems.length === items.length && items.length !== 0
  96. }
  97. indeterminate={
  98. checkedItems.length !== items.length &&
  99. checkedItems.length !== 0
  100. }
  101. disabled={items.length === 0}
  102. />
  103. </ListItemIcon>
  104. <Stack>
  105. <Typography variant="subtitle2">{label}</Typography>
  106. <Typography variant="caption">{`${checkedItems.length}/${
  107. items.length
  108. } ${t("selected")}`}</Typography>
  109. </Stack>
  110. </Stack>
  111. <Divider />
  112. </ListSubheader>
  113. }
  114. >
  115. {groups.map((group) => {
  116. const groupItems = groupedItems[group.id];
  117. const selectedGroupItems = intersectionBy(
  118. checkedItems,
  119. groupItems,
  120. "id",
  121. );
  122. if (!groupItems) return null;
  123. return (
  124. <React.Fragment key={group.id}>
  125. <ListSubheader
  126. disableSticky
  127. onClick={handleToggleAllInGroup(groupItems, checkedItems)}
  128. sx={{
  129. paddingBlock: 2,
  130. lineHeight: 1.8,
  131. display: "flex",
  132. alignItems: "center",
  133. }}
  134. >
  135. <ListItemIcon>
  136. <Checkbox
  137. checked={selectedGroupItems.length === groupItems.length}
  138. indeterminate={
  139. selectedGroupItems.length !== groupItems.length &&
  140. selectedGroupItems.length !== 0
  141. }
  142. />
  143. </ListItemIcon>
  144. {group.name}
  145. </ListSubheader>
  146. {groupItems.map((item) => {
  147. return (
  148. <ListItem key={item.id} onClick={handleToggle(item)}>
  149. <ListItemIcon>
  150. <Checkbox
  151. checked={checkedItems.includes(item)}
  152. tabIndex={-1}
  153. />
  154. </ListItemIcon>
  155. <ListItemText primary={item.label} />
  156. </ListItem>
  157. );
  158. })}
  159. </React.Fragment>
  160. );
  161. })}
  162. </List>
  163. </Paper>
  164. );
  165. };
  166. const TransferList: React.FC<TransferListProps> = ({
  167. allItems,
  168. selectedItems,
  169. allItemsLabel,
  170. selectedItemsLabel,
  171. onChange,
  172. }) => {
  173. // Keep a map for the original order of items
  174. const sortMap = React.useMemo(() => {
  175. return allItems.reduce<{ [id: string]: number }>(
  176. (acc, item, index) => ({ ...acc, [item.id]: index }),
  177. {},
  178. );
  179. }, [allItems]);
  180. const compareFn = React.useCallback(
  181. (a: LabelWithId, b: LabelWithId) => sortMap[a.id] - sortMap[b.id],
  182. [sortMap],
  183. );
  184. const [checkedList, setCheckedList] = React.useState<LabelWithId[]>([]);
  185. const [leftList, setLeftList] = React.useState<LabelWithId[]>(
  186. differenceBy(allItems, selectedItems, "id"),
  187. );
  188. React.useEffect(() => {
  189. setLeftList(differenceBy(allItems, selectedItems, "id"));
  190. }, [allItems, selectedItems]);
  191. const rightList = selectedItems;
  192. const leftListChecked = intersection(checkedList, leftList);
  193. const rightListChecked = intersection(checkedList, rightList);
  194. const handleToggle = React.useCallback(
  195. (value: LabelWithId) => () => {
  196. const isChecked = checkedList.includes(value);
  197. const newCheckedList = isChecked
  198. ? differenceBy(checkedList, [value], "id")
  199. : [...checkedList, value];
  200. setCheckedList(newCheckedList);
  201. },
  202. [checkedList],
  203. );
  204. const handleToggleAll = React.useCallback(
  205. (items: LabelWithId[], checkedItems: LabelWithId[]) => () => {
  206. if (checkedItems.length === items.length) {
  207. setCheckedList(differenceBy(checkedList, checkedItems, "id"));
  208. } else {
  209. setCheckedList([...checkedList, ...items]);
  210. }
  211. },
  212. [checkedList],
  213. );
  214. const handleCheckedRight = () => {
  215. onChange([...selectedItems, ...leftListChecked].sort(compareFn));
  216. setLeftList(differenceBy(leftList, leftListChecked, "id").sort(compareFn));
  217. setCheckedList(differenceBy(checkedList, leftListChecked, "id"));
  218. };
  219. const handleCheckedLeft = () => {
  220. setLeftList([...leftList, ...rightListChecked].sort(compareFn));
  221. onChange(differenceBy(rightList, rightListChecked, "id").sort(compareFn));
  222. setCheckedList(differenceBy(checkedList, rightListChecked, "id"));
  223. };
  224. const handleToggleAllInGroup = React.useCallback(
  225. (groupItems: LabelWithId[], checkedItems: LabelWithId[]) => () => {
  226. const selectedGroupItems = intersectionBy(checkedItems, groupItems, "id");
  227. if (selectedGroupItems.length !== groupItems.length) {
  228. setCheckedList(union(checkedList, groupItems));
  229. } else {
  230. setCheckedList(differenceBy(checkedList, groupItems, "id"));
  231. }
  232. },
  233. [checkedList],
  234. );
  235. return (
  236. <Stack spacing={2} direction="row" alignItems="center" position="relative">
  237. <ItemList
  238. items={leftList}
  239. checkedItems={leftListChecked}
  240. label={allItemsLabel}
  241. handleToggleAll={handleToggleAll}
  242. handleToggle={handleToggle}
  243. handleToggleAllInGroup={handleToggleAllInGroup}
  244. />
  245. <ItemList
  246. items={rightList}
  247. checkedItems={rightListChecked}
  248. label={selectedItemsLabel}
  249. handleToggleAll={handleToggleAll}
  250. handleToggle={handleToggle}
  251. handleToggleAllInGroup={handleToggleAllInGroup}
  252. />
  253. <Stack
  254. spacing={1}
  255. position="absolute"
  256. margin="0 !important"
  257. left="50%"
  258. sx={{ transform: "translateX(-50%)" }}
  259. >
  260. <IconButton
  261. color="secondary"
  262. size="small"
  263. onClick={handleCheckedRight}
  264. disabled={leftListChecked.length === 0}
  265. >
  266. <ChevronRight />
  267. </IconButton>
  268. <IconButton
  269. color="secondary"
  270. size="small"
  271. onClick={handleCheckedLeft}
  272. disabled={rightListChecked.length === 0}
  273. >
  274. <ChevronLeft />
  275. </IconButton>
  276. </Stack>
  277. </Stack>
  278. );
  279. };
  280. export default TransferList;