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 }) => ( + + + + + + + View events + + + + )} + {({ hasAccess }) => ( { + 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); + }} + /> + { + setEventsModalOpen(false); + setModalOpen(true); + }} /> { + 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(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, + }; +}; diff --git a/frontend/src/interfaces/incomingWebhook.ts b/frontend/src/interfaces/incomingWebhook.ts index 44597f41c0..cc02b00158 100644 --- a/frontend/src/interfaces/incomingWebhook.ts +++ b/frontend/src/interfaces/incomingWebhook.ts @@ -15,3 +15,14 @@ export interface IIncomingWebhookToken { createdAt: string; createdByUserId: number; } + +type EventSource = 'incoming-webhook'; + +export interface IIncomingWebhookEvent { + id: number; + payload: Record; + createdAt: string; + source: EventSource; + sourceId: number; + tokenName: string; +}