Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 22 additions & 17 deletions .github/workflows/pr-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,31 +28,36 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Enable Corepack and install correct Yarn version
shell: bash
run: |
corepack enable
corepack prepare yarn@4.10.3 --activate
- uses: actions/cache@v4
id: npm-cache
name: Load npm deps from cache
id: yarn-cache
name: Load yarn deps from cache
with:
path: '**/node_modules'
key: ${{ runner.os }}-npm-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('package-lock.json') }}
- run: npm install --frozen-lockfile --legacy-peer-deps
path: |
node_modules
**/node_modules
key: ${{ runner.os }}-yarn-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock') }}
- run: yarn install --immutable
if: steps.yarn-cache.outputs.cache-hit != 'true'
- run: npm run build
- run: yarn build
name: Build component groups
- uses: actions/cache@v4
id: docs-cache
name: Load webpack cache
with:
path: '.cache'
key: ${{ runner.os }}-v4-${{ hashFiles('yarn.lock') }}
- run: npm run build:docs
- run: yarn build:docs
name: Build docs
- run: node .github/upload-preview.js packages/module/public
name: Upload docs
if: always()
- run: npx puppeteer browsers install chrome
name: Install Chrome for Puppeteer
- run: npm run serve:docs & npm run test:a11y
name: a11y tests
- run: node .github/upload-preview.js packages/module/coverage
name: Upload a11y report
if: always()
- name: Deploy preview to surge
if: env.SURGE_LOGIN != '' && env.SURGE_TOKEN != ''
run: |
npx surge packages/module/public --domain pr-${{ github.event.number }}-widgetized-dashboard.surge.sh
- name: Install Chrome for Puppeteer
run: npx puppeteer browsers install chrome
- name: a11y tests
run: yarn serve:docs & yarn test:a11y
5 changes: 2 additions & 3 deletions packages/module/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"@patternfly/react-core": "^6.3.1",
"@patternfly/react-icons": "^6.3.1",
"clsx": "^2.1.0",
"react-grid-layout": "^1.5.1"
"react-grid-layout": "^2.2.2"
},
"peerDependencies": {
"react": "^18",
Expand All @@ -45,11 +45,10 @@
"@babel/plugin-proposal-private-methods": "^7.18.6",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@patternfly/documentation-framework": "^6.24.2",
"@patternfly/patternfly": "^6.3.1",
"@patternfly/patternfly": "^6.5.0-prerelease.33",
"@patternfly/patternfly-a11y": "^5.1.0",
"@patternfly/react-code-editor": "^6.3.1",
"@patternfly/react-table": "^6.3.1",
"@types/react-grid-layout": "^1.3.5",
"monaco-editor": "^0.53.0",
"nodemon": "^3.0.0",
"react-monaco-editor": "^0.59.0",
Expand Down
45 changes: 44 additions & 1 deletion packages/module/patternfly-docs/content/examples/basic.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,30 @@ const widgetMapping: WidgetMapping = {
defaults: { w: 2, h: 3, maxH: 6, minH: 2 },
config: {
title: 'My Widget',
icon: <MyIcon />
icon: <MyIcon />,
headerLink: {
title: 'View details',
href: '/details'
}
},
renderWidget: (id) => <MyWidgetContent />
}
};
```

### Widget configuration options

| Property | Type | Description |
|----------|------|-------------|
| `defaults.w` | `number` | Default width in grid columns |
| `defaults.h` | `number` | Default height in grid rows |
| `defaults.maxH` | `number` | Maximum height the widget can be resized to |
| `defaults.minH` | `number` | Minimum height the widget can be resized to |
| `config.title` | `string` | Widget title displayed in the header |
| `config.icon` | `ReactNode` | Icon displayed next to the title |
| `config.headerLink` | `{ title: string, href: string }` | Optional link displayed in the widget header |
| `renderWidget` | `(id: string) => ReactNode` | Function that renders the widget content |

## Template configuration

Define your initial layout using the `ExtendedTemplateConfig` type:
Expand All @@ -86,3 +103,29 @@ const initialTemplate: ExtendedTemplateConfig = {
```

Each breakpoint (xl, lg, md, sm) should have its own layout configuration to ensure proper responsive behavior.

### Layout item properties

#### Required properties

| Property | Type | Description |
|----------|------|-------------|
| `i` | `string` | Unique identifier in format `widgetType#uuid` (e.g., `'my-widget#1'`) |
| `x` | `number` | X position in grid columns (0-indexed from left) |
| `y` | `number` | Y position in grid rows (0-indexed from top) |
| `w` | `number` | Width in grid columns |
| `h` | `number` | Height in grid rows |
| `widgetType` | `string` | Must match a key in `widgetMapping` |
| `title` | `string` | Display title for this widget instance |

#### Optional properties

| Property | Type | Description |
|----------|------|-------------|
| `minW` | `number` | Minimum width during resize |
| `maxW` | `number` | Maximum width during resize |
| `minH` | `number` | Minimum height during resize |
| `maxH` | `number` | Maximum height during resize |
| `static` | `boolean` | If `true`, widget cannot be moved or resized |
| `locked` | `boolean` | If `true`, widget is locked in place |
| `config` | `WidgetConfiguration` | Override the widget's default config for this instance |
115 changes: 56 additions & 59 deletions packages/module/src/WidgetLayout/GridLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import 'react-grid-layout/css/styles.css';
import './styles';
import ReactGridLayout, { Layout, ReactGridLayoutProps } from 'react-grid-layout';
import ReactGridLayout, { useContainerWidth, LayoutItem } from 'react-grid-layout';
import GridTile, { SetWidgetAttribute } from './GridTile';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { isWidgetType } from './utils';
import React from 'react';
import {
Expand All @@ -20,7 +20,7 @@ import { columns, breakpoints, droppingElemId, getWidgetIdentifier, extendLayout
export const defaultBreakpoints = breakpoints;

const createSerializableConfig = (config?: WidgetConfiguration) => {
if (!config) return undefined;
if (!config) { return undefined; }
return {
...(config.title && { title: config.title }),
...(config.headerLink && { headerLink: config.headerLink })
Expand All @@ -30,8 +30,8 @@ const createSerializableConfig = (config?: WidgetConfiguration) => {
// SVG resize handle as inline data URI
const resizeHandleSvg = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE2IDEuMTQyODZMMTQuODU3MSAwTDAgMTQuODU3MVYxNkgxLjE0Mjg2TDE2IDEuMTQyODZaIiBmaWxsPSIjRDJEMkQyIi8+Cjwvc3ZnPgo=';

const getResizeHandle = (resizeHandleAxis: string, ref: React.Ref<HTMLDivElement>) => (
<div ref={ref} className={`react-resizable-handle react-resizable-handle-${resizeHandleAxis}`}>
const getResizeHandle = (resizeHandleAxis: string, ref: React.Ref<HTMLElement>) => (
<div ref={ref as React.Ref<HTMLDivElement>} className={`react-resizable-handle react-resizable-handle-${resizeHandleAxis}`}>
<img src={resizeHandleSvg} alt="Resize handle" />
</div>
);
Expand All @@ -57,6 +57,8 @@ export interface GridLayoutProps {
onDrawerExpandChange?: (expanded: boolean) => void;
/** Currently active widgets (for tracking) */
onActiveWidgetsChange?: (widgetTypes: string[]) => void;
/** Widget type currently being dragged from drawer */
droppingWidgetType?: string;
}

const LayoutEmptyState = ({
Expand Down Expand Up @@ -100,35 +102,35 @@ const GridLayout = ({
showEmptyState = true,
onDrawerExpandChange,
onActiveWidgetsChange,
droppingWidgetType,
}: GridLayoutProps) => {
const [isDragging, setIsDragging] = useState(false);
const [isInitialRender, setIsInitialRender] = useState(true);
const [layoutVariant, setLayoutVariant] = useState<Variants>('xl');
const [layoutWidth, setLayoutWidth] = useState<number>(1200);
const layoutRef = useRef<HTMLDivElement>(null);

// Use v2 hook for container width measurement
const { width: layoutWidth, containerRef, mounted } = useContainerWidth();

const [currentDropInItem, setCurrentDropInItem] = useState<string | undefined>();
const [internalTemplate, setInternalTemplate] = useState<ExtendedTemplateConfig>(template);

// Sync external template changes to internal state
useEffect(() => {
setInternalTemplate(template);
}, [template]);

const droppingItemTemplate: ReactGridLayoutProps['droppingItem'] = useMemo(() => {
if (currentDropInItem && isWidgetType(widgetMapping, currentDropInItem)) {
const widget = widgetMapping[currentDropInItem];
const droppingItemTemplate = useMemo(() => {
if (droppingWidgetType && isWidgetType(widgetMapping, droppingWidgetType)) {
const widget = widgetMapping[droppingWidgetType];
if (!widget) {return undefined;}
return {
...widget.defaults,
i: droppingElemId,
widgetType: currentDropInItem,
title: 'New title',
config: createSerializableConfig(widget.config)
x: 0,
y: 0,
};
}
return undefined;
}, [currentDropInItem, widgetMapping]);
}, [droppingWidgetType, widgetMapping]);

const setWidgetAttribute: SetWidgetAttribute = (id, attributeName, value) => {
const newTemplate = Object.entries(internalTemplate).reduce(
Expand All @@ -154,10 +156,11 @@ const GridLayout = ({
onTemplateChange?.(newTemplate);
};

const onDrop: ReactGridLayoutProps['onDrop'] = (_layout: ExtendedLayoutItem[], layoutItem: ExtendedLayoutItem, event: DragEvent) => {
const data = event.dataTransfer?.getData('text') || '';
const onDrop = (_layout: readonly LayoutItem[], layoutItem: LayoutItem | undefined, event: Event) => {
if (!layoutItem) { return; }
const dragEvent = event as DragEvent;
const data = dragEvent.dataTransfer?.getData('text') || '';
if (isWidgetType(widgetMapping, data)) {
setCurrentDropInItem(undefined);
const widget = widgetMapping[data];
if (!widget) {return;}
const newTemplate = Object.entries(internalTemplate).reduce((acc, [size, layout]) => {
Expand Down Expand Up @@ -193,76 +196,70 @@ const GridLayout = ({
onTemplateChange?.(newTemplate);
analytics?.('widget-layout.widget-add', { data });
}
event.preventDefault();
dragEvent.preventDefault();
};

const onLayoutChange = (currentLayout: Layout[]) => {
const onLayoutChange = (currentLayout: readonly LayoutItem[]) => {
if (isInitialRender) {
setIsInitialRender(false);
const activeWidgets = activeLayout.map((item) => item.widgetType);
onActiveWidgetsChange?.(activeWidgets);
return;
}
if (isLayoutLocked || currentDropInItem) {
if (isLayoutLocked || droppingWidgetType) {
return;
}

const newTemplate = extendLayout({ ...internalTemplate, [layoutVariant]: currentLayout });
// Create mutable copy of readonly layout for extendLayout
const newTemplate = extendLayout({ ...internalTemplate, [layoutVariant]: [...currentLayout] });
const activeWidgets = activeLayout.map((item) => item.widgetType);
onActiveWidgetsChange?.(activeWidgets);

setInternalTemplate(newTemplate);
onTemplateChange?.(newTemplate);
};

// Update layout variant when container width changes
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);
if (mounted && layoutWidth > 0) {
const variant: Variants = getGridDimensions(layoutWidth);
setLayoutVariant(variant);
setLayoutWidth(currentWidth);
});

if (layoutRef.current) {
observer.observe(layoutRef.current);
}

return () => {
observer.disconnect();
};
}, []);
}, [layoutWidth, mounted]);

const activeLayout = internalTemplate[layoutVariant] || [];

// Use default width before mount, actual width after
const effectiveWidth = mounted && layoutWidth > 0 ? layoutWidth : 1200;

return (
<div id="widget-layout-container" style={{ position: 'relative' }} ref={layoutRef}>
{activeLayout.length === 0 && !currentDropInItem && showEmptyState && (
<div id="widget-layout-container" style={{ position: 'relative' }} ref={containerRef}>
{activeLayout.length === 0 && !droppingWidgetType && showEmptyState && (
emptyStateComponent || <LayoutEmptyState onDrawerExpandChange={onDrawerExpandChange} documentationLink={documentationLink} />
)}
<ReactGridLayout
{mounted && <ReactGridLayout
key={'grid-' + layoutVariant}
draggableHandle=".drag-handle"
layout={internalTemplate[layoutVariant]}
cols={columns[layoutVariant]}
rowHeight={56}
width={layoutWidth}
isDraggable={!isLayoutLocked}
isResizable={!isLayoutLocked}
resizeHandle={getResizeHandle as unknown as ReactGridLayoutProps['resizeHandle']}
resizeHandles={['sw', 'nw', 'se', 'ne']}
width={effectiveWidth}
droppingItem={droppingItemTemplate}
isDroppable={!isLayoutLocked}
gridConfig={{
cols: columns[layoutVariant],
rowHeight: 56,
}}
dragConfig={{
handle: '.drag-handle',
enabled: !isLayoutLocked,
}}
resizeConfig={{
enabled: !isLayoutLocked,
handles: ['s', 'w', 'e', 'n', 'sw', 'nw', 'se', 'ne'],
handleComponent: getResizeHandle,
}}
dropConfig={{
enabled: !isLayoutLocked,
}}
onDrop={onDrop}
onDragStart={() => setCurrentDropInItem(undefined)}
useCSSTransforms
verticalCompact
onDragStart={() => {}}
onLayoutChange={onLayoutChange}
>
{activeLayout
Expand All @@ -279,7 +276,7 @@ const GridLayout = ({
isDragging={isDragging}
setIsDragging={setIsDragging}
widgetType={widgetType}
widgetConfig={{ ...layoutItem, colWidth: layoutWidth / columns[layoutVariant], config }}
widgetConfig={{ ...layoutItem, colWidth: effectiveWidth / columns[layoutVariant], config }}
setWidgetAttribute={setWidgetAttribute}
removeWidget={removeWidget}
analytics={analytics}
Expand All @@ -290,7 +287,7 @@ const GridLayout = ({
);
})
.filter((layoutItem) => layoutItem !== null)}
</ReactGridLayout>
</ReactGridLayout>}
</div>
);
};
Expand Down
4 changes: 2 additions & 2 deletions packages/module/src/WidgetLayout/GridTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
import { CompressIcon, EllipsisVIcon, ExpandIcon, GripVerticalIcon, LockIcon, MinusCircleIcon, UnlockIcon } from '@patternfly/react-icons';
import React, { useMemo, useState } from 'react';
import clsx from 'clsx';
import { Layout } from 'react-grid-layout';
import type { LayoutItem } from 'react-grid-layout';
import { ExtendedLayoutItem, WidgetConfiguration, AnalyticsTracker } from './types';

export type SetWidgetAttribute = <T extends string | number | boolean>(id: string, attributeName: keyof ExtendedLayoutItem, value: T) => void;
Expand All @@ -32,7 +32,7 @@ export type GridTileProps = React.PropsWithChildren<{
setIsDragging: (isDragging: boolean) => void;
isDragging: boolean;
setWidgetAttribute: SetWidgetAttribute;
widgetConfig: Layout & {
widgetConfig: LayoutItem & {
colWidth: number;
locked?: boolean;
config?: WidgetConfiguration;
Expand Down
Loading
Loading