diff --git a/package-lock.json b/package-lock.json index f4d4f8f1..fdfd7acf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "packages/*" ], "dependencies": { + "@patternfly/react-drag-drop": "^6.3.0", "@patternfly/react-tokens": "^6.0.0", "sharp": "^0.33.5" }, @@ -1983,6 +1984,73 @@ "node": ">=10.0.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", @@ -4400,37 +4468,56 @@ "link": true }, "node_modules/@patternfly/react-core": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-6.2.2.tgz", - "integrity": "sha512-JUrZ57JQ4bkmed1kxaciXb0ZpIVYyCHc2HjtzoKQ5UNRlx204zR2isATSHjdw2GFcWvwpkC5/fU2BR+oT3opbg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-6.3.0.tgz", + "integrity": "sha512-TM+pLwLd5DzaDlOQhqeju9H9QUFQypQiNwXQLNIxOV5r3fmKh4NTp2Av/8WmFkpCj8mejDOfp4TNxoU1zdjCkQ==", "license": "MIT", "dependencies": { - "@patternfly/react-icons": "^6.2.2", - "@patternfly/react-styles": "^6.2.2", - "@patternfly/react-tokens": "^6.2.2", + "@patternfly/react-icons": "^6.3.0", + "@patternfly/react-styles": "^6.3.0", + "@patternfly/react-tokens": "^6.3.0", "focus-trap": "7.6.4", "react-dropzone": "^14.3.5", "tslib": "^2.8.1" }, "peerDependencies": { - "react": "^17 || ^18", - "react-dom": "^17 || ^18" + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + } + }, + "node_modules/@patternfly/react-drag-drop": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-drag-drop/-/react-drag-drop-6.3.0.tgz", + "integrity": "sha512-6MgH1ZoMmugw9ESWO8D2z2Xc9v9kQTCfoQJRifH3PoC7IW0047yw/6vHnLonLxfeBkx5QR/zVmYnKyWNd+Q5OQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@patternfly/react-core": "^6.3.0", + "@patternfly/react-icons": "^6.3.0", + "@patternfly/react-styles": "^6.3.0", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" } }, "node_modules/@patternfly/react-icons": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-6.2.2.tgz", - "integrity": "sha512-XkBwzuV/uiolX+T6QgB3RIqphM1m+vAZjAe3McYtyY22j1rsOdlWDE4RtRrJ1q7EoIZwyZHj0h8T9vMfUsLn4Q==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-6.3.0.tgz", + "integrity": "sha512-W39JyqKW1UL6/YGuinDnpjbhmmLAfuxVrgDcdFBaK4D7D1iqkkqrDMV8zIzmV/RkodJ79xRnucYhYb2RukG4RA==", "license": "MIT", "peerDependencies": { - "react": "^17 || ^18", - "react-dom": "^17 || ^18" + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" } }, "node_modules/@patternfly/react-styles": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-6.2.2.tgz", - "integrity": "sha512-rncRDq66H8VnLyb9DrHHlZtPddlpNL9+W0XuQC0L7F6p78hOwSZmoGTW2Vq8/wJplDj8h/61qRpfRF9VEYPW0g==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-6.3.0.tgz", + "integrity": "sha512-FvuyNsY2oN8f2dvCl4Hx8CxBWCIF3BC9JE3Ay1lCuVqY1WYkvW4AQn3/0WVRINCxB9FkQxVNkSjARdwHNCEulw==", "license": "MIT" }, "node_modules/@patternfly/react-table": { @@ -4452,9 +4539,9 @@ } }, "node_modules/@patternfly/react-tokens": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-6.2.2.tgz", - "integrity": "sha512-2GRWDPBTrcTlGNFc5NPJjrjEVU90RpgcGX/CIe2MplLgM32tpVIkeUtqIoJPLRk5GrbhyFuHJYRU+O93gU4o3Q==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-6.3.0.tgz", + "integrity": "sha512-yWStfkbxg4RWAExFKS/JRGScyadOy35yr4DFispNeHrkZWMp4pwKf0VdwlQZ7+ZtSgEWtzzy1KFxMLmWh3mEqA==", "license": "MIT" }, "node_modules/@pkgjs/parseargs": { @@ -20520,6 +20607,12 @@ "dev": true, "license": "MIT" }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", diff --git a/package.json b/package.json index fa70250a..567b22d5 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "whatwg-fetch": "^3.6.20" }, "dependencies": { + "@patternfly/react-drag-drop": "^6.3.0", "@patternfly/react-tokens": "^6.0.0", "sharp": "^0.33.5" } diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/ColumnManagement/ColumnManagement.md b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ColumnManagement/ColumnManagement.md new file mode 100644 index 00000000..40de02c8 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ColumnManagement/ColumnManagement.md @@ -0,0 +1,28 @@ +--- +# Sidenav top-level section +# should be the same for all markdown files +section: Component groups +subsection: Helpers +# Sidenav secondary level section +# should be the same for all markdown files +id: Column management +# Tab (react | react-demos | html | html-demos | design-guidelines | accessibility) +source: react +# If you use typescript, the name of the interface to display props for +# These are found through the sourceProps function provided in patternfly-docs.source.js +propComponents: ['ColumnManagement'] +sourceLink: https://github.com/patternfly/react-component-groups/blob/main/packages/module/patternfly-docs/content/extensions/component-groups/examples/ColumnManagement/ColumnManagement.md +--- + +import ColumnManagement from '@patternfly/react-component-groups/dist/dynamic/ColumnManagement'; +import { FunctionComponent, useState } from 'react'; + +The **column management** component can be used to implement customizable table columns. Columns can be configured to be enabled or disabled by default or be unhidable. + +## Examples + +### Basic column list + +The order of the columns can be changed by dragging and dropping the columns themselves. This list can be used within a page or within a modal. Always make sure to set `isShownByDefault` and `isShown` to the same boolean value in the initial state. + +```js file="./ColumnManagementExample.tsx" diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/ColumnManagement/ColumnManagementExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ColumnManagement/ColumnManagementExample.tsx new file mode 100644 index 00000000..16e02420 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ColumnManagement/ColumnManagementExample.tsx @@ -0,0 +1,57 @@ +import { FunctionComponent, useState } from 'react'; +import { Column, ColumnManagement } from '@patternfly/react-component-groups'; + +const DEFAULT_COLUMNS: Column[] = [ + { + title: 'ID', + key: 'id', + isShownByDefault: true, + isShown: true, + isUntoggleable: true + }, + { + title: 'Publish date', + key: 'publishDate', + isShownByDefault: true, + isShown: true + }, + { + title: 'Impact', + key: 'impact', + isShownByDefault: true, + isShown: true + }, + { + title: 'Score', + key: 'score', + isShownByDefault: false, + isShown: false + } +]; + +export const ColumnExample: FunctionComponent = () => { + const [ columns, setColumns ] = useState(DEFAULT_COLUMNS); + + return ( + { + const newColumns = [...columns]; + const changedColumn = newColumns.find(c => c.key === col.key); + if (changedColumn) { + changedColumn.isShown = col.isShown; + } + setColumns(newColumns); + }} + onSelectAll={(newColumns) => setColumns(newColumns)} + onSave={(newColumns) => { + setColumns(newColumns); + alert('Changes saved!'); + }} + onCancel={() => alert('Changes cancelled!')} + /> + ); +}; diff --git a/packages/module/src/ColumnManagement/ColumnManagement.test.tsx b/packages/module/src/ColumnManagement/ColumnManagement.test.tsx new file mode 100644 index 00000000..9cb4fa7c --- /dev/null +++ b/packages/module/src/ColumnManagement/ColumnManagement.test.tsx @@ -0,0 +1,91 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import ColumnManagement from './ColumnManagement'; + +jest.mock('@patternfly/react-drag-drop', () => { + const originalModule = jest.requireActual('@patternfly/react-drag-drop'); + return { + ...originalModule, + DragDropSort: ({ onDrop, items }) => { + const handleDrop = () => { + const reorderedItems = [ ...items ].reverse(); + onDrop({}, reorderedItems); + }; + return
{items.map(item => item.content)}
; + }, + }; +}); + +const mockColumns = [ + { key: 'name', title: 'Name', isShown: true, isShownByDefault: true }, + { key: 'status', title: 'Status', isShown: true, isShownByDefault: true }, + { key: 'version', title: 'Version', isShown: false, isShownByDefault: false }, +]; + +describe('Column', () => { + it('renders with initial columns', () => { + render(); + expect(screen.getByTestId('column-check-name')).toBeChecked(); + expect(screen.getByTestId('column-check-status')).toBeChecked(); + expect(screen.getByTestId('column-check-version')).not.toBeChecked(); + }); + + it('renders title and description', () => { + render(); + expect(screen.getByText('Test Title')).toBeInTheDocument(); + expect(screen.getByText('Test Description')).toBeInTheDocument(); + }); + + it('renders a cancel button', async () => { + const onCancel = jest.fn(); + render(); + const cancelButton = screen.getByText('Cancel'); + expect(cancelButton).toBeInTheDocument(); + await userEvent.click(cancelButton); + expect(onCancel).toHaveBeenCalled(); + }); + + it('toggles a column', async () => { + const onSelect = jest.fn(); + render(); + const nameCheckbox = screen.getByTestId('column-check-name'); + await userEvent.click(nameCheckbox); + expect(nameCheckbox).not.toBeChecked(); + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ key: 'name', isShown: false })); + }); + + it('selects all columns', async () => { + render(); + const menuToggle = screen.getByLabelText('Bulk select toggle'); + if (menuToggle) { + await userEvent.click(menuToggle); + } + const selectAllButton = screen.getByText('Select all (3)'); + await userEvent.click(selectAllButton); + expect(screen.getByTestId('column-check-name')).toBeChecked(); + expect(screen.getByTestId('column-check-status')).toBeChecked(); + expect(screen.getByTestId('column-check-version')).toBeChecked(); + }); + + it('selects no columns', async () => { + render(); + const menuToggle = screen.getByLabelText('Bulk select toggle'); + if (menuToggle) { + await userEvent.click(menuToggle); + } + const selectNoneButton = screen.getByText('Select none (0)'); + await userEvent.click(selectNoneButton); + expect(screen.getByTestId('column-check-name')).not.toBeChecked(); + expect(screen.getByTestId('column-check-status')).not.toBeChecked(); + expect(screen.getByTestId('column-check-version')).not.toBeChecked(); + }); + + it('saves changes', async () => { + const onSave = jest.fn(); + render(); + const saveButton = screen.getByText('Save'); + await userEvent.click(saveButton); + expect(onSave).toHaveBeenCalledWith(expect.any(Array)); + }); +}); diff --git a/packages/module/src/ColumnManagement/ColumnManagement.tsx b/packages/module/src/ColumnManagement/ColumnManagement.tsx new file mode 100644 index 00000000..13b79072 --- /dev/null +++ b/packages/module/src/ColumnManagement/ColumnManagement.tsx @@ -0,0 +1,164 @@ +import type { FunctionComponent } from 'react'; +import { useState, useEffect } from 'react'; +import { + DataList, + DataListItemRow, + DataListCheck, + DataListCell, + DataListItemCells, + Button, + ButtonVariant, + Title +} from '@patternfly/react-core'; +import { DragDropSort, Droppable } from '@patternfly/react-drag-drop'; +import BulkSelect, { BulkSelectValue } from '../BulkSelect'; + +export interface Column { + /** Internal identifier of a column by which table displayed columns are filtered. */ + key: string; + /** The actual display name of the column possibly with a tooltip or icon. */ + title: React.ReactNode; + /** If user changes checkboxes, the component will send back column array with this property altered. */ + isShown?: boolean; + /** Set to false if the column should be hidden initially */ + isShownByDefault: boolean; + /** The checkbox will be disabled, this is applicable to columns which should not be toggleable by user */ + isUntoggleable?: boolean; +} + +export interface ColumnProps { + /** Current column state */ + columns: Column[]; + /* Column description text */ + description?: string; + /* Column title text */ + title?: string; + /** Custom OUIA ID */ + ouiaId?: string | number; + /** Callback when a column is selected or deselected */ + onSelect?: (column: Column) => void; + /** Callback when all columns are selected or deselected */ + onSelectAll?: (columns: Column[]) => void; + /** Callback when the column order changes */ + onOrderChange?: (columns: Column[]) => void; + /** Callback to save the column state */ + onSave?: (columns: Column[]) => void; + /** Callback to close the modal */ + onCancel?: () => void; +} + +const ColumnManagement: FunctionComponent = ( + { columns, + description, + title, + ouiaId = 'Column', + onSelect, + onSelectAll, + onOrderChange, + onSave, + onCancel }: ColumnProps) => { + + const [ currentColumns, setCurrentColumns ] = useState( + () => columns.map(column => ({ ...column, isShown: column.isShown ?? column.isShownByDefault, id: column.key })) + ); + + useEffect(() => { + setCurrentColumns(columns.map(column => ({ ...column, isShown: column.isShown ?? column.isShownByDefault, id: column.key }))); + }, [ columns ]); + + const handleChange = index => { + const newColumns = [ ...currentColumns ]; + const changedColumn = { ...newColumns[index] }; + + changedColumn.isShown = !changedColumn.isShown; + newColumns[index] = changedColumn; + + setCurrentColumns(newColumns); + onSelect?.(changedColumn); + }; + + const onDrag = (_event, newOrder) => { + const newColumns = newOrder.map(item => currentColumns.find(c => c.key === item.id)); + setCurrentColumns(newColumns); + onOrderChange?.(newColumns); + }; + + const handleSave = () => { + onSave?.(currentColumns); + } + + const handleBulkSelect = (value: BulkSelectValue) => { + const allSelected = value === 'all' || value === 'page'; + handleSelectAll(allSelected); + }; + + const handleSelectAll = (select = true) => { + const newColumns = currentColumns.map(c => ({ ...c, isShown: c.isUntoggleable ? c.isShown : select })); + setCurrentColumns(newColumns); + onSelectAll?.(newColumns); + } + + return ( + <> + {title} + {description &&

{description}

} +
+ isShown).length} + totalCount={currentColumns.length} + onSelect={handleBulkSelect} + pageSelected={currentColumns.every((item) => item.isShown)} + pagePartiallySelected={ + currentColumns.some((item) => item.isShown) && !currentColumns.every((item) => item.isShown) + } + /> +
+ ({ id: column.key, content: + + handleChange(index)} + isDisabled={column.isUntoggleable} + aria-labelledby={`${ouiaId}-column-${index}-label`} + ouiaId={`${ouiaId}-column-${index}-checkbox`} + id={`${ouiaId}-column-${index}-checkbox`} + /> + + + + ]} + /> + + }))} + onDrop={onDrag} + overlayProps={{ isCompact: true }} + > + + // eslint-disable-next-line no-console + ({ id: column.key, content: column.title }) + )} + wrapper={} + /> +
+ + +
+ + ); +} + +export default ColumnManagement; diff --git a/packages/module/src/ColumnManagement/index.ts b/packages/module/src/ColumnManagement/index.ts new file mode 100644 index 00000000..283ecbe7 --- /dev/null +++ b/packages/module/src/ColumnManagement/index.ts @@ -0,0 +1,2 @@ +export { default } from './ColumnManagement'; +export * from './ColumnManagement'; diff --git a/packages/module/src/index.ts b/packages/module/src/index.ts index 640a3cee..54683748 100644 --- a/packages/module/src/index.ts +++ b/packages/module/src/index.ts @@ -72,6 +72,9 @@ export * from './ErrorBoundary'; export { default as ColumnManagementModal } from './ColumnManagementModal'; export * from './ColumnManagementModal'; +export { default as ColumnManagement } from './ColumnManagement'; +export * from './ColumnManagement'; + export { default as CloseButton } from './CloseButton'; export * from './CloseButton';