feat(discovery): lead with the existing search controls

This commit is contained in:
cesnimda
2026-11-09 22:57:07 +01:00
parent bd34a3504f
commit 0540e84ff6
3 changed files with 28 additions and 17 deletions
+8 -6
View File
@@ -564,7 +564,7 @@ export const translations = {
languageEnglish: "English", languageEnglish: "English",
languageNorwegianBokmal: "Norsk bokmål", languageNorwegianBokmal: "Norsk bokmål",
discoverJobs: "Discover jobs", discoverJobs: "Discover jobs",
discoverJobsSubtitle: "Search official job-board feeds and save opportunities to your tracker.", discoverJobsSubtitle: "Search vacancies and save opportunities to your tracker.",
landingSignIn: "Sign in", landingSignIn: "Sign in",
landingSignInArrow: "Sign in →", landingSignInArrow: "Sign in →",
landingEyebrow: "AI-ASSISTED JOB SEARCH WORKSPACE", landingEyebrow: "AI-ASSISTED JOB SEARCH WORKSPACE",
@@ -636,11 +636,12 @@ export const translations = {
jobDiscoveryRoleCompany: "Role or company", jobDiscoveryRoleCompany: "Role or company",
jobDiscoveryFilters: "Search filters", jobDiscoveryFilters: "Search filters",
jobDiscoveryJumpResults: "Jump to results", jobDiscoveryJumpResults: "Jump to results",
jobDiscoveryReturnFilters: "Return to search filters", jobDiscoveryReturnFilters: "Return to search",
jobDiscoveryMunicipality: "Municipality", jobDiscoveryMunicipality: "Municipality",
jobDiscoverySearchNav: "Search NAV", jobDiscoverySearchNav: "Search NAV",
jobDiscoveryResults: "Search results", jobDiscoveryResults: "Search results",
jobDiscoverySourceHelp: "Official Norwegian vacancies from NAV. FINN, Indeed and LinkedIn jobs can still be captured by URL.", jobDiscoverySourceCoverage: "Sources and coverage",
jobDiscoverySourceHelp: "Search uses NAV for Norwegian vacancies. To track a job from another site, paste its URL and enter any details Jobjakt cannot retrieve.",
jobDiscoveryUnavailable: "NAV job discovery is temporarily unavailable.", jobDiscoveryUnavailable: "NAV job discovery is temporarily unavailable.",
jobDiscoveryGuidance: "Search recent active vacancies by role, company, or municipality.", jobDiscoveryGuidance: "Search recent active vacancies by role, company, or municipality.",
jobDiscoveryNoResults: "No active vacancies matched this search. Try a broader role, company, or municipality.", jobDiscoveryNoResults: "No active vacancies matched this search. Try a broader role, company, or municipality.",
@@ -3787,7 +3788,7 @@ export const translations = {
languageEnglish: "Engelsk", languageEnglish: "Engelsk",
languageNorwegianBokmal: "Norsk bokmål", languageNorwegianBokmal: "Norsk bokmål",
discoverJobs: "Finn jobber", discoverJobs: "Finn jobber",
discoverJobsSubtitle: "Søk i offisielle jobbportaler og lagre muligheter i oversikten din.", discoverJobsSubtitle: "Søk etter stillinger og lagre dem i jobboversikten din.",
landingSignIn: "Logg inn", landingSignIn: "Logg inn",
landingSignInArrow: "Logg inn →", landingSignInArrow: "Logg inn →",
landingEyebrow: "AI-STØTTET ARBEIDSOMRÅDE FOR JOBBSØK", landingEyebrow: "AI-STØTTET ARBEIDSOMRÅDE FOR JOBBSØK",
@@ -3859,11 +3860,12 @@ export const translations = {
jobDiscoveryRoleCompany: "Rolle eller bedrift", jobDiscoveryRoleCompany: "Rolle eller bedrift",
jobDiscoveryFilters: "Søkefiltre", jobDiscoveryFilters: "Søkefiltre",
jobDiscoveryJumpResults: "Gå til resultater", jobDiscoveryJumpResults: "Gå til resultater",
jobDiscoveryReturnFilters: "Tilbake til søkefiltre", jobDiscoveryReturnFilters: "Tilbake til søk",
jobDiscoveryMunicipality: "Kommune", jobDiscoveryMunicipality: "Kommune",
jobDiscoverySearchNav: "Søk i NAV", jobDiscoverySearchNav: "Søk i NAV",
jobDiscoveryResults: "Søkeresultater", jobDiscoveryResults: "Søkeresultater",
jobDiscoverySourceHelp: "Offisielle norske stillinger fra NAV. Stillinger fra FINN, Indeed og LinkedIn kan fortsatt hentes inn via nettadresse.", jobDiscoverySourceCoverage: "Kilder og dekning",
jobDiscoverySourceHelp: "Søket bruker NAV for norske stillinger. For å følge en stilling fra et annet nettsted kan du lime inn nettadressen og fylle ut opplysningene Jobjakt ikke kan hente.",
jobDiscoveryUnavailable: "NAVs jobbsøk er midlertidig utilgjengelig.", jobDiscoveryUnavailable: "NAVs jobbsøk er midlertidig utilgjengelig.",
jobDiscoveryGuidance: "Søk i nylige aktive stillinger etter rolle, bedrift eller kommune.", jobDiscoveryGuidance: "Søk i nylige aktive stillinger etter rolle, bedrift eller kommune.",
jobDiscoveryNoResults: "Ingen aktive stillinger samsvarte med søket. Prøv en bredere rolle, bedrift eller kommune.", jobDiscoveryNoResults: "Ingen aktive stillinger samsvarte med søket. Prøv en bredere rolle, bedrift eller kommune.",
+10 -5
View File
@@ -24,20 +24,25 @@ beforeEach(() => { jest.resetAllMocks(); window.localStorage.clear(); searchResp
afterEach(() => jest.clearAllMocks()); afterEach(() => jest.clearAllMocks());
test.each([ test.each([
['en', 'Search filters', 'Jump to results', 'Return to search filters'], ['en', 'Search filters', 'Jump to results', 'Return to search', 'Sources and coverage'],
['nb', 'Søkefiltre', 'Gå til resultater', 'Tilbake til søkefiltre'], ['nb', 'Søkefiltre', 'Gå til resultater', 'Tilbake til søk', 'Kilder og dekning'],
] as const)('keeps %s filters before results with keyboard jump/return and no automatic provider search', async (language, filtersName, jumpName, returnName) => { ] as const)('keeps %s search before filters and results with keyboard jump/return and no automatic provider search', async (language, filtersName, jumpName, returnName, sourceName) => {
localStorage.setItem('uiLanguage', language); localStorage.setItem('uiLanguage', language);
renderPage(); renderPage();
await screen.findByText(translations[language].jobDiscoveryNoSavedSearches); await screen.findByText(translations[language].jobDiscoveryNoSavedSearches);
const filters = screen.getByRole('complementary', { name: filtersName }); const filters = screen.getByRole('complementary', { name: filtersName });
const form = screen.getByRole('form', { name: translations[language].jobDiscoverySearchNav });
const query = within(form).getByRole('textbox', { name: translations[language].jobDiscoveryRoleCompany });
const municipality = within(filters).getByRole('textbox', { name: translations[language].jobDiscoveryMunicipality }) as HTMLInputElement;
const results = screen.getByRole('region', { name: translations[language].jobDiscoveryResults }); const results = screen.getByRole('region', { name: translations[language].jobDiscoveryResults });
expect(form.compareDocumentPosition(filters) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(filters.compareDocumentPosition(results) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(filters.compareDocumentPosition(results) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(within(filters).getByRole('form', { name: filtersName })).toBeInTheDocument(); expect(municipality.form).toBe(form);
expect(within(filters).getByText(sourceName).closest('details')).not.toHaveAttribute('open');
fireEvent.click(within(filters).getByRole('button', { name: jumpName })); fireEvent.click(within(filters).getByRole('button', { name: jumpName }));
expect(results).toHaveFocus(); expect(results).toHaveFocus();
fireEvent.click(within(results).getByRole('button', { name: returnName })); fireEvent.click(within(results).getByRole('button', { name: returnName }));
expect(screen.getByRole('textbox', { name: translations[language].jobDiscoveryRoleCompany })).toHaveFocus(); expect(query).toHaveFocus();
expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(['/job-discovery/saved-searches']); expect(mockedApi.get.mock.calls.map(([url]) => url)).toEqual(['/job-discovery/saved-searches']);
expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled();
}); });
+10 -6
View File
@@ -1,4 +1,4 @@
import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; import { FormEvent, useEffect, useId, useMemo, useRef, useState } from "react";
import { Alert, Box, Button, Card, CardActions, CardContent, Chip, CircularProgress, FormControlLabel, MenuItem, Stack, Switch, TextField, Typography } from "@mui/material"; import { Alert, Box, Button, Card, CardActions, CardContent, Chip, CircularProgress, FormControlLabel, MenuItem, Stack, Switch, TextField, Typography } from "@mui/material";
import SearchIcon from "@mui/icons-material/Search"; import SearchIcon from "@mui/icons-material/Search";
import AddIcon from "@mui/icons-material/Add"; import AddIcon from "@mui/icons-material/Add";
@@ -36,6 +36,7 @@ type SortOrder = "updated" | "deadline" | "title";
export default function JobDiscoveryPage() { export default function JobDiscoveryPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const searchFormId = useId();
const { language, t } = useI18n(); const { language, t } = useI18n();
const { confirmAction } = useDialogActions(); const { confirmAction } = useDialogActions();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -183,14 +184,17 @@ export default function JobDiscoveryPage() {
return ( return (
<Stack spacing={3} sx={{ minWidth: 0 }}> <Stack spacing={3} sx={{ minWidth: 0 }}>
<Alert severity="info" variant="outlined" sx={{ color: "text.primary", "& .MuiAlert-icon": { color: "info.main" } }}>{t("jobDiscoverySourceHelp")}</Alert> <Box component="form" id={searchFormId} aria-label={t("jobDiscoverySearchNav")} onSubmit={search} sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", sm: "minmax(0, 1fr) auto" }, gap: 1.5 }}>
<TextField inputRef={queryRef} label={t("jobDiscoveryRoleCompany")} value={query} disabled={busy} onChange={(event) => setQuery(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>
<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 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 }}> <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>} /> <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 label={t("jobDiscoveryMunicipality")} value={location} disabled={busy} onChange={(event) => setLocation(event.target.value)} slotProps={{ htmlInput: { form: searchFormId } }} />
<TextField inputRef={queryRef} label={t("jobDiscoveryRoleCompany")} value={query} disabled={busy} onChange={(event) => setQuery(event.target.value)} /> <Box component="details">
<TextField label={t("jobDiscoveryMunicipality")} value={location} disabled={busy} onChange={(event) => setLocation(event.target.value)} /> <Typography component="summary" variant="body2" sx={{ cursor: "pointer", py: 0.5, "@media (pointer: coarse)": { minHeight: 44 } }}>{t("jobDiscoverySourceCoverage")}</Typography>
<Button type="submit" variant="contained" startIcon={loading ? <CircularProgress aria-hidden="true" size={18} color="inherit" /> : <SearchIcon />} disabled={busy}>{t("jobDiscoverySearchNav")}</Button> <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>{t("jobDiscoverySourceHelp")}</Typography>
</Box> </Box>
<Box aria-busy={savedSearchState === "loading"}> <Box aria-busy={savedSearchState === "loading"}>
<Box sx={{ display: "grid", gap: 1.5 }}> <Box sx={{ display: "grid", gap: 1.5 }}>