From 32484460ef5661cbab726ee71d58f2c57915363e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Fri, 26 Jan 2024 08:20:30 +0000 Subject: [PATCH] chore: project actions table (#6039) https://linear.app/unleash/issue/2-1877/ui-add-actions-table Implements the new project actions table. ![image](https://github.com/Unleash/unleash/assets/14320932/2ce96669-4b8f-46cd-9a87-8b14f0682694) ![image](https://github.com/Unleash/unleash/assets/14320932/d73327f2-1e1a-4d57-8ef8-1f4518c4b5d9) ![image](https://github.com/Unleash/unleash/assets/14320932/27b9ffab-4fff-4fdf-808f-b778987fa198) --- .../ProjectActions/ProjectActions.tsx | 7 +- .../ProjectActionsActionsCell.tsx | 64 +++++ .../ProjectActionsActorCell.tsx | 23 ++ .../ProjectActionsDeleteDialog.tsx | 31 +++ .../ProjectActionsFiltersCell.tsx | 43 +++ .../ProjectActionsTable.tsx | 254 +++++++++++++++++- .../ProjectActionsTableActionsCell.tsx | 123 +++++++++ .../ProjectActionsTriggerCell.tsx | 66 +++++ .../actions/useActionsApi/useActionsApi.ts | 35 +++ frontend/src/interfaces/action.ts | 1 + 10 files changed, 644 insertions(+), 3 deletions(-) create mode 100644 frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsActionsCell.tsx create mode 100644 frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsActorCell.tsx create mode 100644 frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsDeleteDialog.tsx create mode 100644 frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsFiltersCell.tsx create mode 100644 frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsTableActionsCell.tsx create mode 100644 frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsTriggerCell.tsx diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActions.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActions.tsx index 651b070895e..fab97c11e04 100644 --- a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActions.tsx +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActions.tsx @@ -57,7 +57,12 @@ export const ProjectActions = () => { } > - + ); diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsActionsCell.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsActionsCell.tsx new file mode 100644 index 00000000000..b10bbce1036 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsActionsCell.tsx @@ -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 0 actions; + else return ; + } + + return ( + + + {actions.map(({ id, action, executionParams }) => ( +
+ {action} + + {Object.entries(executionParams).map( + ([param, value]) => ( +
  • + {param}: {value} +
  • + ), + )} +
    +
    + ))} + + } + > + {actions.length === 1 + ? '1 action' + : `${actions.length} actions`} +
    +
    + ); +}; diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsActorCell.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsActorCell.tsx new file mode 100644 index 00000000000..d3427841112 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsActorCell.tsx @@ -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 No service account; + } + + return {actor.name}; +}; diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsDeleteDialog.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsDeleteDialog.tsx new file mode 100644 index 00000000000..6f870d2ba21 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsDeleteDialog.tsx @@ -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>; + onConfirm: (action: IActionSet) => void; +} + +export const ProjectActionsDeleteDialog = ({ + action, + open, + setOpen, + onConfirm, +}: IProjectActionsDeleteDialogProps) => ( + onConfirm(action!)} + onClose={() => { + setOpen(false); + }} + > +

    + You are about to delete action: {action?.name} +

    +
    +); diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsFiltersCell.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsFiltersCell.tsx new file mode 100644 index 00000000000..41d8c69628a --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsFiltersCell.tsx @@ -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 0 filters; + } + + return ( + + + {filters.map(([parameter, value]) => ( + + {parameter}: {value} + + ))} + + } + > + {filters.length === 1 + ? '1 filter' + : `${filters.length} filters`} + + + ); +}; diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsTable.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsTable.tsx index bfc6d5dc99a..9f0208a1e80 100644 --- a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsTable.tsx +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsTable.tsx @@ -1,3 +1,253 @@ -export const ProjectActionsTable = () => { - return TODO; +import { useMemo, useState } from 'react'; +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>; + selectedAction?: IActionSet; + setSelectedAction: React.Dispatch< + React.SetStateAction + >; +} + +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 } }) => ( + + ), + }, + { + id: 'filters', + Header: 'Filters', + Cell: ({ + row: { original: action }, + }: { + row: { original: IActionSet }; + }) => , + maxWidth: 90, + }, + { + id: 'actor', + Header: 'Service account', + Cell: ({ + row: { original: action }, + }: { + row: { original: IActionSet }; + }) => ( + + ), + minWidth: 160, + }, + { + id: 'actions', + Header: 'Actions', + Cell: ({ + row: { original: action }, + }: { + row: { original: IActionSet }; + }) => ( + { + setSelectedAction(action); + setModalOpen(true); + }} + /> + ), + maxWidth: 130, + }, + { + Header: 'Enabled', + accessor: 'enabled', + Cell: ({ + row: { original: action }, + }: { row: { original: IActionSet } }) => ( + + onToggleAction(action, enabled) + } + /> + ), + sortType: 'boolean', + width: 90, + maxWidth: 90, + }, + { + id: 'table-actions', + Header: '', + align: 'center', + Cell: ({ + row: { original: action }, + }: { row: { original: IActionSet } }) => ( + { + 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 ( + <> + + + No actions available. Get started by adding one. + + } + /> + {/* */} + + + ); }; diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsTableActionsCell.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsTableActionsCell.tsx new file mode 100644 index 00000000000..94113cad497 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsTableActionsCell.tsx @@ -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); + + const open = Boolean(anchorEl); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const id = `action-${actionId}-actions`; + const menuId = `${id}-menu`; + + return ( + + + + + + + ({ + borderRadius: `${theme.shape.borderRadius}px`, + padding: theme.spacing(1, 1.5), + }), + }} + > + + + {({ hasAccess }) => ( + + + + + + + Edit + + + + )} + + + {({ hasAccess }) => ( + + + + + + + Remove + + + + )} + + + + + ); +}; diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsTriggerCell.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsTriggerCell.tsx new file mode 100644 index 00000000000..02b3db62122 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsTriggerCell.tsx @@ -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; + 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 No trigger; + } + + return ( + + + + + {trigger.name} + + + + ); +}; diff --git a/frontend/src/hooks/api/actions/useActionsApi/useActionsApi.ts b/frontend/src/hooks/api/actions/useActionsApi/useActionsApi.ts index 54032411d33..18cad8fc096 100644 --- a/frontend/src/hooks/api/actions/useActionsApi/useActionsApi.ts +++ b/frontend/src/hooks/api/actions/useActionsApi/useActionsApi.ts @@ -52,6 +52,40 @@ export const useActionsApi = () => { 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 requestId = 'removeActionSet'; const req = createRequest( @@ -67,6 +101,7 @@ export const useActionsApi = () => { addActionSet, updateActionSet, removeActionSet, + toggleActionSet, errors, loading, }; diff --git a/frontend/src/interfaces/action.ts b/frontend/src/interfaces/action.ts index e9a8073e402..d521c81f06b 100644 --- a/frontend/src/interfaces/action.ts +++ b/frontend/src/interfaces/action.ts @@ -1,5 +1,6 @@ export interface IActionSet { id: number; + enabled: boolean; name: string; project: string; actorId: number;