fix: render markdown in expanded job descriptions (#297)
* fix: render markdown in expanded job descriptions * fix: respect markdown job description setting and harden rendering
This commit is contained in:
parent
0b22c08d7d
commit
69a10acd4f
@ -71,6 +71,8 @@ Settings gives you runtime overrides for the key parts of discovery, scoring, ta
|
||||

|
||||
|
||||
- Toggle visa sponsor badge visibility in job lists/details
|
||||
- Toggle `Render Markdown in job descriptions` to control whether expanded job descriptions show formatted headings, lists, bold text, and code blocks
|
||||
- Default: Markdown rendering is enabled
|
||||
|
||||
### Writing Style & Language
|
||||
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
import type React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
interface JobDescriptionMarkdownProps {
|
||||
className?: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SAFE_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
|
||||
|
||||
const getSafeHref = (href?: string) => {
|
||||
if (!href) return undefined;
|
||||
|
||||
try {
|
||||
const url = new URL(href, "https://job-ops.local");
|
||||
if (
|
||||
url.origin === "https://job-ops.local" &&
|
||||
!href.startsWith("http://") &&
|
||||
!href.startsWith("https://") &&
|
||||
!href.startsWith("mailto:")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return SAFE_PROTOCOLS.has(url.protocol) ? href : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const JobDescriptionMarkdown: React.FC<JobDescriptionMarkdownProps> = ({
|
||||
className,
|
||||
description,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
className ??
|
||||
"text-sm leading-relaxed text-foreground [&_h1]:text-lg [&_h1]:font-semibold [&_h2]:text-base [&_h2]:font-semibold [&_h3]:font-semibold [&_p]:my-3 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-1 [&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-background [&_pre]:p-3 [&_code]:rounded [&_code]:bg-background/80 [&_code]:px-1 [&_code]:py-0.5 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_a]:text-primary [&_a]:underline"
|
||||
}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
img: () => null,
|
||||
a: ({ children, href, ...props }) => {
|
||||
const safeHref = getSafeHref(href);
|
||||
if (!safeHref) return <span>{children}</span>;
|
||||
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
href={safeHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,3 +1,4 @@
|
||||
import { useSettings } from "@client/hooks/useSettings";
|
||||
import type { Job } from "@shared/types.js";
|
||||
import {
|
||||
ChevronUp,
|
||||
@ -9,6 +10,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { JobDescriptionMarkdown } from "@/client/components/JobDescriptionMarkdown";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -21,7 +23,7 @@ import { FitAssessment, JobHeader, TailoredSummary } from "..";
|
||||
import { KbdHint } from "../KbdHint";
|
||||
import { OpenJobListingButton } from "../OpenJobListingButton";
|
||||
import { CollapsibleSection } from "./CollapsibleSection";
|
||||
import { getPlainDescription } from "./helpers";
|
||||
import { getRenderableJobDescription } from "./helpers";
|
||||
|
||||
interface DecideModeProps {
|
||||
job: Job;
|
||||
@ -46,9 +48,10 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
||||
}) => {
|
||||
const [showDescription, setShowDescription] = useState(false);
|
||||
const jobLink = job.applicationLink || job.jobUrl;
|
||||
const { renderMarkdownInJobDescriptions } = useSettings();
|
||||
|
||||
const description = useMemo(
|
||||
() => getPlainDescription(job.jobDescription),
|
||||
() => getRenderableJobDescription(job.jobDescription),
|
||||
[job.jobDescription],
|
||||
);
|
||||
|
||||
@ -103,9 +106,13 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
||||
label={`${showDescription ? "Hide" : "View"} Full Job Description`}
|
||||
>
|
||||
<div className="rounded-xl border border-border/40 bg-muted/5 p-4 mt-2 max-h-[400px] overflow-y-auto shadow-inner">
|
||||
<p className="text-xs text-muted-foreground/90 whitespace-pre-wrap leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
{renderMarkdownInJobDescriptions ? (
|
||||
<JobDescriptionMarkdown description={description} />
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground/90 whitespace-pre-wrap leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
@ -12,6 +12,11 @@ import { DiscoveredPanel } from "./DiscoveredPanel";
|
||||
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||
renderWithQueryClient(ui);
|
||||
|
||||
const mockSettings = {
|
||||
showSponsorInfo: false,
|
||||
renderMarkdownInJobDescriptions: true,
|
||||
};
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||
return {
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
||||
@ -45,7 +50,7 @@ vi.mock("@/components/ui/dropdown-menu", () => {
|
||||
});
|
||||
|
||||
vi.mock("@client/hooks/useSettings", () => ({
|
||||
useSettings: () => ({ showSponsorInfo: false }),
|
||||
useSettings: () => mockSettings,
|
||||
}));
|
||||
|
||||
vi.mock("@client/api", () => ({
|
||||
@ -91,6 +96,8 @@ vi.mock("sonner", () => ({
|
||||
describe("DiscoveredPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSettings.showSponsorInfo = false;
|
||||
mockSettings.renderMarkdownInJobDescriptions = true;
|
||||
});
|
||||
|
||||
it("re-runs the fit assessment from the menu", async () => {
|
||||
@ -162,4 +169,64 @@ describe("DiscoveredPanel", () => {
|
||||
screen.getByRole("link", { name: /open job listing/i }),
|
||||
).toHaveAttribute("href", "https://example.com/jobs/visit-me");
|
||||
});
|
||||
|
||||
it("renders markdown formatting in the expanded job description when markdown rendering is enabled", () => {
|
||||
const job = createJob({
|
||||
jobDescription:
|
||||
"# Responsibilities\n\n- Build APIs\n- Improve reliability",
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DiscoveredPanel
|
||||
job={job}
|
||||
onJobUpdated={vi.fn()}
|
||||
onJobMoved={vi.fn()}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /view full job description/i }),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Responsibilities" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Build APIs")).toBeInTheDocument();
|
||||
expect(screen.queryByText("# Responsibilities")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders raw markdown in the expanded job description when markdown rendering is disabled", () => {
|
||||
mockSettings.renderMarkdownInJobDescriptions = false;
|
||||
|
||||
const job = createJob({
|
||||
jobDescription:
|
||||
"# Responsibilities\n\n- Build APIs\n- Improve reliability",
|
||||
});
|
||||
|
||||
const rendered = render(
|
||||
<MemoryRouter>
|
||||
<DiscoveredPanel
|
||||
job={job}
|
||||
onJobUpdated={vi.fn()}
|
||||
onJobMoved={vi.fn()}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /view full job description/i }),
|
||||
);
|
||||
|
||||
const rawDescription = rendered.container.querySelector(
|
||||
"p.whitespace-pre-wrap",
|
||||
);
|
||||
expect(rawDescription?.textContent).toBe(
|
||||
"# Responsibilities\n\n- Build APIs\n- Improve reliability",
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Responsibilities" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,9 +1 @@
|
||||
import { stripHtml } from "@/lib/utils";
|
||||
|
||||
export const getPlainDescription = (jobDescription?: string | null) => {
|
||||
if (!jobDescription) return "No description available.";
|
||||
if (jobDescription.includes("<") && jobDescription.includes(">")) {
|
||||
return stripHtml(jobDescription);
|
||||
}
|
||||
return jobDescription;
|
||||
};
|
||||
export { getRenderableJobDescription } from "@client/lib/jobDescription";
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { createAppSettings } from "@shared/testing/factories.js";
|
||||
import { act, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../api";
|
||||
@ -15,8 +16,15 @@ describe("useSettings", () => {
|
||||
});
|
||||
|
||||
it("fetches settings on mount if not already cached", async () => {
|
||||
const mockSettings = { showSponsorInfo: false };
|
||||
vi.mocked(api.getSettings).mockResolvedValue(mockSettings as any);
|
||||
const mockSettings = createAppSettings({
|
||||
showSponsorInfo: { value: false, default: true, override: false },
|
||||
renderMarkdownInJobDescriptions: {
|
||||
value: false,
|
||||
default: true,
|
||||
override: false,
|
||||
},
|
||||
});
|
||||
vi.mocked(api.getSettings).mockResolvedValue(mockSettings);
|
||||
|
||||
const { result } = renderHookWithQueryClient(() => useSettings());
|
||||
|
||||
@ -28,6 +36,7 @@ describe("useSettings", () => {
|
||||
});
|
||||
|
||||
expect(result.current.showSponsorInfo).toBe(false);
|
||||
expect(result.current.renderMarkdownInJobDescriptions).toBe(false);
|
||||
expect(api.getSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@ -39,15 +48,23 @@ describe("useSettings", () => {
|
||||
await waitFor(() => {
|
||||
// settings is null, so showSponsorInfo should default to true
|
||||
expect(result.current.showSponsorInfo).toBe(true);
|
||||
expect(result.current.renderMarkdownInJobDescriptions).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("provides a refresh function that updates settings", async () => {
|
||||
const initialSettings = { showSponsorInfo: true };
|
||||
const updatedSettings = { showSponsorInfo: false };
|
||||
const initialSettings = createAppSettings();
|
||||
const updatedSettings = createAppSettings({
|
||||
showSponsorInfo: { value: false, default: true, override: false },
|
||||
renderMarkdownInJobDescriptions: {
|
||||
value: false,
|
||||
default: true,
|
||||
override: false,
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(api.getSettings).mockResolvedValueOnce(initialSettings as any);
|
||||
vi.mocked(api.getSettings).mockResolvedValueOnce(updatedSettings as any);
|
||||
vi.mocked(api.getSettings).mockResolvedValueOnce(initialSettings);
|
||||
vi.mocked(api.getSettings).mockResolvedValueOnce(updatedSettings);
|
||||
|
||||
const { result } = renderHookWithQueryClient(() => useSettings());
|
||||
|
||||
@ -66,6 +83,7 @@ describe("useSettings", () => {
|
||||
|
||||
expect(refreshed).toEqual(updatedSettings);
|
||||
expect(result.current.showSponsorInfo).toBe(false);
|
||||
expect(result.current.renderMarkdownInJobDescriptions).toBe(false);
|
||||
});
|
||||
|
||||
it("handles errors when fetching settings", async () => {
|
||||
|
||||
@ -26,7 +26,9 @@ export function useSettings() {
|
||||
settings,
|
||||
error: error ?? null,
|
||||
isLoading: isLoading || (!!isFetching && !settings && !error),
|
||||
showSponsorInfo: settings?.showSponsorInfo ?? true,
|
||||
showSponsorInfo: settings?.showSponsorInfo?.value ?? true,
|
||||
renderMarkdownInJobDescriptions:
|
||||
settings?.renderMarkdownInJobDescriptions?.value ?? true,
|
||||
refreshSettings,
|
||||
};
|
||||
}
|
||||
|
||||
20
orchestrator/src/client/lib/jobDescription.ts
Normal file
20
orchestrator/src/client/lib/jobDescription.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { stripHtml } from "@/lib/utils";
|
||||
|
||||
export const getRenderableJobDescription = (jobDescription?: string | null) => {
|
||||
if (!jobDescription) return "No description available.";
|
||||
|
||||
const plainText =
|
||||
jobDescription.includes("<") && jobDescription.includes(">")
|
||||
? stripHtml(jobDescription)
|
||||
: jobDescription;
|
||||
|
||||
const normalizedLineBreaks = plainText.replace(/\r\n/g, "\n");
|
||||
if (
|
||||
normalizedLineBreaks.includes("\\n") &&
|
||||
!normalizedLineBreaks.includes("\n")
|
||||
) {
|
||||
return normalizedLineBreaks.replace(/\\n/g, "\n");
|
||||
}
|
||||
|
||||
return normalizedLineBreaks;
|
||||
};
|
||||
@ -73,6 +73,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
||||
rxresumeMode: "v5",
|
||||
rxresumeBaseResumeId: null,
|
||||
showSponsorInfo: null,
|
||||
renderMarkdownInJobDescriptions: null,
|
||||
chatStyleTone: "",
|
||||
chatStyleFormality: "",
|
||||
chatStyleConstraints: "",
|
||||
@ -149,6 +150,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
||||
rxresumeMode: null,
|
||||
rxresumeBaseResumeId: null,
|
||||
showSponsorInfo: null,
|
||||
renderMarkdownInJobDescriptions: null,
|
||||
chatStyleTone: null,
|
||||
chatStyleFormality: null,
|
||||
chatStyleConstraints: null,
|
||||
@ -192,6 +194,8 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
||||
rxresumeMode: data.rxresumeMode.override ?? data.rxresumeMode.value,
|
||||
rxresumeBaseResumeId: data.rxresumeBaseResumeId,
|
||||
showSponsorInfo: data.showSponsorInfo.override,
|
||||
renderMarkdownInJobDescriptions:
|
||||
data.renderMarkdownInJobDescriptions.override,
|
||||
chatStyleTone: data.chatStyleTone.override ?? "",
|
||||
chatStyleFormality: data.chatStyleFormality.override ?? "",
|
||||
chatStyleConstraints: data.chatStyleConstraints.override ?? "",
|
||||
@ -296,8 +300,14 @@ const getDerivedSettings = (settings: AppSettings | null) => {
|
||||
default: settings?.jobCompleteWebhookUrl?.default ?? "",
|
||||
},
|
||||
display: {
|
||||
effective: settings?.showSponsorInfo?.value ?? true,
|
||||
default: settings?.showSponsorInfo?.default ?? true,
|
||||
showSponsorInfo: {
|
||||
effective: settings?.showSponsorInfo?.value ?? true,
|
||||
default: settings?.showSponsorInfo?.default ?? true,
|
||||
},
|
||||
renderMarkdownInJobDescriptions: {
|
||||
effective: settings?.renderMarkdownInJobDescriptions?.value ?? true,
|
||||
default: settings?.renderMarkdownInJobDescriptions?.default ?? true,
|
||||
},
|
||||
},
|
||||
chat: {
|
||||
tone: {
|
||||
@ -853,7 +863,14 @@ export const SettingsPage: React.FC = () => {
|
||||
...(dirtyFields.rxresumeBaseResumeId
|
||||
? { rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId) }
|
||||
: {}),
|
||||
showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default),
|
||||
showSponsorInfo: nullIfSame(
|
||||
data.showSponsorInfo,
|
||||
display.showSponsorInfo.default,
|
||||
),
|
||||
renderMarkdownInJobDescriptions: nullIfSame(
|
||||
data.renderMarkdownInJobDescriptions,
|
||||
display.renderMarkdownInJobDescriptions.default,
|
||||
),
|
||||
chatStyleTone: normalizeString(data.chatStyleTone),
|
||||
chatStyleFormality: normalizeString(data.chatStyleFormality),
|
||||
chatStyleConstraints: normalizeString(data.chatStyleConstraints),
|
||||
|
||||
@ -10,6 +10,15 @@ import { JobDetailPanel } from "./JobDetailPanel";
|
||||
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||
renderWithQueryClient(ui);
|
||||
|
||||
const mockSettings = {
|
||||
settings: null,
|
||||
error: null,
|
||||
isLoading: false,
|
||||
showSponsorInfo: true,
|
||||
renderMarkdownInJobDescriptions: true,
|
||||
refreshSettings: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||
return {
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
||||
@ -51,6 +60,10 @@ vi.mock("@client/components", () => ({
|
||||
TailoredSummary: () => <div data-testid="tailored-summary" />,
|
||||
}));
|
||||
|
||||
vi.mock("@client/hooks/useSettings", () => ({
|
||||
useSettings: () => mockSettings,
|
||||
}));
|
||||
|
||||
vi.mock("@client/components/ReadyPanel", () => ({
|
||||
ReadyPanel: ({ onEditDescription }: { onEditDescription?: () => void }) => (
|
||||
<div>
|
||||
@ -146,6 +159,7 @@ const renderJobDetailPanel = async (
|
||||
describe("JobDetailPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSettings.renderMarkdownInJobDescriptions = true;
|
||||
});
|
||||
|
||||
it("renders the discovered panel when active tab is discovered", async () => {
|
||||
@ -188,6 +202,51 @@ describe("JobDetailPanel", () => {
|
||||
expect(screen.getByText("Hello world")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders markdown in the description tab when enabled", async () => {
|
||||
await renderJobDetailPanel({
|
||||
activeTab: "all",
|
||||
activeJobs: [],
|
||||
selectedJob: createJob({
|
||||
jobDescription: "# Responsibilities\n\n- Build APIs",
|
||||
}),
|
||||
onSelectJobId: vi.fn(),
|
||||
onJobUpdated: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole("tab", { name: /description/i }));
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Responsibilities" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("# Responsibilities")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders raw markdown in the description tab when disabled", async () => {
|
||||
mockSettings.renderMarkdownInJobDescriptions = false;
|
||||
|
||||
const rendered = await renderJobDetailPanel({
|
||||
activeTab: "all",
|
||||
activeJobs: [],
|
||||
selectedJob: createJob({
|
||||
jobDescription: "# Responsibilities\n\n- Build APIs",
|
||||
}),
|
||||
onSelectJobId: vi.fn(),
|
||||
onJobUpdated: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole("tab", { name: /description/i }));
|
||||
|
||||
const rawDescription = rendered.container.querySelector(
|
||||
"div.whitespace-pre-wrap",
|
||||
);
|
||||
expect(rawDescription?.textContent).toBe(
|
||||
"# Responsibilities\n\n- Build APIs",
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Responsibilities" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves an edited description", async () => {
|
||||
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(api.updateJob).mockResolvedValue(undefined as any);
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
useSkipJobMutation,
|
||||
} from "@client/hooks/queries/useJobMutations";
|
||||
import { useProfile } from "@client/hooks/useProfile";
|
||||
import { useSettings } from "@client/hooks/useSettings";
|
||||
import type { Job, JobListItem } from "@shared/types.js";
|
||||
import {
|
||||
CheckCircle2,
|
||||
@ -28,9 +29,9 @@ import {
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { toast } from "sonner";
|
||||
import { JobDescriptionMarkdown } from "@/client/components/JobDescriptionMarkdown";
|
||||
import { getRenderableJobDescription } from "@/client/lib/jobDescription";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -46,7 +47,6 @@ import {
|
||||
copyTextToClipboard,
|
||||
formatJobForWebhook,
|
||||
safeFilenamePart,
|
||||
stripHtml,
|
||||
} from "@/lib/utils";
|
||||
import type { FilterTab } from "./constants";
|
||||
|
||||
@ -82,6 +82,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
const skipJobMutation = useSkipJobMutation();
|
||||
|
||||
const { personName } = useProfile();
|
||||
const { renderMarkdownInJobDescriptions } = useSettings();
|
||||
|
||||
const handleTailoringDirtyChange = useCallback(
|
||||
(isDirty: boolean) => {
|
||||
@ -105,10 +106,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
}, [onPauseRefreshChange]);
|
||||
|
||||
const description = useMemo(() => {
|
||||
if (!selectedJob?.jobDescription) return "No description available.";
|
||||
const jd = selectedJob.jobDescription;
|
||||
if (jd.includes("<") && jd.includes(">")) return stripHtml(jd);
|
||||
return jd;
|
||||
return getRenderableJobDescription(selectedJob?.jobDescription);
|
||||
}, [selectedJob]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -783,11 +781,11 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : renderMarkdownInJobDescriptions ? (
|
||||
<JobDescriptionMarkdown description={description} />
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap leading-relaxed">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{description}
|
||||
</ReactMarkdown>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -21,10 +21,7 @@ export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const {
|
||||
default: defaultShowSponsorInfo,
|
||||
effective: effectiveShowSponsorInfo,
|
||||
} = values;
|
||||
const { showSponsorInfo, renderMarkdownInJobDescriptions } = values;
|
||||
const { control } = useFormContext<UpdateSettingsInput>();
|
||||
|
||||
return (
|
||||
@ -41,7 +38,7 @@ export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id="showSponsorInfo"
|
||||
checked={field.value ?? defaultShowSponsorInfo}
|
||||
checked={field.value ?? showSponsorInfo.default}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(
|
||||
checked === "indeterminate" ? null : checked === true,
|
||||
@ -68,17 +65,77 @@ export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Controller
|
||||
name="renderMarkdownInJobDescriptions"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id="renderMarkdownInJobDescriptions"
|
||||
checked={
|
||||
field.value ?? renderMarkdownInJobDescriptions.default
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(
|
||||
checked === "indeterminate" ? null : checked === true,
|
||||
);
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="renderMarkdownInJobDescriptions"
|
||||
className="text-sm font-medium leading-none cursor-pointer"
|
||||
>
|
||||
Render Markdown in job descriptions
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Show headings, bold text, lists, and code blocks as formatted
|
||||
content when you expand a full job description. Turn this off if
|
||||
you prefer the raw source text.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Effective</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Sponsor info effective
|
||||
</div>
|
||||
<div className="break-words font-mono text-xs">
|
||||
{effectiveShowSponsorInfo ? "Enabled" : "Disabled"}
|
||||
{showSponsorInfo.effective ? "Enabled" : "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Default</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Sponsor info default
|
||||
</div>
|
||||
<div className="break-words font-mono text-xs font-semibold">
|
||||
{defaultShowSponsorInfo ? "Enabled" : "Disabled"}
|
||||
{showSponsorInfo.default ? "Enabled" : "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Markdown rendering effective
|
||||
</div>
|
||||
<div className="break-words font-mono text-xs">
|
||||
{renderMarkdownInJobDescriptions.effective
|
||||
? "Enabled"
|
||||
: "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Markdown rendering default
|
||||
</div>
|
||||
<div className="break-words font-mono text-xs font-semibold">
|
||||
{renderMarkdownInJobDescriptions.default
|
||||
? "Enabled"
|
||||
: "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -18,7 +18,10 @@ export type ModelValues = EffectiveDefault<string> & {
|
||||
};
|
||||
|
||||
export type WebhookValues = EffectiveDefault<string>;
|
||||
export type DisplayValues = EffectiveDefault<boolean>;
|
||||
export type DisplayValues = {
|
||||
showSponsorInfo: EffectiveDefault<boolean>;
|
||||
renderMarkdownInJobDescriptions: EffectiveDefault<boolean>;
|
||||
};
|
||||
export type ChatValues = {
|
||||
tone: EffectiveDefault<string>;
|
||||
formality: EffectiveDefault<string>;
|
||||
|
||||
@ -21,6 +21,7 @@ export const DEMO_DEFAULT_SETTINGS: DemoDefaultSettings = {
|
||||
"full stack engineer",
|
||||
]),
|
||||
showSponsorInfo: "1",
|
||||
renderMarkdownInJobDescriptions: "1",
|
||||
backupEnabled: "0",
|
||||
backupHour: "2",
|
||||
backupMaxCount: "5",
|
||||
|
||||
@ -57,6 +57,12 @@ describe("settingsRegistry helpers", () => {
|
||||
expect(settingsRegistry.showSponsorInfo.parse("false")).toBe(false);
|
||||
expect(settingsRegistry.showSponsorInfo.parse("")).toBeNull();
|
||||
expect(settingsRegistry.showSponsorInfo.parse(undefined)).toBeNull();
|
||||
expect(settingsRegistry.renderMarkdownInJobDescriptions.parse("1")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(settingsRegistry.renderMarkdownInJobDescriptions.parse("0")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes bit bools correctly", () => {
|
||||
@ -64,6 +70,12 @@ describe("settingsRegistry helpers", () => {
|
||||
expect(settingsRegistry.showSponsorInfo.serialize(false)).toBe("0");
|
||||
expect(settingsRegistry.showSponsorInfo.serialize(null)).toBeNull();
|
||||
expect(settingsRegistry.showSponsorInfo.serialize(undefined)).toBeNull();
|
||||
expect(
|
||||
settingsRegistry.renderMarkdownInJobDescriptions.serialize(true),
|
||||
).toBe("1");
|
||||
expect(
|
||||
settingsRegistry.renderMarkdownInJobDescriptions.serialize(false),
|
||||
).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -371,6 +371,13 @@ export const settingsRegistry = {
|
||||
parse: parseBitBoolOrNull,
|
||||
serialize: serializeBitBool,
|
||||
},
|
||||
renderMarkdownInJobDescriptions: {
|
||||
kind: "typed" as const,
|
||||
schema: z.boolean(),
|
||||
default: (): boolean => true,
|
||||
parse: parseBitBoolOrNull,
|
||||
serialize: serializeBitBool,
|
||||
},
|
||||
chatStyleTone: {
|
||||
kind: "typed" as const,
|
||||
schema: z.string().trim().max(100),
|
||||
|
||||
@ -186,6 +186,11 @@ export const createAppSettings = (
|
||||
override: null,
|
||||
},
|
||||
showSponsorInfo: { value: true, default: true, override: null },
|
||||
renderMarkdownInJobDescriptions: {
|
||||
value: true,
|
||||
default: true,
|
||||
override: null,
|
||||
},
|
||||
chatStyleTone: {
|
||||
value: "professional",
|
||||
default: "professional",
|
||||
|
||||
@ -162,6 +162,7 @@ export interface AppSettings {
|
||||
jobspyResultsWanted: Resolved<number>;
|
||||
jobspyCountryIndeed: Resolved<string>;
|
||||
showSponsorInfo: Resolved<boolean>;
|
||||
renderMarkdownInJobDescriptions: Resolved<boolean>;
|
||||
chatStyleTone: Resolved<string>;
|
||||
chatStyleFormality: Resolved<string>;
|
||||
chatStyleConstraints: Resolved<string>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user