From 12ff4abe6af3831f8fc0b2b4446ebee4f5125be8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Nuno=20G=C3=B3is?=
Date: Fri, 23 Feb 2024 11:01:27 +0000
Subject: [PATCH] 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)
---
.../common/FormTemplate/FormTemplate.tsx | 6 +-
.../ReactJSONEditor}/ReactJSONEditor.tsx | 42 +++-
.../common/SidePanelList/SidePanelList.tsx | 122 ++++++++++++
.../SidePanelList/SidePanelListHeader.tsx | 48 +++++
.../SidePanelList/SidePanelListItem.tsx | 58 ++++++
.../VariantForm/VariantForm.tsx | 4 +-
.../IncomingWebhooksEventsModal.tsx | 179 ++++++++++++++++++
.../IncomingWebhooksModal.tsx | 21 +-
.../IncomingWebhooksActionsCell.tsx | 22 ++-
.../IncomingWebhooksTable.tsx | 20 ++
.../useIncomingWebhookEvents.ts | 79 ++++++++
frontend/src/interfaces/incomingWebhook.ts | 11 ++
12 files changed, 603 insertions(+), 9 deletions(-)
rename frontend/src/component/{feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm => common/ReactJSONEditor}/ReactJSONEditor.tsx (62%)
create mode 100644 frontend/src/component/common/SidePanelList/SidePanelList.tsx
create mode 100644 frontend/src/component/common/SidePanelList/SidePanelListHeader.tsx
create mode 100644 frontend/src/component/common/SidePanelList/SidePanelListItem.tsx
create mode 100644 frontend/src/component/incomingWebhooks/IncomingWebhooksEvents/IncomingWebhooksEventsModal.tsx
create mode 100644 frontend/src/hooks/api/getters/useIncomingWebhookEvents/useIncomingWebhookEvents.ts
diff --git a/frontend/src/component/common/FormTemplate/FormTemplate.tsx b/frontend/src/component/common/FormTemplate/FormTemplate.tsx
index a482aa786a..6ad462553c 100644
--- a/frontend/src/component/common/FormTemplate/FormTemplate.tsx
+++ b/frontend/src/component/common/FormTemplate/FormTemplate.tsx
@@ -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 = ({
showLink = true,
footer,
compact,
+ showGuidance = true,
}) => {
const { setToastData } = useToast();
const smallScreen = useMediaQuery(`(max-width:${1099}px)`);
@@ -252,7 +254,7 @@ const FormTemplate: React.FC = ({
return (
= ({
/>
({
+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 = (props) => {
+interface IReactJSONEditorProps extends JSONEditorPropsOptional {
+ editorStyle?: EditorStyle;
+}
+
+const VanillaJSONEditor: React.FC = (props) => {
const refContainer = useRef(null);
const refEditor = useRef(null);
@@ -58,11 +93,12 @@ const VanillaJSONEditor: React.FC = (props) => {
return ;
};
-const ReactJSONEditor: React.FC = (props) => {
+const ReactJSONEditor: React.FC = (props) => {
const { themeMode } = useContext(UIContext);
return (
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 = {
+ header: string;
+ maxWidth?: number;
+ align?: ColumnAlignment;
+ cell: (item: T) => ReactNode;
+};
+
+interface ISidePanelListProps {
+ items: T[];
+ columns: SidePanelListColumn[];
+ sidePanelHeader: string;
+ renderContent: (item: T) => ReactNode;
+ height?: number;
+ listEnd?: ReactNode;
+}
+
+export const SidePanelList = ({
+ items,
+ columns,
+ sidePanelHeader,
+ renderContent,
+ height,
+ listEnd,
+}: ISidePanelListProps) => {
+ const [selectedItem, setSelectedItem] = useState(items[0]);
+
+ if (items.length === 0) {
+ return null;
+ }
+
+ const activeItem = selectedItem || items[0];
+
+ return (
+
+
+
+
+ {items.map((item) => (
+ setSelectedItem(item)}
+ >
+ {columns.map(
+ ({ header, maxWidth, align, cell }) => (
+
+ {cell(item)}
+
+ ),
+ )}
+
+ ))}
+ {listEnd}
+
+
+ {renderContent(activeItem)}
+
+
+
+ );
+};
diff --git a/frontend/src/component/common/SidePanelList/SidePanelListHeader.tsx b/frontend/src/component/common/SidePanelList/SidePanelListHeader.tsx
new file mode 100644
index 0000000000..66777524b2
--- /dev/null
+++ b/frontend/src/component/common/SidePanelList/SidePanelListHeader.tsx
@@ -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 {
+ columns: SidePanelListColumn[];
+ sidePanelHeader: string;
+}
+
+export const SidePanelListHeader = ({
+ columns,
+ sidePanelHeader,
+}: ISidePanelListHeaderProps) => (
+
+
+ {columns.map(({ header, maxWidth, align }) => (
+
+ {header}
+
+ ))}
+
+
+
+ {sidePanelHeader}
+
+
+
+);
diff --git a/frontend/src/component/common/SidePanelList/SidePanelListItem.tsx b/frontend/src/component/common/SidePanelList/SidePanelListItem.tsx
new file mode 100644
index 0000000000..f8267659cd
--- /dev/null
+++ b/frontend/src/component/common/SidePanelList/SidePanelListItem.tsx
@@ -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 {
+ selected: boolean;
+ onClick: () => void;
+ children: ReactNode;
+}
+
+export const SidePanelListItem = ({
+ selected,
+ onClick,
+ children,
+}: ISidePanelListItemProps) => (
+
+
+ {children}
+
+
+);
diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantForm.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantForm.tsx
index 086ae1a3bd..ed73630950 100644
--- a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantForm.tsx
+++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantForm.tsx
@@ -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',
diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksEvents/IncomingWebhooksEventsModal.tsx b/frontend/src/component/incomingWebhooks/IncomingWebhooksEvents/IncomingWebhooksEventsModal.tsx
new file mode 100644
index 0000000000..3bb7ac0a53
--- /dev/null
+++ b/frontend/src/component/incomingWebhooks/IncomingWebhooksEvents/IncomingWebhooksEventsModal.tsx
@@ -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>;
+ 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 (
+ {
+ setOpen(false);
+ }}
+ label={title}
+ >
+
+
+
+ {title}
+
+ View configuration
+
+
+
+
+ {uiConfig.unleashUrl}/api/incoming-webhook/
+ {incomingWebhook.name}
+
+
+ {incomingWebhook.description}
+
+
+
+
+
+ formatDateYMDHMS(
+ event.createdAt,
+ locationSettings?.locale,
+ ),
+ },
+ {
+ header: 'Token',
+ cell: (event) => event.tokenName,
+ },
+ ]}
+ sidePanelHeader='Payload'
+ renderContent={(event) => (
+
+
+
+ )}
+ listEnd={
+
+ Load more
+
+ }
+ />
+ }
+ />
+
+ No events have been received for this incoming
+ webhook.
+
+ }
+ />
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksModal.tsx b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksModal.tsx
index 4301b7300b..375f136957 100644
--- a/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksModal.tsx
+++ b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksModal.tsx
@@ -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>;
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 = ({
+
+ {title}
+ View events
+
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 = ({
Copy URL
+
+ {({ hasAccess }) => (
+
+ )}
+
{({ hasAccess }) => (