diff --git a/src/app/api/v1/sets/[id]/route.ts b/src/app/api/v1/sets/[id]/route.ts index 4b8b18b4..debb88ab 100644 --- a/src/app/api/v1/sets/[id]/route.ts +++ b/src/app/api/v1/sets/[id]/route.ts @@ -3,6 +3,13 @@ import { zProblemSet } from "common/validation/problem_set_validation"; import { canManageProblemSets } from "server/authorization"; import { getSession } from "server/sessions"; import { updateProblemSet } from "server/logic/problem_sets/update_problem_set"; +import { NextContext } from "types/nextjs"; +import { NestedLookupDTO } from "common/types"; +import { lookupSetFromSlugOrId } from "./utils"; + +type RouteParams = { + id: string; +}; export async function PUT(request: NextRequest) { const session = await getSession(request); @@ -12,6 +19,7 @@ export async function PUT(request: NextRequest) { const data = await request.json(); const parsed = zProblemSet.safeParse(data); + if (parsed.success) { const contest = await updateProblemSet(parsed.data); return NextResponse.json(contest); @@ -19,3 +27,24 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ error: parsed.error.format() }, { status: 400 }); } } + +export async function GET(request: NextRequest, context: NextContext) { + const session = await getSession(request); + if (!canManageProblemSets(session)) { + return NextResponse.json({}, { status: 403 }); + } + + // Accept any of slug, uuid, or hurado id + const slug = context.params.id; + const first = await lookupSetFromSlugOrId(slug); + if (first == null) { + return NextResponse.json(null, { status: 404 }); + } + + const dto: NestedLookupDTO = { + id: first.id, + slug: first.slug, + title: first.title, + }; + return NextResponse.json(dto); +} diff --git a/src/app/api/v1/sets/[id]/utils.ts b/src/app/api/v1/sets/[id]/utils.ts new file mode 100644 index 00000000..00724266 --- /dev/null +++ b/src/app/api/v1/sets/[id]/utils.ts @@ -0,0 +1,28 @@ +import { checkUUIDv4, huradoIDToUUID } from "common/utils/uuid"; +import { db } from "db"; + +export async function lookupSetFromSlugOrId(slug: string) { + const uuid = huradoIDToUUID(slug) ?? checkUUIDv4(slug); + + const lookups = await db + .selectFrom("problem_sets") + .where((eb) => { + if (uuid != null) { + return eb.or([eb("id", "=", uuid), eb("slug", "=", slug)]); + } else { + return eb("slug", "=", slug); + } + }) + .select(["id", "slug", "title"]) + .execute(); + + // Sort the results. Matching UUID trumps matching slug. Best result goes to lowest index. + lookups.sort((a, b) => { + if (a.id === uuid) { + return b.id === uuid ? 0 : -1; + } + return b.id === uuid ? 1 : 0; + }); + + return lookups[0]; +} diff --git a/src/app/api/v1/tasks/[id]/route.ts b/src/app/api/v1/tasks/[id]/route.ts index 1d3d9c28..cba08079 100644 --- a/src/app/api/v1/tasks/[id]/route.ts +++ b/src/app/api/v1/tasks/[id]/route.ts @@ -5,7 +5,7 @@ import { updateEditorTask } from "server/logic/tasks/update_editor_task"; import { getSession } from "server/sessions"; import { NextContext } from "types/nextjs"; import { TaskLookupDTO } from "common/types"; -import { lookupFromSlugOrId } from "./utils"; +import { lookupTaskFromSlugOrId } from "./utils"; type RouteParams = { id: string; @@ -13,7 +13,7 @@ type RouteParams = { export async function PUT(request: NextRequest) { const session = await getSession(request); - if (!canManageTasks(session, request)) { + if (!canManageTasks(session)) { return NextResponse.json({}, { status: 403 }); } @@ -29,13 +29,13 @@ export async function PUT(request: NextRequest) { export async function GET(request: NextRequest, context: NextContext) { const session = await getSession(request); - if (!canManageTasks(session, request)) { + if (!canManageTasks(session)) { return NextResponse.json({}, { status: 403 }); } // Accept any of slug, uuid, or hurado id const slug = context.params.id; - const first = await lookupFromSlugOrId(slug); + const first = await lookupTaskFromSlugOrId(slug); if (first == null) { return NextResponse.json(null, { status: 404 }); } diff --git a/src/app/api/v1/tasks/[id]/utils.ts b/src/app/api/v1/tasks/[id]/utils.ts index db1b2c92..63670605 100644 --- a/src/app/api/v1/tasks/[id]/utils.ts +++ b/src/app/api/v1/tasks/[id]/utils.ts @@ -1,7 +1,7 @@ import { checkUUIDv4, huradoIDToUUID } from "common/utils/uuid"; import { db } from "db"; -export async function lookupFromSlugOrId(slug: string) { +export async function lookupTaskFromSlugOrId(slug: string) { const uuid = huradoIDToUUID(slug) ?? checkUUIDv4(slug); const lookups = await db diff --git a/src/app/sets/[slug]/page.tsx b/src/app/sets/[slug]/page.tsx index 6df82e5b..dd5e264a 100644 --- a/src/app/sets/[slug]/page.tsx +++ b/src/app/sets/[slug]/page.tsx @@ -2,7 +2,7 @@ import { Metadata } from "next"; import { notFound } from "next/navigation"; import { cache } from "react"; // eslint-disable-next-line @typescript-eslint/no-unused-vars -- pre-existing error before eslint inclusion -import { ProblemSetViewerDTO, TaskScoredSummaryDTO, TaskSummaryDTO } from "common/types"; +import { ProblemSetSummaryDTO, ProblemSetViewerDTO, TaskScoredSummaryDTO } from "common/types"; import { db } from "db"; import { DefaultLayout } from "client/components/layouts/default_layout"; import { ProblemSetViewer } from "client/components/problem_set_viewer/problem_set_viewer"; @@ -26,6 +26,7 @@ async function getProblemSetData( const dbTasks = await trx .selectFrom("tasks") + .where("tasks.is_public", "=", true) .innerJoin("problem_set_tasks", "tasks.id", "problem_set_tasks.task_id") .orderBy(["problem_set_tasks.order", "tasks.title"]) .where("problem_set_tasks.set_id", "=", set.id) @@ -40,33 +41,58 @@ async function getProblemSetData( "tasks.slug", "tasks.title", "tasks.description", + "problem_set_tasks.order", "overall_verdicts.score_overall", "overall_verdicts.score_max", ]) .execute(); + const dbNesteds = await trx + .selectFrom("problem_sets") + .where("problem_sets.is_public", "=", true) + .innerJoin("problem_set_nesteds", "problem_sets.id", "problem_set_nesteds.child_id") + .orderBy(["problem_set_nesteds.order", "problem_sets.title"]) + .where("problem_set_nesteds.parent_id", "=", set.id) + .select([ + "problem_sets.id", + "problem_sets.slug", + "problem_sets.title", + "problem_sets.description", + "problem_set_nesteds.order", + ]) + .execute(); + const tasks: TaskScoredSummaryDTO[] = dbTasks.map((t) => ({ id: t.id, slug: t.slug, title: t.title, description: t.description, + order: t.order, score_overall: t.score_overall, score_max: t.score_max, })); + const nesteds: ProblemSetSummaryDTO[] = dbNesteds.map((s) => ({ + id: s.id, + slug: s.slug, + title: s.title, + description: s.description, + order: s.order, + })); + return { id: set.id, slug: set.slug, title: set.title, description: set.description, is_public: set.is_public, - order: set.order, tasks: tasks, + nesteds: nesteds, } satisfies ProblemSetViewerDTO; }); } -const getCachedProblemSetData = cache(getProblemSetData); +export const getCachedProblemSetData = cache(getProblemSetData); type ProblemSetPageProps = { params: { @@ -88,7 +114,7 @@ export async function generateMetadata(props: ProblemSetPageProps): Promise { - const sets = await db - .selectFrom("problem_sets") - .select(["id", "title", "slug", "description", "order"]) - .where("is_public", "=", true) - .orderBy("order", "asc") - .execute(); - - return sets; -} - -export const metadata: Metadata = { - title: "Problem Sets", -}; +import { ProblemSetPage } from "./[slug]/page"; export async function ProblemSetListPage() { - const sets = await getProblemSetsData(); - - if (sets.length == 0) { - return ( - - - - ); - } - - return ( - -
- {sets.map((set) => ( - - ))} -
-
- ); + return ; } export default ProblemSetListPage; diff --git a/src/client/components/cards/common_card.tsx b/src/client/components/cards/common_card.tsx index 10be29d8..195f0f9a 100644 --- a/src/client/components/cards/common_card.tsx +++ b/src/client/components/cards/common_card.tsx @@ -3,9 +3,16 @@ import Link from "next/link"; import classNames from "classnames"; // eslint-disable-next-line @typescript-eslint/no-unused-vars -- pre-existing error before eslint inclusion -import { ContestSummaryDTO, ProblemSetSummaryDTO, TaskScoredSummaryDTO } from "common/types"; +import { + ContestSummaryDTO, + ProblemSetSummaryDTO, + ProblemSetViewerDTO, + TaskScoredSummaryDTO, +} from "common/types"; import { getPath, Path } from "client/paths"; +const TRAIL_KEY = "problem_set_trail"; + type CommonCardProps = { url: string; title: string; @@ -25,15 +32,38 @@ export function CommonCard({ url, title, description }: CommonCardProps) { } type ProblemSetCardProps = { + source: ProblemSetViewerDTO; set: ProblemSetSummaryDTO; }; -export function ProblemSetCard({ set }: ProblemSetCardProps) { +export function ProblemSetCard({ source, set }: ProblemSetCardProps) { const url = getPath({ kind: Path.ProblemSetView, slug: set.slug }); + + const handleCardClick = () => { + const rawTrail = sessionStorage.getItem(TRAIL_KEY); + let trail = []; + + try { + if (rawTrail) { + trail = JSON.parse(rawTrail); + } + } catch { + trail = []; + } + + trail.push({ + path: getPath({ kind: Path.ProblemSetView, slug: source.slug }), + name: source.title, + }); + + sessionStorage.setItem(TRAIL_KEY, JSON.stringify(trail)); + }; + return (

{set.title}

@@ -65,12 +95,33 @@ export function ContestCard({ contest }: ContestCardProps) { } type TaskCardProps = { + source: ProblemSetViewerDTO; task: TaskScoredSummaryDTO; }; -export function TaskCard({ task }: TaskCardProps) { +export function TaskCard({ source, task }: TaskCardProps) { const url = getPath({ kind: Path.TaskView, slug: task.slug }); + const handleCardClick = () => { + const rawTrail = sessionStorage.getItem(TRAIL_KEY); + let trail = []; + + try { + if (rawTrail) { + trail = JSON.parse(rawTrail); + } + } catch { + trail = []; + } + + trail.push({ + path: getPath({ kind: Path.ProblemSetView, slug: source.slug }), + name: source.title, + }); + + sessionStorage.setItem(TRAIL_KEY, JSON.stringify(trail)); + }; + let top_class = "p-[1rem] border-t border-l border-r border-black rounded-tl-2xl rounded-tr-2xl group-hover:bg-gray-150"; const bottom_class = @@ -100,8 +151,14 @@ export function TaskCard({ task }: TaskCardProps) { top_class = classNames(top_class, "bg-green-100 group-hover:bg-green-200"); } } + return ( - +

{task.title}

diff --git a/src/client/components/common_editor/common_editor_parts.tsx b/src/client/components/common_editor/common_editor_parts.tsx index d98ca1fa..1dba0afd 100644 --- a/src/client/components/common_editor/common_editor_parts.tsx +++ b/src/client/components/common_editor/common_editor_parts.tsx @@ -62,14 +62,16 @@ export type SaveResult = SaveResultSuccess | SaveResultFailure; type CommonEditorFooterProps = { initial: T; + origObject: T; object: T; setObject(object: T): void; - saveObject(object: T): Promise>; + saveObject(origObject: T, object: T): Promise>; }; // eslint-disable-next-line @typescript-eslint/ban-types -- pre-existing error before eslint inclusion export const CommonEditorFooter = ({ initial, + origObject, object, setObject, saveObject, @@ -79,7 +81,7 @@ export const CommonEditorFooter = ({ const handleSave = useCallback(async () => { setSaving(true); try { - const result = await saveObject(object); + const result = await saveObject(origObject, object); if (result.success) { setObject(result.value); toast("Great success!", { type: "success" }); diff --git a/src/client/components/contest_editor/contest_editor.tsx b/src/client/components/contest_editor/contest_editor.tsx index 4057d325..7f1f2100 100644 --- a/src/client/components/contest_editor/contest_editor.tsx +++ b/src/client/components/contest_editor/contest_editor.tsx @@ -32,6 +32,7 @@ export const ContestEditor = ({ dto }: ContestEditorProps) => { const [tab, setTab] = useState(coerceContestEditorTab(getLocationHash())); const [contest, setContest] = useState(initialContest); const [isMounted, setIsMounted] = useState(false); + const [origContest] = useState(initialContest); // NextJS hack to detect when hash changes and run some code // https://github.com/vercel/next.js/discussions/49465#discussioncomment-5845312 @@ -76,6 +77,7 @@ export const ContestEditor = ({ dto }: ContestEditorProps) => { {content} > { - const errors = validateContest(contest); +export async function saveContest( + oldContest: ContestED, + contest: ContestED +): Promise> { + const errors = validateContest(oldContest, contest); if (errors.length > 0) { return { success: false, @@ -54,7 +57,7 @@ export async function saveContest(contest: ContestED): Promise coerceProblemSetTaskED(x)), + nesteds: dto.nesteds.map((x) => coerceProblemSetNestedED(x)), }; return set; } -function coerceProblemSetTaskED(dto: ProblemSetTaskEditorDTO): ProblemSetTaskED { +function coerceProblemSetTaskED(dto: ProblemSetTaskEditorDTO): ProblemSetChildED { return { id: dto.task_id, slug: dto.slug, title: dto.title, - order: dto.order, + deleted: false, + }; +} + +function coerceProblemSetNestedED(dto: ProblemSetNestedEditorDTO): ProblemSetChildED { + return { + id: dto.child_id, + slug: dto.slug, + title: dto.title, + deleted: false, }; } diff --git a/src/client/components/problem_set_editor/problem_set_editor.tsx b/src/client/components/problem_set_editor/problem_set_editor.tsx index 506c858d..8f9fd187 100644 --- a/src/client/components/problem_set_editor/problem_set_editor.tsx +++ b/src/client/components/problem_set_editor/problem_set_editor.tsx @@ -29,11 +29,11 @@ import { import commonStyles from "client/components/common_editor/common_editor.module.css"; import http from "client/http"; import { APIPath, getAPIPath, getPath, Path } from "client/paths"; -import { TaskLookupDTO } from "common/types"; +import { ChildLookupDTO } from "common/types"; import { InputChangeEvent } from "common/types/events"; import { Arrays } from "common/utils/arrays"; import { ProblemSetEditorDTO } from "common/validation/problem_set_validation"; -import { ProblemSetED, ProblemSetTaskED } from "./types"; +import { ProblemSetED, ProblemSetChildED } from "./types"; import { coerceProblemSetED } from "./problem_set_coercion"; import { saveProblemSet } from "./problem_set_editor_saving"; import styles from "./problem_set_editor.module.css"; @@ -49,7 +49,7 @@ export const ProblemSetEditor = ({ dto }: ProblemSetEditorProps) => { const [tab, setTab] = useState(coerceProblemSetEditorTab(getLocationHash())); const [problemSet, setProblemSet] = useState(initialProblemSet); const [isMounted, setIsMounted] = useState(false); - + const [origProbSet] = useState(initialProblemSet); // hacks copy-pasted from the task editor! // NextJS hack to detect when hash changes and run some code @@ -85,6 +85,7 @@ export const ProblemSetEditor = ({ dto }: ProblemSetEditorProps) => { {content} - +

{problemSet.id}
+ + { + setProblemSet({ + ...problemSet, + nesteds: children, + }); + }, + childrenOf: (problemSet: ProblemSetED) => problemSet.nesteds, + label: "Nested Set", + viewPath: Path.ProblemSetView, + lookupPath: APIPath.ProblemSetLookup, + }} + /> - + { + setProblemSet({ + ...problemSet, + tasks: children, + }); + }, + childrenOf: (problemSet: ProblemSetED) => problemSet.tasks, + label: "Task", + viewPath: Path.TaskView, + lookupPath: APIPath.TaskLookup, + }} + /> ); }; -type ProblemSetEditorTasksProps = { +type ChildArgs = { + setChildren(problemSet: ProblemSetED, children: ProblemSetChildED[]): void; + childrenOf(problemSet: ProblemSetED): ProblemSetChildED[]; + label: string; + viewPath: Path.TaskView | Path.ProblemSetView; + lookupPath: APIPath.TaskLookup | APIPath.ProblemSetLookup; +}; + +type ProblemSetEditorChildrenProps = { problemSet: ProblemSetED; - setProblemSet(problemSet: ProblemSetED): void; + args: ChildArgs; }; -export const ProblemSetEditorTasks = ({ - problemSet, - setProblemSet, -}: ProblemSetEditorTasksProps) => { - const onAddTask = useCallback(() => { - setProblemSet({ - ...problemSet, - tasks: [ - ...problemSet.tasks, - { - id: "", - slug: "", - title: "", - deleted: false, - }, - ], - }); +export const ProblemSetEditorChildren = ({ problemSet, args }: ProblemSetEditorChildrenProps) => { + const { label, setChildren, childrenOf } = args; + const onAddChild = useCallback(() => { + setChildren(problemSet, [ + ...childrenOf(problemSet), + { + id: "", + slug: "", + title: "", + deleted: false, + }, + ]); // eslint-disable-next-line react-hooks/exhaustive-deps -- pre-existing error before eslint inclusion }, [problemSet]); return (
- + - {problemSet.tasks.map((task, idx) => ( - ( + ))}
- +
); }; -type ProblemSetTaskEditorProps = { - task: ProblemSetTaskED; - taskIndex: number; +type ProblemSetChildEditorProps = { + child: ProblemSetChildED; + index: number; problemSet: ProblemSetED; - setProblemSet(problemSet: ProblemSetED): void; + args: ChildArgs; }; -const ProblemSetTaskEditor = ({ - task, - taskIndex, - problemSet, - setProblemSet, -}: ProblemSetTaskEditorProps) => { - const replaceThisTask = useCallback( - (newTask: ProblemSetTaskED) => { - setProblemSet({ - ...problemSet, - tasks: Arrays.replaceNth(problemSet.tasks, taskIndex, newTask), - }); +const ProblemSetChildEditor = ({ child, index, problemSet, args }: ProblemSetChildEditorProps) => { + const { childrenOf, setChildren } = args; + const replaceThisChild = useCallback( + (newChild: ProblemSetChildED) => { + setChildren(problemSet, Arrays.replaceNth(childrenOf(problemSet), index, newChild)); }, // eslint-disable-next-line react-hooks/exhaustive-deps -- pre-existing error before eslint inclusion - [problemSet, taskIndex] + [problemSet, index] ); - const setThisTask = useCallback( - (value: ProblemSetTaskED | null) => { + const setThisChild = useCallback( + (value: ProblemSetChildED | null) => { if (value == null) { // empty id means it doesn't point to a task - replaceThisTask({ + replaceThisChild({ id: "", slug: "", title: "", - deleted: task.deleted, + deleted: child.deleted, }); return; } - replaceThisTask(value); + replaceThisChild(value); }, - [task, replaceThisTask] + [child, replaceThisChild] ); - const onTaskMoveUp = useCallback(() => { - setProblemSet({ - ...problemSet, - tasks: Arrays.moveUp(problemSet.tasks, taskIndex), - }); - }, [problemSet, taskIndex, setProblemSet]); + const onChildMoveUp = useCallback(() => { + setChildren(problemSet, Arrays.moveUp(childrenOf(problemSet), index)); + }, [problemSet, index, setChildren, childrenOf]); - const onTaskMoveDown = useCallback(() => { - setProblemSet({ - ...problemSet, - tasks: Arrays.moveDown(problemSet.tasks, taskIndex), - }); - }, [problemSet, taskIndex, setProblemSet]); + const onChildMoveDown = useCallback(() => { + setChildren(problemSet, Arrays.moveDown(childrenOf(problemSet), index)); + }, [problemSet, index, setChildren, childrenOf]); - const onTaskRemove = useCallback(() => { - replaceThisTask({ - ...task, - deleted: !task.deleted, + const onChildRemove = useCallback(() => { + replaceThisChild({ + ...child, + deleted: !child.deleted, }); - }, [task, replaceThisTask]); + }, [child, replaceThisChild]); return ( <> - + - - - + + + ); }; -type ProblemSetTaskPickerProps = { - value: ProblemSetTaskED; - setValue(value: ProblemSetTaskED | null): void; +type ProblemSetChildPickerProps = { + value: ProblemSetChildED; + setValue(value: ProblemSetChildED | null): void; + args: ChildArgs; }; -const ProblemSetTaskPicker = (props: ProblemSetTaskPickerProps) => { - const { value, setValue } = props; - +const ProblemSetChildPicker = (props: ProblemSetChildPickerProps) => { + const { value, setValue, args } = props; + const { label, viewPath, lookupPath } = args; const [text, setText] = useState(""); const [searching, setSearching] = useState(false); @@ -271,15 +298,15 @@ const ProblemSetTaskPicker = (props: ProblemSetTaskPickerProps) => { [setText] ); - const onTaskSearch = useCallback(async () => { + const onChildSearch = useCallback(async () => { if (searching) { return; } setSearching(true); - const lookupURL = getAPIPath({ kind: APIPath.TaskLookup, id: text }); + const lookupURL = getAPIPath({ kind: lookupPath, id: text }); try { - const response: AxiosResponse = await http.get(lookupURL); + const response: AxiosResponse = await http.get(lookupURL); setValue({ id: response.data.id, slug: response.data.slug, @@ -288,7 +315,7 @@ const ProblemSetTaskPicker = (props: ProblemSetTaskPickerProps) => { }); } catch (e) { if (e instanceof AxiosError && e.response != null && e.response.status == 404) { - toast("Task does not exist", { + toast(`${label} does not exist`, { type: "error", }); } @@ -298,7 +325,7 @@ const ProblemSetTaskPicker = (props: ProblemSetTaskPickerProps) => { // eslint-disable-next-line react-hooks/exhaustive-deps -- pre-existing error before eslint inclusion }, [searching, text]); - const onTaskClear = useCallback(() => { + const onChildClear = useCallback(() => { setValue(null); }, [setValue]); @@ -309,13 +336,13 @@ const ProblemSetTaskPicker = (props: ProblemSetTaskPickerProps) => { className="flex-auto" value={text} onChange={onTextChange} - placeholder="Task id or slug" + placeholder="id or slug" /> - +
); } else { - const url = getPath({ kind: Path.TaskView, slug: value.slug }); + const url = getPath({ kind: viewPath, slug: value.slug }); return (
{ > {value.title} - +
); } diff --git a/src/client/components/problem_set_editor/problem_set_editor_saving.ts b/src/client/components/problem_set_editor/problem_set_editor_saving.ts index b2578244..8e393ab9 100644 --- a/src/client/components/problem_set_editor/problem_set_editor_saving.ts +++ b/src/client/components/problem_set_editor/problem_set_editor_saving.ts @@ -4,14 +4,18 @@ import { ProblemSetEditorDTO, ProblemSetUpdateDTO, ProblemSetTaskUpdateDTO, + ProblemSetNestedUpdateDTO, } from "common/validation/problem_set_validation"; import { APIPath, getAPIPath } from "client/paths"; import { SaveResult } from "client/components/common_editor"; import { coerceProblemSetED } from "./problem_set_coercion"; -import { ProblemSetED, ProblemSetTaskED } from "./types"; +import { ProblemSetED, ProblemSetChildED } from "./types"; -export async function saveProblemSet(set: ProblemSetED): Promise> { - const errors = validateProblemSet(set); +export async function saveProblemSet( + origSet: ProblemSetED, + set: ProblemSetED +): Promise> { + const errors = validateProblemSet(origSet, set); if (errors.length > 0) { return { success: false, @@ -29,13 +33,16 @@ export async function saveProblemSet(set: ProblemSetED): Promise { return ( <> -
+
+ }> + + {canEdit && }
{set.description && {set.description}}
+ {set.nesteds.map((nested) => ( + + ))} {set.tasks.map((task) => ( - + ))}
diff --git a/src/client/components/source_set_button/source_set_button.tsx b/src/client/components/source_set_button/source_set_button.tsx new file mode 100644 index 00000000..6a581df9 --- /dev/null +++ b/src/client/components/source_set_button/source_set_button.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter, usePathname } from "next/navigation"; +import classNames from "classnames"; +import BoxIcon from "../box_icon"; + +interface TrailStep { + path: string; + name: string; +} + +const TRAIL_KEY = "problem_set_trail"; + +export function SourceSetButton() { + const router = useRouter(); + const pathname = usePathname(); + const [previousStep, setPreviousStep] = useState(null); + + useEffect(() => { + const rawTrail = sessionStorage.getItem(TRAIL_KEY); + if (!rawTrail) return; + + try { + const trail: TrailStep[] = JSON.parse(rawTrail); + + // If we are currently at the top level of the trail, don't show the back button + if (trail.length > 0) { + setPreviousStep(trail[trail.length - 1]); + } else { + setPreviousStep(null); + } + } catch { + setPreviousStep(null); + } + }, [pathname]); + + if (!previousStep) { + return null; + } + + const handleBack = () => { + const rawTrail = sessionStorage.getItem(TRAIL_KEY); + if (!rawTrail) return; + + try { + const trail: TrailStep[] = JSON.parse(rawTrail); + + // Pop the target location off the stack + const targetStep = trail.pop(); + + // Save the trimmed trail back to storage + sessionStorage.setItem(TRAIL_KEY, JSON.stringify(trail)); + + if (targetStep) { + router.push(targetStep.path); + } + } catch { + router.push("/problem-sets"); + } + }; + + return ( + + ); +} diff --git a/src/client/components/submissions_table/submissions_table.tsx b/src/client/components/submissions_table/submissions_table.tsx index 420a7cbb..59b4514c 100644 --- a/src/client/components/submissions_table/submissions_table.tsx +++ b/src/client/components/submissions_table/submissions_table.tsx @@ -78,7 +78,7 @@ const SubmissionRow = memo(({ submission, showUser }: SubmissionRowProps) => { ); }); -SubmissionRow.displayName = 'SubmissionRow'; +SubmissionRow.displayName = "SubmissionRow"; type SubmissionCellProps = { className?: string; @@ -97,7 +97,7 @@ const SubmissionHeader = memo(({ className, children }: SubmissionCellProps) =>
); }); -SubmissionHeader.displayName = 'SubmissionHeader'; +SubmissionHeader.displayName = "SubmissionHeader"; const SubmissionCell = memo(({ className, children }: SubmissionCellProps) => { return ( @@ -111,7 +111,7 @@ const SubmissionCell = memo(({ className, children }: SubmissionCellProps) => { ); }); -SubmissionCell.displayName = 'SubmissionCell'; +SubmissionCell.displayName = "SubmissionCell"; type OverallScoreProps = { overallVerdict: OverallVerdictDisplayDTO | undefined; @@ -144,4 +144,4 @@ export const OverallScoreDisplay = memo(({ overallVerdict, className }: OverallS ); }); -OverallScoreDisplay.displayName = 'OverallScoreDisplay'; +OverallScoreDisplay.displayName = "OverallScoreDisplay"; diff --git a/src/client/components/task_editor/task_editor.tsx b/src/client/components/task_editor/task_editor.tsx index 890d6131..fc522277 100644 --- a/src/client/components/task_editor/task_editor.tsx +++ b/src/client/components/task_editor/task_editor.tsx @@ -39,6 +39,8 @@ export const TaskEditor = ({ dto }: TaskEditorProps) => { const [isMounted, setIsMounted] = useState(false); + const [origTask] = useState(initialTask); + // NextJS hack to detect when hash changes and run some code // https://github.com/vercel/next.js/discussions/49465#discussioncomment-5845312 const params = useParams(); @@ -85,6 +87,7 @@ export const TaskEditor = ({ dto }: TaskEditorProps) => { {content} > { - const errors = validateTask(task); +export async function saveTask(origTask: TaskED, task: TaskED): Promise> { + const errors = validateTask(origTask, task); if (errors.length > 0) { return { success: false, @@ -71,7 +71,7 @@ export async function saveTask(task: TaskED): Promise> { }; } -function validateTask(task: TaskED): string[] { +function validateTask(origTask: TaskED, task: TaskED): string[] { if (task.type === TaskType.Communication) { if (task.communicator == null || task.communicator.file_name.trim() == "") { return ["Communication script must have a file"]; diff --git a/src/client/components/task_viewer/task_viewer_submissions.tsx b/src/client/components/task_viewer/task_viewer_submissions.tsx index 9785725b..7a446177 100644 --- a/src/client/components/task_viewer/task_viewer_submissions.tsx +++ b/src/client/components/task_viewer/task_viewer_submissions.tsx @@ -13,7 +13,7 @@ type TaskViewerSubmissionsProps = { }; export const TaskViewerSubmissions = ({ task, cache }: TaskViewerSubmissionsProps) => { - const [loaded, setLoaded] = useState(cache.loaded); + const [loaded, setLoaded] = useState(cache.loaded); const loadSubmissions = useCallback(async () => { if (cache.loaded) { diff --git a/src/client/components/task_viewer/task_viewer_tabs.tsx b/src/client/components/task_viewer/task_viewer_tabs.tsx index a2df9ddf..5edc198b 100644 --- a/src/client/components/task_viewer/task_viewer_tabs.tsx +++ b/src/client/components/task_viewer/task_viewer_tabs.tsx @@ -1,8 +1,9 @@ import classNames from "classnames"; import Link from "next/link"; -import { memo } from "react"; +import { memo, Suspense } from "react"; import { getPath, Path } from "client/paths"; import { useSession } from "client/sessions"; +import { SourceSetButton } from "../source_set_button/source_set_button"; export enum TaskViewerTab { Statement = "statement", @@ -66,7 +67,12 @@ export const TaskViewerTabComponent = memo( )} - {canEdit && } +
+ }> + + + {canEdit && } +
); } diff --git a/src/client/paths.ts b/src/client/paths.ts index db3d8406..64f537e0 100644 --- a/src/client/paths.ts +++ b/src/client/paths.ts @@ -132,6 +132,7 @@ export enum APIPath { TaskSubmissions = "TaskSubmissions", ProblemSetCreate = "ProblemSetCreate", ProblemSetUpdate = "ProblemSetUpdate", + ProblemSetLookup = "ProblemSetLookup", ContestCreate = "ContestCreate", ContestUpdate = "ContestUpdate", UserEdit = "UserEdit", @@ -155,6 +156,7 @@ export type APIPathArguments = | { kind: APIPath.TaskSubmissions; id: string } | { kind: APIPath.ProblemSetCreate } | { kind: APIPath.ProblemSetUpdate; id: string } + | { kind: APIPath.ProblemSetLookup; id: string } | { kind: APIPath.ContestCreate } | { kind: APIPath.ContestUpdate; id: string } | { kind: APIPath.UserEdit; id: string }; @@ -197,6 +199,8 @@ export function getAPIPath(args: APIPathArguments) { return "/api/v1/sets"; case APIPath.ProblemSetUpdate: return `/api/v1/sets/${args.id}`; + case APIPath.ProblemSetLookup: + return `/api/v1/sets/${args.id}`; case APIPath.ContestCreate: return "/api/v1/contests"; case APIPath.ContestUpdate: diff --git a/src/common/types/index.ts b/src/common/types/index.ts index 792b86b8..026784f9 100644 --- a/src/common/types/index.ts +++ b/src/common/types/index.ts @@ -16,7 +16,7 @@ import { VerdictTaskDataTable, } from "./verdicts"; import { UserTable } from "./users"; -import { ProblemSetTable, ProblemSetTaskTable } from "./problem_sets"; +import { ProblemSetNestedTable, ProblemSetTable, ProblemSetTaskTable } from "./problem_sets"; import { ContestAttachmentTable, ContestTable, @@ -42,6 +42,7 @@ export interface Models { overall_verdicts: OverallVerdictTable; problem_sets: ProblemSetTable; problem_set_tasks: ProblemSetTaskTable; + problem_set_nesteds: ProblemSetNestedTable; contests: ContestTable; contest_attachments: ContestAttachmentTable; contest_tasks: ContestTaskTable; diff --git a/src/common/types/problem_sets.ts b/src/common/types/problem_sets.ts index 4b61bc7d..9719da2c 100644 --- a/src/common/types/problem_sets.ts +++ b/src/common/types/problem_sets.ts @@ -1,6 +1,6 @@ import { Generated, Insertable, Selectable, Updateable } from "kysely"; // eslint-disable-next-line @typescript-eslint/no-unused-vars -- pre-existing error before eslint inclusion -import { TaskScoredSummaryDTO, TaskSummaryDTO } from "./tasks"; +import { TaskLookupDTO, TaskScoredSummaryDTO, TaskSummaryDTO } from "./tasks"; export type ProblemSetTable = { id: Generated; @@ -17,6 +17,12 @@ export type ProblemSetTaskTable = { order: number; }; +export type ProblemSetNestedTable = { + parent_id: string; + child_id: string; + order: number; +}; + export type ProblemSet = Selectable; export type ProblemSetCreate = Insertable; export type ProblemSetUpdate = Updateable; @@ -27,8 +33,8 @@ export type ProblemSetViewerDTO = { title: string; description: string; is_public: boolean; - order: number; tasks: TaskScoredSummaryDTO[]; + nesteds: ProblemSetSummaryDTO[]; }; export type ProblemSetSummaryDTO = { @@ -37,3 +43,17 @@ export type ProblemSetSummaryDTO = { description: string; order: number; }; + +export type NestedLookupDTO = { + id: string; + slug: string; + title: string; +}; + +export type ChildLookupDTO = TaskLookupDTO | NestedLookupDTO; + +// export type ProblemSetCardSummaryDTO = +// { +// kind: ProblemSetCardType.Task, +// value: +// }; diff --git a/src/common/types/tasks.ts b/src/common/types/tasks.ts index d673cf95..5f591044 100644 --- a/src/common/types/tasks.ts +++ b/src/common/types/tasks.ts @@ -134,9 +134,14 @@ export type TaskViewerSampleIO_DTO = { explanation: string; }; -export type TaskSummaryDTO = Pick; +export type TaskSummaryDTO = Pick & { order: number }; export type TaskScoredSummaryDTO = TaskSummaryDTO & { score_overall: number | null; score_max: number | null; }; -export type TaskLookupDTO = Pick; + +export type TaskLookupDTO = { + id: string; + slug: string; + title: string; +}; diff --git a/src/common/validation/problem_set_validation.ts b/src/common/validation/problem_set_validation.ts index f5b10ba0..8cfbba8e 100644 --- a/src/common/validation/problem_set_validation.ts +++ b/src/common/validation/problem_set_validation.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { ProblemSet } from "common/types"; import { REGEX_SLUG } from "./common_validation"; +export type ProblemSetNestedUpdateDTO = z.infer; export type ProblemSetTaskUpdateDTO = z.infer; export type ProblemSetUpdateDTO = z.infer; @@ -10,6 +11,11 @@ const zProblemSetTask = z.object({ order: z.number(), }); +const zProblemSetNested = z.object({ + child_id: z.string().uuid(), + order: z.number(), +}); + export const zProblemSet = z.object({ id: z.string().uuid(), slug: z.string().min(1).regex(REGEX_SLUG), @@ -18,6 +24,7 @@ export const zProblemSet = z.object({ is_public: z.boolean(), order: z.number().int(), tasks: z.array(zProblemSetTask), + nesteds: z.array(zProblemSetNested), }); type ProblemSetEditorKeys = "id" | "slug" | "title" | "description" | "is_public" | "order"; @@ -29,8 +36,16 @@ export type ProblemSetTaskEditorDTO = { order: number; }; +export type ProblemSetNestedEditorDTO = { + child_id: string; + slug: string; + title: string; + order: number; +}; + export type ProblemSetEditorDTO = Pick & { tasks: ProblemSetTaskEditorDTO[]; + nesteds: ProblemSetNestedEditorDTO[]; }; export const zProblemSetCreate = zProblemSet.pick({ diff --git a/src/db/migrations/20260828221200_add_nested_problem_sets.ts b/src/db/migrations/20260828221200_add_nested_problem_sets.ts new file mode 100644 index 00000000..599064c3 --- /dev/null +++ b/src/db/migrations/20260828221200_add_nested_problem_sets.ts @@ -0,0 +1,49 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- pre-existing error before eslint inclusion +import { Kysely, sql } from "kysely"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- pre-existing error before eslint inclusion +export async function up(db: Kysely): Promise { + // 1. Create problem_set_nesteds table + await db.schema + .createTable("problem_set_nesteds") + .addColumn("parent_id", "uuid", (col) => + col.notNull().references("problem_sets.id").onDelete("cascade") + ) + .addColumn("child_id", "uuid", (col) => + col.notNull().references("problem_sets.id").onDelete("cascade") + ) + .addColumn("order", "integer", (col) => col.notNull()) + .addPrimaryKeyConstraint("problem_set_nesteds_pk", ["parent_id", "child_id"]) + .execute(); + + await db.schema + .createIndex("idx_problem_set_nesteds_parent_id_order") + .on("problem_set_nesteds") + .columns(["parent_id", "order"]) + .execute(); + + await db.schema + .createIndex("idx_problem_set_nesteds_child_id") + .on("problem_set_nesteds") + .columns(["child_id"]) + .execute(); + + // 2. Seed the root problem set + await db + .insertInto("problem_sets") + .values({ + slug: "root", + title: "Problem Sets", + description: "", + is_public: true, + order: 0, + }) + .execute(); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- pre-existing error before eslint inclusion +export async function down(db: Kysely): Promise { + await db.deleteFrom("problem_sets").where("slug", "=", "root").execute(); + + await db.schema.dropTable("problem_set_nesteds").execute(); +} diff --git a/src/server/logic/problem_sets/get_editor_problem_set.ts b/src/server/logic/problem_sets/get_editor_problem_set.ts index 80445cd5..04ef51ba 100644 --- a/src/server/logic/problem_sets/get_editor_problem_set.ts +++ b/src/server/logic/problem_sets/get_editor_problem_set.ts @@ -27,6 +27,20 @@ export async function getEditorProblemSet(uuid: string): Promise ({ + child_id: nested.id, + title: nested.title, + slug: nested.slug, + order: nested.order, + })), } satisfies ProblemSetEditorDTO; }); } diff --git a/src/server/logic/problem_sets/update_problem_set.ts b/src/server/logic/problem_sets/update_problem_set.ts index ae8d20cb..a65bd22a 100644 --- a/src/server/logic/problem_sets/update_problem_set.ts +++ b/src/server/logic/problem_sets/update_problem_set.ts @@ -6,6 +6,8 @@ import { ProblemSetEditorDTO, ProblemSetTaskUpdateDTO, ProblemSetTaskEditorDTO, + ProblemSetNestedUpdateDTO, + ProblemSetNestedEditorDTO, } from "common/validation/problem_set_validation"; import { notNull } from "common/utils/guards"; @@ -81,6 +83,67 @@ async function upsertProblemSetTasks( return dtos; } +async function upsertProblemSetNesteds( + trx: Transaction, + problemSetId: string, + nesteds: ProblemSetNestedUpdateDTO[] +): Promise { + const setsOrdered = makeOrdered(nesteds); + await trx.deleteFrom("problem_set_nesteds").where("parent_id", "=", problemSetId).execute(); + + const dbProblemSetNesteds = + setsOrdered.length <= 0 + ? [] + : await trx + .insertInto("problem_set_nesteds") + .values( + setsOrdered.map((nested) => ({ + parent_id: problemSetId, + child_id: nested.child_id, + order: nested.order, + })) + ) + .returning(["parent_id", "child_id", "order"]) + .execute(); + + dbProblemSetNesteds.sort((a, b) => a.order - b.order); + + const dbNesteds = + setsOrdered.length <= 0 + ? [] + : await trx + .selectFrom("problem_sets") + .where( + "id", + "in", + dbProblemSetNesteds.map((s) => s.child_id) + ) + .select(["id", "slug", "title"]) + .execute(); + + const nestedMap = new Map(dbNesteds.map((s) => [s.id, s])); + const nestedToOrder = new Map(dbProblemSetNesteds.map((s) => [s.child_id, s.order])); + + const dtos: ProblemSetNestedEditorDTO[] = dbProblemSetNesteds + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- pre-existing error before eslint inclusion + .map((ct, index) => { + const nested = nestedMap.get(ct.child_id); + const order = nestedToOrder.get(ct.child_id); + if (nested == null || order == null) { + return null; + } + return { + child_id: nested.id, + slug: nested.slug!, + title: nested.title!, + order: order, + }; + }) + .filter(notNull); + + return dtos; +} + export async function updateProblemSet( problemSet: ProblemSetUpdateDTO ): Promise { @@ -99,6 +162,7 @@ export async function updateProblemSet( .executeTakeFirstOrThrow(); const problemSetTasks = await upsertProblemSetTasks(trx, problemSet.id, problemSet.tasks); + const problemSetNesteds = await upsertProblemSetNesteds(trx, problemSet.id, problemSet.nesteds); return { id: dbProblemSet.id, @@ -108,6 +172,7 @@ export async function updateProblemSet( is_public: dbProblemSet.is_public, order: dbProblemSet.order, tasks: problemSetTasks, + nesteds: problemSetNesteds, }; }); }