From 4c948996a894ef263a5989ea5208ca11e50f80e2 Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Tue, 29 Jun 2021 02:54:24 +0530 Subject: [PATCH 1/3] Use ActionGroup extensions to create groups and submenus --- .../actions/__tests__/menu-utils-test-data.ts | 132 ++++++++++++++++++ .../actions/__tests__/menu-utils.spec.ts | 43 ++++++ .../{ => loader}/ActionsHookResolver.tsx | 0 .../actions/{ => loader}/ActionsLoader.tsx | 28 +++- .../src/components/actions/menu/menu-types.ts | 18 +++ .../src/components/actions/menu/menu-utils.ts | 72 ++++++++++ 6 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 frontend/packages/console-shared/src/components/actions/__tests__/menu-utils-test-data.ts create mode 100644 frontend/packages/console-shared/src/components/actions/__tests__/menu-utils.spec.ts rename frontend/packages/console-shared/src/components/actions/{ => loader}/ActionsHookResolver.tsx (100%) rename frontend/packages/console-shared/src/components/actions/{ => loader}/ActionsLoader.tsx (69%) create mode 100644 frontend/packages/console-shared/src/components/actions/menu/menu-types.ts create mode 100644 frontend/packages/console-shared/src/components/actions/menu/menu-utils.ts diff --git a/frontend/packages/console-shared/src/components/actions/__tests__/menu-utils-test-data.ts b/frontend/packages/console-shared/src/components/actions/__tests__/menu-utils-test-data.ts new file mode 100644 index 00000000000..c3e77f98210 --- /dev/null +++ b/frontend/packages/console-shared/src/components/actions/__tests__/menu-utils-test-data.ts @@ -0,0 +1,132 @@ +import { Action, ActionGroup } from '@console/dynamic-plugin-sdk'; +import { LoadedExtension } from '@console/plugin-sdk'; + +export const mockActions: Action[] = [ + { + id: 'mock-action-1', + label: 'Mock Action 1', + path: '$top', + cta: { + href: '/mock-href-1', + }, + }, + { + id: 'mock-action-2', + label: 'Mock Action 2', + path: 'common-1', + cta: { + href: '/mock-href-2', + }, + }, + { + id: 'mock-action-3', + label: 'Mock Action 3', + cta: { + href: '/mock-href-3', + }, + }, + { + id: 'mock-action-4', + label: 'Mock Action 4', + path: 'common-2', + cta: { + href: '/mock-href-4', + }, + }, + { + id: 'mock-action-5', + label: 'Mock Action 5', + path: 'common-1/child-1', + cta: { + href: '/mock-href-5', + }, + }, + { + id: 'mock-action-6', + label: 'Mock Action 6', + path: '$bottom', + cta: { + href: '/mock-href-6', + }, + }, +]; + +export const mockActionGroups: LoadedExtension[] = [ + { + type: 'console.action/group', + properties: { + id: 'common-1', + label: 'Common Group 1', + submenu: true, + }, + flags: { + required: [], + disallowed: [], + }, + pluginID: '@console/helm-plugin', + pluginName: '@console/helm-plugin', + uid: '@console/helm-plugin[15]', + }, + { + type: 'console.action/group', + properties: { + id: 'common-2', + label: 'Common Group 2', + insertAfter: 'common-1', + }, + flags: { + required: [], + disallowed: [], + }, + pluginID: '@console/helm-plugin', + pluginName: '@console/helm-plugin', + uid: '@console/helm-plugin[16]', + }, + { + type: 'console.action/group', + properties: { + id: 'child-1', + label: 'Child Group 1', + submenu: true, + }, + flags: { + required: [], + disallowed: [], + }, + pluginID: '@console/helm-plugin', + pluginName: '@console/helm-plugin', + uid: '@console/helm-plugin[17]', + }, +]; + +export const mockMenuOptions = [ + { + id: '$top', + children: [mockActions[0]], + }, + { + id: 'common-1', + label: 'Common Group 1', + submenu: true, + children: [ + mockActions[1], + { + id: 'child-1', + label: 'Child Group 1', + submenu: true, + children: [mockActions[4]], + }, + ], + }, + mockActions[2], + { + id: 'common-2', + label: 'Common Group 2', + insertAfter: 'common-1', + children: [mockActions[3]], + }, + { + id: '$bottom', + children: [mockActions[5]], + }, +]; diff --git a/frontend/packages/console-shared/src/components/actions/__tests__/menu-utils.spec.ts b/frontend/packages/console-shared/src/components/actions/__tests__/menu-utils.spec.ts new file mode 100644 index 00000000000..f631a6aca81 --- /dev/null +++ b/frontend/packages/console-shared/src/components/actions/__tests__/menu-utils.spec.ts @@ -0,0 +1,43 @@ +import { MenuOptionType } from '../menu/menu-types'; +import { getMenuOptionType, createMenuOptions } from '../menu/menu-utils'; +import { mockActionGroups, mockActions, mockMenuOptions } from './menu-utils-test-data'; + +describe('Menu utils', () => { + it('should create menu options using groups extensions', () => { + const menuOptions = createMenuOptions(mockActions, mockActionGroups); + + expect(menuOptions).toEqual(mockMenuOptions); + }); + + it('should not create any groups if no actions have path for provided action group extensions', () => { + const actions = [mockActions[2]]; + const menuOptions = createMenuOptions(actions, mockActionGroups); + + expect(menuOptions).toEqual(actions); + }); + + it('should not create top and bottom groups if no actions have their paths', () => { + const actions = mockActions.slice(1, -1); + const menuOptions = createMenuOptions(actions, mockActionGroups); + + expect(menuOptions).toEqual(mockMenuOptions.slice(1, -1)); + }); + + it('should return correct menu option type for group menu option', () => { + const menuOptionType = getMenuOptionType(mockMenuOptions[0]); + + expect(menuOptionType).toBe(MenuOptionType.GROUP_MENU); + }); + + it('should return correct menu option type for sub menu option', () => { + const menuOptionType = getMenuOptionType(mockMenuOptions[1]); + + expect(menuOptionType).toBe(MenuOptionType.SUB_MENU); + }); + + it('should return correct menu option type for atomic menu option', () => { + const menuOptionType = getMenuOptionType(mockMenuOptions[2]); + + expect(menuOptionType).toBe(MenuOptionType.ATOMIC_MENU); + }); +}); diff --git a/frontend/packages/console-shared/src/components/actions/ActionsHookResolver.tsx b/frontend/packages/console-shared/src/components/actions/loader/ActionsHookResolver.tsx similarity index 100% rename from frontend/packages/console-shared/src/components/actions/ActionsHookResolver.tsx rename to frontend/packages/console-shared/src/components/actions/loader/ActionsHookResolver.tsx diff --git a/frontend/packages/console-shared/src/components/actions/ActionsLoader.tsx b/frontend/packages/console-shared/src/components/actions/loader/ActionsLoader.tsx similarity index 69% rename from frontend/packages/console-shared/src/components/actions/ActionsLoader.tsx rename to frontend/packages/console-shared/src/components/actions/loader/ActionsLoader.tsx index 70a551d938d..218b32f270d 100644 --- a/frontend/packages/console-shared/src/components/actions/ActionsLoader.tsx +++ b/frontend/packages/console-shared/src/components/actions/loader/ActionsLoader.tsx @@ -2,18 +2,25 @@ import * as React from 'react'; import * as _ from 'lodash'; import { Action, + ActionGroup, ActionProvider, + isActionGroup, isActionProvider, useResolvedExtensions, } from '@console/dynamic-plugin-sdk'; +import { useExtensions } from '@console/plugin-sdk'; +import { MenuOption } from '../menu/menu-types'; +import { createMenuOptions } from '../menu/menu-utils'; import ActionsHookResolver from './ActionsHookResolver'; type ActionsLoaderProps = { contextId: string; scope: any; - children: (actions: Action[], loaded: boolean, error: any) => React.ReactNode; + children: (loader: Loader) => React.ReactNode; }; +type Loader = { actions: Action[]; options: MenuOption[]; loaded: boolean; error: any }; + const ActionsLoader: React.FC = ({ contextId, scope, children }) => { const [actionsMap, setActionsMap] = React.useState<{ [uid: string]: Action[] }>({}); const [loadError, setLoadError] = React.useState(); @@ -33,12 +40,29 @@ const ActionsLoader: React.FC = ({ contextId, scope, childre actionProviderGuard, ); + const groupExtensions = useExtensions(isActionGroup); + const actionsLoaded = providerExtensionsResolved && (providerExtensions.length === 0 || providerExtensions.every(({ uid }) => actionsMap[uid])); const actions: Action[] = React.useMemo(() => _.flatten(Object.values(actionsMap)), [actionsMap]); + const options: MenuOption[] = React.useMemo(() => createMenuOptions(actions, groupExtensions), [ + actions, + groupExtensions, + ]); + + const loader = React.useMemo( + () => ({ + actions, + options, + loaded: actionsLoaded, + error: loadError, + }), + [actions, actionsLoaded, loadError, options], + ); + return ( <> {providerExtensionsResolved && @@ -51,7 +75,7 @@ const ActionsLoader: React.FC = ({ contextId, scope, childre onValueError={setLoadError} /> ))} - {children(actions, actionsLoaded, loadError)} + {children(loader)} ); }; diff --git a/frontend/packages/console-shared/src/components/actions/menu/menu-types.ts b/frontend/packages/console-shared/src/components/actions/menu/menu-types.ts new file mode 100644 index 00000000000..fcc0ba90e71 --- /dev/null +++ b/frontend/packages/console-shared/src/components/actions/menu/menu-types.ts @@ -0,0 +1,18 @@ +import { Action, ActionGroup } from '@console/dynamic-plugin-sdk'; + +export type MenuOption = Action | GroupedMenuOption; + +export type GroupedMenuOption = ActionGroup['properties'] & { + children?: MenuOption[]; +}; + +export enum MenuOptionType { + GROUP_MENU, + SUB_MENU, + ATOMIC_MENU, +} + +export enum ActionMenuVariant { + KEBAB = 'plain', + DROPDOWN = 'default', +} diff --git a/frontend/packages/console-shared/src/components/actions/menu/menu-utils.ts b/frontend/packages/console-shared/src/components/actions/menu/menu-utils.ts new file mode 100644 index 00000000000..4b06096915f --- /dev/null +++ b/frontend/packages/console-shared/src/components/actions/menu/menu-utils.ts @@ -0,0 +1,72 @@ +import { Action, ActionGroup } from '@console/dynamic-plugin-sdk'; +import { LoadedExtension } from '@console/plugin-sdk'; +import { GroupedMenuOption, MenuOption, MenuOptionType } from './menu-types'; + +export const createMenuOptions = ( + actions: Action[], + groupExtensions: LoadedExtension[], +): MenuOption[] => { + const menuOptions = []; + + // Default menu groups $top and $bottom. + const topGroup = { + id: '$top', + children: [], + }; + const bottomGroup = { + id: '$bottom', + children: [], + }; + + const submenus = { + [topGroup.id]: topGroup, + [bottomGroup.id]: bottomGroup, + }; + const groups = [topGroup, ...groupExtensions.map((group) => group.properties), bottomGroup]; + + actions.forEach((action) => { + if (!action.disabled) { + if (action.path) { + const parts = action.path.split('/'); + parts.forEach((part, index) => { + let subMenu = submenus[part]; + const partGroup = groups.find((group) => group.id === part); + if (partGroup && !submenus[part]) { + subMenu = { ...partGroup, children: [] }; + submenus[part] = subMenu; + if (index === 0) { + menuOptions.push(subMenu); + } else { + submenus[parts[index - 1]].children.push(subMenu); + } + } + }); + submenus[parts[parts.length - 1]].children.push(action); + } else { + menuOptions.push(action); + } + } + }); + + if (topGroup.children.length > 0) menuOptions.unshift(topGroup); + if (bottomGroup.children.length > 0) menuOptions.push(bottomGroup); + + return menuOptions; +}; + +export const getMenuOptionType = (option: MenuOption) => { + // a grouped menu has children + const isGroupMenu = Array.isArray((option as GroupedMenuOption).children); + // a submenu menu has children and submenu property true + const isSubMenu = isGroupMenu && (option as GroupedMenuOption).submenu; + + if (isSubMenu) { + return MenuOptionType.SUB_MENU; + } + + if (isGroupMenu) { + return MenuOptionType.GROUP_MENU; + } + + return MenuOptionType.ATOMIC_MENU; +}; From 41e9007ba171e60d8aab8fd30f9e89f53ec30939 Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Tue, 29 Jun 2021 21:39:19 +0530 Subject: [PATCH 2/3] Created new action menu components using PF menu --- .../locales/en/console-shared.json | 2 +- .../src/components/actions/index.ts | 3 +- .../components/actions/menu/ActionMenu.tsx | 105 +++++++++++ .../actions/menu/ActionMenuContent.tsx | 168 ++++++++++++++++++ .../actions/menu/ActionMenuItem.tsx | 99 +++++++++++ .../src/components/kebab/KebabItem.tsx | 75 -------- .../src/components/kebab/KebabMenu.tsx | 115 ------------ .../src/components/kebab/KebabMenuItems.tsx | 130 -------------- .../src/components/kebab/kebab-types.ts | 9 - .../src/components/kebab/kebab-utils.ts | 40 ----- .../history/HelmReleaseHistoryRow.tsx | 5 +- .../list-page/HelmReleaseListRow.tsx | 7 +- 12 files changed, 381 insertions(+), 377 deletions(-) create mode 100644 frontend/packages/console-shared/src/components/actions/menu/ActionMenu.tsx create mode 100644 frontend/packages/console-shared/src/components/actions/menu/ActionMenuContent.tsx create mode 100644 frontend/packages/console-shared/src/components/actions/menu/ActionMenuItem.tsx delete mode 100644 frontend/packages/console-shared/src/components/kebab/KebabItem.tsx delete mode 100644 frontend/packages/console-shared/src/components/kebab/KebabMenu.tsx delete mode 100644 frontend/packages/console-shared/src/components/kebab/KebabMenuItems.tsx delete mode 100644 frontend/packages/console-shared/src/components/kebab/kebab-types.ts delete mode 100644 frontend/packages/console-shared/src/components/kebab/kebab-utils.ts diff --git a/frontend/packages/console-shared/locales/en/console-shared.json b/frontend/packages/console-shared/locales/en/console-shared.json index aa849bf4be5..1777e434f1a 100644 --- a/frontend/packages/console-shared/locales/en/console-shared.json +++ b/frontend/packages/console-shared/locales/en/console-shared.json @@ -1,4 +1,5 @@ { + "Actions": "Actions", "An error occurred": "An error occurred", "Note: Some fields may not be represented in this form view. Please select \"YAML view\" for full control.": "Note: Some fields may not be represented in this form view. Please select \"YAML view\" for full control.", "No resources found": "No resources found", @@ -108,7 +109,6 @@ "Are you sure you want to remove the {{hpaLabel}}": "Are you sure you want to remove the {{hpaLabel}}", "from": "from", "The resources that are attached to the {{hpaLabel}} will be deleted.": "The resources that are attached to the {{hpaLabel}} will be deleted.", - "Actions": "Actions", "Copy to clipboard": "Copy to clipboard", "Run in Web Terminal": "Run in Web Terminal", "Successfully copied to clipboard!": "Successfully copied to clipboard!", diff --git a/frontend/packages/console-shared/src/components/actions/index.ts b/frontend/packages/console-shared/src/components/actions/index.ts index 09d212d0fa8..200e1259048 100644 --- a/frontend/packages/console-shared/src/components/actions/index.ts +++ b/frontend/packages/console-shared/src/components/actions/index.ts @@ -1 +1,2 @@ -export { default as ActionsLoader } from './ActionsLoader'; +export { default as ActionsLoader } from './loader/ActionsLoader'; +export { default as ActionMenu } from './menu/ActionMenu'; diff --git a/frontend/packages/console-shared/src/components/actions/menu/ActionMenu.tsx b/frontend/packages/console-shared/src/components/actions/menu/ActionMenu.tsx new file mode 100644 index 00000000000..ec35215aed1 --- /dev/null +++ b/frontend/packages/console-shared/src/components/actions/menu/ActionMenu.tsx @@ -0,0 +1,105 @@ +import * as React from 'react'; +import { FocusTrap, MenuToggle } from '@patternfly/react-core'; +import { EllipsisVIcon } from '@patternfly/react-icons'; +import * as _ from 'lodash'; +import { useTranslation } from 'react-i18next'; +import { Action } from '@console/dynamic-plugin-sdk'; +import { checkAccess } from '@console/internal/components/utils'; +import { Popper } from '../../popper'; +import ActionMenuContent from './ActionMenuContent'; +import { ActionMenuVariant, MenuOption } from './menu-types'; + +type ActionMenuProps = { + actions: Action[]; + options?: MenuOption[]; + isDisabled?: boolean; + variant?: ActionMenuVariant; + label?: string; +}; + +const ActionMenu: React.FC = ({ + actions, + options, + isDisabled, + variant = ActionMenuVariant.KEBAB, + label, +}) => { + const { t } = useTranslation(); + const [active, setActive] = React.useState(false); + const toggleRef = React.useRef(); + const toggleRefCb = React.useCallback(() => toggleRef.current, []); + const menuRef = React.useRef(); + const menuRefCb = React.useCallback(() => menuRef.current, []); + const toggleLabel = label || t('console-shared~Actions'); + + const toggleMenu = () => setActive((value) => !value); + + const hideMenu = () => { + toggleRef.current?.focus(); + setActive(false); + }; + + const handleRequestClose = (e?: MouseEvent) => { + if (!e || !toggleRef.current?.contains(e.target as Node)) { + hideMenu(); + } + }; + + const handleHover = React.useCallback(() => { + // Check access when hovering over a kebab to minimize flicker when opened. + // This depends on `checkAccess` being memoized. + _.each(actions, (action: Action) => { + if (action.accessReview) { + checkAccess(action.accessReview); + } + }); + }, [actions]); + + const menuOptions = options || actions; + + return ( +
+ + {variant === ActionMenuVariant.KEBAB ? : toggleLabel} + + + +
+ +
+
+
+
+ ); +}; + +export default ActionMenu; diff --git a/frontend/packages/console-shared/src/components/actions/menu/ActionMenuContent.tsx b/frontend/packages/console-shared/src/components/actions/menu/ActionMenuContent.tsx new file mode 100644 index 00000000000..e36e970cedd --- /dev/null +++ b/frontend/packages/console-shared/src/components/actions/menu/ActionMenuContent.tsx @@ -0,0 +1,168 @@ +import * as React from 'react'; +import { + FocusTrap, + MenuContent, + MenuGroup, + MenuItem, + MenuItemAction, + MenuList, +} from '@patternfly/react-core'; +import { AngleRightIcon } from '@patternfly/react-icons'; +import { Action } from '@console/dynamic-plugin-sdk'; +import { orderExtensionBasedOnInsertBeforeAndAfter } from '@console/shared'; +import { Popper } from '../../popper'; +import ActionMenuItem from './ActionMenuItem'; +import { GroupedMenuOption, MenuOption, MenuOptionType } from './menu-types'; +import { getMenuOptionType } from './menu-utils'; + +type GroupMenuContentProps = { + option: GroupedMenuOption; + onClick: () => void; +}; + +const GroupMenuContent: React.FC = ({ option, onClick }) => ( + + + +); + +// Need to keep this in the same file to avoid circular dependency. +const SubMenuContent: React.FC = ({ option, onClick }) => { + const [open, setOpen] = React.useState(false); + const nodeRef = React.useRef(null); + const nodeRefCb = React.useCallback(() => nodeRef.current, []); + const subMenuRef = React.useRef(null); + // use a callback ref because FocusTrap is old and doesn't support non-function refs + const subMenuRefCb = React.useCallback(() => subMenuRef.current, []); + + // mouse enter will open the sub menu + const handleNodeMouseEnter = () => setOpen(true); + + const handleNodeMouseLeave = (e) => { + // if the mouse leaves this item, close the sub menu only if the mouse did not enter the sub menu itself + if (!subMenuRef.current || !subMenuRef.current.contains(e.relatedTarget as Node)) { + setOpen(false); + } + }; + + const handleNodeKeyDown = (e) => { + // open the sub menu on enter or right arrow + if (e.keyCode === 39 || e.keyCode === 13) { + setOpen(true); + e.stopPropagation(); + } + }; + + const handlePopperRequestClose = (e) => { + // 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); + } + }; + + const handleMenuMouseLeave = (e) => { + // 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); + } + }; + + const handleMenuKeyDown = (e) => { + // close the sub menu on left arrow + if (e.keyCode === 37) { + setOpen(false); + e.stopPropagation(); + } + }; + + return ( + <> + } />} + onMouseEnter={handleNodeMouseEnter} + onMouseLeave={handleNodeMouseLeave} + onKeyDown={handleNodeKeyDown} + data-test-action={option.id} + tabIndex={0} + translate="no" + > + {option.label} + + + +
+ +
+
+
+ + ); +}; + +type ActionMenuContentProps = { + options: MenuOption[]; + onClick: () => void; + focusItem?: MenuOption; +}; + +const ActionMenuContent: React.FC = ({ options, onClick, focusItem }) => { + const sortedOptions = orderExtensionBasedOnInsertBeforeAndAfter(options); + return ( + + + {sortedOptions.map((option) => { + const optionType = getMenuOptionType(option); + switch (optionType) { + case MenuOptionType.SUB_MENU: + return ( + + ); + case MenuOptionType.GROUP_MENU: + return ( + + ); + default: + return ( + + ); + } + })} + + + ); +}; + +export default ActionMenuContent; diff --git a/frontend/packages/console-shared/src/components/actions/menu/ActionMenuItem.tsx b/frontend/packages/console-shared/src/components/actions/menu/ActionMenuItem.tsx new file mode 100644 index 00000000000..f1e8e3f5cc0 --- /dev/null +++ b/frontend/packages/console-shared/src/components/actions/menu/ActionMenuItem.tsx @@ -0,0 +1,99 @@ +import * as React from 'react'; +import { KEY_CODES, MenuItem, Tooltip } from '@patternfly/react-core'; +import * as classNames from 'classnames'; +import * as _ from 'lodash'; +import { connect } from 'react-redux'; +import { Action } from '@console/dynamic-plugin-sdk'; +import { useAccessReview, history } from '@console/internal/components/utils'; +import { impersonateStateToProps } from '@console/internal/reducers/ui'; + +export type ActionMenuItemProps = { + action: Action; + autoFocus?: boolean; + onClick?: () => void; + onEscape?: () => void; +}; + +const ActionItem: React.FC = ({ + action, + onClick, + onEscape, + autoFocus, + isAllowed, +}) => { + const { id, label, icon, disabled, cta } = action; + const { href, external } = cta as { href: string; external?: boolean }; + const isDisabled = !isAllowed || disabled; + const classes = classNames({ 'pf-m-disabled': isDisabled }); + + const handleClick = React.useCallback( + (event) => { + event.preventDefault(); + if (_.isFunction(cta)) { + cta(); + } else if (_.isObject(cta)) { + if (!cta.external) { + history.push(cta.href); + } + } + onClick && onClick(); + }, + [cta, onClick], + ); + + const handleKeyDown = (event) => { + if (event.keyCode === KEY_CODES.ESCAPE_KEY) { + onEscape && onEscape(); + } + + if (event.keyCode === KEY_CODES.ENTER) { + handleClick(event); + } + }; + + return ( + + {label} + + ); +}; + +const AccessReviewActionItem = connect(impersonateStateToProps)( + (props: ActionMenuItemProps & { impersonate: string }) => { + const { action, impersonate } = props; + const isAllowed = useAccessReview(action.accessReview, impersonate); + return ; + }, +); + +const ActionMenuItem: React.FC = (props) => { + const { action } = props; + let item; + + if (action.accessReview) { + item = ; + } else { + item = ; + } + + return action.tooltip ? ( + + {item} + + ) : ( + item + ); +}; + +export default ActionMenuItem; diff --git a/frontend/packages/console-shared/src/components/kebab/KebabItem.tsx b/frontend/packages/console-shared/src/components/kebab/KebabItem.tsx deleted file mode 100644 index df3c592a21f..00000000000 --- a/frontend/packages/console-shared/src/components/kebab/KebabItem.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import * as React from 'react'; -import { KEY_CODES, Tooltip } from '@patternfly/react-core'; -import * as classNames from 'classnames'; -import { connect } from 'react-redux'; -import { Action } from '@console/dynamic-plugin-sdk'; -import { useAccessReview } from '@console/internal/components/utils'; -import { impersonateStateToProps } from '@console/internal/reducers/ui'; - -export type KebabItemProps = { - option: Action; - onClick: (event: React.MouseEvent<{}>, action: Action) => void; - autoFocus?: boolean; - onEscape?: () => void; -}; - -const KebabItemButton: React.FC = ({ - option, - onClick, - onEscape, - autoFocus, - isAllowed, -}) => { - const handleEscape = (e) => { - if (e.keyCode === KEY_CODES.ESCAPE_KEY) { - onEscape(); - } - }; - const disabled = !isAllowed || option.disabled; - const classes = classNames('pf-c-dropdown__menu-item', { 'pf-m-disabled': disabled }); - return ( - - ); -}; - -// eslint-disable-next-line no-underscore-dangle -const KebabItemAccessReview_ = (props: KebabItemProps & { impersonate: string }) => { - const { option, impersonate } = props; - const isAllowed = useAccessReview(option.accessReview, impersonate); - return ; -}; - -const KebabItemAccessReview = connect(impersonateStateToProps)(KebabItemAccessReview_); - -const KebabItem: React.FC = (props) => { - const { option } = props; - let item; - - if (option.accessReview) { - item = ; - } else { - item = ; - } - - return option.tooltip ? ( - - {item} - - ) : ( - item - ); -}; - -export default KebabItem; diff --git a/frontend/packages/console-shared/src/components/kebab/KebabMenu.tsx b/frontend/packages/console-shared/src/components/kebab/KebabMenu.tsx deleted file mode 100644 index 3e286a0a993..00000000000 --- a/frontend/packages/console-shared/src/components/kebab/KebabMenu.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import * as React from 'react'; -import { FocusTrap } from '@patternfly/react-core'; -import { EllipsisVIcon } from '@patternfly/react-icons'; -import * as classNames from 'classnames'; -import * as _ from 'lodash'; -import { useTranslation } from 'react-i18next'; -import { Action } from '@console/dynamic-plugin-sdk'; -import { checkAccess, history } from '@console/internal/components/utils'; -import { Popper } from '../popper'; -import { kebabActionsToMenu } from './kebab-utils'; -import KebabMenuItems from './KebabMenuItems'; - -type KebabMenuProps = { - actions: Action[]; - isDisabled?: boolean; -}; - -const KebabMenu: React.FC = ({ actions, isDisabled }) => { - const [active, setActive] = React.useState(false); - const dropdownRef = React.useRef(); - const { t } = useTranslation(); - - const hide = () => { - dropdownRef.current?.focus(); - setActive(false); - }; - - const toggle = () => { - setActive((value) => !value); - }; - - const handleClick = (event, action: Action) => { - event.preventDefault(); - - if (_.isFunction(action.cta)) { - action.cta(); - hide(); - } else if (_.isObject(action.cta)) { - const { href, external } = action.cta; - if (external) { - window.open(href); - } else { - history.push(href); - } - } - }; - - const handleHover = () => { - // Check access when hovering over a kebab to minimize flicker when opened. - // This depends on `checkAccess` being memoized. - _.each(actions, (action: Action) => { - if (action.accessReview) { - checkAccess(action.accessReview); - } - }); - }; - - const handleRequestClose = (e?: MouseEvent) => { - if (!e || !dropdownRef.current?.contains(e.target as Node)) { - hide(); - } - }; - - const getPopperReference = () => dropdownRef.current; - - const menuOptions = kebabActionsToMenu(actions); - - return ( -
- - - -
- -
-
-
-
- ); -}; - -export default KebabMenu; diff --git a/frontend/packages/console-shared/src/components/kebab/KebabMenuItems.tsx b/frontend/packages/console-shared/src/components/kebab/KebabMenuItems.tsx deleted file mode 100644 index 3bcdb3eb840..00000000000 --- a/frontend/packages/console-shared/src/components/kebab/KebabMenuItems.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import * as React from 'react'; -import { FocusTrap } from '@patternfly/react-core'; -import { AngleRightIcon } from '@patternfly/react-icons'; -import * as classNames from 'classnames'; -import * as _ from 'lodash'; -import { Action } from '@console/dynamic-plugin-sdk'; -import { Popper } from '../popper'; -import { KebabMenuOption, KebabSubMenuOption } from './kebab-types'; -import { isKebabSubMenu } from './kebab-utils'; -import KebabItem, { KebabItemProps } from './KebabItem'; - -type KebabMenuItemsProps = { - options: KebabMenuOption[]; - onClick: (event: React.MouseEvent<{}>, action: Action) => void; - focusItem?: KebabMenuOption; - className?: string; -}; - -const KebabMenuItems: React.FC = ({ - className, - options, - onClick, - focusItem, -}) => ( -
    - {_.map(options, (o, index) => ( -
  • - {isKebabSubMenu(o) ? ( - - ) : ( - - )} -
  • - ))} -
-); - -type KebabSubMenuProps = { - option: KebabSubMenuOption; - onClick: KebabItemProps['onClick']; -}; - -// Need to keep this in the same file to avoid circular dependency. -const KebabSubMenu: React.FC = ({ option, onClick }) => { - 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} - > - -
{ - // 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 default KebabMenuItems; diff --git a/frontend/packages/console-shared/src/components/kebab/kebab-types.ts b/frontend/packages/console-shared/src/components/kebab/kebab-types.ts deleted file mode 100644 index 20d9bfc0705..00000000000 --- a/frontend/packages/console-shared/src/components/kebab/kebab-types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Action } from '@console/dynamic-plugin-sdk'; - -export type KebabMenuOption = KebabSubMenuOption | Action; - -export type KebabSubMenuOption = { - id: string; - label?: string; - children: KebabMenuOption[]; -}; diff --git a/frontend/packages/console-shared/src/components/kebab/kebab-utils.ts b/frontend/packages/console-shared/src/components/kebab/kebab-utils.ts deleted file mode 100644 index 6807929619d..00000000000 --- a/frontend/packages/console-shared/src/components/kebab/kebab-utils.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Action } from '@console/dynamic-plugin-sdk'; -import { KebabMenuOption, KebabSubMenuOption } from './kebab-types'; - -export const kebabActionsToMenu = (actions: Action[]): KebabMenuOption[] => { - const subs: { [key: string]: KebabSubMenuOption } = {}; - const menuOptions: KebabMenuOption[] = []; - - actions.forEach((o) => { - if (!o.disabled) { - if (o.path) { - const parts = o.path.split('/'); - parts.forEach((p, i) => { - let subMenu = subs[p]; - if (!subs[p]) { - subMenu = { - id: `${o.id}-${p}`, - label: p, - children: [], - }; - subs[p] = subMenu; - if (i === 0) { - menuOptions.push(subMenu); - } else { - subs[parts[i - 1]].children.push(subMenu); - } - } - }); - subs[parts[parts.length - 1]].children.push(o); - } else { - menuOptions.push(o); - } - } - }); - return menuOptions; -}; - -export const isKebabSubMenu = (option: KebabMenuOption): option is KebabSubMenuOption => { - // only a sub menu has children - return Array.isArray((option as KebabSubMenuOption).children); -}; diff --git a/frontend/packages/helm-plugin/src/components/details-page/history/HelmReleaseHistoryRow.tsx b/frontend/packages/helm-plugin/src/components/details-page/history/HelmReleaseHistoryRow.tsx index 583b175dcb9..49aa0c597ad 100644 --- a/frontend/packages/helm-plugin/src/components/details-page/history/HelmReleaseHistoryRow.tsx +++ b/frontend/packages/helm-plugin/src/components/details-page/history/HelmReleaseHistoryRow.tsx @@ -6,8 +6,7 @@ import { coFetchJSON } from '@console/internal/co-fetch'; import { TableRow, TableData, RowFunction } from '@console/internal/components/factory'; import { confirmModal } from '@console/internal/components/modals'; import { Timestamp } from '@console/internal/components/utils'; -import { Status } from '@console/shared'; -import KebabMenu from '@console/shared/src/components/kebab/KebabMenu'; +import { ActionMenu, Status } from '@console/shared'; import { HelmRelease } from '../../../types/helm-types'; import { tableColumnClasses } from './HelmReleaseHistoryHeader'; @@ -53,7 +52,7 @@ const confirmModalRollbackHelmRelease = ( const HelmReleaseHistoryKebab: React.FC = ({ obj }) => { const { t } = useTranslation(); const menuActions = [confirmModalRollbackHelmRelease(obj.name, obj.namespace, obj.version, t)]; - return ; + return ; }; const HelmReleaseHistoryRow: RowFunction = ({ obj, index, key, style }) => ( diff --git a/frontend/packages/helm-plugin/src/components/list-page/HelmReleaseListRow.tsx b/frontend/packages/helm-plugin/src/components/list-page/HelmReleaseListRow.tsx index 1fec9cc52a4..e49a4a4e8cb 100644 --- a/frontend/packages/helm-plugin/src/components/list-page/HelmReleaseListRow.tsx +++ b/frontend/packages/helm-plugin/src/components/list-page/HelmReleaseListRow.tsx @@ -3,8 +3,7 @@ import * as _ from 'lodash'; import { Link } from 'react-router-dom'; import { TableRow, TableData, RowFunction } from '@console/internal/components/factory'; import { Timestamp, ResourceIcon } from '@console/internal/components/utils'; -import { ActionsLoader, Status } from '@console/shared'; -import KebabMenu from '@console/shared/src/components/kebab/KebabMenu'; +import { ActionsLoader, ActionMenu, Status } from '@console/shared'; import { HelmRelease, HelmActionOrigins } from '../../types/helm-types'; import { tableColumnClasses } from './HelmReleaseListHeader'; @@ -42,7 +41,9 @@ const HelmReleaseListRow: RowFunction = ({ obj, index, key, style } - {(actions, loaded) => loaded && } + {(loader) => + loader.loaded && + } From 781420987bc2a8ec9133aeec013fab265c63f2fc Mon Sep 17 00:00:00 2001 From: Rohit Rai Date: Tue, 29 Jun 2021 21:49:15 +0530 Subject: [PATCH 3/3] Migrate helm details page actions to use extensions --- .../src/components/actions/index.ts | 1 + .../components/actions/menu/ActionMenu.tsx | 111 +++++++++++------- .../details-page/HelmReleaseDetails.tsx | 33 ++++-- .../public/components/factory/details.tsx | 2 + frontend/public/components/utils/headings.tsx | 7 +- 5 files changed, 100 insertions(+), 54 deletions(-) diff --git a/frontend/packages/console-shared/src/components/actions/index.ts b/frontend/packages/console-shared/src/components/actions/index.ts index 200e1259048..b50f3c0d14b 100644 --- a/frontend/packages/console-shared/src/components/actions/index.ts +++ b/frontend/packages/console-shared/src/components/actions/index.ts @@ -1,2 +1,3 @@ export { default as ActionsLoader } from './loader/ActionsLoader'; export { default as ActionMenu } from './menu/ActionMenu'; +export * from './menu/menu-types'; diff --git a/frontend/packages/console-shared/src/components/actions/menu/ActionMenu.tsx b/frontend/packages/console-shared/src/components/actions/menu/ActionMenu.tsx index ec35215aed1..7ec5f0f9a7f 100644 --- a/frontend/packages/console-shared/src/components/actions/menu/ActionMenu.tsx +++ b/frontend/packages/console-shared/src/components/actions/menu/ActionMenu.tsx @@ -4,6 +4,7 @@ import { EllipsisVIcon } from '@patternfly/react-icons'; import * as _ from 'lodash'; import { useTranslation } from 'react-i18next'; import { Action } from '@console/dynamic-plugin-sdk'; +import { useSafetyFirst } from '@console/internal/components/safety-first'; import { checkAccess } from '@console/internal/components/utils'; import { Popper } from '../../popper'; import ActionMenuContent from './ActionMenuContent'; @@ -25,12 +26,15 @@ const ActionMenu: React.FC = ({ label, }) => { const { t } = useTranslation(); + const isKebabVariant = variant === ActionMenuVariant.KEBAB; + const [isVisible, setVisible] = useSafetyFirst(isKebabVariant); const [active, setActive] = React.useState(false); const toggleRef = React.useRef(); const toggleRefCb = React.useCallback(() => toggleRef.current, []); const menuRef = React.useRef(); const menuRefCb = React.useCallback(() => menuRef.current, []); const toggleLabel = label || t('console-shared~Actions'); + const menuOptions = options || actions; const toggleMenu = () => setActive((value) => !value); @@ -55,50 +59,75 @@ const ActionMenu: React.FC = ({ }); }, [actions]); - const menuOptions = options || actions; + // Check if any actions are visible when actions have access reviews. + React.useEffect(() => { + if (!actions.length) { + setVisible(false); + return; + } + // Do nothing if variant is kebab. The action menu should be visible and acces review happens on hover. + if (isKebabVariant) 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, isKebabVariant, setVisible]); return ( -
- - {variant === ActionMenuVariant.KEBAB ? : toggleLabel} - - - + + {isKebabVariant ? : toggleLabel} + + -
- -
-
-
-
+ +
+ +
+
+ + + ) ); }; diff --git a/frontend/packages/helm-plugin/src/components/details-page/HelmReleaseDetails.tsx b/frontend/packages/helm-plugin/src/components/details-page/HelmReleaseDetails.tsx index 3e1ab1cc8e7..27d72a42b61 100755 --- a/frontend/packages/helm-plugin/src/components/details-page/HelmReleaseDetails.tsx +++ b/frontend/packages/helm-plugin/src/components/details-page/HelmReleaseDetails.tsx @@ -13,12 +13,7 @@ import { } from '@console/internal/components/utils'; import { SecretModel } from '@console/internal/models'; import { K8sResourceKindReference } from '@console/internal/module/k8s'; -import { Status } from '@console/shared'; -import { - deleteHelmRelease, - upgradeHelmRelease, - rollbackHelmRelease, -} from '../../actions/modify-helm-release'; +import { ActionMenu, ActionsLoader, ActionMenuVariant, Status } from '@console/shared'; import { HelmRelease, HelmActionOrigins } from '../../types/helm-types'; import { fetchHelmReleases } from '../../utils/helm-utils'; import HelmReleaseHistory from './history/HelmReleaseHistory'; @@ -78,17 +73,31 @@ export const LoadedHelmReleaseDetails: React.FC = ); - const menuActions = [ - () => upgradeHelmRelease(releaseName, namespace, HelmActionOrigins.details), - () => rollbackHelmRelease(releaseName, namespace, HelmActionOrigins.details), - () => deleteHelmRelease(releaseName, namespace, `/helm-releases/ns/${namespace}`), - ]; + const actionsScope = { + releaseName, + namespace, + actionOrigin: HelmActionOrigins.details, + }; + + const customActionMenu = ( + + {(loader) => + loader.loaded && ( + + ) + } + + ); return ( (({ pages = [], ...prop titleFunc={props.titleFunc} menuActions={props.menuActions} buttonActions={props.buttonActions} + customActionMenu={props.customActionMenu} kind={props.customKind || props.kind} breadcrumbs={pluginBreadcrumbs} breadcrumbsFor={ @@ -177,6 +178,7 @@ export type DetailsPageProps = { titleFunc?: (obj: K8sResourceKind) => string | JSX.Element; menuActions?: Function[] | KebabOptionsCreator; // FIXME should be "KebabAction[] |" refactor pipeline-actions.tsx, etc. buttonActions?: any[]; + customActionMenu?: React.ReactNode; // Renders a custom action menu. pages?: Page[]; pagesFor?: (obj: K8sResourceKind) => Page[]; kind: K8sResourceKindReference; diff --git a/frontend/public/components/utils/headings.tsx b/frontend/public/components/utils/headings.tsx index 21f88057445..60963083024 100644 --- a/frontend/public/components/utils/headings.tsx +++ b/frontend/public/components/utils/headings.tsx @@ -94,6 +94,7 @@ export const PageHeading = connectToModel((props: PageHeadingProps) => { title, menuActions, buttonActions, + customActionMenu, obj, breadcrumbs, breadcrumbsFor, @@ -116,7 +117,9 @@ export const PageHeading = connectToModel((props: PageHeadingProps) => { const hasMenuActions = _.isFunction(menuActions) || !_.isEmpty(menuActions); const hasData = !_.isEmpty(data); const showActions = - (hasButtonActions || hasMenuActions) && hasData && !_.get(data, 'metadata.deletionTimestamp'); + (hasButtonActions || hasMenuActions || customActionMenu) && + hasData && + !_.get(data, 'metadata.deletionTimestamp'); const resourceStatus = hasData && getResourceStatus ? getResourceStatus(data) : null; const showHeading = props.icon || kind || resourceTitle || resourceStatus || badge || showActions; const showBreadcrumbs = breadcrumbs || (breadcrumbsFor && !_.isEmpty(data)); @@ -180,6 +183,7 @@ export const PageHeading = connectToModel((props: PageHeadingProps) => { } /> )} + {customActionMenu} )} @@ -286,6 +290,7 @@ export type PageHeadingProps = { kind?: K8sResourceKindReference; kindObj?: K8sKind; menuActions?: Function[] | KebabOptionsCreator; // FIXME should be "KebabAction[] |" refactor pipeline-actions.tsx, etc. + customActionMenu?: React.ReactNode; // Renders a custom action menu. obj?: FirehoseResult; resourceKeys?: string[]; style?: object;