diff --git a/web-ui/src/components/member_selector/MemberSelector.css b/web-ui/src/components/member_selector/MemberSelector.css index 27080dd161..01e439e4cc 100644 --- a/web-ui/src/components/member_selector/MemberSelector.css +++ b/web-ui/src/components/member_selector/MemberSelector.css @@ -15,19 +15,19 @@ } .member-selector-card .member-selector-card-title { - font-size: 1.5em; + font-size: 1rem; } .member-selector-card .member-selector-card-count { - font-size: 1.25em; + font-size: 1rem; } @media screen and (max-width: 600px) { .member-selector-card .member-selector-card-title { - font-size: 1.25em; + font-size: 0.75rem; } .member-selector-card .member-selector-card-count { - font-size: 1em; + font-size: 0.75rem; } } diff --git a/web-ui/src/components/member_selector/MemberSelector.jsx b/web-ui/src/components/member_selector/MemberSelector.jsx index 0c8ad7e2fe..6dc3aba135 100644 --- a/web-ui/src/components/member_selector/MemberSelector.jsx +++ b/web-ui/src/components/member_selector/MemberSelector.jsx @@ -4,30 +4,20 @@ import { Avatar, Card, CardHeader, - Collapse, Divider, IconButton, List, - ListItem, - ListItemAvatar, ListItemIcon, ListItemText, - Menu, - MenuItem, Tooltip, Typography } from '@mui/material'; -import AddIcon from '@mui/icons-material/Add'; -import RemoveIcon from '@mui/icons-material/Remove'; -import HighlightOffIcon from '@mui/icons-material/HighlightOff'; -import MoreVertIcon from '@mui/icons-material/MoreVert'; +import { Add, FileDownload } from '@mui/icons-material'; import { getAvatarURL } from '../../api/api'; -import ExpandMore from '../expand-more/ExpandMore.jsx'; import MemberSelectorDialog, { FilterType } 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'; @@ -58,8 +48,6 @@ const propTypes = { 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 */ @@ -76,7 +64,6 @@ const MemberSelector = ({ expand = true, outlined = false, exportable = false, - listHeight = 400, disabled = false, className, style @@ -93,7 +80,7 @@ const MemberSelector = ({ filter => filter.type === FilterType.ROLE ); const roleFilter = filters.find(filter => filter.type === FilterType.ROLE); - const memberDescriptor = isFilteredByRole ? roleFilter.value : 'members'; + const memberDescriptor = isFilteredByRole ? roleFilter.value : 'Members'; const handleExpandClick = () => setExpanded(!expanded); @@ -104,8 +91,8 @@ const MemberSelector = ({ } }, [disabled]); - const addMembers = membersToAdd => { - onChange([...selected, ...membersToAdd]); + const replaceSelectedMembers = members => { + onChange(members); setDialogOpen(false); }; @@ -115,9 +102,7 @@ const MemberSelector = ({ }; const downloadMemberCsv = useCallback(() => { - if (!exportable) { - return; - } + if (!exportable) return; const memberIds = selected.map(member => member.id); reportSelectedMembersCsv(memberIds, csrf).then(res => { @@ -152,14 +137,6 @@ const MemberSelector = ({ style={style} > - } title={
- + setDialogOpen(true)} disabled={disabled} > - + - setMenuAnchor(event.currentTarget)} - > - - - setMenuAnchor(null)} - > - { - setMenuAnchor(null); - clearMembers(); - }} - disabled={disabled || !selected.length} - > - - - - Remove all - - {exportable && ( - { - setMenuAnchor(null); - downloadMemberCsv(); - }} - disabled={!selected.length} + {exportable && ( + + - - - - Download - - )} - + + + + )} } /> - - - - {selected.length ? ( - selected.map(member => ( - - removeMember(member)} - disabled={disabled} - > - - - - } - > - - - - {member.name} - } - secondary={ - - {member.title} - - } - /> - - )) - ) : ( - - - No {memberDescriptor} selected - - - )} - - setDialogOpen(false)} - onSubmit={membersToAdd => addMembers(membersToAdd)} + onSubmit={replaceSelectedMembers} /> ); diff --git a/web-ui/src/components/member_selector/MemberSelector.test.jsx b/web-ui/src/components/member_selector/MemberSelector.test.jsx index 1f5f6e3e44..a225930908 100644 --- a/web-ui/src/components/member_selector/MemberSelector.test.jsx +++ b/web-ui/src/components/member_selector/MemberSelector.test.jsx @@ -46,7 +46,6 @@ describe('MemberSelector', () => { 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 4a48a7fafd..1c7723a511 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 @@ -9,37 +9,11 @@ exports[`MemberSelector > renders correctly as a controlled component 1`] = `
-
- -
renders correctly as a controlled component 1`] = ` class="MuiCardHeader-action css-sgoict-MuiCardHeader-action" >
-
-
-
-
-
    -
  • -
    -
    - -
    -
    -
    -

    - TestName -

    -
    -
    -
    - -
    -
  • -
  • -
    -
    - -
    -
    -
    -

    - TestName2 -

    -
    -
    -
    - -
    -
  • -
-
-
-
`; @@ -251,37 +95,11 @@ exports[`MemberSelector > renders correctly when disabled 1`] = `
-
- -
renders correctly when disabled 1`] = ` class="MuiCardHeader-action css-sgoict-MuiCardHeader-action" > - -
-
-
-
-
-
-
    -
  • -
    -
    - -
    -
    -
    -

    - TestName -

    -
    -
    -
    - -
    -
  • -
  • -
    -
    - -
    -
    -
    -

    - TestName2 -

    -
    -
    -
    - -
    -
  • -
-
@@ -487,37 +157,11 @@ exports[`MemberSelector > renders correctly with default props 1`] = `
-
- -
renders correctly with default props 1`] = ` class="MuiCardHeader-action css-sgoict-MuiCardHeader-action" > - -
-
-
-
-
-
-
    -
  • -
    - - No - members - selected - -
    -
  • -
-
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 6c49459abc..03fcff5bc6 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 @@ -140,17 +140,23 @@ const MemberSelectorDialog = ({ onSubmit(membersToAdd); }, [checked, members, onSubmit]); + const initializeChecked = useCallback(() => { + const initialChecked = new Set(); + selectedMembers.forEach(member => initialChecked.add(member.id)); + setChecked(initialChecked); + }); + // Reset the dialog when it closes, or set the initial filter when it opens useEffect(() => { if (!open) { // Reset all state except for the chosen filter type and its corresponding options - setChecked(new Set()); setNameQuery(''); setFilter(null); setFilteredMembers([]); setDirectReportsOnly(false); setSelectableMembers([]); } else { + initializeChecked(); // If the dialog is opened with initial filters, set the initial filter if (initialFilter && initialFilter.type === FilterType.ROLE) { setFilterType(initialFilter.type); @@ -249,10 +255,7 @@ const MemberSelectorDialog = ({ // Filters the list of members based on the selected filter type and filter useEffect(() => { const getFilteredMembers = async () => { - // Exclude members that are already selected - let filteredMemberList = members.filter( - member => !selectedMembers.includes(member) - ); + let filteredMemberList = [...members]; // Exclude members that don't have the selected tenure. if (tenure === Tenures.Custom) { @@ -458,7 +461,7 @@ const MemberSelectorDialog = ({ disabled={checked.size === 0} onClick={handleSubmit} > - Add + Save diff --git a/web-ui/src/components/member_selector/member_selector_dialog/__snapshots__/MemberSelectorDialog.test.jsx.snap b/web-ui/src/components/member_selector/member_selector_dialog/__snapshots__/MemberSelectorDialog.test.jsx.snap index 560c0170df..6ba5f92f14 100644 --- a/web-ui/src/components/member_selector/member_selector_dialog/__snapshots__/MemberSelectorDialog.test.jsx.snap +++ b/web-ui/src/components/member_selector/member_selector_dialog/__snapshots__/MemberSelectorDialog.test.jsx.snap @@ -70,17 +70,19 @@ exports[`MemberSelectorDialog > renders correctly 1`] = `

- 0 + 3 selected

@@ -275,27 +277,27 @@ exports[`MemberSelectorDialog > renders correctly 1`] = ` +
renders correctly 1`] = `
    + > +
  • +
    +
    +
    + +
    +
    +
    +

    + Current User +

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

    + Bob Jones +

    +
    + Engineer +
    +
    +
    +
    + + + + +
    +
  • +
  • +
    +
    +
    + +
    +
    +
    +

    + Jane Doe +

    +
    + Manager +
    +
    +
    +
    + + + + +
    +
  • +

diff --git a/web-ui/src/components/reviews/TeamMemberReview-old.jsx b/web-ui/src/components/reviews/TeamMemberReview-old.jsx new file mode 100644 index 0000000000..2ecabcc2e1 --- /dev/null +++ b/web-ui/src/components/reviews/TeamMemberReview-old.jsx @@ -0,0 +1,367 @@ +import React, { useContext, useState, useCallback } from 'react'; +import { useTheme } from '@mui/material/styles'; +import { styled } from '@mui/material/styles'; +import SwipeableViews from 'react-swipeable-views'; +import PropTypes from 'prop-types'; +import { AppContext } from '../../context/AppContext'; +import { selectCurrentUser, selectProfile } from '../../context/selectors'; +import AppBar from '@mui/material/AppBar'; +import Button from '@mui/material/Button'; +import Tabs from '@mui/material/Tabs'; +import Tab from '@mui/material/Tab'; +import Typography from '@mui/material/Typography'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'; +import Box from '@mui/material/Box'; +import { + Card, + CardActions, + CardContent, + CardHeader, + Modal +} from '@mui/material'; +import FeedbackSubmitForm from '../feedback_submit_form/FeedbackSubmitForm'; +import SelectUserModal from './SelectUserModal'; +import { + cancelFeedbackRequest, + updateFeedbackRequest +} from '../../api/feedback'; +import { selectCsrfToken } from '../../context/selectors'; +import { UPDATE_TOAST } from '../../context/actions'; + +const propTypes = { + selfReview: PropTypes.any, + reviews: PropTypes.arrayOf(PropTypes.any), + memberProfile: PropTypes.any, + reloadReviews: PropTypes.func +}; +const displayName = 'TeamMemberReview'; + +const PREFIX = displayName; +const classes = { + actionButtons: `${PREFIX}-actionButtons`, + buttonRow: `${PREFIX}-buttonRow`, + periodModal: `${PREFIX}-periodModal` +}; + +const Root = styled('div')(({ theme }) => ({ + [`& .${classes.actionButtons}`]: { + margin: '0.5em 0 0 1em', + ['@media (max-width:820px)']: { + // eslint-disable-line no-useless-computed-key + padding: '0' + } + }, + [`& .${classes.buttonRow}`]: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-around', + alignItems: 'center', + margin: '0 0 1em 0' + } +})); + +const TabPanel = ({ children, value, index, ...other }) => { + return ( + + ); +}; + +TabPanel.propTypes = { + children: PropTypes.any, + dir: PropTypes.string, + index: PropTypes.number, + value: PropTypes.number +}; +TabPanel.displayName = 'TabPanel'; + +const a11yProps = index => ({ + id: `full-width-tab-${index}`, + 'aria-controls': `full-width-tabpanel-${index}` +}); + +const TeamMemberReview = ({ + selfReview, + reviews, + memberProfile, + reloadReviews +}) => { + const { state } = useContext(AppContext); + const csrf = selectCsrfToken(state); + const currentUser = selectCurrentUser(state); + const theme = useTheme(); + const [value, setValue] = useState(0); + const [reassignOpen, setReassignOpen] = useState(false); + const [cancelOpen, setCancelOpen] = useState(false); + + const handleOpenReassign = useCallback( + () => setReassignOpen(true), + [setReassignOpen] + ); + const handleCloseReassign = useCallback( + () => setReassignOpen(false), + [setReassignOpen] + ); + const handleOpenCancel = useCallback( + () => setCancelOpen(true), + [setCancelOpen] + ); + const handleCloseCancel = useCallback( + () => setCancelOpen(false), + [setCancelOpen] + ); + + const review = reviews && reviews[value - 1]; + const recipient = selectProfile(state, review?.recipientId); + + const handleChange = (event, newValue) => { + setValue(newValue); + }; + + const handleChangeIndex = index => { + setValue(index); + }; + + const handleCancelClick = useCallback(() => { + const cancelRequest = async () => { + const res = await cancelFeedbackRequest(review, csrf); + const cancellationResponse = + res && res.payload && res.payload.status === 200 && !res.error + ? res.payload.data + : null; + if (!cancellationResponse) { + window.snackDispatch({ + type: UPDATE_TOAST, + payload: { + severity: 'error', + toast: + 'There was an error cancelling the review. Please contact your administrator.' + } + }); + } + return cancellationResponse; + }; + + handleCloseCancel(); + if (csrf) { + cancelRequest().then(res => { + if (res) { + reloadReviews(); + window.snackDispatch({ + type: UPDATE_TOAST, + payload: { + severity: 'success', + toast: 'Review canceled' + } + }); + } + }); + } + }, [csrf, handleCloseCancel, review, reloadReviews]); + + const handleReassign = useCallback( + assignee => { + const reassignRequest = async () => { + review.recipientId = assignee.id; + const res = await updateFeedbackRequest(review, csrf); + const updateResponse = + res && res.payload && res.payload.status === 200 && !res.error + ? res.payload.data + : null; + if (!updateResponse) { + window.snackDispatch({ + type: UPDATE_TOAST, + payload: { + severity: 'error', + toast: + 'There was an error reassigning the review. Please contact your administrator.' + } + }); + } + return updateResponse; + }; + + handleCloseReassign(); + if (csrf) { + reassignRequest().then(res => { + if (res) { + reloadReviews(); + window.snackDispatch({ + type: UPDATE_TOAST, + payload: { + severity: 'success', + toast: 'Review reassigned' + } + }); + } + }); + } + }, + [csrf, handleCloseReassign, review, reloadReviews] + ); + + let selfReviewIcon = ; + if (selfReview?.status.toUpperCase() === 'SUBMITTED') { + selfReviewIcon = ; + } + + return ( + + + + + + {reviews && + reviews.map((review, index) => { + const reviewer = selectProfile(state, review?.recipientId); + let label = reviewer?.firstName + "'s Review"; + + if (reviewer?.id === currentUser?.id) { + label = 'Your Review'; + } + + let icon = ; + if (review?.status.toUpperCase() === 'SUBMITTED') { + icon = ; + } + + return ( + + ); + })} + + + + + {selfReview && selfReview.id ? ( + + ) : ( + + {memberProfile?.firstName} has not started their self-review. + + )} + + {reviews && + reviews.map((review, index) => { + const reviewer = selectProfile(state, review?.recipientId); + const requesteeName = memberProfile?.name; + + let readOnly = true; + if ( + reviewer?.id === currentUser?.id && + 'SUBMITTED' !== review?.status?.toUpperCase() + ) { + readOnly = false; + } + + return ( + + {review?.status.toUpperCase() !== 'SUBMITTED' && ( +
+ + +
+ )} + +
+ ); + })} +
+ + + + + Cancel Review + + } + /> + + + Are you sure you want to cancel the review sent to{' '} + {recipient?.name} on {review?.sendDate}? The + recipient will not be able to respond to this request once it is + canceled. + + + + + + + + +
+
+ ); +}; + +TeamMemberReview.displayName = displayName; +TeamMemberReview.propTypes = propTypes; + +export default TeamMemberReview; diff --git a/web-ui/src/components/reviews/TeamReviews.jsx b/web-ui/src/components/reviews/TeamReviews.jsx index 2264e92b3c..6f1689bc27 100644 --- a/web-ui/src/components/reviews/TeamReviews.jsx +++ b/web-ui/src/components/reviews/TeamReviews.jsx @@ -1,3 +1,8 @@ +import DateFnsUtils from '@date-io/date-fns'; +const dateUtils = new DateFnsUtils(); + +import PropTypes from 'prop-types'; +import queryString from 'query-string'; import React, { useEffect, useContext, @@ -5,40 +10,63 @@ import React, { useState, useRef } from 'react'; -import PropTypes from 'prop-types'; import { useLocation, useHistory } from 'react-router-dom'; + +import { + AddCircle, + AddComment, + Archive, + ArrowBack, + Delete, + Download, + ExpandMore, + Unarchive +} from '@mui/icons-material'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Avatar, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Divider, + FormControlLabel, + IconButton, + List, + ListItem, + ListItemAvatar, + ListItemSecondaryAction, + ListItemText, + Skeleton, + Switch, + Tooltip, + Typography +} from '@mui/material'; import { styled } from '@mui/material/styles'; -import AddCircleIcon from '@mui/icons-material/AddCircle'; -import AddCommentIcon from '@mui/icons-material/AddComment'; -import ListItemSecondaryAction from '@mui/material/ListItemSecondaryAction'; -import Avatar from '@mui/material/Avatar'; -import Button from '@mui/material/Button'; -import IconButton from '@mui/material/IconButton'; -import Divider from '@mui/material/Divider'; -import queryString from 'query-string'; -import Accordion from '@mui/material/Accordion'; -import AccordionSummary from '@mui/material/AccordionSummary'; -import AccordionDetails from '@mui/material/AccordionDetails'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import Switch from '@mui/material/Switch'; -import Typography from '@mui/material/Typography'; -import List from '@mui/material/List'; -import ListItem from '@mui/material/ListItem'; -import ListItemText from '@mui/material/ListItemText'; -import ListItemAvatar from '@mui/material/ListItemAvatar'; -import Tooltip from '@mui/material/Tooltip'; -import Skeleton from '@mui/material/Skeleton'; -import TeamMemberReview from './TeamMemberReview'; -import SelectUserModal from './SelectUserModal'; -import { UPDATE_REVIEW_PERIODS, UPDATE_TOAST } from '../../context/actions'; -import { AppContext } from '../../context/AppContext'; -import { getReviewPeriods } from '../../api/reviewperiods.js'; + +import { resolve } from '../../api/api.js'; +import { getAvatarURL } from '../../api/api.js'; import { createFeedbackRequest, findReviewRequestsByPeriodAndTeamMembers, findSelfReviewRequestsByPeriodAndTeamMembers } from '../../api/feedback.js'; +import { + getReviewPeriods, + removeReviewPeriod, + updateReviewPeriod +} from '../../api/reviewperiods.js'; +import { + DELETE_REVIEW_PERIOD, + UPDATE_REVIEW_PERIOD, + UPDATE_REVIEW_PERIODS, + UPDATE_TOAST +} from '../../context/actions'; +import { AppContext } from '../../context/AppContext'; import { selectCsrfToken, selectReviewPeriod, @@ -49,16 +77,22 @@ import { selectCurrentMembers, selectSubordinates } from '../../context/selectors'; -import { getAvatarURL } from '../../api/api.js'; -import DateFnsUtils from '@date-io/date-fns'; -const dateUtils = new DateFnsUtils(); + +import MemberSelector from '../member_selector/MemberSelector'; +import MemberSelectorDialog, { + FilterType +} from '../member_selector/member_selector_dialog/MemberSelectorDialog'; +import SelectUserModal from './SelectUserModal'; +import TeamMemberReview from './TeamMemberReview'; + +import DatePickerField from './periods/DatePickerField.jsx'; +import './periods/DatePickerField.css'; const propTypes = { teamMembers: PropTypes.arrayOf( PropTypes.shape({ id: PropTypes.string, - firstName: PropTypes.string, - lastName: PropTypes.string + onBack: PropTypes.func }) ), periodId: PropTypes.string @@ -89,33 +123,48 @@ const Root = styled('div')(({ theme }) => ({ ['@media (max-width:800px)']: { // eslint-disable-line no-useless-computed-key margin: '0', - 'justify-content': 'center' + justifyContent: 'center' } } })); -const TeamReviews = ({ periodId }) => { +const ReviewStatus = { + PLANNING: 'PLANNING', + AWAITING_APPROVAL: 'AWAITING_APPROVAL', + OPEN: 'OPEN', + CLOSED: 'CLOSED', + UNKNOWN: 'UNKNOWN' +}; + +const TeamReviews = ({ onBack, periodId }) => { const { state, dispatch } = useContext(AppContext); - const csrf = selectCsrfToken(state); - const location = useLocation(); const history = useHistory(); - const currentUser = selectCurrentUser(state); + const location = useLocation(); + + const [confirmOpen, setConfirmOpen] = useState(false); + const [includeAll, setIncludeAll] = useState(false); + const [memberFilters, setMemberFilters] = useState([]); + const [memberSelectorOpen, setMemberSelectorOpen] = useState(false); + const [newRequestOpen, setNewRequestOpen] = useState(false); + const [query, setQuery] = useState({}); + const [reviews, setReviews] = useState(null); + const [selectedMember, setSelectedMember] = useState(null); + const [selfReviews, setSelfReviews] = useState({}); + const [teamMembers, setTeamMembers] = useState([]); + const [toDelete, setToDelete] = useState(null); + + const creatingReview = useRef(false); + const loadedReviews = useRef(false); + const loadingReviews = useRef(false); + + const csrf = selectCsrfToken(state); const currentMembers = selectCurrentMembers(state); - const myTeam = selectMyTeam(state); - const subordinates = selectSubordinates(state, currentUser?.id); + const currentUser = selectCurrentUser(state); const isAdmin = selectIsAdmin(state); + const myTeam = selectMyTeam(state); const period = selectReviewPeriod(state, periodId); - const [teamMembers, setTeamMembers] = useState(null); - const [selfReviews, setSelfReviews] = useState({}); - const [reviews, setReviews] = useState(null); - const [query, setQuery] = useState({}); - const [selectedTeamMember, setSelectedTeamMember] = useState(null); - const selectedMemberProfile = selectProfile(state, selectedTeamMember); - const [newRequestOpen, setNewRequestOpen] = useState(false); - const [includeAll, setIncludeAll] = useState(false); - const loadingReviews = useRef(false); - const loadedReviews = useRef(false); - const creatingReview = useRef(false); + const selectedMemberProfile = selectProfile(state, selectedMember); + const subordinates = selectSubordinates(state, currentUser?.id); const handleOpenNewRequest = useCallback( () => setNewRequestOpen(true), @@ -126,24 +175,58 @@ const TeamReviews = ({ periodId }) => { [setNewRequestOpen] ); + const reviewAssignmentsUrl = '/services/review-assignments'; + useEffect(() => { - if (currentMembers && currentMembers.length > 0) { - isAdmin && includeAll - ? setTeamMembers( - currentMembers.filter(member => member?.id !== currentUser?.id) - ) - : includeAll - ? setTeamMembers(subordinates) - : setTeamMembers(myTeam); + loadTeamMembers(); + }, [currentMembers]); + + const loadTeamMembers = async () => { + const myId = currentUser?.id; + try { + const res = await resolve({ + method: 'GET', + url: `${reviewAssignmentsUrl}/period/${periodId}`, + headers: { + 'X-CSRF-Header': csrf, + Accept: 'application/json', + 'Content-Type': 'application/json;charset=UTF-8' + } + }); + if (res.error) throw new Error(res.error.message); + const assignments = res.payload.data; + const memberIds = assignments.map(a => a.revieweeId); + const members = currentMembers.filter(m => memberIds.includes(m.id)); + setTeamMembers(members); + } catch (err) { + console.error('TeamReviews.jsx loadTeamMembers:', err); } - }, [ - isAdmin, - includeAll, - subordinates, - currentMembers, - myTeam, - currentUser?.id - ]); + }; + + const updateTeamMembers = async teamMembers => { + const data = teamMembers.map(tm => ({ + revieweeId: tm.id, + reviewerId: tm.supervisorid, + reviewPeriodId: periodId, + approved: true + })); + + try { + const res = await resolve({ + method: 'POST', + url: reviewAssignmentsUrl + '/' + periodId, + data, + headers: { + 'X-CSRF-Header': csrf, + Accept: 'application/json', + 'Content-Type': 'application/json;charset=UTF-8' + } + }); + setTeamMembers(teamMembers); + } catch (err) { + console.error('TeamReviews.jsx updateTeamMembers:', err); + } + }; const getReviewStatus = useCallback( teamMemberId => { @@ -219,7 +302,7 @@ const TeamReviews = ({ periodId }) => { }, [query.teamMember, hasTeamMember]); useEffect(() => { - setSelectedTeamMember(getTeamMember()); + setSelectedMember(getTeamMember()); }, [getTeamMember]); useEffect(() => { @@ -288,6 +371,115 @@ const TeamReviews = ({ periodId }) => { } }, [csrf, reviews, currentUser, period, selectedMemberProfile]); + const confirmDelete = useCallback(() => { + setToDelete(period.id); + setConfirmOpen(true); + }, [period, setToDelete, setConfirmOpen]); + + const handleConfirmClose = useCallback(() => { + setToDelete(null); + setConfirmOpen(false); + }, [setToDelete, setConfirmOpen]); + + const deleteReviewPeriod = useCallback(async () => { + if (!csrf) return; + + await removeReviewPeriod(toDelete, csrf); + dispatch({ + type: DELETE_REVIEW_PERIOD, + payload: toDelete + }); + handleConfirmClose(); + history.goBack(); + }, [csrf, dispatch, toDelete, handleConfirmClose]); + + const toggleReviewPeriod = useCallback(async () => { + if (!csrf) return; + + period.reviewStatus = + period?.reviewStatus === ReviewStatus.CLOSED + ? ReviewStatus.OPEN + : ReviewStatus.CLOSED; + const res = await updateReviewPeriod(period, csrf); + const data = res?.payload?.data ? res.payload.data : null; + if (data) { + dispatch({ type: UPDATE_REVIEW_PERIOD, payload: period }); + } else { + console.error(res?.error); + window.snackDispatch({ + type: UPDATE_TOAST, + payload: { + severity: 'error', + toast: 'Error selecting review period' + } + }); + } + }, [csrf, period, state, dispatch]); + + const updateReviewPeriodDates = useCallback( + async period => { + if (!csrf) return; + + const res = await updateReviewPeriod(period, csrf); + const data = res?.payload?.data ?? null; + if (data) { + dispatch({ type: UPDATE_REVIEW_PERIODS, payload: [period] }); + } else { + console.error('Error updating review period:', res?.error); + window.snackDispatch({ + type: UPDATE_TOAST, + payload: { + severity: 'error', + toast: 'Error updating review period' + } + }); + } + }, + [csrf, dispatch, period, state] + ); + + const handleLaunchDateChange = (val, period) => { + const newDate = val?.$d; + const isoDate = newDate.toISOString() ?? null; + const newPeriod = { ...period, launchDate: isoDate }; + + // Clear dates that are not correctly ordered. + const selfReviewCloseDate = new Date(period.selfReviewCloseDate); + const closeDate = new Date(period.closeDate); + if (selfReviewCloseDate <= newDate) newPeriod.selfReviewCloseDate = null; + if (closeDate <= newDate) newPeriod.closeDate = null; + + updateReviewPeriodDates(newPeriod); + }; + + const handleSelfReviewDateChange = (val, period) => { + const newDate = val?.$d; + const isoDate = newDate.toISOString() ?? null; + const newPeriod = { ...period, selfReviewCloseDate: isoDate }; + + // Clear dates that are not correctly ordered. + const launchDate = new Date(period.launchDate); + const closeDate = new Date(period.closeDate); + if (launchDate >= newDate) newPeriod.launchDate = null; + if (closeDate <= newDate) newPeriod.closeDate = null; + + updateReviewPeriodDates(newPeriod); + }; + + const handleCloseDateChange = (val, period) => { + const newDate = val?.$d; + const isoDate = newDate.toISOString() ?? null; + const newPeriod = { ...period, closeDate: isoDate }; + + // Clear dates that are not correctly ordered. + const launchDate = new Date(period.launchDate); + const selfReviewCloseDate = new Date(period.selfReviewCloseDate); + if (launchDate >= newDate) newPeriod.launchDate = null; + if (selfReviewCloseDate >= newDate) newPeriod.selfReviewCloseDate = null; + + updateReviewPeriodDates(newPeriod); + }; + const handleQueryChange = useCallback( (key, value) => { let newQuery = { @@ -478,11 +670,11 @@ const TeamReviews = ({ periodId }) => { }, [ csrf, - period, - selectedMemberProfile, dispatch, handleCloseNewRequest, - reviews + period, + reviews, + selectedMemberProfile ] ); @@ -493,9 +685,51 @@ const TeamReviews = ({ periodId }) => { return ( + + + +
- Team Reviews - {!selectedTeamMember && ( + {period?.name ?? ''} Team Reviews + + {period && isAdmin && ( +
+ + + {period.reviewStatus === ReviewStatus.OPEN ? ( + + ) : ( + + )} + + + + + + + +
+ )} + {!selectedMember && ( @@ -503,11 +737,11 @@ const TeamReviews = ({ periodId }) => { label="Show All" /> )} - {selectedTeamMember && ( + {selectedMember && ( )}
- {!selectedTeamMember && loadedReviews.current && ( - <> - - {teamMembers && teamMembers.length > 0 - ? teamMembers - .sort((a, b) => { - return ('' + a?.lastName) - .toUpperCase() - .localeCompare(b?.lastName.toUpperCase()); - }) - .filter(teamMember => { - return ( - reviews && - (!reviews[teamMember.id] || - reviews[teamMember.id].length === 0 || - !reviews[teamMember.id]?.reduce( - (status, review) => - status && review.status === 'submitted', - true - )) - ); - }) - .map((teamMember, i) => ( - <> - onTeamMemberSelected(teamMember?.id)} - key={`teamMember-${teamMember?.id}`} - > - - - - - - - - { - e.stopPropagation(); - history.push( - `/feedback/request?for=${teamMember?.id}` - ); - }} - /> - - - - - - - )) - : null} - - - } - aria-controls="panel1a-content" - id="panel1a-header" - > - Completed Reviews - - - - {teamMembers && teamMembers.length > 0 - ? teamMembers - .sort((a, b) => { - return ('' + a?.lastName) - .toUpperCase() - .localeCompare(b?.lastName.toUpperCase()); - }) - .filter(teamMember => { - return ( - reviews && - reviews[teamMember.id] && - reviews[teamMember.id].length !== 0 && - reviews[teamMember.id]?.reduce( - (status, review) => - status && review.status === 'submitted', - true - ) - ); - }) - .map((teamMember, i) => ( - <> - onTeamMemberSelected(teamMember?.id)} - key={`teamMember-${teamMember?.id}`} - > - - - - - - - - { - e.stopPropagation(); - history.push( - `/feedback/request?for=${teamMember?.id}` - ); - }} - /> - - - - - - - )) - : null} - - - - + {period && ( +
+ handleLaunchDateChange(val, period)} + label="Launch Date" + disabled={!isAdmin} + open={period?.reviewStatus === ReviewStatus?.PLANNING} + /> + handleSelfReviewDateChange(val, period)} + label="Self-Review Date" + disabled={!isAdmin} + /> + handleCloseDateChange(val, period)} + label="Close Date" + disabled={!isAdmin} + /> +
)} - {!selectedTeamMember && loadingReviews.current && ( + + {!selectedMember && loadingReviews.current && ( <> @@ -668,12 +796,12 @@ const TeamReviews = ({ periodId }) => { )} - {!!selectedTeamMember && reviews && ( + {!!selectedMember && reviews && ( )} { onSelect={handleNewRequest} onClose={handleCloseNewRequest} /> + setDialogOpen(false)} + onSubmit={membersToAdd => setTeamMembers(membersToAdd)} + /> + + + {'Delete this review period?'} + + + + Are you sure that you would like to delete period{' '} + {selectReviewPeriod(state, toDelete)?.name}? + + + + + + +
); }; diff --git a/web-ui/src/components/reviews/periods/DatePickerField.jsx b/web-ui/src/components/reviews/periods/DatePickerField.jsx index 3e29f847cf..a430f2c46a 100644 --- a/web-ui/src/components/reviews/periods/DatePickerField.jsx +++ b/web-ui/src/components/reviews/periods/DatePickerField.jsx @@ -1,7 +1,8 @@ +import dayjs from 'dayjs'; import React, { useRef, useEffect, useState } from 'react'; -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import './DatePickerField.css'; export default function DatePickerField({ @@ -36,7 +37,7 @@ export default function DatePickerField({ { const { state, dispatch } = useContext(AppContext); const [canSave, setCanSave] = useState(false); - const [confirmOpen, setConfirmOpen] = useState(false); const [loading, setLoading] = useState(false); const [reviewStatus, setReviewStatus] = useState(ReviewStatus.CLOSED); @@ -154,7 +141,6 @@ const ReviewPeriods = ({ onPeriodSelected, mode }) => { }); const [selfReviews, setSelfReviews] = useState(null); const [templates, setTemplates] = useState([]); - const [toDelete, setToDelete] = useState(null); const currentUserId = selectCurrentUserId(state); const csrf = selectCsrfToken(state); @@ -234,34 +220,6 @@ const ReviewPeriods = ({ onPeriodSelected, mode }) => { ] ); - const toggleReviewPeriod = useCallback( - async id => { - if (!csrf) { - return; - } - const toUpdate = selectReviewPeriod(state, id); - toUpdate.reviewStatus = - toUpdate?.reviewStatus === ReviewStatus.CLOSED - ? ReviewStatus.OPEN - : ReviewStatus.CLOSED; - const res = await updateReviewPeriod(toUpdate, csrf); - const data = res?.payload?.data ? res.payload.data : null; - if (data) { - dispatch({ type: UPDATE_REVIEW_PERIODS, payload: [...periods] }); - } else { - console.error(res?.error); - window.snackDispatch({ - type: UPDATE_TOAST, - payload: { - severity: 'error', - toast: 'Error selecting review period' - } - }); - } - }, - [csrf, state, periods, dispatch] - ); - const getSecondaryLabel = useCallback( periodId => { if (mode === 'self') { @@ -290,48 +248,6 @@ const ReviewPeriods = ({ onPeriodSelected, mode }) => { [selfReviews, state, mode] ); - const handleConfirmClose = useCallback(() => { - setToDelete(null); - setConfirmOpen(false); - }, [setToDelete, setConfirmOpen]); - - const deleteReviewPeriod = useCallback(async () => { - if (!csrf) { - return; - } - - await removeReviewPeriod(toDelete, csrf); - dispatch({ - type: UPDATE_REVIEW_PERIODS, - payload: periods.filter(period => period?.id !== toDelete) - }); - handleConfirmClose(); - }, [csrf, periods, dispatch, toDelete, handleConfirmClose]); - - const updateReviewPeriodDates = useCallback( - async period => { - if (!csrf) { - return; - } - const res = await updateReviewPeriod(period, csrf); - const data = res?.payload?.data ?? null; - if (data) { - dispatch({ type: UPDATE_REVIEW_PERIODS, payload: [...periods] }); - } else { - console.error(res?.error); - window.snackDispatch({ - type: UPDATE_TOAST, - payload: { - severity: 'error', - toast: 'Error updating review period' - } - }); - } - setPeriodToAdd(period); - }, - [csrf, state, periods, dispatch] - ); - const loadFeedbackTemplates = useCallback(async () => { const res = await getAllFeedbackTemplates(csrf); const templates = res?.payload?.data; @@ -438,14 +354,6 @@ const ReviewPeriods = ({ onPeriodSelected, mode }) => { [state, onPeriodSelected] ); - const confirmDelete = useCallback( - id => { - setToDelete(id); - setConfirmOpen(true); - }, - [setToDelete, setConfirmOpen] - ); - const handleReviewTemplateChange = event => { const templateId = event.target.value; setPeriodToAdd({ @@ -462,30 +370,6 @@ const ReviewPeriods = ({ onPeriodSelected, mode }) => { }); }; - const handleLaunchDateChange = (val, period) => { - const isoDate = val?.$d.toISOString() ?? null; - updateReviewPeriodDates({ - ...period, - launchDate: isoDate - }); - }; - - const handleSelfReviewDateChange = (val, period) => { - const isoDate = val?.$d.toISOString() ?? null; - updateReviewPeriodDates({ - ...period, - selfReviewCloseDate: isoDate - }); - }; - - const handleCloseDateChange = (val, period) => { - const isoDate = val?.$d.toISOString() ?? null; - updateReviewPeriodDates({ - ...period, - closeDate: isoDate - }); - }; - return (
@@ -543,46 +427,7 @@ const ReviewPeriods = ({ onPeriodSelected, mode }) => { i ) => (
- - - toggleReviewPeriod(id)} - aria-label={ - reviewStatus === ReviewStatus.OPEN - ? 'Archive' - : 'Unarchive' - } - > - {reviewStatus === ReviewStatus.OPEN ? ( - - ) : ( - - )} - - - - confirmDelete(id)} - edge="end" - aria-label="Delete" - > - - - - - ) - } - key={`period-${id}`} - > + onPeriodClick(id)} @@ -595,54 +440,6 @@ const ReviewPeriods = ({ onPeriodSelected, mode }) => { primary={`${name} - ${titleCase(reviewStatus)}`} secondary={getSecondaryLabel(id)} /> -
- - handleLaunchDateChange(val, { - id, - name, - reviewStatus, - launchDate, - selfReviewCloseDate, - closeDate - }) - } - label="Launch Date" - disabled={!isAdmin} - open={reviewStatus === ReviewStatus.PLANNING} - /> - - handleSelfReviewDateChange(val, { - id, - name, - reviewStatus, - launchDate, - selfReviewCloseDate, - closeDate - }) - } - label="Self-Review Date" - disabled={!isAdmin} - /> - - handleCloseDateChange(val, { - id, - name, - reviewStatus, - launchDate, - selfReviewCloseDate, - closeDate - }) - } - label="Close Date" - disabled={!isAdmin} - /> -
) @@ -732,28 +529,6 @@ const ReviewPeriods = ({ onPeriodSelected, mode }) => {
- - - {'Delete this review period?'} - - - - Are you sure that you would like to delete period{' '} - {selectReviewPeriod(state, toDelete)?.name}? - - - - - - -
); }; diff --git a/web-ui/src/context/actions.js b/web-ui/src/context/actions.js index a4b8d5f24b..80d90b160b 100644 --- a/web-ui/src/context/actions.js +++ b/web-ui/src/context/actions.js @@ -5,6 +5,7 @@ export const ADD_SKILL = '@@check-ins/add-skill'; export const ADD_TEAM = '@@check-ins/add_team'; export const DELETE_MEMBER_PROFILE = '@@check-ins/delete_member_profile'; export const DELETE_MEMBER_SKILL = '@@check-ins/delete_member_skill'; +export const DELETE_REVIEW_PERIOD = '@@check-ins/delete_review_period'; export const DELETE_SKILL = '@@check-ins/delete-skill'; export const DELETE_ROLE = '@@check-ins/delete-role'; export const MY_PROFILE_UPDATE = '@@check-ins/update_profile'; @@ -31,5 +32,6 @@ export const UPDATE_FEEEDBACK_SUGGESTIONS = '@@check-ins/update_feedback_suggestions'; export const UPDATE_PEOPLE_LOADING = '@@check-ins/update_people_loading'; export const UPDATE_TEAMS_LOADING = '@@check-ins/update_teams_loading'; +export const UPDATE_REVIEW_PERIOD = '@@check-ins/update_review_period'; export const UPDATE_REVIEW_PERIODS = '@@check-ins/update_review_periods'; export const ADD_REVIEW_PERIOD = '@@check-ins/add_review_period'; diff --git a/web-ui/src/context/reducer.js b/web-ui/src/context/reducer.js index ab9d5204e6..081d0f144b 100644 --- a/web-ui/src/context/reducer.js +++ b/web-ui/src/context/reducer.js @@ -7,6 +7,7 @@ import { DELETE_MEMBER_PROFILE, DELETE_MEMBER_SKILL, DELETE_ROLE, + DELETE_REVIEW_PERIOD, DELETE_SKILL, MY_PROFILE_UPDATE, SET_CSRF, @@ -28,6 +29,7 @@ import { UPDATE_USER_BIO, UPDATE_PEOPLE_LOADING, UPDATE_TEAMS_LOADING, + UPDATE_REVIEW_PERIOD, UPDATE_REVIEW_PERIODS, ADD_REVIEW_PERIOD } from './actions'; @@ -225,12 +227,23 @@ export const reducer = (state, action) => { state.guilds.sort((a, b) => a.name.localeCompare(b.name)); state.guilds = [...state.guilds]; break; - case UPDATE_REVIEW_PERIODS: - state.reviewPeriods = action.payload; - break; case ADD_REVIEW_PERIOD: state.reviewPeriods = [...state.reviewPeriods, action.payload]; break; + case DELETE_REVIEW_PERIOD: + const periodId = action.payload; + state.reviewPeriods = state.reviewPeriods.filter(p => p.id !== periodId); + break; + case UPDATE_REVIEW_PERIOD: + const period = action.payload; + const periodIndex = state.reviewPeriods.findIndex( + p => p.id === period.id + ); + if (periodIndex !== -1) state.reviewPeriods[periodIndex] = period; + break; + case UPDATE_REVIEW_PERIODS: + state.reviewPeriods = action.payload; + break; default: } return { ...state }; diff --git a/web-ui/src/pages/CheckinsReportPage.jsx b/web-ui/src/pages/CheckinsReportPage.jsx index 750e3d96af..5e2dcfed9b 100644 --- a/web-ui/src/pages/CheckinsReportPage.jsx +++ b/web-ui/src/pages/CheckinsReportPage.jsx @@ -136,7 +136,6 @@ const CheckinsReportPage = () => { title="Select PDLs" selected={selectedPdls} onChange={setSelectedPdls} - listHeight={180} exportable expand={false} /> diff --git a/web-ui/src/pages/ReviewsPage.jsx b/web-ui/src/pages/ReviewsPage.jsx index 0f9029db0d..113c2e3fd9 100644 --- a/web-ui/src/pages/ReviewsPage.jsx +++ b/web-ui/src/pages/ReviewsPage.jsx @@ -91,7 +91,10 @@ const ReviewPage = () => { {selectedPeriod === null ? ( ) : ( - + onPeriodSelected(null)} + periodId={selectedPeriod} + /> )}
diff --git a/web-ui/src/pages/TeamSkillReportPage.jsx b/web-ui/src/pages/TeamSkillReportPage.jsx index 01ce7936ba..611a72a60c 100644 --- a/web-ui/src/pages/TeamSkillReportPage.jsx +++ b/web-ui/src/pages/TeamSkillReportPage.jsx @@ -162,7 +162,6 @@ const TeamSkillReportPage = () => {
diff --git a/web-ui/src/pages/__snapshots__/TeamSkillReportPage.test.jsx.snap b/web-ui/src/pages/__snapshots__/TeamSkillReportPage.test.jsx.snap index 7bddea5700..e3b3931d04 100644 --- a/web-ui/src/pages/__snapshots__/TeamSkillReportPage.test.jsx.snap +++ b/web-ui/src/pages/__snapshots__/TeamSkillReportPage.test.jsx.snap @@ -11,37 +11,11 @@ exports[`renders correctly 1`] = `
-
- -
- -
-
-
-
-
-
-
    -
  • -
    - - No - members - selected - -
    -
  • -
-