diff --git a/web-ui/src/components/admin/roles/Roles.jsx b/web-ui/src/components/admin/roles/Roles.jsx index 3a8c2ba7da..c3b33f4ed7 100644 --- a/web-ui/src/components/admin/roles/Roles.jsx +++ b/web-ui/src/components/admin/roles/Roles.jsx @@ -37,14 +37,15 @@ import { FormHelperText, Divider } from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import EditIcon from '@mui/icons-material/Edit'; import PersonAddIcon from '@mui/icons-material/PersonAdd'; import SearchIcon from '@mui/icons-material/Search'; -import AddIcon from '@mui/icons-material/Add'; -import { isArrayPresent } from './../../../helpers/checks'; +import { isArrayPresent } from '../../../helpers/checks'; +import { useQueryParameters } from '../../../helpers/query-parameters'; import './Roles.css'; -import EditIcon from '@mui/icons-material/Edit'; const Roles = () => { const { state, dispatch } = useContext(AppContext); @@ -62,29 +63,27 @@ const Roles = () => { memberProfiles?.sort((a, b) => a.name.localeCompare(b.name)); - useEffect(() => { - const url = new URL(location.href); - const selectedRoles = url.searchParams.get('roles'); - if (selectedRoles?.length > 0) { - // Select only the roles specified in the URL. - setSelectedRoles(selectedRoles.split(',')); - } else { - // Select all possible roles. - setSelectedRoles(roles.map(r => r.role)); + if (!roles) console.error('Roles.jsx: state.roles is not set!'); + const allRoles = roles.map(r => r.role).sort(); + useQueryParameters([ + { + name: 'roles', + default: allRoles, + value: selectedRoles, + setter(value) { + setSelectedRoles(isArrayPresent(value) ? value.sort() : allRoles); + }, + toQP() { + return selectedRoles.join(','); + } + }, + { + name: 'search', + default: '', + value: searchText, + setter: setSearchText } - setSearchText(url.searchParams.get('search') ?? ''); - }, []); - - useEffect(() => { - const url = new URL(location.href); - const params = { - roles: selectedRoles.join(','), - search: searchText - }; - const q = new URLSearchParams(params).toString(); - const newUrl = url.origin + url.pathname + '?' + q; - history.replaceState(params, '', newUrl); - }, [searchText, selectedRoles]); + ]); useEffect(() => { const memberMap = {}; @@ -227,9 +226,7 @@ const Roles = () => { value={selectedRoles} onChange={event => { const value = event.target.value; - setSelectedRoles( - typeof value === 'string' ? value.split(',') : value - ); + setSelectedRoles(value.sort()); }} input={} renderValue={selected => selected.join(', ')} diff --git a/web-ui/src/components/admin/users/Users.jsx b/web-ui/src/components/admin/users/Users.jsx index 497acba449..68dbe9ec1e 100644 --- a/web-ui/src/components/admin/users/Users.jsx +++ b/web-ui/src/components/admin/users/Users.jsx @@ -1,7 +1,11 @@ import fileDownload from 'js-file-download'; import React, { useContext, useEffect, useState } from 'react'; +import DownloadIcon from '@mui/icons-material/FileDownload'; +import PersonIcon from '@mui/icons-material/Person'; +import { Button, Grid, TextField } from '@mui/material'; import { styled } from '@mui/material/styles'; + import AdminMemberCard from '../../member-directory/AdminMemberCard'; import MemberModal from '../../member-directory/MemberModal'; import { createMember, reportAllMembersCsv } from '../../../api/member'; @@ -12,10 +16,7 @@ import { selectNormalizedMembers, selectNormalizedMembersAdmin } from '../../../context/selectors'; - -import { Button, Grid, TextField } from '@mui/material'; -import DownloadIcon from '@mui/icons-material/FileDownload'; -import PersonIcon from '@mui/icons-material/Person'; +import { useQueryParameters } from '../../../helpers/query-parameters'; import './Users.css'; @@ -69,30 +70,26 @@ const Users = () => { ? selectNormalizedMembersAdmin(state, searchText) : selectNormalizedMembers(state, searchText); - useEffect(() => { - const url = new URL(location.href); - - const addUser = url.searchParams.get('addUser'); - setOpen(addUser === 'true'); - - const includeTerminated = url.searchParams.get('includeTerminated'); - setIncludeTerminated(includeTerminated === 'true'); - - const search = url.searchParams.get('search') || ''; - setSearchText(search); - }, []); - - useEffect(() => { - const url = new URL(location.href); - const params = { - addUser: open, - includeTerminated, - search: searchText - }; - const q = new URLSearchParams(params).toString(); - const newUrl = url.origin + url.pathname + '?' + q; - history.replaceState(params, '', newUrl); - }, [includeTerminated, open, searchText]); + useQueryParameters([ + { + name: 'addUser', + default: false, + value: open, + setter: setOpen + }, + { + name: 'includeTerminated', + default: false, + value: includeTerminated, + setter: setIncludeTerminated + }, + { + name: 'search', + default: '', + value: searchText, + setter: setSearchText + } + ]); const handleOpen = () => setOpen(true); diff --git a/web-ui/src/components/team-results/TeamResults.jsx b/web-ui/src/components/team-results/TeamResults.jsx index 4aaec41680..c4f4ffae92 100644 --- a/web-ui/src/components/team-results/TeamResults.jsx +++ b/web-ui/src/components/team-results/TeamResults.jsx @@ -1,4 +1,4 @@ -import React, { useContext, useState } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import { styled } from '@mui/material/styles'; import TeamSummaryCard from './TeamSummaryCard'; import { AppContext } from '../../context/AppContext'; @@ -11,6 +11,7 @@ import PropTypes from 'prop-types'; import { TextField } from '@mui/material'; import './TeamResults.css'; import SkeletonLoader from '../skeleton_loader/SkeletonLoader'; +import { useQueryParameters } from '../../helpers/query-parameters'; const PREFIX = 'TeamResults'; const classes = { @@ -38,7 +39,9 @@ const displayName = 'TeamResults'; const TeamResults = () => { const { state } = useContext(AppContext); const loading = selectTeamsLoading(state); + const [addingTeam, setAddingTeam] = useState(false); const [searchText, setSearchText] = useState(''); + const [selectedTeamId, setSelectedTeamId] = useState(''); const teams = selectNormalizedTeams(state, searchText); const teamCards = teams.map((team, index) => { @@ -47,10 +50,33 @@ const TeamResults = () => { key={`team-summary-${team.id}`} index={index} team={team} + onTeamSelect={setSelectedTeamId} + selectedTeamId={selectedTeamId} /> ); }); + useQueryParameters([ + { + name: 'addNew', + default: false, + value: addingTeam, + setter: setAddingTeam + }, + { + name: 'search', + default: '', + value: searchText, + setter: setSearchText + }, + { + name: 'team', + default: '', + value: selectedTeamId, + setter: setSelectedTeamId + } + ]); + return (
@@ -63,7 +89,7 @@ const TeamResults = () => { setSearchText(e.target.value); }} /> - +
{loading diff --git a/web-ui/src/components/team-results/TeamSummaryCard.jsx b/web-ui/src/components/team-results/TeamSummaryCard.jsx index 06ff4ec5c8..18a2269d97 100644 --- a/web-ui/src/components/team-results/TeamSummaryCard.jsx +++ b/web-ui/src/components/team-results/TeamSummaryCard.jsx @@ -55,10 +55,9 @@ const propTypes = { const displayName = 'TeamSummaryCard'; -const TeamSummaryCard = ({ team, index }) => { +const TeamSummaryCard = ({ team, index, onTeamSelect, selectedTeamId }) => { const { state, dispatch } = useContext(AppContext); const { teams, userProfile, csrf } = state; - const [open, setOpen] = useState(false); const [openDelete, setOpenDelete] = useState(false); const [tooltipIsOpen, setTooltipIsOpen] = useState(false); @@ -79,10 +78,8 @@ const TeamSummaryCard = ({ team, index }) => { ? false : leads.some(lead => lead.memberId === userProfile.memberProfile.id); - const handleOpen = () => setOpen(true); const handleOpenDeleteConfirmation = () => setOpenDelete(true); - const handleClose = () => setOpen(false); const handleCloseDeleteConfirmation = () => setOpenDelete(false); const teamId = team?.id; @@ -113,7 +110,7 @@ const TeamSummaryCard = ({ team, index }) => { const handleAction = (e, index) => { if (index === 0) { - handleOpen(); + onTeamSelect(team.id); } else if (index === 1) { handleOpenDeleteConfirmation(); } @@ -216,8 +213,8 @@ const TeamSummaryCard = ({ team, index }) => { onTeamSelect('')} onSave={async editedTeam => { const res = await updateTeam(editedTeam, csrf); const data = diff --git a/web-ui/src/components/team-results/TeamsActions.jsx b/web-ui/src/components/team-results/TeamsActions.jsx index 8b21be5650..8db7309d9f 100644 --- a/web-ui/src/components/team-results/TeamsActions.jsx +++ b/web-ui/src/components/team-results/TeamsActions.jsx @@ -12,24 +12,18 @@ import './TeamResults.css'; const displayName = 'TeamsActions'; -const TeamsActions = () => { +const TeamsActions = ({ isOpen, onOpen }) => { const { state, dispatch } = useContext(AppContext); - const [open, setOpen] = useState(false); - const { csrf } = state; - const handleOpen = () => setOpen(true); - - const handleClose = () => setOpen(false); - return (
- onOpen(false)} onSave={async team => { if (csrf) { let res = await createTeam(team, csrf); diff --git a/web-ui/src/helpers/query-parameters.js b/web-ui/src/helpers/query-parameters.js new file mode 100644 index 0000000000..920add31be --- /dev/null +++ b/web-ui/src/helpers/query-parameters.js @@ -0,0 +1,66 @@ +import { useEffect } from 'react'; + +/** + * @typedef {object} QPBoolean + * @property {string} name + * @property {boolean} default + * @property {boolean} value + * @property {(boolean) => void} setter - takes query parameter value and updates state + * @property {[(any) => string]} toQP - takes state value and returns query parameter value + */ + +/** + * @typedef {object} QPString + * @property {string} name + * @property {string} default + * @property {string} value + * @property {(string) => void} setter - takes query parameter value and updates state + * @property {[(any) => string]} toQP - takes state value and returns query parameter value + */ + +/** + * @param {(QPBoolean | QPString)[]} qps - query parameters + */ +export const useQueryParameters = qps => { + useEffect(() => { + const url = new URL(location.href); + const params = url.searchParams; + for (const qp of qps) { + let v = params.get(qp.name); + if (typeof qp.default === 'boolean') { + qp.setter(v ? v === 'true' : qp.default); + } else { + if (v && Array.isArray(qp.default)) v = v.split(','); + qp.setter(v || qp.default); + } + } + }, []); + + const dependencies = qps.map(qp => qp.value); + + useEffect(() => { + const url = new URL(location.href); + let newUrl = url.origin + url.pathname; + const params = {}; + + // Add query parameters listed in qps that do not have their default value. + for (const qp of qps) { + let { toQP, value } = qp; + if (toQP) value = toQP(value); + if (value && !compare(value, qp.default)) params[qp.name] = value; + } + + // Add query parameters that are not listed in qps. + for (const [k, v] of url.searchParams) { + if (!qps.some(qp => qp.name === k)) params[k] = v; + } + + if (Object.keys(params).length) { + newUrl += '?' + new URLSearchParams(params).toString(); + } + history.replaceState(params, '', newUrl); + }, dependencies); +}; + +const compare = (a, b) => stringValue(a) === stringValue(b); +const stringValue = v => (Array.isArray(v) ? v.sort().join(',') : v); diff --git a/web-ui/src/pages/PermissionsPage.jsx b/web-ui/src/pages/PermissionsPage.jsx index 0733ce45cc..5ae6ca6305 100644 --- a/web-ui/src/pages/PermissionsPage.jsx +++ b/web-ui/src/pages/PermissionsPage.jsx @@ -19,6 +19,7 @@ import { selectRoles, selectHasPermissionAssignmentPermission } from '../context/selectors'; +import { useQueryParameters } from '../helpers/query-parameters'; import './PermissionsPage.css'; @@ -72,21 +73,20 @@ const EditPermissionsPage = () => { const [rolePermissions, setRolePermissions] = useState([]); const [refresh, setRefresh] = useState(true); - useEffect(() => { - const url = new URL(location.href); - const roleName = url.searchParams.get('role'); - const role = roles.find(r => r.role === roleName); - setSelectedRole(role || roles[0]); - }, []); - - useEffect(() => { - if (!selectedRole) return; - const url = new URL(location.href); - const params = { role: selectedRole.role }; - const q = new URLSearchParams(params).toString(); - const newUrl = url.origin + url.pathname + '?' + q; - history.replaceState(params, '', newUrl); - }, [selectedRole]); + useQueryParameters([ + { + name: 'role', + default: roles[0], + value: selectedRole, + setter(value) { + const role = roles.find(r => r.role === value); + setSelectedRole(role); + }, + toQP(selectedRole) { + return selectedRole?.role ?? ''; + } + } + ]); useEffect(() => { const getRolePermissions = async () => { diff --git a/web-ui/src/pages/ViewFeedbackPage.jsx b/web-ui/src/pages/ViewFeedbackPage.jsx index 004a5872c0..0be9a0567f 100644 --- a/web-ui/src/pages/ViewFeedbackPage.jsx +++ b/web-ui/src/pages/ViewFeedbackPage.jsx @@ -33,6 +33,7 @@ import { } from '../context/selectors'; import { getFeedbackTemplate } from '../api/feedbacktemplate'; import SkeletonLoader from '../components/skeleton_loader/SkeletonLoader'; +import { useQueryParameters } from '../helpers/query-parameters'; const PREFIX = 'ViewFeedbackPage'; const classes = { @@ -107,6 +108,33 @@ const ViewFeedbackPage = () => { const [dateRange, setDateRange] = useState(DateRange.THREE_MONTHS); const [includeAll, setIncludeAll] = useState(false); + useQueryParameters([ + { + name: 'dates', + default: DateRange.THREE_MONTHS, + value: dateRange, + setter: setDateRange + }, + { + name: 'search', + default: '', + value: searchText, + setter: setSearchText + }, + { + name: 'showAll', + default: false, + value: includeAll, + setter: setIncludeAll + }, + { + name: 'sort', + default: SortOption.SENT_DATE, + value: sortValue, + setter: setSortValue + } + ]); + useEffect(() => { if (currentMembers && currentMembers.length > 0) { isAdmin && includeAll @@ -380,6 +408,7 @@ const ViewFeedbackPage = () => { ) }} + value={searchText} /> { size="small" label="Show requests sent within" onChange={e => setDateRange(e.target.value)} - defaultValue={DateRange.THREE_MONTHS} + value={dateRange} variant="outlined" > Past 3 months @@ -398,7 +427,6 @@ const ViewFeedbackPage = () => { All time - { size="small" label="Sort by" onChange={e => setSortValue(e.target.value)} - defaultValue={SortOption.SENT_DATE} + value={sortValue} variant="outlined" >