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:
parent
6ae6193d3f
commit
7af91c7e9d
@ -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';
|
||||
|
@ -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;
|
||||
|
41
frontend/src/component/incomingWebhooks/IncomingWebhooks.tsx
Normal file
41
frontend/src/component/incomingWebhooks/IncomingWebhooks.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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='*'
|
||||
|
@ -4,6 +4,7 @@ export interface IIncomingWebhook {
|
||||
name: string;
|
||||
createdAt: string;
|
||||
createdByUserId: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface IIncomingWebhookToken {
|
||||
|
Loading…
Reference in New Issue
Block a user