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. - - )} - - - - - - - {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`] = ` +
+
+
+
+
+
+
+
+ +
+
+

+ No dashboard content +

+
+
+
+ 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. +
+ +
+
+
+
+ + + + + My favorite services + +
+
+ + + + + + + + + + + + + + + + + Red Hat Enterprise Linux + +
+
+ + + + + Widget without icon + +
+
+
+
+`; + +exports[`GridLayout snapshots matches snapshot with widgets and template 1`] = ` +
+
+
+
+
+
+
+
+ +
+
+

+ No dashboard content +

+
+
+
+ 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. +
+ +
+
+
+
+ + + + + My favorite services + +
+
+ + + + + + + + + + + + + + + + + Red Hat Enterprise Linux + +
+
+ + + + + Widget without icon + +
+
+
+
+`; 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 = {