1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

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/<webhook-name>` - 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)
This commit is contained in:
Nuno Góis 2024-01-11 12:05:14 +00:00 committed by GitHub
parent 6ae6193d3f
commit 7af91c7e9d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 461 additions and 6 deletions

View File

@ -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';

View File

@ -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;

View File

@ -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<React.SetStateAction<boolean>>;
selectedIncomingWebhook?: IIncomingWebhook;
setSelectedIncomingWebhook: React.Dispatch<
React.SetStateAction<IIncomingWebhook | undefined>
>;
}
export const IncomingWebhooks = ({
modalOpen,
setModalOpen,
selectedIncomingWebhook,
setSelectedIncomingWebhook,
}: IIncomingWebhooksProps) => {
const { isEnterprise } = useUiConfig();
if (!isEnterprise()) {
return <PremiumFeature feature='incoming-webhooks' page />;
}
return (
<div>
<PermissionGuard permissions={ADMIN}>
<IncomingWebhooksTable
modalOpen={modalOpen}
setModalOpen={setModalOpen}
selectedIncomingWebhook={selectedIncomingWebhook}
setSelectedIncomingWebhook={setSelectedIncomingWebhook}
/>
</PermissionGuard>
</div>
);
};

View File

@ -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 | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const id = `incoming-webhook-${incomingWebhookId}-actions`;
const menuId = `${id}-menu`;
return (
<StyledBoxCell>
<Tooltip title='Incoming webhook 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}>
<MenuItem
sx={defaultBorderRadius}
onClick={onCopyToClipboard}
>
<ListItemIcon>
<FileCopyIcon />
</ListItemIcon>
<ListItemText>
<Typography variant='body2'>Copy URL</Typography>
</ListItemText>
</MenuItem>
<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,32 @@
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { IIncomingWebhook } from 'interfaces/incomingWebhook';
interface IIncomingWebhooksDeleteDialogProps {
incomingWebhook?: IIncomingWebhook;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
onConfirm: (incomingWebhook: IIncomingWebhook) => void;
}
export const IncomingWebhooksDeleteDialog = ({
incomingWebhook,
open,
setOpen,
onConfirm,
}: IIncomingWebhooksDeleteDialogProps) => (
<Dialogue
title='Delete incoming webhook?'
open={open}
primaryButtonText='Delete incoming webhook'
secondaryButtonText='Cancel'
onClick={() => onConfirm(incomingWebhook!)}
onClose={() => {
setOpen(false);
}}
>
<p>
You are about to delete incoming webhook:{' '}
<strong>{incomingWebhook?.name}</strong>
</p>
</Dialogue>
);

View File

@ -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<React.SetStateAction<boolean>>;
selectedIncomingWebhook?: IIncomingWebhook;
setSelectedIncomingWebhook: React.Dispatch<
React.SetStateAction<IIncomingWebhook | undefined>
>;
}
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 } }) => (
<HighlightCell
value={incomingWebhook.name}
subtitle={incomingWebhook.description}
/>
),
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 } }) => (
<ToggleCell
checked={incomingWebhook.enabled}
setChecked={(enabled) =>
onToggleIncomingWebhook(incomingWebhook, enabled)
}
/>
),
sortType: 'boolean',
width: 90,
maxWidth: 90,
},
{
Header: 'Actions',
id: 'Actions',
align: 'center',
Cell: ({
row: { original: incomingWebhook },
}: { row: { original: IIncomingWebhook } }) => (
<IncomingWebhooksActionsCell
incomingWebhookId={incomingWebhook.id}
onCopyToClipboard={() => {
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 (
<>
<VirtualizedTable
rows={rows}
headerGroups={headerGroups}
prepareRow={prepareRow}
/>
<ConditionallyRender
condition={rows.length === 0}
show={
<TablePlaceholder>
No incoming webhooks available. Get started by adding
one.
</TablePlaceholder>
}
/>
{/* <IncomingWebhooksModal
incomingWebhook={selectedIncomingWebhook}
open={modalOpen}
setOpen={setModalOpen}
/> */}
<IncomingWebhooksDeleteDialog
incomingWebhook={selectedIncomingWebhook}
open={deleteOpen}
setOpen={setDeleteOpen}
onConfirm={onDeleteConfirm}
/>
</>
);
};

View File

@ -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<IIncomingWebhook>();
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 = () => {
<Routes>
<Route
path='incoming-webhooks'
element={<span>TODO: Implement</span>}
element={
<IncomingWebhooks
modalOpen={incomingWebhookModalOpen}
setModalOpen={setIncomingWebhookModalOpen}
selectedIncomingWebhook={selectedIncomingWebhook}
setSelectedIncomingWebhook={
setSelectedIncomingWebhook
}
/>
}
/>
<Route
path='*'

View File

@ -4,6 +4,7 @@ export interface IIncomingWebhook {
name: string;
createdAt: string;
createdByUserId: number;
description: string;
}
export interface IIncomingWebhookToken {