Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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!",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ActionGroup>[] = [
{
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]],
},
];
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export { default as ActionsLoader } from './ActionsLoader';
export { default as ActionsLoader } from './loader/ActionsLoader';
export { default as ActionMenu } from './menu/ActionMenu';
export * from './menu/menu-types';
Original file line number Diff line number Diff line change
Expand Up @@ -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<ActionsLoaderProps> = ({ contextId, scope, children }) => {
const [actionsMap, setActionsMap] = React.useState<{ [uid: string]: Action[] }>({});
const [loadError, setLoadError] = React.useState<any>();
Expand All @@ -33,12 +40,29 @@ const ActionsLoader: React.FC<ActionsLoaderProps> = ({ contextId, scope, childre
actionProviderGuard,
);

const groupExtensions = useExtensions<ActionGroup>(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 &&
Expand All @@ -51,7 +75,7 @@ const ActionsLoader: React.FC<ActionsLoaderProps> = ({ contextId, scope, childre
onValueError={setLoadError}
/>
))}
{children(actions, actionsLoaded, loadError)}
{children(loader)}
</>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
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 { useSafetyFirst } from '@console/internal/components/safety-first';
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<ActionMenuProps> = ({
actions,
options,
isDisabled,
variant = ActionMenuVariant.KEBAB,
label,
}) => {
const { t } = useTranslation();
const isKebabVariant = variant === ActionMenuVariant.KEBAB;
const [isVisible, setVisible] = useSafetyFirst(isKebabVariant);
const [active, setActive] = React.useState<boolean>(false);
const toggleRef = React.useRef<HTMLButtonElement>();
const toggleRefCb = React.useCallback(() => toggleRef.current, []);
const menuRef = React.useRef<HTMLDivElement>();
const menuRefCb = React.useCallback(() => menuRef.current, []);
const toggleLabel = label || t('console-shared~Actions');
const menuOptions = options || 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]);

// 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 (
isVisible && (
<div>
<MenuToggle
variant={variant}
innerRef={toggleRef}
isExpanded={active}
isDisabled={isDisabled}
aria-expanded={active}
aria-label={toggleLabel}
aria-haspopup="true"
data-test-id="menu-toggle-button"
onClick={toggleMenu}
{...(isKebabVariant ? { onFocus: handleHover, onMouseEnter: handleHover } : {})}
>
{isKebabVariant ? <EllipsisVIcon /> : toggleLabel}
</MenuToggle>
<Popper
open={!isDisabled && active}
placement="bottom-end"
onRequestClose={handleRequestClose}
reference={toggleRefCb}
closeOnEsc
closeOnOutsideClick
>
<FocusTrap
focusTrapOptions={{
clickOutsideDeactivates: true,
returnFocusOnDeactivate: false,
fallbackFocus: menuRefCb,
}}
>
<div ref={menuRef} className="pf-c-menu pf-m-flyout">
<ActionMenuContent
options={menuOptions}
onClick={hideMenu}
focusItem={menuOptions[0]}
/>
</div>
</FocusTrap>
</Popper>
</div>
)
);
};

export default ActionMenu;
Loading