From 5fb21d18f523b260e39173960ba941d4e5a38132 Mon Sep 17 00:00:00 2001 From: Kwon Seo Jin <97675977+B0XERCAT@users.noreply.github.com> Date: Mon, 11 Mar 2024 17:18:39 +0900 Subject: [PATCH] feat(fe): add admin problem detail (#1524) * feat(fe): add problem detail layout * feat(fe): add submission table * feat(fe): add username filter input * feat(fe): add filter for userID Search * fix: change user to username, change id to underscore * fix: edit datatable input placeholder * fix: delete files of frontend-client --------- Co-authored-by: jiho --- .../problem/[id]/_components/Columns.tsx | 59 ++ .../problem/[id]/_components/DataTable.tsx | 169 +++++ .../app/admin/problem/[id]/edit/page.tsx | 685 +++++++++++++++++ apps/frontend/app/admin/problem/[id]/page.tsx | 713 ++---------------- .../_components/{Lable.tsx => Label.tsx} | 0 .../app/admin/problem/create/page.tsx | 2 +- 6 files changed, 962 insertions(+), 666 deletions(-) create mode 100644 apps/frontend/app/admin/problem/[id]/_components/Columns.tsx create mode 100644 apps/frontend/app/admin/problem/[id]/_components/DataTable.tsx create mode 100644 apps/frontend/app/admin/problem/[id]/edit/page.tsx rename apps/frontend/app/admin/problem/_components/{Lable.tsx => Label.tsx} (100%) diff --git a/apps/frontend/app/admin/problem/[id]/_components/Columns.tsx b/apps/frontend/app/admin/problem/[id]/_components/Columns.tsx new file mode 100644 index 0000000000..4aac34c0f4 --- /dev/null +++ b/apps/frontend/app/admin/problem/[id]/_components/Columns.tsx @@ -0,0 +1,59 @@ +'use client' + +import type { SubmissionItem } from '@/types/type' +import type { ColumnDef } from '@tanstack/react-table' +import dayjs from 'dayjs' + +export const columns: ColumnDef[] = [ + { + header: '#', + accessorKey: 'id', + cell: ({ row }) =>

{row.original.id}

+ }, + { + id: 'username', + header: () => 'User ID', + accessorKey: 'username', + cell: ({ row }) => row.original.user.username, + // submission userID Search Filter + filterFn: (row, _, value) => { + const users = row.original.user + return users.username.includes(value) + } + }, + { + header: () => 'Result', + accessorKey: 'result', + cell: ({ row }) => { + return row.original.result === 'Accepted' ? ( +

{row.original.result}

+ ) : row.original.result === 'Judging' ? ( +

{row.original.result}

+ ) : ( +

{row.original.result}

+ ) + } + }, + { + header: () => 'Language', + accessorKey: 'language', + cell: ({ row }) => row.original.language + }, + { + header: () => 'Submission Time', + accessorKey: 'createTime', + cell: ({ row }) => + dayjs(row.original.createTime).format('YYYY-MM-DD HH:mm:ss') + }, + { + header: () => 'Code Size', + accessorKey: 'codeSize', + cell: ({ row }) => { + return row.original.codeSize === null ? ( +

N/A

+ ) : ( +

{row.original.codeSize} B

+ ) + } + } +] diff --git a/apps/frontend/app/admin/problem/[id]/_components/DataTable.tsx b/apps/frontend/app/admin/problem/[id]/_components/DataTable.tsx new file mode 100644 index 0000000000..b395cc2e66 --- /dev/null +++ b/apps/frontend/app/admin/problem/[id]/_components/DataTable.tsx @@ -0,0 +1,169 @@ +'use client' + +import { Input } from '@/components/ui/input' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@/components/ui/table' +import { cn } from '@/lib/utils' +import type { ColumnDef } from '@tanstack/react-table' +import { + flexRender, + getCoreRowModel, + getFilteredRowModel, + useReactTable +} from '@tanstack/react-table' +import type { Route } from 'next' +import Link from 'next/link' +import { useRouter } from 'next/navigation' + +interface DataTableProps { + columns: ColumnDef[] + data: TData[] + headerStyle: { + [key: string]: string + } + problemId: number +} + +/** + * @param columns + * columns to be displayed + * @param data + * data to be displayed + * @param headerStyle + * tailwindcss class name for each header + * @param name + * name of the table, used for routing + * @example + * ```tsx + * // page.tsx + * + * ``` + * ```tsx + * // _components/Columns.tsx + * import type { Notice } from '@/types/type' + * export const columns: ColumnDef[] = [ + * { + * header: 'Title', + * accessorKey: 'title', + * cell: (row) => row.original.title, + * }, + * ... + * ] + * ``` + */ + +interface Item { + id: number +} + +export default function DataTable({ + columns, + data, + headerStyle, + problemId +}: DataTableProps) { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel() + }) + const router = useRouter() + + return ( +
+ { + table.getColumn('username')?.setFilterValue(event.target.value) + }} + className="h-10 w-[150px] lg:w-[250px]" + /> + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => { + const href = + `/problem/${problemId}/submission/${row.original.id}` as Route + return ( + { + router.push(href) + }} + > + {row.getVisibleCells().map((cell) => ( + +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+ {/* for prefetch */} + +
+ ))} +
+ ) + }) + ) : ( + + + No results. + + + )} +
+
+
+ ) +} diff --git a/apps/frontend/app/admin/problem/[id]/edit/page.tsx b/apps/frontend/app/admin/problem/[id]/edit/page.tsx new file mode 100644 index 0000000000..a81739af7e --- /dev/null +++ b/apps/frontend/app/admin/problem/[id]/edit/page.tsx @@ -0,0 +1,685 @@ +'use client' + +import { gql } from '@generated' +import CheckboxSelect from '@/components/CheckboxSelect' +import OptionSelect from '@/components/OptionSelect' +import TagsSelect from '@/components/TagsSelect' +import TextEditor from '@/components/TextEditor' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Popover, + PopoverContent, + PopoverTrigger +} from '@/components/ui/popover' +import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { languages, levels } from '@/lib/constants' +import { cn } from '@/lib/utils' +import { useMutation, useQuery } from '@apollo/client' +import type { UpdateProblemInput } from '@generated/graphql' +import { zodResolver } from '@hookform/resolvers/zod' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { useState } from 'react' +import { useForm, Controller } from 'react-hook-form' +import { FaEye, FaEyeSlash } from 'react-icons/fa' +import { FaAngleLeft } from 'react-icons/fa6' +import { IoMdCheckmarkCircleOutline } from 'react-icons/io' +import { MdHelpOutline } from 'react-icons/md' +import { PiWarningBold } from 'react-icons/pi' +import { toast } from 'sonner' +import { z } from 'zod' +import ExampleTextarea from '../../_components/ExampleTextarea' +import Label from '../../_components/Label' +import { GET_TAGS, inputStyle } from '../../utils' + +const GET_PROBLEM = gql(` + query GetProblem($groupId: Int!, $id: Int!) { + getProblem(groupId: $groupId, id: $id) { + title + isVisible + difficulty + languages + tag { + tag { + id + name + } + } + description + inputDescription + outputDescription + samples { + id + input + output + } + testcase { + id + input + output + } + timeLimit + memoryLimit + hint + source + template + } + } +`) + +const UPDATE_PROBLEM = gql(` + mutation UpdateProblem($groupId: Int!, $input: UpdateProblemInput!) { + updateProblem(groupId: $groupId, input: $input) { + id + createdById + groupId + title + isVisible + difficulty + languages + problemTag { + tag { + id + name + } + } + description + inputDescription + outputDescription + samples { + input + output + } + problemTestcase { + input + output + } + timeLimit + memoryLimit + hint + source + template + } + } +`) + +const schema = z.object({ + id: z.number(), + title: z.string().min(1).max(25), + isVisible: z.boolean(), + difficulty: z.enum(levels), + languages: z.array(z.enum(languages)), + tags: z + .object({ create: z.array(z.number()), delete: z.array(z.number()) }) + .optional(), + description: z.string().min(1), + inputDescription: z.string().min(1), + outputDescription: z.string().min(1), + samples: z.object({ + create: z.array( + z + .object({ input: z.string().min(1), output: z.string().min(1) }) + .optional() + ), + delete: z.array(z.number().optional()) + }), + testcases: z + .array( + z.object({ + input: z.string().min(1), + output: z.string().min(1) + }) + ) + .min(1), + timeLimit: z.number().min(0), + memoryLimit: z.number().min(0), + hint: z.string().optional(), + source: z.string().optional(), + template: z + .array( + z + .object({ + language: z.enum([ + 'C', + 'Cpp', + 'Golang', + 'Java', + 'Python2', + 'Python3' + ]), + code: z.array( + z.object({ + id: z.number(), + text: z.string(), + locked: z.boolean() + }) + ) + }) + .optional() + ) + .optional() +}) + +export default function Page({ params }: { params: { id: string } }) { + const { id } = params + const [showHint, setShowHint] = useState(true) + const [showSource, setShowSource] = useState(true) + + const { data: tagsData } = useQuery(GET_TAGS) + const tags = + tagsData?.getTags.map(({ id, name }) => ({ id: +id, name })) ?? [] + + const router = useRouter() + + const { + handleSubmit, + control, + register, + getValues, + setValue, + watch, + formState: { errors } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + samples: { create: [], delete: [] }, + template: [] + } + }) + + useQuery(GET_PROBLEM, { + variables: { + groupId: 1, + id: +id + }, + onCompleted: (problemData) => { + const data = problemData.getProblem + setValue('id', +id) + setValue('title', data.title) + setValue('isVisible', data.isVisible) + setValue('difficulty', data.difficulty) + setValue('languages', data.languages ?? []) + setValue( + 'tags.create', + data.tag.map(({ tag }) => Number(tag.id)) + ) + setValue( + 'tags.delete', + data.tag.map(({ tag }) => Number(tag.id)) + ) + setValue('description', data.description) + setValue('inputDescription', data.inputDescription) + setValue('outputDescription', data.outputDescription) + setValue('samples.create', data?.samples || []) + setValue('samples.delete', data.samples?.map(({ id }) => +id) || []) + setValue('testcases', data.testcase) + setValue('timeLimit', data.timeLimit) + setValue('memoryLimit', data.memoryLimit) + setValue('hint', data.hint) + setValue('source', data.source) + setValue('template', []) + } + }) + + const watchedSamples = watch('samples.create') + const watchedTestcases = watch('testcases') + + const [updateProblem, { error }] = useMutation(UPDATE_PROBLEM) + const onSubmit = async (input: UpdateProblemInput) => { + const tagsToDelete = getValues('tags.delete') + const tagsToCreate = getValues('tags.create') + input.tags!.create = tagsToCreate.filter( + (tag) => !tagsToDelete.includes(tag) + ) + input.tags!.delete = tagsToDelete.filter( + (tag) => !tagsToCreate.includes(tag) + ) + + await updateProblem({ + variables: { + groupId: 1, + input + } + }) + if (error) { + toast.error('Failed to update problem') + return + } + toast.success('Succesfully updated problem') + router.push('/admin/problem') + router.refresh() + } + + const addSample = () => { + const values = getValues('samples.create') + const newSample = { input: '', output: '' } + setValue('samples.create', [...values, newSample]) + } + + const addTestcase = () => { + const values = getValues('testcases') ?? [] + const newTestcase = { input: '', output: '' } + setValue('testcases', [...values, newTestcase]) + } + + const removeSample = (index: number) => { + const currentValues = getValues('samples.create') + if (currentValues.length <= 1) { + toast.warning('At least one sample is required') + return + } + const updatedValues = currentValues.filter((_, i) => i !== index) + setValue('samples.create', updatedValues) + } + + const removeTestcase = (index: number) => { + const currentValues = getValues('testcases') + if ((currentValues?.length ?? 0) <= 1) { + toast.warning('At least one testcase is required') + return + } + const updatedValues = currentValues?.filter((_, i) => i !== index) + setValue('testcases', updatedValues) + } + return ( + +
+
+ + + + Edit Problem +
+ +
+
+
+ + + {errors.title && ( +
+ + {getValues('title')?.length === 0 + ? 'required' + : errors.title.message?.toString()} +
+ )} +
+
+
+ + + + + + +
    +
  • For contest, 'hidden' is recommended.
  • +
  • You can edit these settings later.
  • +
+
+
+
+ +
+ ( +
+ + +
+ )} + /> +
+ {errors.isVisible && ( +
+ + required +
+ )} +
+
+ +
+ +
+
+ ( + + )} + name="difficulty" + control={control} + /> + {errors.difficulty && ( +
+ + required +
+ )} +
+
+ ( + { + field.onChange(selectedLanguages) + }} + defaultValue={field.value as string[]} + /> + )} + name="languages" + control={control} + /> + {errors.languages && ( +
+ + required +
+ )} +
+
+ ( + + )} + name="tags.create" + control={control} + /> + {errors.tags && ( +
+ + required +
+ )} +
+
+
+ +
+ + {getValues('description') && ( + ( + + )} + name="description" + control={control} + /> + )} + {errors.description && ( +
+ + required +
+ )} +
+ +
+
+
+ +