@openshift-migration-advisor/shared-components 0.28.0-9da6a7613f16 → 0.28.0-cc35dd2cf135

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.
@@ -1,5 +1,7 @@
1
1
  import {
2
+ Divider,
2
3
  Dropdown,
4
+ DropdownGroup,
3
5
  DropdownItem,
4
6
  DropdownList,
5
7
  MenuToggle,
@@ -7,13 +9,41 @@ import {
7
9
  Spinner,
8
10
  } from "@patternfly/react-core";
9
11
  import { DownloadIcon } from "@patternfly/react-icons";
10
- import { type FC, type Ref, useState } from "react";
12
+ import { type FC, type ReactNode, type Ref, useMemo, useState } from "react";
11
13
  import {
12
14
  type ReportExportOption,
15
+ type ReportExportSection,
13
16
  type StandardReportExportHandlers,
14
17
  standardReportExportOptions,
15
18
  } from "./reportExportOptions.js";
16
19
 
20
+ function sectionGroupKey(section?: ReportExportSection): string | undefined {
21
+ return section?.id;
22
+ }
23
+
24
+ function groupExportOptions(options: ReportExportOption[]): {
25
+ section?: ReportExportSection;
26
+ options: ReportExportOption[];
27
+ }[] {
28
+ const groups: {
29
+ section?: ReportExportSection;
30
+ options: ReportExportOption[];
31
+ }[] = [];
32
+
33
+ for (const option of options) {
34
+ const sectionKey = sectionGroupKey(option.section);
35
+ const last = groups.length > 0 ? groups[groups.length - 1] : undefined;
36
+ const lastKey = last ? sectionGroupKey(last.section) : undefined;
37
+ if (last && lastKey === sectionKey) {
38
+ last.options.push(option);
39
+ } else {
40
+ groups.push({ section: option.section, options: [option] });
41
+ }
42
+ }
43
+
44
+ return groups;
45
+ }
46
+
17
47
  export interface ReportExportMenuProps {
18
48
  options: ReportExportOption[];
19
49
  isLoading?: boolean;
@@ -31,10 +61,44 @@ export const ReportExportMenu: FC<ReportExportMenuProps> = ({
31
61
  }) => {
32
62
  const [isDropdownOpen, setIsDropdownOpen] = useState(false);
33
63
 
64
+ const optionGroups = useMemo(() => groupExportOptions(options), [options]);
65
+
34
66
  if (options.length === 0) {
35
67
  return null;
36
68
  }
37
69
 
70
+ const menuItems: ReactNode[] = [];
71
+ optionGroups.forEach((group, index) => {
72
+ if (index > 0) {
73
+ const dividerKey =
74
+ group.section?.id ?? group.options[0]?.key ?? "ungrouped";
75
+ menuItems.push(
76
+ <Divider key={`divider-before-${dividerKey}`} component="li" />,
77
+ );
78
+ }
79
+
80
+ const items = group.options.map((option) => (
81
+ <DropdownItem
82
+ key={option.key}
83
+ isDisabled={option.isDisabled}
84
+ description={option.description}
85
+ onClick={option.onSelect}
86
+ >
87
+ {option.label}
88
+ </DropdownItem>
89
+ ));
90
+
91
+ if (group.section) {
92
+ menuItems.push(
93
+ <DropdownGroup key={group.section.id} label={group.section.label}>
94
+ {items}
95
+ </DropdownGroup>,
96
+ );
97
+ } else {
98
+ menuItems.push(...items);
99
+ }
100
+ });
101
+
38
102
  return (
39
103
  <Dropdown
40
104
  isOpen={isDropdownOpen}
@@ -70,18 +134,7 @@ export const ReportExportMenu: FC<ReportExportMenuProps> = ({
70
134
  </MenuToggle>
71
135
  )}
72
136
  >
73
- <DropdownList>
74
- {options.map((option) => (
75
- <DropdownItem
76
- key={option.key}
77
- isDisabled={option.isDisabled}
78
- description={option.description}
79
- onClick={option.onSelect}
80
- >
81
- {option.label}
82
- </DropdownItem>
83
- ))}
84
- </DropdownList>
137
+ <DropdownList>{menuItems}</DropdownList>
85
138
  </Dropdown>
86
139
  );
87
140
  };
@@ -94,6 +147,7 @@ export interface ExportReportButtonProps extends StandardReportExportHandlers {
94
147
  loadingLabel?: string | null;
95
148
  isDisabled?: boolean;
96
149
  toggleLabel?: string;
150
+ assessmentSectionLabel?: string;
97
151
  }
98
152
 
99
153
  export const ExportReportButton: FC<ExportReportButtonProps> = ({
@@ -101,6 +155,7 @@ export const ExportReportButton: FC<ExportReportButtonProps> = ({
101
155
  onExportPng,
102
156
  onExportHtml,
103
157
  extraOptions = [],
158
+ assessmentSectionLabel,
104
159
  ...menuProps
105
160
  }) => (
106
161
  <ReportExportMenu
@@ -109,6 +164,7 @@ export const ExportReportButton: FC<ExportReportButtonProps> = ({
109
164
  onExportPdf,
110
165
  onExportPng,
111
166
  onExportHtml,
167
+ assessmentSectionLabel,
112
168
  }),
113
169
  ...extraOptions,
114
170
  ]}
@@ -427,6 +427,20 @@ export const StorageOverview: FC<StorageOverviewProps> = ({
427
427
  .sort((a, b) => orderIndex(a.name) - orderIndex(b.name));
428
428
  }, [diskTypes]);
429
429
 
430
+ const isSharedDisksViewAvailable = totalWithSharedDisks !== 0;
431
+
432
+ // Exclude the "Shared disks" view from the chart export cycle (used to
433
+ // build the PDF/PNG/HTML exports) when that view is disabled in the
434
+ // dropdown, so the exported report stays consistent with what's
435
+ // selectable on screen.
436
+ const availableExportViewLabels = useMemo(() => {
437
+ if (isSharedDisksViewAvailable) {
438
+ return VIEW_MODE_LABELS;
439
+ }
440
+ const { sharedDisks: _sharedDisks, ...rest } = VIEW_MODE_LABELS;
441
+ return rest;
442
+ }, [isSharedDisksViewAvailable]);
443
+
430
444
  const sharedDisksChartData = useMemo(() => {
431
445
  if (
432
446
  totalWithSharedDisks === undefined ||
@@ -489,7 +503,10 @@ export const StorageOverview: FC<StorageOverviewProps> = ({
489
503
  <ChartExportSurface
490
504
  id={CHART_ID}
491
505
  title={chartTitle}
492
- exportViews={chartExportViewsFromLabels(CHART_TITLE, VIEW_MODE_LABELS)}
506
+ exportViews={chartExportViewsFromLabels(
507
+ CHART_TITLE,
508
+ availableExportViewLabels,
509
+ )}
493
510
  activeExportViewId={viewMode}
494
511
  onExportViewChange={(viewId) => {
495
512
  if (isViewMode(viewId)) {
@@ -0,0 +1,358 @@
1
+ import { css } from "@emotion/css";
2
+ import {
3
+ Card,
4
+ CardBody,
5
+ CardTitle,
6
+ Content,
7
+ Dropdown,
8
+ DropdownItem,
9
+ DropdownList,
10
+ Flex,
11
+ FlexItem,
12
+ MenuToggle,
13
+ type MenuToggleElement,
14
+ } from "@patternfly/react-core";
15
+ import { VirtualMachineIcon } from "@patternfly/react-icons";
16
+ import {
17
+ chart_color_blue_300,
18
+ chart_color_purple_100,
19
+ chart_color_purple_300,
20
+ chart_color_teal_200,
21
+ chart_color_yellow_400,
22
+ } from "@patternfly/react-tokens";
23
+ import type { FC, Ref } from "react";
24
+ import { useMemo, useState } from "react";
25
+ import {
26
+ MigrationDonutChart,
27
+ type MigrationDonutChartLegendVariant,
28
+ } from "../charts/MigrationDonutChart.js";
29
+ import { CardEmptyState } from "./CardEmptyState.js";
30
+ import { ChartHeaderActions } from "./ChartDownloadButton.js";
31
+ import { ChartExportSurface } from "./ChartExportSurface.js";
32
+ import { chartExportViewsFromLabels } from "./chartExport.js";
33
+ import {
34
+ chartColorFailure,
35
+ chartColorSuccess,
36
+ REPORT_CARD_EMPTY_STATE_TITLES,
37
+ } from "./constants.js";
38
+ import { dashboardStyles } from "./dashboardStyles.js";
39
+
40
+ /**
41
+ * VM-issue counts this card reads. agent-sdk's `IssuesBreakdown` satisfies it;
42
+ * each field stays optional so older or partial payloads do not throw.
43
+ */
44
+ export interface IssuesBreakdownLike {
45
+ critical?: number;
46
+ error?: number;
47
+ warning?: number;
48
+ information?: number;
49
+ advisory?: number;
50
+ }
51
+
52
+ export interface VMMigrationStatusProps {
53
+ data: {
54
+ migratable: number;
55
+ nonMigratable: number;
56
+ };
57
+ issuesBreakdown?: IssuesBreakdownLike;
58
+ /**
59
+ * Donut-slice click. `true` for the migratable slice, `false` otherwise.
60
+ * Agent-ui maps it to a VM migration-readiness filter; ui-app omits it.
61
+ */
62
+ onReadinessSelect?: (isMigratable: boolean) => void;
63
+ /** Breakdown-bar click. The argument is the concern category label. */
64
+ onCategorySelect?: (category: string) => void;
65
+ /** Center-title click. Agent-ui opens the VM list with no extra filters. */
66
+ onTitleClick?: () => void;
67
+ /** `"html"` in agent-ui; ui-app passes `"chart"`. */
68
+ legendVariant?: MigrationDonutChartLegendVariant;
69
+ }
70
+
71
+ type ViewMode = "issuesVsNoIssues" | "issuesBreakdown";
72
+
73
+ const VIEW_MODE_LABELS: Record<ViewMode, string> = {
74
+ issuesVsNoIssues: "No issues vs with issues",
75
+ issuesBreakdown: "With issues breakdown",
76
+ };
77
+
78
+ const CHART_ID = "vm-migration-status";
79
+ const CHART_TITLE = "VM migration status";
80
+
81
+ const MIGRATABLE_LABEL = "Migratable";
82
+ const NOT_MIGRATABLE_LABEL = "Not ready for migration";
83
+
84
+ const CATEGORY_ORDER: Array<{ label: string; key: keyof IssuesBreakdownLike }> =
85
+ [
86
+ { label: "Critical", key: "critical" },
87
+ { label: "Error", key: "error" },
88
+ { label: "Warning", key: "warning" },
89
+ { label: "Information", key: "information" },
90
+ { label: "Advisory", key: "advisory" },
91
+ ];
92
+
93
+ /**
94
+ * Same categorical order as the previous breakdown hex palette, mapped onto
95
+ * PatternFly `chart_color_*` tokens (nearest token where the old hex was not
96
+ * itself a token).
97
+ */
98
+ const CATEGORY_COLORS: Record<string, string> = {
99
+ Critical: chart_color_blue_300.value,
100
+ Error: chart_color_purple_300.value,
101
+ Warning: chart_color_purple_100.value,
102
+ Information: chart_color_teal_200.value,
103
+ Advisory: chart_color_yellow_400.value,
104
+ };
105
+
106
+ const menuToggleMinWidth = css`
107
+ min-width: 250px;
108
+ `;
109
+
110
+ const isViewMode = (value: string | number | undefined): value is ViewMode =>
111
+ value === "issuesVsNoIssues" || value === "issuesBreakdown";
112
+
113
+ const toCount = (value: number | undefined): number =>
114
+ typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
115
+
116
+ export const VMMigrationStatus: FC<VMMigrationStatusProps> = ({
117
+ data,
118
+ issuesBreakdown,
119
+ onReadinessSelect,
120
+ onCategorySelect,
121
+ onTitleClick,
122
+ legendVariant = "html",
123
+ }) => {
124
+ const [viewMode, setViewMode] = useState<ViewMode>("issuesVsNoIssues");
125
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
126
+
127
+ const totalVMs = data.migratable + data.nonMigratable;
128
+
129
+ const donutData = [
130
+ {
131
+ name: MIGRATABLE_LABEL,
132
+ count: data.migratable,
133
+ countDisplay: `${data.migratable} VMs`,
134
+ legendCategory: MIGRATABLE_LABEL,
135
+ },
136
+ {
137
+ name: NOT_MIGRATABLE_LABEL,
138
+ count: data.nonMigratable,
139
+ countDisplay: `${data.nonMigratable} VMs`,
140
+ legendCategory: NOT_MIGRATABLE_LABEL,
141
+ },
142
+ ];
143
+
144
+ const legend = {
145
+ [MIGRATABLE_LABEL]: chartColorSuccess,
146
+ [NOT_MIGRATABLE_LABEL]: chartColorFailure,
147
+ };
148
+
149
+ const breakdownData = useMemo(() => {
150
+ if (!issuesBreakdown) {
151
+ return [];
152
+ }
153
+ return CATEGORY_ORDER.map(({ label, key }) => ({
154
+ name: label,
155
+ count: toCount(issuesBreakdown[key]),
156
+ }));
157
+ }, [issuesBreakdown]);
158
+
159
+ const maxCount = useMemo(
160
+ () =>
161
+ breakdownData.length > 0
162
+ ? Math.max(...breakdownData.map((d) => d.count))
163
+ : 0,
164
+ [breakdownData],
165
+ );
166
+
167
+ const chartTitle = `${CHART_TITLE} — ${VIEW_MODE_LABELS[viewMode]}`;
168
+
169
+ return (
170
+ <ChartExportSurface
171
+ id={CHART_ID}
172
+ title={chartTitle}
173
+ exportViews={chartExportViewsFromLabels(CHART_TITLE, VIEW_MODE_LABELS)}
174
+ activeExportViewId={viewMode}
175
+ onExportViewChange={(viewId) => {
176
+ if (isViewMode(viewId)) {
177
+ setViewMode(viewId);
178
+ }
179
+ }}
180
+ >
181
+ <Card className={dashboardStyles.card}>
182
+ <CardTitle>
183
+ <Flex
184
+ alignItems={{ default: "alignItemsCenter" }}
185
+ justifyContent={{ default: "justifyContentSpaceBetween" }}
186
+ >
187
+ <FlexItem>
188
+ <VirtualMachineIcon /> VM Migration Status
189
+ </FlexItem>
190
+ <ChartHeaderActions chartId={CHART_ID} title={chartTitle}>
191
+ <Dropdown
192
+ isOpen={isDropdownOpen}
193
+ onSelect={(_event, value) => {
194
+ if (isViewMode(value)) {
195
+ setViewMode(value);
196
+ }
197
+ setIsDropdownOpen(false);
198
+ }}
199
+ onOpenChange={setIsDropdownOpen}
200
+ toggle={(toggleRef: Ref<MenuToggleElement>) => (
201
+ <MenuToggle
202
+ ref={toggleRef}
203
+ onClick={() => setIsDropdownOpen((open) => !open)}
204
+ isExpanded={isDropdownOpen}
205
+ className={menuToggleMinWidth}
206
+ >
207
+ {VIEW_MODE_LABELS[viewMode]}
208
+ </MenuToggle>
209
+ )}
210
+ >
211
+ <DropdownList>
212
+ <DropdownItem value="issuesVsNoIssues" key="issuesVsNoIssues">
213
+ {VIEW_MODE_LABELS.issuesVsNoIssues}
214
+ </DropdownItem>
215
+ <DropdownItem value="issuesBreakdown" key="issuesBreakdown">
216
+ {VIEW_MODE_LABELS.issuesBreakdown}
217
+ </DropdownItem>
218
+ </DropdownList>
219
+ </Dropdown>
220
+ </ChartHeaderActions>
221
+ </Flex>
222
+ </CardTitle>
223
+ <CardBody className={dashboardStyles.cardBodyScrollable}>
224
+ {viewMode === "issuesVsNoIssues" ? (
225
+ totalVMs === 0 ? (
226
+ <CardEmptyState
227
+ title={REPORT_CARD_EMPTY_STATE_TITLES.migrationStatus}
228
+ />
229
+ ) : (
230
+ <MigrationDonutChart
231
+ legendVariant={legendVariant}
232
+ data={donutData}
233
+ legend={legend}
234
+ height={300}
235
+ width={420}
236
+ donutThickness={18}
237
+ padAngle={1}
238
+ title={`${totalVMs}`}
239
+ subTitle="VMs"
240
+ subTitleColor="var(--pf-t--global--text--color--subtle)"
241
+ titleFontSize={34}
242
+ legendLabelFormatter={({ x, countDisplay }) =>
243
+ `${x} (${countDisplay})`
244
+ }
245
+ onItemClick={
246
+ onReadinessSelect
247
+ ? (item) =>
248
+ onReadinessSelect(item.name === MIGRATABLE_LABEL)
249
+ : undefined
250
+ }
251
+ onTitleClick={onTitleClick}
252
+ />
253
+ )
254
+ ) : breakdownData.length === 0 ? (
255
+ <CardEmptyState
256
+ title={REPORT_CARD_EMPTY_STATE_TITLES.issuesBreakdown}
257
+ />
258
+ ) : (
259
+ <div>
260
+ <div className={dashboardStyles.storageChartWrapper}>
261
+ <Flex
262
+ direction={{ default: "row" }}
263
+ alignItems={{ default: "alignItemsFlexEnd" }}
264
+ justifyContent={{ default: "justifyContentCenter" }}
265
+ spaceItems={{ default: "spaceItemsMd" }}
266
+ style={{
267
+ height: "250px",
268
+ width: "100%",
269
+ }}
270
+ >
271
+ {breakdownData.map((item) => {
272
+ const heightPercentage =
273
+ maxCount > 0 ? (item.count / maxCount) * 100 : 0;
274
+ const minHeightPercentage = item.count > 0 ? 20 : 0;
275
+ const finalHeightPercentage = Math.max(
276
+ heightPercentage,
277
+ minHeightPercentage,
278
+ );
279
+ const barColor =
280
+ CATEGORY_COLORS[item.name] || CATEGORY_COLORS.Critical;
281
+
282
+ return (
283
+ <Flex
284
+ key={item.name}
285
+ direction={{ default: "column" }}
286
+ alignItems={{ default: "alignItemsCenter" }}
287
+ spaceItems={{ default: "spaceItemsSm" }}
288
+ style={{ flex: "1", maxWidth: "120px" }}
289
+ >
290
+ <FlexItem
291
+ style={{
292
+ height: "200px",
293
+ display: "flex",
294
+ alignItems: "flex-end",
295
+ width: "100%",
296
+ justifyContent: "center",
297
+ }}
298
+ >
299
+ <button
300
+ type="button"
301
+ onClick={() => onCategorySelect?.(item.name)}
302
+ title={`${item.name}: ${item.count} VMs`}
303
+ style={{
304
+ width: "60px",
305
+ height: `${finalHeightPercentage}%`,
306
+ backgroundColor: barColor,
307
+ transition: "height 0.3s ease",
308
+ borderRadius: "4px 4px 0 0",
309
+ cursor: "pointer",
310
+ border: "none",
311
+ padding: 0,
312
+ }}
313
+ />
314
+ </FlexItem>
315
+ <FlexItem>
316
+ <Content
317
+ component="small"
318
+ style={{
319
+ fontSize: "12px",
320
+ textAlign: "center",
321
+ wordBreak: "break-word",
322
+ color:
323
+ "var(--pf-t--global--text--color--regular)",
324
+ }}
325
+ >
326
+ {item.name}
327
+ <br />({item.count} VMs)
328
+ </Content>
329
+ </FlexItem>
330
+ </Flex>
331
+ );
332
+ })}
333
+ </Flex>
334
+ </div>
335
+ <div>
336
+ <Content
337
+ component="small"
338
+ style={{
339
+ fontSize: "12px",
340
+ color: "var(--pf-t--global--text--color--subtle)",
341
+ marginTop: "16px",
342
+ textAlign: "center",
343
+ display: "block",
344
+ }}
345
+ >
346
+ Totals may exceed the unique VM count because a VM can appear
347
+ in multiple categories
348
+ </Content>
349
+ </div>
350
+ </div>
351
+ )}
352
+ </CardBody>
353
+ </Card>
354
+ </ChartExportSurface>
355
+ );
356
+ };
357
+
358
+ VMMigrationStatus.displayName = "VMMigrationStatus";
@@ -27,6 +27,23 @@ vi.mock("../../charts/MigrationDonutChart.js", () => ({
27
27
  },
28
28
  }));
29
29
 
30
+ vi.mock("../ChartExportSurface.js", () => ({
31
+ ChartExportSurface: ({
32
+ exportViews,
33
+ children,
34
+ }: {
35
+ exportViews?: Array<{ id: string; title: string }>;
36
+ children?: React.ReactNode;
37
+ }): JSX.Element => (
38
+ <div
39
+ data-testid="chart-export-surface"
40
+ data-export-view-ids={JSON.stringify(exportViews?.map((view) => view.id))}
41
+ >
42
+ {children}
43
+ </div>
44
+ ),
45
+ }));
46
+
30
47
  afterEach(() => {
31
48
  donutProps.current = null;
32
49
  cleanup();
@@ -171,6 +188,33 @@ describe("StorageOverview", () => {
171
188
  });
172
189
  });
173
190
 
191
+ it("excludes the shared disks view from the chart export cycle when disabled, so PDF/PNG/HTML exports stay consistent with the dropdown", () => {
192
+ render(<StorageOverview totalVMs={10} totalWithSharedDisks={0} />);
193
+
194
+ const exportViewIds = JSON.parse(
195
+ screen
196
+ .getByTestId("chart-export-surface")
197
+ .getAttribute("data-export-view-ids") || "[]",
198
+ ) as string[];
199
+
200
+ expect(exportViewIds).not.toContain("sharedDisks");
201
+ expect(exportViewIds).toEqual(
202
+ expect.arrayContaining(["totalSize", "vmCount", "vmCountByDiskType"]),
203
+ );
204
+ });
205
+
206
+ it("includes the shared disks view in the chart export cycle when available", () => {
207
+ render(<StorageOverview totalVMs={10} totalWithSharedDisks={4} />);
208
+
209
+ const exportViewIds = JSON.parse(
210
+ screen
211
+ .getByTestId("chart-export-surface")
212
+ .getAttribute("data-export-view-ids") || "[]",
213
+ ) as string[];
214
+
215
+ expect(exportViewIds).toContain("sharedDisks");
216
+ });
217
+
174
218
  it("disables the shared-disks view when no VMs have shared disks", async () => {
175
219
  const user = userEvent.setup();
176
220
  render(<StorageOverview totalVMs={10} totalWithSharedDisks={0} />);