Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions frontend/e2e/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,19 +126,26 @@ export default async function globalSetup(config: FullConfig) {
fs.mkdirSync(path.dirname(ADMIN_AUTH_FILE), { recursive: true });

const authEntries = [
{ loginFn: loginAsStudent, authFile: STUDENT_AUTH_FILE },
{ loginFn: loginAsStaff, authFile: STAFF_AUTH_FILE },
{ loginFn: loginAsAdmin, authFile: ADMIN_AUTH_FILE },
{ loginFn: loginAsOfficer, authFile: OFFICER_AUTH_FILE },
{ loginFn: loginAsPoliceAdmin, authFile: POLICE_AUTH_FILE },
{ label: "student", loginFn: loginAsStudent, authFile: STUDENT_AUTH_FILE },
{ label: "staff", loginFn: loginAsStaff, authFile: STAFF_AUTH_FILE },
{ label: "admin", loginFn: loginAsAdmin, authFile: ADMIN_AUTH_FILE },
{ label: "officer", loginFn: loginAsOfficer, authFile: OFFICER_AUTH_FILE },
{
label: "police admin",
loginFn: loginAsPoliceAdmin,
authFile: POLICE_AUTH_FILE,
},
];
const stale = authEntries.filter(
({ authFile }) => !isAuthFileValid(authFile)
);
const stale = authEntries.filter(({ authFile }) => {
const valid = isAuthFileValid(authFile);
if (valid) console.log(`[auth] using cached ${authFile}`);
return !valid;
});

if (stale.length > 0) {
const browser = await chromium.launch({ chromiumSandbox: false });
for (const { loginFn, authFile } of stale) {
for (const { label, loginFn, authFile } of stale) {
console.log(`[auth] generating new ${label} auth at ${authFile}`);
await saveAuthState(browser, baseURL, loginFn, authFile);
}
await browser.close();
Expand Down
82 changes: 81 additions & 1 deletion frontend/e2e/test/student/dashboard-tracker.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { format } from "date-fns";
import { type Locator } from "@playwright/test";
import { format, parse } from "date-fns";
import { STUDENT_AUTH_FILE } from "../../global-setup";
import { resetDatabase } from "../../helpers/db.helpers";
import {
Expand All @@ -14,6 +15,8 @@ import { formatDateInput } from "../../helpers/seed.helpers";
// Helpers
// ---------------------------------------------------------------------------

const PARTY_DATE_TIME_FORMAT = "M/d/yyyy @ h:mm a";

async function openActivePartyMenu(page: Page) {
// Skip today's party cards — a party at e.g. 18:30 today becomes locked after
// that time, causing "This party has already occurred" errors. Target the first
Expand Down Expand Up @@ -42,6 +45,27 @@ async function setValidPartyDateTime(page: Page, daysAhead: number) {
await page.getByLabel("Party Time").fill("20:00");
}

async function getPartyCardTimes(panel: Locator): Promise<number[]> {
const dateTexts = await panel
.locator(".border-b.border-gray-200 .content-bold p")
.allTextContents();

return dateTexts.map((text) =>
parse(text.trim(), PARTY_DATE_TIME_FORMAT, new Date()).getTime()
);
}

function expectOrderedClosestToNow(times: number[]) {
const now = Date.now();
const distances = times.map((time) => Math.abs(time - now));

expect(distances).toEqual([...distances].sort((a, b) => a - b));
}

function expectOrderedNewestFirst(times: number[]) {
expect(times).toEqual([...times].sort((a, b) => b - a));
}

// ---------------------------------------------------------------------------
// Suite
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -81,6 +105,18 @@ test.describe("Dashboard tracker — student1", () => {
await expect(firstCard.locator("svg").first()).toBeVisible();
});

test("active tab orders party cards closest to now", async ({ page }) => {
const activePanel = page.getByRole("tabpanel", { name: "Active" });
await expect(activePanel).toBeVisible();
await expect(
activePanel.locator(".border-b.border-gray-200").first()
).toBeVisible();

const times = await getPartyCardTimes(activePanel);
expect(times.length).toBeGreaterThan(1);
expectOrderedClosestToNow(times);
});

test("past tab: party cards have no action menu", async ({ page }) => {
await page.getByRole("tab", { name: "Past" }).click();

Expand All @@ -99,6 +135,50 @@ test.describe("Dashboard tracker — student1", () => {
page.getByRole("button", { name: "Party actions" })
).toHaveCount(0);
});

test("past tab loads more party cards when scrolled", async ({ page }) => {
await page.getByRole("tab", { name: "Past" }).click();
await expect(page.getByRole("tab", { name: "Past" })).toHaveAttribute(
"aria-selected",
"true"
);

const pastPanel = page.getByRole("tabpanel", { name: "Past" });
await expect(pastPanel).toBeVisible();

const cards = pastPanel.locator(".border-b.border-gray-200");
await expect(cards.first()).toBeVisible();
const initialCount = await cards.count();

const scrollContainer = pastPanel.locator(":scope > div").first();
await scrollContainer.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});

await expect
.poll(() => cards.count(), {
message: "Past parties should load another page after scrolling",
})
.toBeGreaterThan(initialCount);
});

test("past tab orders party cards newest first", async ({ page }) => {
await page.getByRole("tab", { name: "Past" }).click();
await expect(page.getByRole("tab", { name: "Past" })).toHaveAttribute(
"aria-selected",
"true"
);

const pastPanel = page.getByRole("tabpanel", { name: "Past" });
await expect(pastPanel).toBeVisible();
await expect(
pastPanel.locator(".border-b.border-gray-200").first()
).toBeVisible();

const times = await getPartyCardTimes(pastPanel);
expect(times.length).toBeGreaterThan(1);
expectOrderedNewestFirst(times);
});
});

// -------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Card } from "@/components/ui/card";
import { SkeletonText } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useSnackbar } from "@/contexts/SnackbarContext";
import { useInfiniteScroll } from "@/hooks/useInfiniteScroll";
import { NestedIncidentStudentDto } from "@/lib/api/incident/incident.types";
import { hasActiveHold } from "@/lib/api/location/location.service";
import { useDeleteParty } from "@/lib/api/party/party.queries";
Expand All @@ -23,6 +24,9 @@ import { useState } from "react";
import RegistrationIncidentCard from "./RegistrationIncidentCard";
import RegistrationPartyCard from "./RegistrationPartyCard";

const PAST_PAGE_SIZE = 1;
const INITIAL_PAST_VISIBLE_COUNT = 5;

const EMPTY_CLASS =
"flex h-full items-center justify-center px-12 text-center content-sub text-base!";

Expand Down Expand Up @@ -100,6 +104,9 @@ export default function RegistrationTracker(): React.JSX.Element {
const [activeTab, setActiveTab] = useState<TabValue>("active");
const [editParty, setEditParty] = useState<PartyStudentDto | null>(null);
const [deleteParty, setDeleteParty] = useState<PartyStudentDto | null>(null);
const [pastScrollRoot, setPastScrollRoot] = useState<HTMLDivElement | null>(
null
);

const { openSnackbar } = useSnackbar();
const deletePartyMutation = useDeleteParty();
Expand Down Expand Up @@ -132,6 +139,16 @@ export default function RegistrationTracker(): React.JSX.Element {
: undefined;

const { activeParties, pastParties } = splitParties(partiesQuery.data ?? []);
const [pastVisibleCount, pastSentinelRef] = useInfiniteScroll(
pastParties.length,
PAST_PAGE_SIZE,
{
initialCount: INITIAL_PAST_VISIBLE_COUNT,
root: pastScrollRoot,
rootMargin: "200px 0px",
}
);
const visiblePastParties = pastParties.slice(0, pastVisibleCount);

const hasNoParties = (partiesQuery.data?.length ?? 0) === 0;
const showPartySmartPrompt = hasNoParties && !courseCompleted;
Expand Down Expand Up @@ -182,17 +199,24 @@ export default function RegistrationTracker(): React.JSX.Element {
pastParties.length === 0 ? (
<p className={EMPTY_CLASS}>Your party history will appear here.</p>
) : (
pastParties.map((party) => (
<RegistrationPartyCard
key={party.id}
party={party}
showAddress
residenceLocationId={residenceLocationId}
isPartiesPending={isPartiesPending}
onEdit={setEditParty}
onDelete={setDeleteParty}
/>
))
<>
{visiblePastParties.map((party) => (
<RegistrationPartyCard
key={party.id}
party={party}
showAddress
residenceLocationId={residenceLocationId}
isPartiesPending={isPartiesPending}
onEdit={setEditParty}
onDelete={setDeleteParty}
/>
))}
{pastVisibleCount < pastParties.length && (
<div ref={pastSentinelRef} className="px-4 py-4">
<SkeletonText className="max-w-full" />
</div>
)}
</>
),
},
{
Expand Down Expand Up @@ -260,7 +284,10 @@ export default function RegistrationTracker(): React.JSX.Element {
<Card className="w-full flex-1 min-h-0 overflow-hidden mt-2 flex flex-col">
{tabs.map(({ value, children }) => (
<TabsContent key={value} value={value} className="h-full">
<div className={TAB_CONTENT_CLASS}>
<div
ref={value === "past" ? setPastScrollRoot : undefined}
className={TAB_CONTENT_CLASS}
>
{isPartiesPending ? (
<PartiesLoading />
) : isPartiesError ? (
Expand Down
9 changes: 3 additions & 6 deletions frontend/src/components/ui/skeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn(
"animate-pulse rounded-md bg-[var(--muted-background)]",
className
)}
className={cn("animate-pulse rounded-md bg-muted-background", className)}
{...props}
/>
);
Expand All @@ -19,8 +16,8 @@ function SkeletonAvatar({ className, ...props }: React.ComponentProps<"div">) {
<div className={cn("flex w-fit items-center gap-4", className)} {...props}>
<Skeleton className="size-10 shrink-0 rounded-full" />
<div className="grid gap-2">
<Skeleton className="h-4 w-[150px]" />
<Skeleton className="h-4 w-[100px]" />
<Skeleton className="h-4 w-37.5" />
<Skeleton className="h-4 w-25" />
</div>
</div>
);
Expand Down
Loading
Loading