Skip to content

Commit 4f60321

Browse files
authored
Merge pull request #12 from ruiqi7/feature/question-list
Add question list page
2 parents d798d3e + d9e5e71 commit 4f60321

File tree

7 files changed

+510
-9
lines changed

7 files changed

+510
-9
lines changed

frontend/src/App.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import QuestionEdit from "./pages/QuestionEdit";
66
import PageNotFound from "./pages/PageNotFound";
77
import ProfilePage from "./pages/Profile";
88
import AuthProvider from "./contexts/AuthContext";
9+
import QuestionList from "./pages/QuestionList";
910

1011
function App() {
1112
return (
@@ -14,7 +15,7 @@ function App() {
1415
<Routes>
1516
<Route path="/" element={<Layout />}>
1617
<Route path="questions">
17-
<Route index element={<>question page list</>} />
18+
<Route index element={<QuestionList />} />
1819
<Route path="new" element={<NewQuestion />} />
1920
<Route path=":questionId" element={<QuestionDetail />} />
2021
<Route path=":questionId/edit" element={<QuestionEdit />} />

frontend/src/components/Layout/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const Layout: React.FC = () => {
99
display: "flex",
1010
flexDirection: "column",
1111
minHeight: "100vh",
12+
minWidth: "755px",
1213
// minInlineSize: "100vw",
1314
}}
1415
>

frontend/src/main.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { CssBaseline } from "@mui/material";
88
createRoot(document.getElementById("root")!).render(
99
<StrictMode>
1010
<ThemeProvider theme={theme}>
11-
<CssBaseline />
11+
<CssBaseline enableColorScheme />
1212
<App />
1313
</ThemeProvider>
1414
</StrictMode>
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
import {
2+
Autocomplete,
3+
Box,
4+
Button,
5+
Chip,
6+
Grid2,
7+
IconButton,
8+
InputAdornment,
9+
ListItemIcon,
10+
Menu,
11+
MenuItem,
12+
Stack,
13+
Table,
14+
TableBody,
15+
TableCell,
16+
TableContainer,
17+
TableHead,
18+
TablePagination,
19+
TableRow,
20+
TextField,
21+
Typography,
22+
useTheme,
23+
} from "@mui/material";
24+
import { useEffect, useReducer, useState } from "react";
25+
import AppMargin from "../../components/AppMargin";
26+
import { useNavigate } from "react-router-dom";
27+
import reducer, {
28+
deleteQuestionById,
29+
getQuestionList,
30+
initialState,
31+
} from "../../reducers/questionReducer";
32+
import { categoryList, complexityList } from "../../utils/constants";
33+
import useDebounce from "../../utils/debounce";
34+
import { blue, grey } from "@mui/material/colors";
35+
import { Add, Delete, Edit, MoreVert, Search } from "@mui/icons-material";
36+
37+
// TODO: get dynamic category list from DB
38+
39+
const tableHeaders = ["Title", "Complexity", "Categories"];
40+
const searchCharacterLimit = 255;
41+
const categorySelectionLimit = 10;
42+
const rowsPerPage = 10;
43+
const isAdmin = false; // TODO: check using auth context
44+
45+
const QuestionList: React.FC = () => {
46+
const [page, setPage] = useState<number>(0);
47+
const [searchInput, setSearchInput] = useState<string>("");
48+
const [searchFilter, setSearchFilter] = useDebounce<string>("", 1000);
49+
const [complexityFilter, setComplexityFilter] = useDebounce<string[]>([], 1000);
50+
const [categoryFilter, setCategoryFilter] = useDebounce<string[]>([], 1000);
51+
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
52+
const [state, dispatch] = useReducer(reducer, initialState);
53+
const navigate = useNavigate();
54+
const theme = useTheme();
55+
56+
// For handling edit / delete question for the admin user
57+
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
58+
const menuOpen = Boolean(anchorEl);
59+
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
60+
setAnchorEl(event.currentTarget);
61+
};
62+
const handleMenuClose = () => {
63+
setAnchorEl(null);
64+
};
65+
const handleQuestionDelete = (questionId: string) => {
66+
// TODO
67+
// handleMenuClose();
68+
deleteQuestionById(questionId, dispatch);
69+
};
70+
71+
useEffect(() => {
72+
getQuestionList(
73+
page,
74+
rowsPerPage,
75+
searchFilter,
76+
complexityFilter,
77+
categoryFilter,
78+
dispatch
79+
);
80+
}, [page, searchFilter, complexityFilter, categoryFilter]);
81+
82+
return (
83+
<AppMargin>
84+
<Box sx={{ marginTop: theme.spacing(4), marginBottom: theme.spacing(4) }}>
85+
<Stack
86+
direction="row"
87+
sx={{ justifyContent: "space-between", alignItems: "center" }}
88+
>
89+
<Typography component="h1" variant="h3">
90+
Questions
91+
</Typography>
92+
{isAdmin && (
93+
<Button
94+
variant="contained"
95+
disableElevation
96+
startIcon={<Add />}
97+
sx={{ height: 40 }}
98+
onClick={() => navigate("new")}
99+
>
100+
Create
101+
</Button>
102+
)}
103+
</Stack>
104+
<Grid2
105+
container
106+
rowSpacing={1}
107+
columnSpacing={2}
108+
sx={{
109+
marginTop: theme.spacing(2),
110+
"& fieldset": { borderRadius: theme.spacing(2.5) },
111+
"& .MuiTextField-root": { width: "100%" },
112+
}}
113+
>
114+
<Grid2 size={12}>
115+
<TextField
116+
slotProps={{
117+
input: {
118+
endAdornment: (
119+
<InputAdornment position="end">
120+
<IconButton type="button">
121+
<Search />
122+
</IconButton>
123+
</InputAdornment>
124+
),
125+
},
126+
htmlInput: {
127+
maxLength: searchCharacterLimit,
128+
},
129+
formHelperText: {
130+
sx: { textAlign: "right" },
131+
},
132+
}}
133+
label="Title"
134+
onChange={(input) => {
135+
setSearchInput(input.target.value);
136+
setSearchFilter(input.target.value);
137+
}}
138+
helperText={
139+
searchInput.length + ` / ${searchCharacterLimit} characters`
140+
}
141+
disabled={state.questions.length === 0}
142+
/>
143+
</Grid2>
144+
<Grid2 size={4}>
145+
<Autocomplete
146+
multiple
147+
disableCloseOnSelect
148+
options={complexityList}
149+
onChange={(_, selectedOptions) => {
150+
setComplexityFilter(selectedOptions);
151+
}}
152+
renderInput={(params) => (
153+
<TextField {...params} label="Complexity" />
154+
)}
155+
disabled={state.questions.length === 0}
156+
/>
157+
</Grid2>
158+
<Grid2 size={8}>
159+
<Autocomplete
160+
multiple
161+
disableCloseOnSelect
162+
options={categoryList}
163+
getOptionDisabled={(option) =>
164+
selectedCategories.length > categorySelectionLimit &&
165+
!selectedCategories.includes(option as string)
166+
}
167+
onChange={(_, selectedOptions) => {
168+
setSelectedCategories(selectedOptions);
169+
setCategoryFilter(selectedOptions);
170+
}}
171+
renderInput={(params) => (
172+
<TextField
173+
{...params}
174+
label="Category"
175+
helperText={
176+
selectedCategories.length +
177+
` / ${categorySelectionLimit} selections`
178+
}
179+
slotProps={{
180+
formHelperText: {
181+
sx: { textAlign: "right" },
182+
},
183+
}}
184+
/>
185+
)}
186+
disabled={state.questions.length === 0}
187+
/>
188+
</Grid2>
189+
</Grid2>
190+
<TableContainer>
191+
<Table
192+
sx={{
193+
"& .MuiTableCell-root": { padding: theme.spacing(1.2) },
194+
whiteSpace: "nowrap",
195+
}}
196+
>
197+
<TableHead>
198+
<TableRow>
199+
{tableHeaders.map((header) => (
200+
<TableCell key={header}>
201+
<Typography component="span" variant="h5">
202+
{header}
203+
</Typography>
204+
</TableCell>
205+
))}
206+
</TableRow>
207+
</TableHead>
208+
<TableBody>
209+
{state.questions.slice(0, rowsPerPage).map((question) => (
210+
<TableRow key={question.questionId}>
211+
<TableCell
212+
sx={{
213+
width: "50%",
214+
maxWidth: "250px",
215+
overflow: "hidden",
216+
textOverflow: "ellipsis",
217+
}}
218+
>
219+
<Typography
220+
component="span"
221+
sx={{
222+
"&:hover": { cursor: "pointer", color: "primary.main" },
223+
}}
224+
onClick={() => navigate(`${question.questionId}`)}
225+
>
226+
{question.title}
227+
</Typography>
228+
</TableCell>
229+
<TableCell
230+
sx={{
231+
borderLeft: "1px solid #E0E0E0",
232+
borderRight: "1px solid #E0E0E0",
233+
}}
234+
>
235+
<Typography
236+
component="span"
237+
sx={{
238+
color:
239+
question.complexity === "Easy"
240+
? "success.main"
241+
: question.complexity === "Medium"
242+
? "#D2C350"
243+
: question.complexity === "Hard"
244+
? "error.main"
245+
: grey[500],
246+
}}
247+
>
248+
{question.complexity}
249+
</Typography>
250+
</TableCell>
251+
<TableCell
252+
sx={{
253+
width: "50%",
254+
maxWidth: "250px",
255+
overflow: "auto",
256+
}}
257+
>
258+
<Stack direction="row">
259+
{question.categories.map((category) => (
260+
<Chip
261+
key={category}
262+
label={category}
263+
color="primary"
264+
sx={{
265+
marginLeft: theme.spacing(0.5),
266+
marginRight: theme.spacing(0.5),
267+
}}
268+
/>
269+
))}
270+
<Chip
271+
sx={{ visibility: "hidden", width: theme.spacing(0.5) }}
272+
/>
273+
</Stack>
274+
</TableCell>
275+
{isAdmin && (
276+
<TableCell sx={{ borderTop: "1px solid #E0E0E0" }}>
277+
<IconButton type="button" onClick={handleMenuOpen}>
278+
<MoreVert />
279+
</IconButton>
280+
<Menu
281+
anchorEl={anchorEl}
282+
open={menuOpen}
283+
onClose={handleMenuClose}
284+
>
285+
<MenuItem
286+
onClick={() =>
287+
navigate(`${question.questionId}/edit`)
288+
}
289+
>
290+
<ListItemIcon>
291+
<Edit
292+
sx={{ fontSize: "large", color: blue[800] }}
293+
/>
294+
</ListItemIcon>
295+
Edit
296+
</MenuItem>
297+
<MenuItem
298+
onClick={() =>
299+
handleQuestionDelete(question.questionId)
300+
}
301+
>
302+
<ListItemIcon>
303+
<Delete
304+
sx={{ fontSize: "large", color: "error.main" }}
305+
/>
306+
</ListItemIcon>
307+
Delete
308+
</MenuItem>
309+
</Menu>
310+
</TableCell>
311+
)}
312+
</TableRow>
313+
))}
314+
</TableBody>
315+
</Table>
316+
</TableContainer>
317+
<TablePagination
318+
rowsPerPageOptions={[rowsPerPage]}
319+
component="div"
320+
count={state.questionCount}
321+
rowsPerPage={rowsPerPage}
322+
page={page}
323+
onPageChange={(_, page) => setPage(page)}
324+
/>
325+
{state.questions.length === 0 && (
326+
<Stack
327+
direction="column"
328+
spacing={1}
329+
sx={{ alignItems: "center", fontStyle: "italic" }}
330+
>
331+
<Typography>
332+
Unfortunately, there are no questions available now.
333+
</Typography>
334+
<Typography>
335+
{isAdmin
336+
? "Create questions using the 'Create' button above."
337+
: "Please try again later!"}
338+
</Typography>
339+
</Stack>
340+
)}
341+
</Box>
342+
</AppMargin>
343+
);
344+
};
345+
346+
export default QuestionList;

0 commit comments

Comments
 (0)