Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions src/components/export-panel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// @vitest-environment jsdom

import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"

import { type Project } from "#/domain/types.ts"
import { completeForm, cycleSettings, layoutFixture, submissionFixture } from "#/test/fixtures.ts"

const exportProject = vi.fn()

vi.mock("#/lib/api.ts", async () => {
const actual = await vi.importActual<typeof import("#/lib/api.ts")>("#/lib/api.ts")
return {
...actual,
projectApi: {
...actual.projectApi,
export: (...args: unknown[]) => exportProject(...args),
},
}
})

const { ExportPanel } = await import("./export-panel.tsx")

beforeEach(() => {
exportProject.mockReset()
exportProject.mockRejectedValue(new Error("Stop after request"))
})

afterEach(cleanup)

function projectWithBlockingProblem(): Project {
const layout = layoutFixture()
const submission = submissionFixture("10000000-0000-4000-8000-000000000001", 1)
const pageId = `submission:${submission.id}`

return {
id: layout.projectId,
title: "Test book",
occasion: null,
state: "closed",
formSchema: completeForm,
layouts: [layout],
submissions: [submission],
bookStatus: "current",
archivedAt: null,
book: {
projectId: layout.projectId,
settings: cycleSettings,
pages: [
{
id: pageId,
kind: "submission",
submissionId: submission.id,
layoutId: layout.id,
problems: [
{
id: `${pageId}:text:outside-print-area`,
code: "outside-print-area",
pageId,
elementId: "text",
message: "Text is outside the safe area.",
blocking: true,
},
],
},
],
sourceFingerprint: "current-source",
generatedAt: "2026-08-25T00:00:00.000Z",
updatedAt: "2026-08-25T00:00:00.000Z",
},
} as Project
}

describe("print export blocking problem override", () => {
it("requires an explicit opt-in and confirmation before requesting the export", async () => {
render(<ExportPanel project={projectWithBlockingProblem()} />)

const exportButton = screen.getByRole("button", { name: "Export PDF + report" })
expect((exportButton as HTMLButtonElement).disabled).toBe(true)

fireEvent.click(screen.getByRole("switch", { name: "Export despite blocking problems" }))
expect(screen.getByText("1 blocking page problem accepted")).toBeTruthy()
expect(
(screen.getByRole("button", { name: "Export PDF + report" }) as HTMLButtonElement).disabled
).toBe(false)

fireEvent.click(screen.getByRole("button", { name: "Export PDF + report" }))
expect(exportProject).not.toHaveBeenCalled()

fireEvent.click(await screen.findByRole("button", { name: "Export anyway" }))

await waitFor(() =>
expect(exportProject).toHaveBeenCalledExactlyOnceWith(
"99999999-9999-4999-8999-999999999999",
{
marks: false,
allowBlockingProblems: true,
reviewedBookFingerprint: "current-source",
}
)
)
})
})
142 changes: 116 additions & 26 deletions src/components/export-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,21 @@ import {
PrinterIcon,
XCircleIcon,
} from "lucide-react"
import { useState } from "react"
import { useEffect, useState } from "react"
import { toast } from "sonner"

import { Alert, AlertDescription, AlertTitle } from "#/components/ui/alert.tsx"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "#/components/ui/alert-dialog.tsx"
import { Badge } from "#/components/ui/badge.tsx"
import { Button, buttonVariants } from "#/components/ui/button.tsx"
import {
Expand All @@ -23,15 +34,17 @@ import {
CardHeader,
CardTitle,
} from "#/components/ui/card.tsx"
import { Field, FieldDescription, FieldLabel } from "#/components/ui/field.tsx"
import { Field, FieldDescription, FieldGroup, FieldLabel } from "#/components/ui/field.tsx"
import { Switch } from "#/components/ui/switch.tsx"
import { type ExportArtifact, type Project } from "#/domain/types.ts"
import { pageSpecification } from "#/domain/page-format.ts"
import { captureAnalyticsEvent } from "#/lib/analytics.ts"
import { projectApi } from "#/lib/api.ts"

export function ExportPanel({ project }: { project: Project }) {
const specification = pageSpecification(project.pageFormat, project.pageOrientation)
const [marks, setMarks] = useState(false)
const [allowBlockingProblems, setAllowBlockingProblems] = useState(false)
const [exporting, setExporting] = useState(false)
const [artifact, setArtifact] = useState<ExportArtifact | null>(null)
const blocking =
Expand All @@ -40,15 +53,28 @@ export function ExportPanel({ project }: { project: Project }) {
const ready =
project.bookStatus === "current" &&
Boolean(project.book) &&
blocking === 0 &&
(blocking === 0 || allowBlockingProblems) &&
!project.archivedAt

useEffect(() => {
setAllowBlockingProblems(false)
}, [project.id, project.book?.sourceFingerprint])

const exportBook = async () => {
setExporting(true)
setArtifact(null)
try {
const result = await projectApi.export(project.id, marks)
const result = await projectApi.export(project.id, {
marks,
allowBlockingProblems,
reviewedBookFingerprint: project.book?.sourceFingerprint ?? null,
})
setArtifact(result)
captureAnalyticsEvent("export:completed", {
blocking_override: allowBlockingProblems && blocking > 0,
problem_count: blocking,
printer_marks: marks,
})
toast.success("PDF and preflight report exported")
} catch (error) {
toast.error(error instanceof Error ? error.message : "Export failed")
Expand Down Expand Up @@ -110,6 +136,16 @@ export function ExportPanel({ project }: { project: Project }) {
Return to Book review and regenerate the complete book.
</AlertDescription>
</Alert>
) : blocking > 0 && allowBlockingProblems ? (
<Alert>
<AlertTriangleIcon />
<AlertTitle>
{blocking} blocking page problem{blocking === 1 ? "" : "s"} accepted
</AlertTitle>
<AlertDescription>
Export is enabled. The preflight report will list every accepted problem.
</AlertDescription>
</Alert>
) : blocking > 0 ? (
<Alert variant="destructive">
<XCircleIcon />
Expand All @@ -118,7 +154,7 @@ export function ExportPanel({ project }: { project: Project }) {
{blocking === 1 ? "" : "s"}
</AlertTitle>
<AlertDescription>
Resolve or explicitly override every blocking problem, then regenerate.
Enable export despite blocking problems below to accept them for this export.
</AlertDescription>
</Alert>
) : (
Expand All @@ -139,29 +175,83 @@ export function ExportPanel({ project }: { project: Project }) {
</CardDescription>
</CardHeader>
<CardContent>
<Field orientation="horizontal">
<Switch
id="printer-marks"
checked={marks}
onCheckedChange={(checked) => setMarks(checked === true)}
/>
<div>
<FieldLabel htmlFor="printer-marks">Crop and printer marks</FieldLabel>
<FieldDescription>
Off by default. Pages are never imposed as spreads.
</FieldDescription>
</div>
</Field>
<FieldGroup>
<Field orientation="horizontal">
<Switch
id="printer-marks"
checked={marks}
onCheckedChange={(checked) => setMarks(checked === true)}
/>
<div>
<FieldLabel htmlFor="printer-marks">Crop and printer marks</FieldLabel>
<FieldDescription>
Off by default. Pages are never imposed as spreads.
</FieldDescription>
</div>
</Field>
{blocking > 0 && project.bookStatus === "current" && (
<Field orientation="horizontal">
<Switch
id="allow-blocking-problems"
checked={allowBlockingProblems}
onCheckedChange={(checked) => {
const enabled = checked === true
setAllowBlockingProblems(enabled)
captureAnalyticsEvent("export:blocking_override_changed", {
enabled,
problem_count: blocking,
})
}}
/>
<div>
<FieldLabel htmlFor="allow-blocking-problems">
Export despite blocking problems
</FieldLabel>
<FieldDescription>
Accept {blocking} blocking page problem{blocking === 1 ? "" : "s"} for this
export. The preflight report will list each one.
</FieldDescription>
</div>
</Field>
)}
</FieldGroup>
</CardContent>
<CardFooter className="justify-end">
<Button size="lg" disabled={!ready || exporting} onClick={exportBook}>
{exporting ? (
<LoaderCircleIcon className="animate-spin" data-icon="inline-start" />
) : (
<FileCheck2Icon data-icon="inline-start" />
)}
{exporting ? "Rendering and preflighting…" : "Export PDF + report"}
</Button>
{blocking > 0 && allowBlockingProblems ? (
<AlertDialog>
<AlertDialogTrigger render={<Button size="lg" disabled={!ready || exporting} />}>
{exporting ? (
<LoaderCircleIcon className="animate-spin" data-icon="inline-start" />
) : (
<FileCheck2Icon data-icon="inline-start" />
)}
{exporting ? "Rendering and preflighting…" : "Export PDF + report"}
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Export with blocking problems?</AlertDialogTitle>
<AlertDialogDescription>
The PDF may contain content outside the print area, clipped text, or other
visible problems. The preflight report will record all {blocking} accepted
problem{blocking === 1 ? "" : "s"}.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={exportBook}>Export anyway</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<Button size="lg" disabled={!ready || exporting} onClick={exportBook}>
{exporting ? (
<LoaderCircleIcon className="animate-spin" data-icon="inline-start" />
) : (
<FileCheck2Icon data-icon="inline-start" />
)}
{exporting ? "Rendering and preflighting…" : "Export PDF + report"}
</Button>
)}
</CardFooter>
</Card>

Expand Down
41 changes: 40 additions & 1 deletion src/domain/preflight.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"

import { createPreflightReport, hasFailedPreflight } from "./preflight.ts"
import { createPreflightReport, hasFailedPreflight, reportAsText } from "./preflight.ts"
import { generateBook } from "./generation.ts"
import { pageSpecification } from "./page-format.ts"
import { completeForm, cycleSettings, layoutFixture, submissionFixture } from "../test/fixtures.ts"
Expand Down Expand Up @@ -133,4 +133,43 @@ describe("preflight", () => {
)
expect(hasFailedPreflight(report)).toBe(false)
})

it("records explicitly accepted blocking problems without bypassing stale generation", () => {
const generated = book()
const acceptedProblem = {
id: "accepted-outside-print-area",
code: "outside-print-area" as const,
pageId: generated.pages[0]!.id,
elementId: "text-element",
message: "Text is outside the 6 mm safe area.",
blocking: true,
}
generated.pages[0]!.problems.push(acceptedProblem)
const input = {
projectId: generated.projectId,
book: generated,
bookStatus: "current" as const,
pageCount: generated.pages.length,
fontsEmbedded: true,
outputIntentEmbedded: true,
pageBoxesValid: true,
assetResolutionMetadata: true,
assetResolutionCount: 0,
marks: false,
allowBlockingProblems: true,
}

const report = createPreflightReport(input)

expect(report.checks).toContainEqual(
expect.objectContaining({ id: "blocking-problems", status: "warning" })
)
expect(report.ignoredProblems).toEqual([acceptedProblem])
expect(reportAsText(report)).toContain(
`${acceptedProblem.pageId} / text-element: ${acceptedProblem.message}`
)
expect(hasFailedPreflight(report)).toBe(false)

expect(hasFailedPreflight(createPreflightReport({ ...input, bookStatus: "stale" }))).toBe(true)
})
})
Loading