1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00
Nuno Góis 2024-01-26 08:20:30 +00:00 committed by GitHub
parent 00b3cbaa8b
commit 32484460ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 644 additions and 3 deletions

View File

@ -57,7 +57,12 @@ export const ProjectActions = () => {
} }
> >
<PermissionGuard permissions={ADMIN}> <PermissionGuard permissions={ADMIN}>
<ProjectActionsTable /> <ProjectActionsTable
modalOpen={actionModalOpen}
setModalOpen={setActionModalOpen}
selectedAction={selectedAction}
setSelectedAction={setSelectedAction}
/>
</PermissionGuard> </PermissionGuard>
</PageContent> </PageContent>
); );

View File

@ -0,0 +1,64 @@
import { styled } from '@mui/material';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { IActionSet } from 'interfaces/action';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { TooltipLink } from 'component/common/TooltipLink/TooltipLink';
const StyledActionItems = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
fontSize: theme.fontSizes.smallerBody,
}));
const StyledParameterList = styled('ul')(({ theme }) => ({
listStyle: 'none',
paddingLeft: theme.spacing(1),
margin: 0,
}));
interface IProjectActionsActionsCellProps {
action: IActionSet;
onCreateAction?: () => void;
}
export const ProjectActionsActionsCell = ({
action,
onCreateAction,
}: IProjectActionsActionsCellProps) => {
const { actions } = action;
if (actions.length === 0) {
if (!onCreateAction) return <TextCell>0 actions</TextCell>;
else return <LinkCell title='Create action' onClick={onCreateAction} />;
}
return (
<TextCell>
<TooltipLink
tooltip={
<StyledActionItems>
{actions.map(({ id, action, executionParams }) => (
<div key={id}>
<strong>{action}</strong>
<StyledParameterList>
{Object.entries(executionParams).map(
([param, value]) => (
<li key={param}>
{param}: {value}
</li>
),
)}
</StyledParameterList>
</div>
))}
</StyledActionItems>
}
>
{actions.length === 1
? '1 action'
: `${actions.length} actions`}
</TooltipLink>
</TextCell>
);
};

View File

@ -0,0 +1,23 @@
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { IActionSet } from 'interfaces/action';
import { IServiceAccount } from 'interfaces/service-account';
interface IProjectActionsActorCellProps {
action: IActionSet;
serviceAccounts: IServiceAccount[];
}
export const ProjectActionsActorCell = ({
action,
serviceAccounts,
}: IProjectActionsActorCellProps) => {
const { actorId } = action;
const actor = serviceAccounts.find(({ id }) => id === actorId);
if (!actor) {
return <TextCell>No service account</TextCell>;
}
return <LinkCell to='/admin/service-accounts'>{actor.name}</LinkCell>;
};

View File

@ -0,0 +1,31 @@
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { IActionSet } from 'interfaces/action';
interface IProjectActionsDeleteDialogProps {
action?: IActionSet;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
onConfirm: (action: IActionSet) => void;
}
export const ProjectActionsDeleteDialog = ({
action,
open,
setOpen,
onConfirm,
}: IProjectActionsDeleteDialogProps) => (
<Dialogue
title='Delete action?'
open={open}
primaryButtonText='Delete action'
secondaryButtonText='Cancel'
onClick={() => onConfirm(action!)}
onClose={() => {
setOpen(false);
}}
>
<p>
You are about to delete action: <strong>{action?.name}</strong>
</p>
</Dialogue>
);

View File

@ -0,0 +1,43 @@
import { styled, Typography } from '@mui/material';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { IActionSet } from 'interfaces/action';
import { TooltipLink } from 'component/common/TooltipLink/TooltipLink';
const StyledItem = styled(Typography)(({ theme }) => ({
fontSize: theme.fontSizes.smallerBody,
}));
interface IProjectActionsFiltersCellProps {
action: IActionSet;
}
export const ProjectActionsFiltersCell = ({
action,
}: IProjectActionsFiltersCellProps) => {
const { payload } = action.match;
const filters = Object.entries(payload);
if (filters.length === 0) {
return <TextCell>0 filters</TextCell>;
}
return (
<TextCell>
<TooltipLink
tooltip={
<>
{filters.map(([parameter, value]) => (
<StyledItem key={parameter}>
{parameter}: {value}
</StyledItem>
))}
</>
}
>
{filters.length === 1
? '1 filter'
: `${filters.length} filters`}
</TooltipLink>
</TextCell>
);
};

View File

@ -1,3 +1,253 @@
export const ProjectActionsTable = () => { import { useMemo, useState } from 'react';
return <span>TODO</span>; import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useMediaQuery } from '@mui/material';
import { useFlexLayout, useSortBy, useTable } from 'react-table';
import { sortTypes } from 'utils/sortTypes';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import theme from 'themes/theme';
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
import { useActions } from 'hooks/api/getters/useActions/useActions';
import { useActionsApi } from 'hooks/api/actions/useActionsApi/useActionsApi';
import { IActionSet } from 'interfaces/action';
import { ToggleCell } from 'component/common/Table/cells/ToggleCell/ToggleCell';
import { ProjectActionsTriggerCell } from './ProjectActionsTriggerCell';
import { ProjectActionsFiltersCell } from './ProjectActionsFiltersCell';
import { ProjectActionsActorCell } from './ProjectActionsActorCell';
import { ProjectActionsActionsCell } from './ProjectActionsActionsCell';
import { ProjectActionsTableActionsCell } from './ProjectActionsTableActionsCell';
// import { ProjectActionsModal } from '../ProjectActionsModal/ProjectActionsModal';
import { ProjectActionsDeleteDialog } from './ProjectActionsDeleteDialog';
import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts';
import { useIncomingWebhooks } from 'hooks/api/getters/useIncomingWebhooks/useIncomingWebhooks';
interface IProjectActionsTableProps {
modalOpen: boolean;
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
selectedAction?: IActionSet;
setSelectedAction: React.Dispatch<
React.SetStateAction<IActionSet | undefined>
>;
}
export const ProjectActionsTable = ({
modalOpen,
setModalOpen,
selectedAction,
setSelectedAction,
}: IProjectActionsTableProps) => {
const { setToastData, setToastApiError } = useToast();
const { actions, refetch } = useActions();
const { toggleActionSet, removeActionSet } = useActionsApi();
const { incomingWebhooks } = useIncomingWebhooks();
const { serviceAccounts } = useServiceAccounts();
const [deleteOpen, setDeleteOpen] = useState(false);
const onToggleAction = async (action: IActionSet, enabled: boolean) => {
try {
await toggleActionSet(action.id, enabled);
setToastData({
title: `"${action.name}" has been ${
enabled ? 'enabled' : 'disabled'
}`,
type: 'success',
});
refetch();
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
const onDeleteConfirm = async (action: IActionSet) => {
try {
await removeActionSet(action.id);
setToastData({
title: `"${action.name}" has been deleted`,
type: 'success',
});
refetch();
setDeleteOpen(false);
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
const columns = useMemo(
() => [
{
Header: 'Name',
accessor: 'name',
minWidth: 60,
},
{
id: 'trigger',
Header: 'Trigger',
Cell: ({
row: { original: action },
}: { row: { original: IActionSet } }) => (
<ProjectActionsTriggerCell
action={action}
incomingWebhooks={incomingWebhooks}
/>
),
},
{
id: 'filters',
Header: 'Filters',
Cell: ({
row: { original: action },
}: {
row: { original: IActionSet };
}) => <ProjectActionsFiltersCell action={action} />,
maxWidth: 90,
},
{
id: 'actor',
Header: 'Service account',
Cell: ({
row: { original: action },
}: {
row: { original: IActionSet };
}) => (
<ProjectActionsActorCell
action={action}
serviceAccounts={serviceAccounts}
/>
),
minWidth: 160,
},
{
id: 'actions',
Header: 'Actions',
Cell: ({
row: { original: action },
}: {
row: { original: IActionSet };
}) => (
<ProjectActionsActionsCell
action={action}
onCreateAction={() => {
setSelectedAction(action);
setModalOpen(true);
}}
/>
),
maxWidth: 130,
},
{
Header: 'Enabled',
accessor: 'enabled',
Cell: ({
row: { original: action },
}: { row: { original: IActionSet } }) => (
<ToggleCell
checked={action.enabled}
setChecked={(enabled) =>
onToggleAction(action, enabled)
}
/>
),
sortType: 'boolean',
width: 90,
maxWidth: 90,
},
{
id: 'table-actions',
Header: '',
align: 'center',
Cell: ({
row: { original: action },
}: { row: { original: IActionSet } }) => (
<ProjectActionsTableActionsCell
actionId={action.id}
onEdit={() => {
setSelectedAction(action);
setModalOpen(true);
}}
onDelete={() => {
setSelectedAction(action);
setDeleteOpen(true);
}}
/>
),
width: 50,
disableSortBy: true,
},
],
[actions, incomingWebhooks, serviceAccounts],
);
const [initialState] = useState({
sortBy: [{ id: 'name', desc: true }],
});
const { headerGroups, rows, prepareRow, setHiddenColumns } = useTable(
{
columns: columns as any,
data: actions,
initialState,
sortTypes,
autoResetHiddenColumns: false,
autoResetSortBy: false,
disableSortRemove: true,
disableMultiSort: true,
defaultColumn: {
Cell: TextCell,
},
},
useSortBy,
useFlexLayout,
);
useConditionallyHiddenColumns(
[
{
condition: isMediumScreen,
columns: ['actor', 'enabled'],
},
{
condition: isExtraSmallScreen,
columns: ['filters', 'actions'],
},
],
setHiddenColumns,
columns,
);
return (
<>
<VirtualizedTable
rows={rows}
headerGroups={headerGroups}
prepareRow={prepareRow}
/>
<ConditionallyRender
condition={rows.length === 0}
show={
<TablePlaceholder>
No actions available. Get started by adding one.
</TablePlaceholder>
}
/>
{/* <ProjectActionsModal
action={selectedAction}
open={modalOpen}
setOpen={setModalOpen}
/> */}
<ProjectActionsDeleteDialog
action={selectedAction}
open={deleteOpen}
setOpen={setDeleteOpen}
onConfirm={onDeleteConfirm}
/>
</>
);
}; };

View File

@ -0,0 +1,123 @@
import { useState } from 'react';
import {
Box,
IconButton,
ListItemIcon,
ListItemText,
MenuItem,
MenuList,
Popover,
Tooltip,
Typography,
styled,
} from '@mui/material';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import { Delete, Edit } from '@mui/icons-material';
import { PermissionHOC } from 'component/common/PermissionHOC/PermissionHOC';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { defaultBorderRadius } from 'themes/themeStyles';
const StyledBoxCell = styled(Box)({
display: 'flex',
justifyContent: 'center',
});
interface IProjectActionsTableActionsCellProps {
actionId: number;
onEdit: (event: React.SyntheticEvent) => void;
onDelete: (event: React.SyntheticEvent) => void;
}
export const ProjectActionsTableActionsCell = ({
actionId,
onEdit,
onDelete,
}: IProjectActionsTableActionsCellProps) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const id = `action-${actionId}-actions`;
const menuId = `${id}-menu`;
return (
<StyledBoxCell>
<Tooltip title='Action actions' arrow describeChild>
<IconButton
id={id}
data-loading
aria-controls={open ? menuId : undefined}
aria-haspopup='true'
aria-expanded={open ? 'true' : undefined}
onClick={handleClick}
type='button'
>
<MoreVertIcon />
</IconButton>
</Tooltip>
<Popover
id={menuId}
anchorEl={anchorEl}
open={open}
onClose={handleClose}
onClick={handleClose}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
disableScrollLock={true}
PaperProps={{
sx: (theme) => ({
borderRadius: `${theme.shape.borderRadius}px`,
padding: theme.spacing(1, 1.5),
}),
}}
>
<MenuList aria-labelledby={id}>
<PermissionHOC permission={ADMIN}>
{({ hasAccess }) => (
<MenuItem
sx={defaultBorderRadius}
onClick={onEdit}
disabled={!hasAccess}
>
<ListItemIcon>
<Edit />
</ListItemIcon>
<ListItemText>
<Typography variant='body2'>
Edit
</Typography>
</ListItemText>
</MenuItem>
)}
</PermissionHOC>
<PermissionHOC permission={ADMIN}>
{({ hasAccess }) => (
<MenuItem
sx={defaultBorderRadius}
onClick={onDelete}
disabled={!hasAccess}
>
<ListItemIcon>
<Delete />
</ListItemIcon>
<ListItemText>
<Typography variant='body2'>
Remove
</Typography>
</ListItemText>
</MenuItem>
)}
</PermissionHOC>
</MenuList>
</Popover>
</StyledBoxCell>
);
};

View File

@ -0,0 +1,66 @@
import { Avatar, Box, Link, styled } from '@mui/material';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { IActionSet } from 'interfaces/action';
import { IIncomingWebhook } from 'interfaces/incomingWebhook';
import webhooksIcon from 'assets/icons/webhooks.svg';
import { Link as RouterLink } from 'react-router-dom';
import { ComponentType } from 'react';
import { wrapperStyles } from 'component/common/Table/cells/LinkCell/LinkCell.styles';
const StyledCell = styled(Box)({
display: 'flex',
alignItems: 'center',
});
const StyledIcon = styled(Avatar)(({ theme }) => ({
borderRadius: theme.shape.borderRadius,
overflow: 'hidden',
width: theme.spacing(3),
height: theme.spacing(3),
}));
const StyledLink = styled(Link)<{
component?: ComponentType<any>;
to?: string;
}>(({ theme }) => ({
...wrapperStyles(theme),
'&:hover, &:focus': {
textDecoration: 'underline',
},
}));
interface IProjectActionsTriggerCellProps {
action: IActionSet;
incomingWebhooks: IIncomingWebhook[];
}
export const ProjectActionsTriggerCell = ({
action,
incomingWebhooks,
}: IProjectActionsTriggerCellProps) => {
const { sourceId } = action.match;
const trigger = incomingWebhooks.find(({ id }) => id === sourceId);
if (!trigger) {
return <TextCell>No trigger</TextCell>;
}
return (
<TextCell>
<StyledCell>
<StyledIcon
src={webhooksIcon}
alt='Incoming webhook'
variant='rounded'
/>
<StyledLink
component={RouterLink}
to='/integrations/incoming-webhooks'
underline='hover'
>
{trigger.name}
</StyledLink>
</StyledCell>
</TextCell>
);
};

View File

@ -52,6 +52,40 @@ export const useActionsApi = () => {
await makeRequest(req.caller, req.id); await makeRequest(req.caller, req.id);
}; };
const enableActionSet = async (actionSetId: number) => {
const requestId = 'enableActionSet';
const req = createRequest(
`${ENDPOINT}/${actionSetId}/on`,
{
method: 'POST',
},
requestId,
);
await makeRequest(req.caller, req.id);
};
const disableActionSet = async (actionSetId: number) => {
const requestId = 'disableActionSet';
const req = createRequest(
`${ENDPOINT}/${actionSetId}/off`,
{
method: 'POST',
},
requestId,
);
await makeRequest(req.caller, req.id);
};
const toggleActionSet = async (actionSetId: number, enabled: boolean) => {
if (enabled) {
await enableActionSet(actionSetId);
} else {
await disableActionSet(actionSetId);
}
};
const removeActionSet = async (actionSetId: number) => { const removeActionSet = async (actionSetId: number) => {
const requestId = 'removeActionSet'; const requestId = 'removeActionSet';
const req = createRequest( const req = createRequest(
@ -67,6 +101,7 @@ export const useActionsApi = () => {
addActionSet, addActionSet,
updateActionSet, updateActionSet,
removeActionSet, removeActionSet,
toggleActionSet,
errors, errors,
loading, loading,
}; };

View File

@ -1,5 +1,6 @@
export interface IActionSet { export interface IActionSet {
id: number; id: number;
enabled: boolean;
name: string; name: string;
project: string; project: string;
actorId: number; actorId: number;