feat: quick-capture bookmarklet
One-click job capture from any posting, reusing the existing jobimport/preview parser. - AddJobModal accepts initialUrl and auto-imports once on open - App reads a /?add=<encoded url> param, opens Add Job pre-filled, and strips the param from the address bar - QuickCaptureCard in Settings offers a draggable bookmarklet (href set via ref since React blocks javascript: URLs) plus copyable code - EN/NB translations; README feature note - 2 frontend tests; full suite green (22 suites / 50 tests) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ Job Tracker is a simple, self-hosted app for tracking job applications with a Re
|
||||
- History/event trail per application (created, status changes, follow-up set, delete/restore)
|
||||
- Export jobs to JSON/CSV + daily scheduled JSON export
|
||||
- Optional “job import” preview from supported job sites (plugins) + optional translation to English
|
||||
- Quick-capture bookmarklet (Settings): opens `/?add=<page url>` to pre-fill Add Job from any posting
|
||||
- Optional local AI service for short/full descriptions
|
||||
- Optional Google sign-in (Google ID tokens) to protect the API
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
const compactHeaderActions = useMediaQuery("(max-width:767.95px)");
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [captureUrl, setCaptureUrl] = useState<string | undefined>(undefined);
|
||||
const [quickOpen, setQuickOpen] = useState(false);
|
||||
const [refreshToken, setRefreshToken] = useState(0);
|
||||
const [requireAuth, setRequireAuth] = useState<boolean | null>(null);
|
||||
@@ -124,6 +125,17 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
useEffect(() => {
|
||||
api.get<AuthConfig>("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false));
|
||||
}, []);
|
||||
|
||||
// Quick-capture bookmarklet target: /?add=<encoded job url> opens Add Job pre-filled.
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const add = params.get("add");
|
||||
if (!add) return;
|
||||
setCaptureUrl(add);
|
||||
setAddOpen(true);
|
||||
params.delete("add");
|
||||
navigate({ pathname: location.pathname, search: params.toString() }, { replace: true });
|
||||
}, [location.search, location.pathname, navigate]);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
api.get<MeResponse>("/auth/me")
|
||||
@@ -288,7 +300,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
</AppShell>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<AddJobModal open={addOpen} onClose={() => setAddOpen(false)} onCreated={() => { setRefreshToken((t) => t + 1); }} />
|
||||
<AddJobModal open={addOpen} initialUrl={captureUrl} onClose={() => { setAddOpen(false); setCaptureUrl(undefined); }} onCreated={() => { setRefreshToken((t) => t + 1); }} />
|
||||
<QuickCommandDialog open={quickOpen} onClose={() => setQuickOpen(false)} onNavigate={(to) => navigate(to)} onOpenAddJob={() => setAddOpen(true)} />
|
||||
</Suspense>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||
|
||||
@@ -37,6 +37,7 @@ interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
initialUrl?: string;
|
||||
}
|
||||
|
||||
type DuplicateCandidate = {
|
||||
@@ -97,7 +98,7 @@ function normalizeLanguage(value?: string | null) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Props) {
|
||||
const { toast } = useToast();
|
||||
const { t, language } = useI18n();
|
||||
|
||||
@@ -137,6 +138,21 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
setCompanies(cachedCompanies);
|
||||
}, [cachedCompanies]);
|
||||
|
||||
// Quick-capture: when opened with a URL (from the bookmarklet), prefill and auto-import once.
|
||||
const autoImportedUrlRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
autoImportedUrlRef.current = null;
|
||||
return;
|
||||
}
|
||||
const url = initialUrl?.trim();
|
||||
if (!url || autoImportedUrlRef.current === url) return;
|
||||
autoImportedUrlRef.current = url;
|
||||
setJobUrl(url);
|
||||
void importFromUrl(url);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, initialUrl]);
|
||||
|
||||
const resetForm = () => {
|
||||
setCompany(null);
|
||||
setCompanyInput("");
|
||||
@@ -223,16 +239,17 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const importFromUrl = async () => {
|
||||
const importFromUrl = async (urlArg?: string) => {
|
||||
if (importing) return;
|
||||
if (!jobUrl.trim()) {
|
||||
const url = (urlArg ?? jobUrl).trim();
|
||||
if (!url) {
|
||||
toast(t("addJobModalPasteUrlFirst"), "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const res = await api.post<JobImportResult>("/jobimport/preview", { url: jobUrl.trim() });
|
||||
const res = await api.post<JobImportResult>("/jobimport/preview", { url });
|
||||
const r = res.data;
|
||||
if (!r?.success) throw new Error(r?.error || t("addJobModalImportFailed"));
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
|
||||
import { Box, Paper, TextField, Typography } from "@mui/material";
|
||||
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useToast } from "../toast";
|
||||
|
||||
/** The bookmarklet opens the app at /?add=<current page url>, which triggers quick-capture. */
|
||||
function buildBookmarklet(origin: string): string {
|
||||
// Kept as a single minified expression; opens a small popup so the user's tab is undisturbed.
|
||||
return `javascript:void(window.open('${origin}/?add='+encodeURIComponent(location.href),'jobbjakt','width=520,height=720'))`;
|
||||
}
|
||||
|
||||
export default function QuickCaptureCard() {
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const linkRef = useRef<HTMLAnchorElement>(null);
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
const bookmarklet = buildBookmarklet(origin);
|
||||
|
||||
// React refuses to render javascript: hrefs, so set it directly on the DOM node.
|
||||
useEffect(() => {
|
||||
if (linkRef.current) linkRef.current.setAttribute("href", bookmarklet);
|
||||
}, [bookmarklet]);
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsQuickCaptureTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("settingsQuickCaptureSubtitle")}</Typography>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap", mb: 1.5 }}>
|
||||
<Box
|
||||
component="a"
|
||||
ref={linkRef}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
// Clicking (vs dragging) shouldn't navigate; the value is meant to be dragged to the bar.
|
||||
e.preventDefault();
|
||||
toast(t("settingsQuickCaptureDragHint"), "info");
|
||||
}}
|
||||
sx={{
|
||||
display: "inline-block",
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderRadius: 2,
|
||||
border: "1px solid",
|
||||
borderColor: "primary.main",
|
||||
color: "primary.main",
|
||||
fontWeight: 800,
|
||||
textDecoration: "none",
|
||||
cursor: "grab",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{t("settingsQuickCaptureButton")}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("settingsQuickCaptureDragHint")}</Typography>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
label={t("settingsQuickCaptureManual")}
|
||||
value={bookmarklet}
|
||||
fullWidth
|
||||
size="small"
|
||||
InputProps={{ readOnly: true }}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import ImportExportJobs from "./ImportExportJobs";
|
||||
import GoogleAuthCard from "./GoogleAuthCard";
|
||||
import RulesSettingsCard from "./RulesSettingsCard";
|
||||
import BackupCard from "./BackupCard";
|
||||
import QuickCaptureCard from "./QuickCaptureCard";
|
||||
import AuthStatusCard from "./AuthStatusCard";
|
||||
import { ThemeModePref } from "../themePrefs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
@@ -297,6 +298,8 @@ export default function SettingsView({
|
||||
|
||||
<ImportExportJobs />
|
||||
</Paper>
|
||||
|
||||
<QuickCaptureCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
|
||||
@@ -157,6 +157,11 @@ export const translations = {
|
||||
settingsOpenReminderInbox: "Open reminders",
|
||||
settingsReviewJobs: "Review jobs",
|
||||
settingsNotificationsTitle: "Notification settings",
|
||||
settingsQuickCaptureTitle: "Quick capture bookmarklet",
|
||||
settingsQuickCaptureSubtitle: "Drag this button to your bookmarks bar. On any job posting, click it to open Add Job pre-filled from that page.",
|
||||
settingsQuickCaptureButton: "+ Save to Jobbjakt",
|
||||
settingsQuickCaptureDragHint: "Drag me to your bookmarks bar",
|
||||
settingsQuickCaptureManual: "Or copy the bookmarklet code",
|
||||
settingsNotificationsBody: "Choose which reminders should show up in your workflow. SMTP delivery can be checked from the system page.",
|
||||
settingsNotificationsDelivery: "SMTP delivery and test mail live under Admin → System → Settings.",
|
||||
settingsNotificationsFollowUpReminders: "Email reminders for follow-ups",
|
||||
@@ -1096,6 +1101,11 @@ export const translations = {
|
||||
settingsOpenReminderInbox: "Åpne påminnelser",
|
||||
settingsReviewJobs: "Gå til jobber",
|
||||
settingsNotificationsTitle: "Varslingsinnstillinger",
|
||||
settingsQuickCaptureTitle: "Hurtiglagring (bokmerke)",
|
||||
settingsQuickCaptureButton: "+ Lagre til Jobbjakt",
|
||||
settingsQuickCaptureSubtitle: "Dra denne knappen til bokmerkelinjen. På en stillingsannonse klikker du på den for å åpne Legg til jobb forhåndsutfylt fra siden.",
|
||||
settingsQuickCaptureDragHint: "Dra meg til bokmerkelinjen",
|
||||
settingsQuickCaptureManual: "Eller kopier bokmerkekoden",
|
||||
settingsNotificationsBody: "Velg hvilke påminnelser som skal vises i arbeidsflyten din. SMTP-levering kan kontrolleres fra systemsiden.",
|
||||
settingsNotificationsDelivery: "SMTP-levering og test-epost ligger under Admin → System → Innstillinger.",
|
||||
settingsNotificationsFollowUpReminders: "E-postpåminnelser for oppfølginger",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import { api } from './api';
|
||||
|
||||
// Avoid pulling the date-fns v4 ESM adapter into Jest; the picker isn't under test here.
|
||||
jest.mock('@mui/x-date-pickers/DatePicker', () => ({
|
||||
DatePicker: ({ label }: any) => <div>{label}</div>,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import AddJobModal from './components/AddJobModal';
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
get: jest.fn(() => Promise.resolve({ data: [] })),
|
||||
post: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
put: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
patch: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
delete: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
getApiErrorMessage: jest.fn(() => 'error'),
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderModal(initialUrl?: string) {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<AddJobModal open initialUrl={initialUrl} onClose={() => {}} onCreated={() => {}} />
|
||||
</I18nProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedApi.get.mockResolvedValue({ data: [] } as any);
|
||||
mockedApi.post.mockImplementation((url: string) => {
|
||||
if (url === '/jobimport/preview') {
|
||||
return Promise.resolve({ data: { success: true, title: 'Imported Backend Role', company: 'Acme', location: 'Oslo', description: 'desc', tags: ['C#'] } } as any);
|
||||
}
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
test('auto-imports from initialUrl and prefills the form', async () => {
|
||||
renderModal('https://example.com/jobs/123');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/jobimport/preview', { url: 'https://example.com/jobs/123' });
|
||||
});
|
||||
|
||||
expect(await screen.findByDisplayValue('Imported Backend Role')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not auto-import when no initialUrl is given', async () => {
|
||||
renderModal(undefined);
|
||||
|
||||
// Wait for the modal to render, then confirm no import was triggered.
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalledWith('/jobimport/preview', expect.anything());
|
||||
});
|
||||
Reference in New Issue
Block a user