diff --git a/package-lock.json b/package-lock.json index a49d3324..5db571e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3740,10 +3740,9 @@ } }, "node_modules/@patternfly/react-icons": { - "version": "4.92.6", - "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.92.6.tgz", - "integrity": "sha512-UdMSDqJ7fCxi/E6vlsFHuDZ3L0+kqBZ4ujRi4mjokrsvzOR4WFdaMhC+7iRy4aPNjT0DpHVjVUUUoWwKID9VqA==", - "license": "MIT", + "version": "4.93.6", + "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.93.6.tgz", + "integrity": "sha512-ZrXegc/81oiuTIeWvoHb3nG/eZODbB4rYmekBEsrbiysyO7m/sUFoi/RLvgFINrRoh6YCJqL5fj06Jg6d7jX1g==", "peerDependencies": { "react": "^16.8 || ^17 || ^18", "react-dom": "^16.8 || ^17 || ^18" @@ -3863,7 +3862,6 @@ "version": "1.2.1", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=14" } @@ -4348,6 +4346,12 @@ "@types/node": "*" } }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "dev": true + }, "node_modules/@types/hoist-non-react-statics": { "version": "3.3.1", "dev": true, @@ -4531,6 +4535,27 @@ "@types/react": "^17" } }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "dev": true, + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dev": true, + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, "node_modules/@types/scheduler": { "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", @@ -22295,7 +22320,6 @@ "version": "6.6.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@remix-run/router": "1.2.1" }, @@ -22310,7 +22334,6 @@ "version": "6.6.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@remix-run/router": "1.2.1", "react-router": "6.6.1" @@ -29946,6 +29969,7 @@ "license": "MIT", "dependencies": { "@patternfly/react-core": "^4.250.1", + "@patternfly/react-icons": "^4.93.6", "react-jss": "^10.9.2" }, "devDependencies": { @@ -29958,10 +29982,13 @@ "@redhat-cloud-services/frontend-components-utilities": "^3.2.25", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", + "@types/react-router-dom": "^5.3.3", "classnames": "^2.2.5", "copyfiles": "^2.4.1", "react": "^17.0.0", "react-dom": "^17.0.0", + "react-router": "^6.3.0", + "react-router-dom": "^6.3.0", "rimraf": "^2.6.2", "typescript": "^4.9.5" }, @@ -32506,16 +32533,20 @@ "@patternfly/patternfly-a11y": "4.3.1", "@patternfly/react-code-editor": "^4.82.26", "@patternfly/react-core": "^4.250.1", + "@patternfly/react-icons": "^4.93.6", "@patternfly/react-table": "^4.111.4", "@reach/router": "1.3.4", "@redhat-cloud-services/frontend-components-utilities": "^3.2.25", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", + "@types/react-router-dom": "^5.3.3", "classnames": "^2.2.5", "copyfiles": "^2.4.1", "react": "^17.0.0", "react-dom": "^17.0.0", "react-jss": "^10.9.2", + "react-router": "^6.3.0", + "react-router-dom": "^6.3.0", "rimraf": "^2.6.2", "typescript": "^4.9.5" }, @@ -32546,9 +32577,9 @@ } }, "@patternfly/react-icons": { - "version": "4.92.6", - "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.92.6.tgz", - "integrity": "sha512-UdMSDqJ7fCxi/E6vlsFHuDZ3L0+kqBZ4ujRi4mjokrsvzOR4WFdaMhC+7iRy4aPNjT0DpHVjVUUUoWwKID9VqA==", + "version": "4.93.6", + "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.93.6.tgz", + "integrity": "sha512-ZrXegc/81oiuTIeWvoHb3nG/eZODbB4rYmekBEsrbiysyO7m/sUFoi/RLvgFINrRoh6YCJqL5fj06Jg6d7jX1g==", "requires": {} }, "@patternfly/react-styles": { @@ -32638,8 +32669,7 @@ }, "@remix-run/router": { "version": "1.2.1", - "dev": true, - "peer": true + "dev": true }, "@sentry/browser": { "version": "5.30.0", @@ -33012,6 +33042,12 @@ "@types/node": "*" } }, + "@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "dev": true + }, "@types/hoist-non-react-statics": { "version": "3.3.1", "dev": true, @@ -33168,6 +33204,27 @@ "@types/react": "^17" } }, + "@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "dev": true, + "requires": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dev": true, + "requires": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, "@types/scheduler": { "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", @@ -46019,7 +46076,6 @@ "react-router": { "version": "6.6.1", "dev": true, - "peer": true, "requires": { "@remix-run/router": "1.2.1" } @@ -46027,7 +46083,6 @@ "react-router-dom": { "version": "6.6.1", "dev": true, - "peer": true, "requires": { "@remix-run/router": "1.2.1", "react-router": "6.6.1" diff --git a/packages/module/package.json b/packages/module/package.json index df436a22..ed7cc22e 100644 --- a/packages/module/package.json +++ b/packages/module/package.json @@ -13,8 +13,8 @@ "clean": "rimraf dist", "docs:develop": "pf-docs-framework start", "docs:build": "pf-docs-framework build all --output public", - "docs:serve": "pf-docs-framework serve public --port 5000", - "docs:screenshots": "pf-docs-framework screenshots --urlPrefix http://localhost:5000", + "docs:serve": "pf-docs-framework serve public --port 5001", + "docs:screenshots": "pf-docs-framework screenshots --urlPrefix http://localhost:5001", "test:a11y": "patternfly-a11y --config patternfly-a11y.config", "serve:a11y": "serve coverage" }, @@ -31,6 +31,7 @@ }, "dependencies": { "@patternfly/react-core": "^4.250.1", + "@patternfly/react-icons": "^4.93.6", "react-jss": "^10.9.2" }, "peerDependencies": { @@ -47,10 +48,13 @@ "@redhat-cloud-services/frontend-components-utilities": "^3.2.25", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", + "@types/react-router-dom": "^5.3.3", "classnames": "^2.2.5", "copyfiles": "^2.4.1", "react": "^17.0.0", "react-dom": "^17.0.0", + "react-router": "^6.3.0", + "react-router-dom": "^6.3.0", "rimraf": "^2.6.2", "typescript": "^4.9.5" } diff --git a/packages/module/patternfly-a11y.config.js b/packages/module/patternfly-a11y.config.js index 6f274da3..9688ce62 100644 --- a/packages/module/patternfly-a11y.config.js +++ b/packages/module/patternfly-a11y.config.js @@ -14,7 +14,7 @@ const urls = Object.keys(fullscreenRoutes) .reduce((result, item) => (result.includes(item) ? result : [ ...result, item ]), []); module.exports = { - prefix: 'http://localhost:5000', + prefix: 'http://localhost:5001', waitFor, crawl: false, urls: [ ...urls ], diff --git a/packages/module/patternfly-docs/content/extensions/extended-components/examples/ActionButtonsExample.tsx b/packages/module/patternfly-docs/content/extensions/extended-components/examples/ActionButtonsExample.tsx new file mode 100644 index 00000000..99aff855 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/extended-components/examples/ActionButtonsExample.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { ActionButtons } from '@patternfly/react-component-groups'; + +export const BasicExample: React.FunctionComponent = () => ( + {console.log('Primary action clicked')}, + tooltip: 'Click me!', + }, + { + children: 'Secondary action', + // eslint-disable-next-line no-console + onClick: () => {console.log('Secondary action clicked')}, + variant: 'secondary', + }, + ]} + /> +); diff --git a/packages/module/patternfly-docs/content/extensions/extended-components/examples/ActionMenuExample.tsx b/packages/module/patternfly-docs/content/extensions/extended-components/examples/ActionMenuExample.tsx new file mode 100644 index 00000000..b2ca1e4c --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/extended-components/examples/ActionMenuExample.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { ActionMenu } from '@patternfly/react-component-groups'; + +export const BasicExample: React.FC = () => ( + console.log('Edit resource clicked'), + }, + }, + { + children: 'Delete resource', + itemID: 'action-menu-example-2', + cta: { + // eslint-disable-next-line no-console + callback: () => console.log('Delete resource clicked'), + }, + isDisabled: true, + }, + ]} + id='action-menu-example' + /> +); diff --git a/packages/module/patternfly-docs/content/extensions/extended-components/examples/ActionMenuGroupedExample.tsx b/packages/module/patternfly-docs/content/extensions/extended-components/examples/ActionMenuGroupedExample.tsx new file mode 100644 index 00000000..f806c489 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/extended-components/examples/ActionMenuGroupedExample.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { ActionMenu } from '@patternfly/react-component-groups'; + +export const BasicExample: React.FunctionComponent = () => ( + console.log('Edit resource clicked'), + }, + }, + { + children: 'Delete resource', + itemID: 'action-menu-grouped-group-1-example-2', + cta: { + // eslint-disable-next-line no-console + callback: () => console.log('Delete resource clicked'), + }, + isDisabled: true, + }, + ], + }, + { + groupId: 'group2', + label: 'Links', + groupActions: [ + { + children: 'GitHub', + itemID: 'action-menu-grouped-group-2-example-1', + cta: { + href: 'https://github.com/', + external: true, + }, + }, + { + children: 'Link', + itemID: 'action-menu-grouped-group-2-example-2', + cta: { + href: '/#', + }, + description: 'Description of link', + }, + ], + }, + ]} + displayLabelBeforeIcon + id='action-menu-grouped-example' + /> +); diff --git a/packages/module/patternfly-docs/content/extensions/extended-components/examples/BreadcrumbsExample.tsx b/packages/module/patternfly-docs/content/extensions/extended-components/examples/BreadcrumbsExample.tsx new file mode 100644 index 00000000..034ece4f --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/extended-components/examples/BreadcrumbsExample.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { BrowserRouter as Router } from 'react-router-dom'; +import { Breadcrumbs } from '@patternfly/react-component-groups'; + +export const BasicExample: React.FunctionComponent = () => ( + + + +); diff --git a/packages/module/patternfly-docs/content/extensions/extended-components/examples/DetailsPage.md b/packages/module/patternfly-docs/content/extensions/extended-components/examples/DetailsPage.md new file mode 100644 index 00000000..cacb85bb --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/extended-components/examples/DetailsPage.md @@ -0,0 +1,83 @@ +--- +# Sidenav top-level section +# should be the same for all markdown files +section: extensions +subsection: react-component-groups +# Sidenav secondary level section +# should be the same for all markdown files +id: DetailsPage +# 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: [ + 'DetailsPage', # No output --> see https://github.com/patternfly/patternfly-org/issues/3423 + 'DetailsPageHeader', + 'PageHeading', + # 'PageHeadingLabel', # No output --> is a type and not an interface + 'Breadcrumbs', + # 'Breadcrumb', # No output --> is a type and not an interface + 'ActionButtons', + 'ActionButton', # Incomplete output --> see https://github.com/patternfly/patternfly-org/issues/3423 + # 'ActionCTA', # No output --> is a type and not an interface + 'ActionMenu', + 'GroupedActionsProps', # Removing 'Props' breaks linking from ActionProps + 'ActionProps', # Removing 'Props' breaks output + 'HorizontalNavProps', # Removing 'Props' breaks linking to TabProps + # 'Tab', # No output --> is a type and not an interface +] +beta: true +--- + +import { ActionButtons } from '@patternfly/react-component-groups'; +import { ActionMenu } from '@patternfly/react-component-groups'; +import { Breadcrumbs } from '@patternfly/react-component-groups'; +import { BrowserRouter as Router } from 'react-router-dom'; +import { CheckCircleIcon } from '@patternfly/react-icons'; +import { DetailsPage } from'@patternfly/react-component-groups'; +import { DetailsPageHeader } from'@patternfly/react-component-groups'; +import { HorizontalNav } from '@patternfly/react-component-groups'; + +## Components Usage + +### DetailsPage Component + +```js file="./DetailsPageExample.tsx" + +``` + +### DetailsPageHeader Component + +```js file="./DetailsPageHeaderExample.tsx" + +``` + +### Breadcrumbs Component + +```js file="./BreadcrumbsExample.tsx" + +``` + +### ActionButtons Component + +```js file="./ActionButtonsExample.tsx" + +``` + +### ActionMenu Component + +```js file="./ActionMenuExample.tsx" + +``` + +### ActionMenu with groupedActions Component + +```js file="./ActionMenuGroupedExample.tsx" + +``` + +### HorizonalNav Component + +```js file="./HorizontalNavExample.tsx" + +``` diff --git a/packages/module/patternfly-docs/content/extensions/extended-components/examples/DetailsPageExample.tsx b/packages/module/patternfly-docs/content/extensions/extended-components/examples/DetailsPageExample.tsx new file mode 100644 index 00000000..c25d927c --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/extended-components/examples/DetailsPageExample.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { BrowserRouter as Router } from 'react-router-dom'; +import { DetailsPage } from '@patternfly/react-component-groups'; +import { CheckCircleIcon } from '@patternfly/react-icons'; + +export const BasicExample: React.FunctionComponent = () => ( + + , + isCompact: true, + }, + }} + actionButtons={[ + { + children: 'Primary action', + // eslint-disable-next-line no-console + onClick: () => console.log('Primary action clicked'), + tooltip: 'Click me!', + }, + ]} + actionMenu={{ + actions: [ + { + children: 'Edit resource', + itemID: 'details-page-action-menu-example-1', + cta: { + // eslint-disable-next-line no-console + callback: () => console.log('Edit resource clicked'), + }, + }, + { + children: 'Delete resource', + itemID: 'details-page-action-menu-example-2', + cta: { + // eslint-disable-next-line no-console + callback: () => console.log('Delete resource clicked'), + }, + isDisabled: true, + }, + ], + id: 'details-page-action-menu-example' + }} + tabs={[ + { eventKey: 'details', title: 'Details', children:
Details content
}, + { eventKey: 'other', title: 'Other', children:
Other content
} + ]} + /> +
+); diff --git a/packages/module/patternfly-docs/content/extensions/extended-components/examples/DetailsPageHeaderExample.tsx b/packages/module/patternfly-docs/content/extensions/extended-components/examples/DetailsPageHeaderExample.tsx new file mode 100644 index 00000000..c24682c8 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/extended-components/examples/DetailsPageHeaderExample.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { BrowserRouter as Router } from 'react-router-dom'; +import { DetailsPageHeader } from '@patternfly/react-component-groups'; +import { CheckCircleIcon } from '@patternfly/react-icons'; + +export const BasicExample: React.FunctionComponent = () => ( + + , + isCompact: true, + }, + }} + actionButtons={[ + { + children: 'Primary action', + // eslint-disable-next-line no-console + onClick: () => console.log('Primary action clicked'), + tooltip: 'Click me!', + }, + ]} + actionMenu={{ + actions: [ + { + children: 'Edit resource', + itemID: 'details-page-header-action-menu-example-1', + cta: { + // eslint-disable-next-line no-console + callback: () => console.log('Edit resource clicked'), + }, + }, + { + children: 'Delete resource', + itemID: 'details-page-header-action-menu-example-2', + cta: { + // eslint-disable-next-line no-console + callback: () => console.log('Delete resource clicked'), + }, + isDisabled: true, + }, + ], + id: 'details-page-header-action-menu-example', + }} + /> + +); diff --git a/packages/module/patternfly-docs/content/extensions/extended-components/examples/HorizontalNavExample.tsx b/packages/module/patternfly-docs/content/extensions/extended-components/examples/HorizontalNavExample.tsx new file mode 100644 index 00000000..01e32124 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/extended-components/examples/HorizontalNavExample.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { HorizontalNav } from '@patternfly/react-component-groups'; + +export const BasicExample: React.FunctionComponent = () => ( + Details children }, + { eventKey: 'other', title: 'Other', children:
Other content
} + ]} + /> +); diff --git a/packages/module/src/DetailsPage/DetailsPage.tsx b/packages/module/src/DetailsPage/DetailsPage.tsx new file mode 100644 index 00000000..f901c1d6 --- /dev/null +++ b/packages/module/src/DetailsPage/DetailsPage.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { DetailsPageHeader, DetailsPageHeaderProps } from '../DetailsPageHeader'; +import { HorizontalNav, HorizontalNavProps } from '../HorizontalNav'; + +export interface DetailsPageProps extends DetailsPageHeaderProps, HorizontalNavProps {}; + +export const DetailsPage: React.FunctionComponent = ({ + breadcrumbs, + actionButtons, + actionMenu, + pageHeading, + ariaLabel, + tabs, + location, + params, + navigate +}: DetailsPageProps ) => ( + <> + + + +); diff --git a/packages/module/src/DetailsPage/index.ts b/packages/module/src/DetailsPage/index.ts new file mode 100644 index 00000000..6dccefa0 --- /dev/null +++ b/packages/module/src/DetailsPage/index.ts @@ -0,0 +1 @@ +export * from './DetailsPage'; diff --git a/packages/module/src/DetailsPageHeader/DetailsPageHeader.test.tsx b/packages/module/src/DetailsPageHeader/DetailsPageHeader.test.tsx new file mode 100644 index 00000000..afb86d83 --- /dev/null +++ b/packages/module/src/DetailsPageHeader/DetailsPageHeader.test.tsx @@ -0,0 +1,90 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { DetailsPageHeader, DetailsPageHeaderProps } from './DetailsPageHeader'; + +const mockCallback = jest.fn(); + +const mockProps: DetailsPageHeaderProps = { + breadcrumbs: [ + { children: 'Resources', to: '/resources' }, + { children: 'Resource details', to: '/resources/example-resource' }, + ], + pageHeading: { + title: 'example-resource', + }, + actionButtons: [ + { + children: 'Primary action', + onClick: mockCallback, + }, + ], + actionMenu: { + actions: [ + { + children: 'Edit resource', + itemID: 'details-page-header-action-menu-example-1', + cta: { + // eslint-disable-next-line no-console + callback: () => console.log('Edit resource clicked'), + }, + }, + { + children: 'Delete resource', + itemID: 'details-page-header-action-menu-example-2', + cta: { + // eslint-disable-next-line no-console + callback: () => console.log('Delete resource clicked'), + }, + isDisabled: true, + }, + ], + isDisabled: false, + }, +}; + +const detailsPageHeaderJSX = (args: DetailsPageHeaderProps) => ( + + + } path="/resources/example-resource" /> + Resource list page} path="/resources" /> + + +); + +describe('DetailsPageHeader', () => { + test('DetailsPageHeader is rendered with breadcrumbs, heading, action buttons and action menu', () => { + render(detailsPageHeaderJSX(mockProps)); + + // Breadcrumbs + expect(screen.getByText('Resources')).toBeVisible(); + expect(screen.getByText('Resource details')).toBeVisible(); + // Page heading + expect(screen.getByText('example-resource')).toBeVisible(); + // Action buttons + expect(screen.getByText('Primary action')).toBeVisible(); + // Action menu + expect(screen.getByText('Actions')).toBeVisible(); + }); + test('Clicking on breadcrumb triggers specified path', () => { + render(detailsPageHeaderJSX(mockProps)); + + // Click Workspaces link + fireEvent.click(screen.getByTestId('breadcrumb-link-0')); + expect(screen.getByText('Resource list page')).toBeVisible(); + }); + test('Clicking on actions menu reveals menu options', () => { + render(detailsPageHeaderJSX(mockProps)); + + fireEvent.click(screen.getByText('Actions')); + expect(screen.getByText('Edit resource')).toBeVisible(); + expect(screen.getByText('Delete resource')).toBeVisible(); + }); + test('Action button triggers callback', () => { + render(detailsPageHeaderJSX(mockProps)); + + fireEvent.click(screen.getByText('Primary action')); + expect(mockCallback).toHaveBeenCalled(); + }); +}); diff --git a/packages/module/src/DetailsPageHeader/DetailsPageHeader.tsx b/packages/module/src/DetailsPageHeader/DetailsPageHeader.tsx new file mode 100644 index 00000000..97e99295 --- /dev/null +++ b/packages/module/src/DetailsPageHeader/DetailsPageHeader.tsx @@ -0,0 +1,151 @@ +import { + Label, + LabelProps, + Split, + SplitItem, + Text, + TextContent, + TextVariants, +} from '@patternfly/react-core'; +import React from 'react'; +import { createUseStyles } from 'react-jss' +import { + ActionButtonProps, + ActionButtons, + ActionMenu, + ActionMenuProps, + Breadcrumbs, + BreadcrumbProps +} from './utils'; + +type PageHeadingLabelProps = Omit< + LabelProps, + 'isEditable'|'editableProps'|'onEditComplete'|'onEditCancel'|'onClose'|'closeBtn'|'closeBtnAriaLabel'|'closeBtnProps'|'isOverflowLabel' +>; + +export interface PageHeading { + /** Title for page heading */ + title: string; + /** Optional icon for page heading (appears to the left of the page heading's title) */ + iconBeforeTitle?: React.ReactNode; + /** Optional icon for page heading (appears to the right of the page heading's title) */ + iconAfterTitle?: React.ReactNode; + /** Optional label for page heading */ + label?: PageHeadingLabelProps; +}; + +export interface DetailsPageHeaderProps { + /** Top content area of details page */ + pageHeading: PageHeading; + /** Navigational item that provides page context to help users navigate more efficiently and understand where they are in the application hierarchy */ + breadcrumbs?: BreadcrumbProps[]; + /** One or more action buttons that appear to the right of the title */ + actionButtons?: ActionButtonProps[]; + /** Menu that appears to the right of the title */ + actionMenu?: ActionMenuProps; +}; + +const useStyles = createUseStyles({ + detailsPageHeaderSplit: { + alignItems: 'center', + } +}); + +export const DetailsPageHeader: React.FunctionComponent = ({ + breadcrumbs, + actionButtons, + actionMenu, + pageHeading, +}: DetailsPageHeaderProps) => { + const classes = useStyles(); + return ( + <> + {/* Optional breadcrumbs */} + {breadcrumbs && ( +
+ +
+ )} + + + + {/* Optional icon for details page heading (before title) */} + {pageHeading?.iconBeforeTitle && ( + + {pageHeading.iconBeforeTitle} + + )} + {/* Page heading title */} + + + {pageHeading.title} + + + {/* Icon for details page heading (after title) */} + {pageHeading?.iconAfterTitle && ( + + {pageHeading.iconAfterTitle} + + )} + {/* Optional details page label */} + {pageHeading?.label && ( + + + + )} + + + + + + {/* Optional action buttons */} + {Array.isArray(actionButtons) && actionButtons.length > 0 && ( + + + + )} + {/* Optional action menu - ungrouped actions */} + {actionMenu?.actions && ( + + + + )} + {/* Optional action menu - Grouped actions */} + {actionMenu?.groupedActions && ( + + + + )} + + + + + ); +}; + +export default DetailsPageHeader; diff --git a/packages/module/src/DetailsPageHeader/index.ts b/packages/module/src/DetailsPageHeader/index.ts new file mode 100644 index 00000000..f01c9943 --- /dev/null +++ b/packages/module/src/DetailsPageHeader/index.ts @@ -0,0 +1,2 @@ +export * from './DetailsPageHeader'; +export * from './utils'; diff --git a/packages/module/src/DetailsPageHeader/utils/ActionButton.tsx b/packages/module/src/DetailsPageHeader/utils/ActionButton.tsx new file mode 100644 index 00000000..7de9020f --- /dev/null +++ b/packages/module/src/DetailsPageHeader/utils/ActionButton.tsx @@ -0,0 +1,36 @@ +import { Button, ButtonProps, Tooltip, TooltipProps } from '@patternfly/react-core'; +import React from 'react'; + +interface ActionButtonTooltipProps { + /** Content for the action button tooltip */ + tooltip?: React.ReactNode; + /** ID for the action button tooltip */ + tooltipId?: TooltipProps['id']; + /** Position of the action button tooltip */ + tooltipPosition?: TooltipProps['position']; +} + +export interface ActionButtonProps extends ButtonProps, ActionButtonTooltipProps {}; + +export const ActionButton: React.FunctionComponent = ({ + tooltip, + tooltipId, + tooltipPosition, + ...buttonProps +}: ActionButtonProps) => { + const tooltipRef = React.useRef(); + return ( + <> + + {tooltip ? : null} + + ); +}; + +export default ActionButton; diff --git a/packages/module/src/DetailsPageHeader/utils/ActionButtons.test.tsx b/packages/module/src/DetailsPageHeader/utils/ActionButtons.test.tsx new file mode 100644 index 00000000..18d836c1 --- /dev/null +++ b/packages/module/src/DetailsPageHeader/utils/ActionButtons.test.tsx @@ -0,0 +1,34 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { ActionButtons } from './ActionButtons'; + +const mockCallback = jest.fn(); + +const mockActionButtons = [ + { + children: 'Primary action', + onClick: mockCallback, + tooltip: 'Click me!', + }, + { + children: 'Secondary action', + onClick: jest.fn(), + isDisabled: true, + }, +]; + +describe('ActionButtons', () => { + test('Buttons are rendered', () => { + render(); + + expect(screen.getByText('Primary action')).toBeVisible(); + expect(screen.getByText('Secondary action')).toBeVisible(); + expect(screen.getByText('Secondary action').closest('button')).toHaveAttribute('aria-disabled'); + }); + test('Button clicks trigger callback', () => { + render(); + + fireEvent.click(screen.getByText('Primary action')); + expect(mockCallback).toHaveBeenCalled(); + }); +}); diff --git a/packages/module/src/DetailsPageHeader/utils/ActionButtons.tsx b/packages/module/src/DetailsPageHeader/utils/ActionButtons.tsx new file mode 100644 index 00000000..55d20153 --- /dev/null +++ b/packages/module/src/DetailsPageHeader/utils/ActionButtons.tsx @@ -0,0 +1,30 @@ +import { Flex, FlexItem } from '@patternfly/react-core'; +import React from 'react'; +import ActionButton, { ActionButtonProps } from './ActionButton'; + +export interface ActionButtonsProps { + /** Array of action buttons */ + actionButtons: ActionButtonProps[]; +}; + +export const ActionButtons: React.FunctionComponent = ({ + actionButtons +}: ActionButtonsProps) => ( + + {actionButtons.map((actionButton, i) => ( + + + {actionButton.children} + + + ))} + +); + +export default ActionButtons; diff --git a/packages/module/src/DetailsPageHeader/utils/ActionMenu.test.tsx b/packages/module/src/DetailsPageHeader/utils/ActionMenu.test.tsx new file mode 100644 index 00000000..4ea9bfd1 --- /dev/null +++ b/packages/module/src/DetailsPageHeader/utils/ActionMenu.test.tsx @@ -0,0 +1,106 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { ActionMenu } from './ActionMenu'; + +const mockCallback = jest.fn(); + +const mockActions = [ + { + children: 'Edit resource', + itemID: 'action-menu-example-1', + cta: { + callback: mockCallback, + }, + }, + { + children: 'Delete resource', + itemID: 'action-menu-example-2', + cta: { + callback: jest.fn(), + }, + isDisabled: true, + }, +]; + +const mockGroupedActions = [ + { + groupId: 'group1', + groupActions: [ + { + children: 'Edit resource', + itemID: 'action-menu-grouped-group-1-example-1', + cta: { + callback: jest.fn(), + }, + tooltip: 'Sample tooltip', + }, + { + children: 'Delete resource', + itemID: 'action-menu-grouped-group-1-example-2', + cta: { + callback: jest.fn(), + }, + isDisabled: true, + }, + ], + }, + { + groupId: 'group2', + label: 'Group2', + groupActions: [ + { + children: 'External Link', + itemID: 'action-menu-grouped-group-2-example-1', + cta: { + href: 'https://github.com/', + external: true, + }, + }, + { + itemID: 'action-menu-grouped-group-2-example-2', + label: 'Link', + cta: { + href: '/#', + }, + tooltip: 'Link', + }, + ], + }, +]; + +describe('ActionMenu', () => { + test('ActionMenu is rendered', () => { + render(); + + expect(screen.getByText('Actions')).toBeVisible(); + }); + test('ActionMenu dropdown is expanded', () => { + render(); + + fireEvent.click(screen.getByText('Test Actions')); + expect(screen.getByText('Edit resource')).toBeVisible(); + expect(screen.getByText('Delete resource')).toBeVisible(); + expect(screen.getByText('Delete resource').closest('a')).toHaveAttribute('aria-disabled'); + }); + test('ActionMenu is disabled', () => { + render(); + + expect(screen.getByText('Actions').closest('button')).toHaveAttribute('disabled'); + }); + test('Menu actions trigger callback', () => { + render(); + + fireEvent.click(screen.getByText('Actions')); + expect(screen.getByText('Edit resource')).toBeVisible(); + fireEvent.click(screen.getByText('Edit resource')); + expect(mockCallback).toHaveBeenCalled(); + }); + test('Menu actions are rendered in groups', () => { + render(); + + fireEvent.click(screen.getByText('Actions')); + expect(screen.getByText('Edit resource')).toBeVisible(); + expect(screen.getByText('Group2')).toBeVisible(); + expect(screen.getByText('External Link')).toBeVisible(); + }); +}); diff --git a/packages/module/src/DetailsPageHeader/utils/ActionMenu.tsx b/packages/module/src/DetailsPageHeader/utils/ActionMenu.tsx new file mode 100644 index 00000000..97225323 --- /dev/null +++ b/packages/module/src/DetailsPageHeader/utils/ActionMenu.tsx @@ -0,0 +1,186 @@ +import { + Dropdown, + DropdownGroup, + DropdownGroupProps, + DropdownItem, + DropdownItemProps, + DropdownPosition, + DropdownToggle, + KebabToggle, +} from '@patternfly/react-core'; +import { ExternalLinkAltIcon } from '@patternfly/react-icons'; +import React from 'react'; +import { createUseStyles } from 'react-jss' + +// Duplicated from @openshift/dynamic-plugin-sdk +type Never = { + [K in keyof T]?: never; +}; + +// Duplicated from @openshift/dynamic-plugin-sdk +type EitherNotBoth = (TypeA & Never) | (TypeB & Never); + +export type ActionCTA = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + | { callback: (event: React.MouseEvent | React.KeyboardEvent | MouseEvent) => void } + | { href: string; external?: boolean }; + +export interface ActionProps extends Omit { + /** Executable callback or href. External links should automatically provide an external link icon on action. */ + cta: ActionCTA; + /** Optional tooltip for this action. */ + tooltip?: string; + /** Optional icon for this action. */ + icon?: React.ReactNode; +}; + +export interface GroupedActionsProps extends Omit { + /** A unique identifier for this group. */ + groupId: string; + /** Actions under this group. */ + groupActions: ActionProps[]; +} + +export enum ActionMenuVariant { + KEBAB = 'plain', + DROPDOWN = 'default', +} + +export interface ActionMenuOptions { + /** Optional flag to indicate whether action menu should be disabled */ + isDisabled?: boolean; + /** Optional variant for action menu: DROPDOWN vs KEBAB (defaults to dropdown) */ + variant?: ActionMenuVariant; + /** Optional label for action menu (defaults to 'Actions') */ + label?: string; + /** Optional position (left/right) at which the action menu appears (defaults to right) */ + position?: DropdownPosition; + /** Optional flag to indicate whether labels should appear to the left of icons for the action menu items (icon appears after the label by default) */ + displayLabelBeforeIcon?: boolean; + /** Optional id for action menu (defaults to 'actions') */ + id?: string; +}; + +export type ActionMenuProps = EitherNotBoth< + { actions: ActionProps[] }, + { groupedActions: GroupedActionsProps[] } +> & + ActionMenuOptions; + +const useStyles = createUseStyles({ + menuItemWithLabelBeforeIcon: { + '&.pf-c-dropdown__menu-item': { + flexDirection: 'row-reverse', + justifyContent: 'left' + }, + '.pf-c-dropdown__menu-item-icon': { + marginLeft: 'var(--pf-c-dropdown__menu-item-icon--MarginRight)' + }, + '.pf-c-dropdown__menu-item-main': { + flexDirection: 'row-reverse', + } + } +}); + +export const ActionMenu: React.FunctionComponent = ({ + actions = [], + groupedActions = [], + isDisabled, + variant = ActionMenuVariant.DROPDOWN, + label = 'Actions', + position = DropdownPosition.right, + displayLabelBeforeIcon, + id = 'actions', +}: ActionMenuProps) => { + const [ isOpen, setIsOpen ] = React.useState(false); + + const classes = useStyles(); + + const isGrouped = groupedActions.length > 0; + + /** Returns a DropDownItem element corresponding to an action */ + const dropdownActionItem = React.useCallback( + (action: ActionProps) => { + const externalIcon = + 'href' in action.cta && + 'external' in action.cta && + action.cta.href && + action.cta.external ? ( + + ) : null; + const icon = action.icon ?? externalIcon; + const href = 'href' in action.cta ? action.cta.href : undefined; + const onClick = + 'callback' in action.cta && action.cta.callback ? action.cta.callback : undefined; + return ( + + {action.children} + + ); + }, + [ classes.menuItemWithLabelBeforeIcon, displayLabelBeforeIcon ], + ); + + const dropdownActionItems = React.useMemo(() => { + let ddActionItems: JSX.Element[] = []; + if (actions.length > 0) { + ddActionItems = actions.map((action: ActionProps) => dropdownActionItem(action as ActionProps)); + } + if (isGrouped) { + ddActionItems = groupedActions.map((action: GroupedActionsProps) => ( + + {action.groupActions.map((groupAction: ActionProps) => dropdownActionItem(groupAction))} + + )) + } + return ddActionItems; + }, [ actions, dropdownActionItem, isGrouped, groupedActions ]); + + const onToggle = (open: boolean) => { + setIsOpen(open); + }; + + const onFocus = () => { + const element = document.getElementById(`toggle-menu-${id}`); + if (element) { + element.focus(); + } + }; + + const onSelect = () => { + setIsOpen(false); + onFocus(); + }; + + // Build dropdown + return ( + + {label} + + ) : ( + + ) + } + isOpen={isOpen} + dropdownItems={dropdownActionItems} + isGrouped={isGrouped} + /> + ); +}; + +export default ActionMenu; diff --git a/packages/module/src/DetailsPageHeader/utils/Breadcrumbs.test.tsx b/packages/module/src/DetailsPageHeader/utils/Breadcrumbs.test.tsx new file mode 100644 index 00000000..3073ec7c --- /dev/null +++ b/packages/module/src/DetailsPageHeader/utils/Breadcrumbs.test.tsx @@ -0,0 +1,37 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { Breadcrumbs, BreadcrumbsProps } from './Breadcrumbs'; + +const mockProps: BreadcrumbsProps = { + breadcrumbs: [ + { children: 'Resources', to: '/resources' }, + { children: 'Resource details', to: '/resources/example-resource' }, + ], +}; + +const breadcrumbsJSX = (args: BreadcrumbsProps) => ( + + + } path="/resources/example-resource" /> + Resource list page} path="/resources" /> + + +); + +describe('Breadcrumbs', () => { + test('Breadcrumbs are rendered', () => { + render(breadcrumbsJSX(mockProps)); + + expect(screen.getByText('Resources')).toBeVisible(); + expect(screen.getByText('Resource details')).toBeVisible(); + }); + test('Clicking on breadcrumb triggers specified path', () => { + render(breadcrumbsJSX(mockProps)); + + // Click Resources link + fireEvent.click(screen.getByTestId('breadcrumb-link-0')); + expect(screen.getByText('Resource list page')).toBeVisible(); + }); +}); diff --git a/packages/module/src/DetailsPageHeader/utils/Breadcrumbs.tsx b/packages/module/src/DetailsPageHeader/utils/Breadcrumbs.tsx new file mode 100644 index 00000000..3bea5b09 --- /dev/null +++ b/packages/module/src/DetailsPageHeader/utils/Breadcrumbs.tsx @@ -0,0 +1,39 @@ +import { Breadcrumb, BreadcrumbItem, BreadcrumbItemProps } from '@patternfly/react-core'; +import React from 'react'; +import { Link } from 'react-router-dom'; + +export type BreadcrumbProps = Omit; + +export interface BreadcrumbsProps { + /** Array of breadcrumbs */ + breadcrumbs: BreadcrumbProps[]; +}; + +export const Breadcrumbs: React.FunctionComponent = ({ + breadcrumbs +}: BreadcrumbsProps) => ( + + {breadcrumbs.map((crumb, i, { length }) => { + const id = `breadcrumb-link-${i}`; + const isLast = i === length - 1; + + return ( + } + render={crumb?.render} + key={id} + > + {crumb?.children} + + ); + })} + +); + + +export default Breadcrumbs; diff --git a/packages/module/src/DetailsPageHeader/utils/index.ts b/packages/module/src/DetailsPageHeader/utils/index.ts new file mode 100644 index 00000000..3cd8921e --- /dev/null +++ b/packages/module/src/DetailsPageHeader/utils/index.ts @@ -0,0 +1,4 @@ +export * from './ActionButton'; +export * from './ActionButtons'; +export * from './ActionMenu'; +export * from './Breadcrumbs'; diff --git a/packages/module/src/HorizontalNav/HorizontalNav.test.tsx b/packages/module/src/HorizontalNav/HorizontalNav.test.tsx new file mode 100644 index 00000000..7273bea6 --- /dev/null +++ b/packages/module/src/HorizontalNav/HorizontalNav.test.tsx @@ -0,0 +1,35 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { HorizontalNav, TabProps } from './HorizontalNav'; + +// Sample content components for tabs +const UsersTabContent: React.FunctionComponent = () =>
Users Tab Content
; +const DatabaseTabContent: React.FunctionComponent = () =>
Database Tab Content
; + +const mockTabs: TabProps[] = [ + { eventKey: 'Users', title: 'Users', children: }, + { eventKey: 'Database', title: 'Database', children: }, +]; + +describe('HorizontalNav', () => { + afterAll(() => { + jest.restoreAllMocks(); + }); + describe('Standalone horizontal tabs without routing', () => { + test('loads and displays tabs with default selection', () => { + render(); + + expect(screen.getByRole('tab', { selected: true })).toHaveTextContent('Users'); + expect(screen.getByText('Users Tab Content')).toBeVisible(); + }); + + test('switches tab on click', () => { + render(); + + fireEvent.click(screen.getByText('Database')); + + expect(screen.getByRole('tab', { selected: true })).toHaveTextContent('Database'); + expect(screen.getByText('Database Tab Content')).toBeVisible(); + }); + }); +}); diff --git a/packages/module/src/HorizontalNav/HorizontalNav.tsx b/packages/module/src/HorizontalNav/HorizontalNav.tsx new file mode 100644 index 00000000..44fe1139 --- /dev/null +++ b/packages/module/src/HorizontalNav/HorizontalNav.tsx @@ -0,0 +1,78 @@ +import { Tabs, Tab, TabProps as PfTabProps, TabTitleText } from '@patternfly/react-core'; +import React from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import '@patternfly/react-styles/css/utilities/Spacing/spacing.css'; + +export type TabProps = Omit< + PfTabProps, + 'tabContentId'|'tabContentRef'|'isHidden'|'innerRef'|'closeButtonAriaLabel'|'isCloseDisabled'|'actions' +> + +export interface HorizontalNavProps { + /** aria-label for all tabs */ + ariaLabel?: string; + /** Properties for tabs */ + tabs: TabProps[]; + /** URL parameters */ + params?: Record; + /** Navigate function */ + navigate?: ReturnType; + /** Current location */ + location?: ReturnType; +}; + +export const HorizontalNav: React.FunctionComponent = ({ + ariaLabel, + tabs, + params, + navigate, + location, +}: HorizontalNavProps) => { + const defaultActiveTab = tabs && tabs[0] ? tabs[0].eventKey : 0; // Set first tab as the default active tab + + const activeTabFromUrlParam = params?.selectedTab; + const isValidTabFromUrl = + activeTabFromUrlParam && tabs?.some((tab) => tab.eventKey === activeTabFromUrlParam); + const activeTab = isValidTabFromUrl ? activeTabFromUrlParam : defaultActiveTab; + + const [ activeTabKey, setActiveTabKey ] = React.useState(activeTab); + + return ( + { + setActiveTabKey(eventKey); + if (location?.pathname && navigate) { + const currentPathName = location.pathname; + if (params?.selectedTab) { + navigate(currentPathName.replace(params.selectedTab, eventKey as string), { + replace: true, + }); + } else { + navigate(`${currentPathName}/${eventKey as string}`); + } + } + }} + aria-label={ariaLabel} + role="region" + > + {tabs.map((tab: TabProps) => ( + {tab.title}} + eventKey={tab.eventKey} + isDisabled={tab?.isDisabled} + isAriaDisabled={tab?.isAriaDisabled} + inoperableEvents={tab?.inoperableEvents} + tooltip={tab?.tooltip} + ouiaId={tab?.ouiaId} + key={tab.eventKey} + > +
{tab.children}
+
+ ))} +
+ ); +}; diff --git a/packages/module/src/HorizontalNav/index.ts b/packages/module/src/HorizontalNav/index.ts new file mode 100644 index 00000000..9ba382c3 --- /dev/null +++ b/packages/module/src/HorizontalNav/index.ts @@ -0,0 +1 @@ +export * from './HorizontalNav'; diff --git a/packages/module/src/index.ts b/packages/module/src/index.ts index a7a9138d..58d3e38f 100644 --- a/packages/module/src/index.ts +++ b/packages/module/src/index.ts @@ -1,2 +1,5 @@ +export * from './DetailsPage'; +export * from './DetailsPageHeader'; export * from './ErrorBoundary'; export * from './ErrorState'; +export * from './HorizontalNav';