feat(ui): establish Settings page layout primitives
This commit is contained in:
@@ -130,7 +130,11 @@ test("[PRES-S03 PRES-W11] responsive settings navigation keeps rule edits and pe
|
||||
await expect.poll(async () => (await (await page.request.get(`${apiUrl}/rules`)).json()).appliedFollowUpDays).toBe(5);
|
||||
await page.getByRole("tab", { name: "Notifications", exact: true }).click();
|
||||
await page.getByLabel("Email reminders for follow-ups", { exact: true }).uncheck();
|
||||
// Read back after the mutation completes; concurrent read/write stress is a
|
||||
// separate SQLite defect (P4-04-O02), not this sequential UI preservation flow.
|
||||
const saveNotifications = page.waitForResponse(response => response.url() === `${apiUrl}/notification-settings` && response.request().method() === 'PUT');
|
||||
await page.getByRole("button", { name: "Save notification settings", exact: true }).click();
|
||||
expect((await saveNotifications).status()).toBe(200);
|
||||
await expect.poll(async () => (await (await page.request.get(`${apiUrl}/notification-settings`)).json()).emailFollowUpRemindersEnabled).toBe(false);
|
||||
await page.reload();
|
||||
await page.getByRole("tab", { name: "Notifications", exact: true }).click();
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test as base, type APIRequestContext, type Page, type TestInfo } from "@playwright/test";
|
||||
import { translations } from "../src/i18n/translations";
|
||||
|
||||
const apiUrl = "http://localhost:5302/api";
|
||||
const password = "E2ePassword123!";
|
||||
async function login(api: APIRequestContext, email: string) {
|
||||
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||
const me = await (await api.get(`${apiUrl}/auth/me`)).json();
|
||||
const csrf = (await api.storageState()).cookies.find(c => c.name === "XSRF-TOKEN")!.value;
|
||||
return { "X-CSRF-TOKEN": csrf, "X-Session-Context": me.sessionContext as string };
|
||||
}
|
||||
|
||||
const test = base.extend<{ headers: Record<string, string> }>({
|
||||
headers: async ({ playwright, page, context }, provide) => {
|
||||
const admin = await playwright.request.newContext();
|
||||
try {
|
||||
const adminHeaders = await login(admin, "e2e@example.test");
|
||||
const email = `p404-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
|
||||
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||
const headers = await login(page.request, email);
|
||||
await context.route("**/*", route => {
|
||||
const url = new URL(route.request().url());
|
||||
return ["localhost", "127.0.0.1"].includes(url.hostname) || ["data:", "blob:", "about:"].includes(url.protocol) ? route.continue() : route.abort();
|
||||
});
|
||||
await provide(headers);
|
||||
} finally { await admin.dispose(); }
|
||||
},
|
||||
});
|
||||
|
||||
async function capture(page: Page, info: TestInfo, name: string) {
|
||||
const path = process.env.P4_04_CAPTURE === "true" ? `../docs/redesign/phase-4/p4-04/visual/${name}.png` : info.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path, fullPage: false });
|
||||
await info.attach(name, { path, contentType: "image/png" });
|
||||
}
|
||||
|
||||
async function scan(page: Page, info: TestInfo, name: string) {
|
||||
const result = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze();
|
||||
await info.attach(`${name}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }, null, 2), contentType: "application/json" });
|
||||
expect(result.violations).toEqual([]);
|
||||
}
|
||||
|
||||
async function reflow(page: Page, info: TestInfo, name: string) {
|
||||
// Test actual descendants, not body.scrollWidth: the inherited body overflow rule
|
||||
// must not disguise a child/action extending beyond the available reading area.
|
||||
const outside = await page.locator("main").evaluate(main => {
|
||||
const bounds = main.getBoundingClientRect();
|
||||
return Array.from(main.querySelectorAll("h1,h2,h3,p,button,[role='combobox'],.MuiFormControlLabel-root,a"))
|
||||
.filter(node => node.getClientRects().length && getComputedStyle(node).visibility !== "hidden")
|
||||
.map(node => ({ text: node.textContent?.slice(0, 90), left: node.getBoundingClientRect().left, right: node.getBoundingClientRect().right }))
|
||||
.filter(rect => rect.left < bounds.left - 1 || rect.right > Math.min(bounds.right, innerWidth) + 1);
|
||||
});
|
||||
await info.attach(`${name}-reflow`, { body: JSON.stringify(outside), contentType: "application/json" });
|
||||
expect(outside).toEqual([]);
|
||||
}
|
||||
|
||||
for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb"] as const) {
|
||||
test(`[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] ${mode}/${language} Settings headings, reflow and retained actions`, async ({ page, headers }, info) => {
|
||||
test.setTimeout(180_000);
|
||||
const t = translations[language];
|
||||
const suffix = `${mode}-${language}`;
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.addInitScript(({ mode, language }) => { localStorage.setItem("jobtracker.themeMode", mode); localStorage.setItem("uiLanguage", language); }, { mode, language });
|
||||
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === "nb" ? "nb-NO" : "en" } })).ok()).toBe(true);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto(language === "nb" ? "/settings/?source=preservation" : "/settings");
|
||||
const main = page.getByRole("main");
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", mode);
|
||||
await expect(main.getByRole("heading", { level: 1 })).toHaveText(t.settings);
|
||||
await expect(main.getByRole("switch", { name: t.settingsAiEnabled, exact: true })).toBeVisible();
|
||||
await expect(main.getByText(t.settingsUsageNoAi, { exact: true })).toBeVisible();
|
||||
await expect(main.getByRole("switch", { name: t.settingsAiExternalAllowed, exact: true })).toBeDisabled();
|
||||
for (const title of [t.settingsAppearance, t.settingsLanguageTitle, t.settingsJobs, t.settingsAiPrivacyTitle, t.settingsUsageTitle, t.settingsQuickCaptureTitle, t.connectedAccounts]) {
|
||||
await expect(main.getByRole("heading", { level: 2, name: title, exact: true })).toHaveCount(1);
|
||||
}
|
||||
await scan(page, info, `general-${suffix}`);
|
||||
for (const width of [1920, 1440, 1024, 768, 767, 390, 320]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await reflow(page, info, `general-${width}-${suffix}`);
|
||||
if ([1440, 390, 320].includes(width)) {
|
||||
await page.evaluate(() => scrollTo(0, 0));
|
||||
await capture(page, info, `general-${width}-${suffix}`);
|
||||
}
|
||||
}
|
||||
await scan(page, info, `phone-${suffix}`);
|
||||
// Synthetic long localized title, with no CSS injection or hidden controls.
|
||||
await main.getByRole("heading", { level: 1 }).evaluate((node, title) => { node.textContent = title; }, language === "nb"
|
||||
? "Innstillinger for jobbpreferanser, oppfølginger og tilkoblede innbokser"
|
||||
: "Settings for job preferences, follow-up reminders and connected inboxes");
|
||||
await reflow(page, info, `long-heading-${suffix}`);
|
||||
await capture(page, info, `long-heading-${suffix}`);
|
||||
|
||||
const chooseSection = async (name: string) => {
|
||||
if (page.viewportSize()!.width < 768) {
|
||||
const selector = main.getByRole("combobox", { name: t.settingsNavigation, exact: true });
|
||||
await selector.focus();
|
||||
await selector.press("Enter");
|
||||
await page.getByRole("option", { name, exact: true }).focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(selector).toBeFocused();
|
||||
} else await main.getByRole("tab", { name, exact: true }).click();
|
||||
};
|
||||
|
||||
// Initial rules failure retains heading/error; existing section re-entry reloads.
|
||||
await page.route(`${apiUrl}/rules`, route => route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "Synthetic rules unavailable" }) }));
|
||||
await chooseSection(t.settingsTabFollowUps);
|
||||
await expect(main.getByRole("alert")).toContainText("Synthetic rules unavailable");
|
||||
await expect(main.getByRole("heading", { level: 2, name: t.rulesTitle })).toBeVisible();
|
||||
await capture(page, info, `rules-error-${suffix}`);
|
||||
await scan(page, info, `rules-error-${suffix}`);
|
||||
await page.unroute(`${apiUrl}/rules`);
|
||||
await chooseSection(t.settingsTabGeneral);
|
||||
await chooseSection(t.settingsTabFollowUps);
|
||||
const rule = main.getByRole("spinbutton", { name: t.rulesAppliedFollowUpDays, exact: true });
|
||||
await rule.fill("5");
|
||||
for (const width of [320, 390, 768, 1024, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await expect(rule).toHaveValue("5");
|
||||
await reflow(page, info, `rules-${width}-${suffix}`);
|
||||
}
|
||||
const response = page.waitForResponse(r => r.url() === `${apiUrl}/rules` && r.request().method() === "PUT");
|
||||
await main.getByRole("button", { name: t.rulesSave, exact: true }).click();
|
||||
expect((await response).status()).toBe(204);
|
||||
expect((await (await page.request.get(`${apiUrl}/rules`)).json()).appliedFollowUpDays).toBe(5);
|
||||
|
||||
// Hold only the local notification read to exercise loading in the same layout.
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
await page.route(`${apiUrl}/notification-settings`, async route => { await held; await route.continue(); });
|
||||
await page.reload();
|
||||
await page.setViewportSize({ width: 320, height: 900 });
|
||||
await chooseSection(t.settingsTabNotifications);
|
||||
await expect(main.locator(".MuiSkeleton-root")).toBeVisible();
|
||||
await capture(page, info, `notifications-loading-${suffix}`);
|
||||
release();
|
||||
const pref = main.getByRole("checkbox", { name: t.settingsNotificationsFollowUpReminders, exact: true });
|
||||
await expect(pref).toBeChecked();
|
||||
await page.unroute(`${apiUrl}/notification-settings`);
|
||||
await pref.uncheck();
|
||||
let fail = true;
|
||||
await page.route(`${apiUrl}/notification-settings`, route => {
|
||||
if (route.request().method() === "PUT" && fail) { fail = false; return route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "Synthetic notification save failure" }) }); }
|
||||
return route.continue();
|
||||
});
|
||||
const save = main.getByRole("button", { name: t.settingsNotificationsSave, exact: true });
|
||||
await save.focus();
|
||||
const savedNotification = page.waitForResponse(r => r.url() === `${apiUrl}/notification-settings` && r.request().method() === "PUT");
|
||||
await save.press("Enter");
|
||||
expect((await savedNotification).status()).toBe(503);
|
||||
await expect(main.getByRole("alert")).toContainText("Synthetic notification save failure");
|
||||
await expect(pref).not.toBeChecked();
|
||||
await reflow(page, info, `notifications-error-${suffix}`);
|
||||
await capture(page, info, `notifications-error-${suffix}`);
|
||||
await scan(page, info, `notifications-error-${suffix}`);
|
||||
const retriedNotification = page.waitForResponse(r => r.url() === `${apiUrl}/notification-settings` && r.request().method() === "PUT");
|
||||
await save.press("Enter");
|
||||
expect((await retriedNotification).status()).toBe(200);
|
||||
await expect(main.getByRole("alert")).toHaveCount(0);
|
||||
expect((await (await page.request.get(`${apiUrl}/notification-settings`)).json()).emailFollowUpRemindersEnabled).toBe(false);
|
||||
await page.reload();
|
||||
await chooseSection(t.settingsTabNotifications);
|
||||
await expect(pref).not.toBeChecked();
|
||||
|
||||
await chooseSection(t.settingsTabBackup);
|
||||
await expect(main.getByText(t.accountDeleteDisabled, { exact: true })).toBeVisible();
|
||||
await expect(main.getByRole("button", { name: t.accountDeleteButton, exact: true })).toHaveCount(0);
|
||||
await reflow(page, info, `backup-${suffix}`);
|
||||
await capture(page, info, `backup-${suffix}`);
|
||||
await scan(page, info, `backup-${suffix}`);
|
||||
const download = page.waitForEvent("download");
|
||||
await main.getByRole("button", { name: t.accountExportDownload, exact: true }).click();
|
||||
expect((await download).suggestedFilename()).toMatch(/\.zip$/);
|
||||
// The existing fixed toast can temporarily cover bottom-of-page actions.
|
||||
// Let it dismiss normally before certifying/capturing the focused action.
|
||||
await expect(page.getByRole("alert").filter({ hasText: t.accountExportDownloaded })).toHaveCount(0, { timeout: 15_000 });
|
||||
await chooseSection(t.settingsTabGeneral);
|
||||
const manage = main.getByRole("button", { name: t.settingsManageConnectedAccounts, exact: true });
|
||||
await manage.focus();
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(manage).toBeFocused();
|
||||
expect(await manage.evaluate(node => getComputedStyle(node).outlineStyle)).toBe("solid");
|
||||
await manage.scrollIntoViewIfNeeded();
|
||||
await expect.poll(() => manage.evaluate(node => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2);
|
||||
return node.contains(hit) ? "visible" : `${hit?.tagName}: ${hit?.getAttribute("class")} at ${rect.x},${rect.y},${rect.width},${rect.height}`;
|
||||
})).toBe("visible");
|
||||
await capture(page, info, `connected-action-${suffix}`);
|
||||
await manage.press("Enter");
|
||||
await expect(page).toHaveURL(/\/settings\/connected-accounts$/);
|
||||
expect((await page.request.get(`${apiUrl}/users`)).status()).toBe(403);
|
||||
});
|
||||
}
|
||||
@@ -137,6 +137,7 @@ function titleFor(path: string, t: (k: any) => string): string {
|
||||
}
|
||||
|
||||
function subtitleFor(path: string, t: (k: any) => string): string | undefined {
|
||||
if (/^\/settings\/?$/.test(path)) return t("settingsPageDescription");
|
||||
if (path === "/dashboard") return t("dashboardPageSubtitle");
|
||||
if (path.startsWith("/discover")) return t("discoverJobsSubtitle");
|
||||
if (/^\/jobs\/\d+/.test(path)) return t("jobDetailsWorkspaceSubtitle");
|
||||
@@ -358,6 +359,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
<AppShell
|
||||
pageTitle={pageTitle}
|
||||
pageSubtitle={pageSubtitle}
|
||||
pageLayout={/^\/settings\/?$/.test(path) ? "reading" : undefined}
|
||||
breadcrumbs={breadcrumbs}
|
||||
pathname={path}
|
||||
nav={nav}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Alert, Box, Button, FormControlLabel, Paper, Skeleton, Switch, Typography } from "@mui/material";
|
||||
import { Alert, Box, Button, FormControlLabel, Skeleton, Switch, Typography } from "@mui/material";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
import { api } from "../api";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useToast } from "../toast";
|
||||
@@ -46,9 +47,7 @@ export default function AiPrivacySettingsCard() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, borderRadius: 4, border: "none" }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary", fontWeight: 800 }}>{t("settingsAiPrivacyTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, mb: 1.5 }}>{t("settingsAiPrivacyBody")}</Typography>
|
||||
<PageSection surface title={t("settingsAiPrivacyTitle")} description={t("settingsAiPrivacyBody")}>
|
||||
{failed ? <Alert severity="error" sx={{ mb: 1.5 }}>{t("settingsAiPrivacyUnavailable")}</Alert> : null}
|
||||
{!settings ? <Skeleton variant="rounded" height={96} /> : <Box sx={{ display: "grid", gap: 1 }}>
|
||||
<FormControlLabel
|
||||
@@ -70,6 +69,6 @@ export default function AiPrivacySettingsCard() {
|
||||
</Typography>
|
||||
<Box><Button variant="contained" disabled={saving} onClick={() => void save()}>{t("settingsAiSave")}</Button></Box>
|
||||
</Box>}
|
||||
</Paper>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Alert, Box, Button, LinearProgress, Paper, Skeleton, Stack, Typography } from "@mui/material";
|
||||
import { Alert, Box, Button, LinearProgress, Skeleton, Typography } from "@mui/material";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
import { api } from "../api";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
@@ -59,11 +60,7 @@ export default function AiUsageCard() {
|
||||
const storagePercent = Math.min(100, usage.storageUsedBytes / usage.storageLimitBytes * 100);
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, borderRadius: 4, border: "none" }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="baseline" sx={{ mb: 2 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary", fontWeight: 800 }}>{t("settingsUsageTitle")}</Typography>
|
||||
<Typography variant="caption" sx={{ textTransform: "capitalize", fontWeight: 700 }}>{t("settingsUsagePlan", { plan: usage.plan })}</Typography>
|
||||
</Stack>
|
||||
<PageSection surface title={t("settingsUsageTitle")} actions={<Typography variant="caption" sx={{ textTransform: "capitalize", fontWeight: 700 }}>{t("settingsUsagePlan", { plan: usage.plan })}</Typography>}>
|
||||
{usage.monthlyCallLimit === 0 ? <Alert severity="info" sx={{ mb: 2 }}>{t("settingsUsageNoAi")}</Alert> : <><Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2">{t("settingsUsageGenerations", { used: usage.currentMonth.calls.toLocaleString(), limit: usage.monthlyCallLimit.toLocaleString() })}</Typography>
|
||||
<LinearProgress variant="determinate" value={callsPercent} aria-label={t("settingsUsageGenerationsProgress")} sx={{ mt: 0.75, height: 7, borderRadius: 99 }} />
|
||||
@@ -85,6 +82,6 @@ export default function AiUsageCard() {
|
||||
{t("settingsBillingNotConfigured")}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Paper>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { Alert, Box, Button, Divider, Paper, Typography } from "@mui/material";
|
||||
import { Alert, Box, Button, Divider, Typography } from "@mui/material";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { clearAuthClientState } from "../auth";
|
||||
import { useToast } from "../toast";
|
||||
@@ -95,10 +96,7 @@ export default function BackupCard() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 2, p: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{t("backupTitle")}
|
||||
</Typography>
|
||||
<PageSection surface title={t("backupTitle")}>
|
||||
<Typography sx={{ color: "text.secondary", mb: 1 }}>
|
||||
{t("accountExportBody")}
|
||||
</Typography>
|
||||
@@ -109,7 +107,7 @@ export default function BackupCard() {
|
||||
</Box>
|
||||
<Alert severity="info" sx={{ mt: 1.5 }}>{t("accountExportRecentSignIn")}</Alert>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 0.5 }}>{t("backupEncryptedTitle")}</Typography>
|
||||
<Typography component="h3" variant="sectionTitle">{t("backupEncryptedTitle")}</Typography>
|
||||
<Typography sx={{ color: "text.secondary", mb: 1 }}>
|
||||
{t("backupBody")}
|
||||
</Typography>
|
||||
@@ -119,7 +117,7 @@ export default function BackupCard() {
|
||||
</Button>
|
||||
</Box>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 0.5 }}>{t("accountDeleteTitle")}</Typography>
|
||||
<Typography component="h3" variant="sectionTitle">{t("accountDeleteTitle")}</Typography>
|
||||
{lifecycle?.deletionStatus === "pending" ? (
|
||||
<Alert severity="warning">{t("accountDeletePending", { stage: lifecycle.request?.stage ?? "pending" })}</Alert>
|
||||
) : lifecycle?.deletionEnabled ? (
|
||||
@@ -132,6 +130,6 @@ export default function BackupCard() {
|
||||
) : (
|
||||
<Alert severity="info">{t("accountDeleteDisabled")}</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
import { Box, Button, Paper, Typography } from "@mui/material";
|
||||
import { Box, Button, Typography } from "@mui/material";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
|
||||
import { api } from "../api";
|
||||
import { Company, JobApplication } from "../types";
|
||||
@@ -248,13 +249,7 @@ export default function ImportExportJobs() {
|
||||
const helper = useMemo(() => t("importExportBody"), [t]);
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 2, p: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{t("importExportTitle")}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
|
||||
{helper}
|
||||
</Typography>
|
||||
<PageSection level="h3" title={t("importExportTitle")} description={helper}>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Button variant="outlined" onClick={() => void exportAll("json")}>{t("exportJson")}</Button>
|
||||
@@ -276,6 +271,6 @@ export default function ImportExportJobs() {
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
|
||||
import { Box, Paper, TextField, Typography } from "@mui/material";
|
||||
import { Box, TextField, Typography } from "@mui/material";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useToast } from "../toast";
|
||||
@@ -24,9 +25,7 @@ export default function QuickCaptureCard() {
|
||||
}, [bookmarklet]);
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsQuickCaptureTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("settingsQuickCaptureSubtitle")}</Typography>
|
||||
<PageSection surface title={t("settingsQuickCaptureTitle")} description={t("settingsQuickCaptureSubtitle")}>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap", mb: 1.5 }}>
|
||||
<Box
|
||||
@@ -64,6 +63,6 @@ export default function QuickCaptureCard() {
|
||||
InputProps={{ readOnly: true }}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</Paper>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { Alert, Box, Button, Paper, TextField, Typography } from "@mui/material";
|
||||
import { Alert, Box, Button, TextField, Typography } from "@mui/material";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
@@ -50,12 +51,9 @@ export default function RulesSettingsCard() {
|
||||
|
||||
if (!s) {
|
||||
return (
|
||||
<Paper sx={{ mt: 2, p: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{t("rulesTitle")}
|
||||
</Typography>
|
||||
<PageSection surface title={t("rulesTitle")}>
|
||||
{loadError ? <Alert severity="error">{loadError}</Alert> : <Typography sx={{ color: "text.secondary" }}>{t("rulesLoading")}</Typography>}
|
||||
</Paper>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,13 +66,7 @@ export default function RulesSettingsCard() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 2, p: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{t("rulesTitle")}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
|
||||
{t("rulesBody")}
|
||||
</Typography>
|
||||
<PageSection surface title={t("rulesTitle")} description={t("rulesBody")}>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
|
||||
<TextField label={t("rulesAppliedFollowUpDays")} {...num("appliedFollowUpDays")} />
|
||||
@@ -93,6 +85,6 @@ export default function RulesSettingsCard() {
|
||||
{saving ? t("rulesSaving") : t("rulesSave")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
FormControlLabel,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Skeleton,
|
||||
Tab,
|
||||
@@ -30,6 +29,7 @@ import { ThemeModePref } from "../themePrefs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
|
||||
interface Props {
|
||||
pageSize: 15 | 20 | 25;
|
||||
@@ -42,17 +42,7 @@ interface Props {
|
||||
|
||||
function TabPanel({ value, index, children }: { value: number; index: number; children: React.ReactNode }) {
|
||||
if (value !== index) return null;
|
||||
return <Box sx={{ mt: 2.5 }}>{children}</Box>;
|
||||
}
|
||||
|
||||
function SectionCard({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Paper sx={{ p: "var(--app-card-padding)", borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary", fontWeight: 800 }}>{title}</Typography>
|
||||
{subtitle ? <Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, mb: 1.5 }}>{subtitle}</Typography> : <Box sx={{ mb: 1.5 }} />}
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
return <Box sx={{ mt: "var(--app-section-gap)", minWidth: 0 }}>{children}</Box>;
|
||||
}
|
||||
|
||||
type NotificationPrefs = {
|
||||
@@ -97,7 +87,7 @@ export default function SettingsView({
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 0, p: "var(--app-card-padding)", borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", maxWidth: 960, mx: "auto" }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<FormControl fullWidth sx={{ display: { xs: "flex", sm: "none" }, mb: 1 }}>
|
||||
<InputLabel id="settings-section-label">{t("settingsNavigation")}</InputLabel>
|
||||
<Select
|
||||
@@ -113,7 +103,7 @@ export default function SettingsView({
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} variant="scrollable" scrollButtons="auto" sx={{ display: { xs: "none", sm: "flex" }, mb: 1 }}>
|
||||
<Tabs aria-label={t("settings")} value={tab} onChange={(_, v) => setTab(v)} variant="scrollable" scrollButtons="auto" sx={{ display: { xs: "none", sm: "flex" }, mb: 1 }}>
|
||||
<Tab label={t("settingsTabGeneral")} />
|
||||
<Tab label={t("settingsTabFollowUps")} />
|
||||
<Tab label={t("settingsTabNotifications")} />
|
||||
@@ -121,9 +111,9 @@ export default function SettingsView({
|
||||
</Tabs>
|
||||
|
||||
<TabPanel value={tab} index={0}>
|
||||
<Box sx={{ display: "grid", gap: 2.5 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2.5 }}>
|
||||
<SectionCard title={t("settingsAppearance")}>
|
||||
<Box sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: "var(--app-section-gap)" }}>
|
||||
<PageSection surface title={t("settingsAppearance")}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="theme-mode-label">{t("settingsTheme")}</InputLabel>
|
||||
<Select
|
||||
@@ -137,9 +127,9 @@ export default function SettingsView({
|
||||
<MenuItem value="light">{t("settingsThemeLight")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</SectionCard>
|
||||
</PageSection>
|
||||
|
||||
<SectionCard title={t("settingsLanguageTitle")} subtitle={t("settingsLanguageBody")}>
|
||||
<PageSection surface title={t("settingsLanguageTitle")} description={t("settingsLanguageBody")}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="language-label">{t("settingsPreferredLanguage")}</InputLabel>
|
||||
<Select
|
||||
@@ -152,13 +142,13 @@ export default function SettingsView({
|
||||
<MenuItem value="nb">{t("settingsNorwegian")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</SectionCard>
|
||||
</PageSection>
|
||||
</Box>
|
||||
|
||||
<SectionCard title={t("settingsJobs")}>
|
||||
<PageSection surface title={t("settingsJobs")}>
|
||||
<Box sx={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
|
||||
<Box sx={{ minWidth: 240 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
|
||||
<Box sx={{ flex: "1 1 15rem", minWidth: 0 }}>
|
||||
<Typography component="h3" variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
|
||||
{t("settingsPagination")}
|
||||
</Typography>
|
||||
<FormControl fullWidth>
|
||||
@@ -176,8 +166,8 @@ export default function SettingsView({
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ minWidth: 240 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
|
||||
<Box sx={{ flex: "1 1 15rem", minWidth: 0 }}>
|
||||
<Typography component="h3" variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
|
||||
{t("settingsColumns")}
|
||||
</Typography>
|
||||
{(
|
||||
@@ -200,14 +190,13 @@ export default function SettingsView({
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<ImportExportJobs />
|
||||
</Box>
|
||||
</SectionCard>
|
||||
</PageSection>
|
||||
|
||||
<AiPrivacySettingsCard />
|
||||
<AiUsageCard />
|
||||
<QuickCaptureCard />
|
||||
<SectionCard title={t("connectedAccounts")} subtitle={t("settingsConnectedAccountsBody")}>
|
||||
<Button variant="outlined" onClick={() => navigate("/settings/connected-accounts")}>{t("settingsManageConnectedAccounts")}</Button>
|
||||
</SectionCard>
|
||||
<PageSection surface title={t("connectedAccounts")} description={t("settingsConnectedAccountsBody")}
|
||||
actions={<Button variant="outlined" onClick={() => navigate("/settings/connected-accounts")}>{t("settingsManageConnectedAccounts")}</Button>} />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
@@ -216,7 +205,7 @@ export default function SettingsView({
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tab} index={2}>
|
||||
<SectionCard title={t("settingsNotificationsTitle")} subtitle={t("settingsNotificationsBody")}>
|
||||
<PageSection surface title={t("settingsNotificationsTitle")} description={t("settingsNotificationsBody")}>
|
||||
{notificationError ? <Alert severity="error" sx={{ mb: 1.5 }}>{notificationError}</Alert> : null}
|
||||
{!notificationPrefs ? <Skeleton variant="rounded" height={70} /> : <Box sx={{ display: "grid", gap: 1 }}>
|
||||
<FormControlLabel
|
||||
@@ -232,12 +221,12 @@ export default function SettingsView({
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1.5 }}>
|
||||
<Button variant="outlined" onClick={() => navigate("/reminders")}>{t("settingsOpenReminderInbox")}</Button>
|
||||
</Box>
|
||||
</SectionCard>
|
||||
</PageSection>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tab} index={3}>
|
||||
<BackupCard />
|
||||
</TabPanel>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1057,6 +1057,7 @@ export const translations = {
|
||||
settingsNotificationsSave: "Save notification settings",
|
||||
settingsSaving: "Saving…",
|
||||
settingsAppearance: "Appearance",
|
||||
settingsPageDescription: "Manage appearance, job preferences, follow-ups, notifications and your data.",
|
||||
settingsTheme: "Theme",
|
||||
settingsThemeSystem: "System",
|
||||
settingsThemeDark: "Dark",
|
||||
@@ -3496,6 +3497,7 @@ export const translations = {
|
||||
settingsNotificationsSave: "Lagre varslingsinnstillinger",
|
||||
settingsSaving: "Lagrer…",
|
||||
settingsAppearance: "Utseende",
|
||||
settingsPageDescription: "Administrer utseende, jobbinnstillinger, oppfølginger, varsler og dine data.",
|
||||
settingsTheme: "Tema",
|
||||
settingsThemeSystem: "System",
|
||||
settingsThemeDark: "Mørkt",
|
||||
|
||||
@@ -31,6 +31,7 @@ import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone";
|
||||
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
|
||||
|
||||
import JobbjaktMark from "../assets/JobbjaktMark";
|
||||
import { PageHeader, type PageWidth } from "./PageLayout";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
export type NavItem = {
|
||||
@@ -78,6 +79,7 @@ const SIDEBAR_SELECTED_ICON = "var(--palette-navigation-selectedIcon)";
|
||||
export default function AppShell({
|
||||
pageTitle,
|
||||
pageSubtitle,
|
||||
pageLayout,
|
||||
breadcrumbs,
|
||||
pathname,
|
||||
nav,
|
||||
@@ -97,6 +99,7 @@ export default function AppShell({
|
||||
}: {
|
||||
pageTitle: string;
|
||||
pageSubtitle?: string;
|
||||
pageLayout?: PageWidth;
|
||||
breadcrumbs: string[];
|
||||
pathname: string;
|
||||
nav: NavItem[];
|
||||
@@ -527,7 +530,7 @@ export default function AppShell({
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ mx: "auto", maxWidth: 1320, width: "100%", minWidth: 0 }}>
|
||||
<Box sx={{ mx: "auto", maxWidth: pageLayout ? `var(--app-page-width-${pageLayout})` : 1320, width: "100%", minWidth: 0 }}>
|
||||
<Toolbar sx={{ minHeight: { xs: isMobile ? 124 : 68, md: 76 } }} />
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, mb: "var(--app-section-gap)", minWidth: 0 }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
@@ -538,14 +541,18 @@ export default function AppShell({
|
||||
</Typography>
|
||||
))}
|
||||
</Breadcrumbs>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600, overflowWrap: "anywhere" }}>
|
||||
{pageTitle}
|
||||
</Typography>
|
||||
{pageSubtitle ? (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, overflowWrap: "anywhere" }}>
|
||||
{pageSubtitle}
|
||||
</Typography>
|
||||
) : null}
|
||||
{pageLayout ? <PageHeader title={pageTitle} description={pageSubtitle} /> : (
|
||||
<>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600, overflowWrap: "anywhere" }}>
|
||||
{pageTitle}
|
||||
</Typography>
|
||||
{pageSubtitle ? (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, overflowWrap: "anywhere" }}>
|
||||
{pageSubtitle}
|
||||
</Typography>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { useId } from "react";
|
||||
import { Box, Typography } from "@mui/material";
|
||||
|
||||
// Opt-in widths: the shell owns gutters; consumers must not add a second page gutter.
|
||||
export type PageWidth = "reading" | "data" | "editor";
|
||||
|
||||
type HeaderProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: React.ReactNode;
|
||||
level?: "h1" | "h2" | "h3";
|
||||
titleId?: string;
|
||||
};
|
||||
|
||||
export function PageHeader({ title, description, actions, level = "h1", titleId }: HeaderProps) {
|
||||
return (
|
||||
<Box component="header" sx={{ display: "flex", flexWrap: "wrap", alignItems: "flex-start", gap: 2, minWidth: 0 }}>
|
||||
<Box sx={{ flex: "1 1 16rem", minWidth: 0 }}>
|
||||
<Typography id={titleId} component={level} variant={level === "h1" ? "pageTitle" : "sectionTitle"} sx={{ overflowWrap: "anywhere" }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{description ? <Typography variant="pageDescription" component="p" color="text.secondary" sx={{ mt: 1, maxWidth: "75ch", overflowWrap: "anywhere" }}>
|
||||
{description}
|
||||
</Typography> : null}
|
||||
</Box>
|
||||
{actions ? <Box sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 1, minWidth: 0, maxWidth: "100%", "& > *": { maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" } }}>
|
||||
{actions}
|
||||
</Box> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Presentation only: domain components keep their loading, draft and mutation owners.
|
||||
export function PageSection({ title, description, actions, children, level = "h2", surface = false }: Omit<HeaderProps, "titleId" | "level"> & {
|
||||
children?: React.ReactNode;
|
||||
level?: "h2" | "h3";
|
||||
surface?: boolean;
|
||||
}) {
|
||||
const titleId = useId();
|
||||
return (
|
||||
<Box component="section" aria-labelledby={titleId} sx={{
|
||||
display: "grid", gap: 2, minWidth: 0, alignContent: "start",
|
||||
...(surface ? { p: "var(--app-card-padding)", bgcolor: "background.paper", border: "1px solid", borderColor: "divider", borderRadius: 1 } : {}),
|
||||
}}>
|
||||
<PageHeader title={title} titleId={titleId} description={description} actions={actions} level={level} />
|
||||
{children ? <Box sx={{ minWidth: 0 }}>{children}</Box> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
import "@mui/material/styles";
|
||||
import "@mui/material/Typography";
|
||||
|
||||
declare module "@mui/material/styles" {
|
||||
interface TypographyVariants {
|
||||
pageTitle: React.CSSProperties;
|
||||
sectionTitle: React.CSSProperties;
|
||||
pageDescription: React.CSSProperties;
|
||||
}
|
||||
interface TypographyVariantsOptions {
|
||||
pageTitle?: React.CSSProperties;
|
||||
sectionTitle?: React.CSSProperties;
|
||||
pageDescription?: React.CSSProperties;
|
||||
}
|
||||
interface TypeBackground { elevated: string }
|
||||
interface Palette {
|
||||
border: { subtle: string; default: string; strong: string };
|
||||
@@ -16,3 +27,11 @@ declare module "@mui/material/styles" {
|
||||
navigation?: Palette["navigation"];
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@mui/material/Typography" {
|
||||
interface TypographyPropsVariantOverrides {
|
||||
pageTitle: true;
|
||||
sectionTitle: true;
|
||||
pageDescription: true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { Alert, Button, ThemeProvider } from "@mui/material";
|
||||
import { getTheme } from "./theme";
|
||||
import { PageHeader, PageSection } from "./layout/PageLayout";
|
||||
import ViewStateNotice from "./components/ViewStateNotice";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
|
||||
test("[PRES-E08] page and nested section headings retain full names and independent actions", () => {
|
||||
const save = jest.fn();
|
||||
const manage = jest.fn();
|
||||
render(<ThemeProvider theme={getTheme("light")}>
|
||||
<PageHeader title="Innstillinger for oppfølginger og jobbpreferanser" description="Ærlig informasjon om dine valg."
|
||||
actions={<Button onClick={save}>Lagre varslingsinnstillinger</Button>} />
|
||||
<PageSection title="Tilkoblede kontoer" description="Administrer innbokstilkoblinger separat."
|
||||
actions={<Button onClick={manage}>Administrer tilkoblede kontoer</Button>}>
|
||||
<PageSection title="Kontotilgang" level="h3"><Button disabled>Utilgjengelig</Button></PageSection>
|
||||
</PageSection>
|
||||
</ThemeProvider>);
|
||||
expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Innstillinger for oppfølginger og jobbpreferanser");
|
||||
const section = screen.getByRole("region", { name: "Tilkoblede kontoer" });
|
||||
expect(within(section).getByRole("heading", { level: 2 })).toHaveTextContent("Tilkoblede kontoer");
|
||||
expect(within(section).getByRole("heading", { level: 3 })).toHaveTextContent("Kontotilgang");
|
||||
expect(section).not.toHaveAttribute("tabindex");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Lagre varslingsinnstillinger" }));
|
||||
fireEvent.click(within(section).getByRole("button", { name: "Administrer tilkoblede kontoer" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Utilgjengelig" }));
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(manage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("[PRES-E08] section composition retains loaded, error/retry and empty child semantics", () => {
|
||||
const retry = jest.fn();
|
||||
const content = (loading: boolean, failed: boolean) => <ThemeProvider theme={getTheme("dark")}><I18nProvider>
|
||||
<PageSection title="Notifications">
|
||||
<ViewStateNotice loading={loading} title="Could not load" onRetry={retry}
|
||||
error={failed ? { kind: "unavailable", message: "Could not load", retryable: true } : null} />
|
||||
{!loading && !failed ? <Alert severity="info">No notifications.</Alert> : null}
|
||||
</PageSection>
|
||||
</I18nProvider></ThemeProvider>;
|
||||
const view = render(content(true, false));
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Loading");
|
||||
view.rerender(content(false, true));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(retry).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Could not load");
|
||||
view.rerender(content(false, false));
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Content loaded.");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("No notifications.");
|
||||
expect(screen.getByRole("region", { name: "Notifications" })).not.toHaveAttribute("tabindex");
|
||||
});
|
||||
@@ -25,7 +25,7 @@ jest.mock('./components/BackupCard', () => () => <div>Backup Stub</div>);
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderView() {
|
||||
function renderView(overrides: Partial<React.ComponentProps<typeof SettingsView>> = {}) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
@@ -37,6 +37,7 @@ function renderView() {
|
||||
onColumnsChange={jest.fn()}
|
||||
themeMode="dark"
|
||||
onThemeModeChange={jest.fn()}
|
||||
{...overrides}
|
||||
/>
|
||||
</I18nProvider>
|
||||
</ToastProvider>
|
||||
@@ -163,3 +164,20 @@ test('[PRES-S03 PRES-W11] saving one follow-up rule preserves the other scenario
|
||||
offerFollowUpDays: 7, offerGhostDays: 14, feedbackFollowUpDays: 7, feedbackGhostDays: 14,
|
||||
}));
|
||||
});
|
||||
|
||||
test('[PRES-S03 PRES-J05] table and theme preferences keep their independent callbacks', async () => {
|
||||
const onPageSizeChange = jest.fn();
|
||||
const onColumnsChange = jest.fn();
|
||||
const onThemeModeChange = jest.fn();
|
||||
renderView({ onPageSizeChange, onColumnsChange, onThemeModeChange });
|
||||
await screen.findByText(/AI privacy/i);
|
||||
fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Rows per page' }));
|
||||
fireEvent.click(screen.getByRole('option', { name: '25' }));
|
||||
expect(onPageSizeChange).toHaveBeenCalledWith(25);
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Job URL' }));
|
||||
expect(onColumnsChange).toHaveBeenCalledWith({ status: true, dateApplied: true, daysSince: true, jobUrl: true });
|
||||
fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Theme' }));
|
||||
fireEvent.click(screen.getByRole('option', { name: 'Light' }));
|
||||
expect(onThemeModeChange).toHaveBeenCalledWith('light');
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -218,6 +218,10 @@ function buildTypography() {
|
||||
return {
|
||||
fontFamily: "'Archivo', system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif",
|
||||
letterSpacing: 0,
|
||||
// Opt-in application hierarchy; legacy page/marketing variants stay unchanged.
|
||||
pageTitle: { fontWeight: 700, fontSize: "1.5rem", lineHeight: 1.25, letterSpacing: "-0.01em", "@media (min-width: 768px)": { fontSize: "1.75rem", lineHeight: 34 / 28 } },
|
||||
sectionTitle: { fontWeight: 600, fontSize: "1.125rem", lineHeight: 24 / 18 },
|
||||
pageDescription: { fontWeight: 400, fontSize: "0.875rem", lineHeight: 20 / 14 },
|
||||
// Mockup headings (F:\Pictures\website\jobtracker\new) are bold/black-weight display
|
||||
// type ("Dashboard", "One workspace for the whole search") -- 500 across the board read
|
||||
// as noticeably lighter/flatter than that reference. h1-h4 (page titles, hero, section
|
||||
@@ -283,6 +287,9 @@ export const getTheme = (_mode: "light" | "dark") => {
|
||||
"--app-page-gutter": "16px",
|
||||
"--app-section-gap": "24px",
|
||||
"--app-card-padding": "16px",
|
||||
"--app-page-width-reading": "60rem",
|
||||
"--app-page-width-data": "82.5rem",
|
||||
"--app-page-width-editor": "100%",
|
||||
},
|
||||
"@media (min-width: 768px)": {
|
||||
":root": {
|
||||
|
||||
Reference in New Issue
Block a user