Skip to content

ENG-2380: Add PDF report download for privacy assessments#7535

Merged
lucanovera merged 8 commits intomainfrom
eng-2380-export-config-v3
Mar 6, 2026
Merged

ENG-2380: Add PDF report download for privacy assessments#7535
lucanovera merged 8 commits intomainfrom
eng-2380-export-config-v3

Conversation

@thabofletcher
Copy link
Copy Markdown
Contributor

@thabofletcher thabofletcher commented Mar 2, 2026

Ticket ENG-2380

Description Of Changes

Adds a "Download report" button to the privacy assessment detail page that downloads a PDF report in external format (clean format suitable for sharing/signing).

This is a simplified UI implementation - no internal/external mode selection in the UI. The API supports both modes, but the UI only uses external format for now.

Code Changes

  • Add downloadAssessmentReport mutation to privacy-assessments.slice.ts
  • Update AssessmentDetail.tsx with download button and loading state
  • Use file-saver to trigger browser download of PDF blob
  • Pass export_mode=external query parameter for clean output format
  • Button disabled until assessment is marked complete

Steps to Confirm

  1. Navigate to a privacy assessment detail page
  2. Verify "Download report" button is disabled for incomplete assessments
  3. Complete an assessment (fill all answers) and verify button becomes enabled
  4. Click "Download report" and verify PDF downloads with correct filename
  5. Verify PDF contains clean format (questions and answers only, no internal status/risk indicators)

Pre-Merge Checklist

  • Issue requirements met
  • All CI pipelines succeeded
  • CHANGELOG.md updated
    • Add a db-migration This indicates that a change includes a database migration label to the entry if your change includes a DB migration
    • Add a high-risk This issue suggests changes that have a high-probability of breaking existing code label to the entry if your change includes a high-risk change
    • Updates unreleased work already in Changelog, no new entry necessary
  • UX feedback:
    • All UX related changes have been reviewed by a designer
    • No UX review needed
  • Followup issues:
    • Followup issues created
    • No followup issues
  • Database migrations:
    • No migrations
  • Documentation:

Summary by CodeRabbit

  • New Features
    • Added a download button on the privacy assessment detail page to export assessment reports as PDF files. Download progress is indicated with a loading state, and errors are reported to the user.

@vercel
Copy link
Copy Markdown
Contributor

vercel bot commented Mar 2, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
fides-plus-nightly Ready Ready Preview, Comment Mar 6, 2026 4:38pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
fides-privacy-center Ignored Ignored Mar 6, 2026 4:38pm

Request Review

Add a "Download report" button to the privacy assessment detail page that
downloads a PDF report when the assessment is complete.

- Add downloadAssessmentReport mutation to privacy-assessments.slice.ts
- Use file-saver to trigger browser download of PDF blob
- Update AssessmentDetail component with download button and handler
- Button disabled until assessment is marked complete

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Use correct endpoint path (/pdf instead of /report)
- Add export_mode=external query parameter for clean output
- Improve filename extraction from content-disposition header

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@thabofletcher thabofletcher marked this pull request as ready for review March 2, 2026 04:16
@thabofletcher thabofletcher requested a review from a team as a code owner March 2, 2026 04:16
@thabofletcher thabofletcher requested review from lucanovera and removed request for a team March 2, 2026 04:16
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 2, 2026

Greptile Summary

This PR adds a functional "Download report" button to the privacy assessment detail page that triggers PDF download using the existing backend endpoint.

Key changes:

  • Added RTK Query mutation downloadAssessmentReport that calls the /pdf?export_mode=external endpoint
  • Implemented proper response handling with filename extraction from content-disposition header
  • Used file-saver library to trigger browser download of PDF blob
  • Button is correctly disabled for incomplete assessments and shows loading state during download
  • Error handling properly displays user-friendly messages on failure

The implementation is clean, follows React best practices with useCallback for the handler, and properly manages loading states. The code adheres to the repository's patterns and doesn't introduce any issues.

Confidence Score: 5/5

  • This PR is safe to merge with no concerns
  • The PR makes simple, focused changes to add PDF download functionality with proper error handling, loading states, and validation. The code follows established patterns in the codebase, uses React hooks correctly, and handles edge cases appropriately (disabled button for incomplete assessments, error messages on failure). No custom rules are violated, and the implementation is clean and maintainable.
  • No files require special attention

Important Files Changed

Filename Overview
changelog/7535-pdf-download.yaml Added changelog entry documenting the new PDF download feature
clients/admin-ui/src/features/privacy-assessments/AssessmentDetail.tsx Updated UI to add functional download button with proper loading states and error handling, button correctly disabled for incomplete assessments
clients/admin-ui/src/features/privacy-assessments/privacy-assessments.slice.ts Added RTK Query mutation for PDF download with proper response handling, filename extraction, and blob download using file-saver

Last reviewed commit: 27eb180

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 files reviewed, no comments

Edit Code Review Agent Settings | Greptile

Combine PDF download feature with Slack questionnaire features:
- Show "Download report" button when assessment is complete
- Show "Request input from team" button when assessment is incomplete

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 5, 2026

📝 Walkthrough

Walkthrough

The changes add a PDF report download feature to the privacy assessment detail page. A new mutation (downloadAssessmentReport) is created in the privacy-assessments API to fetch and save PDF files with a custom response handler. The mutation is integrated into the assessment detail page with loading states and error handling.

Changes

Cohort / File(s) Summary
Changelog Entry
changelog/7535-pdf-download.yaml
Documents the new feature: PDF report download button on the privacy assessment detail page.
API Mutation
clients/admin-ui/src/features/privacy-assessments/privacy-assessments.slice.ts
Adds downloadAssessmentReport mutation with custom response handler to fetch PDF files, extract filename from Content-Disposition header, and save via file-saver. Exports useDownloadAssessmentReportMutation hook.
UI Integration
clients/admin-ui/src/pages/privacy-assessments/[id].tsx
Integrates the download mutation with a new handleDownloadReport handler, loading state binding, and error handling via getErrorMessage and message notifications.

Possibly related PRs

Poem

🐰 A button to click, a file to save,
PDF downloads, oh so brave!
With hops and fetches through the cloud,
Assessment reports make us proud! 📄✨

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main change: adding a PDF report download feature for privacy assessments, directly reflecting the changeset.
Description check ✅ Passed The description includes the ticket, clear explanation of changes, code changes list, verification steps, and a completed pre-merge checklist covering all applicable categories.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch eng-2380-export-config-v3

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@clients/admin-ui/src/features/privacy-assessments/privacy-assessments.slice.ts`:
- Around line 216-237: The responseHandler currently treats every fetch response
as a PDF; add an HTTP error check at the start of responseHandler (in the
responseHandler function) to verify response.ok and handle non-2xx responses
before extracting headers/arrayBuffer; if !response.ok, read the error body
(text/json) or throw a wrapped Error (including status and statusText) so
callers don't attempt to save a failed error payload via saveAs; still preserve
existing filename extraction (contentDisposition) and blob/saveAs only when
response.ok.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c3e4dbde-ef5d-41e6-a5ae-dfdd9818e85b

📥 Commits

Reviewing files that changed from the base of the PR and between f7b17b5 and 2f77c9a.

📒 Files selected for processing (3)
  • changelog/7535-pdf-download.yaml
  • clients/admin-ui/src/features/privacy-assessments/AssessmentDetail.tsx
  • clients/admin-ui/src/features/privacy-assessments/privacy-assessments.slice.ts

Check response.ok before treating the response as a PDF blob.
If the response is an error, extract the error message and throw
so callers don't attempt to save failed error payloads via saveAs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@lucanovera lucanovera left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basic download works correctly, I've adjusted the implementation to account for the button being moved to the header.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@clients/admin-ui/src/pages/privacy-assessments/`[id].tsx:
- Around line 51-54: handleDownloadReport uses the transient route query value
assessmentId when calling downloadReport(assessmentId). Change the call to use
the canonical loaded entity ID (assessment.id) instead so the download mutation
always uses the persisted assessment identifier; update the invocation in
handleDownloadReport to call downloadReport(assessment.id).unwrap() (and ensure
assessment is non-null/defined before calling) to avoid edge cases from query
parsing/state.
- Around line 176-183: Update the Button label text to match the implemented
action: replace the displayed string "Generate report" with "Download report" in
the Button that uses onClick={handleDownloadReport} (the Button with props
disabled={!isComplete} and loading={isDownloading}) so the UI and tests reflect
the actual download behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6e8f34d4-e679-4fe7-bd2c-372a48b88166

📥 Commits

Reviewing files that changed from the base of the PR and between fca049c and 5e28848.

📒 Files selected for processing (3)
  • clients/admin-ui/src/features/privacy-assessments/AssessmentDetail.tsx
  • clients/admin-ui/src/features/privacy-assessments/privacy-assessments.slice.ts
  • clients/admin-ui/src/pages/privacy-assessments/[id].tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • clients/admin-ui/src/features/privacy-assessments/privacy-assessments.slice.ts
  • clients/admin-ui/src/features/privacy-assessments/AssessmentDetail.tsx

Comment on lines +51 to +54
const handleDownloadReport = async () => {
try {
await downloadReport(assessmentId).unwrap();
} catch (error) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use canonical assessment ID for download mutation.

On Line 53, downloadReport(assessmentId) uses route query state instead of the loaded entity. Prefer assessment.id to avoid edge cases where query parsing/state is transient.

Suggested patch
   const handleDownloadReport = async () => {
     try {
-      await downloadReport(assessmentId).unwrap();
+      if (!assessment?.id) {
+        return;
+      }
+      await downloadReport(assessment.id).unwrap();
     } catch (error) {
       message.error(
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleDownloadReport = async () => {
try {
await downloadReport(assessmentId).unwrap();
} catch (error) {
const handleDownloadReport = async () => {
try {
if (!assessment?.id) {
return;
}
await downloadReport(assessment.id).unwrap();
} catch (error) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clients/admin-ui/src/pages/privacy-assessments/`[id].tsx around lines 51 -
54, handleDownloadReport uses the transient route query value assessmentId when
calling downloadReport(assessmentId). Change the call to use the canonical
loaded entity ID (assessment.id) instead so the download mutation always uses
the persisted assessment identifier; update the invocation in
handleDownloadReport to call downloadReport(assessment.id).unwrap() (and ensure
assessment is non-null/defined before calling) to avoid edge cases from query
parsing/state.

Comment on lines +176 to 183
<Button
type="primary"
disabled={!isComplete}
loading={isDownloading}
onClick={handleDownloadReport}
>
Generate report
</Button>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Align button copy with the implemented action ("Download report").

The action downloads a PDF, and PR acceptance text references “Download report”. Keeping the button label as “Generate report” may confuse users and test steps.

Suggested patch
-              >
-                Generate report
-              </Button>
+              >
+                Download report
+              </Button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Button
type="primary"
disabled={!isComplete}
loading={isDownloading}
onClick={handleDownloadReport}
>
Generate report
</Button>
<Button
type="primary"
disabled={!isComplete}
loading={isDownloading}
onClick={handleDownloadReport}
>
Download report
</Button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clients/admin-ui/src/pages/privacy-assessments/`[id].tsx around lines 176 -
183, Update the Button label text to match the implemented action: replace the
displayed string "Generate report" with "Download report" in the Button that
uses onClick={handleDownloadReport} (the Button with props
disabled={!isComplete} and loading={isDownloading}) so the UI and tests reflect
the actual download behavior.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
clients/admin-ui/src/features/privacy-assessments/privacy-assessments.slice.ts (1)

237-238: Prefer params over hardcoded query string in URL.

At Line [237], embedding export_mode=external directly in the URL is less maintainable than using the params field.

Proposed refactor
-        url: `plus/privacy-assessments/${id}/pdf?export_mode=external`,
+        url: `plus/privacy-assessments/${id}/pdf`,
+        params: { export_mode: "external" },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@clients/admin-ui/src/features/privacy-assessments/privacy-assessments.slice.ts`
around lines 237 - 238, Replace the hardcoded query string in the request config
by removing `?export_mode=external` from the url
`plus/privacy-assessments/${id}/pdf` and add a `params` object with
`export_mode: "external"` in the same request config (the block that currently
contains `url: \`plus/privacy-assessments/${id}/pdf?export_mode=external\`,
method: "GET",`). This keeps the `method: "GET"` unchanged but moves query
params into `params` for maintainability and better integration with the HTTP
client.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@clients/admin-ui/src/features/privacy-assessments/privacy-assessments.slice.ts`:
- Around line 259-265: The Content-Disposition parsing around the
contentDisposition variable only handles filename= and misses RFC 5987 filename*
entries; update the parsing logic in the privacy-assessments.slice.ts helper
(the block using contentDisposition.match(/filename="?([^";\n]+)"?/)) to first
check for filename* (e.g., match /filename\*\s*=\s*([^;]+)/), parse the RFC5987
value into charset'lang'encodedValue, percent-decode the encodedValue (using
decodeURIComponent) and, if a charset is provided other than UTF-8, convert
bytes to that charset or at least handle UTF-8 correctly; if filename* is
absent, fall back to the existing filename= regex and trimming behavior so
non-ASCII/encoded filenames are correctly extracted and decoded.

---

Nitpick comments:
In
`@clients/admin-ui/src/features/privacy-assessments/privacy-assessments.slice.ts`:
- Around line 237-238: Replace the hardcoded query string in the request config
by removing `?export_mode=external` from the url
`plus/privacy-assessments/${id}/pdf` and add a `params` object with
`export_mode: "external"` in the same request config (the block that currently
contains `url: \`plus/privacy-assessments/${id}/pdf?export_mode=external\`,
method: "GET",`). This keeps the `method: "GET"` unchanged but moves query
params into `params` for maintainability and better integration with the HTTP
client.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b5142849-88ab-4694-b19a-a0da0bc54759

📥 Commits

Reviewing files that changed from the base of the PR and between 5e28848 and ef54dec.

📒 Files selected for processing (1)
  • clients/admin-ui/src/features/privacy-assessments/privacy-assessments.slice.ts

Comment on lines +259 to +265
if (contentDisposition) {
// Try to extract filename from content-disposition header
// Handles both: filename="name.pdf" and filename=name.pdf
const match = contentDisposition.match(/filename="?([^";\n]+)"?/);
if (match && match[1]) {
filename = match[1].trim();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle RFC 5987 filename* in Content-Disposition parsing.

At Lines [259]-[265], parsing only filename= misses common filename*= headers (encoded/non-ASCII), which can lead to incorrect fallback filenames.

Proposed fix
           if (contentDisposition) {
-            // Try to extract filename from content-disposition header
-            // Handles both: filename="name.pdf" and filename=name.pdf
-            const match = contentDisposition.match(/filename="?([^";\n]+)"?/);
-            if (match && match[1]) {
-              filename = match[1].trim();
-            }
+            const filenameStarMatch = contentDisposition.match(
+              /filename\*\s*=\s*([^;]+)/i,
+            );
+            if (filenameStarMatch?.[1]) {
+              const encoded = filenameStarMatch[1].replace(/^UTF-8''/i, "").trim();
+              try {
+                filename = decodeURIComponent(encoded);
+              } catch {
+                filename = encoded;
+              }
+            } else {
+              const filenameMatch = contentDisposition.match(
+                /filename\s*=\s*"?(?<name>[^";\n]+)"?/i,
+              );
+              if (filenameMatch?.groups?.name) {
+                filename = filenameMatch.groups.name.trim();
+              }
+            }
           }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (contentDisposition) {
// Try to extract filename from content-disposition header
// Handles both: filename="name.pdf" and filename=name.pdf
const match = contentDisposition.match(/filename="?([^";\n]+)"?/);
if (match && match[1]) {
filename = match[1].trim();
}
if (contentDisposition) {
const filenameStarMatch = contentDisposition.match(
/filename\*\s*=\s*([^;]+)/i,
);
if (filenameStarMatch?.[1]) {
const encoded = filenameStarMatch[1].replace(/^UTF-8''/i, "").trim();
try {
filename = decodeURIComponent(encoded);
} catch {
filename = encoded;
}
} else {
const filenameMatch = contentDisposition.match(
/filename\s*=\s*"?(?<name>[^";\n]+)"?/i,
);
if (filenameMatch?.groups?.name) {
filename = filenameMatch.groups.name.trim();
}
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@clients/admin-ui/src/features/privacy-assessments/privacy-assessments.slice.ts`
around lines 259 - 265, The Content-Disposition parsing around the
contentDisposition variable only handles filename= and misses RFC 5987 filename*
entries; update the parsing logic in the privacy-assessments.slice.ts helper
(the block using contentDisposition.match(/filename="?([^";\n]+)"?/)) to first
check for filename* (e.g., match /filename\*\s*=\s*([^;]+)/), parse the RFC5987
value into charset'lang'encodedValue, percent-decode the encodedValue (using
decodeURIComponent) and, if a charset is provided other than UTF-8, convert
bytes to that charset or at least handle UTF-8 correctly; if filename* is
absent, fall back to the existing filename= regex and trimming behavior so
non-ASCII/encoded filenames are correctly extracted and decoded.

@lucanovera lucanovera added this pull request to the merge queue Mar 6, 2026
Merged via the queue into main with commit 758479f Mar 6, 2026
47 checks passed
@lucanovera lucanovera deleted the eng-2380-export-config-v3 branch March 6, 2026 16:58
mfbrown pushed a commit that referenced this pull request Mar 12, 2026
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Lucano Vera <lucanovera@live.com.ar>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants