Minimum-viable port of the user-facing Learning Hub at /app/learning.
Sidebar nav flipped to available in the same commit.
client/src/pages/Learning.tsx
Three-screen flow: search + category pills drive a feed grid; clicking
a card opens the viewer; viewer shows body + progress + quiz (if any).
Feed: one query key per filter — ['learning-feed'], ['learning-category',
slug], or ['learning-search', q] — so React Query caches each view
independently and flicking between categories is instant after the first
load. Search hits /api/learning/search; category filter hits
/api/learning/category/:slug; default hits /api/learning/feed?limit=30.
Viewer: body rendered as pre-wrap text intentionally. The vanilla tree
uses DOMPurify (CDN-loaded) to render HTML bodies; adding that dep to
the client bundle is a follow-up. Authored content is still clinical
info, so plain-text preservation is acceptable for this commit — no
content is lost, just unstyled. Presentations (content_type === 'presentation')
link to the legacy viewer at /#learning/:slug — Marp slide rendering is
its own port.
Quiz: supports single-choice, multi-select, and true/false. Answers
tracked via { optionId?, optionIds: Set<number> } per question so the
same state shape drives both radio and checkbox rendering. Submit POSTs
to /api/learning/submit-quiz; results screen shows per-question verdict
with correct answer + why-incorrect + general explanation — same fields
the vanilla showQuizResults renders. Retake wipes the answer map;
Back-to-Feed returns to the list.
Progress list reads content.progress[] directly from the content response
— last 5 attempts, color-coded green/amber at 70%.
shared/types.ts + client/src/shared/types.ts — additive:
LearningCategory/LearningCategoriesOk, LearningFeedRow/LearningFeedListOk,
LearningOption/LearningQuestion/LearningProgressEntry/LearningContentFull/
LearningContentOk, QuizAnswer/QuizResultEntry/QuizSubmitOk. Keys match
the wire shape server routes return (snake_case for DB columns).
e2e/tests/learning-react.spec.js — three smoke tests:
shell renders, feed shows items OR empty-state (no crash on empty DB),
typing into search fires /api/learning/search.
Client tsc -b, server tsc --noEmit, and vite build all pass locally.
Bundle 435.44 kB / 123.81 kB gzipped (+10 kB over Settings complete).
40 lines
1.7 KiB
JavaScript
40 lines
1.7 KiB
JavaScript
// ============================================================
|
|
// LEARNING HUB (React port) — smoke tests for /app/learning.
|
|
//
|
|
// Covers minimum-viable port: search input + category pills +
|
|
// feed list render; clicking an item opens the viewer (if any
|
|
// content is seeded) and renders the Back button.
|
|
// ============================================================
|
|
|
|
const { test, expect, E2E_BASE } = require('../fixtures');
|
|
|
|
async function openReactLearning(page) {
|
|
await page.goto(E2E_BASE + '/app/learning');
|
|
await page.waitForSelector('[data-testid="lh-search"]', { timeout: 15000 });
|
|
}
|
|
|
|
test.describe('React Learning Hub — shell + feed', () => {
|
|
|
|
test('search + categories render', async ({ authedPage: _, page }) => {
|
|
await openReactLearning(page);
|
|
await expect(page.locator('[data-testid="lh-search"]')).toBeVisible();
|
|
await expect(page.locator('[data-testid="lh-categories"]')).toBeVisible();
|
|
});
|
|
|
|
test('feed either lists items or shows an empty-state (no crash)', async ({ authedPage: _, page }) => {
|
|
await openReactLearning(page);
|
|
// Either the feed grid exists with items, or the empty-state label shows.
|
|
const feedItems = await page.locator('[data-testid^="lh-feed-item-"]').count();
|
|
const empty = await page.getByText('No content found.').count();
|
|
expect(feedItems + empty).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('typing in search triggers a new request', async ({ authedPage: _, page }) => {
|
|
await openReactLearning(page);
|
|
const [resp] = await Promise.all([
|
|
page.waitForResponse('**/api/learning/search**', { timeout: 10000 }),
|
|
page.fill('[data-testid="lh-search"]', 'fever'),
|
|
]);
|
|
expect(resp.status()).toBe(200);
|
|
});
|
|
});
|