fix(discovery): expose saved-search load failures and retry
This commit is contained in:
@@ -300,6 +300,8 @@ export const translations = {
|
||||
jobDiscoveryViewListing: "View listing",
|
||||
jobDiscoverySaveTracker: "Save to tracker",
|
||||
jobDiscoverySavedSearches: "Saved searches",
|
||||
jobDiscoverySavedLoading: "Loading saved searches…",
|
||||
jobDiscoverySavedLoadFailed: "Saved searches could not be loaded. Try again.",
|
||||
jobDiscoverySavedSearchesHelp: "Saved NAV searches are checked automatically. You will be notified when new vacancies appear.",
|
||||
jobDiscoveryAutomaticAlerts: "Automatic alerts",
|
||||
jobDiscoveryUpdateAlertsFailed: "The alert setting could not be updated. Try again.",
|
||||
@@ -2878,6 +2880,8 @@ export const translations = {
|
||||
jobDiscoveryViewListing: "Vis stilling",
|
||||
jobDiscoverySaveTracker: "Lagre i oversikten",
|
||||
jobDiscoverySavedSearches: "Lagrede søk",
|
||||
jobDiscoverySavedLoading: "Laster lagrede søk…",
|
||||
jobDiscoverySavedLoadFailed: "Lagrede søk kunne ikke lastes. Prøv igjen.",
|
||||
jobDiscoverySavedSearchesHelp: "Lagrede NAV-søk kontrolleres automatisk. Du får et varsel når nye stillinger dukker opp.",
|
||||
jobDiscoveryAutomaticAlerts: "Automatiske varsler",
|
||||
jobDiscoveryUpdateAlertsFailed: "Varslingsinnstillingen kunne ikke oppdateres. Prøv igjen.",
|
||||
|
||||
@@ -81,7 +81,7 @@ test("renders the discovery workflow in Norwegian Bokmål", async () => {
|
||||
});
|
||||
|
||||
test("runs a saved search, marks unseen vacancies, and dismisses them", async () => {
|
||||
const saved = { id: 7, name: "Backend Oslo", query: "backend", location: "Oslo", resultCount: 0, dismissedCount: 0 };
|
||||
const saved = { id: 7, name: "Backend Oslo", query: "backend", location: "Oslo", isActive: true, resultCount: 0, dismissedCount: 0 };
|
||||
mockedApi.get.mockResolvedValue({ data: [saved] } as any);
|
||||
mockedApi.post.mockResolvedValue({ data: { search: { ...saved, resultCount: 1 }, jobs: [{ job: { id: "new-1", title: "Platform Engineer", url: "https://example.test/new-1", source: "nav", countryCode: "NO" }, isNew: true, isDismissed: false }] } } as any);
|
||||
mockedApi.patch.mockResolvedValue({ data: null } as any);
|
||||
@@ -107,3 +107,51 @@ test("lets the user pause automatic alerts for a saved search", async () => {
|
||||
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith("/job-discovery/saved-searches/7", { isActive: false }));
|
||||
await waitFor(() => expect(toggle).not.toBeChecked());
|
||||
});
|
||||
|
||||
test("announces saved-search loading without presenting a successful empty list", async () => {
|
||||
let finish!: (value: any) => void;
|
||||
mockedApi.get.mockReturnValue(new Promise((resolve) => { finish = resolve; }));
|
||||
renderPage();
|
||||
expect(screen.getByRole("status", { name: "Loading saved searches…" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("No saved searches yet.")).not.toBeInTheDocument();
|
||||
finish({ data: [] });
|
||||
expect(await screen.findByText("No saved searches yet.")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("status", { name: "Loading saved searches…" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("reports saved-search failure and retries that read without replacing active discovery filters or results", async () => {
|
||||
let savedAttempts = 0;
|
||||
mockedApi.get.mockImplementation((url) => {
|
||||
if (String(url).endsWith("saved-searches")) {
|
||||
savedAttempts++;
|
||||
return savedAttempts === 1
|
||||
? Promise.reject({ response: { status: 500, data: { detail: "Internal database stack trace" } } })
|
||||
: Promise.resolve({ data: [{ id: 7, name: "Backend Oslo", query: "backend", location: "Oslo", isActive: true, resultCount: 0, dismissedCount: 0 }] } as any);
|
||||
}
|
||||
return Promise.resolve({ data: [{ id: "vacancy", title: "Existing result", url: "https://example.test/vacancy", source: "nav", countryCode: "NO" }] } as any);
|
||||
});
|
||||
renderPage();
|
||||
expect(await screen.findByText("Saved searches could not be loaded. Try again.")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No saved searches yet.")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save search" })).toBeDisabled();
|
||||
expect(screen.queryByText(/Internal database stack trace/)).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: "developer" } });
|
||||
fireEvent.change(screen.getByLabelText("Municipality"), { target: { value: "Bergen" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Search NAV" }));
|
||||
await screen.findByText("Existing result");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(await screen.findByText("Backend Oslo")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save search" })).toBeEnabled();
|
||||
expect(screen.getByLabelText("Role or company")).toHaveValue("developer");
|
||||
expect(screen.getByLabelText("Municipality")).toHaveValue("Bergen");
|
||||
expect(screen.getByText("Existing result")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Saved searches could not be loaded. Try again.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test.each([null, { message: "not a list" }, [{ id: 7 }]])("rejects malformed saved-search payload %j as a failed read rather than an empty list", async (data) => {
|
||||
mockedApi.get.mockResolvedValue({ data } as any);
|
||||
renderPage();
|
||||
expect(await screen.findByText("Saved searches could not be loaded. Try again.")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No saved searches yet.")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -44,6 +44,8 @@ export default function JobDiscoveryPage() {
|
||||
const [lastSearch, setLastSearch] = useState({ query: "", location: "" });
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("updated");
|
||||
const [savedSearches, setSavedSearches] = useState<SavedSearch[]>([]);
|
||||
const [savedSearchState, setSavedSearchState] = useState<"loading" | "loaded" | "error">("loading");
|
||||
const [savedSearchLoad, setSavedSearchLoad] = useState(0);
|
||||
const [savedName, setSavedName] = useState("");
|
||||
const [savingSearch, setSavingSearch] = useState(false);
|
||||
const [activeSavedSearchId, setActiveSavedSearchId] = useState<number | null>(null);
|
||||
@@ -51,11 +53,22 @@ export default function JobDiscoveryPage() {
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setSavedSearchState("loading");
|
||||
api.get<SavedSearch[]>("/job-discovery/saved-searches")
|
||||
.then((response) => { if (active) setSavedSearches(Array.isArray(response.data) ? response.data : []); })
|
||||
.catch(() => { /* One-off discovery remains usable if saved-search storage is unavailable. */ });
|
||||
.then((response) => {
|
||||
const rows = response.data;
|
||||
if (!Array.isArray(rows) || !rows.every((row) => row && Number.isInteger(row.id) && row.id > 0
|
||||
&& typeof row.name === "string" && typeof row.query === "string" && typeof row.location === "string"
|
||||
&& typeof row.isActive === "boolean" && Number.isInteger(row.resultCount) && row.resultCount >= 0
|
||||
&& Number.isInteger(row.dismissedCount) && row.dismissedCount >= 0
|
||||
&& (row.lastRunAtUtc == null || typeof row.lastRunAtUtc === "string"))) {
|
||||
throw new Error("Invalid saved-search response");
|
||||
}
|
||||
if (active) { setSavedSearches(rows); setSavedSearchState("loaded"); }
|
||||
})
|
||||
.catch(() => { if (active) setSavedSearchState("error"); });
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
}, [savedSearchLoad]);
|
||||
|
||||
const runSearch = async (nextQuery: string, nextLocation: string) => {
|
||||
setLoading(true); setError(""); setJobs([]); setHasSearched(true); setLastSearch({ query: nextQuery, location: nextLocation });
|
||||
@@ -139,7 +152,7 @@ export default function JobDiscoveryPage() {
|
||||
<TextField label={t("jobDiscoveryMunicipality")} value={location} onChange={(event) => setLocation(event.target.value)} />
|
||||
<Button type="submit" variant="contained" startIcon={loading ? <CircularProgress size={18} color="inherit" /> : <SearchIcon />} disabled={loading}>{t("jobDiscoverySearchNav")}</Button>
|
||||
</Box>
|
||||
<Card variant="outlined">
|
||||
<Card variant="outlined" aria-busy={savedSearchState === "loading"}>
|
||||
<CardContent sx={{ display: "grid", gap: 1.5 }}>
|
||||
<Box>
|
||||
<Typography component="h2" variant="h6" sx={{ fontWeight: 800 }}>{t("jobDiscoverySavedSearches")}</Typography>
|
||||
@@ -147,9 +160,13 @@ export default function JobDiscoveryPage() {
|
||||
</Box>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "minmax(0, 1fr) auto" }, gap: 1 }}>
|
||||
<TextField size="small" label={t("jobDiscoverySavedName")} value={savedName} onChange={(event) => setSavedName(event.target.value)} />
|
||||
<Button variant="outlined" startIcon={<BookmarkAddOutlinedIcon />} onClick={() => void saveSearch()} disabled={savingSearch}>{t("jobDiscoverySaveSearch")}</Button>
|
||||
<Button variant="outlined" startIcon={<BookmarkAddOutlinedIcon />} onClick={() => void saveSearch()} disabled={savingSearch || savedSearchState !== "loaded"}>{t("jobDiscoverySaveSearch")}</Button>
|
||||
</Box>
|
||||
{savedSearches.length === 0 ? <Typography variant="body2" color="text.secondary">{t("jobDiscoveryNoSavedSearches")}</Typography> : (
|
||||
{savedSearchState === "loading" ? <Typography role="status" aria-label={t("jobDiscoverySavedLoading")} variant="body2" color="text.secondary">{t("jobDiscoverySavedLoading")}</Typography> : savedSearchState === "error" ? (
|
||||
<Alert severity="error" variant="outlined" action={<Button color="inherit" onClick={() => setSavedSearchLoad((current) => current + 1)}>{t("retry")}</Button>}>
|
||||
{t("jobDiscoverySavedLoadFailed")}
|
||||
</Alert>
|
||||
) : savedSearches.length === 0 ? <Typography variant="body2" color="text.secondary">{t("jobDiscoveryNoSavedSearches")}</Typography> : (
|
||||
<Stack spacing={1}>
|
||||
{savedSearches.map((saved) => (
|
||||
<Box key={saved.id} sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 1, p: 1.25, border: 1, borderColor: activeSavedSearchId === saved.id ? "primary.main" : "divider", borderRadius: 2 }}>
|
||||
|
||||
Reference in New Issue
Block a user