mirror of
https://github.com/Unleash/unleash.git
synced 2024-12-22 19:07:54 +01:00
chore: action events UI (#6358)
https://linear.app/unleash/issue/2-1936/action-events-ui Implements the UI for action events. ![image](https://github.com/Unleash/unleash/assets/14320932/d2f82ddd-6292-4f61-bfdd-05037f746f52) ![image](https://github.com/Unleash/unleash/assets/14320932/673816b8-7dee-4b36-adda-d13d419dc5ac)
This commit is contained in:
parent
9101c39eb7
commit
477a9c6cfa
@ -21,13 +21,14 @@ const StyledSidePanelHalf = styled('div')({
|
||||
});
|
||||
|
||||
const StyledSidePanelHalfLeft = styled(StyledSidePanelHalf, {
|
||||
shouldForwardProp: (prop) => prop !== 'height',
|
||||
})<{ height?: number }>(({ theme, height }) => ({
|
||||
shouldForwardProp: (prop) => prop !== 'height' && prop !== 'maxWidth',
|
||||
})<{ height?: number; maxWidth?: number }>(({ theme, height, maxWidth }) => ({
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderTop: 0,
|
||||
borderBottomLeftRadius: theme.shape.borderRadiusMedium,
|
||||
overflow: 'auto',
|
||||
...(height && { height }),
|
||||
...(maxWidth && { maxWidth }),
|
||||
}));
|
||||
|
||||
const StyledSidePanelHalfRight = styled(StyledSidePanelHalf)(({ theme }) => ({
|
||||
@ -43,12 +44,14 @@ export const StyledSidePanelListColumn = styled('div', {
|
||||
shouldForwardProp: (prop) => prop !== 'maxWidth' && prop !== 'align',
|
||||
})<{ maxWidth?: number; align?: ColumnAlignment }>(
|
||||
({ theme, maxWidth, align = 'start' }) => ({
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
padding: theme.spacing(2),
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
justifyContent: align,
|
||||
...(maxWidth && { maxWidth }),
|
||||
textAlign: align,
|
||||
alignItems: 'center',
|
||||
}),
|
||||
);
|
||||
|
||||
@ -64,6 +67,7 @@ interface ISidePanelListProps<T> {
|
||||
columns: SidePanelListColumn<T>[];
|
||||
sidePanelHeader: string;
|
||||
renderContent: (item: T) => ReactNode;
|
||||
renderItem?: (item: T, children: ReactNode) => ReactNode;
|
||||
height?: number;
|
||||
listEnd?: ReactNode;
|
||||
}
|
||||
@ -73,6 +77,7 @@ export const SidePanelList = <T extends { id: string | number }>({
|
||||
columns,
|
||||
sidePanelHeader,
|
||||
renderContent,
|
||||
renderItem = (_, children) => children,
|
||||
height,
|
||||
listEnd,
|
||||
}: ISidePanelListProps<T>) => {
|
||||
@ -83,34 +88,44 @@ export const SidePanelList = <T extends { id: string | number }>({
|
||||
}
|
||||
|
||||
const activeItem = selectedItem || items[0];
|
||||
const leftPanelMaxWidth = columns.every(({ maxWidth }) => Boolean(maxWidth))
|
||||
? columns.reduce((acc, { maxWidth }) => acc + (maxWidth || 0), 0)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<StyledSidePanelListWrapper>
|
||||
<SidePanelListHeader
|
||||
columns={columns}
|
||||
sidePanelHeader={sidePanelHeader}
|
||||
leftPanelMaxWidth={leftPanelMaxWidth}
|
||||
/>
|
||||
<StyledSidePanelListBody>
|
||||
<StyledSidePanelHalfLeft height={height}>
|
||||
{items.map((item) => (
|
||||
<SidePanelListItem
|
||||
key={item.id}
|
||||
selected={activeItem.id === item.id}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
>
|
||||
{columns.map(
|
||||
({ header, maxWidth, align, cell }) => (
|
||||
<StyledSidePanelListColumn
|
||||
key={header}
|
||||
maxWidth={maxWidth}
|
||||
align={align}
|
||||
>
|
||||
{cell(item)}
|
||||
</StyledSidePanelListColumn>
|
||||
),
|
||||
)}
|
||||
</SidePanelListItem>
|
||||
))}
|
||||
<StyledSidePanelHalfLeft
|
||||
height={height}
|
||||
maxWidth={leftPanelMaxWidth}
|
||||
>
|
||||
{items.map((item) =>
|
||||
renderItem(
|
||||
item,
|
||||
<SidePanelListItem
|
||||
key={item.id}
|
||||
selected={activeItem.id === item.id}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
>
|
||||
{columns.map(
|
||||
({ header, maxWidth, align, cell }) => (
|
||||
<StyledSidePanelListColumn
|
||||
key={header}
|
||||
maxWidth={maxWidth}
|
||||
align={align}
|
||||
>
|
||||
{cell(item)}
|
||||
</StyledSidePanelListColumn>
|
||||
),
|
||||
)}
|
||||
</SidePanelListItem>,
|
||||
),
|
||||
)}
|
||||
{listEnd}
|
||||
</StyledSidePanelHalfLeft>
|
||||
<StyledSidePanelHalfRight>
|
||||
|
@ -13,22 +13,27 @@ const StyledHeader = styled('div')(({ theme }) => ({
|
||||
backgroundColor: theme.palette.table.headerBackground,
|
||||
}));
|
||||
|
||||
const StyledHeaderHalf = styled('div')({
|
||||
const StyledHeaderHalf = styled('div', {
|
||||
shouldForwardProp: (prop) => prop !== 'maxWidth',
|
||||
})<{ maxWidth?: number }>(({ maxWidth }) => ({
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
});
|
||||
...(maxWidth && { maxWidth }),
|
||||
}));
|
||||
|
||||
interface ISidePanelListHeaderProps<T> {
|
||||
columns: SidePanelListColumn<T>[];
|
||||
sidePanelHeader: string;
|
||||
leftPanelMaxWidth?: number;
|
||||
}
|
||||
|
||||
export const SidePanelListHeader = <T,>({
|
||||
columns,
|
||||
sidePanelHeader,
|
||||
leftPanelMaxWidth,
|
||||
}: ISidePanelListHeaderProps<T>) => (
|
||||
<StyledHeader>
|
||||
<StyledHeaderHalf>
|
||||
<StyledHeaderHalf maxWidth={leftPanelMaxWidth}>
|
||||
{columns.map(({ header, maxWidth, align }) => (
|
||||
<StyledSidePanelListColumn
|
||||
key={header}
|
||||
|
@ -39,17 +39,17 @@ const StyledItem = styled(Button, {
|
||||
},
|
||||
}));
|
||||
|
||||
interface ISidePanelListItemProps<T> {
|
||||
interface ISidePanelListItemProps {
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const SidePanelListItem = <T,>({
|
||||
export const SidePanelListItem = ({
|
||||
selected,
|
||||
onClick,
|
||||
children,
|
||||
}: ISidePanelListItemProps<T>) => (
|
||||
}: ISidePanelListItemProps) => (
|
||||
<StyledItemRow>
|
||||
<StyledItem selected={selected} onClick={onClick}>
|
||||
{children}
|
||||
|
@ -121,6 +121,7 @@ export const IncomingWebhooksEventsModal = ({
|
||||
columns={[
|
||||
{
|
||||
header: 'Date',
|
||||
maxWidth: 180,
|
||||
cell: (event) =>
|
||||
formatDateYMDHMS(
|
||||
event.createdAt,
|
||||
@ -129,6 +130,7 @@ export const IncomingWebhooksEventsModal = ({
|
||||
},
|
||||
{
|
||||
header: 'Token',
|
||||
maxWidth: 350,
|
||||
cell: (event) => event.tokenName,
|
||||
},
|
||||
]}
|
||||
|
@ -17,6 +17,7 @@ import {
|
||||
TokenGeneration,
|
||||
useIncomingWebhooksForm,
|
||||
} from './IncomingWebhooksForm/useIncomingWebhooksForm';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
|
||||
const StyledHeader = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
@ -158,7 +159,10 @@ export const IncomingWebhooksModal = ({
|
||||
>
|
||||
<StyledHeader>
|
||||
<StyledTitle>{title}</StyledTitle>
|
||||
<Link onClick={onOpenEvents}>View events</Link>
|
||||
<ConditionallyRender
|
||||
condition={editing}
|
||||
show={<Link onClick={onOpenEvents}>View events</Link>}
|
||||
/>
|
||||
</StyledHeader>
|
||||
<StyledForm onSubmit={onSubmit}>
|
||||
<IncomingWebhooksForm
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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} />;
|
||||
};
|
@ -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';
|
||||
@ -14,6 +14,19 @@ import {
|
||||
import { ProjectActionsForm } from './ProjectActionsForm/ProjectActionsForm';
|
||||
import { useProjectActionsForm } from './ProjectActionsForm/useProjectActionsForm';
|
||||
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')(() => ({
|
||||
display: 'flex',
|
||||
@ -36,12 +49,14 @@ interface IProjectActionsModalProps {
|
||||
action?: IActionSet;
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
onOpenEvents: () => void;
|
||||
}
|
||||
|
||||
export const ProjectActionsModal = ({
|
||||
action,
|
||||
open,
|
||||
setOpen,
|
||||
onOpenEvents,
|
||||
}: IProjectActionsModalProps) => {
|
||||
const projectId = useRequiredPathParam('projectId');
|
||||
const { refetch } = useActions(projectId);
|
||||
@ -142,12 +157,18 @@ export const ProjectActionsModal = ({
|
||||
<FormTemplate
|
||||
loading={loading}
|
||||
modal
|
||||
title={title}
|
||||
description='Actions allow you to configure automations based on specific triggers, like incoming webhooks.'
|
||||
documentationLink='https://docs.getunleash.io/reference/actions'
|
||||
documentationLinkLabel='Actions documentation'
|
||||
formatApiCode={formatApiCode}
|
||||
>
|
||||
<StyledHeader>
|
||||
<StyledTitle>{title}</StyledTitle>
|
||||
<ConditionallyRender
|
||||
condition={editing}
|
||||
show={<Link onClick={onOpenEvents}>View events</Link>}
|
||||
/>
|
||||
</StyledHeader>
|
||||
<StyledForm onSubmit={onSubmit}>
|
||||
<ProjectActionsForm
|
||||
enabled={enabled}
|
||||
|
@ -24,6 +24,7 @@ import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServ
|
||||
import { useIncomingWebhooks } from 'hooks/api/getters/useIncomingWebhooks/useIncomingWebhooks';
|
||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
|
||||
import { ProjectActionsEventsModal } from './ProjectActionsEventsModal/ProjectActionsEventsModal';
|
||||
|
||||
interface IProjectActionsTableProps {
|
||||
modalOpen: boolean;
|
||||
@ -49,6 +50,7 @@ export const ProjectActionsTable = ({
|
||||
const { incomingWebhooks } = useIncomingWebhooks();
|
||||
const { serviceAccounts } = useServiceAccounts();
|
||||
|
||||
const [eventsModalOpen, setEventsModalOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
const onToggleAction = async (action: IActionSet, enabled: boolean) => {
|
||||
@ -182,6 +184,10 @@ export const ProjectActionsTable = ({
|
||||
}: { row: { original: IActionSet } }) => (
|
||||
<ProjectActionsTableActionsCell
|
||||
actionId={action.id}
|
||||
onOpenEvents={() => {
|
||||
setSelectedAction(action);
|
||||
setEventsModalOpen(true);
|
||||
}}
|
||||
onEdit={() => {
|
||||
setSelectedAction(action);
|
||||
setModalOpen(true);
|
||||
@ -255,6 +261,19 @@ export const ProjectActionsTable = ({
|
||||
action={selectedAction}
|
||||
open={modalOpen}
|
||||
setOpen={setModalOpen}
|
||||
onOpenEvents={() => {
|
||||
setModalOpen(false);
|
||||
setEventsModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
<ProjectActionsEventsModal
|
||||
action={selectedAction}
|
||||
open={eventsModalOpen}
|
||||
setOpen={setEventsModalOpen}
|
||||
onOpenConfiguration={() => {
|
||||
setEventsModalOpen(false);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
<ProjectActionsDeleteDialog
|
||||
action={selectedAction}
|
||||
|
@ -12,7 +12,7 @@ import {
|
||||
styled,
|
||||
} from '@mui/material';
|
||||
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 { ADMIN } from 'component/providers/AccessProvider/permissions';
|
||||
import { defaultBorderRadius } from 'themes/themeStyles';
|
||||
@ -24,12 +24,14 @@ const StyledBoxCell = styled(Box)({
|
||||
|
||||
interface IProjectActionsTableActionsCellProps {
|
||||
actionId: number;
|
||||
onOpenEvents: (event: React.SyntheticEvent) => void;
|
||||
onEdit: (event: React.SyntheticEvent) => void;
|
||||
onDelete: (event: React.SyntheticEvent) => void;
|
||||
}
|
||||
|
||||
export const ProjectActionsTableActionsCell = ({
|
||||
actionId,
|
||||
onOpenEvents,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: IProjectActionsTableActionsCellProps) => {
|
||||
@ -80,6 +82,24 @@ export const ProjectActionsTableActionsCell = ({
|
||||
}}
|
||||
>
|
||||
<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}>
|
||||
{({ hasAccess }) => (
|
||||
<MenuItem
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
@ -26,3 +26,37 @@ export interface IAction {
|
||||
createdAt: string;
|
||||
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;
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { ObservableEventSource } from './action';
|
||||
|
||||
export interface IIncomingWebhook {
|
||||
id: number;
|
||||
enabled: boolean;
|
||||
@ -16,13 +18,11 @@ export interface IIncomingWebhookToken {
|
||||
createdByUserId: number;
|
||||
}
|
||||
|
||||
type EventSource = 'incoming-webhook';
|
||||
|
||||
export interface IIncomingWebhookEvent {
|
||||
id: number;
|
||||
payload: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
source: EventSource;
|
||||
source: ObservableEventSource;
|
||||
sourceId: number;
|
||||
tokenName: string;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user