diff --git a/docs/components/custom-widgets.md b/docs/components/custom-widgets.md
index 944977d..80ad2d7 100644
--- a/docs/components/custom-widgets.md
+++ b/docs/components/custom-widgets.md
@@ -2,7 +2,7 @@
## Overview
-Custom widgets are React components that integrate with the Widget Layout system through federated modules. They are loaded dynamically via ScalprumComponent and displayed within GridTile containers that provide drag-and-drop functionality.
+Custom widgets are React components that integrate with the Widget Layout system through federated modules. They are loaded dynamically via ScalprumComponent and displayed within widget cards provided by `@patternfly/widgetized-dashboard`, which handles drag-and-drop functionality.
## Widget System Architecture
@@ -12,7 +12,7 @@ Widgets are loaded through a multi-step process:
1. **Widget Mapping**: The backend provides widget configuration via `/api/chrome-service/v1/dashboard-templates/widget-mapping`
2. **Module Federation**: Widgets are loaded as federated modules using ScalprumComponent (configured via `fec.config.js`)
-3. **Grid Integration**: Each widget is wrapped in a GridTile that provides layout and interaction capabilities
+3. **Grid Integration**: The PatternFly GridLayout component renders each widget within a Card component that provides layout and interaction capabilities
*Note: Module federation is handled by the Red Hat Cloud Services frontend tooling. You don't need to configure webpack directly.*
@@ -77,7 +77,7 @@ export type WidgetPermission = {
### Step 1: Create the Widget Component
-Your widget is a standard React component that provides content for the CardBody wrapper. **Note**: The Card wrapper (including header, title, and actions) is automatically provided by the GridTile component - your widget should only provide the content.
+Your widget is a standard React component that provides content for the CardBody wrapper. **Note**: The Card wrapper (including header, title, and actions) is automatically provided by the PatternFly GridLayout component - your widget should only provide the content.
```tsx
// MyCustomWidget.tsx
@@ -163,9 +163,9 @@ The widget mapping must be provided by the backend API at `/api/chrome-service/v
## Widget Integration Details
-### GridTile Integration
+### PatternFly GridLayout Integration
-Your widget is automatically wrapped in a GridTile component that provides:
+Your widget is automatically wrapped in a widget card by the `@patternfly/widgetized-dashboard` GridLayout component that provides:
- **Drag and Drop**: Move widgets around the grid
- **Resize Handles**: Resize widgets within min/max constraints
@@ -174,26 +174,45 @@ Your widget is automatically wrapped in a GridTile component that provides:
### Widget Container Structure
-Your custom widget content is automatically wrapped in a Card structure by the GridTile component:
+Your custom widget content is automatically wrapped in a Card structure by the PatternFly GridLayout component. The component internally manages widget cards with:
+
+- **Card Header**: Contains the widget title, icon, and action menu
+- **Card Body**: Your custom widget component renders here
+
+Widget rendering is handled through the widget mapping configuration:
```tsx
-// From src/Components/DnDLayout/GridTile.tsx - Simplified structure
-
-
-
-
- {widgetConfig?.config?.title || widgetType}
-
-
-
-
- {/* Your widget component renders here - this is where your custom component appears */}
- {node}
-
-
+// From src/Components/DnDLayout/GridLayout.tsx
+const convertWidgetMapping = (scalprumMapping: ScalprumWidgetMapping): WidgetMapping => {
+ const result: WidgetMapping = {};
+
+ Object.keys(scalprumMapping).forEach((widgetType) => {
+ const scalprumWidget = scalprumMapping[widgetType];
+ result[widgetType] = {
+ defaults: scalprumWidget.defaults,
+ config: {
+ title: scalprumWidget.config?.title,
+ icon: scalprumWidget.config?.icon ? : undefined,
+ headerLink: scalprumWidget.config?.headerLink,
+ wrapperProps: { className: scalprumWidget.scope },
+ cardBodyProps: { className: `${scalprumWidget.scope}-${widgetType}` }
+ },
+ renderWidget: (_widgetId: string) => (
+ }
+ scope={scalprumWidget.scope}
+ module={scalprumWidget.module}
+ importName={scalprumWidget.importName}
+ />
+ ),
+ };
+ });
+
+ return result;
+};
```
-**Important**: Your widget should **NOT** include Card, CardHeader, or CardTitle components as these are provided by the GridTile wrapper.
+**Important**: Your widget should **NOT** include Card, CardHeader, or CardTitle components as these are provided by the PatternFly GridLayout component wrapper.
## Widget Sizing and Layout
diff --git a/docs/components/grid-layout.md b/docs/components/grid-layout.md
index 4c11afd..79e1cf7 100644
--- a/docs/components/grid-layout.md
+++ b/docs/components/grid-layout.md
@@ -2,7 +2,7 @@
## Overview
-The `GridLayout` component (`src/Components/DnDLayout/GridLayout.tsx`) is the core layout engine that provides responsive, drag-and-drop functionality for widget positioning. It uses `react-grid-layout` under the hood and manages template persistence, responsive breakpoints, and widget interactions.
+The `GridLayout` component (`src/Components/DnDLayout/GridLayout.tsx`) is the core layout engine that provides responsive, drag-and-drop functionality for widget positioning. It uses `@patternfly/widgetized-dashboard` which internally uses `react-grid-layout` to manage template persistence, responsive breakpoints, and widget interactions.
## Component Interface
@@ -88,96 +88,56 @@ const setCurrentlyUsedWidgets = useSetAtom(currentlyUsedWidgetsAtom);
- **`widgetMappingAtom`**: Available widget configurations
- **`currentlyUsedWidgetsAtom`**: List of widget types currently in use
-## Drag and Drop System
+## PatternFly Grid Layout Integration
-### Widget Addition via Drop
+### Widget Management
+
+Widget addition, removal, drag-and-drop, and other layout operations are now handled by the `@patternfly/widgetized-dashboard` PatternFlyGridLayout component. The component provides callbacks for template changes and widget tracking:
```tsx
-// From src/Components/DnDLayout/GridLayout.tsx
-const onDrop: ReactGridLayoutProps['onDrop'] = (_layout: ExtendedLayoutItem[], layoutItem: ExtendedLayoutItem, event: DragEvent) => {
- const data = event.dataTransfer?.getData('text') || '';
- if (isWidgetType(widgetMapping, data)) {
- setCurrentDropInItem(undefined);
- setTemplate((prev) =>
- Object.entries(prev).reduce((acc, [size, layout]) => {
- const newWidget = {
- ...layoutItem,
- ...widgetMapping[data].defaults,
- // make sure the configuration is valid for all layout sizes
- w: size === layoutVariant ? layoutItem.w : Math.min(widgetMapping[data].defaults.w, columns[size as Variants]),
- x: size === layoutVariant ? layoutItem.x : Math.min(layoutItem.x, columns[size as Variants]),
- widgetType: data,
- i: getWidgetIdentifier(data),
- title: 'New title',
- config: widgetMapping[data].config,
- };
- return {
- ...acc,
- [size]: layout.reduce(
- (acc, curr) => {
- if (curr.x + curr.w > newWidget.x && curr.y + curr.h <= newWidget.y) {
- acc.push(curr);
- } else {
- // push the current items down on the Y axis if they are supposed to be below the new widget
- acc.push({ ...curr, y: curr.y + curr.h });
- }
-
- return acc;
- },
- [newWidget]
- ),
- };
- }, prev)
- );
- analytics.track('widget-layout.widget-add', { data });
+// Template change handler
+const handleTemplateChange = async (newTemplate: ExtendedTemplateConfig) => {
+ if (isLayoutLocked || templateId < 0) {
+ return;
}
- event.preventDefault();
-};
-```
-### Drop Preview
+ // Update local state
+ setTemplate(newTemplate as any);
-```tsx
-// Drop preview template
-const droppingItemTemplate: ReactGridLayoutProps['droppingItem'] = useMemo(() => {
- if (currentDropInItem && isWidgetType(widgetMapping, currentDropInItem)) {
- return {
- ...widgetMapping[currentDropInItem].defaults,
- i: dropping_elem_id,
- widgetType: currentDropInItem,
- title: 'New title',
- config: widgetMapping[currentDropInItem].config,
- };
- }
-}, [currentDropInItem]);
-```
+ // Update currently used widgets
+ const activeLayout = newTemplate[layoutVariant] || [];
+ setCurrentlyUsedWidgets(activeLayout.map((item) => item.widgetType));
-### Widget Management Functions
+ try {
+ // Convert and persist to backend
+ const templateConfig: any = { sm: [], md: [], lg: [], xl: [] };
+ (Object.keys(newTemplate) as Variants[]).forEach((variant) => {
+ templateConfig[variant] = newTemplate[variant].map(({ widgetType, config, locked, ...item }) => ({
+ ...item,
+ title: item.title || 'Widget',
+ }));
+ });
-```tsx
-// Widget attribute modification
-const setWidgetAttribute: SetWidgetAttribute = (id, attributeName, value) =>
- setTemplate((prev) =>
- Object.entries(prev).reduce(
- (acc, [size, layout]) => ({
- ...acc,
- [size]: layout.map((widget) => (widget.i === id ? { ...widget, [attributeName]: value } : widget)),
- }),
- prev
- )
- );
+ await debouncedPatchDashboardTemplate(templateId, { templateConfig });
+ } catch (error) {
+ console.error(error);
+ addNotification({
+ variant: 'danger',
+ title: 'Failed to patch dashboard configuration',
+ description: 'Your dashboard changes were unable to be saved.',
+ });
+ }
+};
-// Widget removal
-const removeWidget = (id: string) =>
- setTemplate((prev) =>
- Object.entries(prev).reduce(
- (acc, [size, layout]) => ({
- ...acc,
- [size]: layout.filter((widget) => widget.i !== id),
- }),
- prev
- )
- );
+// Active widgets tracking
+const handleActiveWidgetsChange = (widgetTypes: string[]) => {
+ setCurrentlyUsedWidgets(widgetTypes);
+};
+
+// Drawer expand/collapse tracking
+const handleDrawerExpandChange = (expanded: boolean) => {
+ setDrawerExpanded(expanded);
+};
```
## Template Persistence
@@ -263,85 +223,63 @@ useEffect(() => {
## Responsive Behavior
-### Resize Observer
+Responsive breakpoint detection and layout adjustments are now handled automatically by the `@patternfly/widgetized-dashboard` PatternFlyGridLayout component. The component internally manages viewport width detection and automatically switches between responsive variants (xl, lg, md, sm) based on the defined breakpoints.
-```tsx
-// Automatic layout variant detection
-useEffect(() => {
- const currentWidth = layoutRef.current?.getBoundingClientRect().width ?? 1200;
- const variant: Variants = getGridDimensions(currentWidth);
- setLayoutVariant(variant);
- setLayoutWidth(currentWidth);
-
- const observer = new ResizeObserver((entries) => {
- if (!entries[0]) return;
-
- const currentWidth = entries[0].contentRect.width;
- const variant: Variants = getGridDimensions(currentWidth);
- setLayoutVariant(variant);
- setLayoutWidth(currentWidth);
- });
-
- if (layoutRef.current) {
- observer.observe(layoutRef.current);
- }
+## Widget Rendering with PatternFly Grid Layout
- return () => {
- observer.disconnect();
- };
-}, []);
-```
+The component now uses `@patternfly/widgetized-dashboard` for widget rendering and management. Widget cards, drag-and-drop functionality, and layout management are handled by the PatternFlyGridLayout component.
-## Grid Tile Integration
-
-### Widget Rendering
+### PatternFly Integration
```tsx
-// How widgets are rendered within GridTiles
-{activeLayout
- .map(({ widgetType, title, ...rest }, index) => {
- const widget = getWidget(widgetMapping, widgetType);
- if (!widget) {
- return null;
- }
- const config = widgetMapping[widgetType]?.config;
- return (
-
-
- {rest.i}
-
-
- );
- })
- .filter((layoutItem) => layoutItem !== null)}
+// From src/Components/DnDLayout/GridLayout.tsx
+ }
+ documentationLink={documentationLink}
+ analytics={analytics?.track ? (event, data) => analytics.track(event, data) : undefined}
+ showEmptyState={!isLoaded}
+ onDrawerExpandChange={handleDrawerExpandChange}
+ onActiveWidgetsChange={handleActiveWidgetsChange}
+/>
```
-### GridTile Props Interface
+### Widget Mapping Conversion
+
+The component converts Scalprum widget mapping to PatternFly widget mapping format:
```tsx
-// From src/Components/DnDLayout/GridTile.tsx
-export type SetWidgetAttribute = (id: string, attributeName: keyof ExtendedLayoutItem, value: T) => void;
-
-export type GridTileProps = React.PropsWithChildren<{
- widgetType: string;
- icon?: React.ComponentClass;
- setIsDragging: (isDragging: boolean) => void;
- isDragging: boolean;
- setWidgetAttribute: SetWidgetAttribute;
- widgetConfig: Layout & {
- colWidth: number;
- locked?: boolean;
- config?: WidgetConfiguration;
- };
- removeWidget: (id: string) => void;
-}>;
+const convertWidgetMapping = (scalprumMapping: ScalprumWidgetMapping): WidgetMapping => {
+ const result: WidgetMapping = {};
+
+ Object.keys(scalprumMapping).forEach((widgetType) => {
+ const scalprumWidget = scalprumMapping[widgetType];
+ const scopedWidgetType = `${scalprumWidget.scope}-${widgetType}`;
+ result[widgetType] = {
+ defaults: scalprumWidget.defaults,
+ config: {
+ title: scalprumWidget.config?.title,
+ icon: scalprumWidget.config?.icon ? : undefined,
+ headerLink: scalprumWidget.config?.headerLink,
+ wrapperProps: { className: scalprumWidget.scope },
+ cardBodyProps: { className: scopedWidgetType }
+ },
+ renderWidget: (_widgetId: string) => (
+ }
+ scope={scalprumWidget.scope}
+ module={scalprumWidget.module}
+ importName={scalprumWidget.importName}
+ />
+ ),
+ };
+ });
+
+ return result;
+};
```
## Empty State
@@ -382,45 +320,26 @@ const LayoutEmptyState = () => {
{activeLayout.length === 0 && !currentDropInItem && isLoaded && }
```
-## Resize Handles
+## Layout Configuration
-### Custom Resize Handle
+The PatternFly GridLayout component is configured with the following props:
```tsx
-// From src/Components/DnDLayout/GridLayout.tsx
-const getResizeHandle = (resizeHandleAxis: string, ref: React.Ref) => {
- return (
-
-
-
- );
-};
+ }
+ documentationLink={documentationLink} // Link to documentation
+ analytics={analytics?.track} // Analytics tracking function
+ showEmptyState={!isLoaded} // Show empty state while loading
+ onDrawerExpandChange={handleDrawerExpandChange}
+ onActiveWidgetsChange={handleActiveWidgetsChange}
+/>
```
-### ReactGridLayout Configuration
-
-```tsx
-
- {/* Widget content */}
-
-```
+Widget cards, resize handles, drag handles, and other UI elements are provided by the PatternFly component internally.
## Styling
@@ -471,17 +390,17 @@ className={`widget-columns-${rest.w} widget-rows-${rest.h}`}
### Event Tracking
-```tsx
-// Widget addition tracking
-analytics.track('widget-layout.widget-add', { data });
-
-// Widget movement tracking (in GridTile)
-analytics.track('widget-layout.widget-move', { widgetType });
+Analytics tracking is passed to the PatternFly GridLayout component:
-// Widget removal tracking (in GridTile)
-analytics.track('widget-layout.widget-remove', { widgetType });
+```tsx
+ analytics.track(event, data) : undefined}
+ // ... other props
+/>
```
+The PatternFly component handles tracking of widget operations (add, move, remove, resize, etc.) internally.
+
## Error Handling
### Template Loading Errors
@@ -560,10 +479,11 @@ import GridLayout from './Components/DnDLayout/GridLayout';
- `useChrome()` (from `@redhat-cloud-services/frontend-components/useChrome`)
### External Libraries
-- `react-grid-layout`: Core grid functionality
+- `@patternfly/widgetized-dashboard`: Core grid functionality and widget management
- `awesome-debounce-promise`: Debounced API calls
- `jotai`: State management
- `@patternfly/react-core`: UI components
+- `@scalprum/react-core`: Federated module loading for widgets
---
diff --git a/package-lock.json b/package-lock.json
index b1e2fbe..99f4f1e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
"dependencies": {
"@patternfly/react-component-groups": "^6.4.0",
"@patternfly/react-core": "^6.4.1",
+ "@patternfly/widgetized-dashboard": "1.0.0-prerelease.6",
"@redhat-cloud-services/frontend-components": "^7.0.43",
"@redhat-cloud-services/frontend-components-notifications": "^5.0.7",
"awesome-debounce-promise": "^2.1.0",
@@ -18,7 +19,7 @@
"jotai": "^2.18.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "react-grid-layout": "^1.5.3",
+ "react-grid-layout": "^2.2.2",
"react-router-dom": "^6.30.3"
},
"devDependencies": {
@@ -36,7 +37,6 @@
"@testing-library/react": "^14.3.1",
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
- "@types/react-grid-layout": "^1.3.6",
"@types/react-router-dom": "^5.3.3",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
@@ -4011,6 +4011,22 @@
"integrity": "sha512-iZthBoXSGQ/+PfGTdPFJVulaJZI3rwE+7A/whOXPGp3Jyq3k6X52pr1+5nlO6WHasbZ9FyeZGqXf4fazUZNjbw==",
"license": "MIT"
},
+ "node_modules/@patternfly/widgetized-dashboard": {
+ "version": "1.0.0-prerelease.6",
+ "resolved": "https://registry.npmjs.org/@patternfly/widgetized-dashboard/-/widgetized-dashboard-1.0.0-prerelease.6.tgz",
+ "integrity": "sha512-1TxqCB1JIyb2cktDsbDn+JqYROPAYFhDo+HQyhNLP+jzuW7ZaMtHZ0gPfZUDyVTxTAjHoKPdnEGnbQ3ZfEUe7g==",
+ "license": "MIT",
+ "dependencies": {
+ "@patternfly/react-core": "^6.3.1",
+ "@patternfly/react-icons": "^6.3.1",
+ "clsx": "^2.1.0",
+ "react-grid-layout": "^2.2.2"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19",
+ "react-dom": "^18 || ^19"
+ }
+ },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -5485,16 +5501,6 @@
"@types/react": "^18.0.0"
}
},
- "node_modules/@types/react-grid-layout": {
- "version": "1.3.6",
- "resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.6.tgz",
- "integrity": "sha512-Cw7+sb3yyjtmxwwJiXtEXcu5h4cgs+sCGkHwHXsFmPyV30bf14LeD/fa2LwQovuD2HWxCcjIdNhDlcYGj95qGA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/react": "*"
- }
- },
"node_modules/@types/react-router": {
"version": "5.1.20",
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz",
@@ -16504,9 +16510,9 @@
}
},
"node_modules/react-grid-layout": {
- "version": "1.5.3",
- "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.3.tgz",
- "integrity": "sha512-KaG6IbjD6fYhagUtIvOzhftXG+ViKZjCjADe86X1KHl7C/dsBN2z0mi14nbvZKTkp0RKiil9RPcJBgq3LnoA8g==",
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.2.tgz",
+ "integrity": "sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
diff --git a/package.json b/package.json
index a950980..f01497a 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"dependencies": {
"@patternfly/react-component-groups": "^6.4.0",
"@patternfly/react-core": "^6.4.1",
+ "@patternfly/widgetized-dashboard": "1.0.0-prerelease.6",
"@redhat-cloud-services/frontend-components": "^7.0.43",
"@redhat-cloud-services/frontend-components-notifications": "^5.0.7",
"awesome-debounce-promise": "^2.1.0",
@@ -30,7 +31,7 @@
"jotai": "^2.18.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "react-grid-layout": "^1.5.3",
+ "react-grid-layout": "^2.2.2",
"react-router-dom": "^6.30.3"
},
"devDependencies": {
@@ -48,7 +49,6 @@
"@testing-library/react": "^14.3.1",
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
- "@types/react-grid-layout": "^1.3.6",
"@types/react-router-dom": "^5.3.3",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
diff --git a/src/Components/DnDLayout/ConvertWidgetMapping.tsx b/src/Components/DnDLayout/ConvertWidgetMapping.tsx
new file mode 100644
index 0000000..c9da64b
--- /dev/null
+++ b/src/Components/DnDLayout/ConvertWidgetMapping.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import { Skeleton } from '@patternfly/react-core';
+import { CogsIcon } from '@patternfly/react-icons';
+import { WidgetMapping } from '@patternfly/widgetized-dashboard';
+import { ScalprumComponent } from '@scalprum/react-core';
+import { WidgetMapping as ScalprumWidgetMapping } from '../../api/dashboard-templates';
+import HeaderIcon from '../Icons/HeaderIcon';
+import './WidgetSkeleton.scss';
+
+/**
+ * Adapter to convert Scalprum WidgetMapping to PatternFly WidgetMapping.
+ * Maps icon strings to React elements, sets scoped class names, and
+ * wraps widget content in ScalprumComponent for remote module loading.
+ */
+const convertWidgetMapping = (scalprumMapping: ScalprumWidgetMapping): WidgetMapping => {
+ const result: WidgetMapping = {};
+
+ Object.keys(scalprumMapping).forEach((widgetType) => {
+ const scalprumWidget = scalprumMapping[widgetType];
+ const scopedWidgetType = `${scalprumWidget.scope}-${widgetType}`;
+ result[widgetType] = {
+ defaults: scalprumWidget.defaults,
+ config: {
+ title: scalprumWidget.config?.title,
+ icon: scalprumWidget.config?.icon ? : ,
+ headerLink: scalprumWidget.config?.headerLink,
+ wrapperProps: { className: scalprumWidget.scope },
+ cardBodyProps: { className: scopedWidgetType },
+ },
+ renderWidget: (_widgetId: string) => (
+ }
+ scope={scalprumWidget.scope}
+ module={scalprumWidget.module}
+ importName={scalprumWidget.importName}
+ />
+ ),
+ };
+ });
+
+ return result;
+};
+
+export default convertWidgetMapping;
diff --git a/src/Components/DnDLayout/GridLayout.scss b/src/Components/DnDLayout/GridLayout.scss
index 7ae39c1..4fdf236 100644
--- a/src/Components/DnDLayout/GridLayout.scss
+++ b/src/Components/DnDLayout/GridLayout.scss
@@ -1,24 +1,4 @@
.react-grid-item {
- .react-resizable-handle-nw, .react-resizable-handle-sw, .react-resizable-handle-se {
- display: none;
- }
-
- .react-resizable-handle-nw, .react-resizable-handle-se {
- cursor: nwse-resize;
- }
-
- .react-resizable-handle-ne, .react-resizable-handle-sw {
- cursor: nesw-resize;
- }
-
- &:hover, &:active {
- &:not(.static) {
- .react-resizable-handle-nw, .react-resizable-handle-sw, .react-resizable-handle-se {
- display: inherit;
- }
- }
- }
-
&.react-grid-placeholder {
background-color: var(--pf-t--color--gray--60);
border-radius: 12px;
diff --git a/src/Components/DnDLayout/GridLayout.test.tsx b/src/Components/DnDLayout/GridLayout.test.tsx
new file mode 100644
index 0000000..88952ea
--- /dev/null
+++ b/src/Components/DnDLayout/GridLayout.test.tsx
@@ -0,0 +1,267 @@
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import { Provider, createStore } from 'jotai';
+import GridLayout from './GridLayout';
+import convertWidgetMapping from './ConvertWidgetMapping';
+import { widgetMappingAtom } from '../../state/widgetMappingAtom';
+import { templateAtom, templateIdAtom } from '../../state/templateAtom';
+import { layoutVariantAtom } from '../../state/layoutAtom';
+import { WidgetMapping as ScalprumWidgetMapping } from '../../api/dashboard-templates';
+
+// Polyfill crypto.randomUUID for jsdom
+Object.defineProperty(global, 'crypto', {
+ value: { randomUUID: () => 'test-uuid-1234' },
+});
+
+// --- Mocks ---
+
+jest.mock('@redhat-cloud-services/frontend-components/useChrome', () => ({
+ __esModule: true,
+ default: () => ({
+ auth: { getUser: jest.fn().mockResolvedValue({ identity: { user: { username: 'test-user' } } }) },
+ analytics: { track: jest.fn() },
+ }),
+}));
+
+jest.mock('@scalprum/react-core', () => ({
+ ScalprumComponent: ({ scope, module }: { scope: string; module: string }) => ScalprumWidget
,
+}));
+
+const mockGetDashboardTemplates = jest.fn();
+const mockPatchDashboardTemplate = jest.fn();
+
+jest.mock('../../api/dashboard-templates', () => ({
+ ...jest.requireActual('../../api/dashboard-templates'),
+ getDashboardTemplates: (...args: unknown[]) => mockGetDashboardTemplates(...args),
+ patchDashboardTemplate: (...args: unknown[]) => mockPatchDashboardTemplate(...args),
+}));
+
+jest.mock('@patternfly/widgetized-dashboard', () => ({
+ GridLayout: ({ widgetMapping, template, showEmptyState, emptyStateComponent }: any) => (
+
+ {showEmptyState && emptyStateComponent}
+ {Object.keys(widgetMapping).map((type: string) => (
+
+ {widgetMapping[type]?.config?.icon}
+ {widgetMapping[type]?.config?.title}
+
+ ))}
+
+ ),
+}));
+
+// --- Test data ---
+
+const mockScalprumMapping: ScalprumWidgetMapping = {
+ 'favorite-services': {
+ scope: 'chrome',
+ module: './FavoriteServices',
+ importName: 'default',
+ defaults: { w: 2, h: 3, maxH: 6, minH: 2 },
+ config: {
+ title: 'My favorite services',
+ icon: 'StarIcon',
+ headerLink: { title: 'View all services', href: '/services' },
+ },
+ },
+ 'rhel-widget': {
+ scope: 'insights',
+ module: './RhelWidget',
+ importName: 'default',
+ defaults: { w: 2, h: 4, maxH: 8, minH: 2 },
+ config: {
+ title: 'Red Hat Enterprise Linux',
+ icon: 'RhelIcon',
+ },
+ },
+ 'no-icon-widget': {
+ scope: 'test',
+ module: './NoIcon',
+ importName: 'default',
+ defaults: { w: 1, h: 2, maxH: 4, minH: 1 },
+ config: {
+ title: 'Widget without icon',
+ },
+ },
+};
+
+const mockTemplate = {
+ sm: [],
+ md: [],
+ lg: [],
+ xl: [
+ { i: 'favorite-services#1', x: 0, y: 0, w: 2, h: 3, title: 'My favorite services', widgetType: 'favorite-services' },
+ { i: 'rhel-widget#1', x: 2, y: 0, w: 2, h: 4, title: 'Red Hat Enterprise Linux', widgetType: 'rhel-widget' },
+ ],
+};
+
+function renderWithStore(ui: React.ReactElement, storeOverrides?: Record) {
+ const store = createStore();
+ if (storeOverrides?.widgetMapping) store.set(widgetMappingAtom, storeOverrides.widgetMapping as ScalprumWidgetMapping);
+ if (storeOverrides?.template) store.set(templateAtom, storeOverrides.template as any);
+ if (storeOverrides?.templateId !== undefined) store.set(templateIdAtom, storeOverrides.templateId as number);
+ if (storeOverrides?.layoutVariant) store.set(layoutVariantAtom, storeOverrides.layoutVariant as any);
+ return render({ui} );
+}
+
+// --- Tests ---
+
+describe('convertWidgetMapping', () => {
+ it('converts icon strings to React elements', () => {
+ const result = convertWidgetMapping(mockScalprumMapping);
+ expect(result['favorite-services'].config?.icon).toBeDefined();
+ expect(React.isValidElement(result['favorite-services'].config?.icon)).toBe(true);
+ });
+
+ it('converts a custom service icon string to a React element', () => {
+ const result = convertWidgetMapping(mockScalprumMapping);
+ expect(result['rhel-widget'].config?.icon).toBeDefined();
+ expect(React.isValidElement(result['rhel-widget'].config?.icon)).toBe(true);
+ });
+
+ it('falls back to CogsIcon when no icon is provided', () => {
+ const result = convertWidgetMapping(mockScalprumMapping);
+ expect(result['no-icon-widget'].config?.icon).toBeDefined();
+ expect(React.isValidElement(result['no-icon-widget'].config?.icon)).toBe(true);
+ });
+
+ it('passes through title correctly', () => {
+ const result = convertWidgetMapping(mockScalprumMapping);
+ expect(result['favorite-services'].config?.title).toBe('My favorite services');
+ expect(result['rhel-widget'].config?.title).toBe('Red Hat Enterprise Linux');
+ expect(result['no-icon-widget'].config?.title).toBe('Widget without icon');
+ });
+
+ it('passes through headerLink correctly', () => {
+ const result = convertWidgetMapping(mockScalprumMapping);
+ expect(result['favorite-services'].config?.headerLink).toEqual({ title: 'View all services', href: '/services' });
+ expect(result['rhel-widget'].config?.headerLink).toBeUndefined();
+ });
+
+ it('sets wrapperProps.className to the widget scope', () => {
+ const result = convertWidgetMapping(mockScalprumMapping);
+ expect(result['favorite-services'].config?.wrapperProps?.className).toBe('chrome');
+ expect(result['rhel-widget'].config?.wrapperProps?.className).toBe('insights');
+ });
+
+ it('sets cardBodyProps.className to scope-widgetType', () => {
+ const result = convertWidgetMapping(mockScalprumMapping);
+ expect(result['favorite-services'].config?.cardBodyProps?.className).toBe('chrome-favorite-services');
+ expect(result['rhel-widget'].config?.cardBodyProps?.className).toBe('insights-rhel-widget');
+ });
+
+ it('preserves widget defaults', () => {
+ const result = convertWidgetMapping(mockScalprumMapping);
+ expect(result['favorite-services'].defaults).toEqual({ w: 2, h: 3, maxH: 6, minH: 2 });
+ });
+
+ it('provides a renderWidget function', () => {
+ const result = convertWidgetMapping(mockScalprumMapping);
+ expect(typeof result['favorite-services'].renderWidget).toBe('function');
+ });
+
+ it('converts all widget types from input', () => {
+ const result = convertWidgetMapping(mockScalprumMapping);
+ expect(Object.keys(result)).toEqual(['favorite-services', 'rhel-widget', 'no-icon-widget']);
+ });
+
+ it('renderWidget produces a Scalprum component', () => {
+ const result = convertWidgetMapping(mockScalprumMapping);
+ const { getByTestId } = render(<>{result['favorite-services'].renderWidget('test-id')}>);
+ expect(getByTestId('scalprum-chrome-./FavoriteServices')).toBeInTheDocument();
+ });
+});
+
+describe('GridLayout rendering states', () => {
+ beforeEach(() => {
+ mockGetDashboardTemplates.mockReset();
+ mockPatchDashboardTemplate.mockReset();
+ });
+
+ it('renders the container element', () => {
+ renderWithStore( );
+ expect(document.getElementById('widget-layout-container')).toBeInTheDocument();
+ });
+
+ it('does not render PatternFlyGridLayout when widget mapping is empty', () => {
+ renderWithStore( );
+ expect(screen.queryByTestId('pf-grid-layout')).not.toBeInTheDocument();
+ });
+
+ it('renders PatternFlyGridLayout when widget mapping has entries', () => {
+ renderWithStore( , { widgetMapping: mockScalprumMapping });
+ expect(screen.getByTestId('pf-grid-layout')).toBeInTheDocument();
+ });
+
+ it('passes showEmptyState=true while loading (isLoaded is false)', () => {
+ renderWithStore( , { widgetMapping: mockScalprumMapping });
+ const grid = screen.getByTestId('pf-grid-layout');
+ expect(grid).toHaveAttribute('data-show-empty', 'true');
+ });
+
+ it('shows empty state after loading when template has no widgets', async () => {
+ mockGetDashboardTemplates.mockResolvedValue([{ id: 1, default: true, templateConfig: { sm: [], md: [], lg: [], xl: [] } }]);
+
+ renderWithStore( , { widgetMapping: mockScalprumMapping, templateId: -1 });
+
+ await waitFor(() => {
+ expect(screen.getByText('No dashboard content')).toBeInTheDocument();
+ });
+ });
+
+ it('renders widget tiles when template has widgets', () => {
+ renderWithStore( , { widgetMapping: mockScalprumMapping, template: mockTemplate });
+ expect(screen.getByTestId('widget-tile-favorite-services')).toBeInTheDocument();
+ expect(screen.getByTestId('widget-tile-rhel-widget')).toBeInTheDocument();
+ expect(screen.getByTestId('widget-tile-no-icon-widget')).toBeInTheDocument();
+ });
+
+ it('renders correct widget titles', () => {
+ renderWithStore( , { widgetMapping: mockScalprumMapping, template: mockTemplate });
+ expect(screen.getByTestId('widget-title-favorite-services')).toHaveTextContent('My favorite services');
+ expect(screen.getByTestId('widget-title-rhel-widget')).toHaveTextContent('Red Hat Enterprise Linux');
+ });
+
+ it('renders icons inside widget tiles', () => {
+ renderWithStore( , { widgetMapping: mockScalprumMapping, template: mockTemplate });
+ expect(screen.getByTestId('widget-icon-favorite-services').querySelector('svg')).toBeInTheDocument();
+ expect(screen.getByTestId('widget-icon-rhel-widget').querySelector('svg')).toBeInTheDocument();
+ });
+
+ it('shows error notification when API fails', async () => {
+ mockGetDashboardTemplates.mockRejectedValue(new Error('API error'));
+
+ renderWithStore( , { widgetMapping: mockScalprumMapping, templateId: -1 });
+
+ await waitFor(() => {
+ expect(mockGetDashboardTemplates).toHaveBeenCalled();
+ });
+ });
+
+ it('does not fetch templates when templateId is >= 0', () => {
+ renderWithStore( , { widgetMapping: mockScalprumMapping, templateId: 5 });
+ expect(mockGetDashboardTemplates).not.toHaveBeenCalled();
+ });
+});
+
+describe('GridLayout snapshots', () => {
+ it('matches snapshot in loading state (empty mapping)', () => {
+ const { container } = renderWithStore( );
+ expect(container).toMatchSnapshot();
+ });
+
+ it('matches snapshot with widget mapping (before API load)', () => {
+ const { container } = renderWithStore( , { widgetMapping: mockScalprumMapping });
+ expect(container).toMatchSnapshot();
+ });
+
+ it('matches snapshot with widgets and template', () => {
+ const { container } = renderWithStore( , {
+ widgetMapping: mockScalprumMapping,
+ template: mockTemplate,
+ templateId: 1,
+ layoutVariant: 'xl',
+ });
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/src/Components/DnDLayout/GridLayout.tsx b/src/Components/DnDLayout/GridLayout.tsx
index 7d13189..6777054 100644
--- a/src/Components/DnDLayout/GridLayout.tsx
+++ b/src/Components/DnDLayout/GridLayout.tsx
@@ -1,25 +1,19 @@
-import 'react-grid-layout/css/styles.css';
+import '@patternfly/widgetized-dashboard/dist/esm/styles.css';
import './GridLayout.scss';
-import ReactGridLayout, { Layout, ReactGridLayoutProps } from 'react-grid-layout';
-import ResizeHandleIcon from './resize-handle.svg';
-import GridTile, { SetWidgetAttribute } from './GridTile';
-import { useEffect, useMemo, useRef, useState } from 'react';
-import { isWidgetType } from '../Widgets/widgetTypes';
+import './WidgetHeader.scss';
+import '../Icons/HeaderIcon.scss';
+import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
-import { currentDropInItemAtom } from '../../state/currentDropInItemAtom';
+import ResizeHandleSVG from './resize-handle.svg';
import { widgetMappingAtom } from '../../state/widgetMappingAtom';
import { layoutVariantAtom } from '../../state/layoutAtom';
import { templateAtom, templateIdAtom } from '../../state/templateAtom';
+import { currentDropInItemAtom } from '../../state/currentDropInItemAtom';
import DebouncePromise from 'awesome-debounce-promise';
-import React from 'react';
import {
- ExtendedLayoutItem,
LayoutTypes,
- Variants,
- extendLayout,
getDashboardTemplates,
getDefaultTemplate,
- getWidgetIdentifier,
mapTemplateConfigToExtendedTemplateConfig,
patchDashboardTemplate,
} from '../../api/dashboard-templates';
@@ -27,27 +21,17 @@ import useCurrentUser from '../../hooks/useCurrentUser';
import { Button, EmptyState, EmptyStateActions, EmptyStateBody, EmptyStateVariant, PageSection } from '@patternfly/react-core';
import { ExternalLinkAltIcon, GripVerticalIcon, PlusCircleIcon } from '@patternfly/react-icons';
import useChrome from '@redhat-cloud-services/frontend-components/useChrome';
-import { getWidget } from '../Widgets/widgetDefaults';
import { drawerExpandedAtom } from '../../state/drawerExpandedAtom';
-import { columns, dropping_elem_id } from '../../consts';
import { useAddNotification } from '../../state/notificationsAtom';
import { currentlyUsedWidgetsAtom } from '../../state/currentlyUsedWidgetsAtom';
+import { ExtendedTemplateConfig, GridLayout as PatternFlyGridLayout, Variants } from '@patternfly/widgetized-dashboard';
+import convertWidgetMapping from './ConvertWidgetMapping';
-export const breakpoints: {
- [key in Variants]: number;
-} = { xl: 1550, lg: 1400, md: 1100, sm: 800 };
+const sidebarBreakpoints = { xl: 1250, lg: 1100, md: 800, sm: 500 };
const documentationLink =
'https://docs.redhat.com/en/documentation/red_hat_hybrid_cloud_console/1-latest/html-single/getting_started_with_the_red_hat_hybrid_cloud_console/index#customizing-main-page_navigating-the-console';
-const getResizeHandle = (resizeHandleAxis: string, ref: React.Ref) => {
- return (
-
-
-
- );
-};
-
const LayoutEmptyState = () => {
const setDrawerExpanded = useSetAtom(drawerExpandedAtom);
@@ -59,8 +43,8 @@ const LayoutEmptyState = () => {
- You don’t have any widgets on your dashboard. To populate your dashboard, drag items from the blue widget bank to this
- dashboard body here.
+ You don't have any widgets on your dashboard. To populate your dashboard, drag items from the blue widget bank to
+ this dashboard body here.
} iconPosition="end" component="a" href={documentationLink}>
@@ -76,112 +60,66 @@ const debouncedPatchDashboardTemplate = DebouncePromise(patchDashboardTemplate,
onlyResolvesLast: true,
});
+const getResizeHandle = (resizeHandleAxis: string, ref: React.Ref) => (
+
+
+
+);
+
const GridLayout = ({ isLayoutLocked = false, layoutType = 'landingPage' }: { isLayoutLocked?: boolean; layoutType?: LayoutTypes }) => {
- const [isDragging, setIsDragging] = useState(false);
- const [isInitialRender, setIsInitialRender] = useState(true);
const [isLoaded, setIsLoaded] = useState(false);
const [layoutVariant, setLayoutVariant] = useAtom(layoutVariantAtom);
const [template, setTemplate] = useAtom(templateAtom);
const [templateId, setTemplateId] = useAtom(templateIdAtom);
- const [layoutWidth, setLayoutWidth] = useState(1200);
const layoutRef = useRef(null);
const { currentUser } = useCurrentUser();
- const widgetMapping = useAtomValue(widgetMappingAtom);
+ const scalprumWidgetMapping = useAtomValue(widgetMappingAtom);
const addNotification = useAddNotification();
const setCurrentlyUsedWidgets = useSetAtom(currentlyUsedWidgetsAtom);
+ const setDrawerExpanded = useSetAtom(drawerExpandedAtom);
+ const currentDropInItem = useAtomValue(currentDropInItemAtom);
const { analytics } = useChrome();
- const [currentDropInItem, setCurrentDropInItem] = useAtom(currentDropInItemAtom);
- const droppingItemTemplate: ReactGridLayoutProps['droppingItem'] = useMemo(() => {
- if (currentDropInItem && isWidgetType(widgetMapping, currentDropInItem)) {
- return {
- ...widgetMapping[currentDropInItem].defaults,
- i: dropping_elem_id,
- widgetType: currentDropInItem,
- title: 'New title',
- config: widgetMapping[currentDropInItem].config,
- };
- }
- }, [currentDropInItem]);
-
- const setWidgetAttribute: SetWidgetAttribute = (id, attributeName, value) =>
- setTemplate((prev) =>
- Object.entries(prev).reduce(
- (acc, [size, layout]) => ({
- ...acc,
- [size]: layout.map((widget) => (widget.i === id ? { ...widget, [attributeName]: value } : widget)),
- }),
- prev
- )
- );
-
- const removeWidget = (id: string) =>
- setTemplate((prev) =>
- Object.entries(prev).reduce(
- (acc, [size, layout]) => ({
- ...acc,
- [size]: layout.filter((widget) => widget.i !== id),
- }),
- prev
- )
- );
-
- const onDrop: ReactGridLayoutProps['onDrop'] = (_layout: ExtendedLayoutItem[], layoutItem: ExtendedLayoutItem, event: DragEvent) => {
- const data = event.dataTransfer?.getData('text') || '';
- if (isWidgetType(widgetMapping, data)) {
- setCurrentDropInItem(undefined);
- setTemplate((prev) =>
- Object.entries(prev).reduce((acc, [size, layout]) => {
- const newWidget = {
- ...layoutItem,
- ...widgetMapping[data].defaults,
- // make sure the configuration is valid for all layout sizes
- w: size === layoutVariant ? layoutItem.w : Math.min(widgetMapping[data].defaults.w, columns[size as Variants]),
- x: size === layoutVariant ? layoutItem.x : Math.min(layoutItem.x, columns[size as Variants]),
- widgetType: data,
- i: getWidgetIdentifier(data),
- title: 'New title',
- config: widgetMapping[data].config,
- };
- return {
- ...acc,
- [size]: layout.reduce(
- (acc, curr) => {
- if (curr.x + curr.w > newWidget.x && curr.y + curr.h <= newWidget.y) {
- acc.push(curr);
- } else {
- // push the current items down on the Y axis if they are supposed to be below the new widget
- acc.push({ ...curr, y: curr.y + curr.h });
- }
-
- return acc;
- },
- [newWidget]
- ),
- };
- }, prev)
- );
- analytics.track('widget-layout.widget-add', { data });
- }
- event.preventDefault();
- };
+ // Convert Scalprum mapping to PatternFly mapping
+ const widgetMapping = useMemo(() => convertWidgetMapping(scalprumWidgetMapping), [scalprumWidgetMapping]);
+
+ // Convert local ExtendedTemplateConfig to PatternFly ExtendedTemplateConfig
+ const patternFlyTemplate: ExtendedTemplateConfig = useMemo(() => {
+ const result: ExtendedTemplateConfig = { sm: [], md: [], lg: [], xl: [] };
+ (Object.keys(template) as Variants[]).forEach((variant) => {
+ result[variant] = template[variant].map((item) => ({
+ ...item,
+ // Use the original widgetType (no prefix) to match the widgetMapping keys
+ widgetType: item.widgetType,
+ title: item.title || 'Widget',
+ config: scalprumWidgetMapping[item.widgetType]?.config,
+ }));
+ });
+ return result;
+ }, [template, scalprumWidgetMapping]);
- const onLayoutChange = async (currentLayout: Layout[]) => {
- if (isInitialRender) {
- setIsInitialRender(false);
- setCurrentlyUsedWidgets(activeLayout.map((item) => item.widgetType));
- return;
- }
- if (isLayoutLocked || templateId < 0 || !layoutVariant || currentDropInItem) {
+ const handleTemplateChange = async (newTemplate: ExtendedTemplateConfig) => {
+ if (isLayoutLocked || templateId < 0) {
return;
}
- const data = extendLayout({ ...template, [layoutVariant]: currentLayout });
+ setTemplate(newTemplate as any);
+
+ // Update currently used widgets
+ const activeLayout = newTemplate[layoutVariant] || [];
setCurrentlyUsedWidgets(activeLayout.map((item) => item.widgetType));
- setTemplate(data);
try {
- await debouncedPatchDashboardTemplate(templateId, { templateConfig: data });
+ // Convert ExtendedTemplateConfig to TemplateConfig for API
+ const templateConfig: any = { sm: [], md: [], lg: [], xl: [] };
+ (Object.keys(newTemplate) as Variants[]).forEach((variant) => {
+ templateConfig[variant] = newTemplate[variant].map(({ widgetType, config, locked, ...item }) => ({
+ ...item,
+ title: item.title || 'Widget',
+ }));
+ });
+
+ await debouncedPatchDashboardTemplate(templateId, { templateConfig });
} catch (error) {
console.error(error);
addNotification({
@@ -192,11 +130,19 @@ const GridLayout = ({ isLayoutLocked = false, layoutType = 'landingPage' }: { is
}
};
+ const handleActiveWidgetsChange = (widgetTypes: string[]) => {
+ setCurrentlyUsedWidgets(widgetTypes);
+ };
+
+ const handleDrawerExpandChange = (expanded: boolean) => {
+ setDrawerExpanded(expanded);
+ };
+
useEffect(() => {
if (!currentUser || templateId >= 0) {
return;
}
- // TODO template type should be pulled from app config for reusability
+
getDashboardTemplates(layoutType)
.then((templates) => {
const customDefaultTemplate = getDefaultTemplate(templates);
@@ -206,11 +152,11 @@ const GridLayout = ({ isLayoutLocked = false, layoutType = 'landingPage' }: { is
const extendedTemplateConfig = mapTemplateConfigToExtendedTemplateConfig(customDefaultTemplate.templateConfig);
const currentWidth = layoutRef?.current?.clientWidth || document.body.clientWidth;
let targetVariant: Variants;
- if (currentWidth > breakpoints.lg) {
+ if (currentWidth >= sidebarBreakpoints.xl) {
targetVariant = 'xl';
- } else if (breakpoints.lg >= currentWidth && currentWidth > breakpoints.md) {
+ } else if (currentWidth >= sidebarBreakpoints.lg) {
targetVariant = 'lg';
- } else if (breakpoints.md >= currentWidth && currentWidth > breakpoints.sm) {
+ } else if (currentWidth >= sidebarBreakpoints.md) {
targetVariant = 'md';
} else {
targetVariant = 'sm';
@@ -232,92 +178,32 @@ const GridLayout = ({ isLayoutLocked = false, layoutType = 'landingPage' }: { is
});
}, [currentUser, templateId]);
- function getGridDimensions(currentWidth: number) {
- let variant: Variants = 'xl';
- Object.entries(breakpoints).forEach(([breakpoint, value]) => {
- if (value >= currentWidth) {
- variant = breakpoint as Variants;
- }
- });
- return variant;
- }
-
- useEffect(() => {
- const currentWidth = layoutRef.current?.getBoundingClientRect().width ?? 1200;
- const variant: Variants = getGridDimensions(currentWidth);
- setLayoutVariant(variant);
- setLayoutWidth(currentWidth);
- const observer = new ResizeObserver((entries) => {
- if (!entries[0]) return;
-
- const currentWidth = entries[0].contentRect.width;
-
- const variant: Variants = getGridDimensions(currentWidth);
- setLayoutVariant(variant);
- setLayoutWidth(currentWidth);
- });
- if (layoutRef.current) {
- observer.observe(layoutRef.current);
- }
-
- return () => {
- observer.disconnect();
- };
- }, []);
+ const activeLayout = patternFlyTemplate[layoutVariant] || [];
- const activeLayout = template[layoutVariant] || [];
return (
- // {/* relative position is required for the grid layout to properly calculate
- // child translation while dragging is in progress */}
);
};
diff --git a/src/Components/DnDLayout/GridTile.scss b/src/Components/DnDLayout/GridTile.scss
deleted file mode 100644
index b46c340..0000000
--- a/src/Components/DnDLayout/GridTile.scss
+++ /dev/null
@@ -1,57 +0,0 @@
-.grid-tile::before{
- z-index: 1;
-}
-.grid-tile {
- background: var(--pf-t--global--background--color--100);
- position: relative;
- height: 100%;
- overflow: hidden;
- .drag-handle {
- cursor: grab;
- }
- .drag-handle.dragging {
- cursor: grabbing;
- }
- &.static {
- .drag-handle {
- cursor: not-allowed;
- }
- .drag-handle.dragging {
- cursor: not-allowed;
- }
- }
- .pf-v6-c-card {
- &__header {
- padding: var(--pf-t--global--spacer--md);
- padding-bottom: var(--pf-t--global--spacer--sm);
- background: var(--pf-t--global--background--color--200);
- .widg-c-icon--header {
- .service-icon {
- height: 28px;
- width: 28px;
- }
- .pf-v6-svg {
- color: var(--pf-t--color--blue--50);
- height: 22px;
- width: 22px;
- margin-bottom: var(--pf-t--global--spacer--sm);
- }
- }
- .widg-card-header-text {
- gap: 0;
- line-height: 0;
- padding-top: 2px;
- }
- .pf-v6-c-menu-toggle {
- padding-left: 0;
- padding-right: 0;
- }
- }
- &__actions {
- padding-left: var(--pf-t--global--spacer--xs);
- }
- &__body {
- background: var(--pf-t--global--background--color--100);
- }
- }
-}
diff --git a/src/Components/DnDLayout/GridTile.tsx b/src/Components/DnDLayout/GridTile.tsx
deleted file mode 100644
index 56fafcb..0000000
--- a/src/Components/DnDLayout/GridTile.tsx
+++ /dev/null
@@ -1,243 +0,0 @@
-import {
- Button,
- Card,
- CardBody,
- CardHeader,
- CardTitle,
- Divider,
- Dropdown,
- DropdownItem,
- DropdownList,
- Flex,
- FlexItem,
- HelperText,
- HelperTextItem,
- Icon,
- MenuToggle,
- MenuToggleElement,
- Skeleton,
- Tooltip,
-} from '@patternfly/react-core';
-import { CompressIcon, EllipsisVIcon, ExpandIcon, GripVerticalIcon, LockIcon, MinusCircleIcon, UnlockIcon } from '@patternfly/react-icons';
-import React, { useMemo, useState } from 'react';
-import clsx from 'clsx';
-import { Link } from 'react-router-dom';
-
-import './GridTile.scss';
-import { Layout } from 'react-grid-layout';
-import { ExtendedLayoutItem, WidgetConfiguration } from '../../api/dashboard-templates';
-import { widgetMappingAtom } from '../../state/widgetMappingAtom';
-import { getWidget } from '../Widgets/widgetDefaults';
-import { useAtomValue } from 'jotai';
-import classNames from 'classnames';
-import HeaderIcon from '../Icons/HeaderIcon';
-import useChrome from '@redhat-cloud-services/frontend-components/useChrome';
-
-export type SetWidgetAttribute = (id: string, attributeName: keyof ExtendedLayoutItem, value: T) => void;
-
-export type GridTileProps = React.PropsWithChildren<{
- widgetType: string;
- icon?: React.ComponentClass;
- setIsDragging: (isDragging: boolean) => void;
- isDragging: boolean;
- setWidgetAttribute: SetWidgetAttribute;
- widgetConfig: Layout & {
- colWidth: number;
- locked?: boolean;
- config?: WidgetConfiguration;
- };
- removeWidget: (id: string) => void;
-}>;
-
-const GridTile = ({ widgetType, isDragging, setIsDragging, setWidgetAttribute, widgetConfig, removeWidget }: GridTileProps) => {
- const [isOpen, setIsOpen] = useState(false);
- const [isLoaded, setIsLoaded] = useState(false);
- const widgetMapping = useAtomValue(widgetMappingAtom);
- const { headerLink } = widgetConfig.config || {};
- const hasHeader = headerLink && headerLink.href && headerLink.title;
-
- const { analytics } = useChrome();
-
- const widgetData = useMemo(() => {
- return getWidget(widgetMapping, widgetType, () => setIsLoaded(true));
- }, [widgetMapping, widgetType]);
-
- if (!widgetData) {
- return null;
- }
-
- const [linkHref, linkTarget] = useMemo(() => {
- if (!headerLink?.href) {
- return [];
- }
-
- try {
- const url = new URL(headerLink.href);
- return url.origin === window.location.origin ? [url.pathname, undefined] : [headerLink.href, '_blank'];
- } catch {
- return [headerLink.href];
- }
- }, [headerLink?.href]);
-
- const { node, module, scope } = widgetData;
-
- const dropdownItems = useMemo(() => {
- const isMaximized = widgetConfig.h === widgetConfig.maxH;
- const isMinimized = widgetConfig.h === widgetConfig.minH;
- return (
- <>
- {
- setIsOpen(false);
- setWidgetAttribute(widgetConfig.i, 'static', !widgetConfig.static);
- }}
- icon={widgetConfig.static ? : }
- >
- {widgetConfig.static ? 'Unlock location and size' : 'Lock location and size'}
-
- {
- setWidgetAttribute(widgetConfig.i, 'h', widgetConfig.maxH ?? widgetConfig.h);
- setIsOpen(false);
- }}
- icon={ }
- >
- Autosize height to content
-
- {
- setWidgetAttribute(widgetConfig.i, 'h', widgetConfig.minH ?? widgetConfig.h);
- setIsOpen(false);
- }}
- isDisabled={isMinimized || widgetConfig.static}
- icon={ }
- >
- Minimize height
-
- {
- removeWidget(widgetConfig.i);
- analytics.track('widget-layout.widget-remove', { widgetType });
- }}
- icon={
-
-
-
- }
- isDisabled={widgetConfig.static}
- >
- Remove
-
-
- {"All 'removed' widgets can be added back by clicking the 'Add widgets' button."}
-
-
-
- >
- );
- }, [widgetConfig.minH, widgetConfig.maxH, widgetConfig.h, widgetConfig.i, widgetConfig.static, setWidgetAttribute]);
-
- const headerActions = (
- <>
- Actions
}>
- ) => (
- setIsOpen((prev) => !prev)}
- variant="plain"
- aria-label="widget actions menu toggle"
- >
-
-
- )}
- isOpen={isOpen}
- onOpenChange={(isOpen: boolean) => setIsOpen(isOpen)}
- >
- {dropdownItems}
-
-
- {widgetConfig.static ? 'Widget locked' : 'Move'}}>
- {
- setIsDragging(true);
- analytics.track('widget-layout.widget-move', { widgetType });
- }}
- onMouseUp={() => setIsDragging(false)}
- className={clsx('drag-handle', {
- dragging: isDragging,
- })}
- >
-
-
-
- >
- );
-
- return (
-
-
-
-
-
- {isLoaded ? : }
-
-
- {isLoaded ? (
-
- {widgetConfig?.config?.title || widgetType}
-
- ) : (
-
- )}
- {hasHeader && isLoaded && (
-
- }
- >
- {headerLink.title}
-
-
- )}
-
-
-
-
-
-
- {node}
-
-
- );
-};
-
-export default GridTile;
diff --git a/src/Components/DnDLayout/WidgetHeader.scss b/src/Components/DnDLayout/WidgetHeader.scss
new file mode 100644
index 0000000..914ff4e
--- /dev/null
+++ b/src/Components/DnDLayout/WidgetHeader.scss
@@ -0,0 +1,67 @@
+// Widget header overrides for @patternfly/widgetized-dashboard
+// Matches the old grid-tile card header styling
+
+.pf-v6-widget-grid-tile {
+ overflow: hidden;
+
+ // Header padding and background (matches old .pf-v6-c-card__header)
+ .pf-v6-widget-grid-tile__header {
+ background: var(--pf-t--global--background--color--200);
+ border-bottom: var(--pf-t--global--border--width--regular) solid var(--pf-t--global--border--color--default);
+ padding: var(--pf-t--global--spacer--md);
+ padding-top: var(--pf-t--global--spacer--sm);
+ padding-bottom: var(--pf-t--global--spacer--sm);
+
+
+ // Prevent header content from overflowing
+ .pf-v6-c-card__header-main {
+ min-width: 0;
+
+ > .pf-v6-l-flex {
+ min-width: 0;
+ }
+ }
+ }
+
+ // Card actions spacing (matches old .pf-v6-c-card__actions)
+ .pf-v6-c-card__actions {
+ padding-left: var(--pf-t--global--spacer--xs);
+ }
+
+ // Card body background (matches old .pf-v6-c-card__body)
+ .pf-v6-widget-grid-tile__body {
+ background: var(--pf-t--global--background--color--100);
+ }
+
+ // Header layout: icon + text row
+ .pf-v6-widget-header-layout {
+ align-items: center;
+ min-width: 0;
+ flex-shrink: 1;
+ }
+
+ // Header text: title + link on same row, wrapping allowed
+ .pf-v6-widget-card-header-text {
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 0 var(--pf-t--global--spacer--sm);
+ line-height: 1.5;
+ min-width: 0;
+ flex-shrink: 1;
+
+ // Header link (matches old .pf-v6-u-font-size-xs .pf-v6-u-font-weight-bold)
+ .pf-v6-widget-grid-tile__header-link {
+ font-size: var(--pf-t--global--font--size--xs);
+ font-weight: var(--pf-t--global--font--weight--body--bold);
+ white-space: normal;
+ padding: 0;
+ }
+ }
+
+ // Kebab menu toggle compact padding (matches old .pf-v6-c-menu-toggle)
+ .pf-v6-widget-grid-tile__menu-toggle {
+ padding-left: 0;
+ padding-right: 0;
+ }
+}
diff --git a/src/Components/DnDLayout/WidgetSkeleton.scss b/src/Components/DnDLayout/WidgetSkeleton.scss
new file mode 100644
index 0000000..d4242c5
--- /dev/null
+++ b/src/Components/DnDLayout/WidgetSkeleton.scss
@@ -0,0 +1,5 @@
+// Widget loading skeleton styles
+// Target skeletons inside the widget layout container
+#widget-layout-container .pf-v6-c-skeleton {
+ border-radius: 0;
+}
diff --git a/src/Components/DnDLayout/__snapshots__/GridLayout.test.tsx.snap b/src/Components/DnDLayout/__snapshots__/GridLayout.test.tsx.snap
new file mode 100644
index 0000000..2afff92
--- /dev/null
+++ b/src/Components/DnDLayout/__snapshots__/GridLayout.test.tsx.snap
@@ -0,0 +1,462 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`GridLayout snapshots matches snapshot in loading state (empty mapping) 1`] = `
+
+`;
+
+exports[`GridLayout snapshots matches snapshot with widget mapping (before API load) 1`] = `
+
+`;
+
+exports[`GridLayout snapshots matches snapshot with widgets and template 1`] = `
+
+`;
diff --git a/src/Components/Icons/HeaderIcon.scss b/src/Components/Icons/HeaderIcon.scss
new file mode 100644
index 0000000..79feb74
--- /dev/null
+++ b/src/Components/Icons/HeaderIcon.scss
@@ -0,0 +1,33 @@
+// Icon sizing for the widget drawer (uses .widg-c-icon--header wrapper)
+.widg-c-icon--header {
+ .service-icon {
+ height: 28px;
+ width: 28px;
+ }
+ .pf-v6-svg {
+ color: var(--pf-t--color--blue--50);
+ height: 22px;
+ width: 22px;
+ margin-bottom: var(--pf-t--global--spacer--sm);
+ }
+}
+
+// Icon sizing for the card header (library wraps icons in .pf-v6-c-icon)
+.pf-v6-widget-header-layout {
+ .pf-v6-c-icon {
+ --pf-v6-c-icon--Width: 28px;
+ --pf-v6-c-icon--Height: 28px;
+ flex-shrink: 0;
+
+ .service-icon {
+ display: block;
+ height: 28px;
+ width: 28px;
+ }
+ .pf-v6-svg {
+ color: var(--pf-t--color--blue--50);
+ height: 22px;
+ width: 22px;
+ }
+ }
+}
diff --git a/src/api/dashboard-templates.ts b/src/api/dashboard-templates.ts
index 2c83c92..4df9a06 100644
--- a/src/api/dashboard-templates.ts
+++ b/src/api/dashboard-templates.ts
@@ -1,4 +1,4 @@
-import { Layout } from 'react-grid-layout';
+import { LayoutItem } from 'react-grid-layout';
import { ScalprumComponentProps } from '@scalprum/react-core';
import { dropping_elem_id } from '../consts';
import { VisibilityFunctions } from '@redhat-cloud-services/types';
@@ -14,7 +14,7 @@ export type LayoutTypes = 'landingPage';
export type Variants = 'sm' | 'md' | 'lg' | 'xl';
-export type LayoutWithTitle = Layout & { title: string };
+export type LayoutWithTitle = LayoutItem & { title: string };
export type TemplateConfig = {
[k in Variants]: LayoutWithTitle[];
@@ -80,6 +80,7 @@ export type WidgetDefaults = {
h: number;
maxH: number;
minH: number;
+ minW?: number;
};
export type WidgetHeaderLink = {