Skip to content

Commit bcd6594

Browse files
committed
chore/ui: Add CRUD for questions
Signed-off-by: SeeuSim <[email protected]>
1 parent 24ffd5c commit bcd6594

File tree

5 files changed

+119
-20
lines changed

5 files changed

+119
-20
lines changed

frontend/src/components/blocks/questions/admin-delete-form.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
AlertDialogHeader,
1515
AlertDialogTitle,
1616
} from '@/components/ui/alert-dialog';
17+
import { adminDeleteQuestion } from '@/services/question-service';
1718

1819
type AdminDeleteFormProps = {
1920
isOpen: boolean;
@@ -28,7 +29,8 @@ export const AdminDeleteForm: FC<AdminDeleteFormProps> = ({ isOpen, setIsOpen, q
2829
isPending,
2930
isSuccess,
3031
} = useMutation({
31-
mutationFn: async (questionId: number) => {
32+
mutationFn: (questionId: number) => adminDeleteQuestion(questionId),
33+
onSuccess: () => {
3234
setTimeout(() => {
3335
navigate('/');
3436
}, 1000);

frontend/src/components/blocks/questions/admin-edit-form.tsx

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { zodResolver } from '@hookform/resolvers/zod';
22
import { Cross2Icon, PlusIcon } from '@radix-ui/react-icons';
3-
import { useMutation } from '@tanstack/react-query';
3+
import { useMutation, useQueryClient } from '@tanstack/react-query';
44
import { Loader2 } from 'lucide-react';
55
import { type Dispatch, type FC, type SetStateAction, useState } from 'react';
66
import { useForm } from 'react-hook-form';
@@ -39,54 +39,82 @@ import {
3939
} from '@/components/ui/select';
4040
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
4141
import { Textarea } from '@/components/ui/textarea';
42+
import { adminAddQuestion, adminUpdateQuestion } from '@/services/question-service';
43+
import { useAuthedRoute } from '@/stores/auth-store';
4244
import type { IGetQuestionDetailsResponse } from '@/types/question-types';
4345

4446
type AdminEditFormProps = {
4547
isFormOpen: boolean;
4648
setIsFormOpen: Dispatch<SetStateAction<boolean>>;
4749
questionDetails: IGetQuestionDetailsResponse['question'];
50+
mode?: 'create' | 'update';
4851
};
4952

5053
const formSchema = z.object({
5154
title: z.string().min(1),
5255
difficulty: z.enum(['Easy', 'Medium', 'Hard']),
53-
topic: z.string().min(1).array().min(1),
56+
topics: z.string().min(1).array().min(1),
5457
description: z.string().min(1),
5558
});
5659

5760
export const AdminEditForm: FC<AdminEditFormProps> = ({
5861
questionDetails,
5962
isFormOpen,
6063
setIsFormOpen,
64+
mode = 'update',
6165
}) => {
66+
const { userId } = useAuthedRoute();
6267
const [addedTopic, setAddedTopic] = useState('');
63-
const {
64-
mutate: sendUpdate,
65-
isPending,
66-
isSuccess,
67-
} = useMutation({
68-
mutationFn: async (values: z.infer<typeof formSchema>) => {},
69-
});
70-
7168
const form = useForm<z.infer<typeof formSchema>>({
7269
resolver: zodResolver(formSchema),
7370
defaultValues: {
7471
...questionDetails,
72+
topics: questionDetails.topic,
7573
description: questionDetails.description,
7674
difficulty: questionDetails.difficulty as z.infer<typeof formSchema>['difficulty'],
7775
},
7876
mode: 'onSubmit',
7977
});
8078

79+
const queryClient = useQueryClient();
80+
const {
81+
mutate: sendUpdate,
82+
isPending,
83+
isSuccess,
84+
} = useMutation({
85+
mutationFn: (values: z.infer<typeof formSchema>) => {
86+
return mode === 'update'
87+
? adminUpdateQuestion({ ...values, questionId: Number.parseInt(questionDetails.id!) })
88+
: adminAddQuestion(values);
89+
},
90+
onSuccess: () => {
91+
form.reset();
92+
93+
if (mode === 'update') {
94+
queryClient.refetchQueries({
95+
queryKey: ['qn', 'details', Number.parseInt(questionDetails.id!)],
96+
});
97+
} else {
98+
queryClient.refetchQueries({
99+
queryKey: ['questions', userId],
100+
});
101+
}
102+
103+
setTimeout(() => {
104+
setIsFormOpen(false);
105+
}, 500);
106+
},
107+
});
108+
81109
const onSubmit = (formValues: z.infer<typeof formSchema>) => {
82110
sendUpdate(formValues);
83111
};
84112

85113
const addTopic = (topic: string) => {
86-
const val = new Set(form.getValues('topic').map((v) => v.toLowerCase()));
114+
const val = new Set(form.getValues('topics').map((v) => v.toLowerCase()));
87115
val.add(topic.toLowerCase());
88116
form.setValue(
89-
'topic',
117+
'topics',
90118
Array.from(val).map((v) => v.replace(/^[a-z]/, (c) => c.toUpperCase()))
91119
);
92120
};
@@ -96,7 +124,9 @@ export const AdminEditForm: FC<AdminEditFormProps> = ({
96124
<Dialog open={isFormOpen} onOpenChange={setIsFormOpen}>
97125
<DialogContent className='border-border flex h-dvh w-dvw max-w-screen-lg flex-col'>
98126
<DialogHeader className=''>
99-
<DialogTitle className='text-primary'>Edit Question Details</DialogTitle>
127+
<DialogTitle className='text-primary'>
128+
{mode === 'update' ? 'Edit Question Details' : 'Add a question'}
129+
</DialogTitle>
100130
</DialogHeader>
101131
<DialogDescription className='h-full'>
102132
<Form {...form}>
@@ -148,7 +178,7 @@ export const AdminEditForm: FC<AdminEditFormProps> = ({
148178
/>
149179
<FormField
150180
control={form.control}
151-
name='topic'
181+
name='topics'
152182
render={({ field }) => (
153183
<FormItem className='flex flex-col gap-1 sm:col-span-2'>
154184
<div className='flex items-end gap-2'>
@@ -189,7 +219,7 @@ export const AdminEditForm: FC<AdminEditFormProps> = ({
189219
className=''
190220
disabled={isPending}
191221
onClick={() => {
192-
form.resetField('topic');
222+
form.resetField('topics');
193223
}}
194224
size='sm'
195225
>
@@ -211,7 +241,7 @@ export const AdminEditForm: FC<AdminEditFormProps> = ({
211241
}
212242

213243
form.setValue(
214-
'topic',
244+
'topics',
215245
field.value.filter((_value, idx) => idx !== index)
216246
);
217247
}}
@@ -257,7 +287,7 @@ export const AdminEditForm: FC<AdminEditFormProps> = ({
257287
<Markdown
258288
rehypePlugins={[rehypeKatex]}
259289
remarkPlugins={[remarkMath, remarkGfm]}
260-
className='prose prose-neutral text-card-foreground prose-strong:text-card-foreground leading-normal'
290+
className='prose prose-neutral text-card-foreground prose-strong:text-card-foreground prose-pre:bg-secondary prose-pre:text-secondary-foreground leading-normal'
261291
components={{
262292
code: ({ children, className, ...rest }) => {
263293
return (

frontend/src/components/blocks/questions/details.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,15 +99,15 @@ export const QuestionDetails = ({
9999
<Markdown
100100
rehypePlugins={[rehypeKatex]}
101101
remarkPlugins={[remarkMath, remarkGfm]}
102-
className='prose prose-neutral text-card-foreground prose-strong:text-card-foreground leading-normal'
102+
className='prose prose-neutral text-card-foreground prose-strong:text-card-foreground prose-pre:bg-secondary prose-pre:text-secondary-foreground leading-normal'
103103
components={{
104104
code: ({ children, className, ...rest }) => {
105105
// const isCodeBlock = /language-(\w+)/.exec(className || '');
106106

107107
return (
108108
<code
109109
{...rest}
110-
className='bg-secondary text-secondary-foreground rounded px-1.5 py-1 font-mono'
110+
className='dark:bg-secondary dark:text-secondary-foreground rounded px-1.5 py-1 font-mono'
111111
>
112112
{children}
113113
</code>

frontend/src/routes/questions/question-table.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
ArrowRightIcon,
44
DoubleArrowLeftIcon,
55
DoubleArrowRightIcon,
6+
PlusIcon,
67
} from '@radix-ui/react-icons';
78
import {
89
ColumnDef,
@@ -15,6 +16,7 @@ import {
1516
} from '@tanstack/react-table';
1617
import { useState } from 'react';
1718

19+
import { AdminEditForm } from '@/components/blocks/questions/admin-edit-form';
1820
import { Button } from '@/components/ui/button';
1921
import { Input } from '@/components/ui/input';
2022
import { Pagination, PaginationContent, PaginationItem } from '@/components/ui/pagination';
@@ -33,6 +35,7 @@ import {
3335
TableHeader,
3436
TableRow,
3537
} from '@/components/ui/table';
38+
import { useAuthedRoute } from '@/stores/auth-store';
3639

3740
interface QuestionTableProps<TData, TValue> {
3841
columns: Array<ColumnDef<TData, TValue>>;
@@ -45,6 +48,8 @@ export function QuestionTable<TData, TValue>({
4548
data,
4649
isError,
4750
}: QuestionTableProps<TData, TValue>) {
51+
const { isAdmin } = useAuthedRoute();
52+
const [isAdminAddFormOpen, setIsAdminAddFormOpen] = useState(false);
4853
const [pagination, setPagination] = useState({
4954
pageIndex: 0,
5055
pageSize: 12,
@@ -106,6 +111,30 @@ export function QuestionTable<TData, TValue>({
106111
onChange={(event) => table.getColumn('title')?.setFilterValue(event.target.value)}
107112
className='max-w-sm'
108113
/>
114+
{isAdmin && (
115+
<>
116+
<Button
117+
onClick={() => setIsAdminAddFormOpen((open) => !open)}
118+
className='ml-12 flex gap-2'
119+
variant='outline'
120+
size='sm'
121+
>
122+
<span>Add Question</span>
123+
<PlusIcon />
124+
</Button>
125+
<AdminEditForm
126+
mode='create'
127+
isFormOpen={isAdminAddFormOpen}
128+
setIsFormOpen={setIsAdminAddFormOpen}
129+
questionDetails={{
130+
title: '',
131+
topic: [],
132+
description: '',
133+
difficulty: '',
134+
}}
135+
/>
136+
</>
137+
)}
109138
</div>
110139
<div className='border-border rounded-md border'>
111140
<Table>

frontend/src/services/question-service.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ const QUESTION_SERVICE_ROUTES = {
1818
GET_DIFFICULTIES: '/questions/difficulties',
1919
POST_ADD_ATTEMPT: '/questions/newAttempt',
2020
POST_GET_ATTEMPTS: '/questions/attempts',
21+
22+
// Question CRUD
23+
/* POST */
24+
ADD_QUESTION: '/questions/create',
25+
/* PUT */
26+
UPDATE_QUESTION: '/questions/<questionId>',
27+
/* DELETE */
28+
DELETE_QUESTION: '/questions/<questionId>',
2129
};
2230

2331
export const getQuestionDetails = (questionId: number): Promise<IGetQuestionDetailsResponse> => {
@@ -83,3 +91,33 @@ export const getQuestionAttempts = (
8391
.post(QUESTION_SERVICE_ROUTES.POST_GET_ATTEMPTS, { ...params, limit: 10 })
8492
.then((res) => res.data as IPostGetQuestionAttemptsResponse);
8593
};
94+
95+
export const adminAddQuestion = (values: {
96+
title: string;
97+
difficulty: string;
98+
description: string;
99+
topics: Array<string>;
100+
}) => {
101+
return questionApiClient
102+
.post(QUESTION_SERVICE_ROUTES.ADD_QUESTION, values)
103+
.then((res) => res.data as { message?: string });
104+
};
105+
106+
export const adminUpdateQuestion = (values: {
107+
questionId: number;
108+
title: string;
109+
difficulty: string;
110+
description: string;
111+
topics: Array<string>;
112+
}) => {
113+
const { questionId, ...rest } = values;
114+
return questionApiClient
115+
.put(QUESTION_SERVICE_ROUTES.UPDATE_QUESTION.replace(/<questionId>/, String(questionId)), rest)
116+
.then((res) => res.data as { message?: string });
117+
};
118+
119+
export const adminDeleteQuestion = (questionId: number) => {
120+
return questionApiClient
121+
.delete(QUESTION_SERVICE_ROUTES.DELETE_QUESTION.replace(/<questionId>/, String(questionId)))
122+
.then((res) => res.data as { message?: string });
123+
};

0 commit comments

Comments
 (0)