diff --git a/frontend/packages/integration-tests-cypress/tests/cluster-settings/alertmanager/alertmanager.cy.ts b/frontend/packages/integration-tests-cypress/tests/cluster-settings/alertmanager/alertmanager.cy.ts index 5a616f90141c..7762cc44aff9 100644 --- a/frontend/packages/integration-tests-cypress/tests/cluster-settings/alertmanager/alertmanager.cy.ts +++ b/frontend/packages/integration-tests-cypress/tests/cluster-settings/alertmanager/alertmanager.cy.ts @@ -105,7 +105,7 @@ receivers: .within(() => { cy.get('[data-test-id="kebab-button"]').click(); }); - cy.get('[data-test-action="Delete Receiver"]').should('be.disabled'); + cy.get('[data-test-action="Delete Receiver"] button').should('be.disabled'); alertmanager.reset(); }); diff --git a/frontend/packages/pipelines-plugin/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebarHeader.tsx b/frontend/packages/pipelines-plugin/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebarHeader.tsx index a48af2f64323..c924713eb397 100644 --- a/frontend/packages/pipelines-plugin/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebarHeader.tsx +++ b/frontend/packages/pipelines-plugin/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebarHeader.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { ActionsMenu } from '@console/internal/components/utils'; +import { ActionsMenu } from '@console/internal/components/utils/actions-menu'; import { PageHeading } from '@console/shared/src/components/heading/PageHeading'; import { TaskKind } from '../../../../types'; import PipelineResourceRef from '../../../shared/common/PipelineResourceRef'; diff --git a/frontend/packages/topology/src/components/side-bar/TopologyEdgePanel.tsx b/frontend/packages/topology/src/components/side-bar/TopologyEdgePanel.tsx index da5a7b507e5c..37ff1afabd37 100644 --- a/frontend/packages/topology/src/components/side-bar/TopologyEdgePanel.tsx +++ b/frontend/packages/topology/src/components/side-bar/TopologyEdgePanel.tsx @@ -4,7 +4,8 @@ import * as React from 'react'; import { Edge, isNode, Node } from '@patternfly/react-topology'; import { useTranslation } from 'react-i18next'; -import { ActionsMenu, SimpleTabNav } from '@console/internal/components/utils'; +import { SimpleTabNav } from '@console/internal/components/utils'; +import { ActionsMenu } from '@console/internal/components/utils/actions-menu'; import { PageHeading } from '@console/shared/src/components/heading/PageHeading'; import { edgeActions } from '../../actions/edgeActions'; import { TYPE_TRAFFIC_CONNECTOR } from '../../const'; diff --git a/frontend/public/components/utils/__tests__/kebab.spec.tsx b/frontend/public/components/utils/__tests__/kebab.spec.tsx index 99f9abf276f6..d81d705262d7 100644 --- a/frontend/public/components/utils/__tests__/kebab.spec.tsx +++ b/frontend/public/components/utils/__tests__/kebab.spec.tsx @@ -18,21 +18,21 @@ describe('KebabItem', () => { const nothingOption = { ...mockOption, href: undefined }; const trackOnClick = jest.fn(); const renderItem = render(); - fireEvent.click(renderItem.getByRole('button')); + fireEvent.click(renderItem.getByRole('menuitem')); expect(trackOnClick).toHaveBeenCalledTimes(0); }); it('should enable when option has href', () => { const hrefOption = { ...mockOption }; const trackOnClick = jest.fn(); const renderItem = render(); - fireEvent.click(renderItem.getByRole('button')); + fireEvent.click(renderItem.getByRole('menuitem')); expect(trackOnClick).toHaveBeenCalledTimes(1); }); it('should enable when option has a callback', () => { const callbackOption = { ...mockOption, href: undefined, callback: () => {} }; const trackOnClick = jest.fn(); const renderItem = render(); - fireEvent.click(renderItem.getByRole('button')); + fireEvent.click(renderItem.getByRole('menuitem')); expect(trackOnClick).toHaveBeenCalledTimes(1); }); }); @@ -55,7 +55,7 @@ describe('KebabItemAccessReview_', () => { impersonate={mockImpersonate} />, ); - fireEvent.click(renderItem.getByRole('button')); + fireEvent.click(renderItem.getByRole('menuitem')); expect(trackOnClick).toHaveBeenCalledTimes(0); }); it('should enable option when option.accessReview present and allowed', () => { @@ -69,7 +69,7 @@ describe('KebabItemAccessReview_', () => { impersonate={mockImpersonate} />, ); - fireEvent.click(renderItem.getByRole('button')); + fireEvent.click(renderItem.getByRole('menuitem')); expect(trackOnClick).toHaveBeenCalledTimes(1); }); }); diff --git a/frontend/public/components/utils/_kebab.scss b/frontend/public/components/utils/_kebab.scss deleted file mode 100644 index 0315eccd5de5..000000000000 --- a/frontend/public/components/utils/_kebab.scss +++ /dev/null @@ -1,21 +0,0 @@ -.oc-kebab { - &__popper-items { - position: initial !important; - } - - &__sub.pf-v6-c-dropdown__menu-item { - --pf-v6-c-dropdown__menu-item--PaddingRight: var(--pf-t--global--spacer--lg); - - position: relative; - } - - &__arrow { - position: absolute; - right: var(--pf-t--global--spacer--xs); - top: 50%; - transform: translateY(-50%); - } -} -.oc-kebab__icon { - margin-right: calc(2 * var(--pf-c-dropdown__toggle-icon--MarginRight)); -} diff --git a/frontend/public/components/utils/actions-menu.tsx b/frontend/public/components/utils/actions-menu.tsx new file mode 100644 index 000000000000..498a33bebb71 --- /dev/null +++ b/frontend/public/components/utils/actions-menu.tsx @@ -0,0 +1,98 @@ +import { + ImpersonateKind, + impersonateStateToProps, + useSafetyFirst, +} from '@console/dynamic-plugin-sdk'; +import { Dropdown, MenuToggle, MenuToggleElement } from '@patternfly/react-core'; +import { some } from 'lodash-es'; +import { useTranslation } from 'react-i18next'; +import { connect } from 'react-redux'; +import { useNavigate } from 'react-router-dom-v5-compat'; + +import { ReactNode, RefObject, useEffect, useState } from 'react'; +import { KebabItems, KebabOption } from './kebab'; +import { checkAccess } from './rbac'; + +type ActionsMenuProps = { + actions: KebabOption[]; + title?: ReactNode; +}; + +const ActionsMenuDropdown = (props) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [active, setActive] = useState(!!props.active); + + const onClick = (event, option) => { + event.preventDefault(); + + if (option.callback) { + option.callback(); + } + + if (option.href) { + navigate(option.href); + } + + setActive(false); + }; + + return ( + setActive(false)} + onOpenChange={setActive} + shouldFocusToggleOnSelect + popperProps={{ + enableFlip: true, + position: 'right', + }} + toggle={(toggleRef: RefObject) => ( + setActive(!active)} + isExpanded={active} + data-test-id="actions-menu-button" + > + {props.title || t('public~Actions')} + + )} + > + + + ); +}; + +export const ActionsMenu: React.FCC = connect(impersonateStateToProps)( + ({ + actions, + impersonate, + title = undefined, + }: ActionsMenuProps & { impersonate?: ImpersonateKind }) => { + const [isVisible, setVisible] = useSafetyFirst(false); + + // Check if any actions are visible when actions have access reviews. + useEffect(() => { + if (!actions.length) { + setVisible(false); + return; + } + const promises = actions.reduce((acc, action) => { + if (action.accessReview) { + acc.push(checkAccess(action.accessReview)); + } + return acc; + }, []); + + // Only need to resolve if all actions require access review + if (promises.length !== actions.length) { + setVisible(true); + return; + } + Promise.all(promises) + .then((results) => setVisible(some(results, 'status.allowed'))) + .catch(() => setVisible(true)); + }, [actions, impersonate, setVisible]); + return isVisible ? : null; + }, +); diff --git a/frontend/public/components/utils/dropdown.jsx b/frontend/public/components/utils/dropdown.jsx index d9dac95074c3..11e22d811182 100644 --- a/frontend/public/components/utils/dropdown.jsx +++ b/frontend/public/components/utils/dropdown.jsx @@ -2,18 +2,12 @@ import * as _ from 'lodash-es'; import * as React from 'react'; import classNames from 'classnames'; import * as PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { useTranslation, withTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom-v5-compat'; -import { Divider, Popper, Title } from '@patternfly/react-core'; -import { impersonateStateToProps, useSafetyFirst } from '@console/dynamic-plugin-sdk'; import { useUserSettingsCompatibility } from '@console/shared'; +import { Divider, Popper, Title } from '@patternfly/react-core'; import { CaretDownIcon } from '@patternfly/react-icons/dist/esm/icons/caret-down-icon'; import { CheckIcon } from '@patternfly/react-icons/dist/esm/icons/check-icon'; import { StarIcon } from '@patternfly/react-icons/dist/esm/icons/star-icon'; - -import { checkAccess } from './rbac'; -import { KebabItems } from './kebab'; +import { withTranslation } from 'react-i18next'; class DropdownMixin extends React.PureComponent { constructor(props) { @@ -678,147 +672,3 @@ Dropdown.propTypes = { required: PropTypes.bool, dataTest: PropTypes.string, }; - -const ActionsMenuDropdown = (props) => { - const { t } = useTranslation(); - const navigate = useNavigate(); - const [active, setActive] = React.useState(!!props.active); - - const dropdownElement = React.useRef(); - - const show = () => { - setActive(true); - }; - - const hide = (e) => { - e?.stopPropagation(); - setActive(false); - }; - - const listener = React.useCallback( - (event) => { - if (!active) { - return; - } - - const { current } = dropdownElement; - if (!current) { - return; - } - - if (event.target === current || current.contains(event.target)) { - return; - } - - hide(event); - }, - [active, dropdownElement], - ); - - React.useEffect(() => { - if (active) { - window.addEventListener('click', listener); - } else { - window.removeEventListener('click', listener); - } - return () => { - window.removeEventListener('click', listener); - }; - }, [active, listener]); - - const toggle = (e) => { - e.preventDefault(); - - if (props.disabled) { - return; - } - - if (active) { - hide(e); - } else { - show(e); - } - }; - - const onClick = (event, option) => { - event.preventDefault(); - - if (option.callback) { - option.callback(); - } - - if (option.href) { - navigate(option.href); - } - - hide(); - }; - - return ( -
- - {active && ( -
- -
- )} -
- ); -}; - -const ActionsMenu_ = ({ actions, impersonate, title = undefined }) => { - const [isVisible, setVisible] = useSafetyFirst(false); - - // Check if any actions are visible when actions have access reviews. - React.useEffect(() => { - if (!actions.length) { - setVisible(false); - return; - } - const promises = actions.reduce((acc, action) => { - if (action.accessReview) { - acc.push(checkAccess(action.accessReview)); - } - return acc; - }, []); - - // Only need to resolve if all actions require access review - if (promises.length !== actions.length) { - setVisible(true); - return; - } - Promise.all(promises) - .then((results) => setVisible(_.some(results, 'status.allowed'))) - .catch(() => setVisible(true)); - }, [actions, impersonate, setVisible]); - return isVisible ? : null; -}; - -export const ActionsMenu = connect(impersonateStateToProps)(ActionsMenu_); - -ActionsMenu.propTypes = { - actions: PropTypes.arrayOf( - PropTypes.shape({ - label: PropTypes.node, - labelKey: PropTypes.string, - href: PropTypes.string, - callback: PropTypes.func, - accessReview: PropTypes.object, - }), - ).isRequired, - title: PropTypes.node, -}; diff --git a/frontend/public/components/utils/headings.tsx b/frontend/public/components/utils/headings.tsx index 817a6882b98a..ea26f81732c3 100644 --- a/frontend/public/components/utils/headings.tsx +++ b/frontend/public/components/utils/headings.tsx @@ -8,9 +8,10 @@ import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { PageHeading, PageHeadingProps } from '@console/shared/src/components/heading/PageHeading'; +import { ActionsMenu } from '@console/internal/components/utils/actions-menu'; import { connectToModel } from '../../kinds'; import { K8sKind, K8sResourceKind, K8sResourceKindReference } from '../../module/k8s'; -import { ActionsMenu, FirehoseResult, KebabOption, ResourceIcon } from './index'; +import { FirehoseResult, KebabOption, ResourceIcon } from './index'; import { ManagedByOperatorLink } from './managed-by'; export const ResourceItemDeleting = () => { diff --git a/frontend/public/components/utils/index.tsx b/frontend/public/components/utils/index.tsx index 67a13f9feb98..df5ad59319dd 100644 --- a/frontend/public/components/utils/index.tsx +++ b/frontend/public/components/utils/index.tsx @@ -53,7 +53,6 @@ export * from './dom-utils'; export * from './owner-references'; export { default } from './operator-backed-owner-references'; export * from './field-level-help'; -export * from './single-typeahead-dropdown'; export * from './details-item'; export * from './types'; export * from './release-notes-link'; diff --git a/frontend/public/components/utils/kebab.tsx b/frontend/public/components/utils/kebab.tsx index 8ebd031cf600..ed98874b72ad 100644 --- a/frontend/public/components/utils/kebab.tsx +++ b/frontend/public/components/utils/kebab.tsx @@ -1,18 +1,20 @@ -/* eslint-disable @typescript-eslint/no-use-before-define */ import * as _ from 'lodash-es'; import * as React from 'react'; -import classNames from 'classnames'; import { connect } from 'react-redux'; -/* eslint-disable import/named */ import { useTranslation } from 'react-i18next'; import i18next from 'i18next'; -import { KeyTypes, Tooltip, FocusTrap } from '@patternfly/react-core'; -import { AngleRightIcon } from '@patternfly/react-icons/dist/esm/icons/angle-right-icon'; +import { + Dropdown, + DropdownItem, + DropdownList, + MenuToggle, + MenuToggleElement, + Tooltip, +} from '@patternfly/react-core'; import { EllipsisVIcon } from '@patternfly/react-icons/dist/esm/icons/ellipsis-v-icon'; import { useNavigate } from 'react-router-dom-v5-compat'; import { subscribeToExtensions } from '@console/plugin-sdk/src/api/pluginSubscriptionService'; import { KebabActions, isKebabActions } from '@console/plugin-sdk/src/typings/kebab-actions'; -import Popper from '@console/shared/src/components/popper/Popper'; import { HelmChartRepositoryModel, ProjectHelmChartRepositoryModel, @@ -50,9 +52,10 @@ import { RouteModel, VolumeSnapshotModel, } from '../../models'; +import { ContextSubMenuItem } from '@patternfly/react-topology'; export const kebabOptionsToMenu = (options: KebabOption[]): KebabMenuOption[] => { - const subs: { [key: string]: KebabSubMenu } = {}; + const subs: { [key: string]: KebabSubMenuOption } = {}; const menuOptions: KebabMenuOption[] = []; options.forEach((o) => { @@ -91,37 +94,21 @@ export const kebabOptionsToMenu = (options: KebabOption[]): KebabMenuOption[] => const KebabItem_: React.FC = ({ option, onClick, - onEscape, autoFocus, isAllowed, }) => { const { t } = useTranslation(); - const ref = React.useRef(null); - const disabled = !isAllowed || option.isDisabled || (!option.href && !option.callback); - React.useEffect(() => { - const parentNode = ref.current.parentNode; //
  • - disabled - ? parentNode.classList.add('pf-m-disabled') - : parentNode.classList.remove('pf-m-disabled'); - }, [disabled]); - const handleEscape = (e) => { - if (e.keyCode === KeyTypes.Escape) { - onEscape(); - } - }; + const isDisabled = !isAllowed || option.isDisabled || (!option.href && !option.callback); return ( - + ); }; export const KebabItemAccessReview_ = ( @@ -134,99 +121,9 @@ export const KebabItemAccessReview_ = ( const KebabItemAccessReview = connect(impersonateStateToProps)(KebabItemAccessReview_); -type KebabSubMenuProps = { - option: KebabSubMenu; - onClick: KebabItemProps['onClick']; -}; - -const KebabSubMenu: React.FC = ({ option, onClick }) => { - const { t } = useTranslation(); - const [open, setOpen] = React.useState(false); - const nodeRef = React.useRef(null); - const subMenuRef = React.useRef(null); - const referenceCb = React.useCallback(() => nodeRef.current, []); - // use a callback ref because FocusTrap is old and doesn't support non-function refs - const subMenuCbRef = React.useCallback((node) => (subMenuRef.current = node), []); - - return ( - <> - - { - // only close the sub menu if clicking anywhere outside the menu item that owns the sub menu - if (!e || !nodeRef.current || !nodeRef.current.contains(e.target as Node)) { - setOpen(false); - } - }} - reference={referenceCb} - > - subMenuRef.current, // fallback to popover content wrapper div if there are no tabbable elements - }} - > -
    { - // only close the sub menu if the mouse does not enter the item - if (!nodeRef.current || !nodeRef.current.contains(e.relatedTarget as Node)) { - setOpen(false); - } - }} - onKeyDown={(e) => { - // close the sub menu on left arrow - if (e.keyCode === 37) { - setOpen(false); - e.stopPropagation(); - } - }} - > - -
    -
    -
    - - ); -}; - -export const isKebabSubMenu = (option: KebabMenuOption): option is KebabSubMenu => { +export const isKebabSubMenu = (option: KebabMenuOption): option is KebabSubMenuOption => { // only a sub menu has children - return Array.isArray((option as KebabSubMenu).children); + return Array.isArray((option as KebabSubMenuOption).children); }; export const KebabItem: React.FC = (props) => { @@ -256,31 +153,32 @@ type KebabMenuItemsProps = { className?: string; }; -export const KebabMenuItems: React.FC = ({ - className, - options, - onClick, - focusItem, -}) => ( -
      - {_.map(options, (o, index) => ( -
    • - {isKebabSubMenu(o) ? ( - +export const KebabMenuItems: React.FC = ({ options, onClick, focusItem }) => { + const { t } = useTranslation(); + + return ( + + {_.map(options, (o, index) => + isKebabSubMenu(o) ? ( + + + <>{/* ContextSubMenuItem expects ReactNode[] only */} + ) : ( - )} -
    • - ))} -
    -); + ), + )} + + ); +}; export const KebabItems: React.FC = ({ options, ...props }) => { const menuOptions = kebabOptionsToMenu(options); @@ -471,39 +369,20 @@ export const getExtensionsKebabActionsForKind = (kind: K8sKind) => { return actionsForKind; }; -export const ResourceKebab = connectToModel((props: ResourceKebabProps) => { - const { t } = useTranslation(); - const { actions, kindObj, resource, isDisabled, customData, terminatingTooltip } = props; - - if (!kindObj) { - return null; - } - const options = _.reject( - actions.map((a) => a(kindObj, resource, null, customData)), - 'hidden', - ); - return ( - - ); -}); - export const Kebab: KebabComponent = (props) => { const { t } = useTranslation(); const navigate = useNavigate(); const { options, isDisabled, terminatingTooltip } = props; - const dropdownElement = React.useRef(); - const divElement = React.useRef(); const [active, setActive] = React.useState(false); + const hide = () => { + setActive(false); + }; + + const toggle = () => { + setActive((prev) => !prev); + }; + const onClick = (event, option: KebabOption) => { event.preventDefault(); if (option.callback) { @@ -515,15 +394,6 @@ export const Kebab: KebabComponent = (props) => { } }; - const hide = () => { - dropdownElement.current && dropdownElement.current.focus(); - setActive(false); - }; - - const toggle = () => { - setActive((prev) => !prev); - }; - const onHover = () => { // Check access when hovering over a kebab to minimize flicker when opened. // This depends on `checkAccess` being memoized. @@ -537,16 +407,6 @@ export const Kebab: KebabComponent = (props) => { }); }; - const handleRequestClose = (e?: MouseEvent) => { - if (!e || !dropdownElement.current || !dropdownElement.current.contains(e.target as Node)) { - hide(); - } - }; - - const getPopperReference = () => dropdownElement.current; - - const getDivReference = () => divElement.current; - const menuOptions = kebabOptionsToMenu(options); return ( @@ -554,54 +414,32 @@ export const Kebab: KebabComponent = (props) => { content={terminatingTooltip} trigger={isDisabled && terminatingTooltip ? 'mouseenter' : 'manual'} > -
    ) => ( + } + /> + )} + shouldFocusToggleOnSelect > - - - -
    - -
    -
    -
    -
    + + ); }; @@ -609,6 +447,31 @@ Kebab.factory = kebabFactory; Kebab.columnClass = 'pf-v6-c-table__action'; Kebab.getExtensionsActionsForKind = getExtensionsKebabActionsForKind; +export const ResourceKebab = connectToModel((props: ResourceKebabProps) => { + const { t } = useTranslation(); + const { actions, kindObj, resource, isDisabled, customData, terminatingTooltip } = props; + + if (!kindObj) { + return null; + } + const options = _.reject( + actions.map((a) => a(kindObj, resource, null, customData)), + 'hidden', + ); + return ( + + ); +}); + export type KebabOption = { hidden?: boolean; label?: React.ReactNode; @@ -644,14 +507,13 @@ export type ResourceKebabProps = { terminatingTooltip?: string; }; -// eslint-disable-next-line no-redeclare -type KebabSubMenu = { +type KebabSubMenuOption = { label?: string; labelKey?: string; children: KebabMenuOption[]; }; -export type KebabMenuOption = KebabSubMenu | KebabOption; +export type KebabMenuOption = KebabSubMenuOption | KebabOption; type KebabProps = { options: KebabOption[]; @@ -665,7 +527,6 @@ type KebabItemProps = { option: KebabOption; onClick: (event: React.MouseEvent<{}>, option: KebabOption) => void; autoFocus?: boolean; - onEscape?: () => void; }; export type KebabItemsProps = { diff --git a/frontend/public/style.scss b/frontend/public/style.scss index 862f94282251..2a46cfd6dcbd 100644 --- a/frontend/public/style.scss +++ b/frontend/public/style.scss @@ -41,7 +41,6 @@ @import 'components/utils/copy-to-clipboard'; @import 'components/utils/disabled'; @import 'components/utils/file-input'; -@import 'components/utils/kebab'; @import 'components/utils/label'; @import 'components/utils/number-spinner'; @import 'components/utils/request-size-input';