From 12d196b3c56c406d2258847afeeca14119a6711d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Tue, 20 Feb 2024 13:04:16 +0000 Subject: [PATCH 1/6] chore: incoming webhook events UI --- .../common/FormTemplate/FormTemplate.tsx | 6 +- .../common/SidePanelList/SidePanelList.tsx | 3 + .../IncomingWebhooksEventsModal.tsx | 129 ++++++++++++++++++ .../IncomingWebhooksModal.tsx | 22 ++- .../IncomingWebhooksActionsCell.tsx | 22 ++- .../IncomingWebhooksTable.tsx | 20 +++ .../useIncomingWebhookEvents.ts | 49 +++++++ frontend/src/interfaces/incomingWebhook.ts | 11 ++ 8 files changed, 257 insertions(+), 5 deletions(-) create mode 100644 frontend/src/component/common/SidePanelList/SidePanelList.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 a482aa786a89..6ad462553c9c 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 ( = ({ /> { + return TODO: Implement; +}; diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksEvents/IncomingWebhooksEventsModal.tsx b/frontend/src/component/incomingWebhooks/IncomingWebhooksEvents/IncomingWebhooksEventsModal.tsx new file mode 100644 index 000000000000..50c6272df724 --- /dev/null +++ b/frontend/src/component/incomingWebhooks/IncomingWebhooksEvents/IncomingWebhooksEventsModal.tsx @@ -0,0 +1,129 @@ +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 { useState } from 'react'; +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { SidePanelList } from 'component/common/SidePanelList/SidePanelList'; + +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), +})); + +const LIMIT = 50; + +interface IIncomingWebhooksEventsModalProps { + incomingWebhook?: IIncomingWebhook; + open: boolean; + setOpen: React.Dispatch>; + onOpenConfiguration: () => void; +} + +export const IncomingWebhooksEventsModal = ({ + incomingWebhook, + open, + setOpen, + onOpenConfiguration, +}: IIncomingWebhooksEventsModalProps) => { + const { uiConfig } = useUiConfig(); + const [page, setPage] = useState(0); + const { incomingWebhookEvents, loading } = useIncomingWebhookEvents( + incomingWebhook?.id, + LIMIT, + page * LIMIT, + ); + + 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} + +
+
+ + + + + + +
+
+ ); +}; diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksModal.tsx b/frontend/src/component/incomingWebhooks/IncomingWebhooksModal/IncomingWebhooksModal.tsx index 4301b7300b73..ff789d63c7d1 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,16 @@ 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 { isEnterprise } = useUiConfig(); + const incomingWebhooksEnabled = useUiFlag('incomingWebhooks'); + + const { data, error, mutate } = useConditionalSWR<{ + incomingWebhookEvents: IIncomingWebhookEvent[]; + }>( + Boolean(incomingWebhookId) && isEnterprise() && incomingWebhooksEnabled, + DEFAULT_DATA, + formatApiPath( + `${ENDPOINT}/${incomingWebhookId}/events?limit=${limit}&offset=${offset}`, + ), + fetcher, + ); + + return useMemo( + () => ({ + incomingWebhookEvents: data?.incomingWebhookEvents ?? [], + loading: !error && !data, + refetch: () => mutate(), + error, + }), + [data, error, mutate], + ); +}; + +const fetcher = (path: string) => { + return fetch(path) + .then(handleErrorResponses('Incoming webhook events')) + .then((res) => res.json()); +}; diff --git a/frontend/src/interfaces/incomingWebhook.ts b/frontend/src/interfaces/incomingWebhook.ts index 44597f41c009..cc02b0015882 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; +} From ae141a43f9f481ef9a566fbba7a536667e7f9dba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Wed, 21 Feb 2024 08:24:13 +0000 Subject: [PATCH 2/6] ongoing implementation of incoming webhook events UI --- .../common/SidePanelList/SidePanelList.tsx | 55 ++++++++++++++++++- .../SidePanelList/SidePanelListHeader.tsx | 29 ++++++++++ .../SidePanelList/SidePanelListItem.tsx | 0 .../IncomingWebhooksEventsModal.tsx | 19 ++++++- 4 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 frontend/src/component/common/SidePanelList/SidePanelListHeader.tsx create mode 100644 frontend/src/component/common/SidePanelList/SidePanelListItem.tsx diff --git a/frontend/src/component/common/SidePanelList/SidePanelList.tsx b/frontend/src/component/common/SidePanelList/SidePanelList.tsx index d0ea976a0a59..cfd78c563649 100644 --- a/frontend/src/component/common/SidePanelList/SidePanelList.tsx +++ b/frontend/src/component/common/SidePanelList/SidePanelList.tsx @@ -1,3 +1,54 @@ -export const SidePanelList = () => { - return TODO: Implement; +import { styled } from '@mui/material'; +import { ReactNode, useState } from 'react'; + +const StyledSidePanelListWrapper = styled('div')({ + display: 'flex', + flexDirection: 'column', + height: '100%', + width: '100%', +}); + +const StyledSidePanelListBody = styled('div')({ + display: 'flex', + flexDirection: 'row', +}); + +interface ISidePanelListProps { + items: T[]; + header: ReactNode; + renderItem: ( + item: T, + isSelected: boolean, + selectItem: (item: T) => void, + ) => ReactNode; + renderContent: (item: T) => ReactNode; +} + +export const SidePanelList = ({ + items, + header, + renderItem, + renderContent, +}: ISidePanelListProps) => { + const [selectedItem, setSelectedItem] = useState(items[0]); + + return ( + + {header} + +
+ {items.map((item) => + renderItem( + item, + item === selectedItem, + setSelectedItem, + ), + )} +
+
+ {renderContent(selectedItem)} +
+
+
+ ); }; diff --git a/frontend/src/component/common/SidePanelList/SidePanelListHeader.tsx b/frontend/src/component/common/SidePanelList/SidePanelListHeader.tsx new file mode 100644 index 000000000000..37441e3a3ee9 --- /dev/null +++ b/frontend/src/component/common/SidePanelList/SidePanelListHeader.tsx @@ -0,0 +1,29 @@ +import { styled } from '@mui/material'; +import { ReactNode } from 'react'; + +const StyledHeader = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'row', + borderRadius: theme.shape.borderRadius, + backgroundColor: theme.palette.table.headerBackground, +})); + +const StyledHeaderHalf = styled('div')(({ theme }) => ({ + display: 'flex', + flex: 1, +})); + +interface ISidePanelListHeaderProps { + sidePanelHeader: string; + children: ReactNode[]; +} + +export const SidePanelListHeader = ({ + sidePanelHeader, + children, +}: ISidePanelListHeaderProps) => ( + + {children} + {sidePanelHeader} + +); diff --git a/frontend/src/component/common/SidePanelList/SidePanelListItem.tsx b/frontend/src/component/common/SidePanelList/SidePanelListItem.tsx new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksEvents/IncomingWebhooksEventsModal.tsx b/frontend/src/component/incomingWebhooks/IncomingWebhooksEvents/IncomingWebhooksEventsModal.tsx index 50c6272df724..7c5db0694b61 100644 --- a/frontend/src/component/incomingWebhooks/IncomingWebhooksEvents/IncomingWebhooksEventsModal.tsx +++ b/frontend/src/component/incomingWebhooks/IncomingWebhooksEvents/IncomingWebhooksEventsModal.tsx @@ -6,6 +6,7 @@ import { useState } 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 { SidePanelListHeader } from 'component/common/SidePanelList/SidePanelListHeader'; const StyledHeader = styled('div')(({ theme }) => ({ display: 'flex', @@ -35,11 +36,11 @@ const StyledTitle = styled('h1')({ fontWeight: 'normal', }); -const StyledForm = styled('form')(() => ({ +const StyledForm = styled('form')({ display: 'flex', flexDirection: 'column', height: '100%', -})); +}); const StyledButtonContainer = styled('div')(({ theme }) => ({ marginTop: 'auto', @@ -112,7 +113,19 @@ export const IncomingWebhooksEventsModal = ({ - + +
Date
+
Token
+ + } + renderItem={(event) =>
{event.createdAt}
} + renderContent={(event) => ( +
{JSON.stringify(event.payload)}
+ )} + /> + } + /> + } /> { + const response = await fetch(url); + await handleErrorResponses('Incoming webhook events')(response); + return response.json(); }; export const useIncomingWebhookEvents = ( incomingWebhookId?: number, limit = 50, - offset = 0, + options: SWRInfiniteConfiguration = {}, ) => { const { isEnterprise } = useUiConfig(); const incomingWebhooksEnabled = useUiFlag('incomingWebhooks'); - const { data, error, mutate } = useConditionalSWR<{ - incomingWebhookEvents: IIncomingWebhookEvent[]; - }>( - Boolean(incomingWebhookId) && isEnterprise() && incomingWebhooksEnabled, - DEFAULT_DATA, - formatApiPath( - `${ENDPOINT}/${incomingWebhookId}/events?limit=${limit}&offset=${offset}`, - ), - fetcher, - ); - - return useMemo( - () => ({ - incomingWebhookEvents: data?.incomingWebhookEvents ?? [], - loading: !error && !data, - refetch: () => mutate(), - error, - }), - [data, error, mutate], - ); -}; + 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); + }; -const fetcher = (path: string) => { - return fetch(path) - .then(handleErrorResponses('Incoming webhook events')) - .then((res) => res.json()); + return { + incomingWebhookEvents, + hasMore, + loadMore, + loading, + refetch: () => mutate(), + error, + }; }; From f35add8c1545efc11cba516126b63b8cd956f581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Fri, 23 Feb 2024 10:21:37 +0000 Subject: [PATCH 6/6] fix: remove empty title prop --- .../IncomingWebhooksEvents/IncomingWebhooksEventsModal.tsx | 1 - .../IncomingWebhooksModal/IncomingWebhooksModal.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/frontend/src/component/incomingWebhooks/IncomingWebhooksEvents/IncomingWebhooksEventsModal.tsx b/frontend/src/component/incomingWebhooks/IncomingWebhooksEvents/IncomingWebhooksEventsModal.tsx index 13b9906c0c65..3bb7ac0a534b 100644 --- a/frontend/src/component/incomingWebhooks/IncomingWebhooksEvents/IncomingWebhooksEventsModal.tsx +++ b/frontend/src/component/incomingWebhooks/IncomingWebhooksEvents/IncomingWebhooksEventsModal.tsx @@ -92,7 +92,6 @@ export const IncomingWebhooksEventsModal = ({