diff --git a/frontend/src/components/Rbac.tsx b/frontend/src/components/Rbac.tsx index 798aa3e79be..357908d0403 100644 --- a/frontend/src/components/Rbac.tsx +++ b/frontend/src/components/Rbac.tsx @@ -2,9 +2,11 @@ import { makeStyles } from '@mui/styles' import { createSubjectAccessReview, ResourceAttributes } from '../resources' -import { AcmButton, AcmDropdown } from '../ui-components' -import { useEffect, useState } from 'react' +import { AcmButton, AcmDropdown, AcmDropdownItems } from '../ui-components' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from '../lib/acm-i18next' +import { Actions } from '../routes/Infrastructure/Clusters/ManagedClusters/CreateCluster/components/assisted-installer/hypershift/common/common' +import { DropdownPosition } from '@patternfly/react-core' type RbacDropdownProps = { actions: Actions[] @@ -14,17 +16,23 @@ type RbacDropdownProps = { id: string isDisabled?: boolean tooltip?: string + dropdownPosition?: DropdownPosition } -type Actions = { - id: string - text: React.ReactNode - isAriaDisabled?: boolean - tooltip?: string - click: (item: T) => void +type Actions = AcmDropdownItems & { + flyoutMenu?: Actions[] + click?: (item: T) => void rbac?: ResourceAttributes[] | Promise[] } +function flattenAction(action: Actions): Actions | Actions[] { + return action.flyoutMenu ? flattenActions(action.flyoutMenu) : action +} + +function flattenActions(actions: Actions[]): Actions[] { + return actions.flatMap(flattenAction) +} + export function RbacDropdown(props: RbacDropdownProps) { const { t } = useTranslation() const [actions, setActions] = useState[]>([]) @@ -36,10 +44,19 @@ export function RbacDropdown(props: RbacDropdownProps) { } }, [actions, props.actions]) - const onSelect = (id: string) => { - const action = props.actions.find((a) => a.id === id) - return action?.click(props.item) - } + const actionsWithFlyoutActions = useMemo(() => { + return flattenActions(actions) + }, [actions]) + + const onSelect = useCallback( + (id: string) => { + const action = actionsWithFlyoutActions.find((a) => a.id === id) + if (action?.click) { + action.click(props.item) + } + }, + [actionsWithFlyoutActions, props.item] + ) const onToggle = async (isOpen?: boolean) => { if (isOpen) { @@ -75,6 +92,7 @@ export function RbacDropdown(props: RbacDropdownProps) { id={props.id} onSelect={onSelect} dropdownItems={actions} + dropdownPosition={props.dropdownPosition} isKebab={props.isKebab} isPlain={true} text={props.text} diff --git a/frontend/src/routes/Applications/Overview.tsx b/frontend/src/routes/Applications/Overview.tsx index f1f5dae0039..b3e2918eaea 100644 --- a/frontend/src/routes/Applications/Overview.tsx +++ b/frontend/src/routes/Applications/Overview.tsx @@ -1,6 +1,6 @@ /* Copyright Contributors to the Open Cluster Management project */ -import { PageSection, Text, TextContent, TextVariants } from '@patternfly/react-core' +import { DropdownPosition, PageSection, Text, TextContent, TextVariants } from '@patternfly/react-core' import { ExternalLinkAltIcon } from '@patternfly/react-icons' import { cellWidth } from '@patternfly/react-table' import { @@ -981,20 +981,17 @@ export default function ApplicationsOverview() { id: 'create-argo', text: t('Application set'), isDisabled: false, - path: NavigationPath.createApplicationArgo, }, { id: 'create-subscription', text: t('Subscription'), isDisabled: false, - path: NavigationPath.createApplicationSubscription, }, ]} isKebab={false} isPlain={false} isPrimary={true} - // tooltipPosition={tableDropdown.tooltipPosition} - // dropdownPosition={DropdownPosition.left} + dropdownPosition={DropdownPosition.left} /> ), [canCreateApplication, history, t] diff --git a/frontend/src/routes/Governance/components/PolicyActionDropdown.tsx b/frontend/src/routes/Governance/components/PolicyActionDropdown.tsx index e8aa2db58b5..4164ee66eab 100644 --- a/frontend/src/routes/Governance/components/PolicyActionDropdown.tsx +++ b/frontend/src/routes/Governance/components/PolicyActionDropdown.tsx @@ -74,159 +74,175 @@ export function PolicyActionDropdown(props: { rbac: [rbacPatch(PolicyDefinition, item.policy.metadata.namespace)], }, { - id: 'enable-policy', - text: t('Enable'), - tooltip: item.policy.spec.disabled ? t('Enable policy') : t('Policy is already enabled'), - isAriaDisabled: item.policy.spec.disabled === false, - click: (item: PolicyTableItem) => { - setModalProps({ - open: true, - title: t('policy.modal.title.enable'), - action: t('policy.table.actions.enable'), - processing: t('policy.table.actions.enabling'), - items: [item], - emptyState: undefined, // there is always 1 item supplied - description: t('policy.modal.message.enable'), - columns: bulkModalStatusColumns, - keyFn: (item: PolicyTableItem) => item.policy.metadata.uid as string, - actionFn: (item: PolicyTableItem) => { - return patchResource( - { - apiVersion: PolicyApiVersion, - kind: PolicyKind, - metadata: { - name: item.policy.metadata.name, - namespace: item.policy.metadata.namespace, + id: 'status-policy', + text: t('Status'), + rbac: [rbacPatch(PolicyDefinition, item.policy.metadata.namespace)], + flyoutMenu: [ + { + id: 'enable-policy', + text: t('Enable'), + tooltip: item.policy.spec.disabled ? t('Enable policy') : t('Policy is already enabled'), + isSelected: !item.policy.spec.disabled, + click: (item: PolicyTableItem) => { + if (item.policy.spec.disabled) { + setModalProps({ + open: true, + title: t('policy.modal.title.enable'), + action: t('policy.table.actions.enable'), + processing: t('policy.table.actions.enabling'), + items: [item], + emptyState: undefined, // there is always 1 item supplied + description: t('policy.modal.message.enable'), + columns: bulkModalStatusColumns, + keyFn: (item: PolicyTableItem) => item.policy.metadata.uid as string, + actionFn: (item: PolicyTableItem) => { + return patchResource( + { + apiVersion: PolicyApiVersion, + kind: PolicyKind, + metadata: { + name: item.policy.metadata.name, + namespace: item.policy.metadata.namespace, + }, + } as Policy, + [{ op: 'replace', path: '/spec/disabled', value: false }] + ) }, - } as Policy, - [{ op: 'replace', path: '/spec/disabled', value: false }] - ) - }, - close: () => { - setModalProps({ open: false }) - }, - hasExternalResources: item.source !== 'Local', - }) - }, - rbac: item.policy.spec.disabled ? [rbacPatch(PolicyDefinition, item.policy.metadata.namespace)] : undefined, - }, - { - id: 'disable-policy', - text: t('policy.table.actions.disable'), - tooltip: item.policy.spec.disabled ? t('Policy is already disabled') : t('Disable policy'), - isAriaDisabled: item.policy.spec.disabled === true, - click: (item: PolicyTableItem) => { - setModalProps({ - open: true, - title: t('policy.modal.title.disable'), - action: t('policy.table.actions.disable'), - processing: t('policy.table.actions.disabling'), - items: [item], - emptyState: undefined, // there is always 1 item supplied - description: t('policy.modal.message.disable'), - columns: bulkModalStatusColumns, - keyFn: (item: PolicyTableItem) => item.policy.metadata.uid as string, - actionFn: (item) => { - return patchResource( - { - apiVersion: PolicyApiVersion, - kind: PolicyKind, - metadata: { - name: item.policy.metadata.name, - namespace: item.policy.metadata.namespace, + close: () => { + setModalProps({ open: false }) }, - } as Policy, - [{ op: 'replace', path: '/spec/disabled', value: true }] - ) - }, - close: () => { - setModalProps({ open: false }) + hasExternalResources: item.source !== 'Local', + }) + } }, - hasExternalResources: item.source !== 'Local', - }) - }, - rbac: item.policy.spec.disabled ? undefined : [rbacPatch(PolicyDefinition, item.policy.metadata.namespace)], - }, - { - id: 'inform-policy', - text: t('policy.table.actions.inform'), - tooltip: policyRemediationAction === 'inform' ? t('Already informing') : t('Inform policy'), - addSeparator: true, - isAriaDisabled: policyRemediationAction === 'inform', - click: (item: PolicyTableItem) => { - setModalProps({ - open: true, - title: t('policy.modal.title.inform'), - action: t('policy.table.actions.inform'), - processing: t('policy.table.actions.informing'), - items: [item], - emptyState: undefined, // there is always 1 item supplied - description: t('policy.modal.message.inform'), - columns: bulkModalRemediationColumns, - keyFn: (item: PolicyTableItem) => item.policy.metadata.uid as string, - actionFn: (item) => { - return patchResource( - { - apiVersion: PolicyApiVersion, - kind: PolicyKind, - metadata: { - name: item.policy.metadata.name, - namespace: item.policy.metadata.namespace, + }, + { + id: 'disable-policy', + text: t('policy.table.actions.disable'), + tooltip: item.policy.spec.disabled ? t('Policy is already disabled') : t('Disable policy'), + isSelected: item.policy.spec.disabled, + click: (item: PolicyTableItem) => { + if (!item.policy.spec.disabled) { + setModalProps({ + open: true, + title: t('policy.modal.title.disable'), + action: t('policy.table.actions.disable'), + processing: t('policy.table.actions.disabling'), + items: [item], + emptyState: undefined, // there is always 1 item supplied + description: t('policy.modal.message.disable'), + columns: bulkModalStatusColumns, + keyFn: (item: PolicyTableItem) => item.policy.metadata.uid as string, + actionFn: (item) => { + return patchResource( + { + apiVersion: PolicyApiVersion, + kind: PolicyKind, + metadata: { + name: item.policy.metadata.name, + namespace: item.policy.metadata.namespace, + }, + } as Policy, + [{ op: 'replace', path: '/spec/disabled', value: true }] + ) }, - } as Policy, - [{ op: 'replace', path: '/spec/remediationAction', value: 'inform' }] - ) - }, - close: () => { - setModalProps({ open: false }) + close: () => { + setModalProps({ open: false }) + }, + hasExternalResources: item.source !== 'Local', + }) + } }, - hasExternalResources: item.source !== 'Local', - }) - }, - rbac: - policyRemediationAction === 'inform' - ? undefined - : [rbacPatch(PolicyDefinition, item.policy.metadata.namespace)], + }, + ], }, { - id: 'enforce-policy', - text: t('policy.table.actions.enforce'), - tooltip: policyRemediationAction === 'enforce' ? t('Already enforcing') : t('Enforce policy'), - isAriaDisabled: policyRemediationAction === 'enforce', - click: (item: PolicyTableItem) => { - setModalProps({ - open: true, - title: t('policy.modal.title.enforce'), - action: t('policy.table.actions.enforce'), - processing: t('policy.table.actions.enforcing'), - items: [item], - emptyState: undefined, // there is always 1 item supplied - description: t('policy.modal.message.enforce'), - columns: bulkModalRemediationColumns, - keyFn: (item: PolicyTableItem) => item.policy.metadata.uid as string, - actionFn: (item) => { - return patchResource( - { - apiVersion: PolicyApiVersion, - kind: PolicyKind, - metadata: { - name: item.policy.metadata.name, - namespace: item.policy.metadata.namespace, + id: 'remediation-policy', + text: t('Remediation'), + rbac: [rbacPatch(PolicyDefinition, item.policy.metadata.namespace)], + flyoutMenu: [ + { + id: 'inform-policy', + text: t('policy.table.actions.inform'), + tooltip: policyRemediationAction === 'inform' ? t('Already informing') : t('Inform policy'), + addSeparator: true, + isSelected: policyRemediationAction === 'inform', + click: (item: PolicyTableItem) => { + if (policyRemediationAction !== 'inform') { + setModalProps({ + open: true, + title: t('policy.modal.title.inform'), + action: t('policy.table.actions.inform'), + processing: t('policy.table.actions.informing'), + items: [item], + emptyState: undefined, // there is always 1 item supplied + description: t('policy.modal.message.inform'), + columns: bulkModalRemediationColumns, + keyFn: (item: PolicyTableItem) => item.policy.metadata.uid as string, + actionFn: (item) => { + return patchResource( + { + apiVersion: PolicyApiVersion, + kind: PolicyKind, + metadata: { + name: item.policy.metadata.name, + namespace: item.policy.metadata.namespace, + }, + } as Policy, + [{ op: 'replace', path: '/spec/remediationAction', value: 'inform' }] + ) + }, + close: () => { + setModalProps({ open: false }) }, - } as Policy, - [{ op: 'replace', path: '/spec/remediationAction', value: 'enforce' }] - ) + hasExternalResources: item.source !== 'Local', + }) + } }, - close: () => { - setModalProps({ open: false }) + rbac: + policyRemediationAction === 'inform' + ? undefined + : [rbacPatch(PolicyDefinition, item.policy.metadata.namespace)], + }, + { + id: 'enforce-policy', + text: t('policy.table.actions.enforce'), + tooltip: policyRemediationAction === 'enforce' ? t('Already enforcing') : t('Enforce policy'), + isSelected: policyRemediationAction === 'enforce', + click: (item: PolicyTableItem) => { + if (policyRemediationAction !== 'enforce') { + setModalProps({ + open: true, + title: t('policy.modal.title.enforce'), + action: t('policy.table.actions.enforce'), + processing: t('policy.table.actions.enforcing'), + items: [item], + emptyState: undefined, // there is always 1 item supplied + description: t('policy.modal.message.enforce'), + columns: bulkModalRemediationColumns, + keyFn: (item: PolicyTableItem) => item.policy.metadata.uid as string, + actionFn: (item) => { + return patchResource( + { + apiVersion: PolicyApiVersion, + kind: PolicyKind, + metadata: { + name: item.policy.metadata.name, + namespace: item.policy.metadata.namespace, + }, + } as Policy, + [{ op: 'replace', path: '/spec/remediationAction', value: 'enforce' }] + ) + }, + close: () => { + setModalProps({ open: false }) + }, + hasExternalResources: item.source !== 'Local', + }) + } }, - hasExternalResources: item.source !== 'Local', - }) - }, - rbac: - policyRemediationAction === 'enforce' - ? undefined - : [rbacPatch(PolicyDefinition, item.policy.metadata.namespace)], + }, + ], }, { id: 'edit-policy', diff --git a/frontend/src/routes/Infrastructure/Clusters/ManagedClusters/components/ClusterActionDropdown.tsx b/frontend/src/routes/Infrastructure/Clusters/ManagedClusters/components/ClusterActionDropdown.tsx index 87ed5a8141c..eca70cdcc47 100644 --- a/frontend/src/routes/Infrastructure/Clusters/ManagedClusters/components/ClusterActionDropdown.tsx +++ b/frontend/src/routes/Infrastructure/Clusters/ManagedClusters/components/ClusterActionDropdown.tsx @@ -1,6 +1,6 @@ /* Copyright Contributors to the Open Cluster Management project */ -import { Text, TextContent, TextVariants } from '@patternfly/react-core' +import { DropdownPosition, Text, TextContent, TextVariants } from '@patternfly/react-core' import { AcmInlineProvider } from '../../../../../ui-components' import { useContext, useMemo, useState } from 'react' import { useHistory } from 'react-router' @@ -410,6 +410,7 @@ export function ClusterActionDropdown(props: { cluster: Cluster; isKebab: boolea isKebab={props.isKebab} text={t('actions')} actions={actions} + dropdownPosition={DropdownPosition.right} /> )} downloadConfig(id)} text={t('configuration.download')} id="download-configuration" + dropdownPosition={DropdownPosition.right} /> ) } diff --git a/frontend/src/ui-components/AcmDropdown/AcmDropdown.stories.tsx b/frontend/src/ui-components/AcmDropdown/AcmDropdown.stories.tsx index 796a42bc841..9cf6b647d5d 100644 --- a/frontend/src/ui-components/AcmDropdown/AcmDropdown.stories.tsx +++ b/frontend/src/ui-components/AcmDropdown/AcmDropdown.stories.tsx @@ -22,7 +22,7 @@ export const Dropdown = (args: any) => { { id: 'other-config', text: 'Other config', isAriaDisabled: true, tooltip: 'Forbidden' }, { id: 'launch-out', text: 'Launch page', icon: }, { id: 'link item', text: 'Link item', href: 'www.google.com', component: 'a' }, - { id: 'new-feature', text: 'New feature', label: 'Technology Preview', labelColor: 'orange' }, + { id: 'new-feature', text: 'New feature', label: 'Technology Preview', labelColor: 'orange' as const }, ] const onSelect = (id: string) => alert(`clicked: ${id}`) return ( diff --git a/frontend/src/ui-components/AcmDropdown/AcmDropdown.tsx b/frontend/src/ui-components/AcmDropdown/AcmDropdown.tsx index bc97545efc4..8bb2056fbc6 100644 --- a/frontend/src/ui-components/AcmDropdown/AcmDropdown.tsx +++ b/frontend/src/ui-components/AcmDropdown/AcmDropdown.tsx @@ -1,21 +1,26 @@ /* Copyright Contributors to the Open Cluster Management project */ -import { useState } from 'react' +import { forwardRef, useCallback, useEffect, useRef, useState } from 'react' import { - Dropdown, - DropdownToggle, - DropdownItem, DropdownPosition, - KebabToggle, Label, LabelProps, DropdownProps, TooltipPosition, + Menu, + MenuContent, + MenuItem, + Tooltip, + MenuProps, + MenuList, + Popper, + MenuToggle, } from '@patternfly/react-core' -import { makeStyles } from '@mui/styles' +import { ClassNameMap, makeStyles } from '@mui/styles' import { TooltipWrapper } from '../utils' +import { EllipsisVIcon } from '@patternfly/react-icons' -type Props = Omit +type Props = Omit export type AcmDropdownProps = Props & { dropdownItems: AcmDropdownItems[] @@ -50,23 +55,16 @@ export type AcmDropdownItems = { icon?: React.ReactNode tooltipPosition?: TooltipPosition label?: string - labelColor?: string + labelColor?: LabelProps['color'] + isSelected?: boolean + flyoutMenu?: AcmDropdownItems[] } const useStyles = makeStyles({ button: { '& button': { - backgroundColor: (props: AcmDropdownProps) => { - if (!props.isKebab) { - if (props.isDisabled) { - return 'var(--pf-global--disabled-color--200)' - } else if (!props.isDisabled && props.isPrimary) { - return 'var(--pf-c-dropdown__toggle--BackgroundColor)' - } else { - return 'transparent' - } - } - return undefined + '--pf-c-menu-toggle--PaddingRight': (props: AcmDropdownProps) => { + return props.isPlain ? '0' : 'var(--pf-global--spacer--sm)' }, '& span': { color: (props: AcmDropdownProps) => { @@ -80,26 +78,6 @@ const useStyles = makeStyles({ return 'var(--pf-global--primary-color--100)' }, }, - '&:hover, &:focus': { - '& span': { - color: (props: AcmDropdownProps) => (props.isKebab ? undefined : 'var(--pf-global--primary-color--100)'), - }, - '& span.pf-c-dropdown__toggle-text': { - color: (props: AcmDropdownProps) => props.isPrimary && 'var(--pf-global--Color--light-100)', - }, - '& span.pf-c-dropdown__toggle-icon': { - color: (props: AcmDropdownProps) => props.isPrimary && 'var(--pf-global--Color--light-100)', - }, - }, - '& span.pf-c-dropdown__toggle-text': { - // centers dropdown text in plain dropdown button - paddingLeft: (props: AcmDropdownProps) => { - if (props.isPlain) { - return '8px' - } - return undefined - }, - }, }, }, label: { @@ -107,71 +85,166 @@ const useStyles = makeStyles({ }, }) +type MenuItemProps = { + menuItems: AcmDropdownItems[] + onSelect: MenuProps['onSelect'] + classes: ClassNameMap<'label'> +} & MenuProps + +const MenuItems = forwardRef((props, ref) => { + const { menuItems, onSelect, classes, ...menuProps } = props + return ( + mi.flyoutMenu)} {...menuProps}> + + + {menuItems.map((item) => { + const menuItem = ( + + ) : undefined + } + > + {item.text} + {item.label && item.labelColor && ( + + )} + + ) + return item.tooltip ? ( + +
{menuItem}
+
+ ) : ( + menuItem + ) + })} +
+
+
+ ) +}) + export function AcmDropdown(props: AcmDropdownProps) { + const { + dropdownItems, + dropdownPosition, + id, + isDisabled, + isKebab, + isPlain, + isPrimary, + onHover, + onSelect, + onToggle, + text, + tooltip, + tooltipPosition, + } = props const [isOpen, setOpen] = useState(false) + const popperContainer = useRef(null) + const toggleRef = useRef(null) + const menuRef = useRef(null) const classes = useStyles(props) - const onSelect = (id: string) => { - props.onSelect(id) - setOpen(false) - const element = document.getElementById(props.id) - /* istanbul ignore else */ - if (element) element.focus() - } + const toggleMenu = useCallback(() => { + if (onToggle) { + onToggle(!isOpen) + } + setOpen(!isOpen) + }, [isOpen, onToggle]) + + const handleSelect = useCallback( + (_event?: React.MouseEvent, itemId?: string | number) => { + onSelect((itemId || '').toString()) + setOpen(false) + const element = document.getElementById(id) + /* istanbul ignore else */ + if (element) element.focus() + }, + [id, onSelect] + ) + + const handleToggleClick = useCallback(() => { + setTimeout(() => { + if (menuRef.current) { + const firstElement = menuRef.current.querySelector('li > button:not(:disabled), li > a:not(:disabled)') + firstElement && (firstElement as HTMLElement).focus() + } + }, 0) + toggleMenu() + }, [toggleMenu]) + + const handleMenuKeys = useCallback( + (event: KeyboardEvent) => { + if (!isOpen) { + return + } + if (menuRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)) { + if (event.key === 'Escape' || event.key === 'Tab') { + toggleMenu() + toggleRef.current?.focus() + } + } + }, + [isOpen, toggleMenu] + ) + + const handleClickOutside = useCallback( + (event: MouseEvent) => { + if ( + isOpen && + !toggleRef.current?.contains(event.target as Node) && + !menuRef.current?.contains(event.target as Node) + ) { + toggleMenu() + } + }, + [isOpen, toggleMenu] + ) + + useEffect(() => { + window.addEventListener('keydown', handleMenuKeys) + window.addEventListener('click', handleClickOutside) + return () => { + window.removeEventListener('keydown', handleMenuKeys) + window.removeEventListener('click', handleClickOutside) + } + }, [handleMenuKeys, handleClickOutside]) return ( - - ( - onSelect(item.id)} - > - {item.text} - {item.label && item.labelColor && ( - - )} - - ))} - toggle={ - props.isKebab ? ( - { - /* istanbul ignore next */ - if (props.onToggle) { - props.onToggle(!isOpen) - } - setOpen(!isOpen) - }} - /> - ) : ( - { - if (props.onToggle) { - props.onToggle(!isOpen) - } - setOpen(!isOpen) - }} + +
+ - {props.text} - - ) - } - isOpen={isOpen} - isPlain={props.isPlain} - /> + {isKebab ? : text} + + } + popperMatchesTriggerWidth={false} + isVisible={isOpen} + position={dropdownPosition || (isKebab ? DropdownPosition.right : DropdownPosition.left)} + popper={} + /> +
) }