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
21 changes: 13 additions & 8 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,10 @@ npm run dev
- ✅ Submitted confirmation page and .well-known DevTools route

### **What's Coming (Phase 3)**
- 🚧 Notifications: Integrate bulk mark-read endpoint end-to-end with optimistic cache and retry/backoff (target `POST /api/notifications/read` with `{ ids: string[] }`).
- 🚧 Performance polish: Add skeleton loaders for Analytics and Students pages during initial load/refetch.
- 🚧 Testing: Extend Playwright for grading workspace edits/publish flow and notifications realtime badge; continue broadening a11y.
- 🚧 Realtime: Wire assessment publish/update and queue changes; expand predicate-based cache invalidation and coverage.
- 🚧 Testing: Extend a11y and additional UI smoke as new features land.
- ✅ Notifications: Bulk mark-read endpoint integrated with optimistic cache + retry/backoff.
- ✅ Realtime: Assessment publish/update and grading queue change events are wired to invalidate caches via predicate-based invalidation.
- ✅ Performance polish: Skeleton loaders added for Analytics and Students pages during initial load/refetch.

<!-- Recently Implemented section removed; details are now reflected in the Development Roadmap below. -->

Expand Down Expand Up @@ -157,6 +157,12 @@ frontend/
- `NEXT_PUBLIC_AUTH_MODE=mock`
- To run against a real API/gateway, set `NEXT_PUBLIC_AUTH_MODE=api` and configure `NEXT_PUBLIC_API_URL` or `NEXT_PUBLIC_GATEWAY_URL` accordingly. You may also remove or adjust route stubs in the specs.

## 🔑 Admin Panel Usage (quick notes)

- Admin routes are protected with a role guard; non-admin users are redirected to their dashboard.
- When logged in as an admin, the global nav shows “Admin” and “Users” links; the current route is highlighted.
- Admin pages are scaffolded under `/admin` with sections for Dashboard, Users, Settings, Audit, and Reports. Data wiring will be incrementally added as APIs land.

## �🏗️ Application Architecture

### **Single Page Application (SPA) Structure**
Expand Down Expand Up @@ -1164,10 +1170,9 @@ export function AssessmentForm({ assessment, onSubmit }: AssessmentFormProps) {
- 📝 Documentation

### Next steps
- Notifications: integrate bulk mark-read API end-to-end with optimistic cache + retry/backoff
- Performance polish: add skeleton loaders for Analytics and Students pages during initial load/refetch
- Testing: extend Playwright to cover grading workspace edits/publish flows and notifications realtime badge updates
- Realtime: wire assessment publish/update and queue changes; expand predicate-based invalidation and coverage
- Testing: keep expanding E2E/a11y as admin features are implemented
- Admin: wire dashboard metrics, users table (pagination/search/sort/filters), settings save/load with validation + audit log, audit filters/search/export, and system monitoring tiles
- Realtime: broaden event coverage as new admin/system events are introduced

---

Expand Down
12 changes: 6 additions & 6 deletions frontend/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ Derived from the Development Roadmap (active, pending items only).


Nice-to-have (adjacent improvements)
- [ ] Skeletons: Add lightweight skeleton loaders for Analytics and Students pages during initial load and refetch.
- [x] Skeletons: Add lightweight skeleton loaders for Analytics and Students pages during initial load and refetch.

Upcoming (Phase 3 carryover)
- [ ] Notifications: Integrate bulk mark-read endpoint end-to-end with optimistic cache and retry/backoff.
- [ ] Grading workspace E2E: Extend Playwright coverage for edit/publish flows and edge cases.
- [ ] Realtime: Wire assessment publish/update and queue changes; expand predicate-based invalidation.
- [x] Notifications: Integrate bulk mark-read endpoint end-to-end with optimistic cache and retry/backoff.
- [x] Grading workspace E2E: Extend Playwright coverage for edit/publish flows and edge cases.
- [x] Realtime: Wire assessment publish/update and queue changes; expand predicate-based invalidation.

Phase 4: Admin Experience (actionable tasks)

Expand Down Expand Up @@ -52,8 +52,8 @@ System monitoring
- [ ] A11y + unit tests

Admin navigation & permissions
- [ ] Guard admin routes; redirect non-admin users
- [ ] Add Admin to nav and highlight current route
- [x] Guard admin routes; redirect non-admin users
- [x] Add Admin to nav and highlight current route
- [ ] Role-based rendering of sensitive actions/components

Docs & E2E
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Skeleton } from "@/components/ui/skeleton";

export default function Loading() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-2">
<div className="h-6 w-56"><Skeleton /></div>
<div className="h-4 w-80"><Skeleton /></div>
</div>
<div className="flex gap-2">
<div className="h-8 w-28"><Skeleton /></div>
<div className="h-8 w-28"><Skeleton /></div>
<div className="h-8 w-28"><Skeleton /></div>
</div>
</div>

<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{[1,2,3,4].map((i) => (
<div key={i} className="rounded-md border p-4 space-y-2">
<div className="h-3 w-24"><Skeleton /></div>
<div className="h-7 w-20"><Skeleton /></div>
</div>
))}
</section>

<section className="space-y-3">
<div className="flex items-center justify-between">
<div className="h-5 w-40"><Skeleton /></div>
<div className="h-3 w-48"><Skeleton /></div>
</div>
<div className="rounded-md border p-3 space-y-2">
{[1,2,3,4].map((i) => (
<div key={i} className="grid grid-cols-5 gap-3 items-center">
<div className="h-4 w-48"><Skeleton /></div>
<div className="h-3 w-24"><Skeleton /></div>
<div className="h-3 w-24"><Skeleton /></div>
<div className="h-3 w-16"><Skeleton /></div>
<div className="h-3 w-12 justify-self-end"><Skeleton /></div>
</div>
))}
</div>
</section>
</div>
);
}
5 changes: 4 additions & 1 deletion frontend/src/app/teacher/grading/[submissionId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ export default function RubricPage() {
mutationFn: updateQuestion,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['grade', submissionId] });
}
},
onError: () => {
error('Failed to save question grade. Please check your input and try again.');
},
});

const publishMutation = useMutation({
Expand Down
35 changes: 27 additions & 8 deletions frontend/src/components/layout/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export function Header() {
// Hide header during assessment taking for distraction-free mode
if (pathname?.startsWith("/student/assessment/")) return null;

const linkCls = (href: string) => {
const active = pathname === href || (href !== '/' && pathname?.startsWith(href + '/'));
return `hover:underline ${active ? 'font-medium text-foreground' : ''}`.trim();
};

return (
<header className="sticky top-0 z-40 w-full border-b bg-background/80 backdrop-blur">
<div className="container flex h-14 items-center justify-between">
Expand All @@ -45,10 +50,19 @@ export function Header() {
<nav className="hidden lg:flex items-center gap-4 text-sm text-muted">
{hydrated && role && (
<>
<Link href={("/" + role) as unknown as Route}>Dashboard</Link>
{role === "student" && <Link href={("/student/assessments" as Route)}>Assessments</Link>}
{role === "teacher" && <Link href={("/teacher/assessments" as Route)}>Assessments</Link>}
{role === "admin" && <Link href={("/admin/users" as Route)}>Users</Link>}
<Link className={linkCls("/" + role)} href={("/" + role) as unknown as Route} aria-current={pathname === ("/" + role) ? 'page' : undefined}>Dashboard</Link>
{role === "student" && (
<Link className={linkCls("/student/assessments")} href={("/student/assessments" as Route)} aria-current={pathname?.startsWith("/student/assessments") ? 'page' : undefined}>Assessments</Link>
)}
{role === "teacher" && (
<Link className={linkCls("/teacher/assessments")} href={("/teacher/assessments" as Route)} aria-current={pathname?.startsWith("/teacher/assessments") ? 'page' : undefined}>Assessments</Link>
)}
{role === "admin" && (
<>
<Link className={linkCls("/admin")} href={("/admin" as Route)} aria-current={pathname === "/admin" ? 'page' : undefined}>Admin</Link>
<Link className={linkCls("/admin/users")} href={("/admin/users" as Route)} aria-current={pathname?.startsWith("/admin/users") ? 'page' : undefined}>Users</Link>
</>
)}
</>
)}
</nav>
Expand All @@ -74,10 +88,15 @@ export function Header() {
)}
{hydrated && role && (
<>
<Link href={("/" + role) as unknown as Route} onClick={() => setOpen(false)}>Dashboard</Link>
{role === "student" && <Link href={("/student/assessments" as Route)} onClick={() => setOpen(false)}>Assessments</Link>}
{role === "teacher" && <Link href={("/teacher/assessments" as Route)} onClick={() => setOpen(false)}>Assessments</Link>}
{role === "admin" && <Link href={("/admin/users" as Route)} onClick={() => setOpen(false)}>Users</Link>}
<Link href={("/" + role) as unknown as Route} onClick={() => setOpen(false)} aria-current={pathname === ("/" + role) ? 'page' : undefined}>Dashboard</Link>
{role === "student" && <Link href={("/student/assessments" as Route)} onClick={() => setOpen(false)} aria-current={pathname?.startsWith("/student/assessments") ? 'page' : undefined}>Assessments</Link>}
{role === "teacher" && <Link href={("/teacher/assessments" as Route)} onClick={() => setOpen(false)} aria-current={pathname?.startsWith("/teacher/assessments") ? 'page' : undefined}>Assessments</Link>}
{role === "admin" && (
<>
<Link href={("/admin" as Route)} onClick={() => setOpen(false)} aria-current={pathname === "/admin" ? 'page' : undefined}>Admin</Link>
<Link href={("/admin/users" as Route)} onClick={() => setOpen(false)} aria-current={pathname?.startsWith("/admin/users") ? 'page' : undefined}>Users</Link>
</>
)}
<button className="text-left" onClick={() => { logout(); setOpen(false); }}>Logout</button>
</>
)}
Expand Down
88 changes: 88 additions & 0 deletions frontend/src/tests/hooks/notifications-bulk.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import * as Api from '@/lib/api';
import { useBulkMarkNotificationsRead, applyNotificationsReadPatch } from '@/hooks/useNotifications';

jest.useFakeTimers();

function setupClient() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return qc;
}

function TestHarness({ ids }: { ids: string[] }) {
const mutate = useBulkMarkNotificationsRead();
return (
<button onClick={() => mutate.mutateAsync(ids)} data-testid="run" />
);
}

describe('notifications bulk mark-read', () => {
test('optimistically marks read and retries with backoff then succeeds', async () => {
const qc = setupClient();

// prime cache with unread items
const initial = [
{ id: 'n1', title: 't1', createdAt: new Date().toISOString(), read: false },
{ id: 'n2', title: 't2', createdAt: new Date().toISOString(), read: false },
];
qc.setQueryData(['notifications', 50], initial);

// mock API: fail twice, then succeed
const bulkSpy = jest.spyOn(Api.NotificationsApi, 'bulkMarkRead')
.mockRejectedValueOnce(new Error('fail-1'))
.mockRejectedValueOnce(new Error('fail-2'))
.mockResolvedValue({ success: true });

const ids = ['n1', 'n2'];

render(
<QueryClientProvider client={qc}>
<TestHarness ids={ids} />
</QueryClientProvider>
);

fireEvent.click(screen.getByTestId('run'));

// optimistic patch applied (may be async due to react-query batching)
await waitFor(() => {
const afterOptimistic = qc.getQueryData(['notifications', 50]) as any[];
expect(afterOptimistic && afterOptimistic.every((n) => n.read === true)).toBe(true);
});

// advance timers for retries: delays ~200ms, 400ms (cap < 1500 in hook)
await Promise.resolve();
jest.advanceTimersByTime(250);
await Promise.resolve();
jest.advanceTimersByTime(450);

await waitFor(() => expect(bulkSpy).toHaveBeenCalledTimes(3));

// final state remains read
const final = qc.getQueryData(['notifications', 50]) as any[];
expect(final.every((n) => n.read === true)).toBe(true);
});

test('applyNotificationsReadPatch handles arrays and nested shapes', () => {
const ids = new Set(['a', 'b']);
const arr = [
{ id: 'a', read: false },
{ id: 'x', read: false },
];
const res1 = applyNotificationsReadPatch(arr, ids) as any[];
expect(res1[0].read).toBe(true);
expect(res1[1].read).toBe(false);

const pages = {
pages: [
{ items: [{ id: 'b', read: false }], notifications: [{ id: 'z', read: false }] },
{ data: { notifications: [{ id: 'a', read: false }] } },
],
} as any;
const res2 = applyNotificationsReadPatch(pages, ids) as any;
expect(res2.pages[0].items[0].read).toBe(true);
expect(res2.pages[0].notifications[0].read).toBe(false);
expect(res2.pages[1].data.notifications[0].read).toBe(true);
});
});
91 changes: 91 additions & 0 deletions frontend/tests/e2e/grading-workspace-edge-cases.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { test, expect } from "@playwright/test";

// Covers validation/failure scenarios in grading workspace: save error and publish error

test.describe("Manual grading workspace edge cases", () => {
test.beforeEach(async ({ page, baseURL }) => {
await page.addInitScript(() => {
localStorage.setItem("auth_token", "dummy-token");
localStorage.setItem("auth_role", "teacher");
});

await page.route("**/api/auth/me", async (route) => {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: { userId: "t1", name: "Teacher Test", role: "teacher" } }),
});
});

// Provide base grading data
await page.route("**/api/grade/submission/**", async (route) => {
if (route.request().method() === "GET") {
const url = new URL(route.request().url());
const submissionId = url.pathname.split("/").pop() ?? "SUB-200";
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
submissionId,
totalScore: 10,
maxScore: 10,
percentage: 100,
questionGrades: [
{ questionId: "Q1", maxPoints: 10, pointsEarned: 10, feedback: "" },
],
},
}),
});
return;
}
if (route.request().method() === "PUT") {
// Simulate validation failure if points > max
const body = route.request().postDataJSON() as any;
if (typeof body?.pointsEarned === 'number' && body.pointsEarned > 10) {
await route.fulfill({ status: 400, contentType: 'application/json', body: JSON.stringify({ success: false, error: 'Invalid points' }) });
return;
}
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true }) });
return;
}
await route.continue();
});

// Publish failure endpoint
await page.route("**/grade/submission/**/publish", async (route) => {
await route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ success: false }) });
});

await page.goto(`${baseURL!}/teacher`);
});

test("shows error toast on invalid save and prevents navigation", async ({ page, baseURL }) => {
const submissionId = "SUB-200";
await page.goto(`${baseURL!}/teacher/grading/${submissionId}`);

await expect(page.getByRole("heading", { name: /manual grading/i })).toBeVisible();

const pointsInput = page.locator('#points-Q1');
await pointsInput.fill('12');

const saveButton = pointsInput.locator('xpath=ancestor::form').getByRole('button', { name: /save/i });
await saveButton.click();

await expect(page.getByRole('status').filter({ hasText: /failed to save question grade/i })).toBeVisible();
// Still on the same page
await expect(page).toHaveURL(/\/teacher\/grading\/SUB-200$/);
});

test("shows error toast on publish failure and stays in workspace", async ({ page, baseURL }) => {
const submissionId = "SUB-200";
await page.goto(`${baseURL!}/teacher/grading/${submissionId}`);

const publishReq = page.waitForRequest((r) => r.method() === 'POST' && r.url().includes(`/grade/submission/${submissionId}/publish`));
await page.getByRole('button', { name: /publish/i }).click();
await publishReq;

await expect(page.getByRole('status').filter({ hasText: /failed to publish submission/i })).toBeVisible();
await expect(page).toHaveURL(/\/teacher\/grading\/SUB-200$/);
});
});
Loading