feat(ui): add A3 discovery filter rail and results layout

This commit is contained in:
cesnimda
2026-11-03 10:22:19 +01:00
parent 172074e90f
commit c9c6b1abc1
61 changed files with 1434 additions and 5 deletions
+6
View File
@@ -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",
+20
View File
@@ -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();
+11 -5
View File
@@ -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>
);
}