feat(ui): add automation settings and safe AI review presentation
This commit is contained in:
@@ -64,7 +64,7 @@ async function choose(page: Page, label: string, language: Language = 'en') {
|
||||
else { await keyboard(tab); await expect(tab).toHaveAttribute('aria-selected', 'true'); }
|
||||
}
|
||||
function notificationGroup(page: Page, language: Language = 'en') { return page.getByRole('main').getByRole('group', { name: copy('settingsNotificationsTitle', language), exact: true }); }
|
||||
function notificationCheckbox(page: Page, language: Language = 'en') { return page.getByRole('main').getByRole('checkbox', { name: copy('settingsNotificationsFollowUpReminders', language), exact: true }); }
|
||||
function notificationCheckbox(page: Page, language: Language = 'en') { return page.getByRole('main').getByRole('switch', { name: copy('settingsNotificationsFollowUpReminders', language), exact: true }); }
|
||||
async function toggle(control: Locator) { await expect(control).toBeEnabled(); await control.focus(); await control.press('Space'); }
|
||||
async function persistedNotifications(session: Session, api: APIRequestContext) { return json<{ emailFollowUpRemindersEnabled: boolean }>(await call(session.requests, api, 'GET', '/notification-settings')); }
|
||||
function response(page: Page, method: string, path: string) { return page.waitForResponse(value => value.request().method() === method && new URL(value.url()).pathname === `/api${path}`); }
|
||||
|
||||
@@ -135,7 +135,7 @@ for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb
|
||||
await expect(notifications.getByRole("progressbar", { name: t.loading, exact: true })).toBeVisible();
|
||||
await capture(page, info, `notifications-loading-${suffix}`);
|
||||
release();
|
||||
const pref = main.getByRole("checkbox", { name: t.settingsNotificationsFollowUpReminders, exact: true });
|
||||
const pref = main.getByRole("switch", { name: t.settingsNotificationsFollowUpReminders, exact: true });
|
||||
await expect(pref).toBeChecked();
|
||||
await page.unroute(`${apiUrl}/notification-settings`);
|
||||
await pref.uncheck();
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Exported React UI with synthetic API responses; no backend, model or provider access.
|
||||
import { chromium, expect } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import ts from 'typescript';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
const compiled = ts.transpileModule(fs.readFileSync(new URL('../src/i18n/translations.ts', import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText;
|
||||
const { translations } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
|
||||
const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evidence/ai-settings/browser');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const report = { screens: [], journeys: [], errors: [], unexpectedWrites: [], writes: [], proposals: [], unavailableReads: [], blockedExternal: [] };
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const configurations = process.env.A3_PILOT ? [[320, 'en', 'light']] : [320, 390, 768, 1440].flatMap(width => ['en', 'nb'].flatMap(language => ['light', 'dark'].map(mode => [width, language, mode])));
|
||||
for (const [width, language, mode] of configurations) {
|
||||
console.log(`Checking ${width}/${language}/${mode}`);
|
||||
const t = key => { assert.ok(translations[language][key], key); return translations[language][key]; };
|
||||
const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce' });
|
||||
const page = await context.newPage(); page.on('pageerror', error => report.errors.push(error.message));
|
||||
await page.addInitScript(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, { language, mode });
|
||||
let notifications = { emailFollowUpRemindersEnabled: true };
|
||||
let privacy = { enabled: true, externalProcessingAllowed: false, externalProcessingAvailable: false, effectiveExternalProcessing: false, provider: 'local' };
|
||||
let variant = { id: 3, name: 'Teacher CV', version: 4, settings: { language: 'en', themeId: 'modern', showPhoto: false, showPageNumbers: false, showIcons: true, sections: [], overrides: {}, customSections: [] }, isPublic: false, publicSlug: 'synthetic', jobApplicationId: null, updatedAtUtc: '2026-11-03T10:00:00Z' };
|
||||
let proposal;
|
||||
const writes = [], proposals = [];
|
||||
await page.route('**/*', route => {
|
||||
const req = route.request(), url = new URL(req.url()), p = url.pathname;
|
||||
if (!['localhost', '127.0.0.1'].includes(url.hostname)) { report.blockedExternal.push(url.origin); return route.abort(); }
|
||||
if (!p.startsWith('/api/')) return route.continue();
|
||||
const answer = data => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
|
||||
const note = action => { writes.push(action); report.writes.push({ width, language, mode, action }); };
|
||||
if (req.method() === 'PUT' && p === '/api/notification-settings') { assert.deepEqual(req.postDataJSON(), { emailFollowUpRemindersEnabled: false }); notifications = req.postDataJSON(); note('notificationPreference'); return answer(notifications); }
|
||||
if (req.method() === 'PUT' && p === '/api/ai/settings') { const body = req.postDataJSON(); assert.equal(body.externalProcessingAllowed, false); privacy = { ...privacy, ...body }; note('aiPreference'); return answer(privacy); }
|
||||
if (req.method() === 'PUT' && p === '/api/cv/variants/3') {
|
||||
const body = req.postDataJSON(); assert.equal(body.expectedVersion, variant.version); assert.equal(body.settings.headline, 'Manual teacher headline');
|
||||
variant = { ...variant, settings: body.settings, name: body.name, version: variant.version + 1 }; note('manualCvSave'); return answer(variant);
|
||||
}
|
||||
if (req.method() === 'POST' && p === '/api/cv/preview') return answer({ themeId: 'modern', html: '<!doctype html><html lang="en"><head><title>Controlled preview</title></head><body><main><h1>Alex Sample</h1><p>Recorded teacher profile</p></main></body></html>' });
|
||||
if (req.method() === 'POST' && p === '/api/cv/ai/assist') {
|
||||
const body = req.postDataJSON(); assert.equal(privacy.enabled, true); assert.equal(body.expectedVersion, variant.version); assert.equal(body.action, 'document-translate'); assert.equal(body.variantId, 3); assert.equal(Object.hasOwn(body, 'text'), false);
|
||||
assert.equal(body.language, proposals.length ? 'nb-NO' : 'en');
|
||||
proposal = { version: variant.version, language: body.language, token: `synthetic-${proposals.length}` };
|
||||
proposals.push(body.language); report.proposals.push({ width, language, mode, outputLanguage: body.language });
|
||||
return answer({ original: JSON.stringify([{ id: 'headline', items: [variant.settings.headline] }]), result: JSON.stringify([{ id: 'headline', items: ['Reviewed teacher headline'] }]), proposalToken: proposal.token });
|
||||
}
|
||||
if (req.method() === 'POST' && p === '/api/cv/variants/3/ai/apply') {
|
||||
assert.deepEqual(req.postDataJSON(), { proposalToken: proposal.token }); assert.equal(proposal.version, variant.version);
|
||||
variant = { ...variant, version: variant.version + 1, settings: { ...variant.settings, headline: 'Reviewed teacher headline', language: proposal.language } }; note('explicitAiApply'); return answer(variant);
|
||||
}
|
||||
if (req.method() !== 'GET') { report.unexpectedWrites.push(p); return route.abort(); }
|
||||
const fixtures = {
|
||||
'/api/auth/config': { requireAuth: true }, '/api/auth/me': { id: 'a3-ai-owner', sessionContext: 'a3-ai-session', provider: 'local', firstName: 'Alex', displayName: 'Alex Sample', roles: [], plan: 'pro', entitlements: { ai: privacy.enabled, proThemes: true }, uiLanguage: language === 'nb' ? 'nb-NO' : 'en' },
|
||||
'/api/jobapplications/reminders': [], '/api/notifications/unread-count': { count: 0 }, '/api/notification-settings': notifications, '/api/ai/settings': privacy,
|
||||
'/api/ai/usage': { currentMonth: { calls: 2, estimatedTokens: 200 }, plan: 'pro', monthlyCallLimit: 100, monthlyTokenLimit: 10000, storageUsedBytes: 0, storageLimitBytes: 250000000 },
|
||||
'/api/billing/status': { enabled: false, canCheckout: false, canManage: false },
|
||||
'/api/rules': { id: 1, appliedFollowUpDays: 14, appliedGhostDays: 30, offerFollowUpDays: 7, offerGhostDays: 14, feedbackFollowUpDays: 7, feedbackGhostDays: 14 },
|
||||
'/api/cv/variants/3': variant, '/api/cv/variants/3/versions': [], '/api/cv/outline': { fullName: 'Alex Sample', headline: 'Recorded teacher headline', sections: [] },
|
||||
'/api/cv/themes': [{ id: 'modern', name: 'Modern', category: 'Professional', description: 'Synthetic template', layout: 'header-band', accent: '#3157d5', photoShape: 'none', supportsIcons: false, atsFriendly: true, requiresPro: false, available: true, swatches: ['#3157d5'] }],
|
||||
};
|
||||
if (Object.hasOwn(fixtures, p)) return answer(fixtures[p]);
|
||||
report.unavailableReads.push(p); return route.fulfill({ status: 404, contentType: 'application/json', body: '{}' });
|
||||
});
|
||||
const scan = async state => {
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, `overflow:${state}`);
|
||||
const result = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze();
|
||||
report.screens.push({ width, language, mode, state, violations: result.violations }); assert.deepEqual(result.violations, [], `${state} accessibility`);
|
||||
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
|
||||
await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0);
|
||||
await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: width >= 768 });
|
||||
};
|
||||
const settingsSection = async key => {
|
||||
if (width >= 600) await page.getByRole('tab', { name: t(key), exact: true }).click();
|
||||
else { await page.getByRole('combobox', { name: t('settingsNavigation'), exact: true }).click(); await page.getByRole('option', { name: t(key), exact: true }).click(); }
|
||||
};
|
||||
await page.goto('http://localhost:4338/settings');
|
||||
await settingsSection('settingsTabAutomation');
|
||||
const reminder = page.getByRole('switch', { name: t('settingsNotificationsFollowUpReminders'), exact: true }); await reminder.uncheck(); assert.equal(writes.length, 0);
|
||||
await page.getByRole('button', { name: t('settingsNotificationsSave'), exact: true }).click(); await expect.poll(() => notifications.emailFollowUpRemindersEnabled).toBe(false); await scan('automation');
|
||||
await page.getByRole('button', { name: t('settingsAutomationAi'), exact: true }).press('Enter'); await expect(page.locator('#settings-ai-controls')).toBeFocused();
|
||||
await page.getByRole('switch', { name: t('settingsAiEnabled'), exact: true }).uncheck(); await page.getByRole('button', { name: t('settingsAiSave'), exact: true }).click(); await expect.poll(() => privacy.enabled).toBe(false); await scan('ai-disabled');
|
||||
await page.goto('http://localhost:4338/career/builder/3');
|
||||
await page.getByRole('tab', { name: t('cvEditorAiTools'), exact: true }).click();
|
||||
await expect(page.getByText(t('aiUnavailableTitle'), { exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: t('cvDocumentAiReview'), exact: true }).first()).toBeDisabled();
|
||||
await page.getByRole('tab', { name: t('cvEditorContent'), exact: true }).click();
|
||||
await page.getByRole('textbox', { name: t('cvEditorHeadlineOverride'), exact: true }).fill('Manual teacher headline'); await expect.poll(() => variant.version, { timeout: 15000 }).toBe(5); assert.equal(proposals.length, 0); await scan('manual-fallback');
|
||||
await page.goto('http://localhost:4338/settings'); await page.getByRole('switch', { name: t('settingsAiEnabled'), exact: true }).check(); await page.getByRole('button', { name: t('settingsAiSave'), exact: true }).click(); await expect.poll(() => privacy.enabled).toBe(true);
|
||||
await page.goto('http://localhost:4338/career/builder/3'); await page.getByRole('tab', { name: t('cvEditorAiTools'), exact: true }).click();
|
||||
await page.getByRole('button', { name: t('cvDocumentAiReview'), exact: true }).first().click(); await expect(page.getByRole('button', { name: t('cvAiApply'), exact: true })).toBeVisible(); assert.equal(writes.length, 4); await scan('ai-review');
|
||||
await page.getByRole('combobox', { name: new RegExp(`^${t('cvAiLanguage')}`) }).click(); await page.getByRole('option', { name: t('languageNorwegianBokmal'), exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: t('cvAiApply'), exact: true })).toHaveCount(0); assert.equal(proposals.length, 1); assert.equal(variant.settings.headline, 'Manual teacher headline'); await scan('language-discard');
|
||||
await page.getByRole('button', { name: t('cvDocumentAiReview'), exact: true }).first().click(); await page.getByRole('button', { name: t('cvAiApply'), exact: true }).click(); await expect.poll(() => variant.version).toBe(6);
|
||||
await page.getByRole('tab', { name: t('cvEditorContent'), exact: true }).click(); await expect(page.getByRole('textbox', { name: t('cvEditorHeadlineOverride'), exact: true })).toHaveValue('Reviewed teacher headline'); await scan('explicit-apply');
|
||||
assert.deepEqual(writes, ['notificationPreference', 'aiPreference', 'manualCvSave', 'aiPreference', 'explicitAiApply']); assert.deepEqual(proposals, ['en', 'nb-NO']);
|
||||
report.journeys.push({ width, language, mode, manualFallback: 'PASS', languageDiscardWithoutApply: 'PASS', explicitTokenApply: 'PASS', aiSettingsKeyboardFocus: 'PASS' }); await context.close();
|
||||
}
|
||||
assert.equal(report.screens.length, configurations.length * 6); assert.equal(report.journeys.length, configurations.length); assert.equal(report.writes.length, configurations.length * 5); assert.equal(report.proposals.length, configurations.length * 2);
|
||||
assert.deepEqual(report.errors, []); assert.deepEqual(report.unexpectedWrites, []); assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com'));
|
||||
console.log(`PASS:${report.journeys.length} controlled journeys,${report.screens.length} axe scans/captures.`);
|
||||
} finally {
|
||||
fs.writeFileSync(path.join(out, process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify({ ...report, limits: ['All API, AI and preview results are synthetic fixtures. No model activation, semantic translation quality, backend end-to-end or physical PDF qualification.', 'No native device, screen reader, provider, integrated security, deployment or release acceptance.'] }, null, 2));
|
||||
await browser.close();
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from "react";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { Alert, Button, IconButton, Stack, Typography } from "@mui/material";
|
||||
import { useOptionalI18n } from "../i18n/I18nProvider";
|
||||
import { useAccountPlan } from "../accountPlan";
|
||||
|
||||
type Props = {
|
||||
featureKey: string;
|
||||
@@ -15,6 +16,9 @@ function storageKey(featureKey: string) {
|
||||
|
||||
export default function ProFeatureNotice({ featureKey, title, children }: Props) {
|
||||
const i18n = useOptionalI18n();
|
||||
const { plan, canUseAi } = useAccountPlan();
|
||||
const aiUnavailable = plan === "pro" && !canUseAi;
|
||||
const displayTitle = aiUnavailable ? i18n?.t("aiUnavailableTitle") ?? "AI assistance is unavailable" : title;
|
||||
const [dismissed, setDismissed] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
@@ -40,15 +44,15 @@ export default function ProFeatureNotice({ featureKey, title, children }: Props)
|
||||
severity="info"
|
||||
action={(
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
<Button href="/settings" size="small">{i18n?.t("proFeatureView") ?? "View Pro"}</Button>
|
||||
<IconButton size="small" aria-label={i18n?.t("proFeatureDismiss", { title }) ?? `Dismiss ${title}`} onClick={dismiss}>
|
||||
<Button href="/settings" size="small">{aiUnavailable ? i18n?.t("aiUnavailableSettings") ?? "Review AI settings" : i18n?.t("proFeatureView") ?? "View Pro"}</Button>
|
||||
<IconButton size="small" aria-label={i18n?.t("proFeatureDismiss", { title: displayTitle }) ?? `Dismiss ${displayTitle}`} onClick={dismiss}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
)}
|
||||
>
|
||||
<Typography component="span" sx={{ fontWeight: 800 }}>{title}</Typography>{" "}
|
||||
{children}
|
||||
<Typography component="span" sx={{ fontWeight: 800 }}>{displayTitle}</Typography>{" "}
|
||||
{aiUnavailable ? i18n?.t("aiUnavailableBody") ?? "You can continue editing manually. Review your AI settings and usage before trying again." : children}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Switch,
|
||||
Tab,
|
||||
Tabs,
|
||||
Typography,
|
||||
@@ -81,6 +82,7 @@ export default function SettingsView({
|
||||
const rulesSession = useRef({ mounted: true, loading: false, saving: false });
|
||||
const notificationSession = useRef({ mounted: true, loading: false, saving: false, generation: 0 });
|
||||
const notificationBody = useRef<HTMLDivElement>(null);
|
||||
const aiControls = useRef<HTMLDivElement>(null);
|
||||
const notificationRetryFocus = useRef<HTMLElement | null>(null);
|
||||
const currentTranslation = useRef(t);
|
||||
const rulesDirty = Boolean(rulesDraft && savedRules && RULE_FIELDS.some(field => rulesDraft[field] !== String(savedRules[field])));
|
||||
@@ -223,6 +225,36 @@ export default function SettingsView({
|
||||
};
|
||||
const notificationReadStatus = (notificationError?.cause as { response?: { status?: number } } | undefined)?.response?.status;
|
||||
|
||||
const notificationCard = (
|
||||
<PageSection surface title={t("settingsNotificationsTitle")} description={t("settingsNotificationsBody")}>
|
||||
<Box ref={notificationBody} role="group" aria-label={t("settingsNotificationsTitle")} tabIndex={-1} aria-busy={loadingNotifications || savingNotifications} sx={{ minWidth: 0 }}>
|
||||
{!notificationPrefs ? <ViewStateNotice compact loading={loadingNotifications} title={t("settingsNotificationsLoadFailed")} error={notificationError ? {
|
||||
kind: notificationReadStatus === 401 || notificationReadStatus === 403 ? "unauthorized" : "error",
|
||||
message: getApiErrorMessage(notificationError.cause, t("settingsNotificationsLoadFailed")),
|
||||
retryable: notificationReadStatus !== 401 && notificationReadStatus !== 403,
|
||||
} : null} onRetry={retryNotifications} /> : <Box sx={{ display: "grid", gap: 1 }}>
|
||||
{notificationError?.action ? <Alert severity="error" sx={{ mb: 1.5, overflowWrap: "anywhere" }}>{getApiErrorMessage(notificationError.cause, t("settingsNotificationsSaveFailed"))}</Alert> : null}
|
||||
{notificationsDirty ? <Typography role="status" variant="body2" color="text.secondary">{t("assetsUnsavedChanges")}. {t("settingsNotificationsDraftScope")}</Typography> : null}
|
||||
<FormControlLabel
|
||||
control={<Switch checked={notificationPrefs.emailFollowUpRemindersEnabled} onChange={(e) => setNotificationPrefs({ emailFollowUpRemindersEnabled: e.target.checked })} />}
|
||||
label={t("settingsNotificationsFollowUpReminders")}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">{t("settingsNotificationsEmailBody")}</Typography>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
|
||||
<Button disabled={!notificationsDirty || savingNotifications} onClick={discardNotifications}>{t("assetsDiscardChanges")}</Button>
|
||||
<Button variant="contained" disabled={savingNotifications} onClick={() => void saveNotifications()}>{savingNotifications ? t("settingsSaving") : t("settingsNotificationsSave")}</Button>
|
||||
</Box>
|
||||
</Box>}
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1.5 }}>
|
||||
{t("settingsNotificationsDelivery")}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1.5 }}>
|
||||
<Button variant="outlined" onClick={() => navigate("/reminders")}>{t("settingsOpenReminderInbox")}</Button>
|
||||
</Box>
|
||||
</PageSection>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<FormControl fullWidth sx={{ display: { xs: "flex", sm: "none" }, mb: 1 }}>
|
||||
@@ -234,6 +266,7 @@ export default function SettingsView({
|
||||
onChange={(event) => selectSection(Number(event.target.value))}
|
||||
>
|
||||
<MenuItem value={0}>{t("settingsTabGeneral")}</MenuItem>
|
||||
<MenuItem value={4}>{t("settingsTabAutomation")}</MenuItem>
|
||||
<MenuItem value={1}>{t("settingsTabFollowUps")}</MenuItem>
|
||||
<MenuItem value={2}>{t("settingsTabNotifications")}</MenuItem>
|
||||
<MenuItem value={3}>{t("settingsTabBackup")}</MenuItem>
|
||||
@@ -241,10 +274,11 @@ export default function SettingsView({
|
||||
</FormControl>
|
||||
|
||||
<Tabs aria-label={t("settings")} value={tab} onChange={(_, v) => selectSection(v)} variant="scrollable" scrollButtons="auto" sx={{ display: { xs: "none", sm: "flex" }, mb: 1 }}>
|
||||
<Tab id="settings-tab-0" aria-controls="settings-panel-0" label={t("settingsTabGeneral")} />
|
||||
<Tab id="settings-tab-1" aria-controls="settings-panel-1" label={t("settingsTabFollowUps")} />
|
||||
<Tab id="settings-tab-2" aria-controls="settings-panel-2" label={t("settingsTabNotifications")} />
|
||||
<Tab id="settings-tab-3" aria-controls="settings-panel-3" label={t("settingsTabBackup")} />
|
||||
<Tab value={0} id="settings-tab-0" aria-controls="settings-panel-0" label={t("settingsTabGeneral")} />
|
||||
<Tab value={4} id="settings-tab-4" aria-controls="settings-panel-4" label={t("settingsTabAutomation")} />
|
||||
<Tab value={1} id="settings-tab-1" aria-controls="settings-panel-1" label={t("settingsTabFollowUps")} />
|
||||
<Tab value={2} id="settings-tab-2" aria-controls="settings-panel-2" label={t("settingsTabNotifications")} />
|
||||
<Tab value={3} id="settings-tab-3" aria-controls="settings-panel-3" label={t("settingsTabBackup")} />
|
||||
</Tabs>
|
||||
|
||||
{rulesDirty ? <Typography role="status" variant="body2" sx={{ mt: 1, color: "text.secondary" }}>{t("assetsUnsavedChanges")}. {t("rulesDraftScope")}</Typography> : null}
|
||||
@@ -331,7 +365,7 @@ export default function SettingsView({
|
||||
</Box>
|
||||
</PageSection>
|
||||
|
||||
<AiPrivacySettingsCard />
|
||||
<Box ref={aiControls} id="settings-ai-controls" role="group" aria-label={t("settingsAiPrivacyTitle")} tabIndex={-1}><AiPrivacySettingsCard /></Box>
|
||||
<AiUsageCard />
|
||||
<QuickCaptureCard />
|
||||
<PageSection surface title={t("connectedAccounts")} description={t("settingsConnectedAccountsBody")}
|
||||
@@ -349,33 +383,22 @@ export default function SettingsView({
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tab} index={2} label={t("settingsTabNotifications")}>
|
||||
<PageSection surface title={t("settingsNotificationsTitle")} description={t("settingsNotificationsBody")}>
|
||||
<Box ref={notificationBody} role="group" aria-label={t("settingsNotificationsTitle")} tabIndex={-1} aria-busy={loadingNotifications || savingNotifications} sx={{ minWidth: 0 }}>
|
||||
{!notificationPrefs ? <ViewStateNotice compact loading={loadingNotifications} title={t("settingsNotificationsLoadFailed")} error={notificationError ? {
|
||||
kind: notificationReadStatus === 401 || notificationReadStatus === 403 ? "unauthorized" : "error",
|
||||
message: getApiErrorMessage(notificationError.cause, t("settingsNotificationsLoadFailed")),
|
||||
retryable: notificationReadStatus !== 401 && notificationReadStatus !== 403,
|
||||
} : null} onRetry={retryNotifications} /> : <Box sx={{ display: "grid", gap: 1 }}>
|
||||
{notificationError?.action ? <Alert severity="error" sx={{ mb: 1.5, overflowWrap: "anywhere" }}>{getApiErrorMessage(notificationError.cause, t("settingsNotificationsSaveFailed"))}</Alert> : null}
|
||||
{notificationsDirty ? <Typography role="status" variant="body2" color="text.secondary">{t("assetsUnsavedChanges")}. {t("settingsNotificationsDraftScope")}</Typography> : null}
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={notificationPrefs.emailFollowUpRemindersEnabled} onChange={(e) => setNotificationPrefs({ emailFollowUpRemindersEnabled: e.target.checked })} />}
|
||||
label={t("settingsNotificationsFollowUpReminders")}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">{t("settingsNotificationsEmailBody")}</Typography>
|
||||
{notificationCard}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tab} index={4} label={t("settingsTabAutomation")}>
|
||||
<Box sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0 }}>
|
||||
<PageSection surface title={t("settingsTabAutomation")} description={t("settingsAutomationBody")}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>{t("settingsAutomationEmailSchedule")}</Typography>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
|
||||
<Button disabled={!notificationsDirty || savingNotifications} onClick={discardNotifications}>{t("assetsDiscardChanges")}</Button>
|
||||
<Button variant="contained" disabled={savingNotifications} onClick={() => void saveNotifications()}>{savingNotifications ? t("settingsSaving") : t("settingsNotificationsSave")}</Button>
|
||||
<Button onClick={() => { selectSection(0); requestAnimationFrame(() => aiControls.current?.focus()); }}>{t("settingsAutomationAi")}</Button>
|
||||
<Button onClick={() => selectSection(1)}>{t("settingsTabFollowUps")}</Button>
|
||||
<Button onClick={() => navigate("/discover")}>{t("settingsAutomationSearches")}</Button>
|
||||
</Box>
|
||||
</Box>}
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1.5 }}>
|
||||
{t("settingsNotificationsDelivery")}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1.5 }}>
|
||||
<Button variant="outlined" onClick={() => navigate("/reminders")}>{t("settingsOpenReminderInbox")}</Button>
|
||||
</Box>
|
||||
</PageSection>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1.5 }}>{t("settingsAutomationRulesScope")}</Typography>
|
||||
</PageSection>
|
||||
{notificationCard}
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tab} index={3} label={t("settingsTabBackup")}>
|
||||
|
||||
@@ -1107,6 +1107,16 @@ async function reviewDocumentSuggestion(result: string) {
|
||||
expect(request).not.toHaveProperty('targetId');
|
||||
}
|
||||
|
||||
test('changing whole-document output language discards the previous-language review without applying it', async () => {
|
||||
await reviewDocumentSuggestion(JSON.stringify([{ id: 'headline', items: ['Reviewed headline'] }]));
|
||||
expect(await screen.findByText('Review 1 proposed change(s)')).toBeInTheDocument();
|
||||
fireEvent.mouseDown(screen.getByLabelText('Output language'));
|
||||
fireEvent.click(screen.getByRole('option', { name: 'Norsk bokmål' }));
|
||||
expect(screen.queryByText('Review 1 proposed change(s)')).not.toBeInTheDocument();
|
||||
expect(mockedApi.post.mock.calls.filter(([url]) => url === '/cv/variants/3/ai/apply')).toHaveLength(0);
|
||||
expect(mockedApi.post.mock.calls.filter(([url]) => url === '/cv/ai/assist')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test.each(['Apply', 'Reject'])('[PRES-C11 PRES-C07] whole-document AI %s changes only an explicitly accepted CV override', async action => {
|
||||
await reviewDocumentSuggestion(JSON.stringify([{ id: 'headline', items: ['Reviewed headline'] }]));
|
||||
expect(await screen.findByText('Review 1 proposed change(s)')).toBeInTheDocument();
|
||||
|
||||
@@ -2,6 +2,15 @@ export type UiLanguage = "en" | "nb";
|
||||
|
||||
export const translations = {
|
||||
en: {
|
||||
aiUnavailableTitle: "AI assistance is unavailable",
|
||||
aiUnavailableSettings: "Review AI settings",
|
||||
aiUnavailableBody: "You can continue editing manually. Review your AI settings and usage before trying again.",
|
||||
settingsTabAutomation: "Automation",
|
||||
settingsAutomationBody: "Control email follow-up reminders here. Use the linked settings for AI assistance, waiting periods and individual saved-search alerts.",
|
||||
settingsAutomationEmailSchedule: "When reminder delivery is available, due or upcoming applications are checked about every six hours. Emails go only to your confirmed account address, at most once per application per day.",
|
||||
settingsAutomationAi: "AI privacy and usage",
|
||||
settingsAutomationSearches: "Saved-search alerts",
|
||||
settingsAutomationRulesScope: "Day thresholds influence reminders and optional automatic Ghosted status updates. They do not disable status processing. Saved-search alerts are controlled individually in Discover; AI privacy and usage remain in General.",
|
||||
documentsFilters: "Document filters",
|
||||
documentsJumpResults: "Jump to documents",
|
||||
documentsReturnFilters: "Return to document filters",
|
||||
@@ -3197,6 +3206,15 @@ export const translations = {
|
||||
coverAiReject: "Reject",
|
||||
},
|
||||
nb: {
|
||||
aiUnavailableTitle: "KI-hjelp er utilgjengelig",
|
||||
aiUnavailableSettings: "Se KI-innstillinger",
|
||||
aiUnavailableBody: "Du kan fortsette å redigere manuelt. Se gjennom KI-innstillingene og forbruket ditt før du prøver igjen.",
|
||||
settingsTabAutomation: "Automatisering",
|
||||
settingsAutomationBody: "Styr e-postpåminnelser om oppfølging her. Bruk de tilknyttede innstillingene for AI-hjelp, ventetider og varsler for hvert lagret søk.",
|
||||
settingsAutomationEmailSchedule: "Når påminnelseslevering er tilgjengelig, kontrolleres forfalte eller kommende søknader omtrent hver sjette time. E-post sendes bare til den bekreftede kontoadressen din, høyst én gang per søknad per dag.",
|
||||
settingsAutomationAi: "AI-personvern og forbruk",
|
||||
settingsAutomationSearches: "Varsler for lagrede søk",
|
||||
settingsAutomationRulesScope: "Daggrenser påvirker påminnelser og valgfrie automatiske statusoppdateringer til Ghostet. De deaktiverer ikke statusbehandlingen. Varsler for lagrede søk styres enkeltvis i Finn jobber; AI-personvern og forbruk ligger under Generelt.",
|
||||
documentsFilters: "Dokumentfiltre",
|
||||
documentsJumpResults: "Gå til dokumenter",
|
||||
documentsReturnFilters: "Tilbake til dokumentfiltre",
|
||||
|
||||
@@ -3,9 +3,31 @@ import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
import ProFeatureNotice from "./components/ProFeatureNotice";
|
||||
import { AccountPlanProvider } from "./accountPlan";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
|
||||
beforeEach(() => window.sessionStorage.clear());
|
||||
|
||||
test.each([
|
||||
["en", "AI assistance is unavailable", "Review AI settings"],
|
||||
["nb", "KI-hjelp er utilgjengelig", "Se KI-innstillinger"],
|
||||
])("keeps paid users on the manual path without an upgrade prompt (%s)", (language, title, action) => {
|
||||
window.localStorage.setItem("uiLanguage", language);
|
||||
render(
|
||||
<I18nProvider>
|
||||
<AccountPlanProvider value={{ plan: "pro", canUseAi: false, canUseProThemes: true }}>
|
||||
<ProFeatureNotice featureKey="cv-writing-ai" title="Upgrade prompt">Upgrade body</ProFeatureNotice>
|
||||
</AccountPlanProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(screen.getByText(title)).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: action })).toHaveAttribute("href", "/settings");
|
||||
expect(screen.queryByText("Upgrade body")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: new RegExp(title) }));
|
||||
expect(screen.queryByText(title)).not.toBeInTheDocument();
|
||||
window.localStorage.removeItem("uiLanguage");
|
||||
});
|
||||
|
||||
test("shows a respectful upgrade action and remembers dismissal for the session", () => {
|
||||
const { unmount } = render(
|
||||
<ProFeatureNotice featureKey="test-feature" title="Draft faster with Pro.">
|
||||
|
||||
@@ -319,12 +319,31 @@ test('[PRES-S03] Rules draft updates do not refetch an unrelated notification pr
|
||||
});
|
||||
|
||||
function preferenceCopy(key: TranslationKey) { return translations[document.documentElement.lang === 'nb-NO' ? 'nb' : 'en'][key]; }
|
||||
test.each(['en', 'nb'] as const)('%s Automation reuses the saved notification preference and explicit save', async language => {
|
||||
localStorage.setItem('uiLanguage', language); renderView();
|
||||
fireEvent.click(screen.getByRole('tab', { name: preferenceCopy('settingsTabAutomation') }));
|
||||
const panel = within(screen.getByRole('tabpanel', { name: preferenceCopy('settingsTabAutomation') }));
|
||||
const toggle = await panel.findByRole('switch', { name: preferenceCopy('settingsNotificationsFollowUpReminders') });
|
||||
expect(toggle).toBeChecked(); fireEvent.click(toggle); expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('tab', { name: preferenceCopy('settingsTabNotifications') }));
|
||||
expect(await notificationPanel().findByRole('switch', { name: preferenceCopy('settingsNotificationsFollowUpReminders') })).not.toBeChecked();
|
||||
fireEvent.click(notificationPanel().getByRole('button', { name: preferenceCopy('settingsNotificationsSave') }));
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith('/notification-settings', { emailFollowUpRemindersEnabled: false }));
|
||||
});
|
||||
|
||||
test('Automation AI entry moves focus into existing settings without an AI request', async () => {
|
||||
renderView(); fireEvent.click(screen.getByRole('tab', { name: preferenceCopy('settingsTabAutomation') }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: preferenceCopy('settingsAutomationAi') }));
|
||||
await waitFor(() => expect(document.getElementById('settings-ai-controls')).toHaveFocus());
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function notificationPanel() { return within(screen.getByRole('tabpanel', { name: preferenceCopy('settingsTabNotifications') })); }
|
||||
function notificationReads() { return mockedApi.get.mock.calls.filter(([url]) => url === '/notification-settings'); }
|
||||
function notificationWrites() { return mockedApi.put.mock.calls.filter(([url]) => url === '/notification-settings'); }
|
||||
async function openNotifications() {
|
||||
fireEvent.click(screen.getByRole('tab', { name: preferenceCopy('settingsTabNotifications') }));
|
||||
return notificationPanel().findByRole('checkbox', { name: preferenceCopy('settingsNotificationsFollowUpReminders') });
|
||||
return notificationPanel().findByRole('switch', { name: preferenceCopy('settingsNotificationsFollowUpReminders') });
|
||||
}
|
||||
async function preferenceLanguage(language: 'en' | 'nb') {
|
||||
await act(async () => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: language })); });
|
||||
@@ -345,7 +364,7 @@ test('[P7-09 preferences] initial notification failure offers a single GET Retry
|
||||
act(() => { retry.focus(); fireEvent.click(retry); fireEvent.click(retry); });
|
||||
expect(notificationReads()).toHaveLength(2); expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
await act(async () => pending.resolve({ data: { emailFollowUpRemindersEnabled: false } }));
|
||||
expect(await notificationPanel().findByRole('checkbox', { name: translations.en.settingsNotificationsFollowUpReminders })).not.toBeChecked();
|
||||
expect(await notificationPanel().findByRole('switch', { name: translations.en.settingsNotificationsFollowUpReminders })).not.toBeChecked();
|
||||
expect(notificationPanel().queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument();
|
||||
expect(notificationPanel().getByRole('group', { name: translations.en.settingsNotificationsTitle })).toHaveFocus();
|
||||
expect(notificationReads()).toHaveLength(2); expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
@@ -359,7 +378,7 @@ test('[P7-09 preferences] notification drafts survive internal sections and loca
|
||||
const retained = await openNotifications();
|
||||
expect(retained).not.toBeChecked(); expect(notificationReads()).toHaveLength(reads);
|
||||
expect(notificationPanel().getByRole('status')).toHaveTextContent(translations.nb.settingsNotificationsDraftScope);
|
||||
await preferenceLanguage('en'); expect(notificationPanel().getByRole('checkbox', { name: translations.en.settingsNotificationsFollowUpReminders })).not.toBeChecked();
|
||||
await preferenceLanguage('en'); expect(notificationPanel().getByRole('switch', { name: translations.en.settingsNotificationsFollowUpReminders })).not.toBeChecked();
|
||||
expect(notificationReads()).toHaveLength(reads); expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -375,7 +394,7 @@ test('[P7-09 preferences] one held notification save acknowledges its snapshot a
|
||||
expect(await openNotifications()).toBeChecked();
|
||||
expect(notificationPanel().getByRole('status')).toHaveTextContent(translations.en.assetsUnsavedChanges);
|
||||
fireEvent.click(notificationPanel().getByRole('button', { name: translations.en.assetsDiscardChanges }));
|
||||
expect(notificationPanel().getByRole('checkbox', { name: translations.en.settingsNotificationsFollowUpReminders })).not.toBeChecked();
|
||||
expect(notificationPanel().getByRole('switch', { name: translations.en.settingsNotificationsFollowUpReminders })).not.toBeChecked();
|
||||
expect(notificationPanel().queryByText(translations.en.settingsNotificationsDraftScope)).not.toBeInTheDocument();
|
||||
expect(notificationReads()).toHaveLength(1); expect(notificationWrites()).toHaveLength(1);
|
||||
});
|
||||
@@ -387,7 +406,7 @@ test('[P7-09 preferences] failed notification save keeps its current-locale draf
|
||||
expect(await notificationPanel().findByRole('alert')).toHaveTextContent(translations.en.settingsNotificationsSaveFailed);
|
||||
const reads = notificationReads().length; await preferenceLanguage('nb');
|
||||
expect(notificationPanel().getByRole('alert')).toHaveTextContent(translations.nb.settingsNotificationsSaveFailed);
|
||||
expect(notificationPanel().getByRole('checkbox', { name: translations.nb.settingsNotificationsFollowUpReminders })).not.toBeChecked();
|
||||
expect(notificationPanel().getByRole('switch', { name: translations.nb.settingsNotificationsFollowUpReminders })).not.toBeChecked();
|
||||
expect(notificationPanel().queryByRole('button', { name: translations.nb.retry })).not.toBeInTheDocument();
|
||||
fireEvent.click(notificationPanel().getByRole('button', { name: translations.nb.settingsNotificationsSave }));
|
||||
await waitFor(() => expect(notificationPanel().queryByRole('alert')).not.toBeInTheDocument());
|
||||
@@ -403,7 +422,7 @@ test('[P7-09 preferences] discarding a notification draft leaves the independent
|
||||
renderView(); fireEvent.change(await openRules(), { target: { value: '5' } });
|
||||
fireEvent.click(await openNotifications());
|
||||
fireEvent.click(notificationPanel().getByRole('button', { name: translations.en.assetsDiscardChanges }));
|
||||
expect(notificationPanel().getByRole('checkbox', { name: translations.en.settingsNotificationsFollowUpReminders })).toBeChecked();
|
||||
expect(notificationPanel().getByRole('switch', { name: translations.en.settingsNotificationsFollowUpReminders })).toBeChecked();
|
||||
expect(notificationPanel().queryByText(translations.en.settingsNotificationsDraftScope)).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Follow-ups' }));
|
||||
expect(screen.getByLabelText('Applied: follow-up days')).toHaveValue(5);
|
||||
|
||||
@@ -892,7 +892,7 @@ function DocumentAiTab({ settings, outline, onGenerate, onAccept }: CvAiCallback
|
||||
setProposal(null);
|
||||
setBusy(null);
|
||||
return () => { requestId.current += 1; };
|
||||
}, [sourceKey, settings.language, canUseAi]);
|
||||
}, [sourceKey, settings.language, targetLanguage, canUseAi]);
|
||||
|
||||
const discard = () => {
|
||||
requestId.current += 1;
|
||||
|
||||
Reference in New Issue
Block a user