mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-20 00:08:02 +01:00
chore: incoming webhook events UI (#6317)
https://linear.app/unleash/issue/2-1937/incoming-webhook-events-ui This PR implements the UI for incoming webhook events. We're also introducing a new `SidePanelList` component that we'll be able to reuse when we tackle action set events. This PR also promotes `ReactJSONEditor` to a common component and adapts it slightly for this use case. ![image](https://github.com/Unleash/unleash/assets/14320932/b1abc2e0-3971-4882-b6f6-0ae48d1523d5) ![image](https://github.com/Unleash/unleash/assets/14320932/ce5c31e4-650a-4df5-a966-2ce06fd6baa8) We're refreshing the events view every 5s, so if you're monitoring events for a specific incoming webhook you can see the latest ones coming in. We load 20 (configurable through the hook) events by default. Everytime you reach the end of the list you can load 20 more events until you reach the end of the event list. ![image](https://github.com/Unleash/unleash/assets/14320932/94f187a1-8b0f-4138-8dbc-d3ebc9914bfd)
This commit is contained in:
parent
a54ef27adc
commit
12ff4abe6a
@ -32,6 +32,7 @@ interface ICreateProps {
|
||||
formatApiCode?: () => string;
|
||||
footer?: ReactNode;
|
||||
compact?: boolean;
|
||||
showGuidance?: boolean;
|
||||
}
|
||||
|
||||
const StyledContainer = styled('section', {
|
||||
@ -202,6 +203,7 @@ const FormTemplate: React.FC<ICreateProps> = ({
|
||||
showLink = true,
|
||||
footer,
|
||||
compact,
|
||||
showGuidance = true,
|
||||
}) => {
|
||||
const { setToastData } = useToast();
|
||||
const smallScreen = useMediaQuery(`(max-width:${1099}px)`);
|
||||
@ -252,7 +254,7 @@ const FormTemplate: React.FC<ICreateProps> = ({
|
||||
return (
|
||||
<StyledContainer modal={modal} compact={compact}>
|
||||
<ConditionallyRender
|
||||
condition={smallScreen}
|
||||
condition={showGuidance && smallScreen}
|
||||
show={
|
||||
<StyledRelativeDiv>
|
||||
<MobileGuidance
|
||||
@ -293,7 +295,7 @@ const FormTemplate: React.FC<ICreateProps> = ({
|
||||
/>
|
||||
</StyledMain>
|
||||
<ConditionallyRender
|
||||
condition={!smallScreen}
|
||||
condition={showGuidance && !smallScreen}
|
||||
show={
|
||||
<Guidance
|
||||
description={description}
|
||||
|
@ -4,7 +4,11 @@ import 'vanilla-jsoneditor/themes/jse-theme-dark.css';
|
||||
import { styled } from '@mui/material';
|
||||
import UIContext from 'contexts/UIContext';
|
||||
|
||||
const JSONEditorThemeWrapper = styled('div')(({ theme }) => ({
|
||||
type EditorStyle = 'default' | 'sidePanel';
|
||||
|
||||
const JSONEditorThemeWrapper = styled('div', {
|
||||
shouldForwardProp: (prop) => prop !== 'editorStyle',
|
||||
})<{ editorStyle?: EditorStyle }>(({ theme, editorStyle = 'default' }) => ({
|
||||
'&.jse-theme-dark': {
|
||||
'--jse-background-color': theme.palette.background.default,
|
||||
'--jse-panel-background': theme.palette.background.default,
|
||||
@ -24,9 +28,40 @@ const JSONEditorThemeWrapper = styled('div')(({ theme }) => ({
|
||||
borderBottomLeftRadius: theme.shape.borderRadius,
|
||||
borderBottomRightRadius: theme.shape.borderRadius,
|
||||
},
|
||||
...(editorStyle === 'sidePanel' && {
|
||||
'&&&': {
|
||||
'& .jse-main': {
|
||||
minHeight: 0,
|
||||
},
|
||||
'--jse-main-border': 0,
|
||||
'& > div': {
|
||||
height: '100%',
|
||||
},
|
||||
'& .jse-focus': {
|
||||
'--jse-main-border': 0,
|
||||
},
|
||||
'& .cm-gutters': {
|
||||
'--jse-panel-background': 'transparent',
|
||||
'--jse-panel-border': 'transparent',
|
||||
},
|
||||
'& .cm-gutter-lint': {
|
||||
width: 0,
|
||||
},
|
||||
'& .jse-text-mode': {
|
||||
borderBottomRightRadius: theme.shape.borderRadiusMedium,
|
||||
},
|
||||
'& .cm-scroller': {
|
||||
'--jse-delimiter-color': theme.palette.text.primary,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const VanillaJSONEditor: React.FC<JSONEditorPropsOptional> = (props) => {
|
||||
interface IReactJSONEditorProps extends JSONEditorPropsOptional {
|
||||
editorStyle?: EditorStyle;
|
||||
}
|
||||
|
||||
const VanillaJSONEditor: React.FC<IReactJSONEditorProps> = (props) => {
|
||||
const refContainer = useRef<HTMLDivElement | null>(null);
|
||||
const refEditor = useRef<JSONEditor | null>(null);
|
||||
|
||||
@ -58,11 +93,12 @@ const VanillaJSONEditor: React.FC<JSONEditorPropsOptional> = (props) => {
|
||||
return <div ref={refContainer} />;
|
||||
};
|
||||
|
||||
const ReactJSONEditor: React.FC<JSONEditorPropsOptional> = (props) => {
|
||||
const ReactJSONEditor: React.FC<IReactJSONEditorProps> = (props) => {
|
||||
const { themeMode } = useContext(UIContext);
|
||||
return (
|
||||
<JSONEditorThemeWrapper
|
||||
className={themeMode === 'dark' ? 'jse-theme-dark' : ''}
|
||||
editorStyle={props.editorStyle}
|
||||
>
|
||||
<VanillaJSONEditor
|
||||
mainMenuBar={false}
|
122
frontend/src/component/common/SidePanelList/SidePanelList.tsx
Normal file
122
frontend/src/component/common/SidePanelList/SidePanelList.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import { styled } from '@mui/material';
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { SidePanelListHeader } from './SidePanelListHeader';
|
||||
import { SidePanelListItem } from './SidePanelListItem';
|
||||
|
||||
const StyledSidePanelListWrapper = styled('div')({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
const StyledSidePanelListBody = styled('div')({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
});
|
||||
|
||||
const StyledSidePanelHalf = styled('div')({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
const StyledSidePanelHalfLeft = styled(StyledSidePanelHalf, {
|
||||
shouldForwardProp: (prop) => prop !== 'height',
|
||||
})<{ height?: number }>(({ theme, height }) => ({
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderTop: 0,
|
||||
borderBottomLeftRadius: theme.shape.borderRadiusMedium,
|
||||
overflow: 'auto',
|
||||
...(height && { height }),
|
||||
}));
|
||||
|
||||
const StyledSidePanelHalfRight = styled(StyledSidePanelHalf)(({ theme }) => ({
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderTop: 0,
|
||||
borderLeft: 0,
|
||||
borderBottomRightRadius: theme.shape.borderRadiusMedium,
|
||||
}));
|
||||
|
||||
type ColumnAlignment = 'start' | 'end' | 'center';
|
||||
|
||||
export const StyledSidePanelListColumn = styled('div', {
|
||||
shouldForwardProp: (prop) => prop !== 'maxWidth' && prop !== 'align',
|
||||
})<{ maxWidth?: number; align?: ColumnAlignment }>(
|
||||
({ theme, maxWidth, align = 'start' }) => ({
|
||||
flex: 1,
|
||||
padding: theme.spacing(2),
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
justifyContent: align,
|
||||
...(maxWidth && { maxWidth }),
|
||||
textAlign: align,
|
||||
}),
|
||||
);
|
||||
|
||||
export type SidePanelListColumn<T> = {
|
||||
header: string;
|
||||
maxWidth?: number;
|
||||
align?: ColumnAlignment;
|
||||
cell: (item: T) => ReactNode;
|
||||
};
|
||||
|
||||
interface ISidePanelListProps<T> {
|
||||
items: T[];
|
||||
columns: SidePanelListColumn<T>[];
|
||||
sidePanelHeader: string;
|
||||
renderContent: (item: T) => ReactNode;
|
||||
height?: number;
|
||||
listEnd?: ReactNode;
|
||||
}
|
||||
|
||||
export const SidePanelList = <T extends { id: string | number }>({
|
||||
items,
|
||||
columns,
|
||||
sidePanelHeader,
|
||||
renderContent,
|
||||
height,
|
||||
listEnd,
|
||||
}: ISidePanelListProps<T>) => {
|
||||
const [selectedItem, setSelectedItem] = useState<T>(items[0]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeItem = selectedItem || items[0];
|
||||
|
||||
return (
|
||||
<StyledSidePanelListWrapper>
|
||||
<SidePanelListHeader
|
||||
columns={columns}
|
||||
sidePanelHeader={sidePanelHeader}
|
||||
/>
|
||||
<StyledSidePanelListBody>
|
||||
<StyledSidePanelHalfLeft height={height}>
|
||||
{items.map((item) => (
|
||||
<SidePanelListItem
|
||||
key={item.id}
|
||||
selected={activeItem.id === item.id}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
>
|
||||
{columns.map(
|
||||
({ header, maxWidth, align, cell }) => (
|
||||
<StyledSidePanelListColumn
|
||||
key={header}
|
||||
maxWidth={maxWidth}
|
||||
align={align}
|
||||
>
|
||||
{cell(item)}
|
||||
</StyledSidePanelListColumn>
|
||||
),
|
||||
)}
|
||||
</SidePanelListItem>
|
||||
))}
|
||||
{listEnd}
|
||||
</StyledSidePanelHalfLeft>
|
||||
<StyledSidePanelHalfRight>
|
||||
{renderContent(activeItem)}
|
||||
</StyledSidePanelHalfRight>
|
||||
</StyledSidePanelListBody>
|
||||
</StyledSidePanelListWrapper>
|
||||
);
|
||||
};
|
@ -0,0 +1,48 @@
|
||||
import { styled } from '@mui/material';
|
||||
import {
|
||||
SidePanelListColumn,
|
||||
StyledSidePanelListColumn,
|
||||
} from './SidePanelList';
|
||||
|
||||
const StyledHeader = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderTopLeftRadius: theme.shape.borderRadiusMedium,
|
||||
borderTopRightRadius: theme.shape.borderRadiusMedium,
|
||||
backgroundColor: theme.palette.table.headerBackground,
|
||||
}));
|
||||
|
||||
const StyledHeaderHalf = styled('div')({
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
interface ISidePanelListHeaderProps<T> {
|
||||
columns: SidePanelListColumn<T>[];
|
||||
sidePanelHeader: string;
|
||||
}
|
||||
|
||||
export const SidePanelListHeader = <T,>({
|
||||
columns,
|
||||
sidePanelHeader,
|
||||
}: ISidePanelListHeaderProps<T>) => (
|
||||
<StyledHeader>
|
||||
<StyledHeaderHalf>
|
||||
{columns.map(({ header, maxWidth, align }) => (
|
||||
<StyledSidePanelListColumn
|
||||
key={header}
|
||||
maxWidth={maxWidth}
|
||||
align={align}
|
||||
>
|
||||
{header}
|
||||
</StyledSidePanelListColumn>
|
||||
))}
|
||||
</StyledHeaderHalf>
|
||||
<StyledHeaderHalf>
|
||||
<StyledSidePanelListColumn>
|
||||
{sidePanelHeader}
|
||||
</StyledSidePanelListColumn>
|
||||
</StyledHeaderHalf>
|
||||
</StyledHeader>
|
||||
);
|
@ -0,0 +1,58 @@
|
||||
import { Button, styled } from '@mui/material';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
const StyledItemRow = styled('div')(({ theme }) => ({
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
}));
|
||||
|
||||
const StyledItem = styled(Button, {
|
||||
shouldForwardProp: (prop) => prop !== 'selected',
|
||||
})<{ selected: boolean }>(({ theme, selected }) => ({
|
||||
'&.MuiButton-root': {
|
||||
width: '100%',
|
||||
backgroundColor: selected
|
||||
? theme.palette.secondary.light
|
||||
: 'transparent',
|
||||
borderRight: `${theme.spacing(0.5)} solid ${
|
||||
selected ? theme.palette.background.alternative : 'transparent'
|
||||
}`,
|
||||
padding: 0,
|
||||
borderRadius: 0,
|
||||
justifyContent: 'start',
|
||||
transition: 'background-color 0.2s ease',
|
||||
color: theme.palette.text.primary,
|
||||
textAlign: 'left',
|
||||
fontWeight: selected ? theme.fontWeight.bold : theme.fontWeight.medium,
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
overflow: 'auto',
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: selected
|
||||
? theme.palette.secondary.light
|
||||
: theme.palette.neutral.light,
|
||||
},
|
||||
'&.Mui-disabled': {
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
'&:focus-visible': {
|
||||
outline: `2px solid ${theme.palette.primary.main}`,
|
||||
},
|
||||
}));
|
||||
|
||||
interface ISidePanelListItemProps<T> {
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const SidePanelListItem = <T,>({
|
||||
selected,
|
||||
onClick,
|
||||
children,
|
||||
}: ISidePanelListItemProps<T>) => (
|
||||
<StyledItemRow>
|
||||
<StyledItem selected={selected} onClick={onClick}>
|
||||
{children}
|
||||
</StyledItem>
|
||||
</StyledItemRow>
|
||||
);
|
@ -20,7 +20,9 @@ import { WeightType } from 'constants/variantTypes';
|
||||
import { IFeatureVariantEdit } from '../EnvironmentVariantsModal';
|
||||
import { Delete } from '@mui/icons-material';
|
||||
|
||||
const LazyReactJSONEditor = React.lazy(() => import('./ReactJSONEditor'));
|
||||
const LazyReactJSONEditor = React.lazy(
|
||||
() => import('component/common/ReactJSONEditor/ReactJSONEditor'),
|
||||
);
|
||||
|
||||
const StyledVariantForm = styled('div')(({ theme }) => ({
|
||||
position: 'relative',
|
||||
|
@ -0,0 +1,179 @@
|
||||
import { Button, Link, styled } from '@mui/material';
|
||||
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
|
||||
import { IIncomingWebhook } from 'interfaces/incomingWebhook';
|
||||
import { useIncomingWebhookEvents } from 'hooks/api/getters/useIncomingWebhookEvents/useIncomingWebhookEvents';
|
||||
import { Suspense, lazy } from 'react';
|
||||
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import { SidePanelList } from 'component/common/SidePanelList/SidePanelList';
|
||||
import { formatDateYMDHMS } from 'utils/formatDate';
|
||||
import { useLocationSettings } from 'hooks/useLocationSettings';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
|
||||
const LazyReactJSONEditor = lazy(
|
||||
() => import('component/common/ReactJSONEditor/ReactJSONEditor'),
|
||||
);
|
||||
|
||||
const StyledHeader = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginBottom: theme.fontSizes.mainHeader,
|
||||
}));
|
||||
|
||||
const StyledHeaderRow = styled('div')({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
const StyledHeaderSubtitle = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginTop: theme.spacing(2),
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
}));
|
||||
|
||||
const StyledDescription = styled('p')(({ theme }) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
}));
|
||||
|
||||
const StyledTitle = styled('h1')({
|
||||
fontWeight: 'normal',
|
||||
});
|
||||
|
||||
const StyledForm = styled('form')({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
const StyledButtonContainer = styled('div')(({ theme }) => ({
|
||||
marginTop: 'auto',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
paddingTop: theme.spacing(4),
|
||||
}));
|
||||
|
||||
interface IIncomingWebhooksEventsModalProps {
|
||||
incomingWebhook?: IIncomingWebhook;
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
onOpenConfiguration: () => void;
|
||||
}
|
||||
|
||||
export const IncomingWebhooksEventsModal = ({
|
||||
incomingWebhook,
|
||||
open,
|
||||
setOpen,
|
||||
onOpenConfiguration,
|
||||
}: IIncomingWebhooksEventsModalProps) => {
|
||||
const { uiConfig } = useUiConfig();
|
||||
const { locationSettings } = useLocationSettings();
|
||||
const { incomingWebhookEvents, hasMore, loadMore, loading } =
|
||||
useIncomingWebhookEvents(incomingWebhook?.id, 20, {
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
|
||||
if (!incomingWebhook) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = `Events: ${incomingWebhook.name}`;
|
||||
|
||||
return (
|
||||
<SidebarModal
|
||||
open={open}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
label={title}
|
||||
>
|
||||
<FormTemplate
|
||||
loading={loading && incomingWebhookEvents.length === 0}
|
||||
modal
|
||||
description='Incoming Webhooks allow third-party services to send observable events to Unleash.'
|
||||
documentationLink='https://docs.getunleash.io/reference/incoming-webhooks'
|
||||
documentationLinkLabel='Incoming webhooks documentation'
|
||||
showGuidance={false}
|
||||
>
|
||||
<StyledHeader>
|
||||
<StyledHeaderRow>
|
||||
<StyledTitle>{title}</StyledTitle>
|
||||
<Link onClick={onOpenConfiguration}>
|
||||
View configuration
|
||||
</Link>
|
||||
</StyledHeaderRow>
|
||||
<StyledHeaderSubtitle>
|
||||
<p>
|
||||
{uiConfig.unleashUrl}/api/incoming-webhook/
|
||||
{incomingWebhook.name}
|
||||
</p>
|
||||
<StyledDescription>
|
||||
{incomingWebhook.description}
|
||||
</StyledDescription>
|
||||
</StyledHeaderSubtitle>
|
||||
</StyledHeader>
|
||||
<StyledForm>
|
||||
<SidePanelList
|
||||
height={960}
|
||||
items={incomingWebhookEvents}
|
||||
columns={[
|
||||
{
|
||||
header: 'Date',
|
||||
cell: (event) =>
|
||||
formatDateYMDHMS(
|
||||
event.createdAt,
|
||||
locationSettings?.locale,
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Token',
|
||||
cell: (event) => event.tokenName,
|
||||
},
|
||||
]}
|
||||
sidePanelHeader='Payload'
|
||||
renderContent={(event) => (
|
||||
<Suspense fallback={null}>
|
||||
<LazyReactJSONEditor
|
||||
content={{ json: event.payload }}
|
||||
readOnly
|
||||
statusBar={false}
|
||||
editorStyle='sidePanel'
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
listEnd={
|
||||
<ConditionallyRender
|
||||
condition={hasMore}
|
||||
show={
|
||||
<Button onClick={loadMore}>
|
||||
Load more
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={incomingWebhookEvents.length === 0}
|
||||
show={
|
||||
<p>
|
||||
No events have been received for this incoming
|
||||
webhook.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
<StyledButtonContainer>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</StyledButtonContainer>
|
||||
</StyledForm>
|
||||
</FormTemplate>
|
||||
</SidebarModal>
|
||||
);
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
import { FormEvent, useEffect } from 'react';
|
||||
import { Button, styled } from '@mui/material';
|
||||
import { Button, Link, styled } from '@mui/material';
|
||||
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
|
||||
@ -18,6 +18,18 @@ import {
|
||||
useIncomingWebhooksForm,
|
||||
} from './IncomingWebhooksForm/useIncomingWebhooksForm';
|
||||
|
||||
const StyledHeader = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
marginBottom: theme.fontSizes.mainHeader,
|
||||
}));
|
||||
|
||||
const StyledTitle = styled('h1')({
|
||||
fontWeight: 'normal',
|
||||
});
|
||||
|
||||
const StyledForm = styled('form')(() => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@ -40,6 +52,7 @@ interface IIncomingWebhooksModalProps {
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
newToken: (token: string) => void;
|
||||
onOpenEvents: () => void;
|
||||
}
|
||||
|
||||
export const IncomingWebhooksModal = ({
|
||||
@ -47,6 +60,7 @@ export const IncomingWebhooksModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
newToken,
|
||||
onOpenEvents,
|
||||
}: IIncomingWebhooksModalProps) => {
|
||||
const { refetch } = useIncomingWebhooks();
|
||||
const { addIncomingWebhook, updateIncomingWebhook, loading } =
|
||||
@ -137,12 +151,15 @@ export const IncomingWebhooksModal = ({
|
||||
<FormTemplate
|
||||
loading={loading}
|
||||
modal
|
||||
title={title}
|
||||
description='Incoming Webhooks allow third-party services to send observable events to Unleash.'
|
||||
documentationLink='https://docs.getunleash.io/reference/incoming-webhooks'
|
||||
documentationLinkLabel='Incoming webhooks documentation'
|
||||
formatApiCode={formatApiCode}
|
||||
>
|
||||
<StyledHeader>
|
||||
<StyledTitle>{title}</StyledTitle>
|
||||
<Link onClick={onOpenEvents}>View events</Link>
|
||||
</StyledHeader>
|
||||
<StyledForm onSubmit={onSubmit}>
|
||||
<IncomingWebhooksForm
|
||||
incomingWebhook={incomingWebhook}
|
||||
|
@ -13,7 +13,7 @@ import {
|
||||
} 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 { Delete, Edit, Visibility } from '@mui/icons-material';
|
||||
import { PermissionHOC } from 'component/common/PermissionHOC/PermissionHOC';
|
||||
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
||||
import { defaultBorderRadius } from 'themes/themeStyles';
|
||||
@ -26,6 +26,7 @@ const StyledBoxCell = styled(Box)({
|
||||
interface IIncomingWebhooksActionsCellProps {
|
||||
incomingWebhookId: number;
|
||||
onCopyToClipboard: (event: React.SyntheticEvent) => void;
|
||||
onOpenEvents: (event: React.SyntheticEvent) => void;
|
||||
onEdit: (event: React.SyntheticEvent) => void;
|
||||
onDelete: (event: React.SyntheticEvent) => void;
|
||||
}
|
||||
@ -33,6 +34,7 @@ interface IIncomingWebhooksActionsCellProps {
|
||||
export const IncomingWebhooksActionsCell = ({
|
||||
incomingWebhookId,
|
||||
onCopyToClipboard,
|
||||
onOpenEvents,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: IIncomingWebhooksActionsCellProps) => {
|
||||
@ -94,6 +96,24 @@ export const IncomingWebhooksActionsCell = ({
|
||||
<Typography variant='body2'>Copy URL</Typography>
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
<PermissionHOC permission={ADMIN}>
|
||||
{({ hasAccess }) => (
|
||||
<MenuItem
|
||||
sx={defaultBorderRadius}
|
||||
onClick={onOpenEvents}
|
||||
disabled={!hasAccess}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Visibility />
|
||||
</ListItemIcon>
|
||||
<ListItemText>
|
||||
<Typography variant='body2'>
|
||||
View events
|
||||
</Typography>
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
</PermissionHOC>
|
||||
<PermissionHOC permission={ADMIN}>
|
||||
{({ hasAccess }) => (
|
||||
<MenuItem
|
||||
|
@ -22,6 +22,7 @@ import { IncomingWebhookTokensCell } from './IncomingWebhooksTokensCell';
|
||||
import { IncomingWebhooksModal } from '../IncomingWebhooksModal/IncomingWebhooksModal';
|
||||
import { IncomingWebhooksTokensDialog } from '../IncomingWebhooksModal/IncomingWebhooksForm/IncomingWebhooksTokens/IncomingWebhooksTokensDialog';
|
||||
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
|
||||
import { IncomingWebhooksEventsModal } from '../IncomingWebhooksEvents/IncomingWebhooksEventsModal';
|
||||
|
||||
interface IIncomingWebhooksTableProps {
|
||||
modalOpen: boolean;
|
||||
@ -49,6 +50,8 @@ export const IncomingWebhooksTable = ({
|
||||
const [newToken, setNewToken] = useState('');
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
const [eventsModalOpen, setEventsModalOpen] = useState(false);
|
||||
|
||||
const onToggleIncomingWebhook = async (
|
||||
incomingWebhook: IIncomingWebhook,
|
||||
enabled: boolean,
|
||||
@ -174,6 +177,10 @@ export const IncomingWebhooksTable = ({
|
||||
title: 'Copied to clipboard',
|
||||
});
|
||||
}}
|
||||
onOpenEvents={() => {
|
||||
setSelectedIncomingWebhook(incomingWebhook);
|
||||
setEventsModalOpen(true);
|
||||
}}
|
||||
onEdit={() => {
|
||||
setSelectedIncomingWebhook(incomingWebhook);
|
||||
setModalOpen(true);
|
||||
@ -248,6 +255,19 @@ export const IncomingWebhooksTable = ({
|
||||
setNewToken(token);
|
||||
setTokenDialog(true);
|
||||
}}
|
||||
onOpenEvents={() => {
|
||||
setModalOpen(false);
|
||||
setEventsModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
<IncomingWebhooksEventsModal
|
||||
incomingWebhook={selectedIncomingWebhook}
|
||||
open={eventsModalOpen}
|
||||
setOpen={setEventsModalOpen}
|
||||
onOpenConfiguration={() => {
|
||||
setEventsModalOpen(false);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
<IncomingWebhooksTokensDialog
|
||||
open={tokenDialog}
|
||||
|
@ -0,0 +1,79 @@
|
||||
import useSWRInfinite, {
|
||||
SWRInfiniteConfiguration,
|
||||
SWRInfiniteKeyLoader,
|
||||
} from 'swr/infinite';
|
||||
import { formatApiPath } from 'utils/formatPath';
|
||||
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||
import useUiConfig from '../useUiConfig/useUiConfig';
|
||||
import { IIncomingWebhookEvent } from 'interfaces/incomingWebhook';
|
||||
import { useUiFlag } from 'hooks/useUiFlag';
|
||||
|
||||
const ENDPOINT = 'api/admin/incoming-webhooks';
|
||||
|
||||
type IncomingWebhookEventsResponse = {
|
||||
incomingWebhookEvents: IIncomingWebhookEvent[];
|
||||
};
|
||||
|
||||
const fetcher = async (url: string) => {
|
||||
const response = await fetch(url);
|
||||
await handleErrorResponses('Incoming webhook events')(response);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const useIncomingWebhookEvents = (
|
||||
incomingWebhookId?: number,
|
||||
limit = 50,
|
||||
options: SWRInfiniteConfiguration = {},
|
||||
) => {
|
||||
const { isEnterprise } = useUiConfig();
|
||||
const incomingWebhooksEnabled = useUiFlag('incomingWebhooks');
|
||||
|
||||
const getKey: SWRInfiniteKeyLoader = (
|
||||
pageIndex: number,
|
||||
previousPageData: IncomingWebhookEventsResponse,
|
||||
) => {
|
||||
// Does not meet conditions
|
||||
if (!incomingWebhookId || !isEnterprise || !incomingWebhooksEnabled)
|
||||
return null;
|
||||
|
||||
// Reached the end
|
||||
if (previousPageData && !previousPageData.incomingWebhookEvents.length)
|
||||
return null;
|
||||
|
||||
return formatApiPath(
|
||||
`${ENDPOINT}/${incomingWebhookId}/events?limit=${limit}&offset=${
|
||||
pageIndex * limit
|
||||
}`,
|
||||
);
|
||||
};
|
||||
|
||||
const { data, error, size, setSize, mutate } =
|
||||
useSWRInfinite<IncomingWebhookEventsResponse>(getKey, fetcher, {
|
||||
...options,
|
||||
revalidateAll: true,
|
||||
});
|
||||
|
||||
const incomingWebhookEvents = data
|
||||
? data.flatMap(({ incomingWebhookEvents }) => incomingWebhookEvents)
|
||||
: [];
|
||||
|
||||
const isLoadingInitialData = !data && !error;
|
||||
const isLoadingMore = size > 0 && !data?.[size - 1];
|
||||
const loading = isLoadingInitialData || isLoadingMore;
|
||||
|
||||
const hasMore = data?.[size - 1]?.incomingWebhookEvents.length === limit;
|
||||
|
||||
const loadMore = () => {
|
||||
if (loading || !hasMore) return;
|
||||
setSize(size + 1);
|
||||
};
|
||||
|
||||
return {
|
||||
incomingWebhookEvents,
|
||||
hasMore,
|
||||
loadMore,
|
||||
loading,
|
||||
refetch: () => mutate(),
|
||||
error,
|
||||
};
|
||||
};
|
@ -15,3 +15,14 @@ export interface IIncomingWebhookToken {
|
||||
createdAt: string;
|
||||
createdByUserId: number;
|
||||
}
|
||||
|
||||
type EventSource = 'incoming-webhook';
|
||||
|
||||
export interface IIncomingWebhookEvent {
|
||||
id: number;
|
||||
payload: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
source: EventSource;
|
||||
sourceId: number;
|
||||
tokenName: string;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user