diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 46e956c..d4ff2db 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -28,15 +28,22 @@ 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 @@ -44,15 +51,13 @@ jobs: 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 diff --git a/packages/module/package.json b/packages/module/package.json index 185e56c..4c06102 100644 --- a/packages/module/package.json +++ b/packages/module/package.json @@ -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", @@ -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", diff --git a/packages/module/patternfly-docs/content/examples/basic.md b/packages/module/patternfly-docs/content/examples/basic.md index 75eaf5b..cc733f6 100644 --- a/packages/module/patternfly-docs/content/examples/basic.md +++ b/packages/module/patternfly-docs/content/examples/basic.md @@ -63,13 +63,30 @@ const widgetMapping: WidgetMapping = { defaults: { w: 2, h: 3, maxH: 6, minH: 2 }, config: { title: 'My Widget', - icon: + icon: , + headerLink: { + title: 'View details', + href: '/details' + } }, renderWidget: (id) => } }; ``` +### 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: @@ -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 | diff --git a/packages/module/src/WidgetLayout/GridLayout.tsx b/packages/module/src/WidgetLayout/GridLayout.tsx index 23c6c8f..e30aae2 100644 --- a/packages/module/src/WidgetLayout/GridLayout.tsx +++ b/packages/module/src/WidgetLayout/GridLayout.tsx @@ -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 { @@ -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 }) @@ -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) => ( -
+const getResizeHandle = (resizeHandleAxis: string, ref: React.Ref) => ( +
} className={`react-resizable-handle react-resizable-handle-${resizeHandleAxis}`}> Resize handle
); @@ -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 = ({ @@ -100,14 +102,15 @@ const GridLayout = ({ showEmptyState = true, onDrawerExpandChange, onActiveWidgetsChange, + droppingWidgetType, }: GridLayoutProps) => { const [isDragging, setIsDragging] = useState(false); const [isInitialRender, setIsInitialRender] = useState(true); const [layoutVariant, setLayoutVariant] = useState('xl'); - const [layoutWidth, setLayoutWidth] = useState(1200); - const layoutRef = useRef(null); + + // Use v2 hook for container width measurement + const { width: layoutWidth, containerRef, mounted } = useContainerWidth(); - const [currentDropInItem, setCurrentDropInItem] = useState(); const [internalTemplate, setInternalTemplate] = useState(template); // Sync external template changes to internal state @@ -115,20 +118,19 @@ const GridLayout = ({ 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( @@ -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]) => { @@ -193,21 +196,22 @@ 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); @@ -215,54 +219,47 @@ const GridLayout = ({ 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 ( -
- {activeLayout.length === 0 && !currentDropInItem && showEmptyState && ( +
+ {activeLayout.length === 0 && !droppingWidgetType && showEmptyState && ( emptyStateComponent || )} - setCurrentDropInItem(undefined)} - useCSSTransforms - verticalCompact + onDragStart={() => {}} onLayoutChange={onLayoutChange} > {activeLayout @@ -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} @@ -290,7 +287,7 @@ const GridLayout = ({ ); }) .filter((layoutItem) => layoutItem !== null)} - + }
); }; diff --git a/packages/module/src/WidgetLayout/GridTile.tsx b/packages/module/src/WidgetLayout/GridTile.tsx index 3e2d3b2..7ad7d09 100644 --- a/packages/module/src/WidgetLayout/GridTile.tsx +++ b/packages/module/src/WidgetLayout/GridTile.tsx @@ -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 = (id: string, attributeName: keyof ExtendedLayoutItem, value: T) => void; @@ -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; diff --git a/packages/module/src/WidgetLayout/WidgetDrawer.tsx b/packages/module/src/WidgetLayout/WidgetDrawer.tsx index c97eb03..22c0b3f 100644 --- a/packages/module/src/WidgetLayout/WidgetDrawer.tsx +++ b/packages/module/src/WidgetLayout/WidgetDrawer.tsx @@ -28,6 +28,10 @@ export type WidgetDrawerProps = React.PropsWithChildren<{ onOpenChange?: (isOpen: boolean) => void; /** Custom instruction text */ instructionText?: string; + /** Callback when widget drag starts from drawer */ + onWidgetDragStart?: (widgetType: string) => void; + /** Callback when widget drag ends */ + onWidgetDragEnd?: () => void; }>; const WidgetWrapper = ({ widgetType, config, onDragStart, onDragEnd }: { @@ -91,6 +95,8 @@ const WidgetDrawer = ({ isOpen: controlledIsOpen, onOpenChange, instructionText, + onWidgetDragStart, + onWidgetDragEnd, }: WidgetDrawerProps) => { const [internalIsOpen, setInternalIsOpen] = useState(false); @@ -142,8 +148,8 @@ const WidgetDrawer = ({ {}} - onDragEnd={() => {}} + onDragStart={(widgetType) => onWidgetDragStart?.(widgetType)} + onDragEnd={() => onWidgetDragEnd?.()} /> ))} diff --git a/packages/module/src/WidgetLayout/WidgetLayout.tsx b/packages/module/src/WidgetLayout/WidgetLayout.tsx index b2069c3..b7cfbf9 100644 --- a/packages/module/src/WidgetLayout/WidgetLayout.tsx +++ b/packages/module/src/WidgetLayout/WidgetLayout.tsx @@ -44,6 +44,7 @@ const WidgetLayout = ({ const [template, setTemplate] = useState(initialTemplate); const [drawerOpen, setDrawerOpen] = useState(initialDrawerOpen); const [currentlyUsedWidgets, setCurrentlyUsedWidgets] = useState([]); + const [droppingWidgetType, setDroppingWidgetType] = useState(); const handleTemplateChange = (newTemplate: ExtendedTemplateConfig) => { setTemplate(newTemplate); @@ -70,6 +71,7 @@ const WidgetLayout = ({ showEmptyState={showEmptyState} onDrawerExpandChange={handleDrawerExpandChange} onActiveWidgetsChange={handleActiveWidgetsChange} + droppingWidgetType={droppingWidgetType} /> ); @@ -84,6 +86,8 @@ const WidgetLayout = ({ isOpen={drawerOpen} onOpenChange={setDrawerOpen} instructionText={drawerInstructionText} + onWidgetDragStart={setDroppingWidgetType} + onWidgetDragEnd={() => setDroppingWidgetType(undefined)} > {gridLayout} diff --git a/packages/module/src/WidgetLayout/types.ts b/packages/module/src/WidgetLayout/types.ts index 741741b..9d85a5b 100644 --- a/packages/module/src/WidgetLayout/types.ts +++ b/packages/module/src/WidgetLayout/types.ts @@ -1,10 +1,10 @@ -import { Layout } from 'react-grid-layout'; +import type { LayoutItem } from 'react-grid-layout'; export const widgetIdSeparator = '#'; 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[]; diff --git a/packages/module/tsconfig.json b/packages/module/tsconfig.json index 169eb13..b902d16 100644 --- a/packages/module/tsconfig.json +++ b/packages/module/tsconfig.json @@ -45,7 +45,7 @@ "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ /* Module Resolution Options */ - "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, + "moduleResolution": "bundler" /* Updated to 'bundler' for react-grid-layout v2 ES module subpath exports */, // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ diff --git a/yarn.lock b/yarn.lock index 107cf51..555f04e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3258,10 +3258,10 @@ __metadata: languageName: node linkType: hard -"@patternfly/patternfly@npm:^6.3.1": - version: 6.3.1 - resolution: "@patternfly/patternfly@npm:6.3.1" - checksum: 10c0/57f77e3f3a5280961c2528a36e16d85483a866cd015b4ce32e856f7d7eef96c3380dbb203f909975a51c1bfac316fc5d51d237715d6d578c9237dd07667fe780 +"@patternfly/patternfly@npm:^6.5.0-prerelease.33": + version: 6.5.0-prerelease.37 + resolution: "@patternfly/patternfly@npm:6.5.0-prerelease.37" + checksum: 10c0/73907ee81c42ba5257636d6a29034b68143305e8fba02cccb3308ed767d2c510ffd5c0e78082cc2f29cf29c0bda37ea80befd614cd62a2d08abd7a1d11ce0ddd languageName: node linkType: hard @@ -3394,17 +3394,16 @@ __metadata: "@babel/plugin-proposal-private-methods": "npm:^7.18.6" "@babel/plugin-proposal-private-property-in-object": "npm:^7.21.11" "@patternfly/documentation-framework": "npm:^6.24.2" - "@patternfly/patternfly": "npm:^6.3.1" + "@patternfly/patternfly": "npm:^6.5.0-prerelease.33" "@patternfly/patternfly-a11y": "npm:^5.1.0" "@patternfly/react-code-editor": "npm:^6.3.1" "@patternfly/react-core": "npm:^6.3.1" "@patternfly/react-icons": "npm:^6.3.1" "@patternfly/react-table": "npm:^6.3.1" - "@types/react-grid-layout": "npm:^1.3.5" clsx: "npm:^2.1.0" monaco-editor: "npm:^0.53.0" nodemon: "npm:^3.0.0" - react-grid-layout: "npm:^1.5.1" + react-grid-layout: "npm:^2.2.2" react-monaco-editor: "npm:^0.59.0" rimraf: "npm:^6.0.1" peerDependencies: @@ -3920,24 +3919,6 @@ __metadata: languageName: node linkType: hard -"@types/react-grid-layout@npm:^1.3.5": - version: 1.3.5 - resolution: "@types/react-grid-layout@npm:1.3.5" - dependencies: - "@types/react": "npm:*" - checksum: 10c0/abd2a1dda9625c753ff2571a10b69740b2fb9ed1d3141755d54d5814cc12a9701c7c5cd78e8797e945486b441303b82543be71043a32d6a988b57a14237f93c6 - languageName: node - linkType: hard - -"@types/react@npm:*": - version: 19.2.2 - resolution: "@types/react@npm:19.2.2" - dependencies: - csstype: "npm:^3.0.2" - checksum: 10c0/f830b1204aca4634ce3c6cb3477b5d3d066b80a4dd832a4ee0069acb504b6debd2416548a43a11c1407c12bc60e2dc6cf362934a18fe75fe06a69c0a98cba8ab - languageName: node - linkType: hard - "@types/react@npm:^19.2.0": version: 19.2.1 resolution: "@types/react@npm:19.2.1" @@ -13929,9 +13910,9 @@ __metadata: languageName: node linkType: hard -"react-grid-layout@npm:^1.5.1": - version: 1.5.2 - resolution: "react-grid-layout@npm:1.5.2" +"react-grid-layout@npm:^2.2.2": + version: 2.2.2 + resolution: "react-grid-layout@npm:2.2.2" dependencies: clsx: "npm:^2.1.1" fast-equals: "npm:^4.0.3" @@ -13942,7 +13923,7 @@ __metadata: peerDependencies: react: ">= 16.3.0" react-dom: ">= 16.3.0" - checksum: 10c0/b6605d1435fe116c3720d168100a5a08da924c6905686fe8a486c33b82abbde8ccacbb59e5c6243fa52f5e808ad393a7bdf0c09a3446ebf76efe43f29d9f13ee + checksum: 10c0/4b38496948136b3d63689c141bcdfaece5e6ce9d228bffad89f751d3b510f6ab1589609982c6fa340a805bda7e0533b212bdff0185b8a52293e031a2e57ebdb9 languageName: node linkType: hard