feat(ui): refine global shell utilities
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
@@ -34,59 +34,79 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const activeLoad = useRef<AbortController | null>(null);
|
||||
const openGeneration = useRef(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
activeLoad.current?.abort();
|
||||
const controller = new AbortController();
|
||||
activeLoad.current = controller;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get<UserNotification[]>("/notifications?limit=10");
|
||||
const response = await api.get<UserNotification[]>("/notifications?limit=10", { signal: controller.signal });
|
||||
if (controller.signal.aborted) return;
|
||||
setNotifications(response.data ?? []);
|
||||
setError(null);
|
||||
} catch (requestError) {
|
||||
setError(getApiErrorMessage(requestError, t("notificationsLoadFailed")));
|
||||
if (!controller.signal.aborted) setError(getApiErrorMessage(requestError, t("notificationsLoadFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (activeLoad.current === controller) setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
openGeneration.current += 1;
|
||||
if (open) void load();
|
||||
else {
|
||||
activeLoad.current?.abort();
|
||||
setNotifications([]);
|
||||
setError(null);
|
||||
setBusyId(null);
|
||||
}
|
||||
return () => activeLoad.current?.abort();
|
||||
}, [load, open]);
|
||||
|
||||
const markRead = async (notification: UserNotification) => {
|
||||
if (notification.readAtUtc || busyId) return;
|
||||
const generation = openGeneration.current;
|
||||
setBusyId(notification.id);
|
||||
try {
|
||||
await api.post(`/notifications/${notification.id}/read`);
|
||||
if (generation !== openGeneration.current) return;
|
||||
setNotifications((items) => items.map((item) => item.id === notification.id
|
||||
? { ...item, readAtUtc: new Date().toISOString() }
|
||||
: item));
|
||||
setError(null);
|
||||
onChanged();
|
||||
} catch (requestError) {
|
||||
setError(getApiErrorMessage(requestError, t("notificationsActionFailed")));
|
||||
if (generation === openGeneration.current) setError(getApiErrorMessage(requestError, t("notificationsActionFailed")));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
if (generation === openGeneration.current) setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const dismiss = async (notification: UserNotification) => {
|
||||
if (busyId) return;
|
||||
const generation = openGeneration.current;
|
||||
setBusyId(notification.id);
|
||||
try {
|
||||
await api.delete(`/notifications/${notification.id}`);
|
||||
if (generation !== openGeneration.current) return;
|
||||
setNotifications((items) => items.filter((item) => item.id !== notification.id));
|
||||
setError(null);
|
||||
onChanged();
|
||||
} catch (requestError) {
|
||||
setError(getApiErrorMessage(requestError, t("notificationsActionFailed")));
|
||||
if (generation === openGeneration.current) setError(getApiErrorMessage(requestError, t("notificationsActionFailed")));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
if (generation === openGeneration.current) setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openNotification = async (notification: UserNotification) => {
|
||||
if (!notification.linkPath) return;
|
||||
const generation = openGeneration.current;
|
||||
if (!notification.readAtUtc) await markRead(notification);
|
||||
if (generation !== openGeneration.current) return;
|
||||
onClose();
|
||||
onNavigate(notification.linkPath);
|
||||
};
|
||||
@@ -113,7 +133,7 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
|
||||
>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1.5 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 900 }}>{t("notifications")}</Typography>
|
||||
<Typography component="h2" sx={{ fontWeight: 700 }}>{t("notifications")}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{t("notificationsRecent")}</Typography>
|
||||
</Box>
|
||||
<Tooltip title={t("close")}>
|
||||
@@ -123,7 +143,7 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
|
||||
<Divider />
|
||||
|
||||
<Box sx={{ overflowY: "auto", maxHeight: "min(52vh, 450px)", p: 1 }}>
|
||||
{loading ? <Box sx={{ py: 4, display: "grid", placeItems: "center" }}><CircularProgress size={28} aria-label={t("loading")} /></Box> : null}
|
||||
{loading ? <Box role="status" sx={{ py: 4, display: "grid", placeItems: "center" }}><CircularProgress size={28} aria-label={t("loading")} /></Box> : null}
|
||||
{error ? <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => void load()}>{t("retry")}</Button>} sx={{ m: 0.5 }}>{error}</Alert> : null}
|
||||
{!loading && !error && notifications.length === 0 ? (
|
||||
<Box sx={{ py: 4, px: 2, textAlign: "center" }}>
|
||||
@@ -131,7 +151,7 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
|
||||
<Typography variant="body2" color="text.secondary">{t("notificationsEmptyBody")}</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
{!loading ? notifications.map((notification) => (
|
||||
{!loading && !error ? notifications.map((notification) => (
|
||||
<Box
|
||||
key={notification.id}
|
||||
sx={{
|
||||
@@ -144,7 +164,8 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
|
||||
opacity: notification.readAtUtc ? 0.82 : 1,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontWeight: notification.readAtUtc ? 700 : 900, overflowWrap: "anywhere" }}>{notification.title}</Typography>
|
||||
<Typography sx={{ fontWeight: notification.readAtUtc ? 600 : 700, overflowWrap: "anywhere" }}>{notification.title}</Typography>
|
||||
{!notification.readAtUtc ? <Typography variant="caption" color="text.secondary">{t("notificationsUnread")}</Typography> : null}
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25, overflowWrap: "anywhere" }}>{notification.message}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{notificationDateLabel(notification.createdAtUtc)}</Typography>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mt: 0.75, flexWrap: "wrap" }}>
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Alert,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
List,
|
||||
@@ -47,13 +49,19 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
|
||||
const [query, setQuery] = useState("");
|
||||
const [jobs, setJobs] = useState<JobSearchItem[]>([]);
|
||||
const [companies, setCompanies] = useState<CompanySearchItem[]>([]);
|
||||
const [searchState, setSearchState] = useState<"idle" | "loading" | "ready" | "error">("idle");
|
||||
const [searchAttempt, setSearchAttempt] = useState(0);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const resultRefs = useRef<Array<HTMLElement | null>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery("");
|
||||
setJobs([]);
|
||||
setCompanies([]);
|
||||
setSearchState("idle");
|
||||
setActiveIndex(-1);
|
||||
return;
|
||||
}
|
||||
}, [open]);
|
||||
@@ -64,25 +72,28 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
|
||||
if (!q) {
|
||||
setJobs([]);
|
||||
setCompanies([]);
|
||||
setSearchState("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setJobs([]);
|
||||
setCompanies([]);
|
||||
setSearchState("loading");
|
||||
const timeout = window.setTimeout(() => {
|
||||
api
|
||||
.get<{ items: JobSearchItem[] }>("/jobapplications", { params: { q, page: 1, pageSize: 15 } })
|
||||
.then((r) => setJobs(r.data?.items ?? []))
|
||||
.catch(() => setJobs([]));
|
||||
api
|
||||
.get<CompanySearchItem[]>("/companies")
|
||||
.then((r) => {
|
||||
const matches = (r.data ?? []).filter((c) => c.name.toLowerCase().includes(q.toLowerCase())).slice(0, 8);
|
||||
setCompanies(matches);
|
||||
Promise.all([
|
||||
api.get<{ items: JobSearchItem[] }>("/jobapplications", { params: { q, page: 1, pageSize: 15 }, signal: controller.signal }),
|
||||
api.get<CompanySearchItem[]>("/companies", { signal: controller.signal }),
|
||||
])
|
||||
.then(([jobResponse, companyResponse]) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setJobs(jobResponse.data?.items ?? []);
|
||||
setCompanies((companyResponse.data ?? []).filter((c) => c.name.toLowerCase().includes(q.toLowerCase())).slice(0, 8));
|
||||
setSearchState("ready");
|
||||
})
|
||||
.catch(() => setCompanies([]));
|
||||
.catch(() => { if (!controller.signal.aborted) setSearchState("error"); });
|
||||
}, 250);
|
||||
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [open, query]);
|
||||
return () => { window.clearTimeout(timeout); controller.abort(); };
|
||||
}, [open, query, searchAttempt]);
|
||||
|
||||
const commands = useMemo<CommandItem[]>(() => {
|
||||
const base: CommandItem[] = [
|
||||
@@ -99,25 +110,44 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
|
||||
return base.filter((item) => item.label.toLowerCase().includes(q) || item.hint?.toLowerCase().includes(q));
|
||||
}, [onNavigate, onOpenAddJob, query, t]);
|
||||
|
||||
const allItems: CommandItem[] = [
|
||||
...commands,
|
||||
...jobs.slice(0, 6).map((job) => ({
|
||||
const jobItems: CommandItem[] = jobs.slice(0, 6).map((job) => ({
|
||||
id: `job-${job.id}`,
|
||||
label: `${job.company?.name ?? t("company")} - ${job.jobTitle}`,
|
||||
hint: t("openJobListAndSearchResult"),
|
||||
action: () => onNavigate(`/jobs/${job.id}`),
|
||||
})),
|
||||
...companies.slice(0, 6).map((company) => ({
|
||||
}));
|
||||
const companyItems: CommandItem[] = companies.slice(0, 6).map((company) => ({
|
||||
id: `company-${company.id}`,
|
||||
label: company.name,
|
||||
hint: t("openCompanies"),
|
||||
action: () => onNavigate(`/companies?edit=${company.id}`),
|
||||
})),
|
||||
}));
|
||||
const groups = [
|
||||
{ label: t("commandActions"), items: commands },
|
||||
{ label: t("commandJobs"), items: jobItems },
|
||||
{ label: t("commandCompanies"), items: companyItems },
|
||||
];
|
||||
const allItems = groups.flatMap((group) => group.items);
|
||||
const activate = (item: CommandItem) => { item.action(); onClose(); };
|
||||
const onKeys = (event: React.KeyboardEvent) => {
|
||||
if (event.nativeEvent.isComposing || event.keyCode === 229 || !allItems.length) return;
|
||||
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
const current = event.target === searchInputRef.current ? activeIndex : resultRefs.current.indexOf(event.target as HTMLElement);
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? allItems.length - 1
|
||||
: event.key === "ArrowDown" ? (current + 1) % allItems.length
|
||||
: (current < 0 ? allItems.length - 1 : (current - 1 + allItems.length) % allItems.length);
|
||||
setActiveIndex(next);
|
||||
if (event.target !== searchInputRef.current) resultRefs.current[next]?.focus();
|
||||
} else if (event.key === "Enter" && event.target === searchInputRef.current) {
|
||||
event.preventDefault();
|
||||
activate(allItems[activeIndex < 0 ? 0 : activeIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} slotProps={{ paper: { "aria-label": t("quickSearch") }, transition: { onEntered: () => searchInputRef.current?.focus() } }} fullWidth maxWidth="sm">
|
||||
<DialogContent sx={{ p: 1.5 }}>
|
||||
<DialogContent onKeyDown={onKeys} sx={{ p: 1.5, maxHeight: "min(70vh, 620px)" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, px: 1, pt: 0.5 }}>
|
||||
<SearchIcon fontSize="small" />
|
||||
<TextField
|
||||
@@ -128,7 +158,7 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
|
||||
placeholder={t("searchPlaceholder")}
|
||||
slotProps={{ htmlInput: { "aria-label": t("quickSearch") } }}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onChange={(e) => { setQuery(e.target.value); setActiveIndex(-1); }}
|
||||
InputProps={{ disableUnderline: true }}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", whiteSpace: "nowrap" }}>
|
||||
@@ -136,27 +166,22 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<List sx={{ mt: 1 }}>
|
||||
{allItems.length === 0 ? (
|
||||
<ListItem sx={{ px: 2, py: 3 }}>
|
||||
<Typography sx={{ color: "text.secondary" }}>{t("noMatchingCommands")}</Typography>
|
||||
</ListItem>
|
||||
) : (
|
||||
allItems.map((item) => (
|
||||
<ListItem key={item.id} disablePadding>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
item.action();
|
||||
onClose();
|
||||
}}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
<ListItemText primary={item.label} secondary={item.hint} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
<Box aria-live="polite" sx={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
|
||||
{searchState === "loading" ? t("loading") : searchState === "ready" ? t("searchResultsCount", { count: allItems.length }) : ""}
|
||||
{activeIndex >= 0 ? ` ${allItems[activeIndex]?.label ?? ""}` : ""}
|
||||
</Box>
|
||||
{searchState === "error" ? <Alert severity="error" sx={{ mt: 1 }} action={<Button color="inherit" size="small" onClick={() => setSearchAttempt((value) => value + 1)}>{t("retry")}</Button>}>{t("searchLoadFailed")}</Alert> : null}
|
||||
{groups.map((group) => group.items.length ? <Box component="section" key={group.label} sx={{ mt: 1 }}>
|
||||
<Typography component="h2" variant="caption" color="text.secondary" sx={{ px: 2, fontWeight: 700 }}>{group.label}</Typography>
|
||||
<List dense aria-label={group.label}>
|
||||
{group.items.map((item) => <ListItem key={item.id} disablePadding>
|
||||
<ListItemButton ref={(node) => { resultRefs.current[allItems.indexOf(item)] = node; }} selected={activeIndex === allItems.indexOf(item)} onFocus={() => setActiveIndex(allItems.indexOf(item))} onClick={() => activate(item)} sx={{ borderRadius: 2 }}>
|
||||
<ListItemText primary={item.label} secondary={item.hint} />
|
||||
</ListItemButton>
|
||||
</ListItem>)}
|
||||
</List>
|
||||
</Box> : null)}
|
||||
{allItems.length === 0 && searchState !== "loading" && searchState !== "error" ? <Typography color="text.secondary" sx={{ px: 2, py: 3 }}>{t("noMatchingCommands")}</Typography> : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -144,6 +144,7 @@ export const translations = {
|
||||
notifications: "Notifications",
|
||||
notificationsPanel: "Notifications panel",
|
||||
notificationsRecent: "Recent updates and background work",
|
||||
notificationsUnread: "Unread",
|
||||
notificationsLoadFailed: "Notifications could not be loaded.",
|
||||
notificationsActionFailed: "The notification could not be updated.",
|
||||
notificationsEmptyTitle: "You're all caught up",
|
||||
@@ -156,6 +157,11 @@ export const translations = {
|
||||
quickSearch: "Quick Search",
|
||||
searchPlaceholder: "Search jobs, companies, or actions",
|
||||
noMatchingCommands: "No matching commands or records.",
|
||||
commandActions: "Actions and pages",
|
||||
commandJobs: "Jobs",
|
||||
commandCompanies: "Companies",
|
||||
searchLoadFailed: "Search could not be loaded.",
|
||||
searchResultsCount: "{count} results available.",
|
||||
goToDashboard: "Go to dashboard",
|
||||
analyticsOverview: "Analytics overview",
|
||||
goToJobs: "Go to jobs",
|
||||
@@ -2600,6 +2606,7 @@ export const translations = {
|
||||
notifications: "Varsler",
|
||||
notificationsPanel: "Varslingspanel",
|
||||
notificationsRecent: "Nylige oppdateringer og bakgrunnsarbeid",
|
||||
notificationsUnread: "Ulest",
|
||||
notificationsLoadFailed: "Varslene kunne ikke lastes.",
|
||||
notificationsActionFailed: "Varslet kunne ikke oppdateres.",
|
||||
notificationsEmptyTitle: "Du er ajour",
|
||||
@@ -2612,6 +2619,11 @@ export const translations = {
|
||||
quickSearch: "Hurtigsøk",
|
||||
searchPlaceholder: "Søk etter jobber, selskaper eller handlinger",
|
||||
noMatchingCommands: "Ingen treff på kommandoer eller poster.",
|
||||
commandActions: "Handlinger og sider",
|
||||
commandJobs: "Jobber",
|
||||
commandCompanies: "Selskaper",
|
||||
searchLoadFailed: "Søket kunne ikke lastes.",
|
||||
searchResultsCount: "{count} treff tilgjengelig.",
|
||||
goToDashboard: "Gå til dashboard",
|
||||
analyticsOverview: "Analyseoversikt",
|
||||
goToJobs: "Gå til jobber",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import { api } from "./api";
|
||||
import NotificationsPopover from "./components/NotificationsPopover";
|
||||
@@ -53,7 +53,7 @@ test("shows notifications and updates read and dismissed state in place", async
|
||||
const { props } = renderPopover();
|
||||
|
||||
expect(await screen.findByText("CV export failed")).toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledWith("/notifications?limit=10");
|
||||
expect(mockedApi.get).toHaveBeenCalledWith("/notifications?limit=10", expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Mark read" }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(`/notifications/${notification.id}/read`));
|
||||
@@ -82,3 +82,36 @@ test("shows an honest empty state", async () => {
|
||||
|
||||
expect(await screen.findByText("You're all caught up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("[P5-02 PRES-E09] a closed notification request cannot populate a later account's panel", async () => {
|
||||
let resolveOld!: (value: unknown) => void;
|
||||
mockedApi.get.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve; }) as never);
|
||||
const { rerender, props, anchor } = renderPopover();
|
||||
await waitFor(() => expect(resolveOld).toBeDefined());
|
||||
rerender(<I18nProvider><NotificationsPopover {...props} anchorEl={null} /></I18nProvider>);
|
||||
mockedApi.get.mockResolvedValue({ data: [] } as any);
|
||||
rerender(<I18nProvider><NotificationsPopover {...props} anchorEl={anchor} /></I18nProvider>);
|
||||
await act(async () => resolveOld({ data: [notification] }));
|
||||
expect(await screen.findByText("You're all caught up")).toBeInTheDocument();
|
||||
expect(screen.queryByText("CV export failed")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("[P5-02 PRES-E09] a delayed read cannot navigate from a closed earlier-account panel", async () => {
|
||||
let resolveRead!: (value: unknown) => void;
|
||||
mockedApi.post.mockImplementationOnce(() => new Promise(resolve => { resolveRead = resolve; }) as never);
|
||||
const { rerender, props } = renderPopover();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
|
||||
await waitFor(() => expect(resolveRead).toBeDefined());
|
||||
rerender(<I18nProvider><NotificationsPopover {...props} anchorEl={null} /></I18nProvider>);
|
||||
await act(async () => resolveRead({ data: {} }));
|
||||
expect(props.onNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[P5-02 PRES-S02] failed notification load offers Retry without claiming an empty inbox", async () => {
|
||||
mockedApi.get.mockRejectedValueOnce(new Error("offline"));
|
||||
renderPopover();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Notifications could not be loaded");
|
||||
expect(screen.queryByText("You're all caught up")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(await screen.findByText("CV export failed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { api } from "./api";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import QuickCommandDialog from "./components/QuickCommandDialog";
|
||||
@@ -48,12 +48,80 @@ test("[PRES-E07 PRES-J04] the capture command opens review without creating a jo
|
||||
|
||||
test("[P5-00 PRES-E07] command and empty results use valid list semantics", async () => {
|
||||
render(<I18nProvider><QuickCommandDialog open onClose={jest.fn()} onNavigate={jest.fn()} onOpenAddJob={jest.fn()} /></I18nProvider>);
|
||||
const list = screen.getByRole("list");
|
||||
expect(screen.getAllByRole("listitem")).toHaveLength(6);
|
||||
expect(Array.from(list.children).every(item => item.tagName === "LI")).toBe(true);
|
||||
expect(screen.getAllByRole("list").every(list => Array.from(list.children).every(item => item.tagName === "LI"))).toBe(true);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "no-such-result" } });
|
||||
mockedApi.get.mockResolvedValue({ data: [] } as never);
|
||||
await screen.findByText("No matching commands or records.");
|
||||
expect(Array.from(list.children).every(item => item.tagName === "LI")).toBe(true);
|
||||
expect(screen.queryAllByRole("list").every(list => Array.from(list.children).every(item => item.tagName === "LI"))).toBe(true);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[P5-02 PRES-E07] search failure is distinct from no matches and Retry recovers", async () => {
|
||||
mockedApi.get.mockRejectedValueOnce(new Error("offline"));
|
||||
render(<I18nProvider><QuickCommandDialog open onClose={jest.fn()} onNavigate={jest.fn()} onOpenAddJob={jest.fn()} /></I18nProvider>);
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Quick Search" }), { target: { value: "Nordlys" } });
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Search could not be loaded");
|
||||
expect(screen.queryByText("No matching commands or records.")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(await screen.findByText("Nordlys AS - Platform engineer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("[P5-02 PRES-E09] closed and superseded searches cannot display stale account results", async () => {
|
||||
let resolveOld!: (value: unknown) => void;
|
||||
mockedApi.get.mockImplementation((url: string) => url === "/jobapplications"
|
||||
? new Promise(resolve => { resolveOld = resolve; }) as never
|
||||
: Promise.resolve({ data: [] }) as never);
|
||||
const props = { onClose: jest.fn(), onNavigate: jest.fn(), onOpenAddJob: jest.fn() };
|
||||
const view = (open: boolean) => <I18nProvider><QuickCommandDialog open={open} {...props} /></I18nProvider>;
|
||||
const { rerender } = render(view(true));
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "old" } });
|
||||
await waitFor(() => expect(resolveOld).toBeDefined());
|
||||
rerender(view(false));
|
||||
rerender(view(true));
|
||||
await act(async () => resolveOld({ data: { items: [{ id: 42, jobTitle: "Account A job", company: { name: "Private" } }] } }));
|
||||
expect(screen.queryByText(/Account A job/)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox")).toHaveValue("");
|
||||
});
|
||||
|
||||
test("[P5-02 PRES-E07] a slower old query cannot replace the newer matching results", async () => {
|
||||
let resolveOld!: (value: unknown) => void;
|
||||
mockedApi.get.mockImplementation((url: string, config?: any) => url === "/companies"
|
||||
? Promise.resolve({ data: [] }) as never
|
||||
: config?.params?.q === "old"
|
||||
? new Promise(resolve => { resolveOld = resolve; }) as never
|
||||
: Promise.resolve({ data: { items: [{ id: 9, jobTitle: "New result", company: { name: "Current" } }] } }) as never);
|
||||
render(<I18nProvider><QuickCommandDialog open onClose={jest.fn()} onNavigate={jest.fn()} onOpenAddJob={jest.fn()} /></I18nProvider>);
|
||||
const search = screen.getByRole("textbox", { name: "Quick Search" });
|
||||
fireEvent.change(search, { target: { value: "old" } });
|
||||
await waitFor(() => expect(resolveOld).toBeDefined());
|
||||
fireEvent.change(search, { target: { value: "new" } });
|
||||
expect(await screen.findByText("Current - New result")).toBeInTheDocument();
|
||||
await act(async () => resolveOld({ data: { items: [{ id: 8, jobTitle: "Old result", company: { name: "Private" } }] } }));
|
||||
expect(screen.queryByText("Private - Old result")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("[P5-02 PRES-E07] arrows, Home, End and Enter activate one existing command; composition does not", () => {
|
||||
const onNavigate = jest.fn();
|
||||
render(<I18nProvider><QuickCommandDialog open onClose={jest.fn()} onNavigate={onNavigate} onOpenAddJob={jest.fn()} /></I18nProvider>);
|
||||
const search = screen.getByRole("textbox", { name: "Quick Search" });
|
||||
fireEvent.keyDown(search, { key: "End" });
|
||||
fireEvent.keyDown(search, { key: "Enter", isComposing: true });
|
||||
expect(onNavigate).not.toHaveBeenCalled();
|
||||
fireEvent.keyDown(search, { key: "Home" });
|
||||
fireEvent.keyDown(search, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(search, { key: "Enter" });
|
||||
expect(onNavigate).toHaveBeenCalledWith("/jobs");
|
||||
});
|
||||
|
||||
test("[P5-02 PRES-E07] arrowing from a focused result keeps focus and activation on the same item", () => {
|
||||
const onNavigate = jest.fn();
|
||||
render(<I18nProvider><QuickCommandDialog open onClose={jest.fn()} onNavigate={onNavigate} onOpenAddJob={jest.fn()} /></I18nProvider>);
|
||||
const dashboard = screen.getByRole("button", { name: /go to dashboard/i });
|
||||
act(() => dashboard.focus());
|
||||
fireEvent.keyDown(dashboard, { key: "ArrowDown" });
|
||||
const jobs = screen.getByRole("button", { name: /go to jobs/i });
|
||||
expect(jobs).toHaveFocus();
|
||||
fireEvent.click(jobs);
|
||||
expect(onNavigate).toHaveBeenCalledWith("/jobs");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user