feat(ui): add A3 discovery filter rail and results layout
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
// Built React with synthetic NAV-shaped fixtures; no provider or backend access.
|
||||
import { chromium, expect } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evidence/discovery/browser');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const report = { screens: [], journeys: [], errors: [], unexpectedWrites: [], mockedWrites: [], blockedExternal: [] };
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const width of [320, 390, 768, 1440]) for (const language of ['en', 'nb']) for (const mode of ['light', 'dark']) {
|
||||
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 });
|
||||
const jobs = ['Community nurse', 'Primary teacher', 'Electrical engineer'].map((title, index) => ({ id: `vacancy-${index}`, title, company: 'Synthetic employer', location: 'Oslo', modifiedAt: '2026-11-02T10:00:00Z', deadline: '2026-11-12T12:00:00Z', url: `https://example.test/vacancy-${index}`, source: 'nav', sourceName: 'NAV Arbeidsplassen', acquisitionType: 'searched', retrievedAt: '2026-11-03T10:00:00Z', countryCode: 'NO' }));
|
||||
let saved = null, searches = 0;
|
||||
await page.route('**/*', route => {
|
||||
const req = route.request(), url = new URL(req.url());
|
||||
if (!['localhost', '127.0.0.1'].includes(url.hostname)) { report.blockedExternal.push(url.origin); return route.abort(); }
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
const answer = data => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
|
||||
const note = action => report.mockedWrites.push({ width, language, mode, action });
|
||||
if (req.method() === 'POST' && url.pathname === '/api/job-discovery/saved-searches') {
|
||||
const data = req.postDataJSON(); assert.deepEqual(data, { name: 'Care roles', query: 'nurse', location: 'Oslo' });
|
||||
saved = { ...data, id: 7, isActive: true, resultCount: 0, dismissedCount: 0 }; note('create'); return answer(saved);
|
||||
}
|
||||
if (req.method() === 'PATCH' && url.pathname === '/api/job-discovery/saved-searches/7') {
|
||||
assert.deepEqual(req.postDataJSON(), { isActive: false }); saved.isActive = false; note('pauseAlerts'); return answer(saved);
|
||||
}
|
||||
if (req.method() === 'POST' && url.pathname === '/api/job-discovery/saved-searches/7/run') {
|
||||
saved.resultCount = 1; note('manualRun'); return answer({ search: saved, jobs: [{ job: jobs[0], isNew: true, isDismissed: false }] });
|
||||
}
|
||||
if (req.method() === 'PATCH' && url.pathname === '/api/job-discovery/saved-searches/7/results/vacancy-0/dismiss') {
|
||||
assert.deepEqual(req.postDataJSON(), { isDismissed: true }); note('dismiss'); return answer({});
|
||||
}
|
||||
if (req.method() === 'DELETE' && url.pathname === '/api/job-discovery/saved-searches/7') { saved = null; note('confirmedDelete'); return answer({}); }
|
||||
if (req.method() !== 'GET') { report.unexpectedWrites.push(url.pathname); return route.abort(); }
|
||||
if (url.pathname === '/api/job-discovery/saved-searches') return answer(saved ? [saved] : []);
|
||||
if (url.pathname === '/api/job-discovery/search') { searches++; return answer(jobs.filter(job => !url.searchParams.get('q') || job.title.toLowerCase().includes(url.searchParams.get('q').toLowerCase()))); }
|
||||
const fixtures = {
|
||||
'/api/auth/config': { requireAuth: true },
|
||||
'/api/auth/me': { id: 'a3-discovery-owner', sessionContext: 'a3-discovery-session', firstName: 'Alex', displayName: 'Alex Sample', roles: [], uiLanguage: language === 'nb' ? 'nb-NO' : 'en' },
|
||||
'/api/jobapplications/reminders': [], '/api/notifications/unread-count': { count: 0 },
|
||||
};
|
||||
return route.fulfill({ status: Object.hasOwn(fixtures, url.pathname) ? 200 : 404, contentType: 'application/json', body: JSON.stringify(fixtures[url.pathname] ?? {}) });
|
||||
});
|
||||
const scan = async state => {
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false);
|
||||
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, []);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: true });
|
||||
};
|
||||
await page.goto('http://localhost:4338/discover');
|
||||
const filters = page.getByRole('complementary', { name: language === 'en' ? 'Search filters' : 'Søkefiltre' }); await filters.waitFor();
|
||||
const results = page.getByRole('region', { name: language === 'en' ? 'Search results' : 'Søkeresultater', exact: true }); await results.waitFor();
|
||||
const role = page.getByRole('textbox', { name: language === 'en' ? 'Role or company' : 'Rolle eller bedrift', exact: true });
|
||||
await filters.getByRole('button', { name: language === 'en' ? 'Jump to results' : 'Gå til resultater', exact: true }).click();
|
||||
assert.equal(await results.evaluate(node => node === document.activeElement), true);
|
||||
await results.getByRole('button', { name: language === 'en' ? 'Return to search filters' : 'Tilbake til søkefiltre', exact: true }).click();
|
||||
assert.equal(await role.evaluate(node => node === document.activeElement), true); assert.equal(searches, 0);
|
||||
const left = await filters.boundingBox(), right = await results.boundingBox();
|
||||
assert.ok(width > 1000 ? right.x > left.x + left.width - 2 && Math.abs(right.y - left.y) < 2 : right.y >= left.y + left.height - 2);
|
||||
await scan('initial');
|
||||
const search = page.getByRole('button', { name: language === 'en' ? 'Search NAV' : 'Søk i NAV', exact: true });
|
||||
await search.click(); await results.getByRole('article', { name: 'Electrical engineer', exact: true }).waitFor();
|
||||
assert.equal(await results.getByRole('article').count(), 3); await scan('results');
|
||||
await role.fill('nurse'); await page.getByRole('textbox', { name: language === 'en' ? 'Municipality' : 'Kommune', exact: true }).fill('Oslo');
|
||||
await page.getByRole('textbox', { name: language === 'en' ? 'Search name' : 'Navn på søket', exact: true }).fill('Care roles');
|
||||
await page.getByRole('button', { name: language === 'en' ? 'Save search' : 'Lagre søk', exact: true }).click();
|
||||
const group = page.getByRole('group', { name: 'Care roles', exact: true }); await group.waitFor();
|
||||
const alerts = group.getByRole('switch', { name: language === 'en' ? 'Automatic alerts' : 'Automatiske varsler', exact: true });
|
||||
await alerts.click(); await expect(alerts).not.toBeChecked();
|
||||
await group.getByRole('button', { name: language === 'en' ? 'Check for new jobs' : 'Se etter nye stillinger', exact: true }).click();
|
||||
await results.getByRole('article', { name: 'Community nurse', exact: true }).waitFor();
|
||||
assert.equal(await results.getByRole('article').count(), 1); await scan('saved');
|
||||
assert.equal(await results.getByRole('link', { name: language === 'en' ? 'View listing' : 'Vis stilling', exact: true }).getAttribute('href'), jobs[0].url);
|
||||
await results.getByRole('button', { name: language === 'en' ? 'Dismiss' : 'Avvis', exact: true }).click();
|
||||
await results.getByRole('article').waitFor({ state: 'hidden' });
|
||||
await group.getByRole('button', { name: language === 'en' ? 'Delete' : 'Slett', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: language === 'en' ? 'Delete saved search?' : 'Slette lagret søk?', exact: true });
|
||||
await dialog.getByRole('button', { name: language === 'en' ? 'Delete' : 'Slett', exact: true }).click(); await group.waitFor({ state: 'hidden' });
|
||||
report.journeys.push({ width, language, mode, searchSavePauseRunDismissConfirmedDelete: 'PASS' });
|
||||
await context.close();
|
||||
}
|
||||
assert.equal(report.screens.length, 48); assert.equal(report.journeys.length, 16); assert.equal(report.mockedWrites.length, 80);
|
||||
assert.deepEqual(report.errors, []); assert.deepEqual(report.unexpectedWrites, []);
|
||||
assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com'));
|
||||
console.log('PASS:16 controlled discovery journeys,48 axe scans/captures,80 intercepted fixture mutations.');
|
||||
} finally {
|
||||
fs.writeFileSync(path.join(out, 'BROWSER.json'), JSON.stringify({ ...report, limits: ['synthetic provider/API fixtures, not live NAV or backend qualification', 'no new providers/geography or native/security/release acceptance'] }, null, 2));
|
||||
await browser.close();
|
||||
}
|
||||
@@ -593,6 +593,9 @@ export const translations = {
|
||||
landingReadyBody: "Sign in to start tracking applications, tailoring CVs, and following up with intent.",
|
||||
landingFooter: "© {year} Jobbjakt — a focused workspace for the modern job search.",
|
||||
jobDiscoveryRoleCompany: "Role or company",
|
||||
jobDiscoveryFilters: "Search filters",
|
||||
jobDiscoveryJumpResults: "Jump to results",
|
||||
jobDiscoveryReturnFilters: "Return to search filters",
|
||||
jobDiscoveryMunicipality: "Municipality",
|
||||
jobDiscoverySearchNav: "Search NAV",
|
||||
jobDiscoveryResults: "Search results",
|
||||
@@ -3748,6 +3751,9 @@ export const translations = {
|
||||
landingReadyBody: "Logg inn for å følge søknader, tilpasse CV-er og jobbe målrettet med oppfølging.",
|
||||
landingFooter: "© {year} Jobbjakt — et fokusert arbeidsområde for moderne jobbsøk.",
|
||||
jobDiscoveryRoleCompany: "Rolle eller bedrift",
|
||||
jobDiscoveryFilters: "Søkefiltre",
|
||||
jobDiscoveryJumpResults: "Gå til resultater",
|
||||
jobDiscoveryReturnFilters: "Tilbake til søkefiltre",
|
||||
jobDiscoveryMunicipality: "Kommune",
|
||||
jobDiscoverySearchNav: "Søk i NAV",
|
||||
jobDiscoveryResults: "Søkeresultater",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ConfirmProvider } from "./confirm";
|
||||
import { PromptProvider } from "./prompt";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import JobDiscoveryPage from "./views/JobDiscoveryPage";
|
||||
import { translations } from './i18n/translations';
|
||||
|
||||
jest.mock("./api", () => ({ api: { get: jest.fn(), post: jest.fn(), patch: jest.fn(), delete: jest.fn() } }));
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
@@ -22,6 +23,25 @@ function searchResponse(data: any[]) {
|
||||
beforeEach(() => { jest.resetAllMocks(); window.localStorage.clear(); searchResponse([]); });
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
test.each([
|
||||
['en', 'Search filters', 'Jump to results', 'Return to search filters'],
|
||||
['nb', 'Søkefiltre', 'Gå til resultater', 'Tilbake til søkefiltre'],
|
||||
] as const)('keeps %s filters before results with keyboard jump/return and no automatic provider search', async (language, filtersName, jumpName, returnName) => {
|
||||
localStorage.setItem('uiLanguage', language);
|
||||
renderPage();
|
||||
await screen.findByText(translations[language].jobDiscoveryNoSavedSearches);
|
||||
const filters = screen.getByRole('complementary', { name: filtersName });
|
||||
const results = screen.getByRole('region', { name: translations[language].jobDiscoveryResults });
|
||||
expect(filters.compareDocumentPosition(results) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(within(filters).getByRole('form', { name: filtersName })).toBeInTheDocument();
|
||||
fireEvent.click(within(filters).getByRole('button', { name: jumpName }));
|
||||
expect(results).toHaveFocus();
|
||||
fireEvent.click(within(results).getByRole('button', { name: returnName }));
|
||||
expect(screen.getByRole('textbox', { name: translations[language].jobDiscoveryRoleCompany })).toHaveFocus();
|
||||
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(['/job-discovery/saved-searches']);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows honest source metadata and offers the existing reviewed save flow", async () => {
|
||||
searchResponse([{ id: "abc", title: "Backend Developer", company: "Acme", location: "OSLO", modifiedAt: "2026-08-01T10:00:00Z", deadline: "2026-08-15T23:59:59Z", url: "https://arbeidsplassen.nav.no/stillinger/stilling/abc", source: "nav", sourceName: "NAV Arbeidsplassen", acquisitionType: "searched", retrievedAt: "2026-08-10T10:00:00Z", countryCode: "NO" }]);
|
||||
renderPage();
|
||||
|
||||
@@ -56,6 +56,7 @@ export default function JobDiscoveryPage() {
|
||||
const [mutating, setMutating] = useState(false);
|
||||
const pending = useRef(false);
|
||||
const resultsRef = useRef<HTMLDivElement>(null);
|
||||
const queryRef = useRef<HTMLInputElement>(null);
|
||||
const removedControlRef = useRef<Element | null>(null);
|
||||
const busy = loading || savingSearch || updatingSavedSearchId !== null || mutating;
|
||||
|
||||
@@ -182,8 +183,12 @@ export default function JobDiscoveryPage() {
|
||||
|
||||
return (
|
||||
<Stack spacing={3} sx={{ minWidth: 0 }}>
|
||||
<Box component="form" onSubmit={search} sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr auto" }, gap: 1.5 }}>
|
||||
<TextField label={t("jobDiscoveryRoleCompany")} value={query} disabled={busy} onChange={(event) => setQuery(event.target.value)} />
|
||||
<Alert severity="info" variant="outlined" sx={{ color: "text.primary", "& .MuiAlert-icon": { color: "info.main" } }}>{t("jobDiscoverySourceHelp")}</Alert>
|
||||
<Box data-discovery-layout sx={{ display: "grid", gap: 3, minWidth: 0, alignItems: "start", "@media (min-width:1001px)": { gridTemplateColumns: "minmax(16rem, 20rem) minmax(0, 1fr)" } }}>
|
||||
<Box component="aside" aria-label={t("jobDiscoveryFilters")} sx={{ display: "grid", gap: 2, minWidth: 0 }}>
|
||||
<PageHeader level="h2" title={t("jobDiscoveryFilters")} actions={<Button onClick={() => resultsRef.current?.focus()}>{t("jobDiscoveryJumpResults")}</Button>} />
|
||||
<Box component="form" aria-label={t("jobDiscoveryFilters")} onSubmit={search} sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", gap: 1.5 }}>
|
||||
<TextField inputRef={queryRef} label={t("jobDiscoveryRoleCompany")} value={query} disabled={busy} onChange={(event) => setQuery(event.target.value)} />
|
||||
<TextField label={t("jobDiscoveryMunicipality")} value={location} disabled={busy} onChange={(event) => setLocation(event.target.value)} />
|
||||
<Button type="submit" variant="contained" startIcon={loading ? <CircularProgress aria-hidden="true" size={18} color="inherit" /> : <SearchIcon />} disabled={busy}>{t("jobDiscoverySearchNav")}</Button>
|
||||
</Box>
|
||||
@@ -223,9 +228,9 @@ export default function JobDiscoveryPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Alert severity="info" variant="outlined" sx={{ color: "text.primary", "& .MuiAlert-icon": { color: "info.main" } }}>{t("jobDiscoverySourceHelp")}</Alert>
|
||||
</Box>
|
||||
<Box role="region" aria-label={t("jobDiscoveryResults")} tabIndex={-1} ref={resultsRef} aria-busy={loading} sx={{ display: "grid", gap: 2, minWidth: 0 }}>
|
||||
{hasSearched ? <PageHeader level="h2" title={t("jobDiscoveryResults")} /> : null}
|
||||
<PageHeader level="h2" title={t("jobDiscoveryResults")} actions={<Button disabled={busy} onClick={() => queryRef.current?.focus()}>{t("jobDiscoveryReturnFilters")}</Button>} />
|
||||
<ViewStateNotice compact loading={loading} title={t(error ?? "jobDiscoveryUnavailable")}
|
||||
error={error ? { kind: "unavailable", message: t(error), retryable: error === "jobDiscoveryUnavailable" } : null}
|
||||
onRetry={() => {
|
||||
@@ -245,7 +250,7 @@ export default function JobDiscoveryPage() {
|
||||
</TextField>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "repeat(2, minmax(0, 1fr))" }, gap: 2 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", gap: 2 }}>
|
||||
{sortedJobs.map((job) => (
|
||||
<Card key={job.id} component="article" aria-label={job.title} variant="outlined" sx={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||
<CardContent sx={{ p: "var(--app-card-padding)" }}>
|
||||
@@ -274,6 +279,7 @@ export default function JobDiscoveryPage() {
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user