From d3e84ab0a74d9d41b0c7611669c3c8c9d14e045b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 8 Aug 2026 13:02:26 +0000 Subject: [PATCH 1/5] fix(ui): hide close button labels Co-authored-by: Tiankai Ma --- src/lib/components/ui/dialog/dialog-content.svelte | 11 ++++++++--- src/lib/components/ui/sheet/sheet-content.svelte | 11 ++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/lib/components/ui/dialog/dialog-content.svelte b/src/lib/components/ui/dialog/dialog-content.svelte index c0551ee39..9cad06d7c 100644 --- a/src/lib/components/ui/dialog/dialog-content.svelte +++ b/src/lib/components/ui/dialog/dialog-content.svelte @@ -37,9 +37,14 @@ {#if showCloseButton} {#snippet child({ props })} - {/snippet} diff --git a/src/lib/components/ui/sheet/sheet-content.svelte b/src/lib/components/ui/sheet/sheet-content.svelte index 20957dccb..11261a0c0 100644 --- a/src/lib/components/ui/sheet/sheet-content.svelte +++ b/src/lib/components/ui/sheet/sheet-content.svelte @@ -44,9 +44,14 @@ {#if showCloseButton} {#snippet child({ props })} - {/snippet} From 756e254850671f9001560fea220c623d6676e296 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 8 Aug 2026 14:11:00 +0000 Subject: [PATCH 2/5] refactor(ui): redesign homework detail dialog Co-authored-by: Tiankai Ma --- .../SectionHomeworkActionBar.svelte | 95 +++++++++++-------- .../SectionHomeworkDetailDialog.svelte | 40 ++++---- .../SectionHomeworkReadOnlySummary.svelte | 18 ++-- 3 files changed, 87 insertions(+), 66 deletions(-) diff --git a/src/features/section-detail/components/SectionHomeworkActionBar.svelte b/src/features/section-detail/components/SectionHomeworkActionBar.svelte index f76920189..2d3ae6639 100644 --- a/src/features/section-detail/components/SectionHomeworkActionBar.svelte +++ b/src/features/section-detail/components/SectionHomeworkActionBar.svelte @@ -1,4 +1,9 @@ -
- - {#if canWrite} - - {/if} - {#if canWrite} - - {/if} - {#if canManage} - - {/if} -
+{#if canWrite || canManage} +
+ + {#if canManage} + + {/if} + + {#if canWrite} +
+ + +
+ {/if} +
+{/if} diff --git a/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte b/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte index 0fa27cbc8..9e4ac70b8 100644 --- a/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte +++ b/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte @@ -1,10 +1,9 @@ - + {#if homework.description?.content} {#if homework.description.renderedHtml} @@ -37,17 +37,21 @@ export let homeworkCopy: SectionHomeworkCopy;
- +
{homeworkCopy.publishedAt}
-
{fmtDateTime(homework.publishedAt)}
+
{fmtDateTime(homework.publishedAt)}
- +
{homeworkCopy.submissionStart}
-
{fmtDateTime(homework.submissionStartAt)}
+
+ {fmtDateTime(homework.submissionStartAt)} +
- +
{homeworkCopy.submissionDue}
-
{fmtDateTime(homework.submissionDueAt)}
+
+ {fmtDateTime(homework.submissionDueAt)} +
From 6ccb2201d013e59ddcba017d0f8bdefaf787a253 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 8 Aug 2026 14:22:11 +0000 Subject: [PATCH 3/5] fix(ui): size homework dialog to content Co-authored-by: Tiankai Ma --- .../components/SectionHomeworkDetailDialog.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte b/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte index 9e4ac70b8..a98e7c9f9 100644 --- a/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte +++ b/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte @@ -87,7 +87,7 @@ export let sectionJwId: number | string; - +
{#if _editingHomework} From 3e1c2cb141c38a5d42e40c2d9e52c5bc73da337a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 8 Aug 2026 15:03:20 +0000 Subject: [PATCH 4/5] refactor(ui): rebuild detail dialogs on a shared shell Co-authored-by: Tiankai Ma --- .../components/HomeworkDetailActions.svelte | 53 +++--- .../HomeworkDetailCommentsAside.svelte | 22 +-- .../HomeworkDetailDescription.svelte | 2 +- .../components/HomeworkDetailDialog.svelte | 98 +++++----- .../components/HomeworkDetailMetadata.svelte | 68 +++---- .../dashboard/components/HomeworksTab.svelte | 1 - .../components/HomeworksTabDialogs.svelte | 2 - .../components/TodoDetailDialog.svelte | 150 ++++++++-------- .../dashboard-homework-detail-types.ts | 1 + .../lib/dashboard-controller-types.ts | 1 + .../components/HomeworkDetailMetaGrid.svelte | 27 +++ .../components/HomeworkDetailTags.svelte | 14 ++ .../homeworks/lib/homework-detail-meta.ts | 107 +++++++++++ .../SectionHomeworkActionBar.svelte | 88 ++++----- .../SectionHomeworkDetailDialog.svelte | 167 ++++++++---------- .../SectionHomeworkReadOnlySummary.svelte | 40 ++--- src/lib/components/DetailDialog.svelte | 91 ++++++++++ tests/e2e/src/app/dashboard/homeworks/test.ts | 62 +++++++ tests/e2e/src/app/dashboard/todos/test.ts | 37 ++++ tests/e2e/src/app/sections/[jwId]/test.ts | 102 +++++++++++ tests/e2e/utils/detail-dialog.ts | 69 ++++++++ tests/unit/homework-detail-meta.test.ts | 145 +++++++++++++++ 22 files changed, 947 insertions(+), 400 deletions(-) create mode 100644 src/features/homeworks/components/HomeworkDetailMetaGrid.svelte create mode 100644 src/features/homeworks/components/HomeworkDetailTags.svelte create mode 100644 src/features/homeworks/lib/homework-detail-meta.ts create mode 100644 src/lib/components/DetailDialog.svelte create mode 100644 tests/e2e/utils/detail-dialog.ts create mode 100644 tests/unit/homework-detail-meta.test.ts diff --git a/src/features/dashboard/components/HomeworkDetailActions.svelte b/src/features/dashboard/components/HomeworkDetailActions.svelte index 804c49220..f3184f074 100644 --- a/src/features/dashboard/components/HomeworkDetailActions.svelte +++ b/src/features/dashboard/components/HomeworkDetailActions.svelte @@ -1,7 +1,7 @@ -
- -
- -
- - -
-
-
+ + diff --git a/src/features/dashboard/components/HomeworkDetailCommentsAside.svelte b/src/features/dashboard/components/HomeworkDetailCommentsAside.svelte index 73f827ef5..8ce66a70a 100644 --- a/src/features/dashboard/components/HomeworkDetailCommentsAside.svelte +++ b/src/features/dashboard/components/HomeworkDetailCommentsAside.svelte @@ -1,6 +1,5 @@ - +{#key `comments:homework:${homework.id}`} + +{/key} diff --git a/src/features/dashboard/components/HomeworkDetailDescription.svelte b/src/features/dashboard/components/HomeworkDetailDescription.svelte index 9e15895cd..c714ca3a9 100644 --- a/src/features/dashboard/components/HomeworkDetailDescription.svelte +++ b/src/features/dashboard/components/HomeworkDetailDescription.svelte @@ -11,7 +11,7 @@ export let homework: DashboardHomeworkDetailItem; export let homeworksCopy: DashboardHomeworkDetailCopy; - + {#if homework.description} -import CheckCircleIcon from "@lucide/svelte/icons/check-circle"; -import RefreshCw from "@lucide/svelte/icons/refresh-cw"; import type { DashboardMyHomeworksCopy } from "@/features/dashboard/lib/dashboard-controller-types"; -import * as Dialog from "$lib/components/ui/dialog/index.js"; -import { ScrollArea } from "$lib/components/ui/scroll-area/index.js"; +import DetailDialog from "$lib/components/DetailDialog.svelte"; +import { Badge } from "$lib/components/ui/badge/index.js"; import type { DashboardHomeworkCommentsPanel, DashboardHomeworkCompletionToggle, @@ -26,7 +24,6 @@ export let homeworkDetailHref: DashboardHomeworkDetailAction; export let homeworkEtaLabel: DashboardHomeworkDetailFormatter; export let homeworkCourseLabel: DashboardHomeworkDetailAction; export let homeworkSavingById: Record; -export let homeworkSectionHref: DashboardHomeworkDetailAction; export let homeworksCopy: DashboardHomeworkDetailCopy; export let homeworkCopy: DashboardMyHomeworksCopy; export let homeworkStatus: DashboardHomeworkDetailAction; @@ -35,59 +32,46 @@ export let toggleHomeworkCompletion: DashboardHomeworkCompletionToggle; {#if homework} - { - if (!open) onClose(); - }} + {@const selected = homework} + {@const courseLabel = homeworkCourseLabel(selected)} + - - {@const selectedCourseLabel = homeworkCourseLabel(homework)} - {@const SelectedCompletionIcon = homework.completion ? RefreshCw : CheckCircleIcon} - - {homework.title} - - {selectedCourseLabel} · {homeworkCopy.due}: - {fmtDate(homework.submissionDueAt)} - - - -
-
- + {#snippet badges()} + + {homeworkStatus(selected)} + + {/snippet} - + {#snippet body()} + + + {/snippet} - -
- -
-
-
-
+ {#snippet aside()} + + {/snippet} + + {#snippet footer()} + + {/snippet} + {/if} diff --git a/src/features/dashboard/components/HomeworkDetailMetadata.svelte b/src/features/dashboard/components/HomeworkDetailMetadata.svelte index 567442183..940ef8e85 100644 --- a/src/features/dashboard/components/HomeworkDetailMetadata.svelte +++ b/src/features/dashboard/components/HomeworkDetailMetadata.svelte @@ -1,9 +1,11 @@ - - - - - {homeworksCopy.submissionDue} - - {fmtDate(homework.submissionDueAt)} - - - - {homeworkStatus(homework)} - - - - {homeworkEtaLabel(homework.submissionDueAt)} - - - - - {homeworksCopy.submissionStart} - {fmtDate(homework.submissionStartAt)} - - - - - {homeworksCopy.homeworkPublishedAt} - {fmtDate(homework.publishedAt)} - - - - + -
- {#if homework.isMajor} - - {homeworksCopy.tagMajor} - - {/if} - {#if homework.requiresTeam} - - {homeworksCopy.tagTeam} - - {/if} -
+ diff --git a/src/features/dashboard/components/HomeworksTab.svelte b/src/features/dashboard/components/HomeworksTab.svelte index d2c2aaf23..2b7e90dc5 100644 --- a/src/features/dashboard/components/HomeworksTab.svelte +++ b/src/features/dashboard/components/HomeworksTab.svelte @@ -203,7 +203,6 @@ $: ({ {homeworkEtaLabel} {homeworksCopy} {homeworkSavingById} - {homeworkSectionHref} {homeworkSectionLabel} {homeworkStatus} {isCreatingHomework} diff --git a/src/features/dashboard/components/HomeworksTabDialogs.svelte b/src/features/dashboard/components/HomeworksTabDialogs.svelte index 17d204027..9473dfd3f 100644 --- a/src/features/dashboard/components/HomeworksTabDialogs.svelte +++ b/src/features/dashboard/components/HomeworksTabDialogs.svelte @@ -42,7 +42,6 @@ export let homeworkDetailHref: HomeworkAction; export let homeworkEtaLabel: DashboardHomeworkDetailFormatter; export let homeworksCopy: DashboardHomeworksCopy; export let homeworkSavingById: Record; -export let homeworkSectionHref: HomeworkAction; export let homeworkSectionLabel: ( section: DashboardHomeworkCreateSection, ) => string; @@ -92,7 +91,6 @@ export let toggleHomeworkCompletion: ( {homeworkEtaLabel} {homeworkCourseLabel} {homeworkSavingById} - {homeworkSectionHref} {homeworksCopy} {homeworkCopy} {homeworkStatus} diff --git a/src/features/dashboard/components/TodoDetailDialog.svelte b/src/features/dashboard/components/TodoDetailDialog.svelte index 6a1b6a5dd..2a512a238 100644 --- a/src/features/dashboard/components/TodoDetailDialog.svelte +++ b/src/features/dashboard/components/TodoDetailDialog.svelte @@ -1,15 +1,17 @@ {#if todo} - { - if (!open) onClose(); - }} + {@const selected = todo} + - - - {todo.title} - - {todo.priority} · {fmtDate(todo.dueAt)} - - -
- {#if todo.content} - + {#snippet badges()} + + {todosCopy.priority[selected.priority]} + + + {todoStatus(selected)} + + {/snippet} + + {#snippet body()} + + + {#if selected.content} + + {:else} + {todosCopy.contentPlaceholder} + {/if} + + + {/snippet} + + {#snippet footer()} + + + -
- - -
-
-
- - + {todoSavingById[selected.id] ? todosCopy.saving : todoActionLabel(selected)} + + {/snippet} + {/if} diff --git a/src/features/dashboard/components/dashboard-homework-detail-types.ts b/src/features/dashboard/components/dashboard-homework-detail-types.ts index e03a77359..1ffedd768 100644 --- a/src/features/dashboard/components/dashboard-homework-detail-types.ts +++ b/src/features/dashboard/components/dashboard-homework-detail-types.ts @@ -37,6 +37,7 @@ export type DashboardHomeworkCompletionToggle = ( ) => void | Promise; export type DashboardHomeworkCommentsPanel = Component<{ + heading?: string | null; permalinkBaseHref?: string | null; targetId: string; targetType: "homework"; diff --git a/src/features/dashboard/lib/dashboard-controller-types.ts b/src/features/dashboard/lib/dashboard-controller-types.ts index 6bfb51933..60319928e 100644 --- a/src/features/dashboard/lib/dashboard-controller-types.ts +++ b/src/features/dashboard/lib/dashboard-controller-types.ts @@ -358,6 +358,7 @@ export type DashboardTodosCopy = DashboardRecord & { delete: string; deleteAriaLabel: string; dueAtLabel: string; + dueLabel: string; editTitle: string; errorContentTooLong: string; errorInvalidDueAt: string; diff --git a/src/features/homeworks/components/HomeworkDetailMetaGrid.svelte b/src/features/homeworks/components/HomeworkDetailMetaGrid.svelte new file mode 100644 index 000000000..a86799f7e --- /dev/null +++ b/src/features/homeworks/components/HomeworkDetailMetaGrid.svelte @@ -0,0 +1,27 @@ + + +
+ {#each rows as row (row.key)} +
+
{row.label}
+
+ + {row.value} + + {#if row.hint} + {row.hint} + {/if} +
+
+ {/each} +
diff --git a/src/features/homeworks/components/HomeworkDetailTags.svelte b/src/features/homeworks/components/HomeworkDetailTags.svelte new file mode 100644 index 000000000..1b3e27c87 --- /dev/null +++ b/src/features/homeworks/components/HomeworkDetailTags.svelte @@ -0,0 +1,14 @@ + + +{#if tags.length > 0} +
+ {#each tags as tag (tag.key)} + {tag.label} + {/each} +
+{/if} diff --git a/src/features/homeworks/lib/homework-detail-meta.ts b/src/features/homeworks/lib/homework-detail-meta.ts new file mode 100644 index 000000000..ec59fd80f --- /dev/null +++ b/src/features/homeworks/lib/homework-detail-meta.ts @@ -0,0 +1,107 @@ +export type HomeworkDetailMetaValue = Date | string | null | undefined; + +export type HomeworkDetailMetaKey = + | "publishedAt" + | "submissionDueAt" + | "submissionStartAt"; + +export type HomeworkDetailMetaRow = { + emphasis: boolean; + hint: string | null; + key: HomeworkDetailMetaKey; + label: string; + value: string; +}; + +export type HomeworkDetailTagKey = "major" | "team"; + +export type HomeworkDetailTag = { + key: HomeworkDetailTagKey; + label: string; + variant: "secondary"; +}; + +type HomeworkDetailDates = { + publishedAt?: HomeworkDetailMetaValue; + submissionDueAt?: HomeworkDetailMetaValue; + submissionStartAt?: HomeworkDetailMetaValue; +}; + +type HomeworkDetailFlags = { + isMajor?: boolean | null; + requiresTeam?: boolean | null; +}; + +function optionalText(value: string | null | undefined) { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +/** + * Homework timeline cells in reading order (publish → submission window). The + * due cell carries `emphasis` because it is the value users scan for, and an + * optional `hint` for a relative label such as "in 3 days". + */ +export function buildHomeworkDetailMetaRows({ + dueHint, + formatDate, + homework, + labels, +}: { + dueHint?: string | null; + formatDate: (value: HomeworkDetailMetaValue) => string; + homework: HomeworkDetailDates; + labels: { + publishedAt: string; + submissionDue: string; + submissionStart: string; + }; +}): HomeworkDetailMetaRow[] { + return [ + { + emphasis: false, + hint: null, + key: "publishedAt", + label: labels.publishedAt, + value: formatDate(homework.publishedAt), + }, + { + emphasis: false, + hint: null, + key: "submissionStartAt", + label: labels.submissionStart, + value: formatDate(homework.submissionStartAt), + }, + { + emphasis: true, + hint: optionalText(dueHint), + key: "submissionDueAt", + label: labels.submissionDue, + value: formatDate(homework.submissionDueAt), + }, + ]; +} + +/** + * Attribute chips share one variant so they read as a uniform group next to the + * completion status badge, which carries its own emphasis. + */ +export function buildHomeworkDetailTags({ + homework, + labels, +}: { + homework: HomeworkDetailFlags; + labels: { + tagMajor: string; + tagTeam: string; + }; +}): HomeworkDetailTag[] { + const tags: HomeworkDetailTag[] = []; + if (homework.isMajor) { + tags.push({ key: "major", label: labels.tagMajor, variant: "secondary" }); + } + if (homework.requiresTeam) { + tags.push({ key: "team", label: labels.tagTeam, variant: "secondary" }); + } + return tags; +} diff --git a/src/features/section-detail/components/SectionHomeworkActionBar.svelte b/src/features/section-detail/components/SectionHomeworkActionBar.svelte index 2d3ae6639..3033dd81c 100644 --- a/src/features/section-detail/components/SectionHomeworkActionBar.svelte +++ b/src/features/section-detail/components/SectionHomeworkActionBar.svelte @@ -3,76 +3,54 @@ import CheckCircleIcon from "@lucide/svelte/icons/check-circle"; import PencilIcon from "@lucide/svelte/icons/pencil"; import RotateCcwIcon from "@lucide/svelte/icons/rotate-ccw"; import Trash2Icon from "@lucide/svelte/icons/trash-2"; -import XIcon from "@lucide/svelte/icons/x"; import { Button } from "$lib/components/ui/button/index.js"; import type { SectionHomeworkAction, SectionHomeworkCopy, SectionHomeworkDisplay, - SectionHomeworkSectionCopy, } from "./section-homework-display-types"; export let canManage: boolean; export let canWrite: boolean; -export let cancelEdit: () => void; -export let editing: boolean; export let homework: SectionHomeworkDisplay; export let homeworkCopy: SectionHomeworkCopy; -export let sectionCopy: SectionHomeworkSectionCopy; export let setDeleteHomeworkTarget: SectionHomeworkAction; export let startEdit: () => void; export let toggleHomeworkCompletion: SectionHomeworkAction; -{#if canWrite || canManage} -
- - {#if canManage} - - {/if} - - {#if canWrite} -
- - -
+ +{#if canManage} + +{/if} +{#if canWrite} + +
+ {homework.completion ? homeworkCopy.markIncomplete : homeworkCopy.markComplete} + {/if} diff --git a/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte b/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte index a98e7c9f9..931ca8222 100644 --- a/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte +++ b/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte @@ -1,9 +1,8 @@ {#if _selectedHomework} - { - if (!open) close(); - }} + {@const homework = _selectedHomework} + - - -
-
- - {_selectedHomework.title} - - - {_homeworkStatus(_selectedHomework)} - -
- - {_sectionCopy.due} · {_fmtDateTime(_selectedHomework.submissionDueAt)} - -
-
+ {#snippet badges()} + + {_homeworkStatus(homework)} + + {/snippet} - -
-
- {#if _editingHomework} - - {:else} - - {/if} + {#snippet body()} + {#if _editingHomework} + + {:else} + + {/if} - + + {/snippet} - -
+ {#snippet aside()} + {#key `comments:homework:${homework.id}`} + + {/key} + {/snippet} - -
-
-
-
+ {#snippet footer()} + + {/snippet} + {/if} diff --git a/src/features/section-detail/components/SectionHomeworkReadOnlySummary.svelte b/src/features/section-detail/components/SectionHomeworkReadOnlySummary.svelte index f0307a133..3bc086127 100644 --- a/src/features/section-detail/components/SectionHomeworkReadOnlySummary.svelte +++ b/src/features/section-detail/components/SectionHomeworkReadOnlySummary.svelte @@ -1,6 +1,11 @@ - + {#if homework.description?.content} {#if homework.description.renderedHtml} @@ -36,26 +48,6 @@ export let homeworkCopy: SectionHomeworkCopy; -
- -
{homeworkCopy.publishedAt}
-
{fmtDateTime(homework.publishedAt)}
-
- -
{homeworkCopy.submissionStart}
-
- {fmtDateTime(homework.submissionStartAt)} -
-
- -
{homeworkCopy.submissionDue}
-
- {fmtDateTime(homework.submissionDueAt)} -
-
-
+ -
- {#if homework.isMajor}{homeworkCopy.tagMajor}{/if} - {#if homework.requiresTeam}{homeworkCopy.tagTeam}{/if} -
+ diff --git a/src/lib/components/DetailDialog.svelte b/src/lib/components/DetailDialog.svelte new file mode 100644 index 000000000..8ab3ae194 --- /dev/null +++ b/src/lib/components/DetailDialog.svelte @@ -0,0 +1,91 @@ + + + { + if (!next) onClose(); + }} +> + [data-slot=dialog-close]]:top-3.5 [&>[data-slot=dialog-close]]:right-3.5", + aside ? "sm:max-w-5xl" : "sm:max-w-lg", + className, + )} + > + +
+ + {title} + + {#if badges}{@render badges()}{/if} +
+ {#if subtitle} + {subtitle} + {/if} +
+ + +
+
+ {@render body()} +
+ {#if aside} +
+ {@render aside()} +
+ {/if} +
+
+ + {#if footer && showFooter} + + {@render footer()} + + {/if} +
+
diff --git a/tests/e2e/src/app/dashboard/homeworks/test.ts b/tests/e2e/src/app/dashboard/homeworks/test.ts index 8db03a6aa..bab5c2d04 100644 --- a/tests/e2e/src/app/dashboard/homeworks/test.ts +++ b/tests/e2e/src/app/dashboard/homeworks/test.ts @@ -23,6 +23,13 @@ */ import { expect, test } from "@playwright/test"; import { signInAsDebugUser } from "../../../../utils/auth"; +import { + closeDetailDialog, + detailDialog, + detailDialogAside, + detailDialogFooter, + expectHomeworkTimelineCells, +} from "../../../../utils/detail-dialog"; import { DEV_SEED } from "../../../../utils/dev-seed"; import { cleanupHomeworksForE2e } from "../../../../utils/homeworks"; import { visibleText } from "../../../../utils/locators"; @@ -338,6 +345,61 @@ test.describe("仪表盘作业", () => { await captureStepScreenshot(page, testInfo, "homeworks/completion-error"); }); + test("作业详情弹窗展示状态徽标、时间线、讨论与底部操作", async ({ + page, + }, testInfo) => { + await signInAsDebugUser(page, "/workspace/homeworks"); + await ensureSeedSectionSubscription(page); + await gotoAndWaitForReady(page, "/workspace/homeworks"); + + await page + .getByRole("radio", { name: /全部|All/i }) + .first() + .click(); + + const row = page + .getByRole("row") + .filter({ hasText: DEV_SEED.homeworks.title }) + .first(); + await row + .getByRole("button", { name: new RegExp(DEV_SEED.homeworks.title) }) + .first() + .click(); + + const dialog = detailDialog(page); + await expect(dialog).toBeVisible(); + await expect( + dialog.getByRole("heading", { + name: new RegExp(DEV_SEED.homeworks.title), + }), + ).toBeVisible(); + await expect( + dialog.getByText(/待处理|已完成|Pending|Completed/i).first(), + ).toBeVisible(); + + await expectHomeworkTimelineCells(dialog); + + await expect( + detailDialogAside(dialog).getByRole("heading", { + name: /作业讨论|Homework discussion/i, + }), + ).toBeVisible(); + + const footer = detailDialogFooter(dialog); + await expect( + footer.getByRole("link", { name: /查看详情|View details/i }), + ).toBeVisible(); + await expect( + footer.getByRole("button", { + name: /标记为完成|取消完成|Mark as complete|Mark as incomplete/i, + }), + ).toBeVisible(); + + await captureStepScreenshot(page, testInfo, "homeworks/detail-dialog"); + + await closeDetailDialog(page, dialog); + }); + test("查看详情链接到带作业锚点的班级页面", async ({ page }, testInfo) => { await signInAsDebugUser(page, "/workspace/homeworks"); await ensureSeedSectionSubscription(page); diff --git a/tests/e2e/src/app/dashboard/todos/test.ts b/tests/e2e/src/app/dashboard/todos/test.ts index c887d269d..0e497c3ec 100644 --- a/tests/e2e/src/app/dashboard/todos/test.ts +++ b/tests/e2e/src/app/dashboard/todos/test.ts @@ -21,6 +21,11 @@ */ import { expect, test } from "@playwright/test"; import { signInAsDebugUser } from "../../../../utils/auth"; +import { + closeDetailDialog, + detailDialog, + detailDialogFooter, +} from "../../../../utils/detail-dialog"; import { DEV_SEED } from "../../../../utils/dev-seed"; import { visibleText } from "../../../../utils/locators"; import { gotoAndWaitForReady } from "../../../../utils/page-ready"; @@ -161,6 +166,38 @@ test.describe("仪表盘待办", () => { await captureStepScreenshot(page, testInfo, "dashboard-todos-completed"); }); + test("待办详情弹窗展示优先级、状态与底部操作", async ({ page }, testInfo) => { + await signInAsDebugUser(page, "/workspace/todos"); + + await visibleText(page, DEV_SEED.todos.dueTodayTitle).first().click(); + + const dialog = detailDialog(page); + await expect(dialog).toBeVisible(); + await expect( + dialog.getByRole("heading", { + name: new RegExp(DEV_SEED.todos.dueTodayTitle), + }), + ).toBeVisible(); + await expect( + dialog.getByText(/待处理|已完成|Pending|Completed/i).first(), + ).toBeVisible(); + + const footer = detailDialogFooter(dialog); + await expect( + footer.getByRole("button", { name: /删除待办|Delete todo/i }), + ).toBeVisible(); + await expect( + footer.getByRole("button", { name: /编辑待办|Edit Todo/i }), + ).toBeVisible(); + await expect( + footer.getByRole("button", { name: /标记为完成|Mark as complete/i }), + ).toBeVisible(); + + await captureStepScreenshot(page, testInfo, "todos/detail-dialog"); + + await closeDetailDialog(page, dialog); + }); + test("嵌套待办路由渲染服务端操作错误", async ({ page }, testInfo) => { await signInAsDebugUser(page, "/workspace/todos"); diff --git a/tests/e2e/src/app/sections/[jwId]/test.ts b/tests/e2e/src/app/sections/[jwId]/test.ts index cd0f0aded..ce6532f0c 100644 --- a/tests/e2e/src/app/sections/[jwId]/test.ts +++ b/tests/e2e/src/app/sections/[jwId]/test.ts @@ -45,6 +45,14 @@ import { snapshotDescriptionTargetForE2e, waitForDescriptionAuditRows, } from "../../../../utils/description-state"; +import { + closeDetailDialog, + detailDialog, + detailDialogAside, + detailDialogFooter, + expectDetailDialogFitsViewport, + expectHomeworkTimelineCells, +} from "../../../../utils/detail-dialog"; import { DEV_SEED } from "../../../../utils/dev-seed"; import { getCurrentSessionUser } from "../../../../utils/e2e-db"; import { withE2ePrisma } from "../../../../utils/e2e-db/prisma"; @@ -1156,6 +1164,100 @@ test.describe("/catalog/sections/[jwId] 班级详情页", () => { } }); + test("作业详情弹窗展示状态徽标、时间线、讨论与图标关闭按钮", async ({ + page, + }, testInfo) => { + await signInAsDebugUser(page, SECTION_URL); + await gotoAndWaitForReady(page, SECTION_URL); + await jumpToSection(page, /作业|Homework/i, "#homework"); + + await page + .getByRole("button", { + name: new RegExp(escapeForRegExp(DEV_SEED.homeworks.title)), + }) + .first() + .click(); + + const dialog = detailDialog(page); + await expect(dialog).toBeVisible(); + await expect( + dialog.getByRole("heading", { + name: new RegExp(escapeForRegExp(DEV_SEED.homeworks.title)), + }), + ).toBeVisible(); + await expect( + dialog.getByText(/常规作业|已完成|Standard|Completed/i).first(), + ).toBeVisible(); + + await expectHomeworkTimelineCells(dialog); + + await expect( + detailDialogAside(dialog).getByRole("heading", { + name: /作业讨论|Homework discussion/i, + }), + ).toBeVisible(); + + const footer = detailDialogFooter(dialog); + await expect( + footer.getByRole("button", { name: /编辑信息|Edit details/i }), + ).toBeVisible(); + await expect( + footer.getByRole("button", { + name: /标记为完成|取消完成|Mark as complete|Mark as incomplete/i, + }), + ).toBeVisible(); + + await captureStepScreenshot( + page, + testInfo, + "section/homework-detail-dialog", + ); + + await closeDetailDialog(page, dialog); + }); + + test("移动端作业详情弹窗纵向排布且不产生横向溢出", async ({ + page, + }, testInfo) => { + await page.setViewportSize({ height: 844, width: 390 }); + await signInAsDebugUser(page, SECTION_URL); + await gotoAndWaitForReady(page, SECTION_URL); + await jumpToSection(page, /作业|Homework/i, "#homework"); + + await page + .getByRole("button", { + name: new RegExp(escapeForRegExp(DEV_SEED.homeworks.title)), + }) + .first() + .click(); + + const dialog = detailDialog(page); + await expect(dialog).toBeVisible(); + await expectDetailDialogFitsViewport(page, dialog); + await expectHomeworkTimelineCells(dialog); + + const primaryAction = detailDialogFooter(dialog) + .getByRole("button", { + name: /标记为完成|取消完成|Mark as complete|Mark as incomplete/i, + }) + .first(); + await expect(primaryAction).toBeVisible(); + + await expect( + detailDialogAside(dialog).getByRole("heading", { + name: /作业讨论|Homework discussion/i, + }), + ).toBeVisible(); + + await captureStepScreenshot( + page, + testInfo, + "section/homework-detail-dialog-mobile", + ); + + await closeDetailDialog(page, dialog); + }); + test("可编辑班级作业的截止日期、说明、重要和组队标记", async ({ page, }, testInfo) => { diff --git a/tests/e2e/utils/detail-dialog.ts b/tests/e2e/utils/detail-dialog.ts new file mode 100644 index 000000000..4f3d6627d --- /dev/null +++ b/tests/e2e/utils/detail-dialog.ts @@ -0,0 +1,69 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +const DETAIL_DIALOG_SELECTOR = '[data-slot="dialog-content"]'; + +export function detailDialog(page: Page) { + return page.locator(DETAIL_DIALOG_SELECTOR).first(); +} + +export function detailDialogBody(dialog: Locator) { + return dialog.locator('[data-slot="detail-dialog-body"]'); +} + +export function detailDialogAside(dialog: Locator) { + return dialog.locator('[data-slot="detail-dialog-aside"]'); +} + +export function detailDialogFooter(dialog: Locator) { + return dialog.locator('[data-slot="dialog-footer"]'); +} + +/** + * The shared dialog shell names its close control with `aria-label` only, so a + * visible "Close" string next to the icon is a regression. + */ +export async function expectIconOnlyCloseButton(dialog: Locator) { + const close = dialog.getByRole("button", { name: /^(Close|关闭)$/i }).first(); + await expect(close).toBeVisible(); + expect((await close.innerText()).trim()).toBe(""); + return close; +} + +export async function closeDetailDialog(page: Page, dialog: Locator) { + const close = await expectIconOnlyCloseButton(dialog); + await close.click(); + await expect(page.locator(DETAIL_DIALOG_SELECTOR)).toHaveCount(0, { + timeout: 5_000, + }); +} + +export async function expectDetailDialogFitsViewport( + page: Page, + dialog: Locator, +) { + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + ).toBe(true); + + const viewport = page.viewportSize(); + const box = await dialog.boundingBox(); + expect(box).not.toBeNull(); + if (!box || !viewport) return; + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(viewport.width + 1); + expect(box.height).toBeLessThanOrEqual(viewport.height); +} + +/** Publish → submission window cells rendered by `HomeworkDetailMetaGrid`. */ +export async function expectHomeworkTimelineCells(dialog: Locator) { + const body = detailDialogBody(dialog); + await expect(body.getByText(/发布日期|Published/i).first()).toBeVisible(); + await expect( + body.getByText(/提交开始|Submission opens/i).first(), + ).toBeVisible(); + await expect( + body.getByText(/提交截止|Submission due/i).first(), + ).toBeVisible(); +} diff --git a/tests/unit/homework-detail-meta.test.ts b/tests/unit/homework-detail-meta.test.ts new file mode 100644 index 000000000..8ea86276b --- /dev/null +++ b/tests/unit/homework-detail-meta.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildHomeworkDetailMetaRows, + buildHomeworkDetailTags, +} from "@/features/homeworks/lib/homework-detail-meta"; + +const labels = { + publishedAt: "Published", + submissionDue: "Submission due", + submissionStart: "Submission start", +}; + +const tagLabels = { + tagMajor: "Major assignment", + tagTeam: "Team required", +}; + +function formatDate(value: Date | string | null | undefined) { + return value ? `formatted:${String(value)}` : "TBD"; +} + +describe("buildHomeworkDetailMetaRows", () => { + it("orders cells from publish date through the submission window", () => { + const rows = buildHomeworkDetailMetaRows({ + formatDate, + homework: { + publishedAt: "2026-06-01T00:00:00Z", + submissionDueAt: "2026-06-20T00:00:00Z", + submissionStartAt: "2026-06-10T00:00:00Z", + }, + labels, + }); + + expect(rows.map((row) => row.key)).toEqual([ + "publishedAt", + "submissionStartAt", + "submissionDueAt", + ]); + expect(rows.map((row) => row.label)).toEqual([ + "Published", + "Submission start", + "Submission due", + ]); + expect(rows.map((row) => row.value)).toEqual([ + "formatted:2026-06-01T00:00:00Z", + "formatted:2026-06-10T00:00:00Z", + "formatted:2026-06-20T00:00:00Z", + ]); + }); + + it("emphasizes only the due cell", () => { + const rows = buildHomeworkDetailMetaRows({ + formatDate, + homework: {}, + labels, + }); + + expect(rows.filter((row) => row.emphasis).map((row) => row.key)).toEqual([ + "submissionDueAt", + ]); + }); + + it("formats missing dates through the caller's formatter", () => { + const formatter = vi.fn(formatDate); + + const rows = buildHomeworkDetailMetaRows({ + formatDate: formatter, + homework: { submissionDueAt: null }, + labels, + }); + + expect(formatter).toHaveBeenCalledTimes(3); + expect(formatter).toHaveBeenCalledWith(undefined); + expect(formatter).toHaveBeenCalledWith(null); + expect(rows.map((row) => row.value)).toEqual(["TBD", "TBD", "TBD"]); + }); + + it("keeps a trimmed due hint and drops blank hints", () => { + const withHint = buildHomeworkDetailMetaRows({ + dueHint: " in 3 days ", + formatDate, + homework: {}, + labels, + }); + const withBlankHint = buildHomeworkDetailMetaRows({ + dueHint: " ", + formatDate, + homework: {}, + labels, + }); + const withoutHint = buildHomeworkDetailMetaRows({ + formatDate, + homework: {}, + labels, + }); + + expect(withHint.map((row) => row.hint)).toEqual([null, null, "in 3 days"]); + expect(withBlankHint.map((row) => row.hint)).toEqual([null, null, null]); + expect(withoutHint.map((row) => row.hint)).toEqual([null, null, null]); + }); +}); + +describe("buildHomeworkDetailTags", () => { + it("returns nothing when no flag is set", () => { + expect( + buildHomeworkDetailTags({ + homework: { isMajor: false, requiresTeam: false }, + labels: tagLabels, + }), + ).toEqual([]); + expect( + buildHomeworkDetailTags({ + homework: { isMajor: null, requiresTeam: undefined }, + labels: tagLabels, + }), + ).toEqual([]); + }); + + it("lists major before team as one uniform chip group", () => { + expect( + buildHomeworkDetailTags({ + homework: { isMajor: true, requiresTeam: true }, + labels: tagLabels, + }), + ).toEqual([ + { key: "major", label: "Major assignment", variant: "secondary" }, + { key: "team", label: "Team required", variant: "secondary" }, + ]); + }); + + it("includes only the flags that are set", () => { + expect( + buildHomeworkDetailTags({ + homework: { isMajor: true }, + labels: tagLabels, + }).map((tag) => tag.key), + ).toEqual(["major"]); + expect( + buildHomeworkDetailTags({ + homework: { requiresTeam: true }, + labels: tagLabels, + }).map((tag) => tag.key), + ).toEqual(["team"]); + }); +}); From 2a525e034d6ef606a341c1b7b0393c118e8e33f2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 8 Aug 2026 16:06:21 +0000 Subject: [PATCH 5/5] refactor(ui): follow documented detail popup hierarchy Co-authored-by: Tiankai Ma --- .../components/HomeworkDetailActions.svelte | 46 ++--- .../components/HomeworkDetailDialog.svelte | 35 ++-- .../components/HomeworkDetailMetadata.svelte | 24 ++- .../dashboard/components/HomeworksTab.svelte | 1 - .../components/HomeworksTabDialogs.svelte | 2 - .../components/TodoDetailDialog.svelte | 141 ++++++++------- .../components/HomeworkDetailMetaGrid.svelte | 27 --- .../components/HomeworkDetailTags.svelte | 2 +- .../components/HomeworkDueSummary.svelte | 24 +++ .../components/HomeworkMetaList.svelte | 14 ++ .../homeworks/lib/homework-detail-meta.ts | 118 +++++++----- .../components/SectionDetailDialogs.svelte | 2 - .../SectionDetailPageController.svelte | 6 - .../SectionHomeworkActionBar.svelte | 73 ++++---- .../SectionHomeworkDetailDialog.svelte | 43 ++--- .../components/SectionHomeworkDialogs.svelte | 3 - .../SectionHomeworkReadOnlySummary.svelte | 26 ++- .../components/section-detail-dialog-types.ts | 1 - .../section-homework-display-types.ts | 6 +- .../lib/section-detail-controller-types.ts | 1 + .../lib/section-detail-derived-state.ts | 9 - src/lib/components/DetailDialog.svelte | 34 +--- tests/e2e/src/app/dashboard/homeworks/test.ts | 19 +- tests/e2e/src/app/dashboard/todos/test.ts | 15 +- tests/e2e/src/app/sections/[jwId]/test.ts | 37 ++-- tests/e2e/utils/detail-dialog.ts | 54 ++++-- tests/unit/homework-detail-meta.test.ts | 170 +++++++++++------- 27 files changed, 517 insertions(+), 416 deletions(-) delete mode 100644 src/features/homeworks/components/HomeworkDetailMetaGrid.svelte create mode 100644 src/features/homeworks/components/HomeworkDueSummary.svelte create mode 100644 src/features/homeworks/components/HomeworkMetaList.svelte diff --git a/src/features/dashboard/components/HomeworkDetailActions.svelte b/src/features/dashboard/components/HomeworkDetailActions.svelte index f3184f074..ae9b60a84 100644 --- a/src/features/dashboard/components/HomeworkDetailActions.svelte +++ b/src/features/dashboard/components/HomeworkDetailActions.svelte @@ -2,6 +2,7 @@ import CheckCircleIcon from "@lucide/svelte/icons/check-circle"; import RotateCcwIcon from "@lucide/svelte/icons/rotate-ccw"; import { Button } from "$lib/components/ui/button/index.js"; +import { Separator } from "$lib/components/ui/separator/index.js"; import type { DashboardHomeworkCompletionToggle, DashboardHomeworkDetailAction, @@ -17,23 +18,28 @@ export let homeworksCopy: DashboardHomeworkDetailCopy; export let toggleHomeworkCompletion: DashboardHomeworkCompletionToggle; - - +
+ +
+ + +
+
diff --git a/src/features/dashboard/components/HomeworkDetailDialog.svelte b/src/features/dashboard/components/HomeworkDetailDialog.svelte index 10eae1beb..92fb1234a 100644 --- a/src/features/dashboard/components/HomeworkDetailDialog.svelte +++ b/src/features/dashboard/components/HomeworkDetailDialog.svelte @@ -1,7 +1,5 @@ - + + + diff --git a/src/features/dashboard/components/HomeworksTab.svelte b/src/features/dashboard/components/HomeworksTab.svelte index 2b7e90dc5..0c938838c 100644 --- a/src/features/dashboard/components/HomeworksTab.svelte +++ b/src/features/dashboard/components/HomeworksTab.svelte @@ -197,7 +197,6 @@ $: ({ bind:createHomeworkSubmissionStartAt {fmtDate} {homeworkCompletionActionLabel} - {homeworkCopy} {homeworkCourseLabel} {homeworkDetailHref} {homeworkEtaLabel} diff --git a/src/features/dashboard/components/HomeworksTabDialogs.svelte b/src/features/dashboard/components/HomeworksTabDialogs.svelte index 9473dfd3f..9aa314f89 100644 --- a/src/features/dashboard/components/HomeworksTabDialogs.svelte +++ b/src/features/dashboard/components/HomeworksTabDialogs.svelte @@ -36,7 +36,6 @@ export let createHomeworkSubmissionDueAt: string; export let createHomeworkSubmissionStartAt: string; export let fmtDate: DashboardHomeworkDetailFormatter; export let homeworkCompletionActionLabel: HomeworkAction; -export let homeworkCopy: DashboardMyHomeworksCopy; export let homeworkCourseLabel: HomeworkAction; export let homeworkDetailHref: HomeworkAction; export let homeworkEtaLabel: DashboardHomeworkDetailFormatter; @@ -92,7 +91,6 @@ export let toggleHomeworkCompletion: ( {homeworkCourseLabel} {homeworkSavingById} {homeworksCopy} - {homeworkCopy} {homeworkStatus} onClose={() => { selectedHomework = null; diff --git a/src/features/dashboard/components/TodoDetailDialog.svelte b/src/features/dashboard/components/TodoDetailDialog.svelte index 2a512a238..593ab85e7 100644 --- a/src/features/dashboard/components/TodoDetailDialog.svelte +++ b/src/features/dashboard/components/TodoDetailDialog.svelte @@ -12,6 +12,7 @@ import MarkdownPreview from "$lib/components/MarkdownPreview.svelte"; import { Badge } from "$lib/components/ui/badge/index.js"; import { Button } from "$lib/components/ui/button/index.js"; import * as Item from "$lib/components/ui/item/index.js"; +import { Separator } from "$lib/components/ui/separator/index.js"; export let deleteTodo: (todo: DashboardTodoItem) => void; export let fmtDate: (value: string | Date | null | undefined) => string; @@ -27,28 +28,11 @@ export let toggleTodoCompletion: (todo: DashboardTodoItem) => void; {#if todo} {@const selected = todo} - - {#snippet badges()} - - {todosCopy.priority[selected.priority]} - - - {todoStatus(selected)} - - {/snippet} - + + {#snippet body()} - + {#if selected.content} @@ -57,47 +41,82 @@ export let toggleTodoCompletion: (todo: DashboardTodoItem) => void; {/if} - {/snippet} - {#snippet footer()} - - - + + + + {todosCopy.dueLabel} + {fmtDate(selected.dueAt)} + + + + {todoStatus(selected)} + + + + + +
+
+
{todosCopy.priorityLabel}
+
+ + {todosCopy.priority[selected.priority]} + +
+
+
+ +
+ +
+ +
+ + +
+
+
{/snippet}
{/if} diff --git a/src/features/homeworks/components/HomeworkDetailMetaGrid.svelte b/src/features/homeworks/components/HomeworkDetailMetaGrid.svelte deleted file mode 100644 index a86799f7e..000000000 --- a/src/features/homeworks/components/HomeworkDetailMetaGrid.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - -
- {#each rows as row (row.key)} -
-
{row.label}
-
- - {row.value} - - {#if row.hint} - {row.hint} - {/if} -
-
- {/each} -
diff --git a/src/features/homeworks/components/HomeworkDetailTags.svelte b/src/features/homeworks/components/HomeworkDetailTags.svelte index 1b3e27c87..b033b94ae 100644 --- a/src/features/homeworks/components/HomeworkDetailTags.svelte +++ b/src/features/homeworks/components/HomeworkDetailTags.svelte @@ -8,7 +8,7 @@ export let tags: HomeworkDetailTag[]; {#if tags.length > 0}
{#each tags as tag (tag.key)} - {tag.label} + {tag.label} {/each}
{/if} diff --git a/src/features/homeworks/components/HomeworkDueSummary.svelte b/src/features/homeworks/components/HomeworkDueSummary.svelte new file mode 100644 index 000000000..e39176cfa --- /dev/null +++ b/src/features/homeworks/components/HomeworkDueSummary.svelte @@ -0,0 +1,24 @@ + + + + + + {summary.dueLabel} + {summary.dueValue} + + + + {summary.statusLabel} + + + + {#if summary.etaLabel} + {summary.etaLabel} + {/if} + diff --git a/src/features/homeworks/components/HomeworkMetaList.svelte b/src/features/homeworks/components/HomeworkMetaList.svelte new file mode 100644 index 000000000..aa3eae8d0 --- /dev/null +++ b/src/features/homeworks/components/HomeworkMetaList.svelte @@ -0,0 +1,14 @@ + + +
+ {#each rows as row (row.key)} +
+
{row.label}
+
{row.value}
+
+ {/each} +
diff --git a/src/features/homeworks/lib/homework-detail-meta.ts b/src/features/homeworks/lib/homework-detail-meta.ts index ec59fd80f..3aa5ef716 100644 --- a/src/features/homeworks/lib/homework-detail-meta.ts +++ b/src/features/homeworks/lib/homework-detail-meta.ts @@ -1,13 +1,25 @@ -export type HomeworkDetailMetaValue = Date | string | null | undefined; +export type HomeworkDetailDateValue = Date | string | null | undefined; -export type HomeworkDetailMetaKey = - | "publishedAt" - | "submissionDueAt" - | "submissionStartAt"; +export type HomeworkDetailDateFormatter = ( + value: HomeworkDetailDateValue, +) => string; + +/** + * Primary homework properties per `docs/contracts/_ui.json` "Model Property + * Priority": the due time, the completion status, and the relative label users + * scan for. Rendered as the "due summary" block of the detail popup. + */ +export type HomeworkDueSummary = { + completed: boolean; + dueLabel: string; + dueValue: string; + etaLabel: string | null; + statusLabel: string; +}; + +export type HomeworkDetailMetaKey = "publishedAt" | "submissionStartAt"; export type HomeworkDetailMetaRow = { - emphasis: boolean; - hint: string | null; key: HomeworkDetailMetaKey; label: string; value: string; @@ -18,18 +30,6 @@ export type HomeworkDetailTagKey = "major" | "team"; export type HomeworkDetailTag = { key: HomeworkDetailTagKey; label: string; - variant: "secondary"; -}; - -type HomeworkDetailDates = { - publishedAt?: HomeworkDetailMetaValue; - submissionDueAt?: HomeworkDetailMetaValue; - submissionStartAt?: HomeworkDetailMetaValue; -}; - -type HomeworkDetailFlags = { - isMajor?: boolean | null; - requiresTeam?: boolean | null; }; function optionalText(value: string | null | undefined) { @@ -38,59 +38,89 @@ function optionalText(value: string | null | undefined) { } /** - * Homework timeline cells in reading order (publish → submission window). The - * due cell carries `emphasis` because it is the value users scan for, and an - * optional `hint` for a relative label such as "in 3 days". + * Completion status, never the "standard homework" tag: default homework does + * not get a badge of its own. + */ +export function homeworkCompletionStatusLabel( + completed: boolean, + labels: { + completedStatus: string; + incompleteStatus: string; + }, +) { + return completed ? labels.completedStatus : labels.incompleteStatus; +} + +export function buildHomeworkDueSummary({ + completed, + dueLabel, + etaLabel, + formatDate, + homework, + statusLabel, +}: { + completed: boolean; + dueLabel: string; + etaLabel?: string | null; + formatDate: HomeworkDetailDateFormatter; + homework: { submissionDueAt?: HomeworkDetailDateValue }; + statusLabel: string; +}): HomeworkDueSummary { + return { + completed, + dueLabel, + dueValue: formatDate(homework.submissionDueAt), + etaLabel: optionalText(etaLabel), + statusLabel, + }; +} + +/** + * Secondary/tertiary timestamps for the vertical metadata list. The due date + * lives in the due summary and platform `createdAt` is intentionally excluded, + * matching the documented detail popup order. */ -export function buildHomeworkDetailMetaRows({ - dueHint, +export function buildHomeworkMetadataRows({ formatDate, homework, labels, }: { - dueHint?: string | null; - formatDate: (value: HomeworkDetailMetaValue) => string; - homework: HomeworkDetailDates; + formatDate: HomeworkDetailDateFormatter; + homework: { + publishedAt?: HomeworkDetailDateValue; + submissionStartAt?: HomeworkDetailDateValue; + }; labels: { publishedAt: string; - submissionDue: string; submissionStart: string; }; }): HomeworkDetailMetaRow[] { return [ { - emphasis: false, - hint: null, key: "publishedAt", label: labels.publishedAt, value: formatDate(homework.publishedAt), }, { - emphasis: false, - hint: null, key: "submissionStartAt", label: labels.submissionStart, value: formatDate(homework.submissionStartAt), }, - { - emphasis: true, - hint: optionalText(dueHint), - key: "submissionDueAt", - label: labels.submissionDue, - value: formatDate(homework.submissionDueAt), - }, ]; } /** - * Attribute chips share one variant so they read as a uniform group next to the - * completion status badge, which carries its own emphasis. + * Only non-default attributes become chips: standard homework never gets a + * "standard" badge. */ export function buildHomeworkDetailTags({ homework, labels, }: { - homework: HomeworkDetailFlags; + homework: { + isMajor?: boolean | null; + requiresTeam?: boolean | null; + }; labels: { tagMajor: string; tagTeam: string; @@ -98,10 +128,10 @@ export function buildHomeworkDetailTags({ }): HomeworkDetailTag[] { const tags: HomeworkDetailTag[] = []; if (homework.isMajor) { - tags.push({ key: "major", label: labels.tagMajor, variant: "secondary" }); + tags.push({ key: "major", label: labels.tagMajor }); } if (homework.requiresTeam) { - tags.push({ key: "team", label: labels.tagTeam, variant: "secondary" }); + tags.push({ key: "team", label: labels.tagTeam }); } return tags; } diff --git a/src/features/section-detail/components/SectionDetailDialogs.svelte b/src/features/section-detail/components/SectionDetailDialogs.svelte index aeb28d390..a0c1a7da1 100644 --- a/src/features/section-detail/components/SectionDetailDialogs.svelte +++ b/src/features/section-detail/components/SectionDetailDialogs.svelte @@ -40,7 +40,6 @@ export let homeworkAuditActorName: SectionDetailDialogsProps["homeworkAuditActor export let homeworkAuditLogs: SectionDetailDialogsProps["homeworkAuditLogs"]; export let homeworkCopy: SectionDetailDialogsProps["homeworkCopy"]; export let homeworkMessage: string; -export let homeworkStatus: SectionDetailDialogsProps["homeworkStatus"]; export let isCalendarDialogOpen: boolean; export let isHomeworkAuditDialogOpen: boolean; export let sectionCopy: SectionDetailDialogsProps["sectionCopy"]; @@ -145,7 +144,6 @@ $: if (isCalendarDialogOpen) { {homeworkAuditLogs} {homeworkCopy} {homeworkMessage} - {homeworkStatus} {isHomeworkAuditDialogOpen} {sectionCopy} sectionJwId={data.section.jwId} diff --git a/src/features/section-detail/components/SectionDetailPageController.svelte b/src/features/section-detail/components/SectionDetailPageController.svelte index 05bf474ce..f0ae498e9 100644 --- a/src/features/section-detail/components/SectionDetailPageController.svelte +++ b/src/features/section-detail/components/SectionDetailPageController.svelte @@ -23,7 +23,6 @@ import { canManageSectionHomework, canWriteSectionHomework, sectionHomeworkAuditLogs, - sectionHomeworkStatus, } from "@/features/section-detail/lib/section-detail-derived-state"; import { createSectionDetailHomeworkActions } from "@/features/section-detail/lib/section-detail-homework-actions"; import { createSectionHomeworkTimestampActions } from "@/features/section-detail/lib/section-detail-homework-timestamp-actions"; @@ -400,10 +399,6 @@ const { }, }); -function _homeworkStatus(homework: SectionHomework) { - return sectionHomeworkStatus(homework, _homeworkCopy); -} - function _auditLogsForHomework(homeworkId: string) { return sectionHomeworkAuditLogs(_homeworkAuditLogs, homeworkId); } @@ -526,7 +521,6 @@ onMount(() => { homeworkAuditLogs={_homeworkAuditLogs} homeworkCopy={_homeworkCopy} homeworkMessage={_homeworkMessage} - homeworkStatus={_homeworkStatus} isCalendarDialogOpen={_isCalendarDialogOpen} isHomeworkAuditDialogOpen={_isHomeworkAuditDialogOpen} sectionCopy={_sectionCopy} diff --git a/src/features/section-detail/components/SectionHomeworkActionBar.svelte b/src/features/section-detail/components/SectionHomeworkActionBar.svelte index 3033dd81c..c54f28fcd 100644 --- a/src/features/section-detail/components/SectionHomeworkActionBar.svelte +++ b/src/features/section-detail/components/SectionHomeworkActionBar.svelte @@ -4,6 +4,7 @@ import PencilIcon from "@lucide/svelte/icons/pencil"; import RotateCcwIcon from "@lucide/svelte/icons/rotate-ccw"; import Trash2Icon from "@lucide/svelte/icons/trash-2"; import { Button } from "$lib/components/ui/button/index.js"; +import { Separator } from "$lib/components/ui/separator/index.js"; import type { SectionHomeworkAction, SectionHomeworkCopy, @@ -21,36 +22,44 @@ export let toggleHomeworkCompletion: SectionHomeworkAction; -{#if canManage} - -{/if} -{#if canWrite} - - +{#if canWrite || canManage} +
+ +
+ {#if canManage} + + {/if} + {#if canWrite} +
+ + +
+ {/if} +
+
{/if} diff --git a/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte b/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte index 931ca8222..aab65c58a 100644 --- a/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte +++ b/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte @@ -2,7 +2,6 @@ import type { Component } from "svelte"; import { commentTargetPermalinkBaseHref } from "@/features/comments/lib/comment-panel-controller"; import DetailDialog from "$lib/components/DetailDialog.svelte"; -import { Badge } from "$lib/components/ui/badge/index.js"; import SectionHomeworkActionBar from "./SectionHomeworkActionBar.svelte"; import SectionHomeworkAuditTrail from "./SectionHomeworkAuditTrail.svelte"; import SectionHomeworkEditForm from "./SectionHomeworkEditForm.svelte"; @@ -16,7 +15,6 @@ import type { SectionHomeworkDisplay, SectionHomeworkFormatter, SectionHomeworkMarkdownCopy, - SectionHomeworkSectionCopy, SectionHomeworkSemesterDate, SectionHomeworkSubmitHandler, SectionHomeworkTimestampAction, @@ -49,8 +47,6 @@ export let _fmtDateTime: SectionHomeworkFormatter; export let _formatMessage: FormatMessage; export let _homeworkAuditActionLabel: (action: string) => string; export let _homeworkCopy: SectionHomeworkCopy; -export let _homeworkStatus: (homework: SectionHomeworkDisplay) => string; -export let _sectionCopy: SectionHomeworkSectionCopy & { due: string }; export let _selectedHomework: SectionHomeworkDisplay | null; export let _semesterDate: SectionHomeworkSemesterDate; export let _setDeleteHomeworkTarget: SectionHomeworkAction; @@ -63,18 +59,11 @@ export let sectionJwId: number | string; {#if _selectedHomework} {@const homework = _selectedHomework} - - {#snippet badges()} - - {_homeworkStatus(homework)} - - {/snippet} - + + + {#snippet body()} {#if _editingHomework} + + {/if} {/key} {/snippet} - - {#snippet footer()} - - {/snippet} {/if} diff --git a/src/features/section-detail/components/SectionHomeworkDialogs.svelte b/src/features/section-detail/components/SectionHomeworkDialogs.svelte index e3924ec13..d48f77301 100644 --- a/src/features/section-detail/components/SectionHomeworkDialogs.svelte +++ b/src/features/section-detail/components/SectionHomeworkDialogs.svelte @@ -49,7 +49,6 @@ export let homeworkAuditActorName: SectionDetailHomeworkDialogsProps["homeworkAu export let homeworkAuditLogs: SectionDetailHomeworkDialogsProps["homeworkAuditLogs"]; export let homeworkCopy: SectionDetailHomeworkDialogsProps["homeworkCopy"]; export let homeworkMessage: string; -export let homeworkStatus: SectionDetailHomeworkDialogsProps["homeworkStatus"]; export let isHomeworkAuditDialogOpen: boolean; export let sectionCopy: SectionDetailHomeworkDialogsProps["sectionCopy"]; export let sectionJwId: number | string; @@ -109,8 +108,6 @@ export let updateHomework: SectionDetailHomeworkDialogsProps["updateHomework"]; _formatMessage={formatMessage} _homeworkAuditActionLabel={homeworkAuditActionLabel} _homeworkCopy={homeworkCopy} - _homeworkStatus={homeworkStatus} - _sectionCopy={sectionCopy} _selectedHomework={selectedHomework} _semesterDate={semesterDate} _setDeleteHomeworkTarget={setDeleteHomeworkTarget} diff --git a/src/features/section-detail/components/SectionHomeworkReadOnlySummary.svelte b/src/features/section-detail/components/SectionHomeworkReadOnlySummary.svelte index 3bc086127..fc7506e77 100644 --- a/src/features/section-detail/components/SectionHomeworkReadOnlySummary.svelte +++ b/src/features/section-detail/components/SectionHomeworkReadOnlySummary.svelte @@ -1,9 +1,12 @@ - + {#if homework.description?.content} {#if homework.description.renderedHtml} @@ -48,6 +62,8 @@ $: tags = buildHomeworkDetailTags({ homework, labels: homeworkCopy }); - + + + diff --git a/src/features/section-detail/components/section-detail-dialog-types.ts b/src/features/section-detail/components/section-detail-dialog-types.ts index 97b76bb4d..220e52c2c 100644 --- a/src/features/section-detail/components/section-detail-dialog-types.ts +++ b/src/features/section-detail/components/section-detail-dialog-types.ts @@ -132,7 +132,6 @@ export type SectionDetailHomeworkDialogsProps = { homeworkAuditLogs: SectionHomeworkAuditLog[]; homeworkCopy: SectionDetailHomeworkCopy; homeworkMessage: string; - homeworkStatus: (homework: SectionHomework) => string; isHomeworkAuditDialogOpen: boolean; sectionCopy: SectionDetailSectionCopy; selectedHomework: SectionHomework | null; diff --git a/src/features/section-detail/components/section-homework-display-types.ts b/src/features/section-detail/components/section-homework-display-types.ts index 70034c1c7..6860201b5 100644 --- a/src/features/section-detail/components/section-homework-display-types.ts +++ b/src/features/section-detail/components/section-homework-display-types.ts @@ -9,6 +9,7 @@ export interface SectionHomeworkCopy extends HomeworkStyleGuideCopy { auditTitle: string; calendarButtonLabel: string; cancel: string; + completedLabel: string; contentHistoryAction: string; contentHistoryActor: string; deleteAction: string; @@ -18,6 +19,7 @@ export interface SectionHomeworkCopy extends HomeworkStyleGuideCopy { descriptionLabel: string; descriptionPlaceholder: string; editAction: string; + filterIncomplete: string; helperClear: string; helperMonth: string; helperPublishNow: string; @@ -37,10 +39,6 @@ export interface SectionHomeworkCopy extends HomeworkStyleGuideCopy { titlePlaceholder: string; } -export type SectionHomeworkSectionCopy = { - close?: string; -}; - export type SectionHomeworkCommonCopy = { unknown: string; }; diff --git a/src/features/section-detail/lib/section-detail-controller-types.ts b/src/features/section-detail/lib/section-detail-controller-types.ts index 13ccd6a0c..2ea4f835b 100644 --- a/src/features/section-detail/lib/section-detail-controller-types.ts +++ b/src/features/section-detail/lib/section-detail-controller-types.ts @@ -172,6 +172,7 @@ export type SectionDetailCopy = { descriptionLabel: string; descriptionPlaceholder: string; editAction: string; + filterIncomplete: string; helperClear: string; helperMonth: string; helperPublishNow: string; diff --git a/src/features/section-detail/lib/section-detail-derived-state.ts b/src/features/section-detail/lib/section-detail-derived-state.ts index b05d26729..3d8ace58d 100644 --- a/src/features/section-detail/lib/section-detail-derived-state.ts +++ b/src/features/section-detail/lib/section-detail-derived-state.ts @@ -89,15 +89,6 @@ export function canManageSectionHomework( ); } -export function sectionHomeworkStatus( - homework: SectionHomework, - homeworkCopy: SectionDetailCopy["homeworks"], -) { - return homework.completion - ? homeworkCopy.completedLabel - : homeworkCopy.tagDefault; -} - export function sectionHomeworkAuditLogs( logs: HomeworkAuditLog[], homeworkId: string, diff --git a/src/lib/components/DetailDialog.svelte b/src/lib/components/DetailDialog.svelte index 8ab3ae194..9022104e8 100644 --- a/src/lib/components/DetailDialog.svelte +++ b/src/lib/components/DetailDialog.svelte @@ -5,31 +5,26 @@ import { ScrollArea } from "$lib/components/ui/scroll-area/index.js"; import { cn } from "$lib/utils.js"; type Props = { - /** Secondary column, e.g. a discussion rail. Widens the dialog when set. */ + /** + * Discussion column. Per `docs/contracts/_ui.json` layout principles, popups + * with discussion keep details on the left and discussion on the right on + * desktop, and stack in that order on mobile. + */ aside?: Snippet; - badges?: Snippet; body: Snippet; class?: string; - footer?: Snippet; - footerClass?: string; onClose: () => void; open?: boolean; - /** Lets callers keep one footer snippet and drop the bar in some states. */ - showFooter?: boolean; subtitle?: string; title: string; }; let { aside, - badges, body, class: className = "", - footer, - footerClass = "", onClose, open = true, - showFooter = true, subtitle = "", title, }: Props = $props(); @@ -49,13 +44,10 @@ let { className, )} > - -
- - {title} - - {#if badges}{@render badges()}{/if} -
+ + + {title} + {#if subtitle} {subtitle} {/if} @@ -79,13 +71,5 @@ let { {/if}
- - {#if footer && showFooter} - - {@render footer()} - - {/if} diff --git a/tests/e2e/src/app/dashboard/homeworks/test.ts b/tests/e2e/src/app/dashboard/homeworks/test.ts index bab5c2d04..d391ddd3b 100644 --- a/tests/e2e/src/app/dashboard/homeworks/test.ts +++ b/tests/e2e/src/app/dashboard/homeworks/test.ts @@ -27,8 +27,9 @@ import { closeDetailDialog, detailDialog, detailDialogAside, - detailDialogFooter, - expectHomeworkTimelineCells, + detailDialogBody, + expectDialogActionsInBody, + expectHomeworkDetailOrder, } from "../../../../utils/detail-dialog"; import { DEV_SEED } from "../../../../utils/dev-seed"; import { cleanupHomeworksForE2e } from "../../../../utils/homeworks"; @@ -377,7 +378,7 @@ test.describe("仪表盘作业", () => { dialog.getByText(/待处理|已完成|Pending|Completed/i).first(), ).toBeVisible(); - await expectHomeworkTimelineCells(dialog); + await expectHomeworkDetailOrder(dialog); await expect( detailDialogAside(dialog).getByRole("heading", { @@ -385,15 +386,15 @@ test.describe("仪表盘作业", () => { }), ).toBeVisible(); - const footer = detailDialogFooter(dialog); await expect( - footer.getByRole("link", { name: /查看详情|View details/i }), - ).toBeVisible(); - await expect( - footer.getByRole("button", { - name: /标记为完成|取消完成|Mark as complete|Mark as incomplete/i, + detailDialogBody(dialog).getByRole("link", { + name: /查看详情|View details/i, }), ).toBeVisible(); + await expectDialogActionsInBody( + dialog, + /标记为完成|取消完成|Mark as complete|Mark as incomplete/i, + ); await captureStepScreenshot(page, testInfo, "homeworks/detail-dialog"); diff --git a/tests/e2e/src/app/dashboard/todos/test.ts b/tests/e2e/src/app/dashboard/todos/test.ts index 0e497c3ec..349a9111d 100644 --- a/tests/e2e/src/app/dashboard/todos/test.ts +++ b/tests/e2e/src/app/dashboard/todos/test.ts @@ -24,7 +24,7 @@ import { signInAsDebugUser } from "../../../../utils/auth"; import { closeDetailDialog, detailDialog, - detailDialogFooter, + expectDialogActionsInBody, } from "../../../../utils/detail-dialog"; import { DEV_SEED } from "../../../../utils/dev-seed"; import { visibleText } from "../../../../utils/locators"; @@ -182,16 +182,9 @@ test.describe("仪表盘待办", () => { dialog.getByText(/待处理|已完成|Pending|Completed/i).first(), ).toBeVisible(); - const footer = detailDialogFooter(dialog); - await expect( - footer.getByRole("button", { name: /删除待办|Delete todo/i }), - ).toBeVisible(); - await expect( - footer.getByRole("button", { name: /编辑待办|Edit Todo/i }), - ).toBeVisible(); - await expect( - footer.getByRole("button", { name: /标记为完成|Mark as complete/i }), - ).toBeVisible(); + await expectDialogActionsInBody(dialog, /删除待办|Delete todo/i); + await expectDialogActionsInBody(dialog, /编辑待办|Edit Todo/i); + await expectDialogActionsInBody(dialog, /标记为完成|Mark as complete/i); await captureStepScreenshot(page, testInfo, "todos/detail-dialog"); diff --git a/tests/e2e/src/app/sections/[jwId]/test.ts b/tests/e2e/src/app/sections/[jwId]/test.ts index ce6532f0c..134d9c74d 100644 --- a/tests/e2e/src/app/sections/[jwId]/test.ts +++ b/tests/e2e/src/app/sections/[jwId]/test.ts @@ -49,9 +49,9 @@ import { closeDetailDialog, detailDialog, detailDialogAside, - detailDialogFooter, expectDetailDialogFitsViewport, - expectHomeworkTimelineCells, + expectDialogActionsInBody, + expectHomeworkDetailOrder, } from "../../../../utils/detail-dialog"; import { DEV_SEED } from "../../../../utils/dev-seed"; import { getCurrentSessionUser } from "../../../../utils/e2e-db"; @@ -1186,10 +1186,10 @@ test.describe("/catalog/sections/[jwId] 班级详情页", () => { }), ).toBeVisible(); await expect( - dialog.getByText(/常规作业|已完成|Standard|Completed/i).first(), + dialog.getByText(/未完成|已完成|Incomplete|Completed/i).first(), ).toBeVisible(); - await expectHomeworkTimelineCells(dialog); + await expectHomeworkDetailOrder(dialog); await expect( detailDialogAside(dialog).getByRole("heading", { @@ -1197,15 +1197,11 @@ test.describe("/catalog/sections/[jwId] 班级详情页", () => { }), ).toBeVisible(); - const footer = detailDialogFooter(dialog); - await expect( - footer.getByRole("button", { name: /编辑信息|Edit details/i }), - ).toBeVisible(); - await expect( - footer.getByRole("button", { - name: /标记为完成|取消完成|Mark as complete|Mark as incomplete/i, - }), - ).toBeVisible(); + await expectDialogActionsInBody(dialog, /编辑信息|Edit details/i); + await expectDialogActionsInBody( + dialog, + /标记为完成|取消完成|Mark as complete|Mark as incomplete/i, + ); await captureStepScreenshot( page, @@ -1234,14 +1230,11 @@ test.describe("/catalog/sections/[jwId] 班级详情页", () => { const dialog = detailDialog(page); await expect(dialog).toBeVisible(); await expectDetailDialogFitsViewport(page, dialog); - await expectHomeworkTimelineCells(dialog); - - const primaryAction = detailDialogFooter(dialog) - .getByRole("button", { - name: /标记为完成|取消完成|Mark as complete|Mark as incomplete/i, - }) - .first(); - await expect(primaryAction).toBeVisible(); + await expectHomeworkDetailOrder(dialog); + await expectDialogActionsInBody( + dialog, + /标记为完成|取消完成|Mark as complete|Mark as incomplete/i, + ); await expect( detailDialogAside(dialog).getByRole("heading", { @@ -1332,7 +1325,7 @@ test.describe("/catalog/sections/[jwId] 班级详情页", () => { ).toBeVisible(); const dueValue = detailDialog - .locator("dl") + .locator('[data-slot="item"]') .filter({ hasText: /Submission due|提交截止/ }) .first(); await expect(dueValue).toContainText( diff --git a/tests/e2e/utils/detail-dialog.ts b/tests/e2e/utils/detail-dialog.ts index 4f3d6627d..14f4d9a41 100644 --- a/tests/e2e/utils/detail-dialog.ts +++ b/tests/e2e/utils/detail-dialog.ts @@ -14,10 +14,6 @@ export function detailDialogAside(dialog: Locator) { return dialog.locator('[data-slot="detail-dialog-aside"]'); } -export function detailDialogFooter(dialog: Locator) { - return dialog.locator('[data-slot="dialog-footer"]'); -} - /** * The shared dialog shell names its close control with `aria-label` only, so a * visible "Close" string next to the icon is a regression. @@ -56,14 +52,46 @@ export async function expectDetailDialogFitsViewport( expect(box.height).toBeLessThanOrEqual(viewport.height); } -/** Publish → submission window cells rendered by `HomeworkDetailMetaGrid`. */ -export async function expectHomeworkTimelineCells(dialog: Locator) { +/** + * Documented homework popup order (`docs/contracts/homework.json`): description, + * due summary, vertical metadata excluding platform createdAt, action controls, + * then discussion. The due summary carries the primary properties, so it must + * come before the metadata rows, and the metadata list must not repeat the due + * date or expose a creation timestamp. + */ +export async function expectHomeworkDetailOrder(dialog: Locator) { const body = detailDialogBody(dialog); - await expect(body.getByText(/发布日期|Published/i).first()).toBeVisible(); - await expect( - body.getByText(/提交开始|Submission opens/i).first(), - ).toBeVisible(); - await expect( - body.getByText(/提交截止|Submission due/i).first(), - ).toBeVisible(); + + const dueSummary = body + .locator('[data-slot="item"]') + .filter({ hasText: /提交截止|Submission due/i }) + .first(); + await expect(dueSummary).toBeVisible(); + + const metadata = body.locator("dl").first(); + await expect(metadata).toBeVisible(); + await expect(metadata.getByText(/发布日期|Published/i)).toBeVisible(); + await expect(metadata.getByText(/提交开始|Submission opens/i)).toBeVisible(); + await expect(metadata.getByText(/提交截止|Submission due/i)).toHaveCount(0); + await expect(metadata.getByText(/创建于|Created/i)).toHaveCount(0); + + const dueBox = await dueSummary.boundingBox(); + const metadataBox = await metadata.boundingBox(); + expect(dueBox).not.toBeNull(); + expect(metadataBox).not.toBeNull(); + if (dueBox && metadataBox) { + expect(dueBox.y).toBeLessThan(metadataBox.y); + } +} + +/** Action controls belong in the details column, ahead of the discussion. */ +export async function expectDialogActionsInBody( + dialog: Locator, + actionName: RegExp, +) { + const action = detailDialogBody(dialog) + .getByRole("button", { name: actionName }) + .first(); + await expect(action).toBeVisible(); + return action; } diff --git a/tests/unit/homework-detail-meta.test.ts b/tests/unit/homework-detail-meta.test.ts index 8ea86276b..e1112026b 100644 --- a/tests/unit/homework-detail-meta.test.ts +++ b/tests/unit/homework-detail-meta.test.ts @@ -1,13 +1,19 @@ import { describe, expect, it, vi } from "vitest"; import { - buildHomeworkDetailMetaRows, buildHomeworkDetailTags, + buildHomeworkDueSummary, + buildHomeworkMetadataRows, + homeworkCompletionStatusLabel, } from "@/features/homeworks/lib/homework-detail-meta"; -const labels = { +const statusLabels = { + completedStatus: "Completed", + incompleteStatus: "Incomplete", +}; + +const metaLabels = { publishedAt: "Published", - submissionDue: "Submission due", - submissionStart: "Submission start", + submissionStart: "Submission opens", }; const tagLabels = { @@ -19,89 +25,127 @@ function formatDate(value: Date | string | null | undefined) { return value ? `formatted:${String(value)}` : "TBD"; } -describe("buildHomeworkDetailMetaRows", () => { - it("orders cells from publish date through the submission window", () => { - const rows = buildHomeworkDetailMetaRows({ - formatDate, - homework: { - publishedAt: "2026-06-01T00:00:00Z", - submissionDueAt: "2026-06-20T00:00:00Z", - submissionStartAt: "2026-06-10T00:00:00Z", - }, - labels, - }); - - expect(rows.map((row) => row.key)).toEqual([ - "publishedAt", - "submissionStartAt", - "submissionDueAt", - ]); - expect(rows.map((row) => row.label)).toEqual([ - "Published", - "Submission start", - "Submission due", - ]); - expect(rows.map((row) => row.value)).toEqual([ - "formatted:2026-06-01T00:00:00Z", - "formatted:2026-06-10T00:00:00Z", - "formatted:2026-06-20T00:00:00Z", - ]); +describe("homeworkCompletionStatusLabel", () => { + it("reports completion status rather than a homework attribute", () => { + expect(homeworkCompletionStatusLabel(true, statusLabels)).toBe("Completed"); + expect(homeworkCompletionStatusLabel(false, statusLabels)).toBe( + "Incomplete", + ); }); +}); - it("emphasizes only the due cell", () => { - const rows = buildHomeworkDetailMetaRows({ - formatDate, - homework: {}, - labels, +describe("buildHomeworkDueSummary", () => { + it("carries the due date, status and relative label as primary properties", () => { + expect( + buildHomeworkDueSummary({ + completed: false, + dueLabel: "Submission due", + etaLabel: "in 3 days", + formatDate, + homework: { submissionDueAt: "2026-06-20T00:00:00Z" }, + statusLabel: "Incomplete", + }), + ).toEqual({ + completed: false, + dueLabel: "Submission due", + dueValue: "formatted:2026-06-20T00:00:00Z", + etaLabel: "in 3 days", + statusLabel: "Incomplete", }); - - expect(rows.filter((row) => row.emphasis).map((row) => row.key)).toEqual([ - "submissionDueAt", - ]); }); - it("formats missing dates through the caller's formatter", () => { + it("formats a missing due date through the caller's formatter", () => { const formatter = vi.fn(formatDate); - const rows = buildHomeworkDetailMetaRows({ + const summary = buildHomeworkDueSummary({ + completed: true, + dueLabel: "Submission due", formatDate: formatter, - homework: { submissionDueAt: null }, - labels, + homework: {}, + statusLabel: "Completed", }); - expect(formatter).toHaveBeenCalledTimes(3); expect(formatter).toHaveBeenCalledWith(undefined); - expect(formatter).toHaveBeenCalledWith(null); - expect(rows.map((row) => row.value)).toEqual(["TBD", "TBD", "TBD"]); + expect(summary.dueValue).toBe("TBD"); + expect(summary.completed).toBe(true); }); - it("keeps a trimmed due hint and drops blank hints", () => { - const withHint = buildHomeworkDetailMetaRows({ - dueHint: " in 3 days ", + it("keeps a trimmed relative label and drops blank ones", () => { + const trimmed = buildHomeworkDueSummary({ + completed: false, + dueLabel: "Submission due", + etaLabel: " overdue ", formatDate, homework: {}, - labels, + statusLabel: "Incomplete", }); - const withBlankHint = buildHomeworkDetailMetaRows({ - dueHint: " ", + const blank = buildHomeworkDueSummary({ + completed: false, + dueLabel: "Submission due", + etaLabel: " ", formatDate, homework: {}, - labels, + statusLabel: "Incomplete", }); - const withoutHint = buildHomeworkDetailMetaRows({ + const missing = buildHomeworkDueSummary({ + completed: false, + dueLabel: "Submission due", formatDate, homework: {}, - labels, + statusLabel: "Incomplete", }); - expect(withHint.map((row) => row.hint)).toEqual([null, null, "in 3 days"]); - expect(withBlankHint.map((row) => row.hint)).toEqual([null, null, null]); - expect(withoutHint.map((row) => row.hint)).toEqual([null, null, null]); + expect(trimmed.etaLabel).toBe("overdue"); + expect(blank.etaLabel).toBeNull(); + expect(missing.etaLabel).toBeNull(); + }); +}); + +describe("buildHomeworkMetadataRows", () => { + it("lists the publication date before the submission window opens", () => { + const rows = buildHomeworkMetadataRows({ + formatDate, + homework: { + publishedAt: "2026-06-01T00:00:00Z", + submissionStartAt: "2026-06-10T00:00:00Z", + }, + labels: metaLabels, + }); + + expect(rows).toEqual([ + { + key: "publishedAt", + label: "Published", + value: "formatted:2026-06-01T00:00:00Z", + }, + { + key: "submissionStartAt", + label: "Submission opens", + value: "formatted:2026-06-10T00:00:00Z", + }, + ]); + }); + + it("excludes the due date and platform timestamps", () => { + const rows = buildHomeworkMetadataRows({ + formatDate, + homework: { + createdAt: "2026-05-01T00:00:00Z", + submissionDueAt: "2026-06-20T00:00:00Z", + } as Parameters[0]["homework"], + labels: metaLabels, + }); + + expect(rows.map((row) => row.key)).toEqual([ + "publishedAt", + "submissionStartAt", + ]); + expect(rows.every((row) => row.value === "TBD")).toBe(true); }); }); describe("buildHomeworkDetailTags", () => { - it("returns nothing when no flag is set", () => { + it("returns nothing for standard homework", () => { expect( buildHomeworkDetailTags({ homework: { isMajor: false, requiresTeam: false }, @@ -116,15 +160,15 @@ describe("buildHomeworkDetailTags", () => { ).toEqual([]); }); - it("lists major before team as one uniform chip group", () => { + it("lists major before team", () => { expect( buildHomeworkDetailTags({ homework: { isMajor: true, requiresTeam: true }, labels: tagLabels, }), ).toEqual([ - { key: "major", label: "Major assignment", variant: "secondary" }, - { key: "team", label: "Team required", variant: "secondary" }, + { key: "major", label: "Major assignment" }, + { key: "team", label: "Team required" }, ]); });