feat: settings as places with addresses; repair nested cross-references
Settings was one 600px column holding the account form, a theme picker, a Nextcloud integration, a document list and an admin grid, in that order, with no way to link to any of it. It is now a section list beside one panel, with the section in the URL — so "change your password" is a link and Back works. On a phone the list becomes a scrolling strip rather than a second level of navigation. - The exam objective moves in. It scopes the bank, the filters and now the knowledge profile, which makes it a setting; it was only reachable from a dropdown in the header. - The notifications panel is gone. Its one control switched quiz reminders, and the reminder scheduler was removed earlier today — it was a toggle wired to nothing. - Form fields are 16px on touch so iOS does not zoom the page in on focus and refuse to zoom back out; nav rows are 44px targets. Also fixed, found in an agent's report rather than by looking: 37 cross-references across 25 articles are nested and broken — `[[363|[[245|gastroesophageal reflux]] disease]]`, which renders as literal brackets and resolves to nothing. The first linker pass linked the longest title, then let a shorter one cut into the result. The current pass cannot do this (a finished marker is stashed), but the damage was already in the database and strip_owned could not see it: its label group stops at the first "]". link_articles now unwraps the inner marker, keeping the outer — the longer, more specific title. And the ArticleSplitView flake: the preview card appears on a 350ms timer and the query allowed 2s, which the full parallel run exceeded often enough to fail a different case each time. Tried fake timers first; they fight waitFor. A longer allowance is the honest fix — the test is about the split view, not about how fast the box is. Four consecutive clean runs. Frontend 274/274. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
950c591ec5
commit
9d407ca1d9
5 changed files with 336 additions and 100 deletions
|
|
@ -72,6 +72,30 @@ def word_pattern(title: str) -> re.Pattern:
|
|||
return re.compile(rf"(?<![\w\[|]){re.escape(title)}(?![\w\]])", re.I)
|
||||
|
||||
|
||||
#: A marker whose label swallowed another marker. The first version of this
|
||||
#: script linked the longest title first and then let a shorter one cut into
|
||||
#: the result, producing `[[363|[[245|gastroesophageal reflux]] disease]]` —
|
||||
#: which renders as literal brackets and resolves to nothing.
|
||||
NESTED = re.compile(r"\[\[(\d+)\|([^\[\]]*)\[\[\d+\|([^\[\]]*)\]\]([^\[\]]*)\]\]")
|
||||
|
||||
|
||||
def unnest(text: str) -> tuple[str, int]:
|
||||
"""Unwrap the inner marker, keeping the outer.
|
||||
|
||||
The outer is the longer, more specific title — "Gastroesophageal reflux
|
||||
disease" over "Gastroesophageal reflux" — so it is the link worth keeping,
|
||||
and the inner one's label becomes plain words again.
|
||||
"""
|
||||
if not text:
|
||||
return text, 0
|
||||
fixed = 0
|
||||
while True:
|
||||
text, count = NESTED.subn(lambda m: f"[[{m.group(1)}|{m.group(2)}{m.group(3)}{m.group(4)}]]", text)
|
||||
fixed += count
|
||||
if not count: # nesting can be more than one deep
|
||||
return text, fixed
|
||||
|
||||
|
||||
def strip_owned(text: str, titles_by_id: dict[int, str]) -> tuple[str, int]:
|
||||
"""Remove the links this script owns, leaving the words behind.
|
||||
|
||||
|
|
@ -181,15 +205,22 @@ def main() -> int:
|
|||
|
||||
# Strip first, always: the rule is applied to clean prose so a re-run
|
||||
# cannot layer a new pass on top of an old one.
|
||||
stripped = 0
|
||||
stripped = repaired = 0
|
||||
|
||||
def clean(value):
|
||||
nonlocal stripped, repaired
|
||||
value, fixed = unnest(value)
|
||||
repaired += fixed
|
||||
value, removed = strip_owned(value, titles_by_id)
|
||||
stripped += removed
|
||||
return value
|
||||
|
||||
for article in articles:
|
||||
for section in article.sections or []:
|
||||
section["content"], n = strip_owned(section.get("content"), titles_by_id)
|
||||
stripped += n
|
||||
article.summary, n = strip_owned(article.summary, titles_by_id)
|
||||
stripped += n
|
||||
article.content, n = strip_owned(article.content, titles_by_id)
|
||||
stripped += n
|
||||
section["content"] = clean(section.get("content"))
|
||||
article.summary = clean(article.summary)
|
||||
article.content = clean(article.content)
|
||||
print(f" nested markers repaired : {repaired}")
|
||||
print(f" existing auto-links stripped: {stripped}")
|
||||
|
||||
if strip_only:
|
||||
|
|
|
|||
|
|
@ -44,9 +44,13 @@ describe('reading a cross-reference beside the article', () => {
|
|||
})
|
||||
})
|
||||
|
||||
// The preview card appears on a 350ms timer (ArticleLink.HOVER_DELAY). Under
|
||||
// the full parallel run the box is busy enough that the old 2s allowance ran
|
||||
// out, and a different case failed each time. The wait is generous because
|
||||
// this test is about the split view, not about how fast the machine is.
|
||||
const openSplit = async (name) => {
|
||||
await userEvent.hover(screen.getByRole('link', { name }))
|
||||
const card = await screen.findByRole('tooltip', {}, { timeout: 2000 })
|
||||
const card = await screen.findByRole('tooltip', {}, { timeout: 8000 })
|
||||
await userEvent.click(within(card).getByRole('button', { name: /split view/i }))
|
||||
}
|
||||
|
||||
|
|
|
|||
113
frontend/src/pages/SettingsPage.css
Normal file
113
frontend/src/pages/SettingsPage.css
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/* Settings as a set of places rather than one long scroll.
|
||||
*
|
||||
* Everything lived in a single 600px column: account, theme, an integration,
|
||||
* a document list and an admin grid, in that order, with no way to link to any
|
||||
* of it. Each is now its own panel with its own address.
|
||||
*/
|
||||
|
||||
.set-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: 28px;
|
||||
align-items: start;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
.set-head { grid-column: 1 / -1; margin-bottom: 4px; }
|
||||
.set-head h1 { margin: 0 0 4px; font-size: 1.4rem; font-weight: 700; }
|
||||
.set-head p { margin: 0; font-size: 0.86rem; color: var(--text-muted); }
|
||||
|
||||
/* ── Section list ─────────────────────────────────────────────────── */
|
||||
.set-nav { position: sticky; top: 76px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.set-nav button {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 12px; min-height: 44px; /* touch target */
|
||||
font: inherit; font-size: 0.89rem; text-align: left;
|
||||
background: none; border: 0; border-radius: 8px;
|
||||
color: var(--text-muted); cursor: pointer;
|
||||
}
|
||||
.set-nav button:hover { background: var(--bg); color: var(--text); }
|
||||
.set-nav button.is-active { background: var(--option-sel-bg); color: var(--primary); font-weight: 650; }
|
||||
.set-nav-icon { width: 20px; text-align: center; font-size: 1rem; }
|
||||
.set-nav-rule { margin: 8px 4px; border: 0; border-top: 1px solid var(--border); }
|
||||
|
||||
/* ── Panel ────────────────────────────────────────────────────────── */
|
||||
.set-panel {
|
||||
min-width: 0;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.set-panel + .set-panel { margin-top: 16px; }
|
||||
.set-panel-head { padding: 16px 20px; border-bottom: 1px solid var(--border); }
|
||||
.set-panel-head h2 { margin: 0; font-size: 1.02rem; font-weight: 650; }
|
||||
.set-panel-head p { margin: 4px 0 0; font-size: 0.83rem; color: var(--text-muted); line-height: 1.55; }
|
||||
.set-panel-body { padding: 20px; }
|
||||
|
||||
/* Form controls, so a section does not have to style its own. */
|
||||
.set-panel-body .form-group { margin-bottom: 14px; }
|
||||
.set-panel-body .form-group label {
|
||||
display: block; margin-bottom: 6px;
|
||||
font-size: 0.68rem; font-weight: 700; letter-spacing: 0.07em;
|
||||
text-transform: uppercase; color: var(--text-subtle);
|
||||
}
|
||||
.set-panel-body input[type="text"],
|
||||
.set-panel-body input[type="password"],
|
||||
.set-panel-body input[type="email"],
|
||||
.set-panel-body input[type="url"],
|
||||
.set-panel-body select {
|
||||
width: 100%; padding: 10px 12px;
|
||||
/* 16px on touch: iOS zooms in on a smaller font and never zooms back out. */
|
||||
font-size: 16px; font-family: inherit;
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--input-bg); color: var(--text);
|
||||
}
|
||||
@media (min-width: 700px) {
|
||||
.set-panel-body input[type="text"],
|
||||
.set-panel-body input[type="password"],
|
||||
.set-panel-body input[type="email"],
|
||||
.set-panel-body input[type="url"],
|
||||
.set-panel-body select { font-size: 0.92rem; }
|
||||
}
|
||||
|
||||
.set-note { margin: 0 0 14px; font-size: 0.85rem; color: var(--text-muted); line-height: 1.6; }
|
||||
.set-role {
|
||||
display: inline-block; margin-left: 6px; padding: 1px 8px; border-radius: 10px;
|
||||
font-size: 0.72rem; font-weight: 600;
|
||||
background: var(--option-sel-bg); color: var(--primary);
|
||||
}
|
||||
|
||||
/* Cards used by Appearance and Administration. */
|
||||
.set-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 12px; }
|
||||
.set-card {
|
||||
display: block; padding: 14px 16px; text-align: left;
|
||||
background: var(--bg); border: 2px solid var(--border); border-radius: 10px;
|
||||
text-decoration: none; color: inherit; cursor: pointer; font: inherit;
|
||||
}
|
||||
.set-card:hover { border-color: var(--primary); }
|
||||
.set-card.is-on { border-color: var(--primary); background: var(--option-sel-bg); }
|
||||
.set-card-icon { font-size: 1.35rem; margin-bottom: 6px; }
|
||||
.set-card-name { font-weight: 650; font-size: 0.9rem; }
|
||||
.set-card.is-on .set-card-name { color: var(--primary); }
|
||||
.set-card-desc { font-size: 0.78rem; color: var(--text-muted); margin-top: 2px; line-height: 1.45; }
|
||||
|
||||
/* ── Narrow ───────────────────────────────────────────────────────── */
|
||||
/* The section list becomes a scrolling strip above the panel. Two levels of
|
||||
navigation on a phone — list, then panel, then back — is one too many for
|
||||
changing a password. */
|
||||
@media (max-width: 820px) {
|
||||
.set-layout { grid-template-columns: minmax(0, 1fr); gap: 16px; }
|
||||
.set-nav {
|
||||
position: static; flex-direction: row; gap: 6px;
|
||||
overflow-x: auto; scrollbar-width: none;
|
||||
padding-bottom: 4px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.set-nav::-webkit-scrollbar { display: none; }
|
||||
.set-nav button { white-space: nowrap; border-radius: 999px; border: 1px solid var(--border); }
|
||||
.set-nav button.is-active { border-color: var(--primary); }
|
||||
.set-nav-rule { display: none; }
|
||||
.set-nav-icon { display: none; }
|
||||
}
|
||||
|
|
@ -1,15 +1,20 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Fragment, useState, useEffect } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useTheme } from '../context/ThemeContext'
|
||||
import api from '../api/client'
|
||||
import ExamSwitcher from '../components/ExamSwitcher'
|
||||
import './SettingsPage.css'
|
||||
|
||||
function Section({ title, children }) {
|
||||
function Section({ title, description, children }) {
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<h2 style={{ marginBottom: 20 }}>{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
<section className="set-panel">
|
||||
<div className="set-panel-head">
|
||||
<h2>{title}</h2>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
<div className="set-panel-body">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -43,7 +48,7 @@ function ProfileSection({ user }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<Section title="Profile">
|
||||
<Section title="Account" description="Your name, and the password you sign in with.">
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 20 }}>
|
||||
{user?.email}
|
||||
<span style={{
|
||||
|
|
@ -81,49 +86,6 @@ function ProfileSection({ user }) {
|
|||
)
|
||||
}
|
||||
|
||||
function NotificationsSection() {
|
||||
const [remindersDisabled, setRemindersDisabled] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/auth/me/settings').then(res => {
|
||||
setRemindersDisabled(!!res.data.reminders_disabled)
|
||||
}).catch(() => {}).finally(() => setLoaded(true))
|
||||
}, [])
|
||||
|
||||
const toggle = async (disabled) => {
|
||||
setRemindersDisabled(disabled)
|
||||
try {
|
||||
const current = await api.get('/auth/me/settings')
|
||||
await api.put('/auth/me/settings', { ...current.data, reminders_disabled: disabled })
|
||||
} catch { setRemindersDisabled(!disabled) }
|
||||
}
|
||||
|
||||
if (!loaded) return null
|
||||
|
||||
return (
|
||||
<Section title="Notifications">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<strong style={{ display: 'block', marginBottom: 4, fontSize: '0.9rem' }}>Quiz Reminders</strong>
|
||||
<span style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>
|
||||
Receive email reminders to review quizzes based on your performance.
|
||||
</span>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!remindersDisabled}
|
||||
onChange={e => toggle(!e.target.checked)}
|
||||
style={{ width: 'auto', accentColor: 'var(--primary)' }}
|
||||
/>
|
||||
<span style={{ fontSize: '0.85rem', fontWeight: 600 }}>{remindersDisabled ? 'Off' : 'On'}</span>
|
||||
</label>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function AppearanceSection() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const themes = [
|
||||
|
|
@ -131,26 +93,35 @@ function AppearanceSection() {
|
|||
{ value: 'markdown', label: 'Warm Brown', desc: 'Parchment serif — easy on the eyes', icon: '📖' },
|
||||
]
|
||||
return (
|
||||
<Section title="Appearance">
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Section title="Appearance" description="How the site looks on this device.">
|
||||
<div className="set-cards" role="radiogroup" aria-label="Theme">
|
||||
{themes.map(t => (
|
||||
<div key={t.value} onClick={() => setTheme(t.value)}
|
||||
style={{
|
||||
flex: 1, minWidth: 140, padding: '14px 16px', borderRadius: 10, cursor: 'pointer',
|
||||
border: `2px solid ${theme === t.value ? 'var(--primary)' : 'var(--border)'}`,
|
||||
background: theme === t.value ? 'var(--option-sel-bg)' : 'var(--card-bg)',
|
||||
transition: 'border-color 0.15s',
|
||||
}}>
|
||||
<div style={{ fontSize: '1.4rem', marginBottom: 6 }}>{t.icon}</div>
|
||||
<div style={{ fontWeight: 600, fontSize: '0.9rem', color: theme === t.value ? 'var(--primary)' : 'var(--text)' }}>{t.label}</div>
|
||||
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 2 }}>{t.desc}</div>
|
||||
</div>
|
||||
<button type="button" key={t.value} role="radio" aria-checked={theme === t.value}
|
||||
className={`set-card${theme === t.value ? ' is-on' : ''}`} onClick={() => setTheme(t.value)}>
|
||||
<div className="set-card-icon" aria-hidden="true">{t.icon}</div>
|
||||
<div className="set-card-name">{t.label}</div>
|
||||
<div className="set-card-desc">{t.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the learner is revising for. It scopes the question bank, the facets
|
||||
* and now the knowledge profile, which makes it a setting rather than a
|
||||
* navbar control — it was only reachable from a dropdown in the header.
|
||||
*/
|
||||
function StudySection() {
|
||||
return (
|
||||
<Section title="Studying for"
|
||||
description="Scopes the question bank, the filters and your performance analysis. Material not linked to any exam always stays visible.">
|
||||
<ExamSwitcher />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function NextcloudSection() {
|
||||
const [server, setServer] = useState('https://cloud.danvics.com')
|
||||
const [username, setUsername] = useState('')
|
||||
|
|
@ -243,26 +214,20 @@ function NextcloudSection() {
|
|||
|
||||
function AdminSection() {
|
||||
return (
|
||||
<Section title="Administration">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 12 }}>
|
||||
<Section title="Administration" description="Tools for moderators and administrators.">
|
||||
<div className="set-cards">
|
||||
{[
|
||||
{ to: '/admin', icon: '⚙️', label: 'Admin Dashboard', desc: 'Models, users, and settings' },
|
||||
{ to: '/admin', icon: '⚙️', label: 'Admin dashboard', desc: 'Models, users and settings' },
|
||||
{ to: '/upload', icon: '📄', label: 'Upload PDF', desc: 'Add new documents' },
|
||||
{ to: '/trash', icon: '🗑️', label: 'Trash', desc: 'Restore deleted quizzes' },
|
||||
{ to: '/jobs', icon: '📋', label: 'Extraction Jobs', desc: 'View extraction history' },
|
||||
{ to: '/categories', icon: '🗂️', label: 'Taxonomy', desc: 'Topics, systems, symptoms, diseases' },
|
||||
{ to: '/editorial', icon: '✍️', label: 'Editorial', desc: 'Article drafts and references' },
|
||||
{ to: '/trash', icon: '🗑️', label: 'Trash', desc: 'Restore deleted tests' },
|
||||
{ to: '/jobs', icon: '📋', label: 'Extraction jobs', desc: 'Extraction history' },
|
||||
].map(item => (
|
||||
<Link key={item.to} to={item.to} style={{ textDecoration: 'none' }}>
|
||||
<div style={{
|
||||
padding: '16px', borderRadius: 10, border: '1px solid var(--border)',
|
||||
background: 'var(--bg)', transition: 'border-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'}
|
||||
>
|
||||
<div style={{ fontSize: '1.4rem', marginBottom: 6 }}>{item.icon}</div>
|
||||
<div style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--text)' }}>{item.label}</div>
|
||||
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 2 }}>{item.desc}</div>
|
||||
</div>
|
||||
<Link key={item.to} to={item.to} className="set-card">
|
||||
<div className="set-card-icon" aria-hidden="true">{item.icon}</div>
|
||||
<div className="set-card-name">{item.label}</div>
|
||||
<div className="set-card-desc">{item.desc}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -402,23 +367,61 @@ function DataSection() {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings as a set of places, each with its own address.
|
||||
*
|
||||
* It was one 600px column holding the account form, the theme picker, a
|
||||
* Nextcloud integration, a document list and an admin link grid, in that
|
||||
* order, with no way to link to any of it. The section now lives in the URL,
|
||||
* so "change your password" is a link and Back works.
|
||||
*/
|
||||
export default function SettingsPage() {
|
||||
const { user } = useAuth()
|
||||
const isAdmin = user?.role === 'admin'
|
||||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||||
const isModerator = isAdmin || user?.role === 'moderator'
|
||||
const [params, setParams] = useSearchParams()
|
||||
|
||||
const sections = [
|
||||
{ key: 'account', icon: '👤', label: 'Account', render: () => <ProfileSection user={user} /> },
|
||||
{ key: 'study', icon: '🎯', label: 'Studying for', render: () => <StudySection /> },
|
||||
{ key: 'appearance', icon: '🎨', label: 'Appearance', render: () => <AppearanceSection /> },
|
||||
{ key: 'data', icon: '🗄️', label: 'Your data', render: () => <DataSection /> },
|
||||
...(isModerator ? [
|
||||
{ key: 'library', icon: '📚', label: 'Documents', divider: true,
|
||||
render: () => <><DocumentsSection /><NextcloudSection /></> },
|
||||
{ key: 'admin', icon: '🛠️', label: 'Administration', render: () => <AdminSection /> },
|
||||
] : []),
|
||||
]
|
||||
|
||||
const requested = params.get('s')
|
||||
const active = sections.find(section => section.key === requested) || sections[0]
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 600, margin: '0 auto' }}>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<h1 style={{ fontSize: '1.4rem', fontWeight: 700 }}>Settings</h1>
|
||||
<div className="set-layout">
|
||||
<div className="set-head">
|
||||
<h1>Settings</h1>
|
||||
<p>Your account, how the site looks, and what it shows you.</p>
|
||||
</div>
|
||||
<ProfileSection user={user} />
|
||||
<NotificationsSection />
|
||||
<AppearanceSection />
|
||||
<DataSection />
|
||||
{isModerator && <NextcloudSection />}
|
||||
{isModerator && <DocumentsSection />}
|
||||
{(isAdmin || isModerator) && <AdminSection />}
|
||||
|
||||
<nav className="set-nav" aria-label="Settings sections">
|
||||
{/* Buttons are direct children so the list can become a scrolling
|
||||
strip on a narrow screen without a wrapper in the way. */}
|
||||
{sections.map(section => (
|
||||
<Fragment key={section.key}>
|
||||
{section.divider && <hr className="set-nav-rule" />}
|
||||
<button type="button"
|
||||
className={section.key === active.key ? 'is-active' : undefined}
|
||||
aria-current={section.key === active.key ? 'page' : undefined}
|
||||
onClick={() => setParams(section.key === sections[0].key ? {} : { s: section.key },
|
||||
{ replace: true })}>
|
||||
<span className="set-nav-icon" aria-hidden="true">{section.icon}</span>
|
||||
{section.label}
|
||||
</button>
|
||||
</Fragment>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div>{active.render()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
85
frontend/src/pages/SettingsPage.test.jsx
Normal file
85
frontend/src/pages/SettingsPage.test.jsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import SettingsPage from './SettingsPage'
|
||||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({ default: { get: vi.fn(), put: vi.fn(), post: vi.fn(), delete: vi.fn() } }))
|
||||
let currentUser = { id: 1, name: 'Learner', email: 'learner@example.test', role: 'user' }
|
||||
vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ user: currentUser }) }))
|
||||
vi.mock('../context/ThemeContext', () => ({ useTheme: () => ({ theme: 'default', setTheme: vi.fn() }) }))
|
||||
|
||||
const mount = (entry = '/settings') => render(
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<Routes><Route path="/settings" element={<SettingsPage />} /></Routes>
|
||||
</MemoryRouter>)
|
||||
|
||||
describe('settings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
currentUser = { id: 1, name: 'Learner', email: 'learner@example.test', role: 'user' }
|
||||
api.get.mockImplementation(url => {
|
||||
if (url === '/exams/') return Promise.resolve({ data: { exams: [{ id: 1, name: 'Boards', question_count: 900 }], active_exam_id: 1 } })
|
||||
return Promise.resolve({ data: [] })
|
||||
})
|
||||
})
|
||||
|
||||
it('opens on the account section and shows one panel at a time', async () => {
|
||||
mount()
|
||||
expect(await screen.findByRole('heading', { name: 'Account' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: 'Appearance' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('puts the section in the address, so it can be linked to', async () => {
|
||||
mount('/settings?s=appearance')
|
||||
expect(await screen.findByRole('heading', { name: 'Appearance' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: 'Account' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches section from the list', async () => {
|
||||
mount()
|
||||
await screen.findByRole('heading', { name: 'Account' })
|
||||
await userEvent.click(screen.getByRole('button', { name: /Your data/ }))
|
||||
expect(await screen.findByRole('heading', { name: 'Your data' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to the first section when the address names one that is gone', async () => {
|
||||
mount('/settings?s=notifications')
|
||||
expect(await screen.findByRole('heading', { name: 'Account' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps moderator sections away from a learner', async () => {
|
||||
mount()
|
||||
await screen.findByRole('heading', { name: 'Account' })
|
||||
expect(screen.queryByRole('button', { name: /Administration/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Documents/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers them to a moderator', async () => {
|
||||
currentUser = { ...currentUser, role: 'moderator' }
|
||||
mount()
|
||||
await screen.findByRole('heading', { name: 'Account' })
|
||||
expect(screen.getByRole('button', { name: /Administration/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('carries the exam objective, which was only reachable from the navbar', async () => {
|
||||
mount('/settings?s=study')
|
||||
expect(await screen.findByRole('heading', { name: 'Studying for' })).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByLabelText('Studying for')).toHaveValue('1'))
|
||||
})
|
||||
|
||||
it('asks for a typed word before wiping practice data, and says what survives', async () => {
|
||||
mount('/settings?s=data')
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Reset all practice data' }))
|
||||
const confirm = screen.getByRole('button', { name: 'Reset everything' })
|
||||
expect(confirm).toBeDisabled()
|
||||
await userEvent.type(screen.getByLabelText('Type RESET to confirm'), 'reset')
|
||||
expect(confirm).toBeEnabled()
|
||||
|
||||
api.post.mockResolvedValue({ data: { removed: { attempts: 3, answers: 40, saved_questions: 1, question_notes: 0 } } })
|
||||
await userEvent.click(confirm)
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/attempts/reset-all', { confirm: 'reset' }))
|
||||
expect(await screen.findByText(/Removed 3 sessions/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue