From 38cca0fa1abaa557c59cc66a02e47fea971c883a Mon Sep 17 00:00:00 2001 From: pieperm Date: Thu, 11 Apr 2024 15:58:20 -0500 Subject: [PATCH 01/10] Replace TransferList with MemberSelector, add customization props to MemberSelector --- .../member_selector/MemberSelector.jsx | 7 +++-- web-ui/src/pages/EmailPage.jsx | 29 ++++++++++++------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/web-ui/src/components/member_selector/MemberSelector.jsx b/web-ui/src/components/member_selector/MemberSelector.jsx index e99a726877..5f5e1b956d 100644 --- a/web-ui/src/components/member_selector/MemberSelector.jsx +++ b/web-ui/src/components/member_selector/MemberSelector.jsx @@ -24,12 +24,14 @@ import Divider from "@mui/material/Divider"; const propTypes = { onChange: PropTypes.func, + title: PropTypes.string, + outlined: PropTypes.bool, listHeight: PropTypes.number, className: PropTypes.string, style: PropTypes.object }; -const MemberSelector = ({ onChange, listHeight, className, style }) => { +const MemberSelector = ({ onChange, title, outlined, listHeight, className, style }) => { const [selectedMembers, setSelectedMembers] = useState([]); const [dialogOpen, setDialogOpen] = useState(false); const [expanded, setExpanded] = useState(true); @@ -56,6 +58,7 @@ const MemberSelector = ({ onChange, listHeight, className, style }) => { return ( <> { } title={
- Selected Members + {title || "Selected Members"} ({selectedMembers.length})
} diff --git a/web-ui/src/pages/EmailPage.jsx b/web-ui/src/pages/EmailPage.jsx index a831b28484..80d4e93066 100644 --- a/web-ui/src/pages/EmailPage.jsx +++ b/web-ui/src/pages/EmailPage.jsx @@ -29,6 +29,8 @@ import {sendEmail} from "../api/notifications"; import "./EmailPage.css"; import TransferList from "../components/transfer_list/TransferList"; import {getAvatarURL} from "../api/api"; +import MemberSelector from "../components/member_selector/MemberSelector.jsx"; +import {selectCsrfToken, selectMemberProfiles} from "../context/selectors.js"; const Root = styled("div")({ @@ -261,21 +263,27 @@ const SelectRecipientsStep = ({ testEmail, onTestEmailChange, onSendTestEmail, r Send Test Email - onRecipientsChange(lists)} - disabled={emailSent} + onRecipientsChange(selectedMembers)} + title="Recipients" + outlined /> + {/* onRecipientsChange(lists)}*/} + {/* disabled={emailSent}*/} + {/*/>*/} ); } const EmailPage = () => { const { state } = useContext(AppContext); - const { memberProfiles, csrf } = state; + const csrf = selectCsrfToken(state); + const memberProfiles = selectMemberProfiles(state); const [currentStep, setCurrentStep] = useState(0); const [emailFormat, setEmailFormat] = useState(null); const [emailContents, setEmailContents] = useState(""); @@ -445,9 +453,8 @@ const EmailPage = () => { recipientOptions={recipientOptions} recipients={recipients} emailSent={emailSent} - onRecipientsChange={({ left, right }) => { - setRecipientOptions(left); - setRecipients(right); + onRecipientsChange={(recipients) => { + setRecipients(recipients); }} /> )} From d851913acfe8467e5f6a6a28067bed8672b86883 Mon Sep 17 00:00:00 2001 From: pieperm Date: Thu, 11 Apr 2024 15:58:49 -0500 Subject: [PATCH 02/10] Fix members not showing sometimes when reopening dialog --- .../member_selector_dialog/MemberSelectorDialog.jsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/web-ui/src/components/member_selector/member_selector_dialog/MemberSelectorDialog.jsx b/web-ui/src/components/member_selector/member_selector_dialog/MemberSelectorDialog.jsx index dd910415fa..51c073ed15 100644 --- a/web-ui/src/components/member_selector/member_selector_dialog/MemberSelectorDialog.jsx +++ b/web-ui/src/components/member_selector/member_selector_dialog/MemberSelectorDialog.jsx @@ -241,10 +241,12 @@ const MemberSelectorDialog = ({ open, selectedMembers, onClose, onSubmit }) => { return filteredMemberList; } - getFilteredMembers().then(filtered => { - setFilteredMembers(filtered); - }); - }, [state, csrf, members, filterType, filter, selectedMembers, showError, directReportsOnly]); + if (open) { + getFilteredMembers().then(filtered => { + setFilteredMembers(filtered); + }); + } + }, [state, csrf, members, filterType, filter, selectedMembers, showError, directReportsOnly, open]); useEffect(() => { let selectable = [...filteredMembers]; From b20e74a2b8c9658a1ed834526a4547ffb8813237 Mon Sep 17 00:00:00 2001 From: pieperm Date: Fri, 12 Apr 2024 11:57:45 -0500 Subject: [PATCH 03/10] Add option to make MemberSelector a controlled component --- web-ui/src/components/member_selector/MemberSelector.jsx | 7 +++++-- web-ui/src/pages/EmailPage.jsx | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/web-ui/src/components/member_selector/MemberSelector.jsx b/web-ui/src/components/member_selector/MemberSelector.jsx index 5f5e1b956d..88839ffc1f 100644 --- a/web-ui/src/components/member_selector/MemberSelector.jsx +++ b/web-ui/src/components/member_selector/MemberSelector.jsx @@ -23,6 +23,7 @@ import MemberSelectorDialog from "./member_selector_dialog/MemberSelectorDialog" import Divider from "@mui/material/Divider"; const propTypes = { + selected: PropTypes.arrayOf(PropTypes.object), onChange: PropTypes.func, title: PropTypes.string, outlined: PropTypes.bool, @@ -31,8 +32,10 @@ const propTypes = { style: PropTypes.object }; -const MemberSelector = ({ onChange, title, outlined, listHeight, className, style }) => { - const [selectedMembers, setSelectedMembers] = useState([]); +const MemberSelector = ({ selected, onChange, title, outlined, listHeight, className, style }) => { + const isControlled = !!selected && Array.isArray(selected); + + const [selectedMembers, setSelectedMembers] = useState(isControlled ? selected : []); const [dialogOpen, setDialogOpen] = useState(false); const [expanded, setExpanded] = useState(true); diff --git a/web-ui/src/pages/EmailPage.jsx b/web-ui/src/pages/EmailPage.jsx index 80d4e93066..3aa342fc5e 100644 --- a/web-ui/src/pages/EmailPage.jsx +++ b/web-ui/src/pages/EmailPage.jsx @@ -264,6 +264,7 @@ const SelectRecipientsStep = ({ testEmail, onTestEmailChange, onSendTestEmail, r onRecipientsChange(selectedMembers)} title="Recipients" outlined From 2c7c2ac9e66e97a10803ca6b581a32fe2385c8fe Mon Sep 17 00:00:00 2001 From: pieperm Date: Fri, 12 Apr 2024 14:13:46 -0500 Subject: [PATCH 04/10] Add ability to disable MemberSelector, remove unused variables --- .../member_selector/MemberSelector.jsx | 36 ++++++++++++++++--- web-ui/src/pages/EmailPage.jsx | 28 ++------------- 2 files changed, 34 insertions(+), 30 deletions(-) diff --git a/web-ui/src/components/member_selector/MemberSelector.jsx b/web-ui/src/components/member_selector/MemberSelector.jsx index 88839ffc1f..9207d26276 100644 --- a/web-ui/src/components/member_selector/MemberSelector.jsx +++ b/web-ui/src/components/member_selector/MemberSelector.jsx @@ -23,28 +23,45 @@ import MemberSelectorDialog from "./member_selector_dialog/MemberSelectorDialog" import Divider from "@mui/material/Divider"; 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, + /** 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 = ({ selected, onChange, title, outlined, listHeight, className, style }) => { +const MemberSelector = ({selected, onChange, title = "Selected Members", outlined = false, listHeight = 400, disabled = false, className, style }) => { const isControlled = !!selected && Array.isArray(selected); const [selectedMembers, setSelectedMembers] = useState(isControlled ? selected : []); const [dialogOpen, setDialogOpen] = useState(false); const [expanded, setExpanded] = useState(true); + // 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); @@ -72,13 +89,17 @@ const MemberSelector = ({ selected, onChange, title, outlined, listHeight, class } title={
- {title || "Selected Members"} + {title} ({selectedMembers.length})
} action={ - setDialogOpen(true)}> + setDialogOpen(true)} + disabled={disabled} + > @@ -86,7 +107,7 @@ const MemberSelector = ({ selected, onChange, title, outlined, listHeight, class /> - + {selectedMembers.length ? (selectedMembers.map(member => - removeMember(member)}> + removeMember(member)} + disabled={disabled} + > + + } > diff --git a/web-ui/src/pages/EmailPage.jsx b/web-ui/src/pages/EmailPage.jsx index 3aa342fc5e..440816b72d 100644 --- a/web-ui/src/pages/EmailPage.jsx +++ b/web-ui/src/pages/EmailPage.jsx @@ -27,11 +27,9 @@ import {UPDATE_TOAST} from "../context/actions"; import {sendEmail} from "../api/notifications"; import "./EmailPage.css"; -import TransferList from "../components/transfer_list/TransferList"; import {getAvatarURL} from "../api/api"; import MemberSelector from "../components/member_selector/MemberSelector.jsx"; -import {selectCsrfToken, selectMemberProfiles} from "../context/selectors.js"; - +import {selectCsrfToken} from "../context/selectors.js"; const Root = styled("div")({ margin: "2rem" @@ -219,7 +217,7 @@ const ComposeEmailStep = ({ emailFormat, emailContents, emailSubject, onSubjectC return <> } -const SelectRecipientsStep = ({ testEmail, onTestEmailChange, onSendTestEmail, recipientOptions, recipients, onRecipientsChange, emailSent }) => { +const SelectRecipientsStep = ({ testEmail, onTestEmailChange, onSendTestEmail, recipients, onRecipientsChange, emailSent }) => { const [emailError, setEmailError] = useState(false); @@ -268,15 +266,8 @@ const SelectRecipientsStep = ({ testEmail, onTestEmailChange, onSendTestEmail, r onChange={(selectedMembers) => onRecipientsChange(selectedMembers)} title="Recipients" outlined + disabled={emailSent} /> - {/* onRecipientsChange(lists)}*/} - {/* disabled={emailSent}*/} - {/*/>*/} ); } @@ -284,29 +275,17 @@ const SelectRecipientsStep = ({ testEmail, onTestEmailChange, onSendTestEmail, r const EmailPage = () => { const { state } = useContext(AppContext); const csrf = selectCsrfToken(state); - const memberProfiles = selectMemberProfiles(state); const [currentStep, setCurrentStep] = useState(0); const [emailFormat, setEmailFormat] = useState(null); const [emailContents, setEmailContents] = useState(""); const [emailSubject, setEmailSubject] = useState(""); - const [recipientOptions, setRecipientOptions] = useState([]); const [recipients, setRecipients] = useState([]); const [testEmail, setTestEmail] = useState(""); const [testEmailSent, setTestEmailSent] = useState(false); const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); - const [activeMembers, setActiveMembers] = useState([]); const [emailSent, setEmailSent] = useState(false); const steps = ["Choose Email Format", "Compose Email", "Select Recipients"]; - useEffect(() => { - const unterminatedMembers = memberProfiles.filter(member => member.terminationDate === null); - setActiveMembers(unterminatedMembers); - }, [memberProfiles]); - - useEffect(() => { - setRecipientOptions(activeMembers); - }, [activeMembers]); - useEffect(() => { window.scrollTo(0, 0); }, [currentStep]); @@ -451,7 +430,6 @@ const EmailPage = () => { testEmail={testEmail} onTestEmailChange={(address) => setTestEmail(address)} onSendTestEmail={sendTestEmail} - recipientOptions={recipientOptions} recipients={recipients} emailSent={emailSent} onRecipientsChange={(recipients) => { From 678a68ce109c61c3db1830be9728e87b609b2dd0 Mon Sep 17 00:00:00 2001 From: pieperm Date: Fri, 12 Apr 2024 14:16:01 -0500 Subject: [PATCH 05/10] Delete TransferList component --- .../components/transfer_list/TransferList.css | 37 -- .../components/transfer_list/TransferList.jsx | 416 ------------------ 2 files changed, 453 deletions(-) delete mode 100644 web-ui/src/components/transfer_list/TransferList.css delete mode 100644 web-ui/src/components/transfer_list/TransferList.jsx diff --git a/web-ui/src/components/transfer_list/TransferList.css b/web-ui/src/components/transfer_list/TransferList.css deleted file mode 100644 index 267a2430fd..0000000000 --- a/web-ui/src/components/transfer_list/TransferList.css +++ /dev/null @@ -1,37 +0,0 @@ -.transfer-list-container { - display: grid; - grid-template-columns: minmax(270px, 1fr) 7rem minmax(270px, 1fr); - justify-content: center; - align-items: start; -} - -.transfer-list .empty-list-message-container { - width: 100%; - height: 100%; - display: flex; - justify-content: center; - align-items: center; -} - -.transfer-list .empty-list-message-container .empty-list-message { - padding: 2rem 3rem; - color: gray; - background-color: #e8e8e8; - border: 1px dashed #c2c2c2; - border-radius: 4px; -} - -.transfer-list-filter-container { - display: grid; - grid-template-columns: 1fr 150px; - grid-column-gap: 10px; - padding: 8px 16px 16px 16px; -} - -.transfer-list-filter-container .transfer-list-filter-field { - width: 100%; -} - -.transfer-list-filter-container .transfer-list-filter { - width: 100%; -} diff --git a/web-ui/src/components/transfer_list/TransferList.jsx b/web-ui/src/components/transfer_list/TransferList.jsx deleted file mode 100644 index 9c74c368e6..0000000000 --- a/web-ui/src/components/transfer_list/TransferList.jsx +++ /dev/null @@ -1,416 +0,0 @@ -import React, {useContext, useEffect, useState} from "react"; -import { - Autocomplete, - Avatar, - Button, - Card, - CardHeader, - Checkbox, - Divider, FormControl, - Grid, IconButton, InputAdornment, InputLabel, - List, - ListItem, ListItemAvatar, ListItemButton, - ListItemText, MenuItem, Select, TextField, Tooltip, Typography, Collapse, -} from "@mui/material"; -import LeftArrowIcon from "@mui/icons-material/KeyboardArrowLeft"; -import RightArrowIcon from "@mui/icons-material/KeyboardArrowRight"; -import SearchIcon from "@mui/icons-material/Search"; -import FilterIcon from "@mui/icons-material/FilterList"; -import DownloadIcon from "@mui/icons-material/FileDownload"; -import PropTypes from "prop-types"; -import {getAvatarURL} from "../../api/api"; - -import "./TransferList.css"; -import {AppContext} from "../../context/AppContext"; -import {getMembersByTeam} from "../../api/team"; -import {getMembersByGuild} from "../../api/guild"; -import {UPDATE_TOAST} from "../../context/actions"; -import {selectMappedUserRoles} from "../../context/selectors"; -import {reportSelectedMembersCsv} from "../../api/member"; -import fileDownload from "js-file-download"; - -const not = (a, b) => a.filter((value) => b.indexOf(value) === -1); -const intersection = (a, b) => a.filter((value) => b.indexOf(value) !== -1); -const union = (a, b) => [...a, ...not(b, a)]; - -const FilterOption = { - NAME: "NAME", - GUILD: "GUILD", - TEAM: "TEAM", - TITLE: "TITLE", - LOCATION: "LOCATION", - ROLE: "ROLE" -}; - -const propTypes = { - leftList: PropTypes.arrayOf(PropTypes.object).isRequired, - rightList: PropTypes.arrayOf(PropTypes.object).isRequired, - leftLabel: PropTypes.string, - rightLabel: PropTypes.string, - onListsChanged: PropTypes.func, - disabled: PropTypes.bool -}; - -const TransferList = ({ leftList, rightList, leftLabel, rightLabel, onListsChanged, disabled }) => { - - const { state, dispatch } = useContext(AppContext); - const { memberProfiles, guilds, teams, roles, csrf } = state; - const mappedUserRoles = selectMappedUserRoles(state); - const [checked, setChecked] = useState([]); - const [recipientFilterVisible, setRecipientFilterVisible] = useState(false); - const [recipientFilter, setRecipientFilter] = useState(FilterOption.NAME); - const [recipientQuery, setRecipientQuery] = useState(null); - const [filteredLeftList, setFilteredLeftList] = useState([]); - - const leftChecked = intersection(checked, leftList); - const rightChecked = intersection(checked, rightList); - - // Get all unique, defined titles - let memberTitles = memberProfiles.filter(member => !!member.title).map(member => member.title); - memberTitles = [...new Set(memberTitles)]; - memberTitles = memberTitles.map(title => {return {name: title}}); - - // Get all unique, defined locations - let memberLocations = memberProfiles.filter(member => !!member.location).map(member => member.location); - memberLocations = [...new Set(memberLocations)]; - memberLocations = memberLocations.map(location => {return {name: location}}); - - // Get all roles - let memberRoles = roles.map(role => {return {name: role.role}}); - - const filterOptions = { - [FilterOption.GUILD]: guilds, - [FilterOption.TEAM]: teams, - [FilterOption.TITLE]: memberTitles, - [FilterOption.LOCATION]: memberLocations, - [FilterOption.ROLE]: memberRoles - }; - - const handleToggle = (value) => { - if (disabled) return; - - const currentIndex = checked.indexOf(value); - const newChecked = [...checked]; - - if (currentIndex === -1) { - newChecked.push(value); - } else { - newChecked.splice(currentIndex, 1); - } - - setChecked(newChecked); - } - - const numberOfChecked = (items) => intersection(checked, items).length; - - const handleToggleAll = (items) => { - if (numberOfChecked(items) === items.length) { - setChecked(not(checked, items)); - } else { - setChecked(union(checked, items)); - } - } - - const handleCheckedRight = () => { - onListsChanged({ - left: not(leftList, leftChecked), - right: rightList.concat(leftChecked) - }); - setChecked(not(checked, leftChecked)); - } - - const handleCheckedLeft = () => { - onListsChanged({ - left: leftList.concat(rightChecked), - right: not(rightList, rightChecked) - }); - setChecked(not(checked, rightChecked)); - } - - const downloadMemberCsv = async () => { - const memberIds = rightList.map(member => member.id); - const res = await reportSelectedMembersCsv(memberIds, csrf); - if (!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 to CSV", - }, - }); - } - } - - useEffect(() => { - const getFilteredOptions = async (members) => { - let filterMembers; - switch (recipientFilter) { - case FilterOption.NAME: - filterMembers = (member) => member.name.toLowerCase().includes(recipientQuery.trim().toLowerCase()); - break; - case FilterOption.GUILD: - if (csrf) { - const guildId = recipientQuery.id; - const res = await getMembersByGuild(guildId, csrf); - const guildMembers = res && res.payload && res.payload.data && !res.error ? res.payload.data : null; - if (guildMembers) { - // Create set of member ids in the guild, then filter out ids not in the set (using set for quick access) - const guildMemberIds = new Set(); - guildMembers.forEach(guildMember => guildMemberIds.add(guildMember.memberId)); - filterMembers = (member) => guildMemberIds.has(member.id); - } else { - dispatch({ - type: UPDATE_TOAST, - payload: { - severity: "error", - toast: `Could not retrieve members for guild ${recipientQuery.name}` - } - }); - } - } - break; - case FilterOption.TEAM: - if (csrf) { - const teamId = recipientQuery.id; - const res = await getMembersByTeam(teamId, csrf); - const teamMembers = res && res.payload && res.payload.data && !res.error ? res.payload.data : null; - if (teamMembers) { - // Create set of member ids in the guild, then filter out ids not in the set (using set for quick access) - const teamMemberIds = new Set(); - teamMembers.forEach(teamMember => teamMemberIds.add(teamMember.memberId)); - filterMembers = (member) => teamMemberIds.has(member.id); - } else { - dispatch({ - type: UPDATE_TOAST, - payload: { - severity: "error", - toast: `Could not retrieve members for team ${recipientQuery.name}` - } - }); - } - } - break; - case FilterOption.TITLE: - filterMembers = (member) => member.title === recipientQuery.name; - break; - case FilterOption.LOCATION: - filterMembers = (member) => member.location === recipientQuery.name; - break; - case FilterOption.ROLE: - filterMembers = (member) => (member.id in mappedUserRoles) && mappedUserRoles[member.id].has(recipientQuery.name); - break; - default: - console.warn(`Invalid recipient filter ${recipientFilter}`); - return members; - } - - // Display members that are checked, even if not included by the filter - const checkedMembers = new Set(); - checked.forEach(member => checkedMembers.add(member.id)); - if (filterMembers) { - return members.filter(member => filterMembers(member) || checkedMembers.has(member.id)); - } - return members; - } - - // Only filter items if the filter is open and the user has entered a query - if (recipientFilterVisible && recipientQuery) { - getFilteredOptions(leftList).then(filtered => { - setFilteredLeftList(filtered); - }); - } else { - setFilteredLeftList(leftList); - } - },[leftList, recipientFilter, recipientQuery, recipientFilterVisible, csrf, dispatch, checked, mappedUserRoles]); - - const customList = (title, items, emptyMessage, includeFilter) => { - items = items.sort((a, b) => a.name.localeCompare(b.name)); - return ( - - - {includeFilter ? - - { - if (!recipientFilterVisible) { - setRecipientQuery(null); // Reset the query when opening the filter - } - setRecipientFilterVisible(!recipientFilterVisible); - }} - > - - - - : - -
- - - -
-
- } - handleToggleAll(items)} - checked={numberOfChecked(items) === items.length && items.length !== 0} - indeterminate={numberOfChecked(items) !== items.length && numberOfChecked(items) !== 0} - disabled={items.length === 0 || disabled} - style={{marginRight: "8px", marginTop: "-8px"}} - /> - - } - title={title} - subheader={`${numberOfChecked(items)}/${items.length} selected`} - titleTypographyProps={{ - fontWeight: "bold", - fontSize: "18px" - }} - /> - {includeFilter && - -
- {recipientFilter === FilterOption.NAME ? - setRecipientQuery(event.target.value)} - InputProps={{ - endAdornment: - }} - /> - : - option.name} - isOptionEqualToValue={(option, value) => option.name === value.name} - value={recipientQuery} - onChange={(event, value) => setRecipientQuery(value)} - renderInput={(params) => ( - - )} - /> - } - - Filter by - - -
-
- } - - - {items.length === 0 && -
- {emptyMessage} -
- } - {items.map((member) => ( - handleToggle(member)} - disablePadding - secondaryAction={ - - } - > - - - - - {member?.name}} - secondary={{member?.title}} - /> - - - ))} -
-
- ) - }; - - return ( -
- {customList(leftLabel || "Choices", filteredLeftList, "No members to select", true)} - - - - - - - {customList(rightLabel || "Chosen", rightList, "No recipients", false)} -
- ); -} - -TransferList.propTypes = propTypes; - -export default TransferList; \ No newline at end of file From 32368a4e5ced893a28351189542bbbdf29ef3099 Mon Sep 17 00:00:00 2001 From: pieperm Date: Fri, 12 Apr 2024 14:27:15 -0500 Subject: [PATCH 06/10] Update snapshot test and snapshot --- .../member_selector/MemberSelector.test.jsx | 49 +- .../MemberSelector.test.jsx.snap | 556 ++++++++++++++++++ 2 files changed, 594 insertions(+), 11 deletions(-) diff --git a/web-ui/src/components/member_selector/MemberSelector.test.jsx b/web-ui/src/components/member_selector/MemberSelector.test.jsx index 609e116164..fd67d03660 100644 --- a/web-ui/src/components/member_selector/MemberSelector.test.jsx +++ b/web-ui/src/components/member_selector/MemberSelector.test.jsx @@ -28,15 +28,42 @@ const initialState = { }, }; -it("renders correctly", () => { - snapshot( - - - - ); +describe("MemberSelector", () => { + it("renders correctly with default props", () => { + snapshot( + + + + ); + }); + + it("renders correctly as a controlled component", () => { + snapshot( + + + + ); + }); + + it("renders correctly when disabled", () => { + snapshot( + + + + ); + }); }); diff --git a/web-ui/src/components/member_selector/__snapshots__/MemberSelector.test.jsx.snap b/web-ui/src/components/member_selector/__snapshots__/MemberSelector.test.jsx.snap index 10c898b501..a5e708018f 100644 --- a/web-ui/src/components/member_selector/__snapshots__/MemberSelector.test.jsx.snap +++ b/web-ui/src/components/member_selector/__snapshots__/MemberSelector.test.jsx.snap @@ -1,5 +1,561 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`MemberSelector > renders correctly as a controlled component 1`] = ` +
+
+
+
+ +
+
+ +
+
+ Custom Title +
+
+ ( + 2 + ) +
+
+
+
+
+ +
+
+
+
+
+
+
    +
  • +
    +
    + +
    +
    +
    +

    + TestName +

    +
    +
    +
    + +
    +
  • +
  • +
    +
    + +
    +
    +
    +

    + TestName2 +

    +
    +
    +
    + +
    +
  • +
+
+
+
+
+
+`; + +exports[`MemberSelector > renders correctly when disabled 1`] = ` +
+
+
+
+ +
+
+ +
+
+ Selected Members +
+
+ ( + 2 + ) +
+
+
+
+
+ +
+
+
+
+
+
+
    +
  • +
    +
    + +
    +
    +
    +

    + TestName +

    +
    +
    +
    + +
    +
  • +
  • +
    +
    + +
    +
    +
    +

    + TestName2 +

    +
    +
    +
    + +
    +
  • +
+
+
+
+
+
+`; + +exports[`MemberSelector > renders correctly with default props 1`] = ` +
+
+
+
+ +
+
+ +
+
+ Selected Members +
+
+ ( + 0 + ) +
+
+
+
+
+ +
+
+
+
+
+
+
    +
  • +
    + + No members selected + +
    +
  • +
+
+
+
+
+
+`; + exports[`renders correctly 1`] = `
Date: Tue, 16 Apr 2024 13:45:18 -0500 Subject: [PATCH 07/10] Add CSV export to MemberSelector --- .../member_selector/MemberSelector.jsx | 55 ++++++++++++++++++- web-ui/src/pages/EmailPage.jsx | 1 + 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/web-ui/src/components/member_selector/MemberSelector.jsx b/web-ui/src/components/member_selector/MemberSelector.jsx index 9207d26276..e7d782d80b 100644 --- a/web-ui/src/components/member_selector/MemberSelector.jsx +++ b/web-ui/src/components/member_selector/MemberSelector.jsx @@ -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, @@ -21,6 +21,12 @@ import {getAvatarURL} from "../../api/api"; import "./MemberSelector.css"; import MemberSelectorDialog from "./member_selector_dialog/MemberSelectorDialog"; import Divider from "@mui/material/Divider"; +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"; const propTypes = { /** The members that are currently selected. Use to make this a controlled component. */ @@ -31,6 +37,8 @@ const propTypes = { 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. */ @@ -41,9 +49,12 @@ const propTypes = { style: PropTypes.object }; -const MemberSelector = ({selected, onChange, title = "Selected Members", outlined = false, listHeight = 400, disabled = false, className, style }) => { +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); @@ -75,6 +86,33 @@ const MemberSelector = ({selected, onChange, title = "Selected Members", outline setSelectedMembers(selected); }, [selectedMembers]); + const downloadMemberCsv = useCallback(async () => { + if (!exportable) { + return; + } + + const memberIds = selectedMembers.map(member => member.id); + const res = await reportSelectedMembersCsv(memberIds, csrf); + 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]); + return ( <> } action={ + <> + {exportable && + + + + + + } + } /> diff --git a/web-ui/src/pages/EmailPage.jsx b/web-ui/src/pages/EmailPage.jsx index 440816b72d..a25584ba12 100644 --- a/web-ui/src/pages/EmailPage.jsx +++ b/web-ui/src/pages/EmailPage.jsx @@ -266,6 +266,7 @@ const SelectRecipientsStep = ({ testEmail, onTestEmailChange, onSendTestEmail, r onChange={(selectedMembers) => onRecipientsChange(selectedMembers)} title="Recipients" outlined + exportable disabled={emailSent} /> From 247071ef92d156604fa26d8d6a793fa855db3c40 Mon Sep 17 00:00:00 2001 From: pieperm Date: Tue, 16 Apr 2024 13:45:33 -0500 Subject: [PATCH 08/10] Update snapshot test --- .../member_selector/MemberSelector.test.jsx | 1 + .../MemberSelector.test.jsx.snap | 147 +++--------------- 2 files changed, 24 insertions(+), 124 deletions(-) diff --git a/web-ui/src/components/member_selector/MemberSelector.test.jsx b/web-ui/src/components/member_selector/MemberSelector.test.jsx index fd67d03660..0861f71967 100644 --- a/web-ui/src/components/member_selector/MemberSelector.test.jsx +++ b/web-ui/src/components/member_selector/MemberSelector.test.jsx @@ -47,6 +47,7 @@ describe("MemberSelector", () => { onChange={vi.fn()} title="Custom Title" outlined + exportable listHeight={300} className="test-class" style={{ margin: "10px" }} diff --git a/web-ui/src/components/member_selector/__snapshots__/MemberSelector.test.jsx.snap b/web-ui/src/components/member_selector/__snapshots__/MemberSelector.test.jsx.snap index a5e708018f..cb9c7f411c 100644 --- a/web-ui/src/components/member_selector/__snapshots__/MemberSelector.test.jsx.snap +++ b/web-ui/src/components/member_selector/__snapshots__/MemberSelector.test.jsx.snap @@ -60,6 +60,29 @@ exports[`MemberSelector > renders correctly as a controlled component 1`] = `
+
`; - -exports[`renders correctly 1`] = ` -
-
-
-
- -
-
- -
-
- Selected Members -
-
- ( - 0 - ) -
-
-
-
-
- -
-
-
-
-
-
-
    -
  • -
    - - No members selected - -
    -
  • -
-
-
-
-
-
-`; From 14de0252a262fd57e5f83753a596ef370a2453bd Mon Sep 17 00:00:00 2001 From: pieperm Date: Thu, 18 Apr 2024 15:17:46 -0500 Subject: [PATCH 09/10] Move download button into dropdown menu --- .../member_selector/MemberSelector.jsx | 71 +++++++++---------- 1 file changed, 35 insertions(+), 36 deletions(-) diff --git a/web-ui/src/components/member_selector/MemberSelector.jsx b/web-ui/src/components/member_selector/MemberSelector.jsx index 5fe76b36b2..62449d91a6 100644 --- a/web-ui/src/components/member_selector/MemberSelector.jsx +++ b/web-ui/src/components/member_selector/MemberSelector.jsx @@ -90,31 +90,32 @@ const MemberSelector = ({selected, onChange, title = "Selected Members", outline setSelectedMembers(selected); }, [selectedMembers]); - const downloadMemberCsv = useCallback(async () => { + const downloadMemberCsv = useCallback(() => { if (!exportable) { return; } const memberIds = selectedMembers.map(member => member.id); - const res = await reportSelectedMembersCsv(memberIds, csrf); - 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" - } - }); - } + 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(() => { @@ -141,17 +142,6 @@ const MemberSelector = ({selected, onChange, title = "Selected Members", outline } action={ <> - {exportable && - - - - - - } - - setDialogOpen(true)}> - - - setMenuAnchor(event.currentTarget)}> @@ -186,6 +171,20 @@ const MemberSelector = ({selected, onChange, title = "Selected Members", outline Remove all + {exportable && + { + setMenuAnchor(null); + downloadMemberCsv(); + }} + disabled={!selectedMembers.length} + > + + + + Download + + } } From cab9ebb7505b9242103a177660dd43738fa0a865 Mon Sep 17 00:00:00 2001 From: pieperm Date: Thu, 18 Apr 2024 15:25:08 -0500 Subject: [PATCH 10/10] Fix disabling action for remove all, update snapshots --- .../member_selector/MemberSelector.jsx | 2 +- .../MemberSelector.test.jsx.snap | 33 +++++++++++++++---- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/web-ui/src/components/member_selector/MemberSelector.jsx b/web-ui/src/components/member_selector/MemberSelector.jsx index 62449d91a6..167ec20489 100644 --- a/web-ui/src/components/member_selector/MemberSelector.jsx +++ b/web-ui/src/components/member_selector/MemberSelector.jsx @@ -164,7 +164,7 @@ const MemberSelector = ({selected, onChange, title = "Selected Members", outline setMenuAnchor(null); clearMembers(); }} - disabled={!selectedMembers.length} + disabled={disabled || !selectedMembers.length} > diff --git a/web-ui/src/components/member_selector/__snapshots__/MemberSelector.test.jsx.snap b/web-ui/src/components/member_selector/__snapshots__/MemberSelector.test.jsx.snap index a80824d69d..64fdcac68e 100644 --- a/web-ui/src/components/member_selector/__snapshots__/MemberSelector.test.jsx.snap +++ b/web-ui/src/components/member_selector/__snapshots__/MemberSelector.test.jsx.snap @@ -61,7 +61,7 @@ exports[`MemberSelector > renders correctly as a controlled component 1`] = ` class="MuiCardHeader-action css-sgoict-MuiCardHeader-action" > +