1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00
Nuno Góis 2024-02-27 13:52:09 +00:00 committed by GitHub
parent 9101c39eb7
commit 477a9c6cfa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 680 additions and 35 deletions

View File

@ -21,13 +21,14 @@ const StyledSidePanelHalf = styled('div')({
}); });
const StyledSidePanelHalfLeft = styled(StyledSidePanelHalf, { const StyledSidePanelHalfLeft = styled(StyledSidePanelHalf, {
shouldForwardProp: (prop) => prop !== 'height', shouldForwardProp: (prop) => prop !== 'height' && prop !== 'maxWidth',
})<{ height?: number }>(({ theme, height }) => ({ })<{ height?: number; maxWidth?: number }>(({ theme, height, maxWidth }) => ({
border: `1px solid ${theme.palette.divider}`, border: `1px solid ${theme.palette.divider}`,
borderTop: 0, borderTop: 0,
borderBottomLeftRadius: theme.shape.borderRadiusMedium, borderBottomLeftRadius: theme.shape.borderRadiusMedium,
overflow: 'auto', overflow: 'auto',
...(height && { height }), ...(height && { height }),
...(maxWidth && { maxWidth }),
})); }));
const StyledSidePanelHalfRight = styled(StyledSidePanelHalf)(({ theme }) => ({ const StyledSidePanelHalfRight = styled(StyledSidePanelHalf)(({ theme }) => ({
@ -43,12 +44,14 @@ export const StyledSidePanelListColumn = styled('div', {
shouldForwardProp: (prop) => prop !== 'maxWidth' && prop !== 'align', shouldForwardProp: (prop) => prop !== 'maxWidth' && prop !== 'align',
})<{ maxWidth?: number; align?: ColumnAlignment }>( })<{ maxWidth?: number; align?: ColumnAlignment }>(
({ theme, maxWidth, align = 'start' }) => ({ ({ theme, maxWidth, align = 'start' }) => ({
display: 'flex',
flex: 1, flex: 1,
padding: theme.spacing(2), padding: theme.spacing(2),
fontSize: theme.fontSizes.smallBody, fontSize: theme.fontSizes.smallBody,
justifyContent: align, justifyContent: align,
...(maxWidth && { maxWidth }), ...(maxWidth && { maxWidth }),
textAlign: align, textAlign: align,
alignItems: 'center',
}), }),
); );
@ -64,6 +67,7 @@ interface ISidePanelListProps<T> {
columns: SidePanelListColumn<T>[]; columns: SidePanelListColumn<T>[];
sidePanelHeader: string; sidePanelHeader: string;
renderContent: (item: T) => ReactNode; renderContent: (item: T) => ReactNode;
renderItem?: (item: T, children: ReactNode) => ReactNode;
height?: number; height?: number;
listEnd?: ReactNode; listEnd?: ReactNode;
} }
@ -73,6 +77,7 @@ export const SidePanelList = <T extends { id: string | number }>({
columns, columns,
sidePanelHeader, sidePanelHeader,
renderContent, renderContent,
renderItem = (_, children) => children,
height, height,
listEnd, listEnd,
}: ISidePanelListProps<T>) => { }: ISidePanelListProps<T>) => {
@ -83,34 +88,44 @@ export const SidePanelList = <T extends { id: string | number }>({
} }
const activeItem = selectedItem || items[0]; const activeItem = selectedItem || items[0];
const leftPanelMaxWidth = columns.every(({ maxWidth }) => Boolean(maxWidth))
? columns.reduce((acc, { maxWidth }) => acc + (maxWidth || 0), 0)
: undefined;
return ( return (
<StyledSidePanelListWrapper> <StyledSidePanelListWrapper>
<SidePanelListHeader <SidePanelListHeader
columns={columns} columns={columns}
sidePanelHeader={sidePanelHeader} sidePanelHeader={sidePanelHeader}
leftPanelMaxWidth={leftPanelMaxWidth}
/> />
<StyledSidePanelListBody> <StyledSidePanelListBody>
<StyledSidePanelHalfLeft height={height}> <StyledSidePanelHalfLeft
{items.map((item) => ( height={height}
<SidePanelListItem maxWidth={leftPanelMaxWidth}
key={item.id} >
selected={activeItem.id === item.id} {items.map((item) =>
onClick={() => setSelectedItem(item)} renderItem(
> item,
{columns.map( <SidePanelListItem
({ header, maxWidth, align, cell }) => ( key={item.id}
<StyledSidePanelListColumn selected={activeItem.id === item.id}
key={header} onClick={() => setSelectedItem(item)}
maxWidth={maxWidth} >
align={align} {columns.map(
> ({ header, maxWidth, align, cell }) => (
{cell(item)} <StyledSidePanelListColumn
</StyledSidePanelListColumn> key={header}
), maxWidth={maxWidth}
)} align={align}
</SidePanelListItem> >
))} {cell(item)}
</StyledSidePanelListColumn>
),
)}
</SidePanelListItem>,
),
)}
{listEnd} {listEnd}
</StyledSidePanelHalfLeft> </StyledSidePanelHalfLeft>
<StyledSidePanelHalfRight> <StyledSidePanelHalfRight>

View File

@ -13,22 +13,27 @@ const StyledHeader = styled('div')(({ theme }) => ({
backgroundColor: theme.palette.table.headerBackground, backgroundColor: theme.palette.table.headerBackground,
})); }));
const StyledHeaderHalf = styled('div')({ const StyledHeaderHalf = styled('div', {
shouldForwardProp: (prop) => prop !== 'maxWidth',
})<{ maxWidth?: number }>(({ maxWidth }) => ({
display: 'flex', display: 'flex',
flex: 1, flex: 1,
}); ...(maxWidth && { maxWidth }),
}));
interface ISidePanelListHeaderProps<T> { interface ISidePanelListHeaderProps<T> {
columns: SidePanelListColumn<T>[]; columns: SidePanelListColumn<T>[];
sidePanelHeader: string; sidePanelHeader: string;
leftPanelMaxWidth?: number;
} }
export const SidePanelListHeader = <T,>({ export const SidePanelListHeader = <T,>({
columns, columns,
sidePanelHeader, sidePanelHeader,
leftPanelMaxWidth,
}: ISidePanelListHeaderProps<T>) => ( }: ISidePanelListHeaderProps<T>) => (
<StyledHeader> <StyledHeader>
<StyledHeaderHalf> <StyledHeaderHalf maxWidth={leftPanelMaxWidth}>
{columns.map(({ header, maxWidth, align }) => ( {columns.map(({ header, maxWidth, align }) => (
<StyledSidePanelListColumn <StyledSidePanelListColumn
key={header} key={header}

View File

@ -39,17 +39,17 @@ const StyledItem = styled(Button, {
}, },
})); }));
interface ISidePanelListItemProps<T> { interface ISidePanelListItemProps {
selected: boolean; selected: boolean;
onClick: () => void; onClick: () => void;
children: ReactNode; children: ReactNode;
} }
export const SidePanelListItem = <T,>({ export const SidePanelListItem = ({
selected, selected,
onClick, onClick,
children, children,
}: ISidePanelListItemProps<T>) => ( }: ISidePanelListItemProps) => (
<StyledItemRow> <StyledItemRow>
<StyledItem selected={selected} onClick={onClick}> <StyledItem selected={selected} onClick={onClick}>
{children} {children}

View File

@ -121,6 +121,7 @@ export const IncomingWebhooksEventsModal = ({
columns={[ columns={[
{ {
header: 'Date', header: 'Date',
maxWidth: 180,
cell: (event) => cell: (event) =>
formatDateYMDHMS( formatDateYMDHMS(
event.createdAt, event.createdAt,
@ -129,6 +130,7 @@ export const IncomingWebhooksEventsModal = ({
}, },
{ {
header: 'Token', header: 'Token',
maxWidth: 350,
cell: (event) => event.tokenName, cell: (event) => event.tokenName,
}, },
]} ]}

View File

@ -17,6 +17,7 @@ import {
TokenGeneration, TokenGeneration,
useIncomingWebhooksForm, useIncomingWebhooksForm,
} from './IncomingWebhooksForm/useIncomingWebhooksForm'; } from './IncomingWebhooksForm/useIncomingWebhooksForm';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
const StyledHeader = styled('div')(({ theme }) => ({ const StyledHeader = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
@ -158,7 +159,10 @@ export const IncomingWebhooksModal = ({
> >
<StyledHeader> <StyledHeader>
<StyledTitle>{title}</StyledTitle> <StyledTitle>{title}</StyledTitle>
<Link onClick={onOpenEvents}>View events</Link> <ConditionallyRender
condition={editing}
show={<Link onClick={onOpenEvents}>View events</Link>}
/>
</StyledHeader> </StyledHeader>
<StyledForm onSubmit={onSubmit}> <StyledForm onSubmit={onSubmit}>
<IncomingWebhooksForm <IncomingWebhooksForm

View File

@ -0,0 +1,43 @@
import { Alert, styled } from '@mui/material';
import { IActionSetEvent } from 'interfaces/action';
import { ProjectActionsEventsDetailsAction } from './ProjectActionsEventsDetailsAction';
import { ProjectActionsEventsDetailsSource } from './ProjectActionsEventsDetailsSource/ProjectActionsEventsDetailsSource';
const StyledDetails = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
padding: theme.spacing(2),
}));
export const ProjectActionsEventsDetails = ({
state,
actionSet: { actions },
observableEvent,
}: IActionSetEvent) => {
const stateText =
state === 'failed'
? `${
actions.filter(({ state }) => state !== 'success').length
} out of ${actions.length} actions were not successfully executed`
: 'All actions were successfully executed';
return (
<StyledDetails>
<Alert severity={state === 'failed' ? 'error' : 'success'}>
{stateText}
</Alert>
<ProjectActionsEventsDetailsSource
observableEvent={observableEvent}
/>
{actions.map((action, i) => (
<ProjectActionsEventsDetailsAction
key={action.id}
action={action}
>
Action {i + 1}
</ProjectActionsEventsDetailsAction>
))}
</StyledDetails>
);
};

View File

@ -0,0 +1,110 @@
import { CheckCircleOutline, ErrorOutline } from '@mui/icons-material';
import { Alert, CircularProgress, Divider, styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { IActionEvent } from 'interfaces/action';
import { ReactNode } from 'react';
const StyledAction = styled('div', {
shouldForwardProp: (prop) => prop !== 'state',
})<{ state?: IActionEvent['state'] }>(({ theme, state }) => ({
padding: theme.spacing(2),
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadiusMedium,
...(state === 'not started' && {
backgroundColor: theme.palette.background.elevation1,
}),
}));
const StyledHeader = styled('div')({
display: 'flex',
flexDirection: 'column',
});
const StyledHeaderRow = styled('div')({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
});
const StyledHeaderState = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
fontSize: theme.fontSizes.smallBody,
gap: theme.spacing(2),
}));
export const StyledSuccessIcon = styled(CheckCircleOutline)(({ theme }) => ({
color: theme.palette.success.main,
}));
export const StyledFailedIcon = styled(ErrorOutline)(({ theme }) => ({
color: theme.palette.error.main,
}));
const StyledAlert = styled(Alert)(({ theme }) => ({
marginTop: theme.spacing(2),
}));
const StyledDivider = styled(Divider)(({ theme }) => ({
margin: theme.spacing(2, 0),
}));
const StyledActionBody = styled('div')(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
}));
const StyledActionLabel = styled('p')(({ theme }) => ({
fontWeight: theme.fontWeight.bold,
marginBottom: theme.spacing(0.5),
}));
const StyledPropertyLabel = styled('span')(({ theme }) => ({
color: theme.palette.text.secondary,
}));
interface IProjectActionsEventsDetailsActionProps {
action: IActionEvent;
children: ReactNode;
}
export const ProjectActionsEventsDetailsAction = ({
action: { state, details, action, executionParams },
children,
}: IProjectActionsEventsDetailsActionProps) => {
const actionState =
state === 'success' ? (
<StyledSuccessIcon />
) : state === 'failed' ? (
<StyledFailedIcon />
) : state === 'started' ? (
<CircularProgress size={20} />
) : (
<span>Not started</span>
);
return (
<StyledAction state={state}>
<StyledHeader>
<StyledHeaderRow>
<div>{children}</div>
<StyledHeaderState>{actionState}</StyledHeaderState>
</StyledHeaderRow>
<ConditionallyRender
condition={Boolean(details)}
show={<StyledAlert severity='error'>{details}</StyledAlert>}
/>
</StyledHeader>
<StyledDivider />
<StyledActionBody>
<StyledActionLabel>{action}</StyledActionLabel>
{Object.entries(executionParams).map(([property, value]) => (
<div key={property}>
<StyledPropertyLabel>{property}:</StyledPropertyLabel>{' '}
{value}
</div>
))}
</StyledActionBody>
</StyledAction>
);
};

View File

@ -0,0 +1,22 @@
import { IObservableEvent } from 'interfaces/action';
import { ProjectActionsEventsDetailsSourceIncomingWebhook } from './ProjectActionsEventsDetailsSourceIncomingWebhook';
interface IProjectActionsEventsDetailsSourceProps {
observableEvent: IObservableEvent;
}
export const ProjectActionsEventsDetailsSource = ({
observableEvent,
}: IProjectActionsEventsDetailsSourceProps) => {
const { source } = observableEvent;
if (source === 'incoming-webhook') {
return (
<ProjectActionsEventsDetailsSourceIncomingWebhook
observableEvent={observableEvent}
/>
);
}
return null;
};

View File

@ -0,0 +1,75 @@
import { ExpandMore } from '@mui/icons-material';
import {
Accordion,
AccordionDetails,
AccordionSummary,
IconButton,
styled,
} from '@mui/material';
import { useIncomingWebhooks } from 'hooks/api/getters/useIncomingWebhooks/useIncomingWebhooks';
import { IObservableEvent } from 'interfaces/action';
import { Suspense, lazy, useMemo } from 'react';
import { Link } from 'react-router-dom';
const LazyReactJSONEditor = lazy(
() => import('component/common/ReactJSONEditor/ReactJSONEditor'),
);
const StyledAccordion = styled(Accordion)(({ theme }) => ({
boxShadow: 'none',
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadiusMedium,
'&:before': {
display: 'none',
},
}));
const StyledLink = styled(Link)(({ theme }) => ({
marginLeft: theme.spacing(1),
}));
interface IProjectActionsEventsDetailsSourceIncomingWebhookProps {
observableEvent: IObservableEvent;
}
export const ProjectActionsEventsDetailsSourceIncomingWebhook = ({
observableEvent,
}: IProjectActionsEventsDetailsSourceIncomingWebhookProps) => {
const { incomingWebhooks } = useIncomingWebhooks();
const incomingWebhookName = useMemo(() => {
const incomingWebhook = incomingWebhooks.find(
(incomingWebhook) =>
incomingWebhook.id === observableEvent.sourceId,
);
return incomingWebhook?.name;
}, [incomingWebhooks, observableEvent.sourceId]);
return (
<StyledAccordion>
<AccordionSummary
expandIcon={
<IconButton>
<ExpandMore titleAccess='Toggle' />
</IconButton>
}
>
Incoming webhook:
<StyledLink to='/integrations/incoming-webhooks'>
{incomingWebhookName}
</StyledLink>
</AccordionSummary>
<AccordionDetails>
<Suspense fallback={null}>
<LazyReactJSONEditor
content={{ json: observableEvent.payload }}
readOnly
statusBar={false}
editorStyle='sidePanel'
/>
</Suspense>
</AccordionDetails>
</StyledAccordion>
);
};

View File

@ -0,0 +1,169 @@
import { Button, Link, styled } from '@mui/material';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import { IActionSet } from 'interfaces/action';
import { useActionEvents } from 'hooks/api/getters/useActionEvents/useActionEvents';
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import { SidePanelList } from 'component/common/SidePanelList/SidePanelList';
import { formatDateYMDHMS } from 'utils/formatDate';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { ProjectActionsEventsStateCell } from './ProjectActionsEventsStateCell';
import { ProjectActionsEventsDetails } from './ProjectActionsEventsDetails.tsx/ProjectActionsEventsDetails';
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 StyledTitle = styled('h1')({
fontWeight: 'normal',
});
const StyledForm = styled('form')({
display: 'flex',
flexDirection: 'column',
height: '100%',
});
const StyledFailedItemWrapper = styled('div')(({ theme }) => ({
backgroundColor: theme.palette.error.light,
}));
const StyledButtonContainer = styled('div')(({ theme }) => ({
marginTop: 'auto',
display: 'flex',
justifyContent: 'flex-end',
paddingTop: theme.spacing(4),
}));
interface IProjectActionsEventsModalProps {
action?: IActionSet;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
onOpenConfiguration: () => void;
}
export const ProjectActionsEventsModal = ({
action,
open,
setOpen,
onOpenConfiguration,
}: IProjectActionsEventsModalProps) => {
const projectId = useRequiredPathParam('projectId');
const { locationSettings } = useLocationSettings();
const { actionEvents, hasMore, loadMore, loading } = useActionEvents(
action?.id,
projectId,
20,
{
refreshInterval: 5000,
},
);
if (!action) {
return null;
}
const title = `Events: ${action.name}`;
return (
<SidebarModal
open={open}
onClose={() => {
setOpen(false);
}}
label={title}
>
<FormTemplate
loading={loading && actionEvents.length === 0}
modal
description='Actions allow you to configure automations based on specific triggers, like incoming webhooks.'
documentationLink='https://docs.getunleash.io/reference/actions'
documentationLinkLabel='Actions documentation'
showGuidance={false}
>
<StyledHeader>
<StyledHeaderRow>
<StyledTitle>{title}</StyledTitle>
<Link onClick={onOpenConfiguration}>
View configuration
</Link>
</StyledHeaderRow>
</StyledHeader>
<StyledForm>
<SidePanelList
height={960}
items={actionEvents}
columns={[
{
header: 'Status',
align: 'center',
maxWidth: 100,
cell: ProjectActionsEventsStateCell,
},
{
header: 'Date',
maxWidth: 240,
cell: ({ createdAt }) =>
formatDateYMDHMS(
createdAt,
locationSettings?.locale,
),
},
]}
sidePanelHeader='Details'
renderContent={ProjectActionsEventsDetails}
renderItem={({ id, state }, children) => {
if (state === 'failed') {
return (
<StyledFailedItemWrapper key={id}>
{children}
</StyledFailedItemWrapper>
);
}
return children;
}}
listEnd={
<ConditionallyRender
condition={hasMore}
show={
<Button onClick={loadMore}>
Load more
</Button>
}
/>
}
/>
<ConditionallyRender
condition={actionEvents.length === 0}
show={
<p>
No events have been registered for this action
set.
</p>
}
/>
<StyledButtonContainer>
<Button
onClick={() => {
setOpen(false);
}}
>
Close
</Button>
</StyledButtonContainer>
</StyledForm>
</FormTemplate>
</SidebarModal>
);
};

View File

@ -0,0 +1,23 @@
import { CircularProgress, styled } from '@mui/material';
import { CheckCircle, Error as ErrorIcon } from '@mui/icons-material';
import { IActionSetEvent } from 'interfaces/action';
export const StyledSuccessIcon = styled(CheckCircle)(({ theme }) => ({
color: theme.palette.success.main,
}));
export const StyledFailedIcon = styled(ErrorIcon)(({ theme }) => ({
color: theme.palette.error.main,
}));
export const ProjectActionsEventsStateCell = ({ state }: IActionSetEvent) => {
if (state === 'success') {
return <StyledSuccessIcon />;
}
if (state === 'failed') {
return <StyledFailedIcon />;
}
return <CircularProgress size={20} />;
};

View File

@ -1,5 +1,5 @@
import { FormEvent, useEffect } from 'react'; 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 { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import FormTemplate from 'component/common/FormTemplate/FormTemplate'; import FormTemplate from 'component/common/FormTemplate/FormTemplate';
@ -14,6 +14,19 @@ import {
import { ProjectActionsForm } from './ProjectActionsForm/ProjectActionsForm'; import { ProjectActionsForm } from './ProjectActionsForm/ProjectActionsForm';
import { useProjectActionsForm } from './ProjectActionsForm/useProjectActionsForm'; import { useProjectActionsForm } from './ProjectActionsForm/useProjectActionsForm';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
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')(() => ({ const StyledForm = styled('form')(() => ({
display: 'flex', display: 'flex',
@ -36,12 +49,14 @@ interface IProjectActionsModalProps {
action?: IActionSet; action?: IActionSet;
open: boolean; open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>; setOpen: React.Dispatch<React.SetStateAction<boolean>>;
onOpenEvents: () => void;
} }
export const ProjectActionsModal = ({ export const ProjectActionsModal = ({
action, action,
open, open,
setOpen, setOpen,
onOpenEvents,
}: IProjectActionsModalProps) => { }: IProjectActionsModalProps) => {
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const { refetch } = useActions(projectId); const { refetch } = useActions(projectId);
@ -142,12 +157,18 @@ export const ProjectActionsModal = ({
<FormTemplate <FormTemplate
loading={loading} loading={loading}
modal modal
title={title}
description='Actions allow you to configure automations based on specific triggers, like incoming webhooks.' description='Actions allow you to configure automations based on specific triggers, like incoming webhooks.'
documentationLink='https://docs.getunleash.io/reference/actions' documentationLink='https://docs.getunleash.io/reference/actions'
documentationLinkLabel='Actions documentation' documentationLinkLabel='Actions documentation'
formatApiCode={formatApiCode} formatApiCode={formatApiCode}
> >
<StyledHeader>
<StyledTitle>{title}</StyledTitle>
<ConditionallyRender
condition={editing}
show={<Link onClick={onOpenEvents}>View events</Link>}
/>
</StyledHeader>
<StyledForm onSubmit={onSubmit}> <StyledForm onSubmit={onSubmit}>
<ProjectActionsForm <ProjectActionsForm
enabled={enabled} enabled={enabled}

View File

@ -24,6 +24,7 @@ import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServ
import { useIncomingWebhooks } from 'hooks/api/getters/useIncomingWebhooks/useIncomingWebhooks'; import { useIncomingWebhooks } from 'hooks/api/getters/useIncomingWebhooks/useIncomingWebhooks';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell'; import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { ProjectActionsEventsModal } from './ProjectActionsEventsModal/ProjectActionsEventsModal';
interface IProjectActionsTableProps { interface IProjectActionsTableProps {
modalOpen: boolean; modalOpen: boolean;
@ -49,6 +50,7 @@ export const ProjectActionsTable = ({
const { incomingWebhooks } = useIncomingWebhooks(); const { incomingWebhooks } = useIncomingWebhooks();
const { serviceAccounts } = useServiceAccounts(); const { serviceAccounts } = useServiceAccounts();
const [eventsModalOpen, setEventsModalOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const onToggleAction = async (action: IActionSet, enabled: boolean) => { const onToggleAction = async (action: IActionSet, enabled: boolean) => {
@ -182,6 +184,10 @@ export const ProjectActionsTable = ({
}: { row: { original: IActionSet } }) => ( }: { row: { original: IActionSet } }) => (
<ProjectActionsTableActionsCell <ProjectActionsTableActionsCell
actionId={action.id} actionId={action.id}
onOpenEvents={() => {
setSelectedAction(action);
setEventsModalOpen(true);
}}
onEdit={() => { onEdit={() => {
setSelectedAction(action); setSelectedAction(action);
setModalOpen(true); setModalOpen(true);
@ -255,6 +261,19 @@ export const ProjectActionsTable = ({
action={selectedAction} action={selectedAction}
open={modalOpen} open={modalOpen}
setOpen={setModalOpen} setOpen={setModalOpen}
onOpenEvents={() => {
setModalOpen(false);
setEventsModalOpen(true);
}}
/>
<ProjectActionsEventsModal
action={selectedAction}
open={eventsModalOpen}
setOpen={setEventsModalOpen}
onOpenConfiguration={() => {
setEventsModalOpen(false);
setModalOpen(true);
}}
/> />
<ProjectActionsDeleteDialog <ProjectActionsDeleteDialog
action={selectedAction} action={selectedAction}

View File

@ -12,7 +12,7 @@ import {
styled, styled,
} from '@mui/material'; } from '@mui/material';
import MoreVertIcon from '@mui/icons-material/MoreVert'; import MoreVertIcon from '@mui/icons-material/MoreVert';
import { Delete, Edit } from '@mui/icons-material'; import { Delete, Edit, Visibility } from '@mui/icons-material';
import { PermissionHOC } from 'component/common/PermissionHOC/PermissionHOC'; import { PermissionHOC } from 'component/common/PermissionHOC/PermissionHOC';
import { ADMIN } from 'component/providers/AccessProvider/permissions'; import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { defaultBorderRadius } from 'themes/themeStyles'; import { defaultBorderRadius } from 'themes/themeStyles';
@ -24,12 +24,14 @@ const StyledBoxCell = styled(Box)({
interface IProjectActionsTableActionsCellProps { interface IProjectActionsTableActionsCellProps {
actionId: number; actionId: number;
onOpenEvents: (event: React.SyntheticEvent) => void;
onEdit: (event: React.SyntheticEvent) => void; onEdit: (event: React.SyntheticEvent) => void;
onDelete: (event: React.SyntheticEvent) => void; onDelete: (event: React.SyntheticEvent) => void;
} }
export const ProjectActionsTableActionsCell = ({ export const ProjectActionsTableActionsCell = ({
actionId, actionId,
onOpenEvents,
onEdit, onEdit,
onDelete, onDelete,
}: IProjectActionsTableActionsCellProps) => { }: IProjectActionsTableActionsCellProps) => {
@ -80,6 +82,24 @@ export const ProjectActionsTableActionsCell = ({
}} }}
> >
<MenuList aria-labelledby={id}> <MenuList aria-labelledby={id}>
<PermissionHOC permission={ADMIN}>
{({ hasAccess }) => (
<MenuItem
sx={defaultBorderRadius}
onClick={onOpenEvents}
disabled={!hasAccess}
>
<ListItemIcon>
<Visibility />
</ListItemIcon>
<ListItemText>
<Typography variant='body2'>
View events
</Typography>
</ListItemText>
</MenuItem>
)}
</PermissionHOC>
<PermissionHOC permission={ADMIN}> <PermissionHOC permission={ADMIN}>
{({ hasAccess }) => ( {({ hasAccess }) => (
<MenuItem <MenuItem

View File

@ -0,0 +1,83 @@
import useSWRInfinite, {
SWRInfiniteConfiguration,
SWRInfiniteKeyLoader,
} from 'swr/infinite';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import useUiConfig from '../useUiConfig/useUiConfig';
import { IActionSetEvent } from 'interfaces/action';
import { useUiFlag } from 'hooks/useUiFlag';
type ActionEventsResponse = {
actionSetEvents: IActionSetEvent[];
};
const fetcher = async (url: string) => {
const response = await fetch(url);
await handleErrorResponses('Action events')(response);
return response.json();
};
export const useActionEvents = (
actionSetId?: number,
projectId?: string,
limit = 50,
options: SWRInfiniteConfiguration = {},
) => {
const { isEnterprise } = useUiConfig();
const automatedActionsEnabled = useUiFlag('automatedActions');
const getKey: SWRInfiniteKeyLoader = (
pageIndex: number,
previousPageData: ActionEventsResponse,
) => {
// Does not meet conditions
if (
!actionSetId ||
!projectId ||
!isEnterprise ||
!automatedActionsEnabled
)
return null;
// Reached the end
if (previousPageData && !previousPageData.actionSetEvents.length)
return null;
return formatApiPath(
`api/admin/projects/${projectId}/actions/${actionSetId}/events?limit=${limit}&offset=${
pageIndex * limit
}`,
);
};
const { data, error, size, setSize, mutate } =
useSWRInfinite<ActionEventsResponse>(getKey, fetcher, {
...options,
revalidateAll: true,
});
const actionEvents = data
? data.flatMap(({ actionSetEvents }) => actionSetEvents)
: [];
const isLoadingInitialData = !data && !error;
const isLoadingMore = size > 0 && !data?.[size - 1];
const loading = isLoadingInitialData || isLoadingMore;
const hasMore = data?.[size - 1]?.actionSetEvents.length === limit;
const loadMore = () => {
if (loading || !hasMore) return;
setSize(size + 1);
};
return {
actionEvents,
hasMore,
loadMore,
loading,
refetch: () => mutate(),
error,
};
};

View File

@ -26,3 +26,37 @@ export interface IAction {
createdAt: string; createdAt: string;
createdByUserId: number; createdByUserId: number;
} }
export type ObservableEventSource = 'incoming-webhook';
export interface IObservableEvent {
id: number;
source: ObservableEventSource;
sourceId: number;
createdAt: string;
createdByIncomingWebhookTokenId: number;
payload: Record<string, unknown>;
}
type ActionSetState = 'started' | 'success' | 'failed';
type ActionState = ActionSetState | 'not started';
export interface IActionEvent extends IAction {
state: ActionState;
details?: string;
}
interface IActionSetEventActionSet extends IActionSet {
actions: IActionEvent[];
}
export interface IActionSetEvent {
id: number;
actionSetId: number;
observableEventId: number;
createdAt: string;
state: ActionSetState;
observableEvent: IObservableEvent;
actionSet: IActionSetEventActionSet;
}

View File

@ -1,3 +1,5 @@
import { ObservableEventSource } from './action';
export interface IIncomingWebhook { export interface IIncomingWebhook {
id: number; id: number;
enabled: boolean; enabled: boolean;
@ -16,13 +18,11 @@ export interface IIncomingWebhookToken {
createdByUserId: number; createdByUserId: number;
} }
type EventSource = 'incoming-webhook';
export interface IIncomingWebhookEvent { export interface IIncomingWebhookEvent {
id: number; id: number;
payload: Record<string, unknown>; payload: Record<string, unknown>;
createdAt: string; createdAt: string;
source: EventSource; source: ObservableEventSource;
sourceId: number; sourceId: number;
tokenName: string; tokenName: string;
} }