From 114229de548f8ed60ebe5fc5329c2b5450901631 Mon Sep 17 00:00:00 2001 From: Vidya Nambiar Date: Mon, 22 Aug 2022 12:19:08 -0400 Subject: [PATCH 01/17] horizontal tabs --- .../horizontal-nav/HorizontalNav.stories.tsx | 60 +++++++++++++++++++ .../horizontal-nav/HorizontalNav.test.tsx | 32 ++++++++++ .../horizontal-nav/HorizontalNav.tsx | 52 ++++++++++++++++ .../src/components/horizontal-nav/index.ts | 1 + 4 files changed, 145 insertions(+) create mode 100644 packages/lib-utils/src/components/horizontal-nav/HorizontalNav.stories.tsx create mode 100644 packages/lib-utils/src/components/horizontal-nav/HorizontalNav.test.tsx create mode 100644 packages/lib-utils/src/components/horizontal-nav/HorizontalNav.tsx create mode 100644 packages/lib-utils/src/components/horizontal-nav/index.ts diff --git a/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.stories.tsx b/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.stories.tsx new file mode 100644 index 00000000..e9a55a54 --- /dev/null +++ b/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.stories.tsx @@ -0,0 +1,60 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import { Card, CardTitle, CardBody } from '@patternfly/react-core'; +import type { ComponentStory, ComponentMeta } from '@storybook/react'; +import React from 'react'; +import type { Tab } from './HorizontalNav'; +import HorizontalNav from './HorizontalNav'; + +const meta: ComponentMeta = { + title: 'HorizontalNav', + component: HorizontalNav, + argTypes: {}, +}; + +export default meta; + +const Template: ComponentStory = (args) => { + return ; +}; + +export const Primary = Template.bind({}); + +// Sample content components for tabs +const BaseContent: React.FC<{ content: string }> = ({ content }) => { + return ( + + {content} + Sample content + + ); +}; + +const UsersTabContent: React.FC = () => { + return ; +}; + +const ContainersTabContent: React.FC = () => { + return ; +}; + +const DatabaseTabContent: React.FC = () => { + return ; +}; + +// Define tabs +const tabs: Tab[] = [ + { key: 'Users', title: 'Users', content: , ariaLabel: 'Users' }, + { + key: 'Containers', + title: 'Containers', + content: , + ariaLabel: 'Containers', + }, + { key: 'Database', title: 'Database', content: , ariaLabel: 'Database' }, +]; +const ariaLabel = 'Sample tabs'; + +Primary.args = { + ariaLabel, + tabs, +}; diff --git a/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.test.tsx b/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.test.tsx new file mode 100644 index 00000000..a6e80935 --- /dev/null +++ b/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.test.tsx @@ -0,0 +1,32 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import '@testing-library/jest-dom'; +import type { Tab } from './HorizontalNav'; +import HorizontalNav from './HorizontalNav'; + +// Sample content components for tabs +const UsersTabContent: React.FC = () =>
Users Tab Content
; +const DatabaseTabContent: React.FC = () =>
Database Tab Content
; + +const mockTabs: Tab[] = [ + { key: 'Users', title: 'Users', content: , ariaLabel: 'Users' }, + { key: 'Database', title: 'Database', content: , ariaLabel: 'Database' }, +]; + +describe('HorizontalNav', () => { + 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/lib-utils/src/components/horizontal-nav/HorizontalNav.tsx b/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.tsx new file mode 100644 index 00000000..9c60dec6 --- /dev/null +++ b/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.tsx @@ -0,0 +1,52 @@ +import { Tabs, Tab, TabTitleText } from '@patternfly/react-core'; +import React from 'react'; + +export type Tab = { + /** Key for individual tab */ + key: string | number; + /** Title for individual tab */ + title: string; + /** Content for individual tab (provided as a React component) */ + content: React.ReactElement; + /** aria-label for individual tab */ + ariaLabel: string; +}; + +export type HorizontalNavProps = { + /** aria-label for all tabs */ + ariaLabel?: string; + /** Properties for tabs */ + tabs: Tab[]; +}; + +const HorizontalNav: React.FC = ({ ariaLabel, tabs }) => { + const activeTab = tabs && tabs[0] ? tabs[0].key : 0; + const [activeTabKey, setActiveTabKey] = React.useState(activeTab); + + return ( + { + setActiveTabKey(tabIndex); + }} + aria-label={ariaLabel} + role="region" + > + {tabs.map((tab: Tab) => { + return ( + {tab.title}} + aria-label={tab.ariaLabel} + > + {tab.content} + + ); + })} + + ); +}; + +export default HorizontalNav; diff --git a/packages/lib-utils/src/components/horizontal-nav/index.ts b/packages/lib-utils/src/components/horizontal-nav/index.ts new file mode 100644 index 00000000..87b71a87 --- /dev/null +++ b/packages/lib-utils/src/components/horizontal-nav/index.ts @@ -0,0 +1 @@ +export { default as HorizontalNav, Tab, HorizontalNavProps } from './HorizontalNav'; From 70011049e27de072f527d84255978edd84c4eb75 Mon Sep 17 00:00:00 2001 From: Vidya Nambiar Date: Wed, 24 Aug 2022 10:39:23 -0400 Subject: [PATCH 02/17] Horizontal navigation with routing --- .../horizontal-nav/HorizontalNav.stories.tsx | 14 ++++- .../horizontal-nav/HorizontalNav.test.tsx | 60 +++++++++++++++---- .../horizontal-nav/HorizontalNav.tsx | 57 +++++++++++++++--- .../src/components/horizontal-nav/index.ts | 2 +- 4 files changed, 111 insertions(+), 22 deletions(-) diff --git a/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.stories.tsx b/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.stories.tsx index e9a55a54..0bf7adf3 100644 --- a/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.stories.tsx +++ b/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.stories.tsx @@ -2,22 +2,30 @@ import { Card, CardTitle, CardBody } from '@patternfly/react-core'; import type { ComponentStory, ComponentMeta } from '@storybook/react'; import React from 'react'; +import { withRouter } from 'storybook-addon-react-router-v6'; import type { Tab } from './HorizontalNav'; -import HorizontalNav from './HorizontalNav'; +import { HorizontalNav } from './HorizontalNav'; const meta: ComponentMeta = { title: 'HorizontalNav', component: HorizontalNav, + decorators: [withRouter], + parameters: { + reactRouter: { + routePath: '/testNav/:selectedTab', + routeParams: { selectedTab: 'Containers' }, + }, + }, argTypes: {}, }; export default meta; -const Template: ComponentStory = (args) => { +const TemplateWithRouter: ComponentStory = (args) => { return ; }; -export const Primary = Template.bind({}); +export const Primary = TemplateWithRouter.bind({}); // Sample content components for tabs const BaseContent: React.FC<{ content: string }> = ({ content }) => { diff --git a/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.test.tsx b/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.test.tsx index a6e80935..054bc4ec 100644 --- a/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.test.tsx +++ b/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.test.tsx @@ -1,8 +1,9 @@ import { render, screen, fireEvent } from '@testing-library/react'; import React from 'react'; import '@testing-library/jest-dom'; +import { BrowserRouter } from 'react-router-dom'; import type { Tab } from './HorizontalNav'; -import HorizontalNav from './HorizontalNav'; +import { HorizontalNav, HorizontalNavTabs } from './HorizontalNav'; // Sample content components for tabs const UsersTabContent: React.FC = () =>
Users Tab Content
; @@ -13,20 +14,59 @@ const mockTabs: Tab[] = [ { key: 'Database', title: 'Database', content: , ariaLabel: 'Database' }, ]; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + selectedTab: 'Database', + }), + useLocation: () => ({ + pathname: 'testNav/Database', + }), +})); + describe('HorizontalNav', () => { - test('loads and displays tabs with default selection', () => { - render(); + afterAll(() => { + jest.restoreAllMocks(); + }); + describe('Horizontal tabs with routing', () => { + test('loads and displays tabs with initial selection based on route param', () => { + render( + + + , + ); + + expect(screen.getByRole('tab', { selected: true })).toHaveTextContent('Database'); + expect(screen.getByText('Database Tab Content')).toBeVisible(); + }); - 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('Users')); + + expect(screen.getByRole('tab', { selected: true })).toHaveTextContent('Users'); + }); }); + 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(); + test('switches tab on click', () => { + render(); - fireEvent.click(screen.getByText('Database')); + fireEvent.click(screen.getByText('Database')); - expect(screen.getByRole('tab', { selected: true })).toHaveTextContent('Database'); - expect(screen.getByText('Database Tab Content')).toBeVisible(); + expect(screen.getByRole('tab', { selected: true })).toHaveTextContent('Database'); + expect(screen.getByText('Database Tab Content')).toBeVisible(); + }); }); }); diff --git a/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.tsx b/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.tsx index 9c60dec6..777b8fa1 100644 --- a/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.tsx +++ b/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.tsx @@ -1,7 +1,8 @@ import { Tabs, Tab, TabTitleText } from '@patternfly/react-core'; import React from 'react'; +import { useParams, useNavigate, useLocation } from 'react-router-dom'; -export type Tab = { +type Tab = { /** Key for individual tab */ key: string | number; /** Title for individual tab */ @@ -12,23 +13,50 @@ export type Tab = { ariaLabel: string; }; -export type HorizontalNavProps = { +type WithRouterProps = { + /** URL parameters */ + params?: Record; + /** Navigate function */ + navigate?: ReturnType; + /** Current location */ + location?: ReturnType; +}; + +type HorizontalNavProps = { /** aria-label for all tabs */ ariaLabel?: string; /** Properties for tabs */ tabs: Tab[]; -}; +} & WithRouterProps; + +const HorizontalNavTabs: React.FC = ({ + ariaLabel, + tabs, + params, + navigate, + location, +}) => { + const defaultActiveTab = tabs && tabs[0] ? tabs[0].key : 0; // Set first tab as the default active tab + + const activeTabFromUrlParam = params?.selectedTab; + const isValidTabFromUrl = + activeTabFromUrlParam && tabs?.some((tab) => tab.key === activeTabFromUrlParam); + const activeTab = isValidTabFromUrl ? activeTabFromUrlParam : defaultActiveTab; -const HorizontalNav: React.FC = ({ ariaLabel, tabs }) => { - const activeTab = tabs && tabs[0] ? tabs[0].key : 0; const [activeTabKey, setActiveTabKey] = React.useState(activeTab); return ( { - setActiveTabKey(tabIndex); + onSelect={(e, eventKey) => { + setActiveTabKey(eventKey); + if (params?.selectedTab && location?.pathname && navigate) { + const currentPathName = location.pathname; + navigate(currentPathName.replace(params.selectedTab, eventKey as string), { + replace: true, + }); + } }} aria-label={ariaLabel} role="region" @@ -49,4 +77,17 @@ const HorizontalNav: React.FC = ({ ariaLabel, tabs }) => { ); }; -export default HorizontalNav; +const withRouter = (Component: React.ComponentType) => { + return (props: Omit) => { + const params = useParams(); + const navigate = useNavigate(); + const location = useLocation(); + + // eslint-disable-next-line react/jsx-props-no-spreading + return ; + }; +}; + +const HorizontalNav = withRouter(HorizontalNavTabs as React.ComponentType); + +export { HorizontalNav, HorizontalNavTabs, Tab, HorizontalNavProps }; diff --git a/packages/lib-utils/src/components/horizontal-nav/index.ts b/packages/lib-utils/src/components/horizontal-nav/index.ts index 87b71a87..757c033f 100644 --- a/packages/lib-utils/src/components/horizontal-nav/index.ts +++ b/packages/lib-utils/src/components/horizontal-nav/index.ts @@ -1 +1 @@ -export { default as HorizontalNav, Tab, HorizontalNavProps } from './HorizontalNav'; +export { HorizontalNav, HorizontalNavTabs, Tab, HorizontalNavProps } from './HorizontalNav'; From f1876f95c00e85acca79a162868abbc9a689d8c4 Mon Sep 17 00:00:00 2001 From: Vidya Nambiar Date: Tue, 13 Sep 2022 08:49:40 -0400 Subject: [PATCH 03/17] Page header action menu working actions menu page header update tests updated index.ts --- .../DetailsPageHeader.stories.tsx | 109 ++++++++++++ .../DetailsPageHeader.test.tsx | 89 ++++++++++ .../details-page-header/DetailsPageHeader.tsx | 108 +++++++++++ .../components/details-page-header/index.ts | 1 + .../utils/ActionButtons.stories.tsx | 38 ++++ .../utils/ActionButtons.test.tsx | 35 ++++ .../utils/ActionButtons.tsx | 49 +++++ .../utils/ActionMenu.stories.tsx | 110 ++++++++++++ .../utils/ActionMenu.test.tsx | 108 +++++++++++ .../details-page-header/utils/ActionMenu.tsx | 168 ++++++++++++++++++ .../utils/Breadcrumbs.stories.tsx | 47 +++++ .../utils/Breadcrumbs.test.tsx | 39 ++++ .../details-page-header/utils/Breadcrumbs.tsx | 51 ++++++ .../details-page-header/utils/index.ts | 9 + 14 files changed, 961 insertions(+) create mode 100644 packages/lib-utils/src/components/details-page-header/DetailsPageHeader.stories.tsx create mode 100644 packages/lib-utils/src/components/details-page-header/DetailsPageHeader.test.tsx create mode 100644 packages/lib-utils/src/components/details-page-header/DetailsPageHeader.tsx create mode 100644 packages/lib-utils/src/components/details-page-header/index.ts create mode 100644 packages/lib-utils/src/components/details-page-header/utils/ActionButtons.stories.tsx create mode 100644 packages/lib-utils/src/components/details-page-header/utils/ActionButtons.test.tsx create mode 100644 packages/lib-utils/src/components/details-page-header/utils/ActionButtons.tsx create mode 100644 packages/lib-utils/src/components/details-page-header/utils/ActionMenu.stories.tsx create mode 100644 packages/lib-utils/src/components/details-page-header/utils/ActionMenu.test.tsx create mode 100644 packages/lib-utils/src/components/details-page-header/utils/ActionMenu.tsx create mode 100644 packages/lib-utils/src/components/details-page-header/utils/Breadcrumbs.stories.tsx create mode 100644 packages/lib-utils/src/components/details-page-header/utils/Breadcrumbs.test.tsx create mode 100644 packages/lib-utils/src/components/details-page-header/utils/Breadcrumbs.tsx create mode 100644 packages/lib-utils/src/components/details-page-header/utils/index.ts diff --git a/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.stories.tsx b/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.stories.tsx new file mode 100644 index 00000000..41ed323d --- /dev/null +++ b/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.stories.tsx @@ -0,0 +1,109 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import { CheckCircleIcon } from '@patternfly/react-icons'; +import type { ComponentStory, ComponentMeta } from '@storybook/react'; +import React from 'react'; +import { MemoryRouter, Routes, Route, Link } from 'react-router-dom'; +import type { K8sResourceCommon } from '../../types/k8s'; +import { DetailsPageHeader } from './DetailsPageHeader'; + +const meta: ComponentMeta = { + title: 'DetailsPageHeader', + component: DetailsPageHeader, + argTypes: {}, +}; + +export default meta; + +const DetailsPageHeaderTemplate: ComponentStory = (args) => { + return ( + + + } path="/workspaces/demo-workspace" /> + +

Workspaces List Page

+ + + } + path="/workspaces" + /> +
+
+ ); +}; + +export const Primary = DetailsPageHeaderTemplate.bind({}); + +const mockWorkspace: K8sResourceCommon = { + apiVersion: 'v1beta1', + apiGroup: 'tenancy.kcp.dev', + kind: 'Workspace', + metadata: { + name: 'demo-workspace', + labels: { + label1: 'value1', + label2: 'value2', + }, + }, +}; + +Primary.args = { + breadcrumbs: [ + { name: 'Workspaces', path: '/workspaces' }, + { name: 'Workspace details', path: '/workspaces/demo-workspace' }, + ], + obj: mockWorkspace, + pageHeadingLabel: { + name: 'Ready', + icon: , + }, + actionButtons: [ + { + label: 'Test', + callback: (event: React.MouseEvent) => { + // eslint-disable-next-line no-console + console.log('Test Action', event); + }, + }, + ], + actionMenu: { + actions: [ + { + id: '1', + label: 'Edit Action', + cta: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callback: (event: React.MouseEvent | React.KeyboardEvent | MouseEvent) => { + // eslint-disable-next-line no-console + console.log('Edit Action', event); + }, + }, + tooltip: 'Sample tooltip', + }, + { + id: '2', + label: 'Delete Action', + cta: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callback: (event: React.MouseEvent | React.KeyboardEvent | MouseEvent) => { + // eslint-disable-next-line no-console + console.log('Delete Action', event); + }, + }, + isDisabled: true, + }, + { + id: 'Link1', + label: 'External Link', + cta: { + href: 'https://github.com/', + external: true, + }, + }, + ], + isDisabled: false, + }, +}; diff --git a/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.test.tsx b/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.test.tsx new file mode 100644 index 00000000..92620fe5 --- /dev/null +++ b/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.test.tsx @@ -0,0 +1,89 @@ +/* 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 '@testing-library/jest-dom'; +import type { DetailsPageHeaderProps } from './DetailsPageHeader'; +import { DetailsPageHeader } from './DetailsPageHeader'; + +const mockCallback = jest.fn(); + +const mockProps: DetailsPageHeaderProps = { + breadcrumbs: [ + { name: 'Workspaces', path: '/workspaces' }, + { name: 'Workspace details', path: '/workspaces/demo-workspace' }, + ], + pageHeading: 'demo-workspace', + actionButtons: [ + { + label: 'Test Button', + callback: mockCallback, + }, + ], + actionMenu: { + actions: [ + { + id: '2', + label: 'Delete Action', + cta: { + callback: jest.fn(), + }, + isDisabled: true, + }, + { + id: 'Link1', + label: 'Link1', + cta: { + href: '#', + }, + }, + ], + isDisabled: false, + }, +}; + +const detailsPageHeaderJSX = (args: DetailsPageHeaderProps) => ( + + + } path="/workspaces/demo-workspace" /> + Workspaces List Page} path="/workspaces" /> + + +); + +describe('DetailsPageHeader', () => { + test('DetailsPageHeader is rendered with breadcrumbs, heading, action buttons and action menu', () => { + render(detailsPageHeaderJSX(mockProps)); + + // Breadcrumbs + expect(screen.getByText('Workspaces')).toBeVisible(); + expect(screen.getByText('Workspace details')).toBeVisible(); + // Page heading + expect(screen.getByText('demo-workspace')).toBeVisible(); + // Action buttons + expect(screen.getByText('Test Button')).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('Workspaces List Page')).toBeVisible(); + }); + test('Clicking on actions menu reveals menu options', () => { + render(detailsPageHeaderJSX(mockProps)); + + fireEvent.click(screen.getByText('Actions')); + expect(screen.getByText('Link1')).toBeVisible(); + expect(screen.getByText('Delete Action')).toBeVisible(); + expect(screen.getByText('Delete Action').closest('a')).toHaveAttribute('aria-disabled'); + }); + test('Action button triggers callback', () => { + render(detailsPageHeaderJSX(mockProps)); + + fireEvent.click(screen.getByText('Test Button')); + expect(mockCallback).toHaveBeenCalled(); + }); +}); diff --git a/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.tsx b/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.tsx new file mode 100644 index 00000000..25fcbdbe --- /dev/null +++ b/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.tsx @@ -0,0 +1,108 @@ +import { + Split, + SplitItem, + TextContent, + Text, + TextVariants, + Label, + DropdownPosition, +} from '@patternfly/react-core'; +import * as _ from 'lodash'; +import React from 'react'; +import type { K8sResourceCommon } from '../../types/k8s'; +import type { BreadcrumbProp, ActionButtonProp, ActionMenuProps } from './utils'; +import { Breadcrumbs, ActionButtons, ActionMenu } from './utils'; +import '@patternfly/react-styles/css/utilities/Spacing/spacing.css'; + +export type DetailsPageHeaderProps = { + breadcrumbs: BreadcrumbProp[]; + actionButtons?: ActionButtonProp[]; + pageHeading?: string; + obj?: K8sResourceCommon; + pageHeadingLabel?: { + name: string; + key?: string; + icon?: React.ReactNode; + href?: string; + color?: 'blue' | 'cyan' | 'green' | 'orange' | 'purple' | 'red' | 'grey'; + }; + actionMenu?: ActionMenuProps; +}; + +export const DetailsPageHeader: React.SFC = ({ + breadcrumbs, + actionButtons, + actionMenu, + pageHeading, + obj, + pageHeadingLabel, +}) => { + const heading = pageHeading ?? obj?.metadata?.name; + return ( + <> + + {/* Breadcrumbs */} + + + + + + + {/* Details page heading */} + {heading && ( + + + {heading} + + + )} + {/* Optional details page heading label */} + {!_.isEmpty(pageHeadingLabel) && ( + + + + )} + + {/* Optional action buttons */} + {!_.isEmpty(actionButtons) && Array.isArray(actionButtons) && ( + + + + )} + {/* Optional action menu - ungrouped actions */} + {actionMenu && Array.isArray(actionMenu?.actions) && ( + + + + )} + {/* Optional action menu - Grouped actions */} + {actionMenu && Array.isArray(actionMenu?.groupedActions) && ( + + + + )} + + + ); +}; + +export default DetailsPageHeader; diff --git a/packages/lib-utils/src/components/details-page-header/index.ts b/packages/lib-utils/src/components/details-page-header/index.ts new file mode 100644 index 00000000..c318aa51 --- /dev/null +++ b/packages/lib-utils/src/components/details-page-header/index.ts @@ -0,0 +1 @@ +export { DetailsPageHeader, DetailsPageHeaderProps } from './DetailsPageHeader'; diff --git a/packages/lib-utils/src/components/details-page-header/utils/ActionButtons.stories.tsx b/packages/lib-utils/src/components/details-page-header/utils/ActionButtons.stories.tsx new file mode 100644 index 00000000..3e1ad6c1 --- /dev/null +++ b/packages/lib-utils/src/components/details-page-header/utils/ActionButtons.stories.tsx @@ -0,0 +1,38 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import type { ComponentStory, ComponentMeta } from '@storybook/react'; +import React from 'react'; +import { ActionButtons } from './ActionButtons'; + +const meta: ComponentMeta = { + title: 'ActionButtons', + component: ActionButtons, + argTypes: {}, +}; + +export default meta; + +const ActionButtonsTemplate: ComponentStory = (args) => { + return ; +}; + +export const Actions = ActionButtonsTemplate.bind({}); + +Actions.args = { + actionButtons: [ + { + label: 'Edit Workspace', + callback: (event: React.MouseEvent) => { + // eslint-disable-next-line no-console + console.log('Edit Workspace', event); + }, + }, + { + label: 'Delete Workspace', + callback: (event: React.MouseEvent) => { + // eslint-disable-next-line no-console + console.log('Delete Workspace', event); + }, + isDisabled: true, + }, + ], +}; diff --git a/packages/lib-utils/src/components/details-page-header/utils/ActionButtons.test.tsx b/packages/lib-utils/src/components/details-page-header/utils/ActionButtons.test.tsx new file mode 100644 index 00000000..44f4c01f --- /dev/null +++ b/packages/lib-utils/src/components/details-page-header/utils/ActionButtons.test.tsx @@ -0,0 +1,35 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import '@testing-library/jest-dom'; +import { ActionButtons } from './ActionButtons'; + +const mockCallback = jest.fn(); + +const mockActionButtons = [ + { + label: 'Edit Workspace', + callback: mockCallback, + }, + { + label: 'Delete Workspace', + callback: jest.fn(), + isDisabled: true, + tooltip: 'Deletion is currently unavailable', + }, +]; + +describe('ActionButtons', () => { + test('Buttons are rendered', () => { + render(); + + expect(screen.getByText('Edit Workspace')).toBeVisible(); + expect(screen.getByText('Delete Workspace')).toBeVisible(); + expect(screen.getByText('Delete Workspace').closest('button')).toHaveAttribute('aria-disabled'); + }); + test('Button clicks trigger callback', () => { + render(); + + fireEvent.click(screen.getByText('Edit Workspace')); + expect(mockCallback).toHaveBeenCalled(); + }); +}); diff --git a/packages/lib-utils/src/components/details-page-header/utils/ActionButtons.tsx b/packages/lib-utils/src/components/details-page-header/utils/ActionButtons.tsx new file mode 100644 index 00000000..7dbb86f3 --- /dev/null +++ b/packages/lib-utils/src/components/details-page-header/utils/ActionButtons.tsx @@ -0,0 +1,49 @@ +import { Button, Flex, FlexItem, Tooltip } from '@patternfly/react-core'; +import * as _ from 'lodash-es'; +import React from 'react'; + +export type ActionButtonProp = { + label: string; + callback: (event: React.MouseEvent) => void; + isDisabled?: boolean; + tooltip?: string; +}; + +export type ActionButtonsProps = { + actionButtons: ActionButtonProp[]; +}; + +export const ActionButtons: React.SFC = ({ actionButtons }) => ( + + {_.map(actionButtons, (actionButton, i) => { + if (!_.isEmpty(actionButton)) { + return ( + + {actionButton.tooltip ? ( + + + + ) : ( + + )} + + ); + } + return null; + })} + +); + +export default ActionButtons; diff --git a/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.stories.tsx b/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.stories.tsx new file mode 100644 index 00000000..b2feeb83 --- /dev/null +++ b/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.stories.tsx @@ -0,0 +1,110 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import { DropdownPosition } from '@patternfly/react-core'; +import type { ComponentStory, ComponentMeta } from '@storybook/react'; +import React from 'react'; +import { ActionMenu } from './ActionMenu'; + +const meta: ComponentMeta = { + title: 'ActionMenu', + component: ActionMenu, + argTypes: {}, +}; + +export default meta; + +const ActionMenuTemplate: ComponentStory = (args) => { + return ; +}; + +export const Actions = ActionMenuTemplate.bind({}); + +Actions.args = { + actions: [ + { + id: '1', + label: 'Edit Action', + cta: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callback: (event: React.MouseEvent | React.KeyboardEvent | MouseEvent) => { + // eslint-disable-next-line no-console + console.log('Edit Action', event); + }, + }, + tooltip: 'Sample tooltip', + }, + { + id: '2', + label: 'Delete Action', + cta: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callback: (event: React.MouseEvent | React.KeyboardEvent | MouseEvent) => { + // eslint-disable-next-line no-console + console.log('Delete Action', event); + }, + }, + isDisabled: true, + }, + ], + isDisabled: false, + position: DropdownPosition.left, +}; + +export const GroupedActions = ActionMenuTemplate.bind({}); + +GroupedActions.args = { + groupedActions: [ + { + groupId: 'group1', + groupActions: [ + { + id: '1', + label: 'Edit Action', + cta: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callback: (event: React.MouseEvent | React.KeyboardEvent | MouseEvent) => { + // eslint-disable-next-line no-console + console.log('Edit Action', event); + }, + }, + tooltip: 'Sample tooltip', + }, + { + id: '2', + label: 'Delete Action', + cta: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callback: (event: React.MouseEvent | React.KeyboardEvent | MouseEvent) => { + // eslint-disable-next-line no-console + console.log('Delete Action', event); + }, + }, + isDisabled: true, + }, + ], + }, + { + groupId: 'group2', + groupLabel: 'Group2', + groupActions: [ + { + id: 'Link1', + label: 'External Link', + cta: { + href: 'https://github.com/', + external: true, + }, + }, + { + id: 'Link2', + label: 'Link', + cta: { + href: '/#', + external: false, + }, + tooltip: 'Link', + }, + ], + }, + ], + position: DropdownPosition.left, +}; diff --git a/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.test.tsx b/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.test.tsx new file mode 100644 index 00000000..874d48ba --- /dev/null +++ b/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.test.tsx @@ -0,0 +1,108 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import '@testing-library/jest-dom'; +import { ActionMenu } from './ActionMenu'; + +const mockCallback = jest.fn(); + +const mockActions = [ + { + id: '1', + label: 'Edit Action', + cta: { + callback: mockCallback, + }, + }, + { + id: '2', + label: 'Delete Action', + cta: { + callback: jest.fn(), + }, + isDisabled: true, + }, +]; + +const mockGroupedActions = [ + { + groupId: 'group1', + groupActions: [ + { + id: '1', + label: 'Edit Action', + cta: { + callback: jest.fn(), + }, + tooltip: 'Sample tooltip', + }, + { + id: '2', + label: 'Delete Action', + cta: { + callback: jest.fn(), + }, + isDisabled: true, + }, + ], + }, + { + groupId: 'group2', + groupLabel: 'Group2', + groupActions: [ + { + id: 'Link1', + label: 'External Link', + cta: { + href: 'https://github.com/', + external: true, + }, + }, + { + id: 'Link2', + label: 'Link', + cta: { + href: '/#', + external: false, + }, + 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 Action')).toBeVisible(); + expect(screen.getByText('Delete Action')).toBeVisible(); + expect(screen.getByText('Delete Action').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 Action')).toBeVisible(); + fireEvent.click(screen.getByText('Edit Action')); + expect(mockCallback).toHaveBeenCalled(); + }); + test('Menu actions are rendered in groups', () => { + render(); + + fireEvent.click(screen.getByText('Actions')); + expect(screen.getByText('Edit Action')).toBeVisible(); + expect(screen.getByText('Group2')).toBeVisible(); + expect(screen.getByText('External Link')).toBeVisible(); + }); +}); diff --git a/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.tsx b/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.tsx new file mode 100644 index 00000000..8df190c6 --- /dev/null +++ b/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.tsx @@ -0,0 +1,168 @@ +import type { EitherNotBoth } from '@monorepo/common'; +import { + Dropdown, + DropdownPosition, + DropdownGroup, + DropdownToggle, + KebabToggle, + DropdownItem, +} from '@patternfly/react-core'; +import { ExternalLinkAltIcon } from '@patternfly/react-icons'; +import * as _ from 'lodash-es'; +import React from 'react'; + +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 type Action = { + /** A unique identifier for this action. */ + id: string; + /** The label to display in the UI. */ + label: React.ReactNode; + /** Subtext for the menu item */ + description?: string; + /** Executable callback or href. + * External links should automatically provide an external link icon on action. + * */ + cta: ActionCTA; + /** Whether the action is disabled. */ + isDisabled?: boolean; + /** The tooltip for this action. */ + tooltip?: string; + /** The icon for this action. */ + icon?: React.ReactNode; +}; + +export type GroupedActions = { + /** A unique identifier for this group. */ + groupId: string; + /** Optional label to display as group heading */ + groupLabel?: string; + /** Actions under this group. */ + groupActions: Action[]; +}; + +export enum ActionMenuVariant { + KEBAB = 'plain', + DROPDOWN = 'default', +} + +export type ActionMenuProps = EitherNotBoth< + { actions: Action[] }, + { groupedActions: GroupedActions[] } +> & { + /** 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; +}; + +export const ActionMenu: React.FC = ({ + actions = [], + groupedActions = [], + isDisabled, + variant = ActionMenuVariant.DROPDOWN, + label = 'Actions', + position = DropdownPosition.right, +}) => { + const [isOpen, setIsOpen] = React.useState(false); + const [isGrouped, setIsGrouped] = React.useState(false); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [dropdownActionItems, setDropdownActionItems] = React.useState([]); + + React.useEffect(() => { + if (!_.isEmpty(groupedActions)) { + setIsGrouped(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onToggle = (open: boolean) => { + setIsOpen(open); + }; + + const onFocus = () => { + const element = document.getElementById(`toggle-menu-${label}`); + if (element) { + element.focus(); + } + }; + + const onSelect = () => { + setIsOpen(false); + onFocus(); + }; + + /** Returns a DropDownItem element corresponding to an action */ + const dropdownActionItem = React.useCallback((action: Action) => { + 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.label} + + ); + }, []); + + React.useEffect(() => { + let ddActionItems: JSX.Element[] = []; + if (!_.isEmpty(actions)) { + ddActionItems = actions.map((action: Action) => { + return dropdownActionItem(action as Action); + }); + } + if (!_.isEmpty(groupedActions)) { + ddActionItems = groupedActions.map((action: GroupedActions) => { + // Grouped Actions + return ( + + {action.groupActions.map((groupAction: Action) => dropdownActionItem(groupAction))} + + ); + }); + } + setDropdownActionItems(ddActionItems); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Build dropdown + return ( + + {label} + + ) : ( + + ) + } + isOpen={isOpen} + dropdownItems={dropdownActionItems} + isGrouped={isGrouped} + /> + ); +}; + +export default ActionMenu; diff --git a/packages/lib-utils/src/components/details-page-header/utils/Breadcrumbs.stories.tsx b/packages/lib-utils/src/components/details-page-header/utils/Breadcrumbs.stories.tsx new file mode 100644 index 00000000..5f9ab4a5 --- /dev/null +++ b/packages/lib-utils/src/components/details-page-header/utils/Breadcrumbs.stories.tsx @@ -0,0 +1,47 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import type { ComponentStory, ComponentMeta } from '@storybook/react'; +import React from 'react'; +import { MemoryRouter, Routes, Route, Link } from 'react-router-dom'; +import type { BreadcrumbProp } from './Breadcrumbs'; +import { Breadcrumbs } from './Breadcrumbs'; + +const meta: ComponentMeta = { + title: 'Breadcrumbs', + component: Breadcrumbs, + argTypes: {}, +}; + +export default meta; + +const BreadcrumbsTemplate: ComponentStory = (args) => { + return ( + + + } path="/workspaces/demo-workspace" /> + +

Workspaces List Page

+ + + } + path="/workspaces" + /> +
+
+ ); +}; + +export const Primary = BreadcrumbsTemplate.bind({}); + +// Define tabs +const breadcrumbs: BreadcrumbProp[] = [ + { name: 'Workspaces', path: '/workspaces' }, + { name: 'Workspace details', path: '/workspaces/demo-workspace' }, +]; + +Primary.args = { + breadcrumbs, +}; diff --git a/packages/lib-utils/src/components/details-page-header/utils/Breadcrumbs.test.tsx b/packages/lib-utils/src/components/details-page-header/utils/Breadcrumbs.test.tsx new file mode 100644 index 00000000..a13246a3 --- /dev/null +++ b/packages/lib-utils/src/components/details-page-header/utils/Breadcrumbs.test.tsx @@ -0,0 +1,39 @@ +/* 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 '@testing-library/jest-dom'; +import type { BreadcrumbsProps } from './Breadcrumbs'; +import { Breadcrumbs } from './Breadcrumbs'; + +const mockProps: BreadcrumbsProps = { + breadcrumbs: [ + { name: 'Workspaces', path: '/workspaces' }, + { name: 'Workspace details', path: '/workspaces/demo-workspace' }, + ], +}; + +const breadcrumbsJSX = (args: BreadcrumbsProps) => ( + + + } path="/workspaces/demo-workspace" /> + Workspaces List Page} path="/workspaces" /> + + +); + +describe('Breadcrumbs', () => { + test('Breadcrumbs are rendered', () => { + render(breadcrumbsJSX(mockProps)); + + expect(screen.getByText('Workspaces')).toBeVisible(); + expect(screen.getByText('Workspace details')).toBeVisible(); + }); + test('Clicking on breadcrumb triggers specified path', () => { + render(breadcrumbsJSX(mockProps)); + + // Click Workspaces link + fireEvent.click(screen.getByTestId('breadcrumb-link-0')); + expect(screen.getByText('Workspaces List Page')).toBeVisible(); + }); +}); diff --git a/packages/lib-utils/src/components/details-page-header/utils/Breadcrumbs.tsx b/packages/lib-utils/src/components/details-page-header/utils/Breadcrumbs.tsx new file mode 100644 index 00000000..1702dc2a --- /dev/null +++ b/packages/lib-utils/src/components/details-page-header/utils/Breadcrumbs.tsx @@ -0,0 +1,51 @@ +import { v4 as uuidv4 } from 'uuid'; +import { Breadcrumb, BreadcrumbItem } from '@patternfly/react-core'; +import React from 'react'; +import { Link } from 'react-router-dom'; + +export type BreadcrumbProp = { name: string; path: string; uuid?: string; id?: string }; + +export type BreadcrumbsProps = { + breadcrumbs: BreadcrumbProp[]; +}; + +export const Breadcrumbs: React.SFC = ({ breadcrumbs }) => { + const [crumbs, setCrumbs] = React.useState([]); + + const addUUID = (allData: BreadcrumbProp[]) => { + return allData.map((item: BreadcrumbProp) => ({ + ...item, + uuid: item.uuid || item.id || uuidv4(), + })); + }; + + React.useEffect(() => { + setCrumbs(addUUID(breadcrumbs)); + }, [breadcrumbs]); + + return ( + + {crumbs.map((crumb, i, { length }) => { + const isLast = i === length - 1; + + return ( + + {isLast ? ( + crumb.name + ) : ( + + {crumb.name} + + )} + + ); + })} + + ); +}; + +export default Breadcrumbs; diff --git a/packages/lib-utils/src/components/details-page-header/utils/index.ts b/packages/lib-utils/src/components/details-page-header/utils/index.ts new file mode 100644 index 00000000..d6266f94 --- /dev/null +++ b/packages/lib-utils/src/components/details-page-header/utils/index.ts @@ -0,0 +1,9 @@ +export { Breadcrumbs, BreadcrumbProp, BreadcrumbsProps } from './Breadcrumbs'; +export { ActionButtons, ActionButtonProp, ActionButtonsProps } from './ActionButtons'; +export { + ActionMenu, + Action, + GroupedActions, + ActionMenuVariant, + ActionMenuProps, +} from './ActionMenu'; From a09d43af1352b2e8fd27097631c6f71eebb01395 Mon Sep 17 00:00:00 2001 From: Vidya Nambiar Date: Wed, 21 Sep 2022 10:26:50 -0400 Subject: [PATCH 04/17] Option to have action menu icon after text Option to have action menu icon after text --- .../details-page-header/utils/ActionMenu.css | 12 +++ .../utils/ActionMenu.stories.tsx | 8 +- .../details-page-header/utils/ActionMenu.tsx | 75 +++++++++++-------- 3 files changed, 63 insertions(+), 32 deletions(-) create mode 100644 packages/lib-utils/src/components/details-page-header/utils/ActionMenu.css diff --git a/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.css b/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.css new file mode 100644 index 00000000..d7b7d3ec --- /dev/null +++ b/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.css @@ -0,0 +1,12 @@ +.menu-item-with-label-before-icon .pf-c-dropdown__menu-item-icon { + margin-left: var(--pf-c-dropdown__menu-item-icon--MarginRight); +} + +.menu-item-with-label-before-icon.pf-c-dropdown__menu-item { + flex-direction: row-reverse; + justify-content: left; +} + +.menu-item-with-label-before-icon .pf-c-dropdown__menu-item-main { + flex-direction: row-reverse; +} diff --git a/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.stories.tsx b/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.stories.tsx index b2feeb83..10d0039c 100644 --- a/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.stories.tsx +++ b/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.stories.tsx @@ -2,12 +2,12 @@ import { DropdownPosition } from '@patternfly/react-core'; import type { ComponentStory, ComponentMeta } from '@storybook/react'; import React from 'react'; -import { ActionMenu } from './ActionMenu'; +import { ActionMenu, ActionMenuVariant } from './ActionMenu'; const meta: ComponentMeta = { title: 'ActionMenu', component: ActionMenu, - argTypes: {}, + argTypes: { groupedActions: { control: 'object' } }, }; export default meta; @@ -102,9 +102,13 @@ GroupedActions.args = { external: false, }, tooltip: 'Link', + description: 'Sample description', }, ], }, ], + variant: ActionMenuVariant.DROPDOWN, + label: 'Grouped Actions', position: DropdownPosition.left, + displayLabelBeforeIcon: true, }; diff --git a/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.tsx b/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.tsx index 8df190c6..01d405c3 100644 --- a/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.tsx +++ b/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.tsx @@ -10,6 +10,7 @@ import { import { ExternalLinkAltIcon } from '@patternfly/react-icons'; import * as _ from 'lodash-es'; import React from 'react'; +import './ActionMenu.css'; export type ActionCTA = // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -21,17 +22,17 @@ export type Action = { id: string; /** The label to display in the UI. */ label: React.ReactNode; - /** Subtext for the menu item */ + /** Optional subtext for the menu item */ description?: string; /** Executable callback or href. * External links should automatically provide an external link icon on action. * */ cta: ActionCTA; - /** Whether the action is disabled. */ + /** Optional flag to indicate whether the action is disabled. */ isDisabled?: boolean; - /** The tooltip for this action. */ + /** Optional tooltip for this action. */ tooltip?: string; - /** The icon for this action. */ + /** Optional icon for this action. */ icon?: React.ReactNode; }; @@ -49,10 +50,7 @@ export enum ActionMenuVariant { DROPDOWN = 'default', } -export type ActionMenuProps = EitherNotBoth< - { actions: Action[] }, - { groupedActions: GroupedActions[] } -> & { +export type ActionMenuOptions = { /** Optional flag to indicate whether action menu should be disabled */ isDisabled?: boolean; /** Optional variant for action menu: DROPDOWN vs KEBAB (defaults to dropdown) */ @@ -61,8 +59,16 @@ export type ActionMenuProps = EitherNotBoth< 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; }; +export type ActionMenuProps = EitherNotBoth< + { actions: Action[] }, + { groupedActions: GroupedActions[] } +> & + ActionMenuOptions; + export const ActionMenu: React.FC = ({ actions = [], groupedActions = [], @@ -70,6 +76,7 @@ export const ActionMenu: React.FC = ({ variant = ActionMenuVariant.DROPDOWN, label = 'Actions', position = DropdownPosition.right, + displayLabelBeforeIcon, }) => { const [isOpen, setIsOpen] = React.useState(false); const [isGrouped, setIsGrouped] = React.useState(false); @@ -100,28 +107,36 @@ export const ActionMenu: React.FC = ({ }; /** Returns a DropDownItem element corresponding to an action */ - const dropdownActionItem = React.useCallback((action: Action) => { - 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.label} - - ); - }, []); + const dropdownActionItem = React.useCallback( + (action: Action) => { + 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.label} + + ); + }, + [displayLabelBeforeIcon], + ); React.useEffect(() => { let ddActionItems: JSX.Element[] = []; From cbd1a4963ba55ad0c05366a2c52ed2c220385e8a Mon Sep 17 00:00:00 2001 From: Vidya Nambiar Date: Thu, 22 Sep 2022 13:53:19 -0400 Subject: [PATCH 05/17] Option to add icon before of after page heading title --- .../DetailsPageHeader.stories.tsx | 11 +++-- .../DetailsPageHeader.test.tsx | 4 +- .../details-page-header/DetailsPageHeader.tsx | 48 +++++++++++++------ 3 files changed, 44 insertions(+), 19 deletions(-) diff --git a/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.stories.tsx b/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.stories.tsx index 41ed323d..6a7babd1 100644 --- a/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.stories.tsx +++ b/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.stories.tsx @@ -1,5 +1,5 @@ /* eslint-disable react/jsx-props-no-spreading */ -import { CheckCircleIcon } from '@patternfly/react-icons'; +import { CheckCircleIcon, GithubIcon } from '@patternfly/react-icons'; import type { ComponentStory, ComponentMeta } from '@storybook/react'; import React from 'react'; import { MemoryRouter, Routes, Route, Link } from 'react-router-dom'; @@ -56,9 +56,12 @@ Primary.args = { { name: 'Workspace details', path: '/workspaces/demo-workspace' }, ], obj: mockWorkspace, - pageHeadingLabel: { - name: 'Ready', - icon: , + pageHeading: { + label: { + name: 'Ready', + icon: , + }, + iconAfterTitle: , }, actionButtons: [ { diff --git a/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.test.tsx b/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.test.tsx index 92620fe5..0cad064c 100644 --- a/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.test.tsx +++ b/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.test.tsx @@ -13,7 +13,9 @@ const mockProps: DetailsPageHeaderProps = { { name: 'Workspaces', path: '/workspaces' }, { name: 'Workspace details', path: '/workspaces/demo-workspace' }, ], - pageHeading: 'demo-workspace', + pageHeading: { + title: 'demo-workspace', + }, actionButtons: [ { label: 'Test Button', diff --git a/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.tsx b/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.tsx index 25fcbdbe..70e7ffd6 100644 --- a/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.tsx +++ b/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.tsx @@ -14,18 +14,29 @@ import type { BreadcrumbProp, ActionButtonProp, ActionMenuProps } from './utils' import { Breadcrumbs, ActionButtons, ActionMenu } from './utils'; import '@patternfly/react-styles/css/utilities/Spacing/spacing.css'; -export type DetailsPageHeaderProps = { - breadcrumbs: BreadcrumbProp[]; - actionButtons?: ActionButtonProp[]; - pageHeading?: string; - obj?: K8sResourceCommon; - pageHeadingLabel?: { +export type PageHeading = { + /** Optional title for page heading */ + title?: string; + /** Optional label for page heading */ + label?: { name: string; key?: string; icon?: React.ReactNode; href?: string; color?: 'blue' | 'cyan' | 'green' | 'orange' | 'purple' | 'red' | 'grey'; }; + /** 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; +}; + +export type DetailsPageHeaderProps = { + breadcrumbs: BreadcrumbProp[]; + actionButtons?: ActionButtonProp[]; + pageHeading?: PageHeading; + /** Optional resource object (if no title for the page heading is provided, the title can be taken from the resource's name) */ + obj?: K8sResourceCommon; actionMenu?: ActionMenuProps; }; @@ -35,9 +46,8 @@ export const DetailsPageHeader: React.SFC = ({ actionMenu, pageHeading, obj, - pageHeadingLabel, }) => { - const heading = pageHeading ?? obj?.metadata?.name; + const heading = pageHeading?.title ?? obj?.metadata?.name; return ( <> @@ -48,6 +58,12 @@ export const DetailsPageHeader: React.SFC = ({ + {/** Icon for details page heading (before title) */} + {pageHeading && !_.isEmpty(pageHeading?.iconBeforeTitle) && ( + + {pageHeading?.iconBeforeTitle} + + )} {/* Details page heading */} {heading && ( @@ -56,16 +72,20 @@ export const DetailsPageHeader: React.SFC = ({ )} + {/** Icon for details page heading (after title) */} + {pageHeading && !_.isEmpty(pageHeading?.iconAfterTitle) && ( + {pageHeading?.iconAfterTitle} + )} {/* Optional details page heading label */} - {!_.isEmpty(pageHeadingLabel) && ( + {!_.isEmpty(pageHeading) && !_.isEmpty(pageHeading?.label) && ( )} From 31098b58666567bdc6cc3424198bb971449ee9de Mon Sep 17 00:00:00 2001 From: Vidya Nambiar Date: Wed, 21 Sep 2022 07:57:03 -0400 Subject: [PATCH 06/17] detailspage & storybook update --- .../details-page/DetailsPage.stories.tsx | 183 ++++++++++++++++++ .../components/details-page/DetailsPage.tsx | 34 ++++ .../horizontal-nav/HorizontalNav.tsx | 17 +- .../src/components/horizontal-nav/index.ts | 8 +- 4 files changed, 235 insertions(+), 7 deletions(-) create mode 100644 packages/lib-utils/src/components/details-page/DetailsPage.stories.tsx create mode 100644 packages/lib-utils/src/components/details-page/DetailsPage.tsx diff --git a/packages/lib-utils/src/components/details-page/DetailsPage.stories.tsx b/packages/lib-utils/src/components/details-page/DetailsPage.stories.tsx new file mode 100644 index 00000000..44dce81c --- /dev/null +++ b/packages/lib-utils/src/components/details-page/DetailsPage.stories.tsx @@ -0,0 +1,183 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import { + TextContent, + Text, + TextVariants, + DescriptionList, + DescriptionListTerm, + DescriptionListGroup, + DescriptionListDescription, + Label, +} from '@patternfly/react-core'; +import { CheckCircleIcon } from '@patternfly/react-icons'; +import type { ComponentStory, ComponentMeta } from '@storybook/react'; +import React from 'react'; +import { MemoryRouter, Routes, Route, Link } from 'react-router-dom'; +import type { K8sResourceCommon } from '../../types/k8s'; +import { DetailsPage } from './DetailsPage'; + +const meta: ComponentMeta = { + title: 'DetailsPage', + component: DetailsPage, + argTypes: {}, +}; + +export default meta; + +const DetailsPageTemplate: ComponentStory = (args) => { + return ( + + + } path="/workspaces/demo-workspace" /> + } path="/workspaces/demo-workspace/:selectedTab" /> + +

Workspaces List Page

+ + Demo Workspace + + + } + path="/workspaces" + /> +
+
+ ); +}; + +export const Primary = DetailsPageTemplate.bind({}); + +const mockWorkspace: K8sResourceCommon = { + apiVersion: 'v1beta1', + apiGroup: 'tenancy.kcp.dev', + kind: 'Workspace', + metadata: { + name: 'demo-workspace', + labels: { + label1: 'value1', + label2: 'value2', + }, + }, +}; + +export const SampleOverview: React.FC = () => ( + + + Name + demo-workspace + + + Members + Alex and 3 others + + + Services + + + + + + APIs + + 21 kinds + + + + Labels + + + + + + Applications + + + Demo app,Billing app + + + + +); + +export const PlaceholderComponent: React.FC<{ value: string }> = ({ value }) => ( + + {value} + +); + +Primary.args = { + ariaLabel: 'Workspace details', + tabs: [ + { key: 'overview', title: 'Overview', content: , ariaLabel: 'Overview' }, + { + key: 'applications', + title: 'Applications', + content: , + ariaLabel: 'applications', + }, + { + key: 'environments', + title: 'Environments', + content: , + ariaLabel: 'Environments', + }, + { + key: 'apis', + title: 'APIs', + content: , + ariaLabel: 'APIs', + }, + ], + breadcrumbs: [ + { name: 'Workspaces', path: '/workspaces' }, + { name: 'Workspace details', path: '/workspaces/demo-workspace' }, + ], + obj: mockWorkspace, + pageHeadingLabel: { + name: 'Ready', + icon: , + }, + actionButtons: [ + { + label: 'Download kubeconfig', + callback: (event: React.MouseEvent) => { + // eslint-disable-next-line no-console + console.log('Test Action', event); + }, + }, + ], + actionMenu: { + actions: [ + { + id: '1', + label: 'Edit workspace', + cta: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callback: (event: React.MouseEvent | React.KeyboardEvent | MouseEvent) => { + // eslint-disable-next-line no-console + console.log('Edit workspace', event); + }, + }, + }, + { + id: '2', + label: 'Delete workspace', + cta: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callback: (event: React.MouseEvent | React.KeyboardEvent | MouseEvent) => { + // eslint-disable-next-line no-console + console.log('Delete workspace', event); + }, + }, + isDisabled: true, + tooltip: 'Sample tooltip', + }, + ], + isDisabled: false, + }, +}; diff --git a/packages/lib-utils/src/components/details-page/DetailsPage.tsx b/packages/lib-utils/src/components/details-page/DetailsPage.tsx new file mode 100644 index 00000000..5abdaf96 --- /dev/null +++ b/packages/lib-utils/src/components/details-page/DetailsPage.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import type { DetailsPageHeaderProps } from '../details-page-header'; +import { DetailsPageHeader } from '../details-page-header'; +import type { HorizontalNavProps } from '../horizontal-nav'; +import { withRouter, HorizontalNav } from '../horizontal-nav'; + +export type DetailsPageProps = HorizontalNavProps & DetailsPageHeaderProps; + +export const DetailsPage = withRouter( + ({ + ariaLabel, + tabs, + breadcrumbs, + actionButtons, + actionMenu, + pageHeading, + obj, + pageHeadingLabel, + }) => { + return ( + <> + + + + ); + }, +); diff --git a/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.tsx b/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.tsx index 777b8fa1..ac4760ab 100644 --- a/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.tsx +++ b/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.tsx @@ -1,6 +1,7 @@ import { Tabs, Tab, TabTitleText } from '@patternfly/react-core'; import React from 'react'; import { useParams, useNavigate, useLocation } from 'react-router-dom'; +import '@patternfly/react-styles/css/utilities/Spacing/spacing.css'; type Tab = { /** Key for individual tab */ @@ -51,11 +52,15 @@ const HorizontalNavTabs: React.FC = ({ activeKey={activeTabKey} onSelect={(e, eventKey) => { setActiveTabKey(eventKey); - if (params?.selectedTab && location?.pathname && navigate) { + if (location?.pathname && navigate) { const currentPathName = location.pathname; - navigate(currentPathName.replace(params.selectedTab, eventKey as string), { - replace: true, - }); + if (params?.selectedTab) { + navigate(currentPathName.replace(params.selectedTab, eventKey as string), { + replace: true, + }); + } else { + navigate(`${currentPathName}/${eventKey as string}`); + } } }} aria-label={ariaLabel} @@ -69,7 +74,7 @@ const HorizontalNavTabs: React.FC = ({ title={{tab.title}} aria-label={tab.ariaLabel} > - {tab.content} +
{tab.content}
); })} @@ -90,4 +95,4 @@ const withRouter = (Component: React.ComponentType const HorizontalNav = withRouter(HorizontalNavTabs as React.ComponentType); -export { HorizontalNav, HorizontalNavTabs, Tab, HorizontalNavProps }; +export { HorizontalNav, HorizontalNavTabs, Tab, HorizontalNavProps, withRouter }; diff --git a/packages/lib-utils/src/components/horizontal-nav/index.ts b/packages/lib-utils/src/components/horizontal-nav/index.ts index 757c033f..e9568029 100644 --- a/packages/lib-utils/src/components/horizontal-nav/index.ts +++ b/packages/lib-utils/src/components/horizontal-nav/index.ts @@ -1 +1,7 @@ -export { HorizontalNav, HorizontalNavTabs, Tab, HorizontalNavProps } from './HorizontalNav'; +export { + HorizontalNav, + HorizontalNavTabs, + Tab, + HorizontalNavProps, + withRouter, +} from './HorizontalNav'; From 71aa44b4aeea13a7bcf6aff2167f1b66aa4c22aa Mon Sep 17 00:00:00 2001 From: Vidya Nambiar Date: Fri, 30 Sep 2022 12:41:36 -0400 Subject: [PATCH 07/17] update stories based on changes to header component --- .../components/details-page/DetailsPage.stories.tsx | 8 +++++--- .../src/components/details-page/DetailsPage.tsx | 12 +----------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/lib-utils/src/components/details-page/DetailsPage.stories.tsx b/packages/lib-utils/src/components/details-page/DetailsPage.stories.tsx index 44dce81c..71bbfc0a 100644 --- a/packages/lib-utils/src/components/details-page/DetailsPage.stories.tsx +++ b/packages/lib-utils/src/components/details-page/DetailsPage.stories.tsx @@ -138,9 +138,11 @@ Primary.args = { { name: 'Workspace details', path: '/workspaces/demo-workspace' }, ], obj: mockWorkspace, - pageHeadingLabel: { - name: 'Ready', - icon: , + pageHeading: { + label: { + name: 'Ready', + icon: , + }, }, actionButtons: [ { diff --git a/packages/lib-utils/src/components/details-page/DetailsPage.tsx b/packages/lib-utils/src/components/details-page/DetailsPage.tsx index 5abdaf96..68e68975 100644 --- a/packages/lib-utils/src/components/details-page/DetailsPage.tsx +++ b/packages/lib-utils/src/components/details-page/DetailsPage.tsx @@ -7,16 +7,7 @@ import { withRouter, HorizontalNav } from '../horizontal-nav'; export type DetailsPageProps = HorizontalNavProps & DetailsPageHeaderProps; export const DetailsPage = withRouter( - ({ - ariaLabel, - tabs, - breadcrumbs, - actionButtons, - actionMenu, - pageHeading, - obj, - pageHeadingLabel, - }) => { + ({ ariaLabel, tabs, breadcrumbs, actionButtons, actionMenu, pageHeading, obj }) => { return ( <> ( actionMenu={actionMenu} pageHeading={pageHeading} obj={obj} - pageHeadingLabel={pageHeadingLabel} /> From 9339af9c579998e1f155ddf77b5bd03a9e5880a6 Mon Sep 17 00:00:00 2001 From: Vojtech Szocs Date: Wed, 12 Oct 2022 20:29:01 +0200 Subject: [PATCH 08/17] Fix test performance --- .../components/details-page-header/DetailsPageHeader.test.tsx | 1 - .../components/details-page-header/utils/ActionButtons.test.tsx | 1 - .../src/components/details-page-header/utils/ActionMenu.test.tsx | 1 - .../components/details-page-header/utils/Breadcrumbs.test.tsx | 1 - .../src/components/horizontal-nav/HorizontalNav.test.tsx | 1 - 5 files changed, 5 deletions(-) diff --git a/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.test.tsx b/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.test.tsx index 0cad064c..2acd666d 100644 --- a/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.test.tsx +++ b/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.test.tsx @@ -2,7 +2,6 @@ import { render, screen, fireEvent } from '@testing-library/react'; import React from 'react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; -import '@testing-library/jest-dom'; import type { DetailsPageHeaderProps } from './DetailsPageHeader'; import { DetailsPageHeader } from './DetailsPageHeader'; diff --git a/packages/lib-utils/src/components/details-page-header/utils/ActionButtons.test.tsx b/packages/lib-utils/src/components/details-page-header/utils/ActionButtons.test.tsx index 44f4c01f..146049d3 100644 --- a/packages/lib-utils/src/components/details-page-header/utils/ActionButtons.test.tsx +++ b/packages/lib-utils/src/components/details-page-header/utils/ActionButtons.test.tsx @@ -1,6 +1,5 @@ import { render, screen, fireEvent } from '@testing-library/react'; import React from 'react'; -import '@testing-library/jest-dom'; import { ActionButtons } from './ActionButtons'; const mockCallback = jest.fn(); diff --git a/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.test.tsx b/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.test.tsx index 874d48ba..458c2d4e 100644 --- a/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.test.tsx +++ b/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.test.tsx @@ -1,6 +1,5 @@ import { render, screen, fireEvent } from '@testing-library/react'; import React from 'react'; -import '@testing-library/jest-dom'; import { ActionMenu } from './ActionMenu'; const mockCallback = jest.fn(); diff --git a/packages/lib-utils/src/components/details-page-header/utils/Breadcrumbs.test.tsx b/packages/lib-utils/src/components/details-page-header/utils/Breadcrumbs.test.tsx index a13246a3..1da97780 100644 --- a/packages/lib-utils/src/components/details-page-header/utils/Breadcrumbs.test.tsx +++ b/packages/lib-utils/src/components/details-page-header/utils/Breadcrumbs.test.tsx @@ -2,7 +2,6 @@ import { render, screen, fireEvent } from '@testing-library/react'; import React from 'react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; -import '@testing-library/jest-dom'; import type { BreadcrumbsProps } from './Breadcrumbs'; import { Breadcrumbs } from './Breadcrumbs'; diff --git a/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.test.tsx b/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.test.tsx index 054bc4ec..3a5b21d3 100644 --- a/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.test.tsx +++ b/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.test.tsx @@ -1,6 +1,5 @@ import { render, screen, fireEvent } from '@testing-library/react'; import React from 'react'; -import '@testing-library/jest-dom'; import { BrowserRouter } from 'react-router-dom'; import type { Tab } from './HorizontalNav'; import { HorizontalNav, HorizontalNavTabs } from './HorizontalNav'; From 535f6e1bdb1c0753af1a9f9576056601a8ff5c4b Mon Sep 17 00:00:00 2001 From: Kim Doberstein Date: Mon, 10 Oct 2022 15:17:14 -0500 Subject: [PATCH 09/17] Add Resource Summary --- .../details-page/DetailsPage.stories.tsx | 67 ++++--------------- 1 file changed, 13 insertions(+), 54 deletions(-) diff --git a/packages/lib-utils/src/components/details-page/DetailsPage.stories.tsx b/packages/lib-utils/src/components/details-page/DetailsPage.stories.tsx index 71bbfc0a..92422c29 100644 --- a/packages/lib-utils/src/components/details-page/DetailsPage.stories.tsx +++ b/packages/lib-utils/src/components/details-page/DetailsPage.stories.tsx @@ -1,19 +1,11 @@ /* eslint-disable react/jsx-props-no-spreading */ -import { - TextContent, - Text, - TextVariants, - DescriptionList, - DescriptionListTerm, - DescriptionListGroup, - DescriptionListDescription, - Label, -} from '@patternfly/react-core'; +import { TextContent, Text, TextVariants } from '@patternfly/react-core'; import { CheckCircleIcon } from '@patternfly/react-icons'; import type { ComponentStory, ComponentMeta } from '@storybook/react'; import React from 'react'; import { MemoryRouter, Routes, Route, Link } from 'react-router-dom'; import type { K8sResourceCommon } from '../../types/k8s'; +import { OverviewPage } from '../resource-summary'; import { DetailsPage } from './DetailsPage'; const meta: ComponentMeta = { @@ -58,52 +50,10 @@ const mockWorkspace: K8sResourceCommon = { label1: 'value1', label2: 'value2', }, + creationTimestamp: '2022-09-15T21:07:32Z', }, }; -export const SampleOverview: React.FC = () => ( - - - Name - demo-workspace - - - Members - Alex and 3 others - - - Services - - - - - - APIs - - 21 kinds - - - - Labels - - - - - - Applications - - - Demo app,Billing app - - - - -); - export const PlaceholderComponent: React.FC<{ value: string }> = ({ value }) => ( {value} @@ -113,7 +63,16 @@ export const PlaceholderComponent: React.FC<{ value: string }> = ({ value }) => Primary.args = { ariaLabel: 'Workspace details', tabs: [ - { key: 'overview', title: 'Overview', content: , ariaLabel: 'Overview' }, + { + key: 'overview', + title: 'Overview', + content: ( + Right column content}> + Additional content + + ), + ariaLabel: 'Overview', + }, { key: 'applications', title: 'Applications', From 0c82ee9a63d28d508e238afdc823a3107ccf0535 Mon Sep 17 00:00:00 2001 From: Kim Doberstein Date: Mon, 24 Oct 2022 22:15:48 -0500 Subject: [PATCH 10/17] Add action buttons to list view --- .../utils/ActionButtons.tsx | 58 ++++++++++++------- 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/packages/lib-utils/src/components/details-page-header/utils/ActionButtons.tsx b/packages/lib-utils/src/components/details-page-header/utils/ActionButtons.tsx index 7dbb86f3..3cf645c6 100644 --- a/packages/lib-utils/src/components/details-page-header/utils/ActionButtons.tsx +++ b/packages/lib-utils/src/components/details-page-header/utils/ActionButtons.tsx @@ -3,41 +3,57 @@ import * as _ from 'lodash-es'; import React from 'react'; export type ActionButtonProp = { - label: string; + id?: string; + label?: string; callback: (event: React.MouseEvent) => void; isDisabled?: boolean; tooltip?: string; + variant?: 'primary' | 'secondary' | 'tertiary' | 'link'; }; export type ActionButtonsProps = { actionButtons: ActionButtonProp[]; }; +const ActionButton: React.FC = ({ + id, + children, + callback, + isDisabled, + tooltip, + variant = 'primary', +}) => { + const tooltipRef = React.useRef(); + return ( + <> + + {tooltip ? : null} + + ); +}; + export const ActionButtons: React.SFC = ({ actionButtons }) => ( {_.map(actionButtons, (actionButton, i) => { if (!_.isEmpty(actionButton)) { return ( - - {actionButton.tooltip ? ( - - - - ) : ( - - )} + + + {actionButton.label} + ); } From 0710c90a58ab42e8e6dbf7e4ba5b12c20b5a9fa5 Mon Sep 17 00:00:00 2001 From: Vojtech Szocs Date: Mon, 24 Oct 2022 19:25:35 +0200 Subject: [PATCH 11/17] Use API Extractor on distributable packages --- .../details-page-header/DetailsPageHeader.tsx | 2 +- .../src/components/details-page-header/index.ts | 12 +++++++++++- .../details-page-header/utils/ActionMenu.tsx | 6 ++---- .../components/details-page-header/utils/index.ts | 8 +++++--- .../lib-utils/src/components/details-page/index.ts | 1 + .../src/components/horizontal-nav/HorizontalNav.tsx | 2 +- .../lib-utils/src/components/horizontal-nav/index.ts | 1 + 7 files changed, 22 insertions(+), 10 deletions(-) create mode 100644 packages/lib-utils/src/components/details-page/index.ts diff --git a/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.tsx b/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.tsx index 70e7ffd6..481ac59d 100644 --- a/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.tsx +++ b/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.tsx @@ -7,7 +7,7 @@ import { Label, DropdownPosition, } from '@patternfly/react-core'; -import * as _ from 'lodash'; +import * as _ from 'lodash-es'; import React from 'react'; import type { K8sResourceCommon } from '../../types/k8s'; import type { BreadcrumbProp, ActionButtonProp, ActionMenuProps } from './utils'; diff --git a/packages/lib-utils/src/components/details-page-header/index.ts b/packages/lib-utils/src/components/details-page-header/index.ts index c318aa51..07be0844 100644 --- a/packages/lib-utils/src/components/details-page-header/index.ts +++ b/packages/lib-utils/src/components/details-page-header/index.ts @@ -1 +1,11 @@ -export { DetailsPageHeader, DetailsPageHeaderProps } from './DetailsPageHeader'; +export { DetailsPageHeader, DetailsPageHeaderProps, PageHeading } from './DetailsPageHeader'; +export { + Action, + ActionButtonProp, + ActionCTA, + ActionMenuOptions, + ActionMenuProps, + ActionMenuVariant, + GroupedActions, + BreadcrumbProp, +} from './utils'; diff --git a/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.tsx b/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.tsx index 01d405c3..82e24c0d 100644 --- a/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.tsx +++ b/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.tsx @@ -1,4 +1,4 @@ -import type { EitherNotBoth } from '@monorepo/common'; +import type { EitherNotBoth } from '@openshift/dynamic-plugin-sdk'; import { Dropdown, DropdownPosition, @@ -24,9 +24,7 @@ export type Action = { label: React.ReactNode; /** Optional subtext for the menu item */ description?: string; - /** Executable callback or href. - * External links should automatically provide an external link icon on action. - * */ + /** Executable callback or href. External links should automatically provide an external link icon on action. */ cta: ActionCTA; /** Optional flag to indicate whether the action is disabled. */ isDisabled?: boolean; diff --git a/packages/lib-utils/src/components/details-page-header/utils/index.ts b/packages/lib-utils/src/components/details-page-header/utils/index.ts index d6266f94..20f0311f 100644 --- a/packages/lib-utils/src/components/details-page-header/utils/index.ts +++ b/packages/lib-utils/src/components/details-page-header/utils/index.ts @@ -1,9 +1,11 @@ export { Breadcrumbs, BreadcrumbProp, BreadcrumbsProps } from './Breadcrumbs'; export { ActionButtons, ActionButtonProp, ActionButtonsProps } from './ActionButtons'; export { - ActionMenu, Action, - GroupedActions, - ActionMenuVariant, + ActionCTA, + ActionMenu, + ActionMenuOptions, ActionMenuProps, + ActionMenuVariant, + GroupedActions, } from './ActionMenu'; diff --git a/packages/lib-utils/src/components/details-page/index.ts b/packages/lib-utils/src/components/details-page/index.ts new file mode 100644 index 00000000..0f7d02b3 --- /dev/null +++ b/packages/lib-utils/src/components/details-page/index.ts @@ -0,0 +1 @@ +export { DetailsPage, DetailsPageProps } from './DetailsPage'; diff --git a/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.tsx b/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.tsx index ac4760ab..74623820 100644 --- a/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.tsx +++ b/packages/lib-utils/src/components/horizontal-nav/HorizontalNav.tsx @@ -14,7 +14,7 @@ type Tab = { ariaLabel: string; }; -type WithRouterProps = { +export type WithRouterProps = { /** URL parameters */ params?: Record; /** Navigate function */ diff --git a/packages/lib-utils/src/components/horizontal-nav/index.ts b/packages/lib-utils/src/components/horizontal-nav/index.ts index e9568029..bc315850 100644 --- a/packages/lib-utils/src/components/horizontal-nav/index.ts +++ b/packages/lib-utils/src/components/horizontal-nav/index.ts @@ -4,4 +4,5 @@ export { Tab, HorizontalNavProps, withRouter, + WithRouterProps, } from './HorizontalNav'; From cfb2f849a651c882dc001ee7f7cd99d959edf9ef Mon Sep 17 00:00:00 2001 From: Bryan Florkiewicz Date: Fri, 27 Jan 2023 12:12:00 -0500 Subject: [PATCH 12/17] Pull AnyObject and EitherNotBoth helpers from monorepo common --- .../src/components/details-page-header/utils/ActionMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.tsx b/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.tsx index 82e24c0d..c7a05cec 100644 --- a/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.tsx +++ b/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.tsx @@ -1,4 +1,4 @@ -import type { EitherNotBoth } from '@openshift/dynamic-plugin-sdk'; +import type { EitherNotBoth } from '@monorepo/common'; import { Dropdown, DropdownPosition, From 59fd2c1ebaf991a782303cd88cb13c9269b25c9e Mon Sep 17 00:00:00 2001 From: Bryan Florkiewicz Date: Fri, 27 Jan 2023 15:17:50 -0500 Subject: [PATCH 13/17] Revert "Merge pull request #196 from florkbr/update-lib-utils-to-use-monorepo-common" This reverts commit a406b8675ec8f3181848452985a1d4d66da903dd, reversing changes made to 364a2d740a5daa1c5528af118e7fd3d4dff64ba7. --- .../src/components/details-page-header/utils/ActionMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.tsx b/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.tsx index c7a05cec..82e24c0d 100644 --- a/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.tsx +++ b/packages/lib-utils/src/components/details-page-header/utils/ActionMenu.tsx @@ -1,4 +1,4 @@ -import type { EitherNotBoth } from '@monorepo/common'; +import type { EitherNotBoth } from '@openshift/dynamic-plugin-sdk'; import { Dropdown, DropdownPosition, From 0b8b5f108a69e6ddf91c4f7b85e354217a21591b Mon Sep 17 00:00:00 2001 From: Vojtech Szocs Date: Wed, 5 Apr 2023 17:35:14 +0200 Subject: [PATCH 14/17] Support CommonJS build for all dist packages --- .../details-page-header/DetailsPageHeader.tsx | 10 +++++----- .../details-page-header/utils/ActionButtons.tsx | 6 +++--- .../details-page-header/utils/ActionMenu.tsx | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.tsx b/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.tsx index 481ac59d..84a80e3a 100644 --- a/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.tsx +++ b/packages/lib-utils/src/components/details-page-header/DetailsPageHeader.tsx @@ -7,7 +7,7 @@ import { Label, DropdownPosition, } from '@patternfly/react-core'; -import * as _ from 'lodash-es'; +import { isEmpty } from 'lodash'; import React from 'react'; import type { K8sResourceCommon } from '../../types/k8s'; import type { BreadcrumbProp, ActionButtonProp, ActionMenuProps } from './utils'; @@ -59,7 +59,7 @@ export const DetailsPageHeader: React.SFC = ({
{/** Icon for details page heading (before title) */} - {pageHeading && !_.isEmpty(pageHeading?.iconBeforeTitle) && ( + {pageHeading && !isEmpty(pageHeading?.iconBeforeTitle) && ( {pageHeading?.iconBeforeTitle} @@ -73,11 +73,11 @@ export const DetailsPageHeader: React.SFC = ({ )} {/** Icon for details page heading (after title) */} - {pageHeading && !_.isEmpty(pageHeading?.iconAfterTitle) && ( + {pageHeading && !isEmpty(pageHeading?.iconAfterTitle) && ( {pageHeading?.iconAfterTitle} )} {/* Optional details page heading label */} - {!_.isEmpty(pageHeading) && !_.isEmpty(pageHeading?.label) && ( + {!isEmpty(pageHeading) && !isEmpty(pageHeading?.label) && (