@openshift-migration-advisor/shared-components 0.0.0

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 (105) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +76 -0
  3. package/dist/charts/MigrationDonutChart.d.ts +53 -0
  4. package/dist/charts/MigrationDonutChart.d.ts.map +1 -0
  5. package/dist/charts/MigrationDonutChart.js +186 -0
  6. package/dist/charts/index.d.ts +2 -0
  7. package/dist/charts/index.d.ts.map +1 -0
  8. package/dist/charts/index.js +1 -0
  9. package/dist/forms/CheckboxFormGroup.d.ts +5 -0
  10. package/dist/forms/CheckboxFormGroup.d.ts.map +1 -0
  11. package/dist/forms/CheckboxFormGroup.js +14 -0
  12. package/dist/forms/FormFieldHelperText.d.ts +7 -0
  13. package/dist/forms/FormFieldHelperText.d.ts.map +1 -0
  14. package/dist/forms/FormFieldHelperText.js +8 -0
  15. package/dist/forms/RadioButtonFormGroup.d.ts +12 -0
  16. package/dist/forms/RadioButtonFormGroup.d.ts.map +1 -0
  17. package/dist/forms/RadioButtonFormGroup.js +12 -0
  18. package/dist/forms/SelectFormGroup.d.ts +8 -0
  19. package/dist/forms/SelectFormGroup.d.ts.map +1 -0
  20. package/dist/forms/SelectFormGroup.js +12 -0
  21. package/dist/forms/TextAreaFormGroup.d.ts +3 -0
  22. package/dist/forms/TextAreaFormGroup.d.ts.map +1 -0
  23. package/dist/forms/TextAreaFormGroup.js +12 -0
  24. package/dist/forms/TextInputFormGroup.d.ts +11 -0
  25. package/dist/forms/TextInputFormGroup.d.ts.map +1 -0
  26. package/dist/forms/TextInputFormGroup.js +16 -0
  27. package/dist/forms/index.d.ts +8 -0
  28. package/dist/forms/index.d.ts.map +1 -0
  29. package/dist/forms/index.js +6 -0
  30. package/dist/forms/types.d.ts +11 -0
  31. package/dist/forms/types.d.ts.map +1 -0
  32. package/dist/forms/types.js +1 -0
  33. package/dist/index.d.ts +4 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +3 -0
  36. package/dist/report/CardEmptyState.d.ts +8 -0
  37. package/dist/report/CardEmptyState.d.ts.map +1 -0
  38. package/dist/report/CardEmptyState.js +13 -0
  39. package/dist/report/DashboardEmptyStateBase.d.ts +8 -0
  40. package/dist/report/DashboardEmptyStateBase.d.ts.map +1 -0
  41. package/dist/report/DashboardEmptyStateBase.js +5 -0
  42. package/dist/report/EmptySearchResults.d.ts +7 -0
  43. package/dist/report/EmptySearchResults.d.ts.map +1 -0
  44. package/dist/report/EmptySearchResults.js +4 -0
  45. package/dist/report/OSDistribution.d.ts +14 -0
  46. package/dist/report/OSDistribution.d.ts.map +1 -0
  47. package/dist/report/OSDistribution.js +37 -0
  48. package/dist/report/OsSupportTiersHelpPopover.d.ts +3 -0
  49. package/dist/report/OsSupportTiersHelpPopover.d.ts.map +1 -0
  50. package/dist/report/OsSupportTiersHelpPopover.js +37 -0
  51. package/dist/report/OsUpgradeNotice.d.ts +3 -0
  52. package/dist/report/OsUpgradeNotice.d.ts.map +1 -0
  53. package/dist/report/OsUpgradeNotice.js +9 -0
  54. package/dist/report/OsUpgradeRecommendationPopover.d.ts +13 -0
  55. package/dist/report/OsUpgradeRecommendationPopover.d.ts.map +1 -0
  56. package/dist/report/OsUpgradeRecommendationPopover.js +24 -0
  57. package/dist/report/PopoverIcon.d.ts +11 -0
  58. package/dist/report/PopoverIcon.d.ts.map +1 -0
  59. package/dist/report/PopoverIcon.js +8 -0
  60. package/dist/report/SupportTierBadge.d.ts +9 -0
  61. package/dist/report/SupportTierBadge.d.ts.map +1 -0
  62. package/dist/report/SupportTierBadge.js +23 -0
  63. package/dist/report/constants.d.ts +11 -0
  64. package/dist/report/constants.d.ts.map +1 -0
  65. package/dist/report/constants.js +12 -0
  66. package/dist/report/dashboardStyles.d.ts +20 -0
  67. package/dist/report/dashboardStyles.d.ts.map +1 -0
  68. package/dist/report/dashboardStyles.js +99 -0
  69. package/dist/report/index.d.ts +15 -0
  70. package/dist/report/index.d.ts.map +1 -0
  71. package/dist/report/index.js +13 -0
  72. package/dist/report/osSupportTier.d.ts +42 -0
  73. package/dist/report/osSupportTier.d.ts.map +1 -0
  74. package/dist/report/osSupportTier.js +106 -0
  75. package/dist/report/useOsBarChartViewModel.d.ts +26 -0
  76. package/dist/report/useOsBarChartViewModel.d.ts.map +1 -0
  77. package/dist/report/useOsBarChartViewModel.js +59 -0
  78. package/package.json +63 -0
  79. package/src/charts/MigrationDonutChart.tsx +398 -0
  80. package/src/charts/index.ts +5 -0
  81. package/src/forms/CheckboxFormGroup.tsx +53 -0
  82. package/src/forms/FormFieldHelperText.tsx +32 -0
  83. package/src/forms/RadioButtonFormGroup.tsx +62 -0
  84. package/src/forms/SelectFormGroup.tsx +57 -0
  85. package/src/forms/TextAreaFormGroup.tsx +43 -0
  86. package/src/forms/TextInputFormGroup.tsx +63 -0
  87. package/src/forms/index.ts +7 -0
  88. package/src/forms/types.ts +11 -0
  89. package/src/index.ts +3 -0
  90. package/src/report/CardEmptyState.tsx +30 -0
  91. package/src/report/DashboardEmptyStateBase.tsx +30 -0
  92. package/src/report/EmptySearchResults.tsx +14 -0
  93. package/src/report/OSDistribution.tsx +206 -0
  94. package/src/report/OsSupportTiersHelpPopover.tsx +87 -0
  95. package/src/report/OsUpgradeNotice.tsx +29 -0
  96. package/src/report/OsUpgradeRecommendationPopover.tsx +78 -0
  97. package/src/report/PopoverIcon.tsx +46 -0
  98. package/src/report/SupportTierBadge.tsx +55 -0
  99. package/src/report/__tests__/OSDistribution.test.tsx +236 -0
  100. package/src/report/__tests__/osSupportTier.test.ts +101 -0
  101. package/src/report/constants.ts +14 -0
  102. package/src/report/dashboardStyles.ts +116 -0
  103. package/src/report/index.ts +54 -0
  104. package/src/report/osSupportTier.ts +161 -0
  105. package/src/report/useOsBarChartViewModel.ts +115 -0
@@ -0,0 +1,59 @@
1
+ import { useMemo, useState } from "react";
2
+ import { getSupportTierLegendLabel, getSupportTierSortOrder, hasOsUpgradeNotice, resolveSupportTier, } from "./osSupportTier.js";
3
+ export const ALL_TIERS_FILTER = "all";
4
+ function buildOsTableRows(osData) {
5
+ return Object.entries(osData)
6
+ .filter(([osName]) => osName.trim() !== "")
7
+ .map(([osName, entry]) => ({
8
+ osName,
9
+ tier: resolveSupportTier(entry.supportTier, entry.supported),
10
+ count: entry.count,
11
+ upgradeRecommendation: entry.upgradeRecommendation || undefined,
12
+ }))
13
+ .sort((a, b) => {
14
+ const tierOrder = getSupportTierSortOrder(a.tier) - getSupportTierSortOrder(b.tier);
15
+ if (tierOrder !== 0) {
16
+ return tierOrder;
17
+ }
18
+ return b.count - a.count;
19
+ });
20
+ }
21
+ export function useOsBarChartViewModel(osData) {
22
+ const [osFilter, setOsFilter] = useState("");
23
+ const [tierFilter, setTierFilter] = useState(ALL_TIERS_FILTER);
24
+ const [isTierSelectOpen, setIsTierSelectOpen] = useState(false);
25
+ const tableRows = useMemo(() => buildOsTableRows(osData), [osData]);
26
+ const filteredRows = useMemo(() => {
27
+ const normalizedFilter = osFilter.trim().toLowerCase();
28
+ return tableRows.filter((row) => {
29
+ const matchesOs = normalizedFilter === "" ||
30
+ row.osName.toLowerCase().includes(normalizedFilter);
31
+ const matchesTier = tierFilter === ALL_TIERS_FILTER || row.tier === tierFilter;
32
+ return matchesOs && matchesTier;
33
+ });
34
+ }, [osFilter, tableRows, tierFilter]);
35
+ const tierFilterLabel = tierFilter === ALL_TIERS_FILTER
36
+ ? "Filter by tier"
37
+ : getSupportTierLegendLabel(tierFilter);
38
+ const handleTierSelect = (_event, value) => {
39
+ if (typeof value === "string") {
40
+ setTierFilter(value);
41
+ }
42
+ setIsTierSelectOpen(false);
43
+ };
44
+ return {
45
+ osFilter,
46
+ tierFilter,
47
+ isTierSelectOpen,
48
+ tableRows,
49
+ filteredRows,
50
+ tierFilterLabel,
51
+ showNoResults: filteredRows.length === 0,
52
+ showUpgradeNotice: hasOsUpgradeNotice(osData),
53
+ setOsFilter,
54
+ clearOsFilter: () => setOsFilter(""),
55
+ setIsTierSelectOpen,
56
+ toggleTierSelectOpen: () => setIsTierSelectOpen((open) => !open),
57
+ handleTierSelect,
58
+ };
59
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@openshift-migration-advisor/shared-components",
3
+ "version": "0.0.0",
4
+ "description": "Shared React UI components for Migration Advisor apps (report charts, forms, OS support tiers)",
5
+ "author": "Red Hat",
6
+ "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/kubev2v/migration-planner-agent-ui.git",
10
+ "directory": "packages/shared-components"
11
+ },
12
+ "type": "module",
13
+ "exports": {
14
+ ".": {
15
+ "development": "./src/index.ts",
16
+ "import": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ }
20
+ },
21
+ "typesVersions": {
22
+ "*": {
23
+ ".": [
24
+ "./dist/index.d.ts"
25
+ ]
26
+ }
27
+ },
28
+ "sideEffects": false,
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "scripts": {
33
+ "build": "yarn run -T tsc -b",
34
+ "bundle": "yarn build && yarn pack --out ../../out/%s-%v.tgz",
35
+ "clean": "rm -rf node_modules dist .tmp",
36
+ "check": "yarn run -T biome check .",
37
+ "check:fix": "yarn run -T biome check --write .",
38
+ "test": "yarn run -T vitest run",
39
+ "typecheck": "yarn run -T tsc -b --noEmit"
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "src",
44
+ "LICENSE",
45
+ "README.md"
46
+ ],
47
+ "peerDependencies": {
48
+ "@emotion/css": "^11.13.0",
49
+ "@patternfly/react-charts": "^8.0.0",
50
+ "@patternfly/react-core": "^6.5.0",
51
+ "@patternfly/react-icons": "^6.5.0",
52
+ "@patternfly/react-table": "^6.5.0",
53
+ "@patternfly/react-tokens": "^6.5.0",
54
+ "react": "^18.3.0",
55
+ "react-dom": "^18.3.0",
56
+ "react-hook-form": "^7.0.0"
57
+ },
58
+ "devDependencies": {
59
+ "@testing-library/jest-dom": "^6.9.1",
60
+ "@testing-library/react": "^16.3.2",
61
+ "@testing-library/user-event": "^14.6.1"
62
+ }
63
+ }
@@ -0,0 +1,398 @@
1
+ import { css } from "@emotion/css";
2
+ import { ChartDonut, ChartLabel } from "@patternfly/react-charts/victory";
3
+ import {
4
+ EmptyState,
5
+ EmptyStateVariant,
6
+ Flex,
7
+ FlexItem,
8
+ } from "@patternfly/react-core";
9
+ import { InboxIcon } from "@patternfly/react-icons";
10
+ import type { FC } from "react";
11
+ import { useCallback, useMemo } from "react";
12
+
13
+ export interface MigrationDonutChartDatum {
14
+ name: string;
15
+ count: number;
16
+ legendCategory: string;
17
+ countDisplay?: string;
18
+ diskRange?: { min: number; max?: number };
19
+ memoryRange?: { min: number; max?: number };
20
+ clusterNames?: string[];
21
+ networkNames?: string[];
22
+ }
23
+
24
+ export interface MigrationDonutChartProps {
25
+ data: MigrationDonutChartDatum[];
26
+ legend?: Record<string, string>;
27
+ customColors?: Record<string, string>;
28
+ height?: number;
29
+ width?: number;
30
+ title?: string;
31
+ subTitle?: string;
32
+ titleColor?: string;
33
+ subTitleColor?: string;
34
+ marginLeft?: string;
35
+ titleFontSize?: number;
36
+ subTitleFontSize?: number;
37
+ donutThickness?: number;
38
+ padAngle?: number;
39
+ tooltipLabelFormatter?: (args: {
40
+ datum: {
41
+ x: string;
42
+ y: number;
43
+ countDisplay?: string | number;
44
+ legendCategory: string;
45
+ };
46
+ percent: number;
47
+ total: number;
48
+ }) => string;
49
+ onItemClick?: (item: MigrationDonutChartDatum) => void;
50
+ onTitleClick?: () => void;
51
+ /** When set, used for legend label instead of "name (countDisplay)". */
52
+ legendLabelFormatter?: (item: {
53
+ x: string;
54
+ countDisplay?: string | number;
55
+ }) => string;
56
+ }
57
+
58
+ const legendColors = ["#0066cc", "#5e40be", "#b6a6e9", "#b98412"];
59
+
60
+ const styles = {
61
+ legendIcon: css`
62
+ margin-right: 4px;
63
+ `,
64
+ cursorPointer: css`
65
+ cursor: pointer;
66
+ `,
67
+ cursorDefault: css`
68
+ cursor: default;
69
+ `,
70
+ chartContainer: css`
71
+ padding: 1em 0;
72
+ `,
73
+ donutWrapper: css`
74
+ position: relative;
75
+ display: inline-block;
76
+ `,
77
+ legendContainer: css`
78
+ overflow: hidden;
79
+ min-height: 40px;
80
+ `,
81
+ legendButton: css`
82
+ gap: var(--pf-t--global--spacer--lg);
83
+ cursor: pointer;
84
+ border: none;
85
+ background: none;
86
+ padding: var(--pf-t--global--spacer--xs) var(--pf-t--global--spacer--ml);
87
+ margin: 0;
88
+ transition: opacity var(--pf-t--global--motion--duration--short);
89
+ white-space: nowrap;
90
+
91
+ &:hover {
92
+ opacity: 0.7;
93
+ }
94
+ `,
95
+ legendLabel: css`
96
+ gap: var(--pf-t--global--spacer--lg);
97
+ padding: var(--pf-t--global--spacer--xs) var(--pf-t--global--spacer--ml);
98
+ white-space: nowrap;
99
+ `,
100
+ legendInner: css`
101
+ max-width: 680px;
102
+ padding: var(--pf-t--global--spacer--ml);
103
+ `,
104
+ };
105
+
106
+ export const MigrationDonutChart: FC<MigrationDonutChartProps> = ({
107
+ data,
108
+ legend,
109
+ customColors,
110
+ height = 260,
111
+ width = 420,
112
+ title,
113
+ subTitle,
114
+ titleColor = "#000000",
115
+ subTitleColor = "#000000",
116
+ marginLeft = "0%",
117
+ titleFontSize = 28,
118
+ subTitleFontSize = 14,
119
+ donutThickness = 45,
120
+ padAngle = 1,
121
+ tooltipLabelFormatter,
122
+ onItemClick,
123
+ onTitleClick,
124
+ legendLabelFormatter,
125
+ }) => {
126
+ const dynamicLegend = useMemo(() => {
127
+ return data.reduce(
128
+ (acc, current) => {
129
+ const key = `${current.legendCategory}`;
130
+ if (!acc.seen.has(key)) {
131
+ acc.seen.add(key);
132
+ const color =
133
+ customColors?.[key] ??
134
+ legendColors[(acc.seen.size - 1) % legendColors.length];
135
+ acc.result.push({ [key]: color });
136
+ }
137
+ return acc;
138
+ },
139
+ { seen: new Set(), result: [] } as {
140
+ seen: Set<string>;
141
+ result: Record<string, string>[];
142
+ },
143
+ ).result;
144
+ }, [data, customColors]);
145
+
146
+ const chartLegend = legend ? legend : Object.assign({}, ...dynamicLegend);
147
+ const getColor = useCallback(
148
+ (name: string): string => chartLegend[name],
149
+ [chartLegend],
150
+ );
151
+
152
+ const chartData = useMemo(() => {
153
+ return data.map((item) => ({
154
+ x: item.name,
155
+ y: item.count,
156
+ legendCategory: item.legendCategory,
157
+ countDisplay: item.countDisplay ?? item.count,
158
+ }));
159
+ }, [data]);
160
+
161
+ const colorScale = useMemo(() => {
162
+ return chartData.map((item) => getColor(item.legendCategory));
163
+ }, [chartData, getColor]);
164
+
165
+ const innerRadius = useMemo(() => {
166
+ const outerApprox = Math.min(width, height) / 2;
167
+ const computed = outerApprox - donutThickness;
168
+ return computed > 0 ? computed : 0;
169
+ }, [width, height, donutThickness]);
170
+
171
+ const totalY = useMemo(() => {
172
+ return chartData.reduce((sum, item) => sum + (Number(item.y) || 0), 0);
173
+ }, [chartData]);
174
+
175
+ const handleClick = useCallback(
176
+ // biome-ignore lint/suspicious/noExplicitAny: Victory chart types are not well-typed
177
+ (props: any) => {
178
+ if (!onItemClick) return;
179
+
180
+ const datum = props?.datum;
181
+ if (!datum) return;
182
+
183
+ let clickedItem = data.find((item) => item.name === datum.x);
184
+
185
+ if (!clickedItem && typeof props.index === "number") {
186
+ clickedItem = data[props.index];
187
+ }
188
+
189
+ if (!clickedItem) {
190
+ clickedItem = data.find(
191
+ (item) => item.legendCategory === datum.legendCategory,
192
+ );
193
+ }
194
+
195
+ if (clickedItem) {
196
+ onItemClick(clickedItem);
197
+ }
198
+ },
199
+ [onItemClick, data],
200
+ );
201
+
202
+ const chartEvents = useMemo(() => {
203
+ if (!onItemClick) return undefined;
204
+
205
+ return [
206
+ {
207
+ target: "data" as const,
208
+ eventHandlers: {
209
+ onClick: () => [
210
+ {
211
+ target: "data" as const,
212
+ // biome-ignore lint/suspicious/noExplicitAny: Victory chart types are not well-typed
213
+ mutation: (props: any) => {
214
+ handleClick(props);
215
+ return null;
216
+ },
217
+ },
218
+ ],
219
+ },
220
+ },
221
+ ];
222
+ }, [onItemClick, handleClick]);
223
+
224
+ if (!data || data.length === 0) {
225
+ return (
226
+ <EmptyState
227
+ headingLevel="h3"
228
+ titleText="No data available"
229
+ icon={InboxIcon}
230
+ variant={EmptyStateVariant.xs}
231
+ />
232
+ );
233
+ }
234
+
235
+ return (
236
+ <Flex
237
+ direction={{ default: "column" }}
238
+ alignItems={{ default: "alignItemsCenter" }}
239
+ className={`${onItemClick ? styles.cursorPointer : styles.cursorDefault} ${styles.chartContainer}`}
240
+ >
241
+ <div className={styles.donutWrapper}>
242
+ <ChartDonut
243
+ ariaDesc="Migration data donut chart"
244
+ data={chartData}
245
+ events={chartEvents}
246
+ labels={({
247
+ datum,
248
+ }: {
249
+ datum: {
250
+ x: string;
251
+ y: number;
252
+ legendCategory: string;
253
+ countDisplay?: string | number;
254
+ };
255
+ }) => {
256
+ const percent = totalY > 0 ? (Number(datum.y) / totalY) * 100 : 0;
257
+ return tooltipLabelFormatter
258
+ ? tooltipLabelFormatter({
259
+ datum: {
260
+ x: datum.x,
261
+ y: Number(datum.y),
262
+ countDisplay: datum.countDisplay,
263
+ legendCategory: datum.legendCategory,
264
+ },
265
+ percent,
266
+ total: totalY,
267
+ })
268
+ : `${datum.x}: ${datum.countDisplay ?? datum.y}`;
269
+ }}
270
+ colorScale={colorScale}
271
+ constrainToVisibleArea
272
+ innerRadius={innerRadius}
273
+ padAngle={padAngle}
274
+ title={title}
275
+ subTitle={subTitle}
276
+ height={height}
277
+ width={width}
278
+ padding={{
279
+ bottom: 5,
280
+ left: 20,
281
+ right: 20,
282
+ top: 0,
283
+ }}
284
+ titleComponent={
285
+ title ? (
286
+ <ChartLabel
287
+ style={[
288
+ {
289
+ fill: titleColor,
290
+ fontSize: titleFontSize,
291
+ fontWeight: "bold",
292
+ },
293
+ ]}
294
+ />
295
+ ) : undefined
296
+ }
297
+ subTitleComponent={
298
+ subTitle ? (
299
+ <ChartLabel
300
+ style={[
301
+ {
302
+ fill: subTitleColor,
303
+ fontSize: subTitleFontSize,
304
+ },
305
+ ]}
306
+ />
307
+ ) : undefined
308
+ }
309
+ />
310
+ {onTitleClick && title && (
311
+ // biome-ignore lint/a11y/useSemanticElements: Transparent overlay requires precise positioning and styling that button element would interfere with
312
+ <div
313
+ onClick={onTitleClick}
314
+ className={css`
315
+ position: absolute;
316
+ top: 50%;
317
+ left: 50%;
318
+ transform: translate(-50%, -50%);
319
+ width: ${innerRadius * 2}px;
320
+ height: ${innerRadius * 2}px;
321
+ cursor: pointer;
322
+ border-radius: 50%;
323
+ z-index: 10;
324
+ `}
325
+ title="Click to view all VMs"
326
+ role="button"
327
+ tabIndex={0}
328
+ onKeyDown={(e) => {
329
+ if (e.key === "Enter" || e.key === " ") {
330
+ e.preventDefault();
331
+ onTitleClick();
332
+ }
333
+ }}
334
+ />
335
+ )}
336
+ </div>
337
+ <Flex
338
+ className={`${styles.legendContainer} ${css`margin-left: ${marginLeft};`}`}
339
+ justifyContent={{ default: "justifyContentCenter" }}
340
+ alignItems={{ default: "alignItemsFlexStart" }}
341
+ >
342
+ <Flex
343
+ className={styles.legendInner}
344
+ spaceItems={{ default: "spaceItemsMd" }}
345
+ justifyContent={{ default: "justifyContentCenter" }}
346
+ alignItems={{ default: "alignItemsCenter" }}
347
+ flexWrap={{ default: "wrap" }}
348
+ >
349
+ {data.map((item) => {
350
+ const label = legendLabelFormatter
351
+ ? legendLabelFormatter({
352
+ x: item.name,
353
+ countDisplay: item.countDisplay,
354
+ })
355
+ : item.name;
356
+
357
+ const content = (
358
+ <>
359
+ <svg
360
+ width="10"
361
+ height="10"
362
+ aria-hidden="true"
363
+ className={styles.legendIcon}
364
+ >
365
+ <title>Legend color indicator</title>
366
+ <rect
367
+ width="10"
368
+ height="10"
369
+ fill={getColor(item.legendCategory)}
370
+ />
371
+ </svg>
372
+ <span>{label}</span>
373
+ </>
374
+ );
375
+
376
+ return (
377
+ <FlexItem key={`${item.legendCategory}-${item.name}`}>
378
+ {onItemClick ? (
379
+ <button
380
+ type="button"
381
+ onClick={() => onItemClick(item)}
382
+ className={styles.legendButton}
383
+ >
384
+ {content}
385
+ </button>
386
+ ) : (
387
+ <span className={styles.legendLabel}>{content}</span>
388
+ )}
389
+ </FlexItem>
390
+ );
391
+ })}
392
+ </Flex>
393
+ </Flex>
394
+ </Flex>
395
+ );
396
+ };
397
+
398
+ export default MigrationDonutChart;
@@ -0,0 +1,5 @@
1
+ export {
2
+ MigrationDonutChart,
3
+ type MigrationDonutChartDatum,
4
+ type MigrationDonutChartProps,
5
+ } from "./MigrationDonutChart.js";
@@ -0,0 +1,53 @@
1
+ import { Checkbox, FormGroup } from "@patternfly/react-core";
2
+ import { Controller, type FieldError, useFormContext } from "react-hook-form";
3
+
4
+ import FormFieldHelperText from "./FormFieldHelperText.js";
5
+ import type { FormGroupProps } from "./types.js";
6
+
7
+ type CheckboxFormGroupProps = Omit<FormGroupProps, "placeholder">;
8
+
9
+ export default function CheckboxFormGroup({
10
+ id,
11
+ label,
12
+ name,
13
+ isRequired = false,
14
+ isDisabled = false,
15
+ helpText,
16
+ ...props
17
+ }: CheckboxFormGroupProps) {
18
+ const methods = useFormContext();
19
+ const error = methods.formState.errors[name] as FieldError | undefined;
20
+ const isTouched = methods.formState.touchedFields[name] as
21
+ | boolean
22
+ | undefined;
23
+ const isSubmitted = methods.formState.isSubmitted;
24
+ const showError = error && (isTouched || isSubmitted);
25
+
26
+ return (
27
+ <FormGroup isRequired={isRequired} fieldId={id} {...props}>
28
+ <Controller
29
+ name={name}
30
+ control={methods.control}
31
+ render={({ field }) => {
32
+ return (
33
+ <Checkbox
34
+ label={label}
35
+ id={id}
36
+ isRequired={isRequired}
37
+ isDisabled={isDisabled}
38
+ isChecked={field.value as boolean}
39
+ onChange={field.onChange}
40
+ onBlur={field.onBlur}
41
+ name={field.name}
42
+ ref={field.ref}
43
+ />
44
+ );
45
+ }}
46
+ />
47
+ <FormFieldHelperText
48
+ helpText={helpText}
49
+ errorMessage={showError ? error?.message : undefined}
50
+ />
51
+ </FormGroup>
52
+ );
53
+ }
@@ -0,0 +1,32 @@
1
+ import {
2
+ FormHelperText,
3
+ HelperText,
4
+ HelperTextItem,
5
+ } from "@patternfly/react-core";
6
+ import { ExclamationCircleIcon } from "@patternfly/react-icons";
7
+
8
+ interface FormFieldHelperTextProps {
9
+ helpText?: string;
10
+ errorMessage?: string;
11
+ }
12
+
13
+ export default function FormFieldHelperText({
14
+ helpText,
15
+ errorMessage,
16
+ }: FormFieldHelperTextProps) {
17
+ if (!helpText && !errorMessage) return null;
18
+
19
+ return (
20
+ <FormHelperText>
21
+ <HelperText>
22
+ {errorMessage ? (
23
+ <HelperTextItem icon={<ExclamationCircleIcon />} variant="error">
24
+ {errorMessage}
25
+ </HelperTextItem>
26
+ ) : (
27
+ helpText && <HelperTextItem>{helpText}</HelperTextItem>
28
+ )}
29
+ </HelperText>
30
+ </FormHelperText>
31
+ );
32
+ }
@@ -0,0 +1,62 @@
1
+ import { FormGroup, Radio } from "@patternfly/react-core";
2
+ import { Controller, type FieldError, useFormContext } from "react-hook-form";
3
+
4
+ import FormFieldHelperText from "./FormFieldHelperText.js";
5
+ import type { FormGroupProps } from "./types.js";
6
+
7
+ interface RadioOption {
8
+ value: string;
9
+ label: string;
10
+ id: string;
11
+ }
12
+
13
+ interface RadioButtonFormGroupProps
14
+ extends Omit<FormGroupProps, "placeholder"> {
15
+ options: RadioOption[];
16
+ }
17
+
18
+ export default function RadioButtonFormGroup({
19
+ id,
20
+ label,
21
+ name,
22
+ isRequired = false,
23
+ options,
24
+ helpText,
25
+ ...props
26
+ }: RadioButtonFormGroupProps) {
27
+ const methods = useFormContext();
28
+ const error = methods.formState.errors[name] as FieldError | undefined;
29
+ const isTouched = methods.formState.touchedFields[name] as
30
+ | boolean
31
+ | undefined;
32
+ const isSubmitted = methods.formState.isSubmitted;
33
+ const showError = error && (isTouched || isSubmitted);
34
+
35
+ return (
36
+ <FormGroup label={label} isRequired={isRequired} fieldId={id} {...props}>
37
+ <Controller
38
+ name={name}
39
+ control={methods.control}
40
+ render={({ field }) => (
41
+ <div style={{ display: "flex", gap: "16px" }} role="radiogroup">
42
+ {options.map((option) => (
43
+ <Radio
44
+ key={option.value}
45
+ id={option.id}
46
+ name={name}
47
+ label={option.label}
48
+ isChecked={field.value === option.value}
49
+ onChange={() => field.onChange(option.value)}
50
+ onBlur={field.onBlur}
51
+ />
52
+ ))}
53
+ </div>
54
+ )}
55
+ />
56
+ <FormFieldHelperText
57
+ helpText={helpText}
58
+ errorMessage={showError ? error?.message : undefined}
59
+ />
60
+ </FormGroup>
61
+ );
62
+ }