From a8a928a1851c81858b42aa0b3b0262b3650c6a6a Mon Sep 17 00:00:00 2001 From: Eunbi Kang Date: Sun, 3 Nov 2024 18:23:48 +0900 Subject: [PATCH] refactor(fe): replace data table admin component to new components (1) (#2176) * refactor(fe): refactor admin user table * refactor(fe): refactor admin problem table * refactor(fe): refactor admin contest table * refactor(fe): refactor contest problem table and import problem table * chore(fe): fix admin page layout * fix(fe): reset page index when the filter is changed * chore(fe): add error toast message * chore(fe): separate component file * fix(fe): force to trigger sorting fn when the row selection is changed --- .../table/DataTableMultiSelectFilter.tsx | 3 + .../table/DataTableProblemFilter.tsx | 1 + .../app/admin/contest/[id]/edit/page.tsx | 36 ++-- .../ContestProblemColumns.tsx} | 38 ++-- .../_components/ContestProblemTable.tsx | 33 ++++ .../contest/_components/ContestTable.tsx | 86 +++++++++ .../{Columns.tsx => ContestTableColumns.tsx} | 5 +- .../_components/DuplicateContestButton.tsx | 166 ++++++++++++++++ .../_components/ImportProblemButton.tsx | 51 +++++ .../_components/ImportProblemTable.tsx | 177 +++++++----------- .../_components/ImportProblemTableColumns.tsx | 31 ++- .../app/admin/contest/create/page.tsx | 36 ++-- apps/frontend/app/admin/contest/page.tsx | 83 ++------ apps/frontend/app/admin/layout.tsx | 6 +- .../problem/_components/ContainedContests.tsx | 30 ++- .../problem/_components/ProblemTable.tsx | 66 +++++++ .../{Columns.tsx => ProblemTableColumns.tsx} | 15 +- .../_components/ProblemsDeleteButton.tsx | 58 ++++++ .../problem/_components/UploadDialog.tsx | 21 +-- apps/frontend/app/admin/problem/page.tsx | 115 +++--------- .../app/admin/user/_components/Columns.tsx | 1 + .../app/admin/user/_components/UserTable.tsx | 35 ++++ apps/frontend/app/admin/user/page.tsx | 66 +------ 23 files changed, 739 insertions(+), 420 deletions(-) rename apps/frontend/app/admin/contest/{[id]/_components/Columns.tsx => _components/ContestProblemColumns.tsx} (86%) create mode 100644 apps/frontend/app/admin/contest/_components/ContestProblemTable.tsx create mode 100644 apps/frontend/app/admin/contest/_components/ContestTable.tsx rename apps/frontend/app/admin/contest/_components/{Columns.tsx => ContestTableColumns.tsx} (97%) create mode 100644 apps/frontend/app/admin/contest/_components/DuplicateContestButton.tsx create mode 100644 apps/frontend/app/admin/contest/_components/ImportProblemButton.tsx create mode 100644 apps/frontend/app/admin/problem/_components/ProblemTable.tsx rename apps/frontend/app/admin/problem/_components/{Columns.tsx => ProblemTableColumns.tsx} (91%) create mode 100644 apps/frontend/app/admin/problem/_components/ProblemsDeleteButton.tsx create mode 100644 apps/frontend/app/admin/user/_components/UserTable.tsx diff --git a/apps/frontend/app/admin/_components/table/DataTableMultiSelectFilter.tsx b/apps/frontend/app/admin/_components/table/DataTableMultiSelectFilter.tsx index a36f47e7ab..64364e6436 100644 --- a/apps/frontend/app/admin/_components/table/DataTableMultiSelectFilter.tsx +++ b/apps/frontend/app/admin/_components/table/DataTableMultiSelectFilter.tsx @@ -17,6 +17,7 @@ import { Separator } from '@/components/ui/separator' import type { Column } from '@tanstack/react-table' import type { ReactNode } from 'react' import { IoFilter } from 'react-icons/io5' +import { useDataTable } from './context' interface DataTableMultiSelectFilterProps { column?: Column @@ -45,6 +46,7 @@ export default function DataTableMultiSelectFilter({ options, emptyMessage }: DataTableMultiSelectFilterProps) { + const { table } = useDataTable() const selectedValues = getSelectedValues(column?.getFilterValue()) return ( @@ -109,6 +111,7 @@ export default function DataTableMultiSelectFilter({ column?.setFilterValue( filterValues.length ? filterValues : undefined ) + table.resetPageIndex() }} > diff --git a/apps/frontend/app/admin/_components/table/DataTableProblemFilter.tsx b/apps/frontend/app/admin/_components/table/DataTableProblemFilter.tsx index b04b2075c8..a70105a688 100644 --- a/apps/frontend/app/admin/_components/table/DataTableProblemFilter.tsx +++ b/apps/frontend/app/admin/_components/table/DataTableProblemFilter.tsx @@ -84,6 +84,7 @@ export default function DataTableProblemFilter({ onSelect={() => { column?.setFilterValue(value) setOpen(false) + table.resetPageIndex() }} >

diff --git a/apps/frontend/app/admin/contest/[id]/edit/page.tsx b/apps/frontend/app/admin/contest/[id]/edit/page.tsx index 2a39db2ebf..4d594dff7d 100644 --- a/apps/frontend/app/admin/contest/[id]/edit/page.tsx +++ b/apps/frontend/app/admin/contest/[id]/edit/page.tsx @@ -5,7 +5,6 @@ import DescriptionForm from '@/app/admin/_components/DescriptionForm' import FormSection from '@/app/admin/_components/FormSection' import SwitchField from '@/app/admin/_components/SwitchField' import TitleForm from '@/app/admin/_components/TitleForm' -import { DataTableAdmin } from '@/components/DataTableAdmin' import { AlertDialog, AlertDialogTrigger, @@ -40,16 +39,19 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PlusCircleIcon } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' -import { useRef, useState } from 'react' +import { Suspense, useRef, useState } from 'react' import { FormProvider, useForm } from 'react-hook-form' import { FaAngleLeft } from 'react-icons/fa6' import { IoIosCheckmarkCircle } from 'react-icons/io' import { toast } from 'sonner' import ContestProblemListLabel from '../../_components/ContestProblemListLabel' -import ImportProblemTable from '../../_components/ImportProblemTable' +import ContestProblemTable from '../../_components/ContestProblemTable' +import { + ImportProblemTable, + ImportProblemTableFallback +} from '../../_components/ImportProblemTable' import TimeForm from '../../_components/TimeForm' import { type ContestProblem, editSchema } from '../../utils' -import { columns } from '../_components/Columns' export default function Page({ params }: { params: { id: string } }) { const [prevProblemIds, setPrevProblemIds] = useState([]) @@ -305,22 +307,22 @@ export default function Page({ params }: { params: { id: string } }) { Import Problem - - setProblems(problems as ContestProblem[]) - } - onCloseDialog={() => setShowImportDialog(false)} - /> + }> + { + setProblems(problems) + setShowImportDialog(false) + }} + /> + - + + +

Select only one contest to duplicate

+ + + + ) +} + +function EnabledDuplicateButton({ + contestId, + contestStatus +}: { + contestId: number + contestStatus: string +}) { + const { table } = useDataTable() + + const client = useApolloClient() + const [duplicateContest] = useMutation(DUPLICATE_CONTEST) + + const duplicateContestById = async () => { + const toastId = toast.loading('Duplicating contest...') + + duplicateContest({ + variables: { + groupId: 1, + contestId + }, + onCompleted: (data) => { + toast.success( + `Contest duplicated completed.\n Duplicated contest title: ${data.duplicateContest.contest.title}`, + { + id: toastId + } + ) + client.refetchQueries({ + include: [GET_CONTESTS] + }) + table.resetRowSelection() + }, + onError: () => { + toast.error('Failed to duplicate the contest') + } + }) + } + + return ( + + + + + + + + Duplicate {contestStatus === 'ongoing' ? 'Ongoing ' : ''}Contest + + +

Contents That Will Be Copied:

+
    +
  • Title
  • +
  • Start Time & End Time
  • +
  • Description
  • +
  • + Contest Security Settings (invitation code, allow copy/paste) +
  • +
  • Contest Problems
  • +
  • + Participants of the selected contest
    + + (All participants of the selected contest will be + automatically registered for the duplicated contest.) + +
  • +
+

Contents That Will Not Be Copied:

+
    +
  • Users' Submissions
  • +
+ {contestStatus === 'ongoing' ? ( +

+ Caution: The new contest will be set to visible. +

+ ) : ( +

+ Caution: The new contest will be set to invisible +

+ )} +

+ Are you sure you want to proceed with duplicating the selected + contest? +

+
+
+ + + Cancel + + + Duplicate + + +
+
+ ) +} diff --git a/apps/frontend/app/admin/contest/_components/ImportProblemButton.tsx b/apps/frontend/app/admin/contest/_components/ImportProblemButton.tsx new file mode 100644 index 0000000000..bb463e81b7 --- /dev/null +++ b/apps/frontend/app/admin/contest/_components/ImportProblemButton.tsx @@ -0,0 +1,51 @@ +import { Button } from '@/components/ui/button' +import { useDataTable } from '../../_components/table/context' +import type { ContestProblem } from '../utils' +import type { DataTableProblem } from './ImportProblemTableColumns' + +interface ImportProblemButtonProps { + onSelectedExport: (data: ContestProblem[]) => void +} + +export default function ImportProblemButton({ + onSelectedExport +}: ImportProblemButtonProps) { + const { table } = useDataTable() + + const handleImportProblems = () => { + const selectedRows = table + .getSelectedRowModel() + .rows.map((row) => row.original) + + const problems = selectedRows + .map((problem) => ({ + ...problem, + score: problem?.score ?? 0, + order: problem?.order ?? Number.MAX_SAFE_INTEGER + })) + .sort((a, b) => a.order - b.order) + + let order = 0 + const exportedProblems = problems.map((problem, index, arr) => { + if ( + index > 0 && + // NOTE: 만약 현재 요소가 새로 추가된 문제이거나 새로 추가된 문제가 아니라면 이전 문제와 기존 순서가 다를 때 + (arr[index].order === Number.MAX_SAFE_INTEGER || + arr[index - 1].order !== arr[index].order) + ) { + order++ + } + return { + ...problem, + order + } + }) + onSelectedExport(exportedProblems) + } + + return ( + + ) +} diff --git a/apps/frontend/app/admin/contest/_components/ImportProblemTable.tsx b/apps/frontend/app/admin/contest/_components/ImportProblemTable.tsx index 72e2682019..4bc3869b9c 100644 --- a/apps/frontend/app/admin/contest/_components/ImportProblemTable.tsx +++ b/apps/frontend/app/admin/contest/_components/ImportProblemTable.tsx @@ -1,35 +1,31 @@ -import { DataTableAdmin } from '@/components/DataTableAdmin' -import { Skeleton } from '@/components/ui/skeleton' import { GET_PROBLEMS } from '@/graphql/problem/queries' -import { useQuery } from '@apollo/client' +import { useSuspenseQuery } from '@apollo/client' import { Language, Level } from '@generated/graphql' -import { columns } from './ImportProblemTableColumns' +import { toast } from 'sonner' +import DataTable from '../../_components/table/DataTable' +import DataTableFallback from '../../_components/table/DataTableFallback' +import DataTableLangFilter from '../../_components/table/DataTableLangFilter' +import DataTableLevelFilter from '../../_components/table/DataTableLevelFilter' +import DataTablePagination from '../../_components/table/DataTablePagination' +import DataTableRoot from '../../_components/table/DataTableRoot' +import DataTableSearchBar from '../../_components/table/DataTableSearchBar' +import type { ContestProblem } from '../utils' +import ImportProblemButton from './ImportProblemButton' +import { + columns, + DEFAULT_PAGE_SIZE, + ERROR_MESSAGE, + MAX_SELECTED_ROW_COUNT +} from './ImportProblemTableColumns' -interface ContestProblem { - id: number - title: string - difficulty: string - score: number - order: number -} - -interface OrderContestProblem { - id: number - title: string - difficulty: string - order: number -} - -export default function ImportProblemTable({ +export function ImportProblemTable({ checkedProblems, - onSelectedExport, - onCloseDialog + onSelectedExport }: { checkedProblems: ContestProblem[] - onSelectedExport: (selectedRows: OrderContestProblem[]) => void - onCloseDialog: () => void + onSelectedExport: (selectedRows: ContestProblem[]) => void }) { - const { data, loading } = useQuery(GET_PROBLEMS, { + const { data } = useSuspenseQuery(GET_PROBLEMS, { variables: { groupId: 1, take: 500, @@ -46,87 +42,58 @@ export default function ImportProblemTable({ } }) - const problems = - data?.getProblems.map((problem) => ({ - ...problem, - id: Number(problem.id), - isVisible: problem.isVisible !== undefined ? problem.isVisible : null, - languages: problem.languages ?? [], - tag: problem.tag.map(({ id, tag }) => ({ - id: +id, - tag: { - ...tag, - id: +tag.id - } - })), - score: checkedProblems.find((item) => item.id === Number(problem.id)) - ?.score - })) ?? [] - - return ( - <> - {loading ? ( - <> -
- - - - - - - - - -
- {[...Array(10)].map((_, i) => ( - - ))} - - ) : ( - { - onCloseDialog() + const problems = data.getProblems.map((problem) => ({ + ...problem, + id: Number(problem.id), + isVisible: problem.isVisible !== undefined ? problem.isVisible : null, + languages: problem.languages ?? [], + tag: problem.tag.map(({ id, tag }) => ({ + id: +id, + tag: { + ...tag, + id: Number(tag.id) + } + })), + score: checkedProblems.find((item) => item.id === Number(problem.id)) + ?.score, + order: checkedProblems.find((item) => item.id === Number(problem.id))?.order + })) - const problemsWithOrder = problems - .map((problem) => ({ - ...problem, - order: - checkedProblems.find((item) => item.id === problem.id) - ?.order ?? Number.MAX_SAFE_INTEGER - })) - .sort((a, b) => a.order - b.order) + const selectedProblemIds = checkedProblems.map((problem) => problem.id) - let order = 0 - const exportedProblems = problemsWithOrder.map( - (problem, index, arr) => { - if ( - index > 0 && - // NOTE: 만약 현재 요소가 새로 추가된 문제이거나 새로 추가된 문제가 아니라면 이전 문제와 기존 순서가 다를 때 - (arr[index].order === Number.MAX_SAFE_INTEGER || - arr[index - 1].order !== arr[index].order) - ) { - order++ - } - return { - ...problem, - order - } - } - ) - onSelectedExport(exportedProblems) - }} - defaultPageSize={5} - /> - )} - + return ( + +
+ + + + +
+ { + const selectedRowCount = table.getSelectedRowModel().rows.length + if ( + selectedRowCount < MAX_SELECTED_ROW_COUNT || + row.getIsSelected() + ) { + row.toggleSelected() + table.setSorting([{ id: 'select', desc: true }]) // NOTE: force to trigger sortingFn + } else { + toast.error(ERROR_MESSAGE) + } + }} + /> + +
) } + +export function ImportProblemTableFallback() { + return +} diff --git a/apps/frontend/app/admin/contest/_components/ImportProblemTableColumns.tsx b/apps/frontend/app/admin/contest/_components/ImportProblemTableColumns.tsx index 5364d595fb..8bcce0f9f6 100644 --- a/apps/frontend/app/admin/contest/_components/ImportProblemTableColumns.tsx +++ b/apps/frontend/app/admin/contest/_components/ImportProblemTableColumns.tsx @@ -1,11 +1,11 @@ -import { DataTableColumnHeader } from '@/components/DataTableColumnHeader' +import DataTableColumnHeader from '@/app/admin/_components/table/DataTableColumnHeader' import { Badge } from '@/components/ui/badge' import { Checkbox } from '@/components/ui/checkbox' import type { Level } from '@/types/type' import type { ColumnDef } from '@tanstack/react-table' import { toast } from 'sonner' -interface DataTableProblem { +export interface DataTableProblem { id: number title: string updateTime: string @@ -14,22 +14,33 @@ interface DataTableProblem { acceptedRate: number languages: string[] score?: number + order?: number } +export const DEFAULT_PAGE_SIZE = 5 +export const MAX_SELECTED_ROW_COUNT = 20 +export const ERROR_MESSAGE = `You can only import up to ${MAX_SELECTED_ROW_COUNT} problems in a contest` export const columns: ColumnDef[] = [ { accessorKey: 'select', header: ({ table }) => ( { - const currentPageRows = table.getRowModel().rows - const currentSelectedCount = currentPageRows.filter((row) => - row.getIsSelected() - ).length - table.getSelectedRowModel().rows.length - currentSelectedCount > 15 - ? toast.error('You can only import up to 20 problems in a contest') - : table.toggleAllPageRowsSelected(!!value) + onCheckedChange={() => { + const currentPageNotSelectedCount = table + .getRowModel() + .rows.filter((row) => !row.getIsSelected()).length + const selectedRowCount = table.getSelectedRowModel().rows.length + + if ( + selectedRowCount + currentPageNotSelectedCount <= + MAX_SELECTED_ROW_COUNT + ) { + table.toggleAllPageRowsSelected() + table.setSorting([{ id: 'select', desc: true }]) // NOTE: force to trigger sortingFn + } else { + toast.error(ERROR_MESSAGE) + } }} aria-label="Select all" className="translate-y-[2px]" diff --git a/apps/frontend/app/admin/contest/create/page.tsx b/apps/frontend/app/admin/contest/create/page.tsx index 7ac3c2195b..2c8698c906 100644 --- a/apps/frontend/app/admin/contest/create/page.tsx +++ b/apps/frontend/app/admin/contest/create/page.tsx @@ -1,6 +1,5 @@ 'use client' -import { DataTableAdmin } from '@/components/DataTableAdmin' import { AlertDialog, AlertDialogTrigger, @@ -31,7 +30,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PlusCircleIcon } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' -import { useState, useRef } from 'react' +import { useState, useRef, Suspense } from 'react' import { useForm, FormProvider } from 'react-hook-form' import { FaAngleLeft } from 'react-icons/fa6' import { IoMdCheckmarkCircleOutline } from 'react-icons/io' @@ -41,9 +40,12 @@ import DescriptionForm from '../../_components/DescriptionForm' import FormSection from '../../_components/FormSection' import SwitchField from '../../_components/SwitchField' import TitleForm from '../../_components/TitleForm' -import { columns } from '../[id]/_components/Columns' import ContestProblemListLabel from '../_components/ContestProblemListLabel' -import ImportProblemTable from '../_components/ImportProblemTable' +import ContestProblemTable from '../_components/ContestProblemTable' +import { + ImportProblemTable, + ImportProblemTableFallback +} from '../_components/ImportProblemTable' import TimeForm from '../_components/TimeForm' import { type ContestProblem, createSchema } from '../utils' @@ -230,22 +232,22 @@ export default function Page() { Import Problem - - setProblems(problems as ContestProblem[]) - } - onCloseDialog={() => setShowImportDialog(false)} - /> + }> + { + setProblems(problems) + setShowImportDialog(false) + }} + /> + - +
+
+
+

Contest List

+

Here's a list you made

- {loading ? ( - <> -
- - - -
- {[...Array(8)].map((_, i) => ( - - ))} - - ) : ( - - )} +
- + }> + + +
) } diff --git a/apps/frontend/app/admin/layout.tsx b/apps/frontend/app/admin/layout.tsx index 01b1cf2fba..447f5f17ad 100644 --- a/apps/frontend/app/admin/layout.tsx +++ b/apps/frontend/app/admin/layout.tsx @@ -32,8 +32,10 @@ export default function Layout({ children }: { children: React.ReactNode }) { */} - -
{children}
+ {/*NOTE: full width - sidebar width */} +
+ {children} +
) diff --git a/apps/frontend/app/admin/problem/_components/ContainedContests.tsx b/apps/frontend/app/admin/problem/_components/ContainedContests.tsx index ac44ddbc2d..09421ec71e 100644 --- a/apps/frontend/app/admin/problem/_components/ContainedContests.tsx +++ b/apps/frontend/app/admin/problem/_components/ContainedContests.tsx @@ -4,14 +4,16 @@ import { DialogContent, DialogHeader } from '@/components/ui/dialog' +import { Skeleton } from '@/components/ui/skeleton' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { GET_BELONGED_CONTESTS } from '@/graphql/contest/queries' import FileInfoIcon from '@/public/24_compile.svg' -import type { GetContestsByProblemIdQuery } from '@generated/graphql' +import { useQuery } from '@apollo/client' import * as TooltipPrimitive from '@radix-ui/react-tooltip' import Image from 'next/image' import { useState } from 'react' @@ -38,20 +40,31 @@ function ContestSection({ } export default function ContainedContests({ - data + problemId }: { - data: GetContestsByProblemIdQuery + problemId: number }) { const [isTooltipOpen, setIsTooltipOpen] = useState(false) - const contestData = data.getContestsByProblemId + const { data, loading } = useQuery(GET_BELONGED_CONTESTS, { + variables: { + problemId + } + }) - return ( + const contestData = data?.getContestsByProblemId + + if (loading) { + return + } + + return contestData ? ( setIsTooltipOpen(false)}> - ) + ) : null } diff --git a/apps/frontend/app/admin/problem/_components/ProblemTable.tsx b/apps/frontend/app/admin/problem/_components/ProblemTable.tsx new file mode 100644 index 0000000000..8a3e62acc3 --- /dev/null +++ b/apps/frontend/app/admin/problem/_components/ProblemTable.tsx @@ -0,0 +1,66 @@ +import { GET_PROBLEMS } from '@/graphql/problem/queries' +import { useSuspenseQuery } from '@apollo/client' +import { Language, Level } from '@generated/graphql' +import DataTable from '../../_components/table/DataTable' +import DataTableFallback from '../../_components/table/DataTableFallback' +import DataTableLangFilter from '../../_components/table/DataTableLangFilter' +import DataTableLevelFilter from '../../_components/table/DataTableLevelFilter' +import DataTablePagination from '../../_components/table/DataTablePagination' +import DataTableRoot from '../../_components/table/DataTableRoot' +import DataTableSearchBar from '../../_components/table/DataTableSearchBar' +import { columns } from './ProblemTableColumns' +import ProblemsDeleteButton from './ProblemsDeleteButton' + +export function ProblemTable() { + const { data } = useSuspenseQuery(GET_PROBLEMS, { + variables: { + groupId: 1, + take: 500, + input: { + difficulty: [ + Level.Level1, + Level.Level2, + Level.Level3, + Level.Level4, + Level.Level5 + ], + languages: [Language.C, Language.Cpp, Language.Java, Language.Python3] + } + } + }) + + const problems = data.getProblems.map((problem) => ({ + ...problem, + id: Number(problem.id), + isVisible: problem.isVisible !== undefined ? problem.isVisible : null, + languages: problem.languages ?? [], + tag: problem.tag.map(({ id, tag }) => ({ + id: +id, + tag: { + ...tag, + id: Number(tag.id) + } + })) + })) + + return ( + +
+ + + + +
+ `/admin/problem/${data.id}`} /> + +
+ ) +} + +export function ProblemTableFallback() { + return +} diff --git a/apps/frontend/app/admin/problem/_components/Columns.tsx b/apps/frontend/app/admin/problem/_components/ProblemTableColumns.tsx similarity index 91% rename from apps/frontend/app/admin/problem/_components/Columns.tsx rename to apps/frontend/app/admin/problem/_components/ProblemTableColumns.tsx index b797075752..92ee372413 100644 --- a/apps/frontend/app/admin/problem/_components/Columns.tsx +++ b/apps/frontend/app/admin/problem/_components/ProblemTableColumns.tsx @@ -1,11 +1,10 @@ -import { DataTableColumnHeader } from '@/components/DataTableColumnHeader' +import DataTableColumnHeader from '@/app/admin/_components/table/DataTableColumnHeader' import { Badge } from '@/components/ui/badge' import { Checkbox } from '@/components/ui/checkbox' import { Switch } from '@/components/ui/switch' -import { GET_BELONGED_CONTESTS } from '@/graphql/contest/queries' import { UPDATE_PROBLEM_VISIBLE } from '@/graphql/problem/mutations' import type { Level } from '@/types/type' -import { useMutation, useQuery } from '@apollo/client' +import { useMutation } from '@apollo/client' import type { ColumnDef, Row } from '@tanstack/react-table' import ContainedContests from './ContainedContests' @@ -14,7 +13,7 @@ interface Tag { name: string } -interface DataTableProblem { +export interface DataTableProblem { id: number title: string updateTime: string @@ -28,11 +27,7 @@ interface DataTableProblem { function VisibleCell({ row }: { row: Row }) { const [updateVisible] = useMutation(UPDATE_PROBLEM_VISIBLE) - const contestData = useQuery(GET_BELONGED_CONTESTS, { - variables: { - problemId: Number(row.original.id) - } - }).data + return (
}) { }) }} /> - {contestData && } +
) } diff --git a/apps/frontend/app/admin/problem/_components/ProblemsDeleteButton.tsx b/apps/frontend/app/admin/problem/_components/ProblemsDeleteButton.tsx new file mode 100644 index 0000000000..70e0d5f65d --- /dev/null +++ b/apps/frontend/app/admin/problem/_components/ProblemsDeleteButton.tsx @@ -0,0 +1,58 @@ +import { GET_BELONGED_CONTESTS } from '@/graphql/contest/queries' +import { DELETE_PROBLEM } from '@/graphql/problem/mutations' +import { GET_PROBLEMS } from '@/graphql/problem/queries' +import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client' +import { toast } from 'sonner' +import DataTableDeleteButton from '../../_components/table/DataTableDeleteButton' +import type { DataTableProblem } from './ProblemTableColumns' + +export default function ProblemsDeleteButton() { + const client = useApolloClient() + const [deleteProblem] = useMutation(DELETE_PROBLEM) + const [fetchContests] = useLazyQuery(GET_BELONGED_CONTESTS) + + const getCanDelete = async (data: DataTableProblem[]) => { + const promises = data.map((item) => + fetchContests({ + variables: { + problemId: Number(item.id) + } + }) + ) + + const results = await Promise.all(promises) + const isAllSafe = results.every(({ data }) => data === undefined) + + if (isAllSafe) { + return true + } + + toast.error('Failed: Problem included in the contest') + return false + } + + const deleteTarget = (id: number) => { + return deleteProblem({ + variables: { + groupId: 1, + id + } + }) + } + + const onSuccess = () => { + client.refetchQueries({ + include: [GET_PROBLEMS] + }) + } + + return ( + + ) +} diff --git a/apps/frontend/app/admin/problem/_components/UploadDialog.tsx b/apps/frontend/app/admin/problem/_components/UploadDialog.tsx index 6f25168e63..aa056dbbd4 100644 --- a/apps/frontend/app/admin/problem/_components/UploadDialog.tsx +++ b/apps/frontend/app/admin/problem/_components/UploadDialog.tsx @@ -6,7 +6,8 @@ import { DialogTrigger } from '@/components/ui/dialog' import { UPLOAD_PROBLEMS } from '@/graphql/problem/mutations' -import { useMutation } from '@apollo/client' +import { GET_PROBLEMS } from '@/graphql/problem/queries' +import { useApolloClient, useMutation } from '@apollo/client' import { UploadIcon, UploadCloudIcon } from 'lucide-react' import { useRef, useState } from 'react' import { createPortal } from 'react-dom' @@ -14,11 +15,8 @@ import { RiFileExcel2Fill } from 'react-icons/ri' import { useDrop } from 'react-use' import { toast } from 'sonner' -interface Props { - refetch: () => Promise -} - -export default function UploadDialog({ refetch }: Props) { +export default function UploadDialog() { + const client = useApolloClient() const [file, setFile] = useState(null) const fileRef = useRef(null) @@ -62,14 +60,15 @@ export default function UploadDialog({ refetch }: Props) { } } }) + toast.success('File uploaded successfully') + document.getElementById('closeDialog')?.click() + resetFile() + client.refetchQueries({ + include: [GET_PROBLEMS] + }) } catch (error) { toast.error('Failed to upload file') - return } - toast.success('File uploaded successfully') - document.getElementById('closeDialog')?.click() - resetFile() - await refetch() } return ( diff --git a/apps/frontend/app/admin/problem/page.tsx b/apps/frontend/app/admin/problem/page.tsx index 7f1434ca37..a46009e190 100644 --- a/apps/frontend/app/admin/problem/page.tsx +++ b/apps/frontend/app/admin/problem/page.tsx @@ -1,6 +1,5 @@ 'use client' -import { DataTableAdmin } from '@/components/DataTableAdmin' import { Button } from '@/components/ui/button' import { Dialog, @@ -11,15 +10,10 @@ import { DialogTitle, DialogClose } from '@/components/ui/dialog' -import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' -import { Skeleton } from '@/components/ui/skeleton' -import { GET_PROBLEMS } from '@/graphql/problem/queries' -import { useQuery } from '@apollo/client' -import { Language, Level } from '@generated/graphql' import { PlusCircleIcon } from 'lucide-react' import Link from 'next/link' -import { useState, useEffect } from 'react' -import { columns } from './_components/Columns' +import { useState, useEffect, Suspense } from 'react' +import { ProblemTable, ProblemTableFallback } from './_components/ProblemTable' import UploadDialog from './_components/UploadDialog' export default function Page({ @@ -36,58 +30,9 @@ export default function Page({ }, [searchParams.import]) const importProblem = searchParams.import - const { data, loading, refetch } = useQuery(GET_PROBLEMS, { - variables: { - groupId: 1, - take: 500, - input: { - difficulty: [ - Level.Level1, - Level.Level2, - Level.Level3, - Level.Level4, - Level.Level5 - ], - languages: [Language.C, Language.Cpp, Language.Java, Language.Python3] - } - } - }) - - const problems = - data?.getProblems.map((problem) => ({ - ...problem, - id: Number(problem.id), - isVisible: problem.isVisible !== undefined ? problem.isVisible : null, - languages: problem.languages ?? [], - tag: problem.tag.map(({ id, tag }) => ({ - id: +id, - tag: { - ...tag, - id: +tag.id - } - })) - })) ?? [] return ( - - - - - Import problem list - - When importing problems from the problem list to the contest, - selected problems is automatically set to 'not visible.' - - - - - - - - - + <>
@@ -98,7 +43,7 @@ export default function Page({
{importProblem ? null : (
- +
)}
- {loading ? ( - <> -
- - - - - - - - - -
- {[...Array(10)].map((_, i) => ( - - ))} - - ) : ( - - )} + }> + +
- -
+ + + + Import problem list + + When importing problems from the problem list to the contest, + selected problems is automatically set to 'not visible.' + + + + + + + + + + ) } diff --git a/apps/frontend/app/admin/user/_components/Columns.tsx b/apps/frontend/app/admin/user/_components/Columns.tsx index 1072c6ef5a..7ffd68d0d5 100644 --- a/apps/frontend/app/admin/user/_components/Columns.tsx +++ b/apps/frontend/app/admin/user/_components/Columns.tsx @@ -1,6 +1,7 @@ import type { ColumnDef } from '@tanstack/react-table' interface DataTableUser { + id: number username: string userId: number name: string diff --git a/apps/frontend/app/admin/user/_components/UserTable.tsx b/apps/frontend/app/admin/user/_components/UserTable.tsx new file mode 100644 index 0000000000..0d5f504a0e --- /dev/null +++ b/apps/frontend/app/admin/user/_components/UserTable.tsx @@ -0,0 +1,35 @@ +'use client' + +import { GET_GROUP_MEMBERS } from '@/graphql/user/queries' +import { useSuspenseQuery } from '@apollo/client' +import DataTable from '../../_components/table/DataTable' +import DataTableFallback from '../../_components/table/DataTableFallback' +import DataTablePagination from '../../_components/table/DataTablePagination' +import DataTableRoot from '../../_components/table/DataTableRoot' +import { columns } from './Columns' + +export function UserTable() { + const { data } = useSuspenseQuery(GET_GROUP_MEMBERS, { + variables: { + groupId: 1, + cursor: 1, + take: 1000, + leaderOnly: false + } + }) + const users = data.getGroupMembers.map((member) => ({ + ...member, + id: member.userId + })) + + return ( + + + + + ) +} + +export function UserTableFallback() { + return +} diff --git a/apps/frontend/app/admin/user/page.tsx b/apps/frontend/app/admin/user/page.tsx index 9aa4061971..ec404d7829 100644 --- a/apps/frontend/app/admin/user/page.tsx +++ b/apps/frontend/app/admin/user/page.tsx @@ -1,63 +1,17 @@ -'use client' +import { Suspense } from 'react' +import { UserTable, UserTableFallback } from './_components/UserTable' -import { DataTableAdmin } from '@/components/DataTableAdmin' -import { ScrollBar } from '@/components/ui/scroll-area' -import { Skeleton } from '@/components/ui/skeleton' -import { GET_GROUP_MEMBERS } from '@/graphql/user/queries' -import { useQuery } from '@apollo/client' -import { ScrollArea } from '@radix-ui/react-scroll-area' -import { columns } from './_components/Columns' +export const dynamic = 'force-dynamic' export default function User() { - const { data, loading } = useQuery(GET_GROUP_MEMBERS, { - variables: { - groupId: 1, - cursor: 1, - take: 1000, - leaderOnly: false - } - }) return ( - -
-
-

User List

-
- {loading ? ( - <> -
- - - - - - - - - -
- {[...Array(10)].map((_, i) => ( - - ))} - - ) : ( - { - return { - username: member.username, - userId: member.userId, - name: member.name, - email: member.email - } - }) ?? [] - } - enablePagination - /> - )} +
+
+

User List

- - + }> + + +
) }