Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

frontend: refactor row action menus #2621

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
2 changes: 1 addition & 1 deletion frontend/src/components/common/Resource/EditButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export default function EditButton(props: EditButtonProps) {
}

if (isReadOnly) {
return <ViewButton item={item} />;
return <ViewButton item={item} buttonStyle={buttonStyle} />;
}

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,9 @@
import { has } from 'lodash';
import React, { isValidElement } from 'react';
import React from 'react';
import { useLocation } from 'react-router-dom';
import { KubeObject } from '../../../../lib/k8s/KubeObject';
import {
DefaultHeaderAction,
HeaderAction,
HeaderActionType,
} from '../../../../redux/actionButtonsSlice';
import { useTypedSelector } from '../../../../redux/reducers/reducers';
import ErrorBoundary from '../../ErrorBoundary';
import { HeaderAction } from '../../../../redux/actionButtonsSlice';
import SectionHeader, { HeaderStyle } from '../../SectionHeader';
import DeleteButton from '../DeleteButton';
import EditButton from '../EditButton';
import { RestartButton } from '../RestartButton';
import ScaleButton from '../ScaleButton';
import { generateActions } from '../generateHeaderActions';

export interface MainInfoHeaderProps<T extends KubeObject> {
resource: T | null;
Expand All @@ -32,90 +22,7 @@ export interface MainInfoHeaderProps<T extends KubeObject> {

export function MainInfoHeader<T extends KubeObject>(props: MainInfoHeaderProps<T>) {
const { resource, title, actions = [], headerStyle = 'main', noDefaultActions = false } = props;
const headerActions = useTypedSelector(state => state.actionButtons.headerActions);
const headerActionsProcessors = useTypedSelector(
state => state.actionButtons.headerActionsProcessors
);
function setupAction(headerAction: HeaderAction) {
let Action = has(headerAction, 'action') ? (headerAction as any).action : headerAction;

if (!noDefaultActions && has(headerAction, 'id')) {
switch ((headerAction as HeaderAction).id) {
case DefaultHeaderAction.RESTART:
Action = RestartButton;
break;
case DefaultHeaderAction.SCALE:
Action = ScaleButton;
break;
case DefaultHeaderAction.EDIT:
Action = EditButton;
break;
case DefaultHeaderAction.DELETE:
Action = DeleteButton;
break;
default:
break;
}
}

if (!Action || (headerAction as unknown as HeaderAction).action === null) {
return null;
}

if (isValidElement(Action)) {
return <ErrorBoundary>{Action}</ErrorBoundary>;
} else if (Action === null) {
return null;
} else if (typeof Action === 'function') {
return (
<ErrorBoundary>
<Action item={resource} />
</ErrorBoundary>
);
}
}

const defaultActions = [
{
id: DefaultHeaderAction.RESTART,
},
{
id: DefaultHeaderAction.SCALE,
},
{
id: DefaultHeaderAction.EDIT,
},
{
id: DefaultHeaderAction.DELETE,
},
];

let hAccs: HeaderAction[] = [];
const accs = typeof actions === 'function' ? actions(resource) || [] : actions;
if (accs !== null) {
hAccs = [...accs].map((action, i): HeaderAction => {
if ((action as HeaderAction)?.id !== undefined) {
return action as HeaderAction;
} else {
return { id: `gen-${i}`, action: action as HeaderActionType };
}
});
}

let actionsProcessed = [...headerActions, ...hAccs, ...defaultActions];
if (headerActionsProcessors.length > 0) {
for (const headerProcessor of headerActionsProcessors) {
actionsProcessed = headerProcessor.processor(resource, actionsProcessed);
}
}

const allActions = React.Children.toArray(
(function propsActions() {
const pluginAddedActions = actionsProcessed.map(setupAction);
return React.Children.toArray(pluginAddedActions);
})()
);

const allActions = generateActions(resource, 'action', actions, noDefaultActions);
return (
<SectionHeader
title={title || (resource ? `${resource.kind}: ${resource.getName()}` : '')}
Expand Down
58 changes: 7 additions & 51 deletions frontend/src/components/common/Resource/ResourceTable.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MenuItem, TableCellProps } from '@mui/material';
import { TableCellProps } from '@mui/material';
import { useTheme } from '@mui/material/styles';
import { MRT_FilterFns, MRT_Row, MRT_SortingFn, MRT_TableInstance } from 'material-react-table';
import { ComponentProps, ReactNode, useEffect, useMemo, useRef, useState } from 'react';
Expand All @@ -9,7 +9,7 @@ import { ApiError } from '../../../lib/k8s/apiProxy';
import { KubeObject } from '../../../lib/k8s/KubeObject';
import { KubeObjectClass } from '../../../lib/k8s/KubeObject';
import { useFilterFunc } from '../../../lib/util';
import { DefaultHeaderAction, RowAction } from '../../../redux/actionButtonsSlice';
import { HeaderAction } from '../../../redux/actionButtonsSlice';
import { useNamespaces } from '../../../redux/filterSlice';
import { HeadlampEventType, useEventCallback } from '../../../redux/headlampEventSlice';
import { useTypedSelector } from '../../../redux/reducers/reducers';
Expand All @@ -18,12 +18,8 @@ import { ClusterGroupErrorMessage } from '../../cluster/ClusterGroupErrorMessage
import { DateLabel } from '../Label';
import Link from '../Link';
import Table, { TableColumn } from '../Table';
import DeleteButton from './DeleteButton';
import EditButton from './EditButton';
import generateRowActionsMenu from './generateHeaderActions';
import ResourceTableMultiActions from './ResourceTableMultiActions';
import { RestartButton } from './RestartButton';
import ScaleButton from './ScaleButton';
import ViewButton from './ViewButton';

export type ResourceTableColumn<RowItem> = {
/** Unique id for the column, not required but recommended */
Expand Down Expand Up @@ -87,7 +83,7 @@ export interface ResourceTableProps<RowItem> {
enableRowActions?: boolean;
/** Show or hide row selections and actions @default false*/
enableRowSelection?: boolean;
actions?: null | RowAction[];
actions?: null | HeaderAction[];
/** Provide a list of columns that won't be shown and cannot be turned on */
Comment on lines -90 to +86
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to leave this with the previous type because plugins may be using it and it would mean a type break for them.

hideColumns?: string[] | null;
/** ID for the table. Will be used by plugins to identify this table.
Expand Down Expand Up @@ -416,52 +412,12 @@ function ResourceTableContent<RowItem extends KubeObject>(props: ResourceTablePr
tableSettings,
]);

const defaultActions: RowAction[] = [
{
id: DefaultHeaderAction.RESTART,
action: ({ item }) => <RestartButton item={item} buttonStyle="menu" />,
},
{
id: DefaultHeaderAction.SCALE,
action: ({ item }) => <ScaleButton item={item} buttonStyle="menu" />,
},
{
id: DefaultHeaderAction.EDIT,
action: ({ item, closeMenu }) => (
<EditButton item={item} buttonStyle="menu" afterConfirm={closeMenu} />
),
},
{
id: DefaultHeaderAction.VIEW,
action: ({ item }) => <ViewButton item={item} buttonStyle="menu" />,
},
{
id: DefaultHeaderAction.DELETE,
action: ({ item, closeMenu }) => (
<DeleteButton item={item} buttonStyle="menu" afterConfirm={closeMenu} />
),
},
];
let hAccs: RowAction[] = [];
if (actions !== undefined && actions !== null) {
hAccs = actions;
}

const actionsProcessed: RowAction[] = [...hAccs, ...defaultActions];

const renderRowActionMenuItems = useMemo(() => {
if (actionsProcessed.length === 0) {
if (!enableRowActions) {
return null;
}
return ({ closeMenu, row }: { closeMenu: () => void; row: MRT_Row<Record<string, any>> }) => {
return actionsProcessed.map(action => {
if (action.action === undefined || action.action === null) {
return <MenuItem />;
}
return action.action({ item: row.original, closeMenu });
});
};
}, [actionsProcessed]);
return generateRowActionsMenu(actions);
}, [actions, enableRowActions]);

const wrappedEnableRowSelection = useMemo(() => {
if (import.meta.env.REACT_APP_HEADLAMP_ENABLE_ROW_SELECTION === 'false') {
Expand Down
130 changes: 130 additions & 0 deletions frontend/src/components/common/Resource/generateHeaderActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { has } from 'lodash';
import { MRT_Row } from 'material-react-table';
import { isValidElement } from 'react';
import React from 'react';
import { KubeObject } from '../../../lib/k8s/KubeObject';
import {
DefaultHeaderAction,
HeaderAction,
HeaderActionType,
} from '../../../redux/actionButtonsSlice';
import { useTypedSelector } from '../../../redux/reducers/reducers';
import { ButtonStyle } from '../ActionButton/ActionButton';
import ErrorBoundary from '../ErrorBoundary';
import DeleteButton from './DeleteButton';
import EditButton from './EditButton';
import { RestartButton } from './RestartButton';
import ScaleButton from './ScaleButton';

export function generateActions<T extends KubeObject>(
resource: T | null,
buttonStyle: ButtonStyle,
actions:
| ((resource: T | null) => React.ReactNode[] | HeaderAction[] | null)
| React.ReactNode[]
| null
| HeaderAction[],
noDefaultActions?: boolean,
closeMenu?: () => void
): React.ReactNode[] {
const headerActions = useTypedSelector(state => state.actionButtons.headerActions);
const headerActionsProcessors = useTypedSelector(
state => state.actionButtons.headerActionsProcessors
);
function setupAction(headerAction: HeaderAction) {
let Action = has(headerAction, 'action') ? (headerAction as any).action : headerAction;

if (!noDefaultActions && has(headerAction, 'id')) {
switch ((headerAction as HeaderAction).id) {
case DefaultHeaderAction.RESTART:
Action = RestartButton;
break;
case DefaultHeaderAction.SCALE:
Action = ScaleButton;
break;
case DefaultHeaderAction.EDIT:
Action = EditButton;
break;
case DefaultHeaderAction.DELETE:
Action = DeleteButton;
break;
default:
break;
}
}

if (!Action || (headerAction as unknown as HeaderAction).action === null) {
return null;
}

if (isValidElement(Action)) {
return <ErrorBoundary>{Action}</ErrorBoundary>;
} else if (Action === null) {
return null;
} else if (typeof Action === 'function') {
return (
<ErrorBoundary>
<Action item={resource} buttonStyle={buttonStyle} closeMenu={closeMenu} />
</ErrorBoundary>
);
}
}

const defaultActions = [
{
id: DefaultHeaderAction.RESTART,
},
{
id: DefaultHeaderAction.SCALE,
},
{
id: DefaultHeaderAction.EDIT,
},
{
id: DefaultHeaderAction.DELETE,
},
];

let hAccs: HeaderAction[] = [];
const accs = typeof actions === 'function' ? actions(resource) || [] : actions;
if (accs !== null) {
hAccs = [...accs].map((action, i): HeaderAction => {
if ((action as HeaderAction)?.id !== undefined) {
return action as HeaderAction;
} else {
return { id: `gen-${i}`, action: action as HeaderActionType };
}
});
}

let actionsProcessed = [...headerActions, ...hAccs, ...defaultActions];
if (headerActionsProcessors.length > 0) {
for (const headerProcessor of headerActionsProcessors) {
actionsProcessed = headerProcessor.processor(resource, actionsProcessed);
}
}

const allActions = React.Children.toArray(
(function propsActions() {
const pluginAddedActions = actionsProcessed.map(setupAction);
return React.Children.toArray(pluginAddedActions);
})()
);
return allActions;
}

export default function generateRowActionsMenu(actions: HeaderAction[] | null | undefined) {
return ({ closeMenu, row }: { closeMenu: () => void; row: MRT_Row<Record<string, any>> }) => {
const actionsProcessed = generateActions(
row.original as any,
'menu',
actions || [],
false,
closeMenu
);
if (actionsProcessed.length === 0) {
return null;
}
return actionsProcessed;
};
}
1 change: 1 addition & 0 deletions frontend/src/components/common/Resource/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const checkExports = [
'SimpleEditor',
'ViewButton',
'AuthVisible',
'generateHeaderActions',
];

function getFilesToVerify() {
Expand Down
6 changes: 1 addition & 5 deletions frontend/src/redux/actionButtonsSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,13 @@ export type HeaderActionType = ((...args: any[]) => ReactNode) | null | ReactEle
export type DetailsViewFunc = HeaderActionType;

export type AppBarActionType = ((...args: any[]) => ReactNode) | null | ReactElement | ReactNode;
export type RowActionType = ((item: any) => JSX.Element | null | ReactNode) | null;

export type HeaderAction = {
id: string;
action?: HeaderActionType;
};

export type RowAction = {
id: string;
action?: RowActionType;
};
export type RowAction = HeaderAction;

export type AppBarAction = {
id: string;
Expand Down
Loading