Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 108 additions & 23 deletions web-ui/src/components/member_selector/MemberSelector.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {useCallback, useEffect, useState} from "react";
import React, {useCallback, useContext, useEffect, useState} from "react";
import PropTypes from "prop-types";
import {
Avatar,
Expand All @@ -22,28 +22,61 @@ import MoreVertIcon from "@mui/icons-material/MoreVert";
import {getAvatarURL} from "../../api/api";

import MemberSelectorDialog from "./member_selector_dialog/MemberSelectorDialog";
import DownloadIcon from "@mui/icons-material/FileDownload";
import {reportSelectedMembersCsv} from "../../api/member.js";
import {AppContext} from "../../context/AppContext.jsx";
import {selectCsrfToken} from "../../context/selectors.js";
import fileDownload from "js-file-download";
import {UPDATE_TOAST} from "../../context/actions.js";

import "./MemberSelector.css";

const propTypes = {
/** The members that are currently selected. Use to make this a controlled component. */
selected: PropTypes.arrayOf(PropTypes.object),
/** Listener for whenever the list of selected members changes. Passes the updated list as an argument. */
onChange: PropTypes.func,
/** Optional title for the card. Default is "Selected Members". */
title: PropTypes.string,
/** Set to true to use the outlined variant of the card. Default is the elevated variant. */
outlined: PropTypes.bool,
/** If true, include a button to export the list of members to a CSV file. False by default. */
exportable: PropTypes.bool,
/** Adjusts the height of the scrollable list of selected members (in pixels) */
listHeight: PropTypes.number,
/** If true, members cannot be added to or removed from the current selection. False by default. */
disabled: PropTypes.bool,
/** A custom class name to additionally apply to the top-level card */
className: PropTypes.string,
/** Custom style properties to apply to the top-level card */
style: PropTypes.object
};

const MemberSelector = ({ onChange, listHeight, className, style }) => {
const [selectedMembers, setSelectedMembers] = useState([]);
const MemberSelector = ({selected, onChange, title = "Selected Members", outlined = false, exportable = false, listHeight = 400, disabled = false, className, style }) => {
const isControlled = !!selected && Array.isArray(selected);

const { state, dispatch } = useContext(AppContext);
const csrf = selectCsrfToken(state);

const [selectedMembers, setSelectedMembers] = useState(isControlled ? selected : []);
const [dialogOpen, setDialogOpen] = useState(false);
const [expanded, setExpanded] = useState(true);
const [menuAnchor, setMenuAnchor] = useState(null);

// When the selected members change, fire the onChange event
useEffect(() => {
if (onChange) {
onChange(selectedMembers);
}
}, [selectedMembers, onChange]);

// If the selector is disabled, make sure the selector dialog is closed
useEffect(() => {
if (disabled) {
setDialogOpen(false);
}
}, [disabled]);

const addMembers = useCallback((membersToAdd) => {
const selected = [...selectedMembers, ...membersToAdd];
setSelectedMembers(selected);
Expand All @@ -57,13 +90,42 @@ const MemberSelector = ({ onChange, listHeight, className, style }) => {
setSelectedMembers(selected);
}, [selectedMembers]);

const downloadMemberCsv = useCallback(() => {
if (!exportable) {
return;
}

const memberIds = selectedMembers.map(member => member.id);
reportSelectedMembersCsv(memberIds, csrf).then(res => {
if (res && !res.error) {
fileDownload(res.payload.data, "members.csv");
dispatch({
type: UPDATE_TOAST,
payload: {
severity: "success",
toast: "Member export has been saved"
}
});
} else {
dispatch({
type: UPDATE_TOAST,
payload: {
severity: "error",
toast: "Failed to export members to CSV"
}
});
}
});
}, [selectedMembers, csrf, dispatch]);

const clearMembers = useCallback(() => {
setSelectedMembers([]);
}, []);

return (
<>
<Card
variant={outlined ? "outlined" : "elevation"}
className={"member-selector-card" + (className ? ` ${className}` : "")}
style={style}>
<CardHeader
Expand All @@ -74,52 +136,75 @@ const MemberSelector = ({ onChange, listHeight, className, style }) => {
}
title={
<div className="member-selector-card-title-container">
<Typography className="member-selector-card-title" variant="h5" noWrap>Selected Members</Typography>
<Typography className="member-selector-card-title" variant="h5" noWrap>{title}</Typography>
<Typography className="member-selector-card-count" variant="h6" color="gray">({selectedMembers.length})</Typography>
</div>
}
action={
<>
<Tooltip title="Add members" arrow>
<IconButton style={{ margin: "4px 8px 0 0" }} onClick={() => setDialogOpen(true)}>
<AddIcon/>
</IconButton>
</Tooltip>
<IconButton style={{ margin: "4px 8px 0 0" }} onClick={(event) => setMenuAnchor(event.currentTarget)}>
<MoreVertIcon/>
<>
<Tooltip title="Add members" arrow>
<IconButton
style={{ margin: "4px 8px 0 0" }}
onClick={() => setDialogOpen(true)}
disabled={disabled}
>
<AddIcon/>
</IconButton>
<Menu
anchorEl={menuAnchor}
open={!!menuAnchor}
onClose={() => setMenuAnchor(null)}
</Tooltip>
<IconButton style={{ margin: "4px 8px 0 0" }} onClick={(event) => setMenuAnchor(event.currentTarget)}>
<MoreVertIcon/>
</IconButton>
<Menu
anchorEl={menuAnchor}
open={!!menuAnchor}
onClose={() => setMenuAnchor(null)}
>
<MenuItem
onClick={() => {
setMenuAnchor(null);
clearMembers();
}}
disabled={disabled || !selectedMembers.length}
>
<ListItemIcon>
<HighlightOffIcon fontSize="small"/>
</ListItemIcon>
<ListItemText>Remove all</ListItemText>
</MenuItem>
{exportable &&
<MenuItem
onClick={() => {
setMenuAnchor(null);
clearMembers();
downloadMemberCsv();
}}
disabled={!selectedMembers.length}
>
<ListItemIcon>
<HighlightOffIcon fontSize="small"/>
<DownloadIcon fontSize="small"/>
</ListItemIcon>
<ListItemText>Remove all</ListItemText>
<ListItemText>Download</ListItemText>
</MenuItem>
</Menu>
</>
}
</Menu>
</>
}
/>
<Collapse in={expanded}>
<Divider/>
<List dense role="list" sx={{ maxHeight: listHeight || 400, overflow: "auto" }}>
<List dense role="list" sx={{ maxHeight: listHeight, overflow: "auto" }}>
{selectedMembers.length
? (selectedMembers.map(member =>
<ListItem
key={member.id}
role="listitem"
secondaryAction={
<Tooltip title="Deselect member" arrow>
<IconButton onClick={() => removeMember(member)}><RemoveIcon/></IconButton>
<IconButton
onClick={() => removeMember(member)}
disabled={disabled}
>
<RemoveIcon/>
</IconButton>
</Tooltip>
}
>
Expand Down
50 changes: 39 additions & 11 deletions web-ui/src/components/member_selector/MemberSelector.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,43 @@ const initialState = {
},
};

it("renders correctly", () => {
snapshot(
<AppContextProvider value={initialState}>
<MemberSelector
onChange={vi.fn()}
listHeight={300}
className="test-class"
style={{ margin: "10px" }}
/>
</AppContextProvider>
);
describe("MemberSelector", () => {
it("renders correctly with default props", () => {
snapshot(
<AppContextProvider value={initialState}>
<MemberSelector
onChange={vi.fn()}
/>
</AppContextProvider>
);
});

it("renders correctly as a controlled component", () => {
snapshot(
<AppContextProvider value={initialState}>
<MemberSelector
selected={initialState.state.memberProfiles}
onChange={vi.fn()}
title="Custom Title"
outlined
exportable
listHeight={300}
className="test-class"
style={{ margin: "10px" }}
/>
</AppContextProvider>
);
});

it("renders correctly when disabled", () => {
snapshot(
<AppContextProvider value={initialState}>
<MemberSelector
selected={initialState.state.memberProfiles}
onChange={vi.fn()}
disabled
/>
</AppContextProvider>
);
});
});
Loading