@openshift-migration-advisor/shared-components 0.25.0-fce5293f01c1 → 0.27.0-5d313f88dc52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/report/ChartExportProvider.d.ts +3 -2
  2. package/dist/report/ChartExportProvider.d.ts.map +1 -1
  3. package/dist/report/ChartExportProvider.js +3 -3
  4. package/dist/report/ErrorTable.d.ts +18 -0
  5. package/dist/report/ErrorTable.d.ts.map +1 -0
  6. package/dist/report/ErrorTable.js +20 -0
  7. package/dist/report/HostsOverview.d.ts +13 -0
  8. package/dist/report/HostsOverview.d.ts.map +1 -0
  9. package/dist/report/HostsOverview.js +84 -0
  10. package/dist/report/ReportTable.d.ts +14 -0
  11. package/dist/report/ReportTable.d.ts.map +1 -0
  12. package/dist/report/ReportTable.js +23 -0
  13. package/dist/report/WarningsTable.d.ts +9 -0
  14. package/dist/report/WarningsTable.d.ts.map +1 -0
  15. package/dist/report/WarningsTable.js +20 -0
  16. package/dist/report/captureChartElement.d.ts.map +1 -1
  17. package/dist/report/captureChartElement.js +28 -8
  18. package/dist/report/chartExport.d.ts +23 -0
  19. package/dist/report/chartExport.d.ts.map +1 -1
  20. package/dist/report/chartExport.js +20 -0
  21. package/dist/report/chartExportContext.d.ts +6 -2
  22. package/dist/report/chartExportContext.d.ts.map +1 -1
  23. package/dist/report/constants.d.ts +2 -0
  24. package/dist/report/constants.d.ts.map +1 -1
  25. package/dist/report/constants.js +2 -0
  26. package/dist/report/index.d.ts +6 -2
  27. package/dist/report/index.d.ts.map +1 -1
  28. package/dist/report/index.js +4 -0
  29. package/dist/report/pdfExport.d.ts +2 -2
  30. package/dist/report/pdfExport.d.ts.map +1 -1
  31. package/dist/report/pdfExport.js +52 -3
  32. package/package.json +2 -2
  33. package/src/report/ChartExportProvider.tsx +13 -5
  34. package/src/report/ErrorTable.tsx +85 -0
  35. package/src/report/HostsOverview.tsx +185 -0
  36. package/src/report/ReportTable.tsx +81 -0
  37. package/src/report/WarningsTable.tsx +75 -0
  38. package/src/report/__tests__/ChartExportProvider.test.tsx +110 -2
  39. package/src/report/__tests__/HostsOverview.test.tsx +145 -0
  40. package/src/report/__tests__/captureChartElement.test.ts +73 -0
  41. package/src/report/__tests__/chartExportPackagers.test.ts +75 -0
  42. package/src/report/captureChartElement.ts +37 -7
  43. package/src/report/chartExport.ts +40 -0
  44. package/src/report/chartExportContext.tsx +9 -1
  45. package/src/report/constants.ts +2 -0
  46. package/src/report/index.ts +18 -0
  47. package/src/report/pdfExport.ts +81 -2
@@ -101,12 +101,18 @@ function DownloadChartButton({ id }: { id: string }): JSX.Element {
101
101
  );
102
102
  }
103
103
 
104
- function DownloadPdfButton(): JSX.Element {
104
+ function DownloadPdfButton({
105
+ extraPages,
106
+ }: {
107
+ extraPages?: Parameters<
108
+ NonNullable<ReturnType<typeof useChartExport>>["downloadPdf"]
109
+ >[1];
110
+ }): JSX.Element {
105
111
  const exportApi = useChartExport();
106
112
  return (
107
113
  <button
108
114
  type="button"
109
- onClick={() => void exportApi?.downloadPdf("Overview")}
115
+ onClick={() => void exportApi?.downloadPdf("Overview", extraPages)}
110
116
  >
111
117
  Download pdf
112
118
  </button>
@@ -181,6 +187,46 @@ describe("ChartExportProvider", () => {
181
187
  expect(downloadFile).toHaveBeenCalledWith(expect.any(Blob), "disks.png");
182
188
  });
183
189
 
190
+ it("does not change the live theme while exporting", async () => {
191
+ const user = userEvent.setup();
192
+ document.documentElement.classList.add("pf-v6-theme-dark");
193
+ const capture = vi.fn(async () => {
194
+ expect(
195
+ document.documentElement.classList.contains("pf-v6-theme-dark"),
196
+ ).toBe(true);
197
+ return fakeCanvas();
198
+ });
199
+ const downloadFile = vi.fn();
200
+
201
+ render(
202
+ <ChartExportProvider
203
+ capture={capture}
204
+ encodePng={async () => new Blob(["png"])}
205
+ zipFiles={async () => new Blob(["zip"])}
206
+ downloadFile={downloadFile}
207
+ getZipFilename={() => "charts.zip"}
208
+ >
209
+ <RegisteredChart id="storage" title="Disks" />
210
+ <DownloadChartButton id="storage" />
211
+ <DownloadAllButton />
212
+ </ChartExportProvider>,
213
+ );
214
+
215
+ await user.click(screen.getByRole("button", { name: "Download storage" }));
216
+ await waitFor(() =>
217
+ expect(downloadFile).toHaveBeenCalledWith(expect.any(Blob), "disks.png"),
218
+ );
219
+ await user.click(screen.getByRole("button", { name: "Download all" }));
220
+ await waitFor(() =>
221
+ expect(downloadFile).toHaveBeenCalledWith(expect.any(Blob), "charts.zip"),
222
+ );
223
+
224
+ expect(
225
+ document.documentElement.classList.contains("pf-v6-theme-dark"),
226
+ ).toBe(true);
227
+ document.documentElement.classList.remove("pf-v6-theme-dark");
228
+ });
229
+
184
230
  it("re-registers charts after React Strict Mode's extra effect cycle", async () => {
185
231
  const user = userEvent.setup();
186
232
  const capture = vi.fn(async () => fakeCanvas());
@@ -236,6 +282,68 @@ describe("ChartExportProvider", () => {
236
282
  expect(downloadFile).toHaveBeenCalledWith(expect.any(Blob), "report.pdf");
237
283
  });
238
284
 
285
+ it("forwards extra text pages (e.g. cluster sizing recommendations) to the pdf builder", async () => {
286
+ const user = userEvent.setup();
287
+ const capture = vi.fn(async () => fakeCanvas());
288
+ const buildPdf = vi.fn(async () => new Blob(["pdf"]));
289
+ const downloadFile = vi.fn();
290
+ const extraPages = [
291
+ {
292
+ title: "Cluster sizing recommendations",
293
+ items: [
294
+ { label: "Cluster name", value: "cluster-1" },
295
+ { label: "Target platform", value: "Bare metal" },
296
+ ],
297
+ footer: "Note: estimates only.",
298
+ },
299
+ ];
300
+
301
+ render(
302
+ <ChartExportProvider
303
+ capture={capture}
304
+ zipFiles={vi.fn()}
305
+ buildPdf={buildPdf}
306
+ downloadFile={downloadFile}
307
+ getPdfFilename={() => "report.pdf"}
308
+ >
309
+ <RegisteredChart id="storage" title="Disks" />
310
+ <DownloadPdfButton extraPages={extraPages} />
311
+ </ChartExportProvider>,
312
+ );
313
+
314
+ await user.click(screen.getByRole("button", { name: "Download pdf" }));
315
+
316
+ expect(buildPdf).toHaveBeenCalledTimes(1);
317
+ expect(buildPdf.mock.calls[0]?.[2]).toEqual(extraPages);
318
+ expect(downloadFile).toHaveBeenCalledWith(expect.any(Blob), "report.pdf");
319
+ });
320
+
321
+ it("builds a PDF from extra text pages alone when no charts are registered", async () => {
322
+ const user = userEvent.setup();
323
+ const buildPdf = vi.fn(async () => new Blob(["pdf"]));
324
+ const downloadFile = vi.fn();
325
+ const extraPages = [{ title: "Cluster sizing recommendations", items: [] }];
326
+
327
+ render(
328
+ <ChartExportProvider
329
+ capture={vi.fn()}
330
+ zipFiles={vi.fn()}
331
+ buildPdf={buildPdf}
332
+ downloadFile={downloadFile}
333
+ getPdfFilename={() => "report.pdf"}
334
+ >
335
+ <DownloadPdfButton extraPages={extraPages} />
336
+ </ChartExportProvider>,
337
+ );
338
+
339
+ await user.click(screen.getByRole("button", { name: "Download pdf" }));
340
+
341
+ expect(buildPdf).toHaveBeenCalledTimes(1);
342
+ expect(buildPdf.mock.calls[0]?.[0]).toEqual([]);
343
+ expect(buildPdf.mock.calls[0]?.[2]).toEqual(extraPages);
344
+ expect(downloadFile).toHaveBeenCalledWith(expect.any(Blob), "report.pdf");
345
+ });
346
+
239
347
  it("builds HTML from the same captured canvases", async () => {
240
348
  const user = userEvent.setup();
241
349
  const capture = vi.fn(async () => fakeCanvas());
@@ -0,0 +1,145 @@
1
+ import "@testing-library/jest-dom";
2
+ import {
3
+ chart_color_blue_300,
4
+ chart_color_green_300,
5
+ chart_color_purple_100,
6
+ chart_color_purple_300,
7
+ chart_color_teal_200,
8
+ chart_color_yellow_400,
9
+ } from "@patternfly/react-tokens";
10
+ import { cleanup, render, screen } from "@testing-library/react";
11
+ import { afterEach, describe, expect, it, vi } from "vitest";
12
+ import type { MigrationDonutChartProps } from "../../charts/MigrationDonutChart.js";
13
+ import { HostsOverview } from "../HostsOverview.js";
14
+
15
+ const donutProps = vi.hoisted(() => ({
16
+ current: null as MigrationDonutChartProps | null,
17
+ }));
18
+
19
+ vi.mock("../../charts/MigrationDonutChart.js", () => ({
20
+ MigrationDonutChart: (props: MigrationDonutChartProps): JSX.Element => {
21
+ donutProps.current = props;
22
+ return (
23
+ <div data-testid="hosts-donut">
24
+ {props.title} {props.subTitle}
25
+ </div>
26
+ );
27
+ },
28
+ }));
29
+
30
+ afterEach(() => {
31
+ donutProps.current = null;
32
+ cleanup();
33
+ });
34
+
35
+ const renderedDonut = (): MigrationDonutChartProps => {
36
+ const props = donutProps.current;
37
+ if (!props) {
38
+ throw new Error("expected the host model donut to render");
39
+ }
40
+ return props;
41
+ };
42
+
43
+ describe("HostsOverview", () => {
44
+ it("shows the shared empty state when no hosts are available", () => {
45
+ render(<HostsOverview />);
46
+
47
+ expect(screen.getByText("Host distribution by model")).toBeInTheDocument();
48
+ expect(screen.getByText("Top 5 models")).toBeInTheDocument();
49
+ expect(screen.getByText("Host data not collected")).toBeInTheDocument();
50
+ expect(screen.queryByTestId("hosts-donut")).not.toBeInTheDocument();
51
+ });
52
+
53
+ it("buckets host counts into the top 5 models plus Other models", () => {
54
+ render(
55
+ <HostsOverview
56
+ hosts={[
57
+ { model: "M1" },
58
+ { model: "M1" },
59
+ { model: "M1" },
60
+ { model: "M1" },
61
+ { model: "M1" },
62
+ { model: "M1" },
63
+ { model: "M2" },
64
+ { model: "M2" },
65
+ { model: "M2" },
66
+ { model: "M2" },
67
+ { model: "M2" },
68
+ { model: "M3" },
69
+ { model: "M3" },
70
+ { model: "M3" },
71
+ { model: "M3" },
72
+ { model: "M4" },
73
+ { model: "M4" },
74
+ { model: "M4" },
75
+ { model: "M5" },
76
+ { model: "M5" },
77
+ { model: "M6" },
78
+ { model: "M7" },
79
+ ]}
80
+ />,
81
+ );
82
+
83
+ expect(screen.getByTestId("hosts-donut")).toHaveTextContent("22 Hosts");
84
+
85
+ const props = renderedDonut();
86
+ expect(props.data.map((slice) => [slice.name, slice.count])).toEqual([
87
+ ["M1", 6],
88
+ ["M2", 5],
89
+ ["M3", 4],
90
+ ["M4", 3],
91
+ ["M5", 2],
92
+ ["Other models", 2],
93
+ ]);
94
+ expect(props.data[5]?.countDisplay).toBe("2 hosts");
95
+ expect(props.legend).toEqual({
96
+ M1: chart_color_blue_300.value,
97
+ M2: chart_color_purple_300.value,
98
+ M3: chart_color_purple_100.value,
99
+ M4: chart_color_teal_200.value,
100
+ M5: chart_color_yellow_400.value,
101
+ "Other models": chart_color_green_300.value,
102
+ });
103
+ expect(props.legendVariant).toBe("html");
104
+ expect(props.height).toBe(300);
105
+ expect(props.width).toBe(420);
106
+ expect(props.donutThickness).toBe(18);
107
+ expect(props.titleFontSize).toBe(34);
108
+ expect(props.legendWidth).toBe(680);
109
+ expect(props.itemsPerRow).toBe(2);
110
+ expect(props.labelFontSize).toBe(16);
111
+ expect(props.marginLeft).toBe("0%");
112
+ expect(props.subTitleColor).toBe(
113
+ "var(--pf-t--global--text--color--subtle)",
114
+ );
115
+ expect(
116
+ props.tooltipLabelFormatter?.({
117
+ datum: {
118
+ x: "M1",
119
+ y: 6,
120
+ countDisplay: "6 hosts",
121
+ legendCategory: "M1",
122
+ },
123
+ percent: 27.2727,
124
+ total: 22,
125
+ }),
126
+ ).toBe("6 hosts\n27.3%");
127
+ });
128
+
129
+ it("treats numeric models as labels and blank models as unknown", () => {
130
+ render(
131
+ <HostsOverview
132
+ legendVariant="chart"
133
+ hosts={[{ model: 42 }, { model: " " }, { model: " R640 " }]}
134
+ />,
135
+ );
136
+
137
+ expect(renderedDonut().data.map((slice) => slice.name)).toEqual([
138
+ "42",
139
+ "Unknown model",
140
+ "R640",
141
+ ]);
142
+ expect(renderedDonut().legendVariant).toBe("chart");
143
+ expect(renderedDonut().title).toBe("3");
144
+ });
145
+ });
@@ -16,6 +16,12 @@ const mockedHtml2Canvas = vi.mocked(html2canvas);
16
16
  afterEach(() => {
17
17
  mockedHtml2Canvas.mockClear();
18
18
  document.body.replaceChildren();
19
+ document.documentElement.classList.remove(
20
+ "pf-v6-theme-dark",
21
+ "pf-v5-theme-dark",
22
+ );
23
+ document.documentElement.style.colorScheme = "";
24
+ document.body.style.colorScheme = "";
19
25
  });
20
26
 
21
27
  describe("captureChartElement", () => {
@@ -129,4 +135,71 @@ describe("captureChartElement", () => {
129
135
 
130
136
  await captureChartElement(root);
131
137
  });
138
+
139
+ it("applies light theme on the clone and leaves the live document dark", async () => {
140
+ document.documentElement.classList.add("pf-v6-theme-dark");
141
+ const root = document.createElement("div");
142
+ const card = document.createElement("div");
143
+ card.className = "pf-v6-c-card";
144
+ card.style.backgroundColor = "rgb(30, 30, 30)";
145
+ card.style.border = "1px solid rgb(80, 80, 80)";
146
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
147
+ const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
148
+ text.style.fill = "rgb(255, 255, 255)";
149
+ text.textContent = "42";
150
+ svg.append(text);
151
+ card.append(svg);
152
+ root.append(card);
153
+ document.body.append(root);
154
+
155
+ mockedHtml2Canvas.mockImplementation(async (_element, options) => {
156
+ expect(
157
+ document.documentElement.classList.contains("pf-v6-theme-dark"),
158
+ ).toBe(true);
159
+
160
+ const clonedDoc = document.implementation.createHTMLDocument("export");
161
+ clonedDoc.documentElement.classList.add("pf-v6-theme-dark");
162
+ const cloneRoot = clonedDoc.createElement("div");
163
+ const cloneCard = clonedDoc.createElement("div");
164
+ cloneCard.className = "pf-v6-c-card";
165
+ cloneCard.style.backgroundColor = "rgb(30, 30, 30)";
166
+ const cloneSvg = clonedDoc.createElementNS(
167
+ "http://www.w3.org/2000/svg",
168
+ "svg",
169
+ );
170
+ const cloneText = clonedDoc.createElementNS(
171
+ "http://www.w3.org/2000/svg",
172
+ "text",
173
+ );
174
+ cloneText.style.fill = "rgb(255, 255, 255)";
175
+ cloneSvg.append(cloneText);
176
+ cloneCard.append(cloneSvg);
177
+ cloneRoot.append(cloneCard);
178
+ clonedDoc.body.append(cloneRoot);
179
+
180
+ await options?.onclone?.(clonedDoc, cloneRoot);
181
+
182
+ expect(
183
+ clonedDoc.documentElement.classList.contains("pf-v6-theme-dark"),
184
+ ).toBe(false);
185
+ expect(
186
+ document.documentElement.classList.contains("pf-v6-theme-dark"),
187
+ ).toBe(true);
188
+ expect(cloneCard.style.backgroundColor).toBe("rgb(255, 255, 255)");
189
+ expect(cloneCard.style.borderColor).toBe("rgb(224, 224, 224)");
190
+ expect(cloneText.style.fill).toBe("rgb(21, 21, 21)");
191
+
192
+ const canvas = document.createElement("canvas");
193
+ canvas.width = 1;
194
+ canvas.height = 1;
195
+ return canvas;
196
+ });
197
+
198
+ await captureChartElement(root);
199
+
200
+ expect(
201
+ document.documentElement.classList.contains("pf-v6-theme-dark"),
202
+ ).toBe(true);
203
+ expect(card.style.backgroundColor).toBe("rgb(30, 30, 30)");
204
+ });
132
205
  });
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
+ applyLightThemeToDocument,
3
4
  chartExportViewsFromLabels,
4
5
  restoreChartExportViews,
5
6
  sortRegisteredChartsByDocumentOrder,
@@ -7,6 +8,7 @@ import {
7
8
  } from "../chartExport.js";
8
9
  import { getChartExportFilename } from "../chartExportFilenames.js";
9
10
  import { buildHtmlReport, escapeHtml } from "../htmlExport.js";
11
+ import { buildPdfFromCharts } from "../pdfExport.js";
10
12
  import {
11
13
  fitPdfImageSize,
12
14
  placePdfBlock,
@@ -198,6 +200,63 @@ describe("placePdfBlock", () => {
198
200
  });
199
201
  });
200
202
 
203
+ describe("buildPdfFromCharts with extra text pages", () => {
204
+ it("appends a text page even when there are no chart captures", async () => {
205
+ const blob = await buildPdfFromCharts([], "Report", [
206
+ {
207
+ title: "Cluster sizing recommendations",
208
+ items: [
209
+ { label: "Cluster name", value: "cluster-1" },
210
+ { label: "Target platform", value: "Bare metal" },
211
+ {
212
+ label: "Total nodes",
213
+ value: "6 (3 workers + 3 control plane)",
214
+ },
215
+ { label: "Failover capacity", value: "1 failover nodes" },
216
+ { label: "Worker node size", value: "8 CPU, 32 GB memory" },
217
+ {
218
+ label: "Control plane node size",
219
+ value: "16 CPU, 64 GB memory",
220
+ },
221
+ ],
222
+ footer: "Note: estimates only.",
223
+ },
224
+ ]);
225
+
226
+ expect(blob).toBeInstanceOf(Blob);
227
+ expect(blob.size).toBeGreaterThan(0);
228
+ expect(blob.type).toBe("application/pdf");
229
+ });
230
+
231
+ it("paginates extra pages with many rows without throwing", async () => {
232
+ const manyItems = Array.from({ length: 60 }, (_, index) => ({
233
+ label: `Field ${index}`,
234
+ value: `Value ${index}`,
235
+ }));
236
+
237
+ const blob = await buildPdfFromCharts([], "Report", [
238
+ { title: "Cluster sizing recommendations", items: manyItems },
239
+ ]);
240
+
241
+ expect(blob.size).toBeGreaterThan(0);
242
+ });
243
+
244
+ it("supports multiple extra pages (e.g. one per sized cluster)", async () => {
245
+ const blob = await buildPdfFromCharts([], "Report", [
246
+ {
247
+ title: "Cluster sizing recommendations — cluster-a",
248
+ items: [{ label: "Cluster name", value: "cluster-a" }],
249
+ },
250
+ {
251
+ title: "Cluster sizing recommendations — cluster-b",
252
+ items: [{ label: "Cluster name", value: "cluster-b" }],
253
+ },
254
+ ]);
255
+
256
+ expect(blob.size).toBeGreaterThan(0);
257
+ });
258
+ });
259
+
201
260
  describe("buildHtmlReport", () => {
202
261
  it("embeds captured chart images and escapes the title", () => {
203
262
  const html = buildHtmlReport(
@@ -222,3 +281,19 @@ describe("buildHtmlReport", () => {
222
281
  expect(escapeHtml("it's")).toBe("it&#39;s");
223
282
  });
224
283
  });
284
+
285
+ describe("applyLightThemeToDocument", () => {
286
+ it("removes PatternFly dark theme classes from the given document", () => {
287
+ const clone = document.implementation.createHTMLDocument("export");
288
+ clone.documentElement.classList.add("pf-v6-theme-dark");
289
+ clone.body.style.colorScheme = "dark";
290
+
291
+ applyLightThemeToDocument(clone);
292
+
293
+ expect(clone.documentElement.classList.contains("pf-v6-theme-dark")).toBe(
294
+ false,
295
+ );
296
+ expect(clone.documentElement.style.colorScheme).toBe("light");
297
+ expect(clone.body.style.colorScheme).toBe("light");
298
+ });
299
+ });
@@ -1,5 +1,11 @@
1
+ import {
2
+ t_color_gray_20,
3
+ t_color_white,
4
+ t_global_text_color_regular,
5
+ } from "@patternfly/react-tokens";
1
6
  import html2canvas from "html2canvas-pro";
2
7
  import {
8
+ applyLightThemeToDocument,
3
9
  CHART_EXPORT_CAPTURING_ATTR,
4
10
  CHART_EXPORT_SCROLL_ATTR,
5
11
  shouldIgnoreChartExportElement,
@@ -94,6 +100,10 @@ const PADDED_CAPTURE_SELECTOR =
94
100
  ".pf-v6-c-card__title, .pf-v6-c-card__header, .pf-v6-c-card__body";
95
101
  const CARD_CAPTURE_SELECTOR = ".pf-v6-c-card";
96
102
  const TRANSPARENT_FILLS = new Set(["", "transparent", "rgba(0, 0, 0, 0)"]);
103
+ /** PatternFly light-theme chrome. html2canvas inlines live computed SVG styles. */
104
+ const LIGHT_EXPORT_BACKGROUND = t_color_white.value;
105
+ const LIGHT_EXPORT_BORDER = t_color_gray_20.value;
106
+ const LIGHT_EXPORT_TEXT = t_global_text_color_regular.value;
97
107
 
98
108
  function queryCards(root: HTMLElement): HTMLElement[] {
99
109
  const cards = Array.from(
@@ -149,16 +159,34 @@ function readCardBorder(source: HTMLElement): {
149
159
  return { width, style, color };
150
160
  }
151
161
 
152
- function applyCardChrome(source: HTMLElement, dest: HTMLElement): void {
162
+ function applyCardChrome(
163
+ source: HTMLElement,
164
+ dest: HTMLElement,
165
+ colors: "computed" | "light",
166
+ ): void {
153
167
  const computed = window.getComputedStyle(source);
154
168
  dest.style.borderRadius = computed.borderRadius;
155
- dest.style.backgroundColor = resolvedFill(computed.backgroundColor);
156
169
  dest.style.overflow = "hidden";
170
+ dest.style.backgroundColor =
171
+ colors === "light"
172
+ ? LIGHT_EXPORT_BACKGROUND
173
+ : resolvedFill(computed.backgroundColor);
157
174
  const border = readCardBorder(source);
158
175
  if (border) {
159
176
  dest.style.borderWidth = border.width;
160
177
  dest.style.borderStyle = border.style;
161
- dest.style.borderColor = border.color;
178
+ dest.style.borderColor =
179
+ colors === "light" ? LIGHT_EXPORT_BORDER : border.color;
180
+ }
181
+ }
182
+
183
+ function applyLightThemeSvgText(root: HTMLElement): void {
184
+ for (const node of root.querySelectorAll("text, tspan")) {
185
+ if (!(node instanceof SVGElement)) {
186
+ continue;
187
+ }
188
+ node.style.fill = LIGHT_EXPORT_TEXT;
189
+ node.style.color = LIGHT_EXPORT_TEXT;
162
190
  }
163
191
  }
164
192
 
@@ -218,7 +246,7 @@ function copyCardChrome(node: HTMLElement): {
218
246
  height: node.style.height,
219
247
  maxHeight: node.style.maxHeight,
220
248
  };
221
- applyCardChrome(node, node);
249
+ applyCardChrome(node, node, "computed");
222
250
  node.style.minHeight = "0px";
223
251
  node.style.height = "auto";
224
252
  node.style.maxHeight = "none";
@@ -234,7 +262,7 @@ function applyComputedStylesToClone(
234
262
  sourceCards.forEach((source, index) => {
235
263
  const dest = cloneCards[index];
236
264
  if (dest) {
237
- applyCardChrome(source, dest);
265
+ applyCardChrome(source, dest, "light");
238
266
  }
239
267
  });
240
268
 
@@ -348,15 +376,17 @@ export async function captureChartElement(
348
376
  }
349
377
  return html2canvas(element, {
350
378
  useCORS: true,
351
- backgroundColor: null,
379
+ backgroundColor: LIGHT_EXPORT_BACKGROUND,
352
380
  logging: false,
353
381
  scale: CHART_CAPTURE_SCALE,
354
382
  imageTimeout: 0,
355
383
  ignoreElements: (node) =>
356
384
  node instanceof Element &&
357
385
  shouldIgnoreChartExportElement(node, element),
358
- onclone: (_clonedDoc, clonedElement) => {
386
+ onclone: (clonedDoc, clonedElement) => {
387
+ applyLightThemeToDocument(clonedDoc);
359
388
  applyComputedStylesToClone(element, clonedElement);
389
+ applyLightThemeSvgText(clonedElement);
360
390
  },
361
391
  });
362
392
  } finally {
@@ -51,6 +51,24 @@ export type ChartCaptureSource = {
51
51
  capture: () => Promise<HTMLCanvasElement>;
52
52
  };
53
53
 
54
+ /** One label/value row rendered on a {@link PdfTextPage}. */
55
+ export type PdfTextPageItem = {
56
+ label: string;
57
+ value: string;
58
+ };
59
+
60
+ /**
61
+ * A native-text PDF page appended after the captured chart images.
62
+ *
63
+ * Used for content that is not a chart/card (e.g. calculated recommendations)
64
+ * but still needs to be part of the exported PDF.
65
+ */
66
+ export type PdfTextPage = {
67
+ title: string;
68
+ items: PdfTextPageItem[];
69
+ footer?: string;
70
+ };
71
+
54
72
  export function releaseCanvas(canvas: HTMLCanvasElement): void {
55
73
  canvas.width = 0;
56
74
  canvas.height = 0;
@@ -67,6 +85,28 @@ export function waitForChartExportPaint(): Promise<void> {
67
85
  });
68
86
  }
69
87
 
88
+ /** PatternFly applies dark tokens when this class is on `:root`. */
89
+ export const PATTERNFLY_DARK_THEME_CLASSES = [
90
+ "pf-v6-theme-dark",
91
+ "pf-v5-theme-dark",
92
+ ] as const;
93
+
94
+ /**
95
+ * Force PatternFly light tokens on a document (the html2canvas clone).
96
+ * Does not touch the live page.
97
+ */
98
+ export function applyLightThemeToDocument(root: Document): void {
99
+ for (const node of [root.documentElement, root.body]) {
100
+ if (!node) {
101
+ continue;
102
+ }
103
+ for (const className of PATTERNFLY_DARK_THEME_CLASSES) {
104
+ node.classList.remove(className);
105
+ }
106
+ node.style.colorScheme = "light";
107
+ }
108
+ }
109
+
70
110
  export function chartExportViewsFromLabels(
71
111
  titlePrefix: string,
72
112
  labels: Record<string, string>,
@@ -9,6 +9,7 @@ import {
9
9
  type ChartExportMeta,
10
10
  type ChartExportView,
11
11
  chartPngFilename,
12
+ type PdfTextPage,
12
13
  type RegisteredChart,
13
14
  } from "./chartExport.js";
14
15
 
@@ -17,7 +18,14 @@ export type ChartExportFormat = "pdf" | "png" | "html";
17
18
  export type ChartExportApi = {
18
19
  downloadChart: (id: string) => Promise<void>;
19
20
  downloadAll: () => Promise<void>;
20
- downloadPdf: (documentTitle: string) => Promise<void>;
21
+ /**
22
+ * @param extraPages - Native-text pages appended after the captured chart
23
+ * images (e.g. calculated recommendations that aren't rendered as charts).
24
+ */
25
+ downloadPdf: (
26
+ documentTitle: string,
27
+ extraPages?: PdfTextPage[],
28
+ ) => Promise<void>;
21
29
  downloadHtml: (documentTitle: string) => Promise<void>;
22
30
  downloadingChartId: string | null;
23
31
  exportingFormat: ChartExportFormat | null;
@@ -25,6 +25,8 @@ export const REPORT_CARD_EMPTY_STATE_TITLES = {
25
25
  operatingSystems: "Operating system data not collected",
26
26
  migrationStatus: "Migration status data not collected",
27
27
  issuesBreakdown: "Issues breakdown data not collected",
28
+ errors: "Error data not collected",
29
+ warnings: "Warning data not collected",
28
30
  hostPowerStates: "Host power state data not collected",
29
31
  vmPowerStates: "VM power state data not collected",
30
32
  } as const;