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 ( = ({ /> ({ +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 000000000000..66777524b280 --- /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 000000000000..f8267659cd00 --- /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 086ae1a3bd7d..ed736309508c 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 000000000000..3bb7ac0a534b --- /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 4301b7300b73..375f1369571d 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 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; +}