@openshift-migration-advisor/shared-components 0.27.0 → 0.28.0-9da6a7613f16
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.
- package/dist/charts/MigrationDonutChart.d.ts.map +1 -1
- package/dist/charts/MigrationDonutChart.js +7 -0
- package/dist/report/ChartExportProvider.d.ts +4 -3
- package/dist/report/ChartExportProvider.d.ts.map +1 -1
- package/dist/report/ChartExportProvider.js +7 -4
- package/dist/report/ClustersOverview.d.ts +24 -0
- package/dist/report/ClustersOverview.d.ts.map +1 -0
- package/dist/report/ClustersOverview.js +228 -0
- package/dist/report/ClustersOverviewStyles.d.ts +12 -0
- package/dist/report/ClustersOverviewStyles.d.ts.map +1 -0
- package/dist/report/ClustersOverviewStyles.js +101 -0
- package/dist/report/CpuAndMemoryOverview.d.ts +19 -0
- package/dist/report/CpuAndMemoryOverview.d.ts.map +1 -0
- package/dist/report/CpuAndMemoryOverview.js +112 -0
- package/dist/report/FeatureStatusBadge.js +1 -1
- package/dist/report/StorageOverview.d.ts +27 -0
- package/dist/report/StorageOverview.d.ts.map +1 -0
- package/dist/report/StorageOverview.js +319 -0
- package/dist/report/captureChartElement.d.ts +6 -1
- package/dist/report/captureChartElement.d.ts.map +1 -1
- package/dist/report/captureChartElement.js +41 -12
- package/dist/report/chartExport.d.ts +23 -0
- package/dist/report/chartExport.d.ts.map +1 -1
- package/dist/report/chartExport.js +21 -1
- package/dist/report/chartExportContext.d.ts +6 -2
- package/dist/report/chartExportContext.d.ts.map +1 -1
- package/dist/report/dashboardStyles.d.ts +6 -0
- package/dist/report/dashboardStyles.d.ts.map +1 -1
- package/dist/report/dashboardStyles.js +7 -4
- package/dist/report/index.d.ts +5 -2
- package/dist/report/index.d.ts.map +1 -1
- package/dist/report/index.js +3 -0
- package/dist/report/pdfExport.d.ts +2 -2
- package/dist/report/pdfExport.d.ts.map +1 -1
- package/dist/report/pdfExport.js +52 -3
- package/package.json +2 -2
- package/src/charts/MigrationDonutChart.tsx +9 -0
- package/src/report/ChartExportProvider.tsx +18 -6
- package/src/report/ClustersOverview.tsx +451 -0
- package/src/report/ClustersOverviewStyles.ts +110 -0
- package/src/report/CpuAndMemoryOverview.tsx +277 -0
- package/src/report/FeatureStatusBadge.tsx +1 -1
- package/src/report/StorageOverview.tsx +629 -0
- package/src/report/__tests__/ChartExportProvider.test.tsx +110 -2
- package/src/report/__tests__/ClustersOverview.test.tsx +241 -0
- package/src/report/__tests__/CpuAndMemoryOverview.test.tsx +200 -0
- package/src/report/__tests__/StorageOverview.test.tsx +223 -0
- package/src/report/__tests__/captureChartElement.test.ts +120 -0
- package/src/report/__tests__/chartExportPackagers.test.ts +75 -0
- package/src/report/captureChartElement.ts +55 -10
- package/src/report/chartExport.ts +41 -1
- package/src/report/chartExportContext.tsx +9 -1
- package/src/report/dashboardStyles.ts +7 -4
- package/src/report/index.ts +17 -0
- package/src/report/pdfExport.ts +81 -2
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
import {
|
|
3
|
+
chart_color_blue_300,
|
|
4
|
+
chart_color_purple_100,
|
|
5
|
+
chart_color_purple_300,
|
|
6
|
+
chart_color_teal_300,
|
|
7
|
+
chart_color_yellow_400,
|
|
8
|
+
} from "@patternfly/react-tokens";
|
|
9
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
10
|
+
import userEvent from "@testing-library/user-event";
|
|
11
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
12
|
+
import type { MigrationDonutChartProps } from "../../charts/MigrationDonutChart.js";
|
|
13
|
+
import { StorageOverview } from "../StorageOverview.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="storage-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 a storage donut to render");
|
|
39
|
+
}
|
|
40
|
+
return props;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const selectView = async (name: string): Promise<void> => {
|
|
44
|
+
const user = userEvent.setup();
|
|
45
|
+
await user.click(
|
|
46
|
+
screen.getByRole("button", { name: "VM count by disk size tier" }),
|
|
47
|
+
);
|
|
48
|
+
await user.click(screen.getByRole("menuitem", { name }));
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe("StorageOverview", () => {
|
|
52
|
+
it("shows the storage empty state when no disk tiers were collected", () => {
|
|
53
|
+
render(<StorageOverview />);
|
|
54
|
+
|
|
55
|
+
expect(screen.getByText("Disks")).toBeInTheDocument();
|
|
56
|
+
expect(screen.getByText("Storage data not collected")).toBeInTheDocument();
|
|
57
|
+
expect(screen.queryByTestId("storage-donut")).not.toBeInTheDocument();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("orders VM-count tiers and totals the VM count", () => {
|
|
61
|
+
render(
|
|
62
|
+
<StorageOverview
|
|
63
|
+
diskSizeTier={{
|
|
64
|
+
Hard: { vmCount: 3, totalSizeTB: 30 },
|
|
65
|
+
Easy: { vmCount: 10, totalSizeTB: 5 },
|
|
66
|
+
Medium: { vmCount: 6, totalSizeTB: 15 },
|
|
67
|
+
}}
|
|
68
|
+
/>,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
expect(screen.getByTestId("storage-donut")).toHaveTextContent("19 VMs");
|
|
72
|
+
|
|
73
|
+
const props = renderedDonut();
|
|
74
|
+
expect(props.data.map((slice) => [slice.name, slice.count])).toEqual([
|
|
75
|
+
["0-10 TB", 10],
|
|
76
|
+
["11-20 TB", 6],
|
|
77
|
+
["21-50 TB", 3],
|
|
78
|
+
]);
|
|
79
|
+
expect(props.data[0]?.countDisplay).toBe("10 VMs");
|
|
80
|
+
expect(props.legend).toEqual({
|
|
81
|
+
Easy: chart_color_blue_300.value,
|
|
82
|
+
Medium: chart_color_purple_300.value,
|
|
83
|
+
Hard: chart_color_purple_100.value,
|
|
84
|
+
});
|
|
85
|
+
expect(props.legendVariant).toBe("html");
|
|
86
|
+
expect(props.subTitleColor).toBe(
|
|
87
|
+
"var(--pf-t--global--text--color--subtle)",
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("switches to total disk size and formats TB totals", async () => {
|
|
92
|
+
render(
|
|
93
|
+
<StorageOverview
|
|
94
|
+
diskSizeTier={{
|
|
95
|
+
Easy: { vmCount: 10, totalSizeTB: 5.5 },
|
|
96
|
+
Medium: { vmCount: 6, totalSizeTB: 15 },
|
|
97
|
+
}}
|
|
98
|
+
/>,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
await selectView("Total disk size by tier");
|
|
102
|
+
|
|
103
|
+
const props = renderedDonut();
|
|
104
|
+
expect(props.title).toBe("20.50 TB");
|
|
105
|
+
expect(props.subTitle).toBe("16 VMs");
|
|
106
|
+
expect(props.data[0]?.countDisplay).toBe("5.50 TB");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("drills down into a disk-size range when a tier slice is clicked", () => {
|
|
110
|
+
const onDiskTierSelect = vi.fn();
|
|
111
|
+
render(
|
|
112
|
+
<StorageOverview
|
|
113
|
+
diskSizeTier={{ Easy: { vmCount: 10, totalSizeTB: 5 } }}
|
|
114
|
+
onDiskTierSelect={onDiskTierSelect}
|
|
115
|
+
/>,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const props = renderedDonut();
|
|
119
|
+
const slice = props.data[0];
|
|
120
|
+
if (!slice) {
|
|
121
|
+
throw new Error("expected a tier slice");
|
|
122
|
+
}
|
|
123
|
+
props.onItemClick?.(slice);
|
|
124
|
+
|
|
125
|
+
expect(onDiskTierSelect).toHaveBeenCalledWith({
|
|
126
|
+
min: 0,
|
|
127
|
+
max: 10 * 1024 * 1024,
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("renders the disk-type bar chart and its empty state", async () => {
|
|
132
|
+
const { rerender } = render(
|
|
133
|
+
<StorageOverview
|
|
134
|
+
diskTypes={{ NFS: { vmCount: 2 }, VMFS: { vmCount: 5 } }}
|
|
135
|
+
/>,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
await selectView("VM count by disk type");
|
|
139
|
+
|
|
140
|
+
// Preferred order places VMFS before NFS.
|
|
141
|
+
const vmfs = screen.getByText("VMFS");
|
|
142
|
+
const nfs = screen.getByText("NFS");
|
|
143
|
+
expect(vmfs.compareDocumentPosition(nfs)).toBe(
|
|
144
|
+
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
145
|
+
);
|
|
146
|
+
expect(
|
|
147
|
+
screen.getByText(/Totals may exceed the unique VM count/),
|
|
148
|
+
).toBeInTheDocument();
|
|
149
|
+
|
|
150
|
+
rerender(<StorageOverview diskTypes={{}} />);
|
|
151
|
+
expect(
|
|
152
|
+
screen.getByText("Disk type data not collected"),
|
|
153
|
+
).toBeInTheDocument();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("renders the shared-disks donut", async () => {
|
|
157
|
+
render(<StorageOverview totalVMs={10} totalWithSharedDisks={4} />);
|
|
158
|
+
|
|
159
|
+
await selectView("Shared disks VS. No shared disks");
|
|
160
|
+
|
|
161
|
+
const props = renderedDonut();
|
|
162
|
+
expect(props.title).toBe("10 VMs");
|
|
163
|
+
expect(props.subTitle).toBe("4 with shared disks");
|
|
164
|
+
expect(props.data.map((slice) => [slice.name, slice.count])).toEqual([
|
|
165
|
+
["With shared disks", 4],
|
|
166
|
+
["No shared disks", 6],
|
|
167
|
+
]);
|
|
168
|
+
expect(props.customColors).toEqual({
|
|
169
|
+
"With shared disks": chart_color_yellow_400.value,
|
|
170
|
+
"No shared disks": chart_color_blue_300.value,
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("disables the shared-disks view when no VMs have shared disks", async () => {
|
|
175
|
+
const user = userEvent.setup();
|
|
176
|
+
render(<StorageOverview totalVMs={10} totalWithSharedDisks={0} />);
|
|
177
|
+
|
|
178
|
+
await user.click(
|
|
179
|
+
screen.getByRole("button", { name: "VM count by disk size tier" }),
|
|
180
|
+
);
|
|
181
|
+
const sharedItem = screen.getByRole("menuitem", {
|
|
182
|
+
name: "Shared disks VS. No shared disks",
|
|
183
|
+
});
|
|
184
|
+
expect(sharedItem).toBeDisabled();
|
|
185
|
+
|
|
186
|
+
await user.click(sharedItem);
|
|
187
|
+
// The disabled item does not switch the active view.
|
|
188
|
+
expect(
|
|
189
|
+
screen.getByRole("button", { name: "VM count by disk size tier" }),
|
|
190
|
+
).toBeInTheDocument();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("passes the chart legend variant through", () => {
|
|
194
|
+
render(
|
|
195
|
+
<StorageOverview
|
|
196
|
+
legendVariant="chart"
|
|
197
|
+
diskSizeTier={{ Easy: { vmCount: 2, totalSizeTB: 1 } }}
|
|
198
|
+
/>,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
expect(renderedDonut().legendVariant).toBe("chart");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("keeps the configured tier palette aligned with the categorical order", () => {
|
|
205
|
+
render(
|
|
206
|
+
<StorageOverview
|
|
207
|
+
diskSizeTier={{
|
|
208
|
+
Easy: { vmCount: 1 },
|
|
209
|
+
Medium: { vmCount: 1 },
|
|
210
|
+
Hard: { vmCount: 1 },
|
|
211
|
+
White: { vmCount: 1 },
|
|
212
|
+
}}
|
|
213
|
+
/>,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
expect(renderedDonut().legend).toEqual({
|
|
217
|
+
Easy: chart_color_blue_300.value,
|
|
218
|
+
Medium: chart_color_purple_300.value,
|
|
219
|
+
Hard: chart_color_purple_100.value,
|
|
220
|
+
"White glove": chart_color_teal_300.value,
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -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,118 @@ 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
|
+
});
|
|
205
|
+
|
|
206
|
+
it("relights tables and labels via the clone cascade, not by hand-painting them", async () => {
|
|
207
|
+
document.documentElement.classList.add("pf-v6-theme-dark");
|
|
208
|
+
const root = document.createElement("div");
|
|
209
|
+
const label = document.createElement("span");
|
|
210
|
+
label.className = "pf-v6-c-label pf-m-compact pf-m-outline";
|
|
211
|
+
const labelContent = document.createElement("span");
|
|
212
|
+
labelContent.className = "pf-v6-c-label__content";
|
|
213
|
+
label.append(labelContent);
|
|
214
|
+
root.append(label);
|
|
215
|
+
document.body.append(root);
|
|
216
|
+
|
|
217
|
+
mockedHtml2Canvas.mockImplementation(async (_element, options) => {
|
|
218
|
+
const clonedDoc = document.implementation.createHTMLDocument("export");
|
|
219
|
+
clonedDoc.documentElement.classList.add("pf-v6-theme-dark");
|
|
220
|
+
const cloneRoot = clonedDoc.createElement("div");
|
|
221
|
+
const cloneTh = clonedDoc.createElement("th");
|
|
222
|
+
cloneTh.className = "pf-v6-c-table__th";
|
|
223
|
+
const cloneLabelContent = clonedDoc.createElement("span");
|
|
224
|
+
cloneLabelContent.className = "pf-v6-c-label__content";
|
|
225
|
+
cloneRoot.append(cloneTh, cloneLabelContent);
|
|
226
|
+
clonedDoc.body.append(cloneRoot);
|
|
227
|
+
|
|
228
|
+
await options?.onclone?.(clonedDoc, cloneRoot);
|
|
229
|
+
|
|
230
|
+
// Dropping the dark class off the clone root is what relights these
|
|
231
|
+
// surfaces — the cascade does the work. We must NOT hand-paint inline
|
|
232
|
+
// colors onto PatternFly table/label nodes, and the live page stays dark.
|
|
233
|
+
expect(
|
|
234
|
+
clonedDoc.documentElement.classList.contains("pf-v6-theme-dark"),
|
|
235
|
+
).toBe(false);
|
|
236
|
+
expect(cloneTh.style.backgroundColor).toBe("");
|
|
237
|
+
expect(cloneLabelContent.style.backgroundColor).toBe("");
|
|
238
|
+
|
|
239
|
+
const canvas = document.createElement("canvas");
|
|
240
|
+
canvas.width = 1;
|
|
241
|
+
canvas.height = 1;
|
|
242
|
+
return canvas;
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await captureChartElement(root);
|
|
246
|
+
|
|
247
|
+
expect(
|
|
248
|
+
document.documentElement.classList.contains("pf-v6-theme-dark"),
|
|
249
|
+
).toBe(true);
|
|
250
|
+
expect(labelContent.style.backgroundColor).toBe("");
|
|
251
|
+
});
|
|
132
252
|
});
|
|
@@ -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'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,12 +1,25 @@
|
|
|
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,
|
|
6
12
|
} from "./chartExport.js";
|
|
7
13
|
|
|
8
|
-
/**
|
|
9
|
-
const
|
|
14
|
+
/** Default scale for bulk exports; single-chart PNG uses a higher scale. */
|
|
15
|
+
const CHART_CAPTURE_SCALE_DEFAULT = 1;
|
|
16
|
+
export const CHART_CAPTURE_SCALE_SINGLE = 2;
|
|
17
|
+
const EXPORT_FONT_FAMILY = "RedHatText, Overpass, Helvetica, Arial, sans-serif";
|
|
18
|
+
|
|
19
|
+
export type CaptureChartElementOptions = {
|
|
20
|
+
/** Higher scale sharpens text in PNG exports; keep at 1 for bulk PDF/zip. */
|
|
21
|
+
scale?: number;
|
|
22
|
+
};
|
|
10
23
|
const HIDDEN_LAYOUT_STYLE_KEYS = [
|
|
11
24
|
"display",
|
|
12
25
|
"position",
|
|
@@ -94,6 +107,10 @@ const PADDED_CAPTURE_SELECTOR =
|
|
|
94
107
|
".pf-v6-c-card__title, .pf-v6-c-card__header, .pf-v6-c-card__body";
|
|
95
108
|
const CARD_CAPTURE_SELECTOR = ".pf-v6-c-card";
|
|
96
109
|
const TRANSPARENT_FILLS = new Set(["", "transparent", "rgba(0, 0, 0, 0)"]);
|
|
110
|
+
/** PatternFly light-theme chrome. html2canvas inlines live computed SVG styles. */
|
|
111
|
+
const LIGHT_EXPORT_BACKGROUND = t_color_white.value;
|
|
112
|
+
const LIGHT_EXPORT_BORDER = t_color_gray_20.value;
|
|
113
|
+
const LIGHT_EXPORT_TEXT = t_global_text_color_regular.value;
|
|
97
114
|
|
|
98
115
|
function queryCards(root: HTMLElement): HTMLElement[] {
|
|
99
116
|
const cards = Array.from(
|
|
@@ -149,16 +166,35 @@ function readCardBorder(source: HTMLElement): {
|
|
|
149
166
|
return { width, style, color };
|
|
150
167
|
}
|
|
151
168
|
|
|
152
|
-
function applyCardChrome(
|
|
169
|
+
function applyCardChrome(
|
|
170
|
+
source: HTMLElement,
|
|
171
|
+
dest: HTMLElement,
|
|
172
|
+
colors: "computed" | "light",
|
|
173
|
+
): void {
|
|
153
174
|
const computed = window.getComputedStyle(source);
|
|
154
175
|
dest.style.borderRadius = computed.borderRadius;
|
|
155
|
-
dest.style.backgroundColor = resolvedFill(computed.backgroundColor);
|
|
156
176
|
dest.style.overflow = "hidden";
|
|
177
|
+
dest.style.backgroundColor =
|
|
178
|
+
colors === "light"
|
|
179
|
+
? LIGHT_EXPORT_BACKGROUND
|
|
180
|
+
: resolvedFill(computed.backgroundColor);
|
|
157
181
|
const border = readCardBorder(source);
|
|
158
182
|
if (border) {
|
|
159
183
|
dest.style.borderWidth = border.width;
|
|
160
184
|
dest.style.borderStyle = border.style;
|
|
161
|
-
dest.style.borderColor =
|
|
185
|
+
dest.style.borderColor =
|
|
186
|
+
colors === "light" ? LIGHT_EXPORT_BORDER : border.color;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function applyLightThemeSvgText(root: HTMLElement): void {
|
|
191
|
+
for (const node of root.querySelectorAll("text, tspan")) {
|
|
192
|
+
if (!(node instanceof SVGElement)) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
node.style.fill = LIGHT_EXPORT_TEXT;
|
|
196
|
+
node.style.color = LIGHT_EXPORT_TEXT;
|
|
197
|
+
node.style.fontFamily = EXPORT_FONT_FAMILY;
|
|
162
198
|
}
|
|
163
199
|
}
|
|
164
200
|
|
|
@@ -218,7 +254,7 @@ function copyCardChrome(node: HTMLElement): {
|
|
|
218
254
|
height: node.style.height,
|
|
219
255
|
maxHeight: node.style.maxHeight,
|
|
220
256
|
};
|
|
221
|
-
applyCardChrome(node, node);
|
|
257
|
+
applyCardChrome(node, node, "computed");
|
|
222
258
|
node.style.minHeight = "0px";
|
|
223
259
|
node.style.height = "auto";
|
|
224
260
|
node.style.maxHeight = "none";
|
|
@@ -234,7 +270,7 @@ function applyComputedStylesToClone(
|
|
|
234
270
|
sourceCards.forEach((source, index) => {
|
|
235
271
|
const dest = cloneCards[index];
|
|
236
272
|
if (dest) {
|
|
237
|
-
applyCardChrome(source, dest);
|
|
273
|
+
applyCardChrome(source, dest, "light");
|
|
238
274
|
}
|
|
239
275
|
});
|
|
240
276
|
|
|
@@ -331,7 +367,9 @@ function prepareCaptureLayout(element: HTMLElement): () => void {
|
|
|
331
367
|
|
|
332
368
|
export async function captureChartElement(
|
|
333
369
|
element: HTMLElement,
|
|
370
|
+
options: CaptureChartElementOptions = {},
|
|
334
371
|
): Promise<HTMLCanvasElement> {
|
|
372
|
+
const scale = options.scale ?? CHART_CAPTURE_SCALE_DEFAULT;
|
|
335
373
|
const restoreHidden = revealHiddenChartAncestors(element);
|
|
336
374
|
const restoreLayout = prepareCaptureLayout(element);
|
|
337
375
|
element.setAttribute(CHART_EXPORT_CAPTURING_ATTR, "");
|
|
@@ -348,15 +386,22 @@ export async function captureChartElement(
|
|
|
348
386
|
}
|
|
349
387
|
return html2canvas(element, {
|
|
350
388
|
useCORS: true,
|
|
351
|
-
backgroundColor:
|
|
389
|
+
backgroundColor: LIGHT_EXPORT_BACKGROUND,
|
|
352
390
|
logging: false,
|
|
353
|
-
scale
|
|
391
|
+
scale,
|
|
354
392
|
imageTimeout: 0,
|
|
355
393
|
ignoreElements: (node) =>
|
|
356
394
|
node instanceof Element &&
|
|
357
395
|
shouldIgnoreChartExportElement(node, element),
|
|
358
|
-
onclone: (
|
|
396
|
+
onclone: (clonedDoc, clonedElement) => {
|
|
397
|
+
// Dropping the dark-theme class off the clone root makes every
|
|
398
|
+
// PatternFly CSS-variable surface (cards, tables, labels) resolve to
|
|
399
|
+
// light automatically — html2canvas reads computed styles from this
|
|
400
|
+
// clone. Only Victory's inlined SVG text needs explicit recoloring.
|
|
401
|
+
applyLightThemeToDocument(clonedDoc);
|
|
359
402
|
applyComputedStylesToClone(element, clonedElement);
|
|
403
|
+
applyLightThemeSvgText(clonedElement);
|
|
404
|
+
clonedElement.style.fontFamily = EXPORT_FONT_FAMILY;
|
|
360
405
|
},
|
|
361
406
|
});
|
|
362
407
|
} finally {
|
|
@@ -14,7 +14,7 @@ export const chartExportScrollProps = {
|
|
|
14
14
|
|
|
15
15
|
export const chartExportRootStyle: CSSProperties = {
|
|
16
16
|
width: "100%",
|
|
17
|
-
height: "
|
|
17
|
+
height: "auto",
|
|
18
18
|
};
|
|
19
19
|
|
|
20
20
|
export type ChartExportMeta = {
|
|
@@ -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
|
-
|
|
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;
|
|
@@ -30,6 +30,12 @@ export const dashboardStyles = {
|
|
|
30
30
|
height: 100%;
|
|
31
31
|
`,
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Flex child of {@link dashboardStyles.card}. `min-height: 0` lets the body
|
|
35
|
+
* shrink inside the card max-height; `overflow: auto` scrolls tables and tall
|
|
36
|
+
* charts when needed (phantom scrollbars on donut cards are avoided by the
|
|
37
|
+
* card layout and ChartExportSurface sizing, not a second body class).
|
|
38
|
+
*/
|
|
33
39
|
cardBodyScrollable: css`
|
|
34
40
|
overflow: auto;
|
|
35
41
|
`,
|
|
@@ -38,12 +44,9 @@ export const dashboardStyles = {
|
|
|
38
44
|
// at the same specificity, which wins in the production build due to stylesheet
|
|
39
45
|
// injection order. `!important` forces `hidden` regardless of that order.
|
|
40
46
|
card: css`
|
|
41
|
-
|
|
42
|
-
max-height: 520px;
|
|
43
|
-
height: 100%;
|
|
47
|
+
height: 520px;
|
|
44
48
|
display: flex;
|
|
45
49
|
flex-direction: column;
|
|
46
|
-
justify-content: space-between;
|
|
47
50
|
overflow: hidden !important;
|
|
48
51
|
`,
|
|
49
52
|
|