From 7af91c7e9d1afff46b232edf61e05f96952d90ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Thu, 11 Jan 2024 12:05:14 +0000 Subject: [PATCH] chore: incoming webhooks table (#5837) https://linear.app/unleash/issue/2-1817/ui-create-an-incoming-webhooks-configuration-page This adds an incoming webhooks page with the respective table. We plan on possibly extending the table with a couple more columns in a future PR. This allows us: - View all configured incoming webhooks; - Copy their URL to the clipboard; - Remove them; For "new" and "edit" operations we still need the incoming webhooks form/dialog, coming in a future PR. **Note**: Even though we are showing the full URL in the table for now, we may end up truncating its start in the future (e.g. `.../api/incoming-webhook/` - This decision depends on how it will look like after the rest of the columns are added. ![image](https://github.com/Unleash/unleash/assets/14320932/1cac3286-818f-4967-8686-43f78aa6bd33) --- .../src/component/admin/roles/RolesPage.tsx | 1 - .../common/PremiumFeature/PremiumFeature.tsx | 5 + .../incomingWebhooks/IncomingWebhooks.tsx | 41 ++++ .../IncomingWebhooksActionsCell.tsx | 138 +++++++++++ .../IncomingWebhooksDeleteDialog.tsx | 32 +++ .../IncomingWebhooksTable.tsx | 224 ++++++++++++++++++ .../IntegrationList/IntegrationList.tsx | 25 +- frontend/src/interfaces/incomingWebhook.ts | 1 + 8 files changed, 461 insertions(+), 6 deletions(-) create mode 100644 frontend/src/component/incomingWebhooks/IncomingWebhooks.tsx create mode 100644 frontend/src/component/incomingWebhooks/IncomingWebhooksTable/IncomingWebhooksActionsCell.tsx create mode 100644 frontend/src/component/incomingWebhooks/IncomingWebhooksTable/IncomingWebhooksDeleteDialog.tsx create mode 100644 frontend/src/component/incomingWebhooks/IncomingWebhooksTable/IncomingWebhooksTable.tsx diff --git a/frontend/src/component/admin/roles/RolesPage.tsx b/frontend/src/component/admin/roles/RolesPage.tsx index ddca15fde9..0ffa53f466 100644 --- a/frontend/src/component/admin/roles/RolesPage.tsx +++ b/frontend/src/component/admin/roles/RolesPage.tsx @@ -5,7 +5,6 @@ import { RolesTable } from './RolesTable/RolesTable'; import { PageContent } from 'component/common/PageContent/PageContent'; import { Tab, Tabs, styled, useMediaQuery } from '@mui/material'; import { Route, Routes, useLocation } from 'react-router-dom'; -import { CenteredNavLink } from '../menu/CenteredNavLink'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { PROJECT_ROLE_TYPE, ROOT_ROLE_TYPE } from '@server/util/constants'; import { useRoles } from 'hooks/api/getters/useRoles/useRoles'; diff --git a/frontend/src/component/common/PremiumFeature/PremiumFeature.tsx b/frontend/src/component/common/PremiumFeature/PremiumFeature.tsx index dbdff39e36..72ced7beb1 100644 --- a/frontend/src/component/common/PremiumFeature/PremiumFeature.tsx +++ b/frontend/src/component/common/PremiumFeature/PremiumFeature.tsx @@ -108,6 +108,11 @@ const PremiumFeatures = { url: 'https://docs.getunleash.io/reference/banners', label: 'Banners', }, + 'incoming-webhooks': { + plan: FeaturePlan.ENTERPRISE, + url: 'https://docs.getunleash.io/reference/incoming-webhooks', + label: 'Incoming Webhooks', + }, }; type PremiumFeatureType = keyof typeof PremiumFeatures; diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooks.tsx b/frontend/src/component/incomingWebhooks/IncomingWebhooks.tsx new file mode 100644 index 0000000000..b9cf52d9d7 --- /dev/null +++ b/frontend/src/component/incomingWebhooks/IncomingWebhooks.tsx @@ -0,0 +1,41 @@ +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import { PermissionGuard } from 'component/common/PermissionGuard/PermissionGuard'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { PremiumFeature } from 'component/common/PremiumFeature/PremiumFeature'; +import { IncomingWebhooksTable } from './IncomingWebhooksTable/IncomingWebhooksTable'; +import { IIncomingWebhook } from 'interfaces/incomingWebhook'; + +interface IIncomingWebhooksProps { + modalOpen: boolean; + setModalOpen: React.Dispatch>; + selectedIncomingWebhook?: IIncomingWebhook; + setSelectedIncomingWebhook: React.Dispatch< + React.SetStateAction + >; +} + +export const IncomingWebhooks = ({ + modalOpen, + setModalOpen, + selectedIncomingWebhook, + setSelectedIncomingWebhook, +}: IIncomingWebhooksProps) => { + const { isEnterprise } = useUiConfig(); + + if (!isEnterprise()) { + return ; + } + + return ( +
+ + + +
+ ); +}; diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksTable/IncomingWebhooksActionsCell.tsx b/frontend/src/component/incomingWebhooks/IncomingWebhooksTable/IncomingWebhooksActionsCell.tsx new file mode 100644 index 0000000000..02fc1e4418 --- /dev/null +++ b/frontend/src/component/incomingWebhooks/IncomingWebhooksTable/IncomingWebhooksActionsCell.tsx @@ -0,0 +1,138 @@ +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 FileCopyIcon from '@mui/icons-material/FileCopy'; +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)(({ theme }) => ({ + display: 'flex', + justifyContent: 'center', + paddingRight: theme.spacing(2), +})); + +interface IIncomingWebhooksActionsCellProps { + incomingWebhookId: number; + onCopyToClipboard: (event: React.SyntheticEvent) => void; + onEdit: (event: React.SyntheticEvent) => void; + onDelete: (event: React.SyntheticEvent) => void; +} + +export const IncomingWebhooksActionsCell = ({ + incomingWebhookId, + onCopyToClipboard, + onEdit, + onDelete, +}: IIncomingWebhooksActionsCellProps) => { + const [anchorEl, setAnchorEl] = useState(null); + + const open = Boolean(anchorEl); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const id = `incoming-webhook-${incomingWebhookId}-actions`; + const menuId = `${id}-menu`; + + return ( + + + + + + + ({ + borderRadius: `${theme.shape.borderRadius}px`, + padding: theme.spacing(1, 1.5), + }), + }} + > + + + + + + + Copy URL + + + + {({ hasAccess }) => ( + + + + + + + Edit + + + + )} + + + {({ hasAccess }) => ( + + + + + + + Remove + + + + )} + + + + + ); +}; diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksTable/IncomingWebhooksDeleteDialog.tsx b/frontend/src/component/incomingWebhooks/IncomingWebhooksTable/IncomingWebhooksDeleteDialog.tsx new file mode 100644 index 0000000000..b8568551b3 --- /dev/null +++ b/frontend/src/component/incomingWebhooks/IncomingWebhooksTable/IncomingWebhooksDeleteDialog.tsx @@ -0,0 +1,32 @@ +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import { IIncomingWebhook } from 'interfaces/incomingWebhook'; + +interface IIncomingWebhooksDeleteDialogProps { + incomingWebhook?: IIncomingWebhook; + open: boolean; + setOpen: React.Dispatch>; + onConfirm: (incomingWebhook: IIncomingWebhook) => void; +} + +export const IncomingWebhooksDeleteDialog = ({ + incomingWebhook, + open, + setOpen, + onConfirm, +}: IIncomingWebhooksDeleteDialogProps) => ( + onConfirm(incomingWebhook!)} + onClose={() => { + setOpen(false); + }} + > +

+ You are about to delete incoming webhook:{' '} + {incomingWebhook?.name} +

+
+); diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksTable/IncomingWebhooksTable.tsx b/frontend/src/component/incomingWebhooks/IncomingWebhooksTable/IncomingWebhooksTable.tsx new file mode 100644 index 0000000000..f54dc26e31 --- /dev/null +++ b/frontend/src/component/incomingWebhooks/IncomingWebhooksTable/IncomingWebhooksTable.tsx @@ -0,0 +1,224 @@ +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 { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; +import theme from 'themes/theme'; +import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns'; +import { useIncomingWebhooks } from 'hooks/api/getters/useIncomingWebhooks/useIncomingWebhooks'; +import { useIncomingWebhooksApi } from 'hooks/api/actions/useIncomingWebhooksApi/useIncomingWebhooksApi'; +import { IIncomingWebhook } from 'interfaces/incomingWebhook'; +import { IncomingWebhooksActionsCell } from './IncomingWebhooksActionsCell'; +import { IncomingWebhooksDeleteDialog } from './IncomingWebhooksDeleteDialog'; +import { ToggleCell } from 'component/common/Table/cells/ToggleCell/ToggleCell'; +import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; +import copy from 'copy-to-clipboard'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +// import { IncomingWebhooksModal } from '../IncomingWebhooksModal/IncomingWebhooksModal'; + +interface IIncomingWebhooksTableProps { + modalOpen: boolean; + setModalOpen: React.Dispatch>; + selectedIncomingWebhook?: IIncomingWebhook; + setSelectedIncomingWebhook: React.Dispatch< + React.SetStateAction + >; +} + +export const IncomingWebhooksTable = ({ + modalOpen, + setModalOpen, + selectedIncomingWebhook, + setSelectedIncomingWebhook, +}: IIncomingWebhooksTableProps) => { + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + + const { incomingWebhooks, refetch, loading } = useIncomingWebhooks(); + const { toggleIncomingWebhook, removeIncomingWebhook } = + useIncomingWebhooksApi(); + + const [deleteOpen, setDeleteOpen] = useState(false); + + const onToggleIncomingWebhook = async ( + incomingWebhook: IIncomingWebhook, + enabled: boolean, + ) => { + try { + await toggleIncomingWebhook(incomingWebhook.id, enabled); + setToastData({ + title: `"${incomingWebhook.name}" has been ${ + enabled ? 'enabled' : 'disabled' + }`, + type: 'success', + }); + refetch(); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const onDeleteConfirm = async (incomingWebhook: IIncomingWebhook) => { + try { + await removeIncomingWebhook(incomingWebhook.id); + setToastData({ + title: `"${incomingWebhook.name}" has been deleted`, + type: 'success', + }); + refetch(); + setDeleteOpen(false); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + + const columns = useMemo( + () => [ + { + Header: 'Name', + accessor: 'name', + Cell: ({ + row: { original: incomingWebhook }, + }: { row: { original: IIncomingWebhook } }) => ( + + ), + minWidth: 200, + }, + { + Header: 'URL', + accessor: (row: IIncomingWebhook) => + `${uiConfig.unleashUrl}/api/incoming-webhook/${row.name}`, + minWidth: 200, + }, + { + Header: 'Created', + accessor: 'createdAt', + Cell: DateCell, + width: 120, + maxWidth: 120, + }, + { + Header: 'Enabled', + accessor: 'enabled', + Cell: ({ + row: { original: incomingWebhook }, + }: { row: { original: IIncomingWebhook } }) => ( + + onToggleIncomingWebhook(incomingWebhook, enabled) + } + /> + ), + sortType: 'boolean', + width: 90, + maxWidth: 90, + }, + { + Header: 'Actions', + id: 'Actions', + align: 'center', + Cell: ({ + row: { original: incomingWebhook }, + }: { row: { original: IIncomingWebhook } }) => ( + { + copy( + `${uiConfig.unleashUrl}/api/incoming-webhook/${incomingWebhook.name}`, + ); + setToastData({ + type: 'success', + title: 'Copied to clipboard', + }); + }} + onEdit={() => { + setSelectedIncomingWebhook(incomingWebhook); + setModalOpen(true); + }} + onDelete={() => { + setSelectedIncomingWebhook(incomingWebhook); + setDeleteOpen(true); + }} + /> + ), + width: 100, + disableSortBy: true, + }, + ], + [], + ); + + const [initialState] = useState({ + sortBy: [{ id: 'createdAt', desc: true }], + }); + + const { headerGroups, rows, prepareRow, setHiddenColumns } = useTable( + { + columns: columns as any, + data: incomingWebhooks, + initialState, + sortTypes, + autoResetHiddenColumns: false, + autoResetSortBy: false, + disableSortRemove: true, + disableMultiSort: true, + defaultColumn: { + Cell: TextCell, + }, + }, + useSortBy, + useFlexLayout, + ); + + useConditionallyHiddenColumns( + [ + { + condition: isSmallScreen, + columns: ['createdAt'], + }, + ], + setHiddenColumns, + columns, + ); + + return ( + <> + + + No incoming webhooks available. Get started by adding + one. + + } + /> + {/* */} + + + ); +}; diff --git a/frontend/src/component/integrations/IntegrationList/IntegrationList.tsx b/frontend/src/component/integrations/IntegrationList/IntegrationList.tsx index 949f802b48..b6e1924799 100644 --- a/frontend/src/component/integrations/IntegrationList/IntegrationList.tsx +++ b/frontend/src/component/integrations/IntegrationList/IntegrationList.tsx @@ -1,4 +1,4 @@ -import { VFC } from 'react'; +import { VFC, useState } from 'react'; import useAddons from 'hooks/api/getters/useAddons/useAddons'; import { AvailableIntegrations } from './AvailableIntegrations/AvailableIntegrations'; import { ConfiguredIntegrations } from './ConfiguredIntegrations/ConfiguredIntegrations'; @@ -13,6 +13,8 @@ import { PageHeader } from 'component/common/PageHeader/PageHeader'; import { TabLink } from 'component/common/TabNav/TabLink'; import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton'; import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import { IIncomingWebhook } from 'interfaces/incomingWebhook'; +import { IncomingWebhooks } from 'component/incomingWebhooks/IncomingWebhooks'; const StyledHeader = styled('div')(() => ({ display: 'flex', @@ -37,11 +39,15 @@ export const IntegrationList: VFC = () => { const { providers, addons, loading } = useAddons(); const { incomingWebhooks } = useIncomingWebhooks(); + const [selectedIncomingWebhook, setSelectedIncomingWebhook] = + useState(); + const [incomingWebhookModalOpen, setIncomingWebhookModalOpen] = + useState(false); + const onNewIncomingWebhook = () => { navigate('/integrations/incoming-webhooks'); - // TODO: Implement: - // setSelectedIncomingWebhook(undefined); - // setIncomingWebhookModalOpen(true); + setSelectedIncomingWebhook(undefined); + setIncomingWebhookModalOpen(true); }; const tabs = [ @@ -114,7 +120,16 @@ export const IntegrationList: VFC = () => { TODO: Implement} + element={ + + } />