feat(calendar): add application date integrations
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { api } from "./api";
|
||||
import ApplicationCalendarActions from "./components/ApplicationCalendarActions";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: { get: jest.fn(), post: jest.fn() },
|
||||
getApiErrorMessage: (_: unknown, fallback: string) => fallback,
|
||||
}));
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
mockedApi.get.mockResolvedValue({ data: { google: { connected: true, writable: true }, microsoft: { connected: false, writable: false } } } as any);
|
||||
mockedApi.post.mockResolvedValue({ data: { provider: "google", id: "event-1", webUrl: "https://calendar.google.test/event-1" } } as any);
|
||||
});
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
test("adds the selected application date to a writable connected calendar", async () => {
|
||||
render(<I18nProvider><ApplicationCalendarActions jobId={42} followUpAt="2026-09-02T10:30:00Z" deadline="2026-09-15T00:00:00Z" /></I18nProvider>);
|
||||
|
||||
const googleButtons = await screen.findAllByRole("button", { name: "Google Calendar" });
|
||||
fireEvent.click(googleButtons[0]);
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/calendar/jobs/42/events", { provider: "google", kind: "follow-up" }));
|
||||
expect(await screen.findByText("The event was added to your calendar.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Open event" })).toHaveAttribute("href", "https://calendar.google.test/event-1");
|
||||
});
|
||||
|
||||
test("keeps portable calendar download available without a provider connection", async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: { google: { connected: false, writable: false }, microsoft: { connected: false, writable: false } } } as any);
|
||||
render(<I18nProvider><ApplicationCalendarActions jobId={42} followUpAt={null} deadline="2026-09-15T00:00:00Z" /></I18nProvider>);
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Download .ics" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Google Calendar" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("explains when an existing connection needs renewed calendar consent", async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: { google: { connected: true, writable: false }, microsoft: { connected: false, writable: false } } } as any);
|
||||
render(<I18nProvider><ApplicationCalendarActions jobId={42} followUpAt="2026-09-02T10:30:00Z" /></I18nProvider>);
|
||||
|
||||
expect(await screen.findByText("Reconnect your calendar account to approve calendar access.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Reconnect" })).toHaveAttribute("href", "/settings");
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Box, Button, CircularProgress, Stack, Typography } from "@mui/material";
|
||||
import CalendarMonthOutlinedIcon from "@mui/icons-material/CalendarMonthOutlined";
|
||||
import DownloadOutlinedIcon from "@mui/icons-material/DownloadOutlined";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
type ProviderStatus = { connected: boolean; writable: boolean };
|
||||
type CalendarStatus = { google: ProviderStatus; microsoft: ProviderStatus };
|
||||
type EventKind = "follow-up" | "deadline";
|
||||
type CalendarEventResult = { provider: string; id?: string; webUrl?: string };
|
||||
|
||||
export default function ApplicationCalendarActions({ jobId, followUpAt, deadline }: { jobId: number; followUpAt?: string | null; deadline?: string | null }) {
|
||||
const { t } = useI18n();
|
||||
const [status, setStatus] = useState<CalendarStatus | null>(null);
|
||||
const [busy, setBusy] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [created, setCreated] = useState<CalendarEventResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
api.get<CalendarStatus>("/calendar/status")
|
||||
.then((response) => { if (active) setStatus(response.data); })
|
||||
.catch(() => { if (active) setStatus({ google: { connected: false, writable: false }, microsoft: { connected: false, writable: false } }); });
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
const create = async (provider: "google" | "microsoft", kind: EventKind) => {
|
||||
setBusy(`${provider}-${kind}`); setError(""); setCreated(null);
|
||||
try {
|
||||
const response = await api.post<CalendarEventResult>(`/calendar/jobs/${jobId}/events`, { provider, kind });
|
||||
setCreated(response.data);
|
||||
} catch (err) { setError(getApiErrorMessage(err, t("calendarCreateFailed"))); }
|
||||
finally { setBusy(""); }
|
||||
};
|
||||
|
||||
const download = async (kind: EventKind) => {
|
||||
setBusy(`ics-${kind}`); setError("");
|
||||
try {
|
||||
const response = await api.get(`/calendar/jobs/${jobId}/events.ics`, { params: { kind }, responseType: "blob" });
|
||||
const url = URL.createObjectURL(response.data);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url; anchor.download = `jobjakt-${jobId}-${kind}.ics`; anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) { setError(getApiErrorMessage(err, t("calendarDownloadFailed"))); }
|
||||
finally { setBusy(""); }
|
||||
};
|
||||
|
||||
const rows: Array<{ kind: EventKind; title: string }> = [
|
||||
...(followUpAt ? [{ kind: "follow-up" as const, title: t("calendarFollowUp") }] : []),
|
||||
...(deadline ? [{ kind: "deadline" as const, title: t("calendarDeadline") }] : []),
|
||||
];
|
||||
|
||||
if (rows.length === 0) return <Alert severity="info">{t("calendarNoDates")}</Alert>;
|
||||
if (!status) return <Stack direction="row" spacing={1} alignItems="center"><CircularProgress size={18} /><Typography variant="body2">{t("loading")}</Typography></Stack>;
|
||||
|
||||
return (
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="body2" color="text.secondary">{t("calendarHelp")}</Typography>
|
||||
{(status.google.connected && !status.google.writable) || (status.microsoft.connected && !status.microsoft.writable) ? (
|
||||
<Alert severity="info" action={<Button color="inherit" size="small" href="/settings">{t("calendarReconnect")}</Button>}>{t("calendarPermissionNeeded")}</Alert>
|
||||
) : null}
|
||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||
{created ? (
|
||||
<Alert severity="success" action={created.webUrl ? <Button color="inherit" size="small" href={created.webUrl} target="_blank" rel="noreferrer" endIcon={<OpenInNewIcon />}>{t("calendarOpen")}</Button> : undefined}>
|
||||
{t("calendarCreated")}
|
||||
</Alert>
|
||||
) : null}
|
||||
{rows.map((row) => (
|
||||
<Box key={row.kind} sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 1, p: 1.25, border: 1, borderColor: "divider", borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 750, flex: "1 1 180px" }}>{row.title}</Typography>
|
||||
{status.google.writable ? <Button size="small" startIcon={<CalendarMonthOutlinedIcon />} disabled={Boolean(busy)} onClick={() => void create("google", row.kind)}>{t("calendarGoogle")}</Button> : null}
|
||||
{status.microsoft.writable ? <Button size="small" startIcon={<CalendarMonthOutlinedIcon />} disabled={Boolean(busy)} onClick={() => void create("microsoft", row.kind)}>{t("calendarOutlook")}</Button> : null}
|
||||
<Button size="small" startIcon={<DownloadOutlinedIcon />} disabled={Boolean(busy)} onClick={() => void download(row.kind)}>{t("calendarDownload")}</Button>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -278,6 +278,20 @@ export const translations = {
|
||||
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.",
|
||||
calendarTitle: "Calendar",
|
||||
calendarHelp: "Add important application dates to a connected calendar, or download a portable calendar file.",
|
||||
calendarFollowUp: "Application follow-up",
|
||||
calendarDeadline: "Application deadline",
|
||||
calendarGoogle: "Google Calendar",
|
||||
calendarOutlook: "Outlook Calendar",
|
||||
calendarDownload: "Download .ics",
|
||||
calendarReconnect: "Reconnect",
|
||||
calendarPermissionNeeded: "Reconnect your calendar account to approve calendar access.",
|
||||
calendarCreated: "The event was added to your calendar.",
|
||||
calendarOpen: "Open event",
|
||||
calendarNoDates: "Set a follow-up date or application deadline to add it to a calendar.",
|
||||
calendarCreateFailed: "The calendar event could not be created.",
|
||||
calendarDownloadFailed: "The calendar file could not be downloaded.",
|
||||
jobDiscoverySavedName: "Search name",
|
||||
jobDiscoverySavedDefaultName: "Recent vacancies",
|
||||
jobDiscoverySaveSearch: "Save search",
|
||||
@@ -2680,6 +2694,20 @@ export const translations = {
|
||||
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.",
|
||||
calendarTitle: "Kalender",
|
||||
calendarHelp: "Legg viktige søknadsdatoer til i en tilkoblet kalender, eller last ned en kalenderfil.",
|
||||
calendarFollowUp: "Oppfølging av søknad",
|
||||
calendarDeadline: "Søknadsfrist",
|
||||
calendarGoogle: "Google Kalender",
|
||||
calendarOutlook: "Outlook-kalender",
|
||||
calendarDownload: "Last ned .ics",
|
||||
calendarReconnect: "Koble til på nytt",
|
||||
calendarPermissionNeeded: "Koble til kalenderkontoen på nytt for å godkjenne kalendertilgang.",
|
||||
calendarCreated: "Hendelsen ble lagt til i kalenderen.",
|
||||
calendarOpen: "Åpne hendelsen",
|
||||
calendarNoDates: "Angi en oppfølgingsdato eller søknadsfrist for å legge den til i en kalender.",
|
||||
calendarCreateFailed: "Kalenderhendelsen kunne ikke opprettes.",
|
||||
calendarDownloadFailed: "Kalenderfilen kunne ikke lastes ned.",
|
||||
jobDiscoverySavedName: "Navn på søket",
|
||||
jobDiscoverySavedDefaultName: "Nylige stillinger",
|
||||
jobDiscoverySaveSearch: "Lagre søk",
|
||||
|
||||
@@ -22,6 +22,7 @@ import { getApiErrorMessage } from "../api";
|
||||
import Attachments from "../components/Attachments";
|
||||
import Correspondence from "../components/Correspondence";
|
||||
import ApplicationChecklist from "../components/ApplicationChecklist";
|
||||
import ApplicationCalendarActions from "../components/ApplicationCalendarActions";
|
||||
import {
|
||||
ApplicationAnalysis, ApplicationMatch, ApplicationTimeline,
|
||||
} from "../components/ApplicationIntelligence";
|
||||
@@ -387,6 +388,7 @@ function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number;
|
||||
const panels = [
|
||||
{ id: "details", title: t("workspaceJobDetails"), content: <JobDetailsSection overview={overview} onEdit={onEdit} /> },
|
||||
{ id: "tasks", title: t("workspaceChecklist"), content: <ApplicationChecklist jobId={jobId} onChanged={onReload} /> },
|
||||
{ id: "calendar", title: t("calendarTitle"), content: <ApplicationCalendarActions jobId={jobId} followUpAt={overview.followUpAt} deadline={overview.deadline} /> },
|
||||
{ id: "timeline", title: t("workspaceActivityHistory"), content: <ApplicationTimeline jobId={jobId} /> },
|
||||
{ id: "documents", title: t("workspaceDocuments"), content: <Attachments jobId={jobId} /> },
|
||||
{ id: "communication", title: t("workspaceCommunication"), content: <Correspondence jobId={jobId} jobContext={{ companyName: overview.company, jobTitle: overview.jobTitle }} /> },
|
||||
|
||||
Reference in New Issue
Block a user