From 452f73988e1e030b7a2a40a2acc329f106348e68 Mon Sep 17 00:00:00 2001 From: Woojin Jung <44637040+jwoojin9@users.noreply.github.com> Date: Sun, 17 Nov 2024 00:53:37 +0900 Subject: [PATCH] feat(fe): show alert modal after editting problem score (#2209) * feat(fe): add score caution modal * chore(fe): apply belonged contest queries update * feat(fe): create set to zero button * feat(fe): create revert score button * feat(fe): apply update contest problems score * feat(fe): remove create problem alert dialog * feat(fe): remove create contest alert dialog * chore(fe): resolve reviews --- .../app/admin/contest/create/page.tsx | 33 +---- .../edit/_components/BelongedContestTable.tsx | 91 ++++++++++++++ .../BelongedContestTableColumns.tsx | 99 +++++++++++++++ .../edit/_components/RevertScoreButton.tsx | 32 +++++ .../edit/_components/ScoreCautionDialog.tsx | 119 ++++++++++++++++++ .../edit/_components/SetToZeroButton.tsx | 25 ++++ .../admin/problem/[problemId]/edit/page.tsx | 93 ++++++++++---- .../_components/CreateProblemAlertDialog.tsx | 88 ------------- .../create/_components/CreateProblemForm.tsx | 40 ++++-- apps/frontend/graphql/contest/queries.ts | 6 + apps/frontend/graphql/problem/mutations.ts | 12 ++ 11 files changed, 483 insertions(+), 155 deletions(-) create mode 100644 apps/frontend/app/admin/problem/[problemId]/edit/_components/BelongedContestTable.tsx create mode 100644 apps/frontend/app/admin/problem/[problemId]/edit/_components/BelongedContestTableColumns.tsx create mode 100644 apps/frontend/app/admin/problem/[problemId]/edit/_components/RevertScoreButton.tsx create mode 100644 apps/frontend/app/admin/problem/[problemId]/edit/_components/ScoreCautionDialog.tsx create mode 100644 apps/frontend/app/admin/problem/[problemId]/edit/_components/SetToZeroButton.tsx delete mode 100644 apps/frontend/app/admin/problem/create/_components/CreateProblemAlertDialog.tsx diff --git a/apps/frontend/app/admin/contest/create/page.tsx b/apps/frontend/app/admin/contest/create/page.tsx index 9088d3c04d..ef193a318c 100644 --- a/apps/frontend/app/admin/contest/create/page.tsx +++ b/apps/frontend/app/admin/contest/create/page.tsx @@ -53,7 +53,6 @@ export default function Page() { const [problems, setProblems] = useState([]) const [isCreating, setIsCreating] = useState(false) const [showImportDialog, setShowImportDialog] = useState(false) - const [showCreateModal, setShowCreateModal] = useState(false) const shouldSkipWarning = useRef(false) const router = useRouter() @@ -90,7 +89,7 @@ export default function Page() { toast.error('Duplicate problem order found') return } - setShowCreateModal(true) + onSubmit() } const onSubmit = async () => { @@ -253,39 +252,11 @@ export default function Page() { - - - - Create Contest? - - Once user submit any coding, the contest problem list and - score cannot be modified. - - - - setShowCreateModal(false)} - > - Cancel - - - - - - - diff --git a/apps/frontend/app/admin/problem/[problemId]/edit/_components/BelongedContestTable.tsx b/apps/frontend/app/admin/problem/[problemId]/edit/_components/BelongedContestTable.tsx new file mode 100644 index 0000000000..524e29ec24 --- /dev/null +++ b/apps/frontend/app/admin/problem/[problemId]/edit/_components/BelongedContestTable.tsx @@ -0,0 +1,91 @@ +'use client' + +import DataTable from '@/app/admin/_components/table/DataTable' +import DataTableFallback from '@/app/admin/_components/table/DataTableFallback' +import DataTableRoot from '@/app/admin/_components/table/DataTableRoot' +import { GET_BELONGED_CONTESTS } from '@/graphql/contest/queries' +import { useSuspenseQuery } from '@apollo/client' +import { useEffect, useState } from 'react' +import { columns, type BelongedContest } from './BelongedContestTableColumns' +import RevertScoreButton from './RevertScoreButton' +import SetToZeroButton from './SetToZeroButton' + +export function BelongedContestTable({ + problemId, + onSetToZero, + onRevertScore +}: { + problemId: number + onSetToZero: (data: number[]) => void + onRevertScore: () => void +}) { + const [contests, setContests] = useState([]) + + const { data } = useSuspenseQuery(GET_BELONGED_CONTESTS, { + variables: { + problemId + } + }) + + useEffect(() => { + if (data) { + const mappedData: BelongedContest[] = [ + ...data.getContestsByProblemId.upcoming.map((contest) => ({ + id: Number(contest.id), + title: contest.title, + state: 'Upcoming', + problemScore: contest.problemScore, + totalScore: contest.totalScore, + isSetToZero: false + })), + ...data.getContestsByProblemId.ongoing.map((contest) => ({ + id: Number(contest.id), + title: contest.title, + state: 'Ongoing', + problemScore: contest.problemScore, + totalScore: contest.totalScore, + isSetToZero: false + })), + ...data.getContestsByProblemId.finished.map((contest) => ({ + id: Number(contest.id), + title: contest.title, + state: 'Finished', + problemScore: contest.problemScore, + totalScore: contest.totalScore, + isSetToZero: false + })) + ] + setContests(mappedData) + } + }, [data]) + + return ( + + + { + setContests((contests) => + contests.map((contest) => + contestsToSetZero.includes(contest.id) + ? { ...contest, isSetToZero: true } + : contest + ) + ) + onSetToZero(contestsToSetZero) + }} + /> + { + setContests( + contests.map((contest) => ({ ...contest, isSetToZero: false })) + ) + onRevertScore() + }} + /> + + ) +} + +export function BelongedContestTableFallback() { + return +} diff --git a/apps/frontend/app/admin/problem/[problemId]/edit/_components/BelongedContestTableColumns.tsx b/apps/frontend/app/admin/problem/[problemId]/edit/_components/BelongedContestTableColumns.tsx new file mode 100644 index 0000000000..56698e991a --- /dev/null +++ b/apps/frontend/app/admin/problem/[problemId]/edit/_components/BelongedContestTableColumns.tsx @@ -0,0 +1,99 @@ +'use client' + +import DataTableColumnHeader from '@/app/admin/_components/table/DataTableColumnHeader' +import { Checkbox } from '@/components/shadcn/checkbox' +import { cn } from '@/lib/utils' +import type { ColumnDef } from '@tanstack/react-table' + +export interface BelongedContest { + id: number + title: string + state: string + problemScore: number + totalScore: number + isSetToZero: boolean +} + +export const columns: ColumnDef[] = [ + { + id: 'select', + header: ({ table }) => ( + e.stopPropagation()} + checked={table.getIsAllPageRowsSelected()} + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-[2px] bg-white" + /> + ), + cell: ({ row }) => ( + e.stopPropagation()} + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-[2px] bg-white" + /> + ), + enableSorting: false, + enableHiding: false + }, + { + accessorKey: 'title', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +

+ {row.getValue('title')} +

+ ), + enableSorting: false, + enableHiding: false + }, + { + accessorKey: 'state', + header: () => ( +

State

+ ), + cell: ({ row }) => ( +

{row.getValue('state')}

+ ) + }, + { + accessorKey: 'problemScore', + header: () => ( +

Problem Score

+ ), + cell: ({ row }) => ( +

+ {row.original.isSetToZero ? '0' : row.getValue('problemScore')} +

+ ) + }, + { + accessorKey: 'totalScore', + header: () => ( +

Total Score

+ ), + cell: ({ row }) => ( +

+ {row.original.isSetToZero ? '0' : row.getValue('problemScore')}/ + {row.original.isSetToZero + ? Number(row.getValue('totalScore')) - + Number(row.getValue('problemScore')) + : row.getValue('totalScore')} +

+ ) + } +] diff --git a/apps/frontend/app/admin/problem/[problemId]/edit/_components/RevertScoreButton.tsx b/apps/frontend/app/admin/problem/[problemId]/edit/_components/RevertScoreButton.tsx new file mode 100644 index 0000000000..032fb6fde6 --- /dev/null +++ b/apps/frontend/app/admin/problem/[problemId]/edit/_components/RevertScoreButton.tsx @@ -0,0 +1,32 @@ +import { useDataTable } from '@/app/admin/_components/table/context' +import { Button } from '@/components/shadcn/button' +import type { BelongedContest } from './BelongedContestTableColumns' + +interface SetToZeroButtonProps { + onRevertScore: () => void +} + +export default function RevertScoreButton({ + onRevertScore +}: SetToZeroButtonProps) { + const { table } = useDataTable() + + const selectedContests = table.getSelectedRowModel().rows + + return ( + <> + {selectedContests.length > 0 && ( + + )} + + ) +} diff --git a/apps/frontend/app/admin/problem/[problemId]/edit/_components/ScoreCautionDialog.tsx b/apps/frontend/app/admin/problem/[problemId]/edit/_components/ScoreCautionDialog.tsx new file mode 100644 index 0000000000..85a883ff8d --- /dev/null +++ b/apps/frontend/app/admin/problem/[problemId]/edit/_components/ScoreCautionDialog.tsx @@ -0,0 +1,119 @@ +import { Button } from '@/components/shadcn/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/shadcn/dialog' +import { UPDATE_CONTEST_PROBLEMS_SCORES } from '@/graphql/problem/mutations' +import { useMutation } from '@apollo/client' +import { Suspense, useState } from 'react' +import { + BelongedContestTable, + BelongedContestTableFallback +} from './BelongedContestTable' + +interface ScoreCautionDialogProps { + isOpen: boolean + onCancel: () => void + onConfirm: () => void + problemId: number +} + +export function ScoreCautionDialog({ + isOpen, + onCancel, + onConfirm, + problemId +}: ScoreCautionDialogProps) { + const [updateContestsProblemsScores] = useMutation( + UPDATE_CONTEST_PROBLEMS_SCORES + ) + + const [zeroSetContests, setZeroSetContests] = useState([]) + + return ( + + + + Are you sure you want to edit this problem? + +
    +
  • +

    + Editing the problem may affect the{' '} + accuracy of grading + results. +

    +
      +
    • + Future submissions will be graded based on the updated + problem. +
    • +
    • + Previous submissions will retain their original grading + based on the pre-edit version. +
    • +
    +
  • +
  • +

    + This problem is part of the following contest. +

    {' '} +

    + Please check the contest + for which you want to set its{' '} + score to zero(0). +

    +
      +
    • + Setting the score to ‘0’ will remove this problem’s impact + on grading results. +
    • +
    + }> + setZeroSetContests(contests)} + onRevertScore={() => setZeroSetContests([])} + > + +
    +
  • +
+
+
+ + + + +
+
+ ) +} diff --git a/apps/frontend/app/admin/problem/[problemId]/edit/_components/SetToZeroButton.tsx b/apps/frontend/app/admin/problem/[problemId]/edit/_components/SetToZeroButton.tsx new file mode 100644 index 0000000000..d6ac279638 --- /dev/null +++ b/apps/frontend/app/admin/problem/[problemId]/edit/_components/SetToZeroButton.tsx @@ -0,0 +1,25 @@ +import { useDataTable } from '@/app/admin/_components/table/context' +import { Button } from '@/components/shadcn/button' +import type { BelongedContest } from './BelongedContestTableColumns' + +interface SetToZeroButtonProps { + onSetToZero: (data: number[]) => void +} + +export default function SetToZeroButton({ onSetToZero }: SetToZeroButtonProps) { + const { table } = useDataTable() + + const selectedContests = table + .getSelectedRowModel() + .rows.map((row) => row.original.id) + + return ( + <> + {selectedContests.length > 0 && ( + + )} + + ) +} diff --git a/apps/frontend/app/admin/problem/[problemId]/edit/page.tsx b/apps/frontend/app/admin/problem/[problemId]/edit/page.tsx index 0aaf6eb268..d62300b74a 100644 --- a/apps/frontend/app/admin/problem/[problemId]/edit/page.tsx +++ b/apps/frontend/app/admin/problem/[problemId]/edit/page.tsx @@ -28,6 +28,7 @@ import TestcaseField from '../../_components/TestcaseField' import VisibleForm from '../../_components/VisibleForm' import { editSchema } from '../../_libs/schemas' import { validateScoreWeight } from '../../_libs/utils' +import { ScoreCautionDialog } from './_components/ScoreCautionDialog' export default function Page({ params }: { params: { problemId: string } }) { const { problemId } = params @@ -43,11 +44,18 @@ export default function Page({ params }: { params: { problemId: string } }) { const { handleSubmit, setValue, getValues } = methods - const [blockEdit, setBlockEdit] = useState(false) const [showHint, setShowHint] = useState(false) const [showSource, setShowSource] = useState(false) const [isDialogOpen, setDialogOpen] = useState(false) const [dialogDescription, setDialogDescription] = useState('') + const [isScoreDialogOpen, setIsScoreDialogOpen] = useState(false) + const initialValues = useRef<{ + testcases: Testcase[] + timeLimit: number + memoryLimit: number + } | null>(null) + + const pendingInput = useRef(null) useQuery(GET_PROBLEM, { variables: { @@ -57,7 +65,12 @@ export default function Page({ params }: { params: { problemId: string } }) { onCompleted: (problemData) => { const data = problemData.getProblem - if (data.submissionCount > 0) setBlockEdit(true) + const initialFormValues = { + testcases: data.testcase, + timeLimit: data.timeLimit, + memoryLimit: data.memoryLimit + } + initialValues.current = initialFormValues setValue('id', Number(problemId)) setValue('title', data.title) @@ -108,6 +121,25 @@ export default function Page({ params }: { params: { problemId: string } }) { const [updateProblem, { error }] = useMutation(UPDATE_PROBLEM) + const handleUpdate = async () => { + if (pendingInput.current) { + await updateProblem({ + variables: { + groupId: 1, + input: pendingInput.current + } + }) + if (error) { + toast.error('Failed to update problem') + return + } + shouldSkipWarning.current = true + toast.success('Successfully updated problem') + router.push('/admin/problem') + router.refresh() + } + } + const onSubmit = async (input: UpdateProblemInput) => { const testcases = getValues('testcases') as Testcase[] if (validateScoreWeight(testcases) === false) { @@ -117,29 +149,31 @@ export default function Page({ params }: { params: { problemId: string } }) { setDialogOpen(true) return } - 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 + + pendingInput.current = input + if (initialValues.current) { + const currentValues = getValues() + let scoreCalculationChanged = false + + if ( + JSON.stringify(currentValues.testcases) !== + JSON.stringify(initialValues.current.testcases) + ) { + scoreCalculationChanged = true + } else if (currentValues.timeLimit !== initialValues.current.timeLimit) { + scoreCalculationChanged = true + } else if ( + currentValues.memoryLimit !== initialValues.current.memoryLimit + ) { + scoreCalculationChanged = true + } + + if (scoreCalculationChanged) { + setIsScoreDialogOpen(true) + return } - }) - if (error) { - toast.error('Failed to update problem') - return } - shouldSkipWarning.current = true - toast.success('Succesfully updated problem') - router.push('/admin/problem') - router.refresh() + await handleUpdate() } return ( @@ -195,10 +229,10 @@ export default function Page({ params }: { params: { problemId: string } }) { - {getValues('testcases') && } + {getValues('testcases') && } - + setDialogOpen(false)} description={dialogDescription} /> + setIsScoreDialogOpen(false)} + onConfirm={async () => { + await handleUpdate() + setIsScoreDialogOpen(false) + }} + problemId={Number(problemId)} + /> ) } diff --git a/apps/frontend/app/admin/problem/create/_components/CreateProblemAlertDialog.tsx b/apps/frontend/app/admin/problem/create/_components/CreateProblemAlertDialog.tsx deleted file mode 100644 index 03de45fc27..0000000000 --- a/apps/frontend/app/admin/problem/create/_components/CreateProblemAlertDialog.tsx +++ /dev/null @@ -1,88 +0,0 @@ -'use client' - -import { useConfirmNavigationContext } from '@/app/admin/_components/ConfirmNavigation' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle -} from '@/components/shadcn/alert-dialog' -import { Button } from '@/components/shadcn/button' -import { CREATE_PROBLEM } from '@/graphql/problem/mutations' -import { useMutation } from '@apollo/client' -import type { CreateProblemInput } from '@generated/graphql' -import { useRouter } from 'next/navigation' -import { useFormContext } from 'react-hook-form' -import { toast } from 'sonner' - -interface CreateProblemAlertDialogProps { - open: boolean - onClose: () => void -} - -export default function CreateProblemAlertDialog({ - open, - onClose -}: CreateProblemAlertDialogProps) { - const { setShouldSkipWarning } = useConfirmNavigationContext() - const router = useRouter() - const methods = useFormContext() - - const [createProblem, { loading }] = useMutation(CREATE_PROBLEM, { - onError: () => { - toast.error('Failed to create problem') - }, - onCompleted: () => { - setShouldSkipWarning(true) - toast.success('Problem created successfully') - router.push('/admin/problem') - router.refresh() - } - }) - - const onSubmit = async () => { - const input = methods.getValues() - await createProblem({ - variables: { - groupId: 1, - input - } - }) - } - - return ( - - - - Create Problem? - - Once this problem is included in a contest and a user submit any -
- code in the contest, testcases or time/memory limit{' '} - cannot be -
- modified. -
-
- - - Cancel - - - - - -
-
- ) -} diff --git a/apps/frontend/app/admin/problem/create/_components/CreateProblemForm.tsx b/apps/frontend/app/admin/problem/create/_components/CreateProblemForm.tsx index d14449c355..02515a17a6 100644 --- a/apps/frontend/app/admin/problem/create/_components/CreateProblemForm.tsx +++ b/apps/frontend/app/admin/problem/create/_components/CreateProblemForm.tsx @@ -1,13 +1,17 @@ 'use client' +import { useConfirmNavigationContext } from '@/app/admin/_components/ConfirmNavigation' import { createSchema } from '@/app/admin/problem/_libs/schemas' +import { CREATE_PROBLEM } from '@/graphql/problem/mutations' +import { useMutation } from '@apollo/client' import { Level, type CreateProblemInput } from '@generated/graphql' import { zodResolver } from '@hookform/resolvers/zod' +import { useRouter } from 'next/navigation' import { useState, type ReactNode } from 'react' import { FormProvider, useForm } from 'react-hook-form' +import { toast } from 'sonner' import { CautionDialog } from '../../_components/CautionDialog' import { validateScoreWeight } from '../../_libs/utils' -import CreateProblemAlertDialog from './CreateProblemAlertDialog' interface CreateProblemFormProps { children: ReactNode @@ -36,7 +40,21 @@ export default function CreateProblemForm({ const [message, setMessage] = useState('') const [showCautionModal, setShowCautionModal] = useState(false) - const [showCreateModal, setShowCreateModal] = useState(false) + + const { setShouldSkipWarning } = useConfirmNavigationContext() + const router = useRouter() + + const [createProblem] = useMutation(CREATE_PROBLEM, { + onError: () => { + toast.error('Failed to create problem') + }, + onCompleted: () => { + setShouldSkipWarning(true) + toast.success('Problem created successfully') + router.push('/admin/problem') + router.refresh() + } + }) const validate = () => { const testcases = methods.getValues('testcases') @@ -50,21 +68,21 @@ export default function CreateProblemForm({ return true } - const onSubmit = methods.handleSubmit(() => { + const onSubmit = methods.handleSubmit(async () => { if (!validate()) return - setShowCreateModal(true) + const input = methods.getValues() + await createProblem({ + variables: { + groupId: 1, + input + } + }) }) return ( <>
- - {children} - setShowCreateModal(false)} - /> - + {children}