Skip to content

Commit 9bb18c2

Browse files
committed
2024-10-30 - feedback - external reviewer - server-side
1 parent ad7b9e4 commit 9bb18c2

File tree

5 files changed

+522
-81
lines changed

5 files changed

+522
-81
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
.feedback-recipient-selector {
2+
width: 100%;
3+
height: 100%;
4+
padding: 1rem 2rem 1rem 2rem;
5+
}
6+
7+
.selected-recipients-container {
8+
width: 100%;
9+
height: 50%;
10+
min-height: 380px;
11+
margin-top: 2em;
12+
padding: 20px 0 20px 26px;
13+
border-radius: 20px;
14+
border: 1px dashed gray;
15+
}
16+
17+
.selectable-recipients-container {
18+
width: 100%;
19+
height: 50%;
20+
min-height: 300px;
21+
margin-top: 2em;
22+
}
23+
24+
.selected-recipients-container .recipient-card-container {
25+
display: flex;
26+
flex-direction: row;
27+
flex-wrap: wrap;
28+
}
29+
30+
.selectable-recipients-container .recipient-card-container {
31+
display: flex;
32+
flex-direction: row;
33+
flex-wrap: wrap;
34+
}
35+
36+
@media (min-width: 321px) and (max-width: 820px) {
37+
.selected-recipients-container {
38+
padding: 10px 5px 10px 5px;
39+
}
40+
}
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
import React, { useContext, useEffect, useState, useRef } from 'react';
2+
import { styled } from '@mui/material/styles';
3+
import FeedbackRecipientCard from '../feedback_recipient_card/FeedbackRecipientCard';
4+
import { AppContext } from '../../context/AppContext';
5+
import {
6+
selectProfile,
7+
selectCsrfToken,
8+
selectCurrentUser,
9+
selectNormalizedMembers
10+
} from '../../context/selectors';
11+
import { getFeedbackSuggestion } from '../../api/feedback';
12+
import Typography from '@mui/material/Typography';
13+
import { TextField, Grid, InputAdornment } from '@mui/material';
14+
import { Search } from '@mui/icons-material';
15+
import PropTypes from 'prop-types';
16+
17+
import './FeedbackExternalRecipientSelector.css';
18+
19+
const PREFIX = 'FeedbackExternalRecipientSelector';
20+
const classes = {
21+
search: `${PREFIX}-search`,
22+
searchInput: `${PREFIX}-searchInput`,
23+
searchInputIcon: `${PREFIX}-searchInputIcon`,
24+
members: `${PREFIX}-members`,
25+
textField: `${PREFIX}-textField`
26+
};
27+
28+
const StyledGrid = styled(Grid)({
29+
[`& .${classes.search}`]: {
30+
display: 'flex',
31+
justifyContent: 'space-between',
32+
alignItems: 'center'
33+
},
34+
[`& .${classes.searchInput}`]: {
35+
width: '20em'
36+
},
37+
[`& .${classes.searchInputIcon}`]: {
38+
color: 'gray'
39+
},
40+
[`& .${classes.members}`]: {
41+
display: 'flex',
42+
flexWrap: 'wrap',
43+
justifyContent: 'space-evenly',
44+
width: '100%'
45+
}
46+
});
47+
48+
const propTypes = {
49+
changeQuery: PropTypes.func.isRequired,
50+
fromQuery: PropTypes.array.isRequired,
51+
forQuery: PropTypes.string.isRequired
52+
};
53+
54+
const FeedbackExternalRecipientSelector = ({ changeQuery, fromQuery, forQuery }) => {
55+
const { state } = useContext(AppContext);
56+
const csrf = selectCsrfToken(state);
57+
const userProfile = selectCurrentUser(state);
58+
const { id } = userProfile;
59+
const searchTextUpdated = useRef(false);
60+
const hasRenewedFromURL = useRef(false);
61+
const [searchText, setSearchText] = useState('');
62+
const [profiles, setProfiles] = useState([]);
63+
const normalizedMembers = selectNormalizedMembers(state, searchText);
64+
65+
useEffect(() => {
66+
if (
67+
!searchTextUpdated.current &&
68+
searchText.length !== 0 &&
69+
searchText !== '' &&
70+
searchText
71+
) {
72+
if (fromQuery !== undefined) {
73+
let selectedMembers = profiles.filter(profile =>
74+
fromQuery.includes(profile.id)
75+
);
76+
let filteredNormalizedMembers = normalizedMembers.filter(member => {
77+
return !selectedMembers.some(selectedMember => {
78+
return selectedMember.id === member.id;
79+
});
80+
});
81+
setProfiles(filteredNormalizedMembers);
82+
} else {
83+
setProfiles(normalizedMembers);
84+
}
85+
searchTextUpdated.current = true;
86+
}
87+
}, [searchText, profiles, fromQuery, state, userProfile, normalizedMembers]);
88+
89+
useEffect(() => {
90+
function bindFromURL() {
91+
if (
92+
!hasRenewedFromURL.current &&
93+
fromQuery !== null &&
94+
fromQuery !== undefined
95+
) {
96+
let profileCopy = profiles;
97+
if (typeof fromQuery === 'string') {
98+
let newProfile = { id: fromQuery };
99+
if (
100+
profiles.filter(member => member.id === newProfile.id).length === 0
101+
) {
102+
profileCopy.push(newProfile);
103+
}
104+
} else if (Array.isArray(fromQuery)) {
105+
for (let i = 0; i < fromQuery.length; ++i) {
106+
let newProfile = { id: fromQuery[i] };
107+
if (
108+
profiles.filter(member => member.id === newProfile.id).length ===
109+
0
110+
) {
111+
profileCopy.push(newProfile);
112+
}
113+
}
114+
}
115+
setProfiles(profileCopy);
116+
hasRenewedFromURL.current = true;
117+
}
118+
}
119+
120+
async function getSuggestions() {
121+
if (forQuery === undefined || forQuery === null) {
122+
return;
123+
}
124+
let res = await getFeedbackSuggestion(forQuery, csrf);
125+
if (res && res.payload) {
126+
return res.payload.data && !res.error ? res.payload.data : undefined;
127+
}
128+
return null;
129+
}
130+
131+
if (csrf && (searchText === '' || searchText.length === 0)) {
132+
getSuggestions().then(res => {
133+
bindFromURL();
134+
if (res !== undefined && res !== null) {
135+
let filteredProfileCopy = profiles.filter(member => {
136+
return !res.some(suggestedMember => {
137+
return suggestedMember.id === member.id;
138+
});
139+
});
140+
let newProfiles = filteredProfileCopy.concat(res);
141+
setProfiles(newProfiles);
142+
}
143+
});
144+
} // eslint-disable-next-line react-hooks/exhaustive-deps
145+
}, [id, csrf, searchText]);
146+
147+
const cardClickHandler = id => {
148+
if (!Array.isArray(fromQuery)) {
149+
fromQuery = fromQuery ? [fromQuery] : [];
150+
}
151+
if (fromQuery.includes(id)) {
152+
fromQuery.splice(fromQuery.indexOf(id), 1);
153+
} else {
154+
fromQuery.push(id);
155+
}
156+
157+
changeQuery('from', fromQuery);
158+
hasRenewedFromURL.current = false;
159+
};
160+
161+
const getSelectedCards = () => {
162+
if (fromQuery) {
163+
const title = (
164+
<Typography
165+
style={{ fontWeight: 'bold', color: '#454545', marginBottom: '1em' }}
166+
variant="h5"
167+
>
168+
{fromQuery.length} external recipient
169+
{fromQuery.length === 1 ? '' : 's'} selected
170+
</Typography>
171+
);
172+
173+
// If there are no recipients selected, show a message
174+
if (fromQuery.length === 0) {
175+
return (
176+
<>
177+
{title}
178+
<p style={{ color: 'gray' }}>
179+
Click on external recipients to request feedback from them
180+
</p>
181+
</>
182+
);
183+
}
184+
185+
// If there are any selected recipients, display them
186+
return (
187+
<>
188+
{title}
189+
<div className="recipient-card-container">
190+
{fromQuery.map(id => (
191+
<FeedbackRecipientCard
192+
key={id}
193+
profileId={id}
194+
recipientProfile={selectProfile(state, id)}
195+
selected
196+
onClick={() => cardClickHandler(id)}
197+
/>
198+
))}
199+
</div>
200+
</>
201+
);
202+
}
203+
};
204+
205+
return (
206+
<StyledGrid className="feedback-recipient-selector">
207+
<Grid container spacing={3}>
208+
<Grid item xs={12} className={classes.search}>
209+
<TextField
210+
className={classes.searchInput}
211+
label="Search employees..."
212+
placeholder="Member Name"
213+
value={searchText}
214+
onChange={e => {
215+
setSearchText(e.target.value);
216+
searchTextUpdated.current = false;
217+
}}
218+
InputProps={{
219+
startAdornment: (
220+
<InputAdornment
221+
className={classes.searchInputIcon}
222+
position="start"
223+
>
224+
<Search />
225+
</InputAdornment>
226+
)
227+
}}
228+
/>
229+
</Grid>
230+
</Grid>
231+
<div className="selected-recipients-container">{getSelectedCards()}</div>
232+
<div className="selectable-recipients-container">
233+
{profiles ? (
234+
<div className="recipient-card-container">
235+
{profiles
236+
.filter(
237+
profile =>
238+
!fromQuery ||
239+
(!fromQuery.includes(profile.id) && profile.id !== forQuery)
240+
)
241+
.map(profile => (
242+
<FeedbackRecipientCard
243+
key={profile.id}
244+
recipientProfile={selectProfile(state, profile.id)}
245+
reason={profile?.reason ? profile.reason : null}
246+
onClick={() => cardClickHandler(profile.id)}
247+
/>
248+
))}
249+
</div>
250+
) : (
251+
<p>Can't get suggestions, please come back later :(</p>
252+
)}
253+
</div>
254+
</StyledGrid>
255+
);
256+
};
257+
258+
FeedbackExternalRecipientSelector.propTypes = propTypes;
259+
260+
export default FeedbackExternalRecipientSelector;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import React from 'react';
2+
import FeedbackExternalRecipientSelector from './FeedbackExternalRecipientSelector.jsx';
3+
import { AppContextProvider } from '../../context/AppContext';
4+
import { BrowserRouter } from 'react-router-dom';
5+
6+
describe('FeedbackExternalRecipientSelector', () => {
7+
it('renders the component', () => {
8+
snapshot(
9+
<BrowserRouter>
10+
<AppContextProvider>
11+
<FeedbackExternalRecipientSelector
12+
changeQuery={vi.fn()}
13+
fromQuery={[]}
14+
forQuery=""
15+
/>
16+
</AppContextProvider>
17+
</BrowserRouter>
18+
);
19+
});
20+
});

0 commit comments

Comments
 (0)