From 30f513d0c993800ec5d5583329ed65dc7ec9a8d4 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 9 Oct 2026 19:02:50 +0200 Subject: [PATCH] fix(settings): preserve Rules drafts across internal tabs --- .../p5-15-01/rules-draft-repair/README.md | 6 +- job-tracker-ui/e2e/rules-draft-repair.spec.ts | 204 +++++++++++++++++- .../src/components/RulesSettingsCard.tsx | 82 +++---- .../src/components/SettingsView.tsx | 88 +++++++- job-tracker-ui/src/i18n/translations.ts | 6 + job-tracker-ui/src/settings-view.test.tsx | 121 ++++++++++- 6 files changed, 446 insertions(+), 61 deletions(-) diff --git a/docs/redesign/phase-5/p5-15/p5-15-01/rules-draft-repair/README.md b/docs/redesign/phase-5/p5-15/p5-15-01/rules-draft-repair/README.md index 0b9788e..702a62b 100644 --- a/docs/redesign/phase-5/p5-15/p5-15-01/rules-draft-repair/README.md +++ b/docs/redesign/phase-5/p5-15/p5-15-01/rules-draft-repair/README.md @@ -20,9 +20,9 @@ The new ordinary browser regression uses a disposable authenticated SQLite appli - [x] Trace and reproduce the exact native draft loss. - [x] Add the ordinary failing browser regression before application changes. -- [ ] Repair and validate controlled draft/save/discard/error ownership. -- [ ] Complete native/browser, localisation/responsive and account evidence. +- [x] Repair and validate controlled draft/save/discard/error ownership. +- [x] Complete focused native/browser, localisation/responsive and account evidence. - [ ] Run the fresh full baseline and reconcile ordinary failures/counts. - [ ] Update acceptance/defect evidence, commit/push each completed unit and verify clean remote equality. -This is the **reproducer checkpoint**, not repair acceptance. No application change or fresh full-suite pass is claimed yet. The original audit and R-01 evidence remain unchanged. Production NO-GO and all deferred qualification obligations remain open. +The reproducer was committed/pushed as `5b5dcd0b80aa0713545ecf1fdab079fb9dec28ee` before implementation. The bounded implementation now passes all four native SQLite browser journeys and all472 frontend tests (464+8 focused behavioural cases), TypeScript, lint and the production build. Backend1,033, focused AI108, revision5 and sidecar49 have fresh passing runs. The complete browser/security/document run is in progress; no final browser count or repair acceptance is claimed here. The original audit and R-01 evidence remain unchanged. Production NO-GO and all deferred qualification obligations remain open. diff --git a/job-tracker-ui/e2e/rules-draft-repair.spec.ts b/job-tracker-ui/e2e/rules-draft-repair.spec.ts index 871125d..59cc835 100644 --- a/job-tracker-ui/e2e/rules-draft-repair.spec.ts +++ b/job-tracker-ui/e2e/rules-draft-repair.spec.ts @@ -1,7 +1,16 @@ -import { expect, test as base, type APIRequestContext } from '@playwright/test'; +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!'; +const baseline = { id: 1, appliedFollowUpDays: 14, appliedGhostDays: 30, offerFollowUpDays: 7, offerGhostDays: 14, feedbackFollowUpDays: 7, feedbackGhostDays: 14 }; + +async function createAccount(admin: APIRequestContext, headers: Record) { + const email = `rules-repair-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; + expect((await admin.post(`${apiUrl}/users`, { headers, data: { email, password, roles: [] } })).status()).toBe(200); + return email; +} async function login(api: APIRequestContext, email: string) { expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200); @@ -15,9 +24,8 @@ const test = base.extend<{ headers: Record }>({ const admin = await playwright.request.newContext(); try { const adminHeaders = await login(admin, 'e2e@example.test'); - const email = `rules-repair-${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); - await context.addInitScript(() => localStorage.setItem('uiLanguage', 'en')); + const email = await createAccount(admin, adminHeaders); + await context.addInitScript(() => { if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', 'en'); }); 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) @@ -28,6 +36,40 @@ const test = base.extend<{ headers: Record }>({ }, }); +async function choose(page: Page, label: string) { + if (page.viewportSize()!.width < 768) { + const selector = page.getByRole('combobox', { name: /Settings section|Innstillingsområde/ }); + await selector.focus(); + await selector.press('Enter'); + await page.getByRole('option', { name: label, exact: true }).focus(); + await page.keyboard.press('Enter'); + await expect(selector).toBeFocused(); + } else { + const tab = page.getByRole('tab', { name: label, exact: true }); + await tab.focus(); + await tab.press('Enter'); + await expect(tab).toHaveAttribute('aria-selected', 'true'); + } +} + +async function capture(page: Page, info: TestInfo, name: string) { + const outside = await page.getByRole('main').evaluate(main => Array.from(main.querySelectorAll('input,button,p,h2,[role="status"]')) + .filter(node => node.getClientRects().length && getComputedStyle(node).visibility !== 'hidden') + .filter(node => { const rect = node.getBoundingClientRect(); return rect.left < -1 || rect.right > innerWidth + 1; }) + .map(node => node.textContent?.slice(0, 80))); + expect(outside, `${name} usable controls/reflow`).toEqual([]); + const path = info.outputPath(`${name}.png`); + await page.screenshot({ path, fullPage: true, animations: 'disabled' }); + await info.attach(name, { path, contentType: 'image/png' }); + 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 }), contentType: 'application/json' }); + expect(result.violations).toEqual([]); +} + +function response(page: Page, method: string) { + return page.waitForResponse(r => r.url() === `${apiUrl}/rules` && r.request().method() === method); +} + test('[P5-15-01R-02 PRES-S03 W11] unsaved Rules draft survives an internal tab round trip without saving', async ({ page, headers }, info) => { expect((await page.request.put(`${apiUrl}/rules`, { headers, data: { id: 1, appliedFollowUpDays: 14, appliedGhostDays: 30, @@ -53,3 +95,157 @@ test('[P5-15-01R-02 PRES-S03 W11] unsaved Rules draft survives an internal tab r expect(persisted.appliedFollowUpDays).toBe(14); await expect(field).toHaveValue('5'); }); + +test('[P5-15-01R-02] native Rules save, validation, failure/retry and discard across themes/locales/viewports', async ({ page, headers }, info) => { + test.setTimeout(180_000); + await page.emulateMedia({ reducedMotion: 'reduce' }); + for (const variant of [ + { language: 'en', mode: 'light', width: 1440 }, { language: 'nb', mode: 'dark', width: 768 }, + { language: 'en', mode: 'dark', width: 390 }, { language: 'nb', mode: 'light', width: 320 }, + ] as const) { + const t = translations[variant.language]; + const suffix = `${variant.language}-${variant.mode}-${variant.width}`; + expect((await page.request.put(`${apiUrl}/rules`, { headers, data: baseline })).status()).toBe(204); + expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: variant.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204); + await page.setViewportSize({ width: variant.width, height: 900 }); + await page.goto('/settings'); + await page.evaluate(({ mode, language }) => { localStorage.setItem('jobtracker.themeMode', mode); localStorage.setItem('uiLanguage', language); }, variant); + await page.reload(); + await expect(page.locator('html')).toHaveAttribute('data-color-scheme', variant.mode); + await choose(page, t.settingsTabFollowUps); + const main = page.getByRole('main'); + const labels = [t.rulesAppliedFollowUpDays, t.rulesAppliedGhostDays, t.rulesOfferFollowUpDays, t.rulesOfferGhostDays, t.rulesFeedbackFollowUpDays, t.rulesFeedbackGhostDays]; + const fields = labels.map(name => main.getByRole('spinbutton', { name, exact: true })); + await expect(fields[0]).toHaveValue('14'); + for (const [index, value] of ['5', '40', '9', '20', '11', '25'].entries()) await fields[index].fill(value); + for (let i = 0; i < 2; i++) { + await choose(page, t.settingsTabNotifications); + await expect(main.getByRole('status')).toContainText(t.assetsUnsavedChanges); + await choose(page, t.settingsTabFollowUps); + } + for (const [index, value] of ['5', '40', '9', '20', '11', '25'].entries()) await expect(fields[index]).toHaveValue(value); + expect(await (await page.request.get(`${apiUrl}/rules`)).json()).toEqual(baseline); + await capture(page, info, `rules-draft-${suffix}`); + + await fields[0].fill(''); + await fields[2].fill('366'); + let writes = 0; + const countWrite = (request: import('@playwright/test').Request) => { if (request.url() === `${apiUrl}/rules` && request.method() === 'PUT') writes++; }; + page.on('request', countWrite); + await main.getByRole('button', { name: t.rulesSave, exact: true }).press('Enter'); + await expect(fields[0]).toHaveAttribute('aria-invalid', 'true'); + await expect(fields[0]).toHaveAccessibleDescription(t.rulesDaysInvalid); + await choose(page, t.settingsTabNotifications); + await choose(page, t.settingsTabFollowUps); + await expect(fields[0]).toHaveValue(''); + await expect(fields[2]).toHaveValue('366'); + await expect(fields[2]).toHaveAccessibleDescription(t.rulesDaysInvalid); + expect(writes).toBe(0); + await capture(page, info, `rules-validation-${suffix}`); + await fields[0].fill('5'); await fields[2].fill('9'); + + let fail = true; + await page.route(`${apiUrl}/rules`, route => { + if (route.request().method() === 'PUT' && fail) { fail = false; return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ message: 'Synthetic internal details must not appear' }) }); } + return route.continue(); + }); + const failed = response(page, 'PUT'); + await main.getByRole('button', { name: t.rulesSave, exact: true }).press('Enter'); + expect((await failed).status()).toBe(500); + await expect(main.getByRole('alert')).toHaveText(t.rulesSaveFailed); + await choose(page, t.settingsTabNotifications); await choose(page, t.settingsTabFollowUps); + await expect(main.getByRole('alert')).toHaveText(t.rulesSaveFailed); + await expect(fields[0]).toHaveValue('5'); + expect(await (await page.request.get(`${apiUrl}/rules`)).json()).toEqual(baseline); + await capture(page, info, `rules-save-error-${suffix}`); + const retried = response(page, 'PUT'); + await main.getByRole('button', { name: t.rulesSave, exact: true }).press('Enter'); + expect((await retried).status()).toBe(204); + await expect(main.getByRole('status')).toHaveCount(0); + await expect(main.getByRole('alert')).toHaveCount(0); + const saved = { id: 1, appliedFollowUpDays: 5, appliedGhostDays: 40, offerFollowUpDays: 9, offerGhostDays: 20, feedbackFollowUpDays: 11, feedbackGhostDays: 25 }; + expect(await (await page.request.get(`${apiUrl}/rules`)).json()).toEqual(saved); + expect(writes).toBe(2); + page.off('request', countWrite); + await page.unroute(`${apiUrl}/rules`); + await page.reload(); await choose(page, t.settingsTabFollowUps); + await expect(fields[0]).toHaveValue('5'); await expect(fields[2]).toHaveValue('9'); + await fields[0].fill('6'); await fields[2].fill('10'); + await choose(page, t.settingsTabNotifications); await choose(page, t.settingsTabFollowUps); + await main.getByRole('button', { name: t.assetsDiscardChanges, exact: true }).press('Enter'); + await expect(fields[0]).toHaveValue('5'); await expect(fields[2]).toHaveValue('9'); + await expect(main.getByRole('status')).toHaveCount(0); + expect(await (await page.request.get(`${apiUrl}/rules`)).json()).toEqual(saved); + await capture(page, info, `rules-discard-${suffix}`); + } +}); + +test('[P5-15-01R-02] delayed native Rules reads and save completion do not reset a newer session draft', async ({ page, headers }, info) => { + expect((await page.request.put(`${apiUrl}/rules`, { headers, data: baseline })).status()).toBe(204); + let releaseRead!: () => void; + const readGate = new Promise(resolve => { releaseRead = resolve; }); + let reads = 0; + await page.route(`${apiUrl}/rules`, async route => { + if (route.request().method() === 'GET') { reads++; await readGate; } + await route.continue(); + }); + await page.goto('/settings'); await choose(page, translations.en.settingsTabFollowUps); + await expect(page.getByRole('status')).toHaveText(translations.en.rulesLoading); + await choose(page, translations.en.settingsTabNotifications); await choose(page, translations.en.settingsTabFollowUps); + releaseRead(); + const field = page.getByRole('spinbutton', { name: translations.en.rulesAppliedFollowUpDays, exact: true }); + await expect(field).toHaveValue('14'); + expect(reads).toBe(1); + await page.unroute(`${apiUrl}/rules`); + await field.fill('5'); + let releaseSave!: () => void; + const saveGate = new Promise(resolve => { releaseSave = resolve; }); + await page.route(`${apiUrl}/rules`, async route => { if (route.request().method() === 'PUT') await saveGate; await route.continue(); }); + const savedResponse = response(page, 'PUT'); + await page.getByRole('button', { name: translations.en.rulesSave, exact: true }).click(); + await expect(page.getByRole('button', { name: translations.en.rulesSaving, exact: true })).toBeDisabled(); + await field.fill('6'); + await choose(page, translations.en.settingsTabNotifications); await choose(page, translations.en.settingsTabFollowUps); + releaseSave(); + expect((await savedResponse).status()).toBe(204); + await expect(field).toHaveValue('6'); + await expect(page.getByRole('main').getByRole('status')).toContainText(translations.en.assetsUnsavedChanges); + expect((await (await page.request.get(`${apiUrl}/rules`)).json()).appliedFollowUpDays).toBe(5); + await info.attach('native-save-snapshot', { body: JSON.stringify({ initial: 14, submitted: 5, newerDraft: 6, persisted: 5, reads }), contentType: 'application/json' }); + await page.getByRole('button', { name: translations.en.assetsDiscardChanges, exact: true }).click(); + await expect(field).toHaveValue('5'); + await field.fill('8'); + await expect(page.getByRole('main').getByRole('status')).toContainText(translations.en.rulesDraftScope); + await page.getByRole('button', { name: translations.en.jobApplications, exact: true }).click(); + await page.getByRole('navigation', { name: translations.en.navigation, exact: true }).getByRole('button', { name: translations.en.settings, exact: true }).click(); + await choose(page, translations.en.settingsTabFollowUps); + await expect(field).toHaveValue('5'); +}); + +test('[P5-15-01R-02 PRES-E09] Rules API ignores forged owners and account switching disposes the previous draft', async ({ page, playwright, headers }, info) => { + const meA = await (await page.request.get(`${apiUrl}/auth/me`)).json(); + const ownerA = await playwright.request.newContext({ storageState: await page.context().storageState() }); + const admin = await playwright.request.newContext(); + const anonymous = await playwright.request.newContext(); + try { + const adminHeaders = await login(admin, 'e2e@example.test'); + const emailB = await createAccount(admin, adminHeaders); + expect((await page.request.put(`${apiUrl}/rules`, { headers, data: baseline })).status()).toBe(204); + await page.goto('/settings'); await choose(page, translations.en.settingsTabFollowUps); + const field = page.getByRole('spinbutton', { name: translations.en.rulesAppliedFollowUpDays, exact: true }); + await expect(field).toHaveValue('14'); await field.fill('9'); + const headersB = await login(page.request, emailB); + expect((await page.request.put(`${apiUrl}/rules?ownerUserId=${meA.id}`, { headers: headersB, data: { ...baseline, id: 999, ownerUserId: meA.id, appliedFollowUpDays: 22 } })).status()).toBe(204); + expect((await (await page.request.get(`${apiUrl}/rules?ownerUserId=${meA.id}`)).json()).appliedFollowUpDays).toBe(22); + expect((await (await ownerA.get(`${apiUrl}/rules`)).json()).appliedFollowUpDays).toBe(14); + await expect(page).toHaveURL(/\/login\?sessionChanged=1$/); + await expect(page.getByText(translations.en.sessionChangedCleared, { exact: true })).toBeVisible(); + await expect(field).toHaveCount(0); + await page.goto('/settings'); await choose(page, translations.en.settingsTabFollowUps); + await expect(field).toHaveValue('22'); + await expect(page.getByRole('main').getByRole('status')).toHaveCount(0); + expect((await anonymous.get(`${apiUrl}/rules`)).status()).toBe(401); + expect((await anonymous.put(`${apiUrl}/rules`, { data: baseline })).status()).toBe(401); + await info.attach('rules-native-account-boundary', { body: JSON.stringify({ accountAUnchanged: 14, accountB: 22, oldDraft: 9, oldPageDisposed: true, anonymous: [401, 401] }), contentType: 'application/json' }); + } finally { await ownerA.dispose(); await admin.dispose(); await anonymous.dispose(); } +}); diff --git a/job-tracker-ui/src/components/RulesSettingsCard.tsx b/job-tracker-ui/src/components/RulesSettingsCard.tsx index 2e14bd8..6024eff 100644 --- a/job-tracker-ui/src/components/RulesSettingsCard.tsx +++ b/job-tracker-ui/src/components/RulesSettingsCard.tsx @@ -1,13 +1,11 @@ -import React, { useEffect, useState } from "react"; +import React from "react"; import { Alert, Box, Button, TextField, Typography } from "@mui/material"; import { PageSection } from "../layout/PageLayout"; -import { api, getApiErrorMessage } from "../api"; -import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; -type RuleSettings = { +export type RuleSettings = { id: number; appliedFollowUpDays: number; appliedGhostDays: number; @@ -17,61 +15,47 @@ type RuleSettings = { feedbackGhostDays: number; }; -export default function RulesSettingsCard() { - const { toast } = useToast(); +export const RULE_FIELDS = ["appliedFollowUpDays", "appliedGhostDays", "offerFollowUpDays", "offerGhostDays", "feedbackFollowUpDays", "feedbackGhostDays"] as const; +export type RuleField = typeof RULE_FIELDS[number]; +export type RuleDraft = Record; + +type Props = { + draft: RuleDraft | null; + loadError: string | null; + saveError: boolean; + invalid: RuleField[]; + saving: boolean; + dirty: boolean; + onChange: (field: RuleField, value: string) => void; + onSave: () => void; + onDiscard: () => void; + onRetry: () => void; +}; + +export default function RulesSettingsCard({ draft, loadError, saveError, invalid, saving, dirty, onChange, onSave, onDiscard, onRetry }: Props) { const { t } = useI18n(); - const [s, setS] = useState(null); - const [saving, setSaving] = useState(false); - const [loadError, setLoadError] = useState(null); - - useEffect(() => { - let active = true; - api.get("/rules") - .then((r) => { - if (!active) return; - setS(r.data); - setLoadError(null); - }) - .catch((e: any) => { - if (!active) return; - setS(null); - setLoadError(getApiErrorMessage(e, t("rulesLoadFailed"))); - }); - return () => { active = false; }; - }, [t]); - - const save = async () => { - if (!s) return; - setSaving(true); - try { - await api.put("/rules", s); - toast(t("rulesSave"), "success"); - } catch { - toast(t("rulesSaveFailed"), "error"); - } finally { - setSaving(false); - } - }; - - if (!s) { + if (!draft) { return ( - {loadError ? {loadError} : {t("rulesLoading")}} + {loadError ? {t("retry")}}>{loadError} : {t("rulesLoading")}} ); } - const num = (k: keyof RuleSettings) => ({ - value: s[k], + const num = (k: RuleField) => ({ + value: draft[k], onChange: (e: React.ChangeEvent) => - setS((p) => (p ? { ...p, [k]: Number(e.target.value) } : p)), + onChange(k, e.target.value), type: "number" as const, - inputProps: { min: 1, max: 365 }, + error: invalid.includes(k), + helperText: invalid.includes(k) ? t("rulesDaysInvalid") : undefined, + inputProps: { min: 1, max: 365, step: 1, required: true }, }); return ( - + { event.preventDefault(); onSave(); }}> + {saveError ? {t("rulesSaveFailed")} : null} @@ -84,11 +68,13 @@ export default function RulesSettingsCard() { - - + + ); } diff --git a/job-tracker-ui/src/components/SettingsView.tsx b/job-tracker-ui/src/components/SettingsView.tsx index 7518cdb..5bce33c 100644 --- a/job-tracker-ui/src/components/SettingsView.tsx +++ b/job-tracker-ui/src/components/SettingsView.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { Box, @@ -20,7 +20,7 @@ import { useNavigate } from "react-router-dom"; import { JobTableColumns } from "./JobTable"; import ImportExportJobs from "./ImportExportJobs"; -import RulesSettingsCard from "./RulesSettingsCard"; +import RulesSettingsCard, { RULE_FIELDS, RuleDraft, RuleField, RuleSettings } from "./RulesSettingsCard"; import BackupCard from "./BackupCard"; import QuickCaptureCard from "./QuickCaptureCard"; import AiUsageCard from "./AiUsageCard"; @@ -49,6 +49,10 @@ type NotificationPrefs = { emailFollowUpRemindersEnabled: boolean; }; +function ruleDraft(settings: RuleSettings): RuleDraft { + return Object.fromEntries(RULE_FIELDS.map(field => [field, String(settings[field])])) as RuleDraft; +} + export default function SettingsView({ pageSize, onPageSizeChange, @@ -64,6 +68,73 @@ export default function SettingsView({ const [notificationPrefs, setNotificationPrefs] = useState(null); const [notificationError, setNotificationError] = useState(null); const [savingNotifications, setSavingNotifications] = useState(false); + const [savedRules, setSavedRules] = useState(null); + const [rulesDraft, setRulesDraft] = useState(null); + const [rulesLoadError, setRulesLoadError] = useState(null); + const [rulesSaveError, setRulesSaveError] = useState(false); + const [invalidRules, setInvalidRules] = useState([]); + const [savingRules, setSavingRules] = useState(false); + const rulesSession = useRef({ mounted: true, loading: false, saving: false }); + const rulesDirty = Boolean(rulesDraft && savedRules && RULE_FIELDS.some(field => rulesDraft[field] !== String(savedRules[field]))); + + useEffect(() => { + const session = rulesSession.current; + session.mounted = true; + return () => { session.mounted = false; }; + }, []); + + const loadRules = async () => { + const session = rulesSession.current; + if (rulesDraft || session.loading) return; + session.loading = true; + setRulesLoadError(null); + try { + const { data } = await api.get("/rules"); + if (!session.mounted) return; + setSavedRules(data); + setRulesDraft(ruleDraft(data)); + } catch (error) { + if (session.mounted) setRulesLoadError(getApiErrorMessage(error, t("rulesLoadFailed"))); + } finally { session.loading = false; } + }; + + const selectSection = (next: number) => { + setTab(next); + if (next === 1) void loadRules(); + }; + + const saveRules = async () => { + const session = rulesSession.current; + if (!rulesDraft || !savedRules || session.saving) return; + const invalid = RULE_FIELDS.filter(field => !rulesDraft[field].trim() || !Number.isInteger(Number(rulesDraft[field])) || Number(rulesDraft[field]) < 1 || Number(rulesDraft[field]) > 365); + setInvalidRules(invalid); + if (invalid.length) return; + const submittedDraft = rulesDraft; + const submitted = { id: savedRules.id, ...Object.fromEntries(RULE_FIELDS.map(field => [field, Number(submittedDraft[field])])) } as RuleSettings; + session.saving = true; + setSavingRules(true); + setRulesSaveError(false); + try { + await api.put("/rules", submitted); + if (!session.mounted) return; + setSavedRules(submitted); + // A completed save acknowledges its snapshot, not edits made while it was pending. + setRulesDraft(current => current && RULE_FIELDS.every(field => current[field] === submittedDraft[field]) ? ruleDraft(submitted) : current); + toast(t("rulesSaved"), "success"); + } catch { + if (session.mounted) setRulesSaveError(true); + } finally { + session.saving = false; + if (session.mounted) setSavingRules(false); + } + }; + + const discardRules = () => { + if (!savedRules || savingRules) return; + setRulesDraft(ruleDraft(savedRules)); + setInvalidRules([]); + setRulesSaveError(false); + }; useEffect(() => { let active = true; @@ -94,7 +165,7 @@ export default function SettingsView({ labelId="settings-section-label" value={tab} label={t("settingsNavigation")} - onChange={(event) => setTab(Number(event.target.value))} + onChange={(event) => selectSection(Number(event.target.value))} > {t("settingsTabGeneral")} {t("settingsTabFollowUps")} @@ -103,13 +174,15 @@ export default function SettingsView({ - setTab(v)} variant="scrollable" scrollButtons="auto" sx={{ display: { xs: "none", sm: "flex" }, mb: 1 }}> + selectSection(v)} variant="scrollable" scrollButtons="auto" sx={{ display: { xs: "none", sm: "flex" }, mb: 1 }}> + {rulesDirty ? {t("assetsUnsavedChanges")}. {t("rulesDraftScope")} : null} + @@ -201,7 +274,12 @@ export default function SettingsView({ - + void saveRules()} onRetry={() => void loadRules()} onDiscard={discardRules} + onChange={(field, value) => { + setRulesDraft(current => current ? { ...current, [field]: value } : current); + setInvalidRules(current => current.filter(key => key !== field)); + }} /> diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 47b0e26..3e1aec6 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -2453,6 +2453,9 @@ export const translations = { rulesSaving: "Saving...", rulesSave: "Save Rules", rulesSaveFailed: "Failed to save rules.", + rulesSaved: "Rules saved.", + rulesDaysInvalid: "Enter a whole number from 1 to 365.", + rulesDraftScope: "Rules changes stay while you switch Settings sections. Save or discard them before leaving Settings; leaving discards unsaved changes.", workspace: "Workspace", workspaceBack: "Back to applications", workspaceOpenFull: "Open full-page workspace", @@ -5033,6 +5036,9 @@ export const translations = { rulesSaving: "Lagrer...", rulesSave: "Lagre regler", rulesSaveFailed: "Kunne ikke lagre regler.", + rulesSaved: "Reglene er lagret.", + rulesDaysInvalid: "Skriv et helt tall fra 1 til 365.", + rulesDraftScope: "Regelendringer beholdes når du bytter del i Innstillinger. Lagre eller forkast dem før du går ut; ulagrede endringer går tapt når du forlater Innstillinger.", workspace: "Arbeidsområde", workspaceBack: "Tilbake til søknader", workspaceOpenFull: "Åpne arbeidsområdet på helside", diff --git a/job-tracker-ui/src/settings-view.test.tsx b/job-tracker-ui/src/settings-view.test.tsx index cf35dfb..7355f79 100644 --- a/job-tracker-ui/src/settings-view.test.tsx +++ b/job-tracker-ui/src/settings-view.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import '@testing-library/jest-dom'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import SettingsView from './components/SettingsView'; @@ -181,3 +181,122 @@ test('[PRES-S03 PRES-J05] table and theme preferences keep their independent cal expect(onThemeModeChange).toHaveBeenCalledWith('light'); expect(mockedApi.put).not.toHaveBeenCalled(); }); + +async function openRules() { + fireEvent.click(screen.getByRole('tab', { name: /follow-ups/i })); + const field = await screen.findByLabelText('Applied: follow-up days'); + await waitFor(() => expect(field).toHaveValue(14)); + return field; +} + +function rulesRoundTrip() { + fireEvent.click(screen.getByRole('tab', { name: /notifications/i })); + fireEvent.click(screen.getByRole('tab', { name: /follow-ups/i })); +} + +test('[PRES-S03 W11] the Settings session retains independent pending Rules fields across tab unmounts', async () => { + renderView(); + fireEvent.change(await openRules(), { target: { value: '5' } }); + fireEvent.change(screen.getByLabelText('Offer: follow-up days'), { target: { value: '9' } }); + rulesRoundTrip(); + expect(screen.getByLabelText('Applied: follow-up days')).toHaveValue(5); + expect(screen.getByLabelText('Offer: follow-up days')).toHaveValue(9); + expect(screen.getByLabelText('Feedback: follow-up days')).toHaveValue(7); + expect(screen.getByRole('status')).toHaveTextContent(/unsaved/i); + expect(mockedApi.put).not.toHaveBeenCalled(); +}); + +test('[PRES-S03] failed Rules save remains actionable after tab return and explicit retry clears dirty state', async () => { + renderView(); + fireEvent.change(await openRules(), { target: { value: '5' } }); + mockedApi.put.mockRejectedValueOnce(new Error('offline')); + fireEvent.click(screen.getByRole('button', { name: /save rules/i })); + expect(await screen.findByRole('alert')).toHaveTextContent(/failed to save rules/i); + rulesRoundTrip(); + expect(screen.getByLabelText('Applied: follow-up days')).toHaveValue(5); + expect(screen.getByRole('alert')).toHaveTextContent(/failed to save rules/i); + expect(screen.getByRole('status')).toHaveTextContent(/unsaved/i); + fireEvent.click(screen.getByRole('button', { name: /save rules/i })); + await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument()); + expect(screen.queryByText('Failed to save rules.')).not.toBeInTheDocument(); +}); + +test('[PRES-S03] invalid and empty Rules input and associated validation survive internal navigation', async () => { + renderView(); + fireEvent.change(await openRules(), { target: { value: '' } }); + fireEvent.change(screen.getByLabelText('Offer: follow-up days'), { target: { value: '366' } }); + fireEvent.click(screen.getByRole('button', { name: /save rules/i })); + expect(mockedApi.put).not.toHaveBeenCalled(); + rulesRoundTrip(); + const empty = screen.getByLabelText('Applied: follow-up days'); + expect(empty).toHaveValue(null); + expect(empty).toHaveAttribute('aria-invalid', 'true'); + expect(empty).toHaveAccessibleDescription(/whole number.*1.*365/i); + expect(screen.getByLabelText('Offer: follow-up days')).toHaveValue(366); + fireEvent.change(empty, { target: { value: '5' } }); + fireEvent.change(screen.getByLabelText('Offer: follow-up days'), { target: { value: '9' } }); + fireEvent.click(screen.getByRole('button', { name: /save rules/i })); + await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(1)); +}); + +test('[PRES-S03] explicit discard resets only the Rules draft to its saved baseline without writing', async () => { + renderView(); + fireEvent.change(await openRules(), { target: { value: '5' } }); + rulesRoundTrip(); + fireEvent.click(screen.getByRole('button', { name: /discard changes/i })); + expect(screen.getByLabelText('Applied: follow-up days')).toHaveValue(14); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect(mockedApi.put).not.toHaveBeenCalled(); +}); + +test('[PRES-S03] later edits stay dirty when an earlier Rules save completes after a tab switch', async () => { + let finish!: (value: any) => void; + mockedApi.put.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + renderView(); + fireEvent.change(await openRules(), { target: { value: '5' } }); + fireEvent.click(screen.getByRole('button', { name: /save rules/i })); + fireEvent.change(screen.getByLabelText('Applied: follow-up days'), { target: { value: '6' } }); + rulesRoundTrip(); + await act(async () => finish({ status: 204 })); + expect(screen.getByLabelText('Applied: follow-up days')).toHaveValue(6); + expect(screen.getByRole('status')).toHaveTextContent(/unsaved/i); + fireEvent.click(screen.getByRole('button', { name: /discard changes/i })); + expect(screen.getByLabelText('Applied: follow-up days')).toHaveValue(5); +}); + +test('[PRES-S03 E09] leaving the Settings session disposes its draft and pending responses', async () => { + let finish!: (value: any) => void; + mockedApi.put.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const first = renderView(); + fireEvent.change(await openRules(), { target: { value: '5' } }); + fireEvent.click(screen.getByRole('button', { name: /save rules/i })); + first.unmount(); + renderView(); + await openRules(); + await act(async () => finish({ status: 204 })); + expect(screen.getByLabelText('Applied: follow-up days')).toHaveValue(14); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); +}); + +test('[PRES-S03] switching locale while Rules is hidden does not reload or overwrite its pending draft', async () => { + renderView(); + fireEvent.change(await openRules(), { target: { value: '5' } }); + const reads = mockedApi.get.mock.calls.filter(([url]) => url === '/rules').length; + fireEvent.click(screen.getByRole('tab', { name: /general/i })); + fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Preferred language' })); + fireEvent.click(screen.getByRole('option', { name: 'Norwegian Bokmål' })); + fireEvent.click(screen.getByRole('tab', { name: 'Oppfølging' })); + expect(screen.getByLabelText('Søkt: oppfølgingsdager')).toHaveValue(5); + expect(mockedApi.get.mock.calls.filter(([url]) => url === '/rules')).toHaveLength(reads); +}); + +test('[PRES-S03] Rules draft updates do not refetch an unrelated notification preference', async () => { + renderView(); + const field = await openRules(); + const reads = mockedApi.get.mock.calls.filter(([url]) => url === '/notification-settings').length; + fireEvent.change(field, { target: { value: '5' } }); + fireEvent.change(field, { target: { value: '6' } }); + await act(async () => undefined); + expect(mockedApi.get.mock.calls.filter(([url]) => url === '/notification-settings')).toHaveLength(reads); +});