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.
 
 

211 line
6.1 KiB

  1. "use client";
  2. import {
  3. Box,
  4. Checkbox,
  5. Divider,
  6. FormControl,
  7. InputLabel,
  8. ListItemIcon,
  9. ListItemText,
  10. ListSubheader,
  11. MenuItem,
  12. Select,
  13. SelectChangeEvent,
  14. Stack,
  15. Typography,
  16. } from "@mui/material";
  17. import React, { useCallback } from "react";
  18. import { LabelGroup, LabelWithId, TransferListProps } from "./TransferList";
  19. import { useTranslation } from "react-i18next";
  20. import uniqBy from "lodash/uniqBy";
  21. import groupBy from "lodash/groupBy";
  22. import intersectionBy from "lodash/intersectionBy";
  23. import union from "lodash/union";
  24. import differenceBy from "lodash/differenceBy";
  25. export const MultiSelectList: React.FC<TransferListProps> = ({
  26. allItems,
  27. selectedItems,
  28. selectedItemsLabel,
  29. allItemsLabel,
  30. onChange,
  31. }) => {
  32. // Keep a map for the original order of items
  33. const sortMap = React.useMemo(() => {
  34. return allItems.reduce<{ [id: string]: LabelWithId & { index: number } }>(
  35. (acc, item, index) => ({ ...acc, [item.id]: { ...item, index } }),
  36. {},
  37. );
  38. }, [allItems]);
  39. const compareFn = React.useCallback(
  40. (a: number, b: number) => sortMap[a].index - sortMap[b].index,
  41. [sortMap],
  42. );
  43. const handleChange = useCallback(
  44. (event: SelectChangeEvent<number[]>) => {
  45. const {
  46. target: { value },
  47. } = event;
  48. const selectedValues =
  49. typeof value === "string" ? [Number(value)] : value;
  50. onChange(allItems.filter((item) => selectedValues.includes(item.id)));
  51. },
  52. [allItems, onChange],
  53. );
  54. const handleToggleAll = useCallback(() => {
  55. if (selectedItems.length === allItems.length) {
  56. onChange([]);
  57. } else {
  58. onChange(allItems);
  59. }
  60. }, [allItems, onChange, selectedItems.length]);
  61. const handleToggleAllInGroup = useCallback(
  62. (groupItems: LabelWithId[]) => () => {
  63. const selectedGroupItems = intersectionBy(
  64. selectedItems,
  65. groupItems,
  66. "id",
  67. );
  68. if (selectedGroupItems.length !== groupItems.length) {
  69. onChange(union(selectedItems, groupItems));
  70. } else {
  71. onChange(differenceBy(selectedItems, groupItems, "id"));
  72. }
  73. },
  74. [onChange, selectedItems],
  75. );
  76. const { t } = useTranslation();
  77. const groups: LabelGroup[] = uniqBy(
  78. [
  79. ...allItems.reduce<LabelGroup[]>((acc, item) => {
  80. return item.group ? [...acc, item.group] : acc;
  81. }, []),
  82. // Items with no group
  83. { id: 0, name: t("Ungrouped") },
  84. ],
  85. "id",
  86. );
  87. const groupedItems = groupBy(allItems, (item) => item.group?.id ?? 0);
  88. return (
  89. <Box>
  90. <FormControl fullWidth>
  91. <InputLabel>{selectedItemsLabel}</InputLabel>
  92. <Select
  93. multiple
  94. value={selectedItems.map((item) => item.id)}
  95. onChange={handleChange}
  96. renderValue={(values) => {
  97. return (
  98. <Stack spacing={2}>
  99. {values.toSorted(compareFn).map((value) => (
  100. <Typography key={value} whiteSpace="normal">
  101. {sortMap[value]?.label}
  102. </Typography>
  103. ))}
  104. </Stack>
  105. );
  106. }}
  107. MenuProps={{
  108. slotProps: {
  109. paper: {
  110. sx: { maxHeight: 400 },
  111. },
  112. },
  113. anchorOrigin: {
  114. vertical: "top",
  115. horizontal: "left",
  116. },
  117. transformOrigin: {
  118. vertical: "top",
  119. horizontal: "left",
  120. },
  121. }}
  122. >
  123. <ListSubheader disableGutters sx={{ zIndex: 1 }}>
  124. <Stack
  125. direction="row"
  126. paddingY={1}
  127. paddingX={3}
  128. onClick={handleToggleAll}
  129. >
  130. <ListItemIcon>
  131. <Checkbox
  132. disableRipple
  133. checked={
  134. selectedItems.length === allItems.length &&
  135. allItems.length !== 0
  136. }
  137. indeterminate={
  138. selectedItems.length !== allItems.length &&
  139. selectedItems.length !== 0
  140. }
  141. />
  142. </ListItemIcon>
  143. <Stack>
  144. <Typography variant="subtitle2">{allItemsLabel}</Typography>
  145. <Typography variant="caption">{`${selectedItems.length}/${allItems.length} selected`}</Typography>
  146. </Stack>
  147. </Stack>
  148. <Divider />
  149. </ListSubheader>
  150. {groups.flatMap((group) => {
  151. const groupItems = groupedItems[group.id];
  152. const selectedGroupItems = intersectionBy(
  153. selectedItems,
  154. groupItems,
  155. "id",
  156. );
  157. if (!groupItems) return null;
  158. return [
  159. <ListSubheader disableSticky key={`${group.id}-${group.name}`}>
  160. <Stack
  161. onClick={handleToggleAllInGroup(groupItems)}
  162. direction="row"
  163. paddingX={1}
  164. >
  165. <Checkbox
  166. disableRipple
  167. checked={selectedGroupItems.length === groupItems.length}
  168. indeterminate={
  169. selectedGroupItems.length !== groupItems.length &&
  170. selectedGroupItems.length !== 0
  171. }
  172. />
  173. {group.name}
  174. </Stack>
  175. </ListSubheader>,
  176. ...groupItems.map((item) => {
  177. return (
  178. <MenuItem key={item.id} value={item.id} disableRipple>
  179. <Checkbox
  180. checked={Boolean(
  181. selectedItems.find(
  182. (selected) => selected.id === item.id,
  183. ),
  184. )}
  185. disableRipple
  186. />
  187. <ListItemText sx={{ whiteSpace: "normal" }}>
  188. {item.label}
  189. </ListItemText>
  190. </MenuItem>
  191. );
  192. }),
  193. ];
  194. })}
  195. </Select>
  196. </FormControl>
  197. </Box>
  198. );
  199. };
  200. export default MultiSelectList;