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 }) => (
+
+ )}
+
{({ hasAccess }) => (