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 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
|
### 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 type { Job } from "@shared/types.js";
|
||||||
import {
|
import {
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
@ -9,6 +10,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { JobDescriptionMarkdown } from "@/client/components/JobDescriptionMarkdown";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@ -21,7 +23,7 @@ import { FitAssessment, JobHeader, TailoredSummary } from "..";
|
|||||||
import { KbdHint } from "../KbdHint";
|
import { KbdHint } from "../KbdHint";
|
||||||
import { OpenJobListingButton } from "../OpenJobListingButton";
|
import { OpenJobListingButton } from "../OpenJobListingButton";
|
||||||
import { CollapsibleSection } from "./CollapsibleSection";
|
import { CollapsibleSection } from "./CollapsibleSection";
|
||||||
import { getPlainDescription } from "./helpers";
|
import { getRenderableJobDescription } from "./helpers";
|
||||||
|
|
||||||
interface DecideModeProps {
|
interface DecideModeProps {
|
||||||
job: Job;
|
job: Job;
|
||||||
@ -46,9 +48,10 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [showDescription, setShowDescription] = useState(false);
|
const [showDescription, setShowDescription] = useState(false);
|
||||||
const jobLink = job.applicationLink || job.jobUrl;
|
const jobLink = job.applicationLink || job.jobUrl;
|
||||||
|
const { renderMarkdownInJobDescriptions } = useSettings();
|
||||||
|
|
||||||
const description = useMemo(
|
const description = useMemo(
|
||||||
() => getPlainDescription(job.jobDescription),
|
() => getRenderableJobDescription(job.jobDescription),
|
||||||
[job.jobDescription],
|
[job.jobDescription],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -103,9 +106,13 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
|||||||
label={`${showDescription ? "Hide" : "View"} Full Job Description`}
|
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">
|
<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">
|
{renderMarkdownInJobDescriptions ? (
|
||||||
{description}
|
<JobDescriptionMarkdown description={description} />
|
||||||
</p>
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground/90 whitespace-pre-wrap leading-relaxed">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -12,6 +12,11 @@ import { DiscoveredPanel } from "./DiscoveredPanel";
|
|||||||
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||||
renderWithQueryClient(ui);
|
renderWithQueryClient(ui);
|
||||||
|
|
||||||
|
const mockSettings = {
|
||||||
|
showSponsorInfo: false,
|
||||||
|
renderMarkdownInJobDescriptions: true,
|
||||||
|
};
|
||||||
|
|
||||||
vi.mock("@/components/ui/dropdown-menu", () => {
|
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||||
return {
|
return {
|
||||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
||||||
@ -45,7 +50,7 @@ vi.mock("@/components/ui/dropdown-menu", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("@client/hooks/useSettings", () => ({
|
vi.mock("@client/hooks/useSettings", () => ({
|
||||||
useSettings: () => ({ showSponsorInfo: false }),
|
useSettings: () => mockSettings,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@client/api", () => ({
|
vi.mock("@client/api", () => ({
|
||||||
@ -91,6 +96,8 @@ vi.mock("sonner", () => ({
|
|||||||
describe("DiscoveredPanel", () => {
|
describe("DiscoveredPanel", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockSettings.showSponsorInfo = false;
|
||||||
|
mockSettings.renderMarkdownInJobDescriptions = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("re-runs the fit assessment from the menu", async () => {
|
it("re-runs the fit assessment from the menu", async () => {
|
||||||
@ -162,4 +169,64 @@ describe("DiscoveredPanel", () => {
|
|||||||
screen.getByRole("link", { name: /open job listing/i }),
|
screen.getByRole("link", { name: /open job listing/i }),
|
||||||
).toHaveAttribute("href", "https://example.com/jobs/visit-me");
|
).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 { getRenderableJobDescription } from "@client/lib/jobDescription";
|
||||||
|
|
||||||
export const getPlainDescription = (jobDescription?: string | null) => {
|
|
||||||
if (!jobDescription) return "No description available.";
|
|
||||||
if (jobDescription.includes("<") && jobDescription.includes(">")) {
|
|
||||||
return stripHtml(jobDescription);
|
|
||||||
}
|
|
||||||
return jobDescription;
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { createAppSettings } from "@shared/testing/factories.js";
|
||||||
import { act, waitFor } from "@testing-library/react";
|
import { act, waitFor } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
@ -15,8 +16,15 @@ describe("useSettings", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("fetches settings on mount if not already cached", async () => {
|
it("fetches settings on mount if not already cached", async () => {
|
||||||
const mockSettings = { showSponsorInfo: false };
|
const mockSettings = createAppSettings({
|
||||||
vi.mocked(api.getSettings).mockResolvedValue(mockSettings as any);
|
showSponsorInfo: { value: false, default: true, override: false },
|
||||||
|
renderMarkdownInJobDescriptions: {
|
||||||
|
value: false,
|
||||||
|
default: true,
|
||||||
|
override: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(api.getSettings).mockResolvedValue(mockSettings);
|
||||||
|
|
||||||
const { result } = renderHookWithQueryClient(() => useSettings());
|
const { result } = renderHookWithQueryClient(() => useSettings());
|
||||||
|
|
||||||
@ -28,6 +36,7 @@ describe("useSettings", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.showSponsorInfo).toBe(false);
|
expect(result.current.showSponsorInfo).toBe(false);
|
||||||
|
expect(result.current.renderMarkdownInJobDescriptions).toBe(false);
|
||||||
expect(api.getSettings).toHaveBeenCalledTimes(1);
|
expect(api.getSettings).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -39,15 +48,23 @@ describe("useSettings", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// settings is null, so showSponsorInfo should default to true
|
// settings is null, so showSponsorInfo should default to true
|
||||||
expect(result.current.showSponsorInfo).toBe(true);
|
expect(result.current.showSponsorInfo).toBe(true);
|
||||||
|
expect(result.current.renderMarkdownInJobDescriptions).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("provides a refresh function that updates settings", async () => {
|
it("provides a refresh function that updates settings", async () => {
|
||||||
const initialSettings = { showSponsorInfo: true };
|
const initialSettings = createAppSettings();
|
||||||
const updatedSettings = { showSponsorInfo: false };
|
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(initialSettings);
|
||||||
vi.mocked(api.getSettings).mockResolvedValueOnce(updatedSettings as any);
|
vi.mocked(api.getSettings).mockResolvedValueOnce(updatedSettings);
|
||||||
|
|
||||||
const { result } = renderHookWithQueryClient(() => useSettings());
|
const { result } = renderHookWithQueryClient(() => useSettings());
|
||||||
|
|
||||||
@ -66,6 +83,7 @@ describe("useSettings", () => {
|
|||||||
|
|
||||||
expect(refreshed).toEqual(updatedSettings);
|
expect(refreshed).toEqual(updatedSettings);
|
||||||
expect(result.current.showSponsorInfo).toBe(false);
|
expect(result.current.showSponsorInfo).toBe(false);
|
||||||
|
expect(result.current.renderMarkdownInJobDescriptions).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles errors when fetching settings", async () => {
|
it("handles errors when fetching settings", async () => {
|
||||||
|
|||||||
@ -26,7 +26,9 @@ export function useSettings() {
|
|||||||
settings,
|
settings,
|
||||||
error: error ?? null,
|
error: error ?? null,
|
||||||
isLoading: isLoading || (!!isFetching && !settings && !error),
|
isLoading: isLoading || (!!isFetching && !settings && !error),
|
||||||
showSponsorInfo: settings?.showSponsorInfo ?? true,
|
showSponsorInfo: settings?.showSponsorInfo?.value ?? true,
|
||||||
|
renderMarkdownInJobDescriptions:
|
||||||
|
settings?.renderMarkdownInJobDescriptions?.value ?? true,
|
||||||
refreshSettings,
|
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",
|
rxresumeMode: "v5",
|
||||||
rxresumeBaseResumeId: null,
|
rxresumeBaseResumeId: null,
|
||||||
showSponsorInfo: null,
|
showSponsorInfo: null,
|
||||||
|
renderMarkdownInJobDescriptions: null,
|
||||||
chatStyleTone: "",
|
chatStyleTone: "",
|
||||||
chatStyleFormality: "",
|
chatStyleFormality: "",
|
||||||
chatStyleConstraints: "",
|
chatStyleConstraints: "",
|
||||||
@ -149,6 +150,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
|||||||
rxresumeMode: null,
|
rxresumeMode: null,
|
||||||
rxresumeBaseResumeId: null,
|
rxresumeBaseResumeId: null,
|
||||||
showSponsorInfo: null,
|
showSponsorInfo: null,
|
||||||
|
renderMarkdownInJobDescriptions: null,
|
||||||
chatStyleTone: null,
|
chatStyleTone: null,
|
||||||
chatStyleFormality: null,
|
chatStyleFormality: null,
|
||||||
chatStyleConstraints: null,
|
chatStyleConstraints: null,
|
||||||
@ -192,6 +194,8 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
|||||||
rxresumeMode: data.rxresumeMode.override ?? data.rxresumeMode.value,
|
rxresumeMode: data.rxresumeMode.override ?? data.rxresumeMode.value,
|
||||||
rxresumeBaseResumeId: data.rxresumeBaseResumeId,
|
rxresumeBaseResumeId: data.rxresumeBaseResumeId,
|
||||||
showSponsorInfo: data.showSponsorInfo.override,
|
showSponsorInfo: data.showSponsorInfo.override,
|
||||||
|
renderMarkdownInJobDescriptions:
|
||||||
|
data.renderMarkdownInJobDescriptions.override,
|
||||||
chatStyleTone: data.chatStyleTone.override ?? "",
|
chatStyleTone: data.chatStyleTone.override ?? "",
|
||||||
chatStyleFormality: data.chatStyleFormality.override ?? "",
|
chatStyleFormality: data.chatStyleFormality.override ?? "",
|
||||||
chatStyleConstraints: data.chatStyleConstraints.override ?? "",
|
chatStyleConstraints: data.chatStyleConstraints.override ?? "",
|
||||||
@ -296,8 +300,14 @@ const getDerivedSettings = (settings: AppSettings | null) => {
|
|||||||
default: settings?.jobCompleteWebhookUrl?.default ?? "",
|
default: settings?.jobCompleteWebhookUrl?.default ?? "",
|
||||||
},
|
},
|
||||||
display: {
|
display: {
|
||||||
effective: settings?.showSponsorInfo?.value ?? true,
|
showSponsorInfo: {
|
||||||
default: settings?.showSponsorInfo?.default ?? true,
|
effective: settings?.showSponsorInfo?.value ?? true,
|
||||||
|
default: settings?.showSponsorInfo?.default ?? true,
|
||||||
|
},
|
||||||
|
renderMarkdownInJobDescriptions: {
|
||||||
|
effective: settings?.renderMarkdownInJobDescriptions?.value ?? true,
|
||||||
|
default: settings?.renderMarkdownInJobDescriptions?.default ?? true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
chat: {
|
chat: {
|
||||||
tone: {
|
tone: {
|
||||||
@ -853,7 +863,14 @@ export const SettingsPage: React.FC = () => {
|
|||||||
...(dirtyFields.rxresumeBaseResumeId
|
...(dirtyFields.rxresumeBaseResumeId
|
||||||
? { rxresumeBaseResumeId: normalizeString(data.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),
|
chatStyleTone: normalizeString(data.chatStyleTone),
|
||||||
chatStyleFormality: normalizeString(data.chatStyleFormality),
|
chatStyleFormality: normalizeString(data.chatStyleFormality),
|
||||||
chatStyleConstraints: normalizeString(data.chatStyleConstraints),
|
chatStyleConstraints: normalizeString(data.chatStyleConstraints),
|
||||||
|
|||||||
@ -10,6 +10,15 @@ import { JobDetailPanel } from "./JobDetailPanel";
|
|||||||
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||||
renderWithQueryClient(ui);
|
renderWithQueryClient(ui);
|
||||||
|
|
||||||
|
const mockSettings = {
|
||||||
|
settings: null,
|
||||||
|
error: null,
|
||||||
|
isLoading: false,
|
||||||
|
showSponsorInfo: true,
|
||||||
|
renderMarkdownInJobDescriptions: true,
|
||||||
|
refreshSettings: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
vi.mock("@/components/ui/dropdown-menu", () => {
|
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||||
return {
|
return {
|
||||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
||||||
@ -51,6 +60,10 @@ vi.mock("@client/components", () => ({
|
|||||||
TailoredSummary: () => <div data-testid="tailored-summary" />,
|
TailoredSummary: () => <div data-testid="tailored-summary" />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@client/hooks/useSettings", () => ({
|
||||||
|
useSettings: () => mockSettings,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@client/components/ReadyPanel", () => ({
|
vi.mock("@client/components/ReadyPanel", () => ({
|
||||||
ReadyPanel: ({ onEditDescription }: { onEditDescription?: () => void }) => (
|
ReadyPanel: ({ onEditDescription }: { onEditDescription?: () => void }) => (
|
||||||
<div>
|
<div>
|
||||||
@ -146,6 +159,7 @@ const renderJobDetailPanel = async (
|
|||||||
describe("JobDetailPanel", () => {
|
describe("JobDetailPanel", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockSettings.renderMarkdownInJobDescriptions = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the discovered panel when active tab is discovered", async () => {
|
it("renders the discovered panel when active tab is discovered", async () => {
|
||||||
@ -188,6 +202,51 @@ describe("JobDetailPanel", () => {
|
|||||||
expect(screen.getByText("Hello world")).toBeInTheDocument();
|
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 () => {
|
it("saves an edited description", async () => {
|
||||||
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||||
vi.mocked(api.updateJob).mockResolvedValue(undefined as any);
|
vi.mocked(api.updateJob).mockResolvedValue(undefined as any);
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
useSkipJobMutation,
|
useSkipJobMutation,
|
||||||
} from "@client/hooks/queries/useJobMutations";
|
} from "@client/hooks/queries/useJobMutations";
|
||||||
import { useProfile } from "@client/hooks/useProfile";
|
import { useProfile } from "@client/hooks/useProfile";
|
||||||
|
import { useSettings } from "@client/hooks/useSettings";
|
||||||
import type { Job, JobListItem } from "@shared/types.js";
|
import type { Job, JobListItem } from "@shared/types.js";
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@ -28,9 +29,9 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } 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 { toast } from "sonner";
|
||||||
|
import { JobDescriptionMarkdown } from "@/client/components/JobDescriptionMarkdown";
|
||||||
|
import { getRenderableJobDescription } from "@/client/lib/jobDescription";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@ -46,7 +47,6 @@ import {
|
|||||||
copyTextToClipboard,
|
copyTextToClipboard,
|
||||||
formatJobForWebhook,
|
formatJobForWebhook,
|
||||||
safeFilenamePart,
|
safeFilenamePart,
|
||||||
stripHtml,
|
|
||||||
} from "@/lib/utils";
|
} from "@/lib/utils";
|
||||||
import type { FilterTab } from "./constants";
|
import type { FilterTab } from "./constants";
|
||||||
|
|
||||||
@ -82,6 +82,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
const skipJobMutation = useSkipJobMutation();
|
const skipJobMutation = useSkipJobMutation();
|
||||||
|
|
||||||
const { personName } = useProfile();
|
const { personName } = useProfile();
|
||||||
|
const { renderMarkdownInJobDescriptions } = useSettings();
|
||||||
|
|
||||||
const handleTailoringDirtyChange = useCallback(
|
const handleTailoringDirtyChange = useCallback(
|
||||||
(isDirty: boolean) => {
|
(isDirty: boolean) => {
|
||||||
@ -105,10 +106,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
}, [onPauseRefreshChange]);
|
}, [onPauseRefreshChange]);
|
||||||
|
|
||||||
const description = useMemo(() => {
|
const description = useMemo(() => {
|
||||||
if (!selectedJob?.jobDescription) return "No description available.";
|
return getRenderableJobDescription(selectedJob?.jobDescription);
|
||||||
const jd = selectedJob.jobDescription;
|
|
||||||
if (jd.includes("<") && jd.includes(">")) return stripHtml(jd);
|
|
||||||
return jd;
|
|
||||||
}, [selectedJob]);
|
}, [selectedJob]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -783,11 +781,11 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : renderMarkdownInJobDescriptions ? (
|
||||||
|
<JobDescriptionMarkdown description={description} />
|
||||||
) : (
|
) : (
|
||||||
<div className="whitespace-pre-wrap leading-relaxed">
|
<div className="whitespace-pre-wrap leading-relaxed">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
{description}
|
||||||
{description}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -21,10 +21,7 @@ export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({
|
|||||||
isLoading,
|
isLoading,
|
||||||
isSaving,
|
isSaving,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const { showSponsorInfo, renderMarkdownInJobDescriptions } = values;
|
||||||
default: defaultShowSponsorInfo,
|
|
||||||
effective: effectiveShowSponsorInfo,
|
|
||||||
} = values;
|
|
||||||
const { control } = useFormContext<UpdateSettingsInput>();
|
const { control } = useFormContext<UpdateSettingsInput>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -41,7 +38,7 @@ export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="showSponsorInfo"
|
id="showSponsorInfo"
|
||||||
checked={field.value ?? defaultShowSponsorInfo}
|
checked={field.value ?? showSponsorInfo.default}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
field.onChange(
|
field.onChange(
|
||||||
checked === "indeterminate" ? null : checked === true,
|
checked === "indeterminate" ? null : checked === true,
|
||||||
@ -68,17 +65,77 @@ export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({
|
|||||||
|
|
||||||
<Separator />
|
<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>
|
||||||
<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">
|
<div className="break-words font-mono text-xs">
|
||||||
{effectiveShowSponsorInfo ? "Enabled" : "Disabled"}
|
{showSponsorInfo.effective ? "Enabled" : "Disabled"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -18,7 +18,10 @@ export type ModelValues = EffectiveDefault<string> & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type WebhookValues = EffectiveDefault<string>;
|
export type WebhookValues = EffectiveDefault<string>;
|
||||||
export type DisplayValues = EffectiveDefault<boolean>;
|
export type DisplayValues = {
|
||||||
|
showSponsorInfo: EffectiveDefault<boolean>;
|
||||||
|
renderMarkdownInJobDescriptions: EffectiveDefault<boolean>;
|
||||||
|
};
|
||||||
export type ChatValues = {
|
export type ChatValues = {
|
||||||
tone: EffectiveDefault<string>;
|
tone: EffectiveDefault<string>;
|
||||||
formality: EffectiveDefault<string>;
|
formality: EffectiveDefault<string>;
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export const DEMO_DEFAULT_SETTINGS: DemoDefaultSettings = {
|
|||||||
"full stack engineer",
|
"full stack engineer",
|
||||||
]),
|
]),
|
||||||
showSponsorInfo: "1",
|
showSponsorInfo: "1",
|
||||||
|
renderMarkdownInJobDescriptions: "1",
|
||||||
backupEnabled: "0",
|
backupEnabled: "0",
|
||||||
backupHour: "2",
|
backupHour: "2",
|
||||||
backupMaxCount: "5",
|
backupMaxCount: "5",
|
||||||
|
|||||||
@ -57,6 +57,12 @@ describe("settingsRegistry helpers", () => {
|
|||||||
expect(settingsRegistry.showSponsorInfo.parse("false")).toBe(false);
|
expect(settingsRegistry.showSponsorInfo.parse("false")).toBe(false);
|
||||||
expect(settingsRegistry.showSponsorInfo.parse("")).toBeNull();
|
expect(settingsRegistry.showSponsorInfo.parse("")).toBeNull();
|
||||||
expect(settingsRegistry.showSponsorInfo.parse(undefined)).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", () => {
|
it("serializes bit bools correctly", () => {
|
||||||
@ -64,6 +70,12 @@ describe("settingsRegistry helpers", () => {
|
|||||||
expect(settingsRegistry.showSponsorInfo.serialize(false)).toBe("0");
|
expect(settingsRegistry.showSponsorInfo.serialize(false)).toBe("0");
|
||||||
expect(settingsRegistry.showSponsorInfo.serialize(null)).toBeNull();
|
expect(settingsRegistry.showSponsorInfo.serialize(null)).toBeNull();
|
||||||
expect(settingsRegistry.showSponsorInfo.serialize(undefined)).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,
|
parse: parseBitBoolOrNull,
|
||||||
serialize: serializeBitBool,
|
serialize: serializeBitBool,
|
||||||
},
|
},
|
||||||
|
renderMarkdownInJobDescriptions: {
|
||||||
|
kind: "typed" as const,
|
||||||
|
schema: z.boolean(),
|
||||||
|
default: (): boolean => true,
|
||||||
|
parse: parseBitBoolOrNull,
|
||||||
|
serialize: serializeBitBool,
|
||||||
|
},
|
||||||
chatStyleTone: {
|
chatStyleTone: {
|
||||||
kind: "typed" as const,
|
kind: "typed" as const,
|
||||||
schema: z.string().trim().max(100),
|
schema: z.string().trim().max(100),
|
||||||
|
|||||||
@ -186,6 +186,11 @@ export const createAppSettings = (
|
|||||||
override: null,
|
override: null,
|
||||||
},
|
},
|
||||||
showSponsorInfo: { value: true, default: true, override: null },
|
showSponsorInfo: { value: true, default: true, override: null },
|
||||||
|
renderMarkdownInJobDescriptions: {
|
||||||
|
value: true,
|
||||||
|
default: true,
|
||||||
|
override: null,
|
||||||
|
},
|
||||||
chatStyleTone: {
|
chatStyleTone: {
|
||||||
value: "professional",
|
value: "professional",
|
||||||
default: "professional",
|
default: "professional",
|
||||||
|
|||||||
@ -162,6 +162,7 @@ export interface AppSettings {
|
|||||||
jobspyResultsWanted: Resolved<number>;
|
jobspyResultsWanted: Resolved<number>;
|
||||||
jobspyCountryIndeed: Resolved<string>;
|
jobspyCountryIndeed: Resolved<string>;
|
||||||
showSponsorInfo: Resolved<boolean>;
|
showSponsorInfo: Resolved<boolean>;
|
||||||
|
renderMarkdownInJobDescriptions: Resolved<boolean>;
|
||||||
chatStyleTone: Resolved<string>;
|
chatStyleTone: Resolved<string>;
|
||||||
chatStyleFormality: Resolved<string>;
|
chatStyleFormality: Resolved<string>;
|
||||||
chatStyleConstraints: Resolved<string>;
|
chatStyleConstraints: Resolved<string>;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user