1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-20 00:08:02 +01:00

chore: new actions UI (#6448)

https://linear.app/unleash/issue/2-1995/ui-feature-rename-adapt-the-actions-ui

Refreshes the UI for project actions according to the new designs and
suggestions from @gastonfournier and @nicolaesocaciu

Also includes some refactoring.


![image](https://github.com/Unleash/unleash/assets/14320932/83e8e8ed-46aa-471b-9d1d-0c051a298a9a)
This commit is contained in:
Nuno Góis 2024-03-06 15:08:15 +00:00 committed by GitHub
parent 85e9c934a9
commit 64593c57cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 732 additions and 350 deletions

View File

@ -59,10 +59,10 @@ const StyledCard = styled('div', {
'&:after': {
content: '""',
width: 'auto',
height: theme.spacing(0.75),
height: theme.spacing(1),
position: 'absolute',
zIndex: -1,
bottom: theme.spacing(-0.75),
bottom: theme.spacing(-1),
left: theme.spacing(1),
right: theme.spacing(1),
borderBottomLeftRadius: `${theme.shape.borderRadiusMedium}px`,

View File

@ -32,6 +32,7 @@ interface IIntegrationIconProps {
}
const StyledAvatar = styled(Avatar)(({ theme }) => ({
background: 'transparent',
marginRight: theme.spacing(2),
borderRadius: theme.shape.borderRadius,
overflow: 'hidden',
@ -52,7 +53,7 @@ const StyledCustomIcon = styled(Icon)({
});
const StyledSignalsIcon = styled(StyledCustomIcon)(({ theme }) => ({
background: theme.palette.primary.main,
background: theme.palette.background.alternative,
color: theme.palette.primary.contrastText,
}));

View File

@ -9,6 +9,7 @@ const StyledCell = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
padding: theme.spacing(2),
}));
const StyledActionItems = styled('div')(({ theme }) => ({

View File

@ -3,18 +3,32 @@ import { TooltipLink } from 'component/common/TooltipLink/TooltipLink';
import { useActionEvents } from 'hooks/api/getters/useActionEvents/useActionEvents';
import { ProjectActionsEventsDetails } from '../ProjectActionsEventsModal/ProjectActionsEventsDetails/ProjectActionsEventsDetails';
import { CircularProgress, styled } from '@mui/material';
import { CheckCircle, Error as ErrorIcon } from '@mui/icons-material';
import { CheckCircleOutline, ErrorOutline } from '@mui/icons-material';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { formatDateYMDHMS } from 'utils/formatDate';
const StyledTooltipLink = styled(TooltipLink)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
}));
export const StyledSuccessIcon = styled(CheckCircle)(({ theme }) => ({
const StyledTitle = styled('div')(({ theme }) => ({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
margin: theme.spacing(0, 2),
marginTop: theme.spacing(2),
}));
const StyledLastEventSpan = styled('span')(({ theme }) => ({
fontSize: theme.fontSizes.bodySize,
}));
export const StyledSuccessIcon = styled(CheckCircleOutline)(({ theme }) => ({
color: theme.palette.success.main,
}));
export const StyledFailedIcon = styled(ErrorIcon)(({ theme }) => ({
export const StyledFailedIcon = styled(ErrorOutline)(({ theme }) => ({
color: theme.palette.error.main,
}));
@ -29,6 +43,7 @@ export const ProjectActionsLastEvent = ({
const { actionEvents } = useActionEvents(id, project, 1, {
refreshInterval: 5000,
});
const { locationSettings } = useLocationSettings();
if (actionEvents.length === 0) {
return null;
@ -51,7 +66,20 @@ export const ProjectActionsLastEvent = ({
maxWidth: 500,
maxHeight: 600,
}}
tooltip={<ProjectActionsEventsDetails {...actionSetEvent} />}
tooltip={
<>
<StyledTitle>
<StyledLastEventSpan>Last event</StyledLastEventSpan>
<span>
{formatDateYMDHMS(
actionSetEvent.createdAt,
locationSettings?.locale,
)}
</span>
</StyledTitle>
<ProjectActionsEventsDetails {...actionSetEvent} />
</>
}
>
{icon}
</StyledTooltipLink>

View File

@ -2,6 +2,7 @@ import { Alert, styled } from '@mui/material';
import { IActionSetEvent } from 'interfaces/action';
import { ProjectActionsEventsDetailsAction } from './ProjectActionsEventsDetailsAction';
import { ProjectActionsEventsDetailsSource } from './ProjectActionsEventsDetailsSource/ProjectActionsEventsDetailsSource';
import { CheckCircleOutline } from '@mui/icons-material';
const StyledDetails = styled('div')(({ theme }) => ({
display: 'flex',
@ -28,7 +29,10 @@ export const ProjectActionsEventsDetails = ({
return (
<StyledDetails>
<StyledAlert severity={state === 'failed' ? 'error' : 'success'}>
<StyledAlert
severity={state === 'failed' ? 'error' : 'success'}
icon={state === 'success' ? <CheckCircleOutline /> : undefined}
>
{stateText}
</StyledAlert>
<ProjectActionsEventsDetailsSource signal={signal} />

View File

@ -76,13 +76,11 @@ export const ProjectActionsEventsDetailsAction = ({
const actionState =
state === 'success' ? (
<StyledSuccessIcon />
) : state === 'failed' ? (
<StyledFailedIcon />
) : state === 'started' ? (
<CircularProgress size={20} />
) : (
) : state === 'not started' ? (
<span>Not started</span>
);
) : null;
return (
<StyledAction state={state}>

View File

@ -1,12 +1,12 @@
import { CircularProgress, styled } from '@mui/material';
import { CheckCircle, Error as ErrorIcon } from '@mui/icons-material';
import { CheckCircleOutline, ErrorOutline } from '@mui/icons-material';
import { IActionSetEvent } from 'interfaces/action';
export const StyledSuccessIcon = styled(CheckCircle)(({ theme }) => ({
export const StyledSuccessIcon = styled(CheckCircleOutline)(({ theme }) => ({
color: theme.palette.success.main,
}));
export const StyledFailedIcon = styled(ErrorIcon)(({ theme }) => ({
export const StyledFailedIcon = styled(ErrorOutline)(({ theme }) => ({
color: theme.palette.error.main,
}));

View File

@ -1,127 +0,0 @@
import { IconButton, Tooltip, styled } from '@mui/material';
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
import { Delete } from '@mui/icons-material';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useFeatureSearch } from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
import { ActionsActionState } from './useProjectActionsForm';
import { ProjectActionsFormItem } from './ProjectActionsFormItem';
import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview';
const StyledItemRow = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
width: '100%',
}));
const StyledFieldContainer = styled('div')({
flex: 1,
});
export const ProjectActionsActionItem = ({
action,
index,
stateChanged,
onDelete,
}: {
action: ActionsActionState;
index: number;
stateChanged: (action: ActionsActionState) => void;
onDelete: () => void;
}) => {
const { action: actionName } = action;
const projectId = useRequiredPathParam('projectId');
const { project } = useProjectOverview(projectId);
const environments = project.environments.map(
({ environment }) => environment,
);
const { features } = useFeatureSearch({ project: `IS:${projectId}` });
const header = (
<>
<span>Action {index + 1}</span>
<div>
<Tooltip title='Delete action' arrow>
<IconButton onClick={onDelete}>
<Delete />
</IconButton>
</Tooltip>
</div>
</>
);
return (
<ProjectActionsFormItem index={index} header={header} separator='THEN'>
<StyledItemRow>
<StyledFieldContainer>
<GeneralSelect
label='Action'
name='action'
options={[
{
label: 'Enable flag',
key: 'TOGGLE_FEATURE_ON',
},
{
label: 'Disable flag',
key: 'TOGGLE_FEATURE_OFF',
},
]}
value={actionName}
onChange={(selected) =>
stateChanged({
...action,
action: selected,
})
}
fullWidth
/>
</StyledFieldContainer>
<StyledFieldContainer>
<GeneralSelect
label='Environment'
name='environment'
options={environments.map((environment) => ({
label: environment,
key: environment,
}))}
value={action.executionParams.environment as string}
onChange={(selected) =>
stateChanged({
...action,
executionParams: {
...action.executionParams,
environment: selected,
},
})
}
fullWidth
/>
</StyledFieldContainer>
<StyledFieldContainer>
<GeneralSelect
label='Flag name'
name='flag'
options={features.map((feature) => ({
label: feature.name,
key: feature.name,
}))}
value={action.executionParams.featureName as string}
onChange={(selected) =>
stateChanged({
...action,
executionParams: {
...action.executionParams,
featureName: selected,
},
})
}
fullWidth
/>
</StyledFieldContainer>
</StyledItemRow>
</ProjectActionsFormItem>
);
};

View File

@ -1,4 +1,4 @@
import { Alert, Button, Divider, Link, styled } from '@mui/material';
import { Alert, Link, styled } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom';
import Input from 'component/common/Input/Input';
import { FormSwitch } from 'component/common/FormSwitch/FormSwitch';
@ -9,17 +9,8 @@ import {
ProjectActionsFormErrors,
} from './useProjectActionsForm';
import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts';
import { useSignalEndpoints } from 'hooks/api/getters/useSignalEndpoints/useSignalEndpoints';
import { v4 as uuidv4 } from 'uuid';
import { useMemo } from 'react';
import GeneralSelect, {} from 'component/common/GeneralSelect/GeneralSelect';
import { Add } from '@mui/icons-material';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { ProjectActionsActionItem } from './ProjectActionsActionItem';
import { ProjectActionsFilterItem } from './ProjectActionsFilterItem';
import { ProjectActionsFormStep } from './ProjectActionsFormStep';
import { IN } from 'constants/operators';
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
import { ProjectActionsFormStepSource } from './ProjectActionsFormStep/ProjectActionsFormStepSource/ProjectActionsFormStepSource';
import { ProjectActionsFormStepActions } from './ProjectActionsFormStep/ProjectActionsFormStepActions/ProjectActionsFormStepActions';
const StyledServiceAccountAlert = styled(Alert)(({ theme }) => ({
marginBottom: theme.spacing(4),
@ -45,21 +36,8 @@ const StyledInput = styled(Input)(() => ({
width: '100%',
}));
const StyledButtonContainer = styled('div')(({ theme }) => ({
display: 'flex',
marginTop: theme.spacing(1),
gap: theme.spacing(1),
}));
const StyledDivider = styled(Divider)(({ theme }) => ({
margin: theme.spacing(3, 0),
marginBottom: theme.spacing(2),
}));
const StyledTooltip = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
const StyledAlert = styled(Alert)(({ theme }) => ({
marginTop: theme.spacing(3),
}));
interface IProjectActionsFormProps {
@ -67,6 +45,8 @@ interface IProjectActionsFormProps {
setEnabled: React.Dispatch<React.SetStateAction<boolean>>;
name: string;
setName: React.Dispatch<React.SetStateAction<string>>;
description: string;
setDescription: React.Dispatch<React.SetStateAction<string>>;
sourceId: number;
setSourceId: React.Dispatch<React.SetStateAction<number>>;
filters: ActionsFilterState[];
@ -85,6 +65,8 @@ export const ProjectActionsForm = ({
setEnabled,
name,
setName,
description,
setDescription,
sourceId,
setSourceId,
filters,
@ -97,82 +79,13 @@ export const ProjectActionsForm = ({
validateName,
validated,
}: IProjectActionsFormProps) => {
const projectId = useRequiredPathParam('projectId');
const { serviceAccounts, loading: serviceAccountsLoading } =
useServiceAccounts();
const { signalEndpoints, loading: signalEndpointsLoading } =
useSignalEndpoints();
const handleOnBlur = (callback: Function) => {
setTimeout(() => callback(), 300);
};
const addFilter = () => {
const id = uuidv4();
setFilters((filters) => [
...filters,
{
id,
parameter: '',
operator: IN,
},
]);
};
const updateInFilters = (updatedFilter: ActionsFilterState) => {
setFilters((filters) =>
filters.map((filter) =>
filter.id === updatedFilter.id ? updatedFilter : filter,
),
);
};
const addAction = (projectId: string) => {
const id = uuidv4();
const action: ActionsActionState = {
id,
action: '',
sortOrder:
actions
.map((a) => a.sortOrder)
.reduce((a, b) => Math.max(a, b), 0) + 1,
executionParams: {
project: projectId,
},
};
setActions([...actions, action]);
};
const updateInActions = (updatedAction: ActionsActionState) => {
setActions((actions) =>
actions.map((action) =>
action.id === updatedAction.id ? updatedAction : action,
),
);
};
const signalEndpointOptions = useMemo(() => {
if (signalEndpointsLoading) {
return [];
}
return signalEndpoints.map(({ id, name }) => ({
label: name,
key: `${id}`,
}));
}, [signalEndpointsLoading, signalEndpoints]);
const serviceAccountOptions = useMemo(() => {
if (serviceAccountsLoading) {
return [];
}
return serviceAccounts.map((sa) => ({
label: sa.name,
key: `${sa.id}`,
}));
}, [serviceAccountsLoading, serviceAccounts]);
const showErrors = validated && Object.values(errors).some(Boolean);
return (
@ -214,116 +127,36 @@ export const ProjectActionsForm = ({
onBlur={(e) => handleOnBlur(() => validateName(e.target.value))}
autoComplete='off'
/>
<StyledInputDescription>
What is your new action description?
</StyledInputDescription>
<StyledInput
label='Action description'
value={description}
onChange={(e) => setDescription(e.target.value)}
autoComplete='off'
/>
<ProjectActionsFormStep
name='Trigger'
resourceLink={
<RouterLink to='/integrations/signals'>
Create signal endpoint
</RouterLink>
}
>
<GeneralSelect
label='Source'
options={signalEndpointOptions}
value={`${sourceId}`}
onChange={(v) => {
setSourceId(parseInt(v));
}}
/>
</ProjectActionsFormStep>
<ProjectActionsFormStepSource
sourceId={sourceId}
setSourceId={setSourceId}
filters={filters}
setFilters={setFilters}
/>
<ProjectActionsFormStep name='When this' verticalConnector>
{filters.map((filter, index) => (
<ProjectActionsFilterItem
key={filter.id}
index={index}
filter={filter}
stateChanged={updateInFilters}
onDelete={() =>
setFilters((filters) =>
filters.filter((f) => f.id !== filter.id),
)
}
/>
))}
<StyledButtonContainer>
<Button
startIcon={<Add />}
onClick={addFilter}
variant='outlined'
color='primary'
>
Add filter
</Button>
<HelpIcon
htmlTooltip
tooltip={
<StyledTooltip>
<p>
Filters allow you to add conditions to the
execution of the actions based on the source
payload.
</p>
<p>
If no filters are defined then the action
will always be triggered from the selected
source, no matter the payload.
</p>
</StyledTooltip>
}
/>
</StyledButtonContainer>
</ProjectActionsFormStep>
<ProjectActionsFormStep
name='Do these actions'
verticalConnector
resourceLink={
<RouterLink to='/admin/service-accounts'>
Create service account
</RouterLink>
}
>
<GeneralSelect
label='Service account'
name='service-account'
options={serviceAccountOptions}
value={`${actorId}`}
onChange={(v) => {
setActorId(parseInt(v));
}}
/>
<StyledDivider />
{actions.map((action, index) => (
<ProjectActionsActionItem
index={index}
key={action.id}
action={action}
stateChanged={updateInActions}
onDelete={() =>
setActions((actions) =>
actions.filter((a) => a.id !== action.id),
)
}
/>
))}
<StyledButtonContainer>
<Button
startIcon={<Add />}
onClick={() => addAction(projectId)}
variant='outlined'
color='primary'
>
Add action
</Button>
</StyledButtonContainer>
</ProjectActionsFormStep>
<ProjectActionsFormStepActions
serviceAccounts={serviceAccounts}
serviceAccountsLoading={serviceAccountsLoading}
actions={actions}
setActions={setActions}
actorId={actorId}
setActorId={setActorId}
/>
<ConditionallyRender
condition={showErrors}
show={() => (
<Alert severity='error' icon={false}>
<StyledAlert severity='error' icon={false}>
<ul>
{Object.values(errors)
.filter(Boolean)
@ -331,7 +164,7 @@ export const ProjectActionsForm = ({
<li key={error}>{error}</li>
))}
</ul>
</Alert>
</StyledAlert>
)}
/>
</div>

View File

@ -0,0 +1,185 @@
import { Alert, IconButton, Tooltip, styled } from '@mui/material';
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
import { Delete } from '@mui/icons-material';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useFeatureSearch } from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
import { ActionsActionState } from '../../useProjectActionsForm';
import { ProjectActionsFormItem } from '../ProjectActionsFormItem';
import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useServiceAccountAccessMatrix } from 'hooks/api/getters/useServiceAccountAccessMatrix/useServiceAccountAccessMatrix';
import { useMemo } from 'react';
const StyledItemBody = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
width: '100%',
gap: theme.spacing(2),
}));
const StyledItemRow = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
width: '100%',
}));
const StyledFieldContainer = styled('div')({
flex: 1,
});
const options = [
{
label: 'Enable flag',
key: 'TOGGLE_FEATURE_ON',
permissions: ['UPDATE_FEATURE_ENVIRONMENT'],
},
{
label: 'Disable flag',
key: 'TOGGLE_FEATURE_OFF',
permissions: ['UPDATE_FEATURE_ENVIRONMENT'],
},
];
interface IProjectActionsItemProps {
action: ActionsActionState;
index: number;
stateChanged: (action: ActionsActionState) => void;
actorId: number;
onDelete: () => void;
}
export const ProjectActionsActionItem = ({
action,
index,
stateChanged,
actorId,
onDelete,
}: IProjectActionsItemProps) => {
const { action: actionName } = action;
const projectId = useRequiredPathParam('projectId');
const { project } = useProjectOverview(projectId);
const { permissions } = useServiceAccountAccessMatrix(
actorId,
projectId,
action.executionParams.environment as string,
);
const hasPermission = useMemo(() => {
const requiredPermissions = options.find(
({ key }) => key === actionName,
)?.permissions;
const { environment: actionEnvironment } = action.executionParams;
if (
permissions.length === 0 ||
!requiredPermissions ||
!actionEnvironment
) {
return true;
}
return requiredPermissions.some((requiredPermission) =>
permissions.some(
({ permission, environment }) =>
permission === requiredPermission &&
environment === actionEnvironment,
),
);
}, [actionName, permissions]);
const environments = project.environments.map(
({ environment }) => environment,
);
const { features } = useFeatureSearch({ project: `IS:${projectId}` });
const header = (
<>
<span>Action {index + 1}</span>
<div>
<Tooltip title='Delete action' arrow>
<IconButton onClick={onDelete}>
<Delete />
</IconButton>
</Tooltip>
</div>
</>
);
return (
<ProjectActionsFormItem index={index} header={header} separator='THEN'>
<StyledItemBody>
<StyledItemRow>
<StyledFieldContainer>
<GeneralSelect
label='Action'
name='action'
options={options}
value={actionName}
onChange={(selected) =>
stateChanged({
...action,
action: selected,
})
}
fullWidth
/>
</StyledFieldContainer>
<StyledFieldContainer>
<GeneralSelect
label='Environment'
name='environment'
options={environments.map((environment) => ({
label: environment,
key: environment,
}))}
value={action.executionParams.environment as string}
onChange={(selected) =>
stateChanged({
...action,
executionParams: {
...action.executionParams,
environment: selected,
},
})
}
fullWidth
/>
</StyledFieldContainer>
<StyledFieldContainer>
<GeneralSelect
label='Flag name'
name='flag'
options={features.map((feature) => ({
label: feature.name,
key: feature.name,
}))}
value={action.executionParams.featureName as string}
onChange={(selected) =>
stateChanged({
...action,
executionParams: {
...action.executionParams,
featureName: selected,
},
})
}
fullWidth
/>
</StyledFieldContainer>
</StyledItemRow>
<ConditionallyRender
condition={!hasPermission}
show={
<Alert severity='error'>
The selected service account does not have
permissions to execute this action currently.
</Alert>
}
/>
</StyledItemBody>
</ProjectActionsFormItem>
);
};

View File

@ -0,0 +1,124 @@
import { useMemo } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { Button, Divider, styled } from '@mui/material';
import { v4 as uuidv4 } from 'uuid';
import { ProjectActionsActionItem } from './ProjectActionsActionItem';
import { ActionsActionState } from '../../useProjectActionsForm';
import { ProjectActionsFormStep } from '../ProjectActionsFormStep';
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
import { Add } from '@mui/icons-material';
import { IServiceAccount } from 'interfaces/service-account';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
const StyledDivider = styled(Divider)(({ theme }) => ({
margin: theme.spacing(2, 0),
marginBottom: theme.spacing(1),
borderStyle: 'dashed',
}));
const StyledButtonContainer = styled('div')(({ theme }) => ({
display: 'flex',
marginTop: theme.spacing(1),
gap: theme.spacing(1),
}));
interface IProjectActionsFormStepActionsProps {
serviceAccounts: IServiceAccount[];
serviceAccountsLoading: boolean;
actions: ActionsActionState[];
setActions: React.Dispatch<React.SetStateAction<ActionsActionState[]>>;
actorId: number;
setActorId: React.Dispatch<React.SetStateAction<number>>;
}
export const ProjectActionsFormStepActions = ({
serviceAccounts,
serviceAccountsLoading,
actions,
setActions,
actorId,
setActorId,
}: IProjectActionsFormStepActionsProps) => {
const projectId = useRequiredPathParam('projectId');
const addAction = (projectId: string) => {
const id = uuidv4();
const action: ActionsActionState = {
id,
action: '',
sortOrder:
actions
.map((a) => a.sortOrder)
.reduce((a, b) => Math.max(a, b), 0) + 1,
executionParams: {
project: projectId,
},
};
setActions([...actions, action]);
};
const updateInActions = (updatedAction: ActionsActionState) => {
setActions((actions) =>
actions.map((action) =>
action.id === updatedAction.id ? updatedAction : action,
),
);
};
const serviceAccountOptions = useMemo(() => {
if (serviceAccountsLoading) {
return [];
}
return serviceAccounts.map((sa) => ({
label: sa.name,
key: `${sa.id}`,
}));
}, [serviceAccountsLoading, serviceAccounts]);
return (
<ProjectActionsFormStep
name='Do these actions'
verticalConnector
resourceLink={
<RouterLink to='/admin/service-accounts'>
Create service account
</RouterLink>
}
>
<GeneralSelect
label='Service account'
name='service-account'
options={serviceAccountOptions}
value={`${actorId}`}
onChange={(v) => {
setActorId(parseInt(v));
}}
/>
<StyledDivider />
{actions.map((action, index) => (
<ProjectActionsActionItem
index={index}
key={action.id}
action={action}
stateChanged={updateInActions}
actorId={actorId}
onDelete={() =>
setActions((actions) =>
actions.filter((a) => a.id !== action.id),
)
}
/>
))}
<StyledButtonContainer>
<Button
startIcon={<Add />}
onClick={() => addAction(projectId)}
variant='outlined'
color='primary'
>
Add action
</Button>
</StyledButtonContainer>
</ProjectActionsFormStep>
);
};

View File

@ -1,8 +1,14 @@
import { IconButton, Tooltip, styled } from '@mui/material';
import { ActionsFilterState } from './useProjectActionsForm';
import {
Autocomplete,
IconButton,
TextField,
Tooltip,
styled,
} from '@mui/material';
import { ActionsFilterState } from '../../useProjectActionsForm';
import { Delete } from '@mui/icons-material';
import Input from 'component/common/Input/Input';
import { ProjectActionsFormItem } from './ProjectActionsFormItem';
import { ProjectActionsFormItem } from '../ProjectActionsFormItem';
import { ConstraintOperatorSelect } from 'component/common/ConstraintAccordion/ConstraintOperatorSelect';
import {
Operator,
@ -99,6 +105,7 @@ interface IProjectActionsFilterItemProps {
filter: ActionsFilterState;
index: number;
stateChanged: (updatedFilter: ActionsFilterState) => void;
suggestions: string[];
onDelete: () => void;
}
@ -106,6 +113,7 @@ export const ProjectActionsFilterItem = ({
filter,
index,
stateChanged,
suggestions,
onDelete,
}: IProjectActionsFilterItemProps) => {
const { parameter, inverted, operator, caseInsensitive, value, values } =
@ -211,15 +219,23 @@ export const ProjectActionsFilterItem = ({
<StyledFilter>
<StyledFilterHeader>
<StyledInputContainer>
<StyledInput
label='Parameter'
<Autocomplete
freeSolo
options={suggestions}
value={parameter}
onChange={(e) =>
onInputChange={(_, parameter) =>
stateChanged({
...filter,
parameter: e.target.value,
parameter,
})
}
renderInput={(params) => (
<TextField
{...params}
size='small'
label='Parameter'
/>
)}
/>
</StyledInputContainer>
<StyledOperatorOptions>

View File

@ -0,0 +1,158 @@
import { useMemo } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { Button, Divider, styled } from '@mui/material';
import { v4 as uuidv4 } from 'uuid';
import { IN } from 'constants/operators';
import { useSignalEndpoints } from 'hooks/api/getters/useSignalEndpoints/useSignalEndpoints';
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
import { ProjectActionsFilterItem } from './ProjectActionsFilterItem';
import { ActionsFilterState } from '../../useProjectActionsForm';
import { ProjectActionsFormStep } from '../ProjectActionsFormStep';
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
import { Add } from '@mui/icons-material';
import { ProjectActionsPreviewPayload } from './ProjectActionsPreviewPayload';
import { useSignalEndpointSignals } from 'hooks/api/getters/useSignalEndpointSignals/useSignalEndpointSignals';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
const StyledDivider = styled(Divider)(({ theme }) => ({
margin: theme.spacing(2, 0),
marginBottom: theme.spacing(1),
borderStyle: 'dashed',
}));
const StyledTooltip = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
}));
const StyledButtonContainer = styled('div')(({ theme }) => ({
display: 'flex',
marginTop: theme.spacing(1),
gap: theme.spacing(1),
}));
interface IProjectActionsFormStepSourceProps {
sourceId: number;
setSourceId: React.Dispatch<React.SetStateAction<number>>;
filters: ActionsFilterState[];
setFilters: React.Dispatch<React.SetStateAction<ActionsFilterState[]>>;
}
export const ProjectActionsFormStepSource = ({
sourceId,
setSourceId,
filters,
setFilters,
}: IProjectActionsFormStepSourceProps) => {
const { signalEndpoints, loading: signalEndpointsLoading } =
useSignalEndpoints();
const { signalEndpointSignals } = useSignalEndpointSignals(sourceId, 1);
const addFilter = () => {
const id = uuidv4();
setFilters((filters) => [
...filters,
{
id,
parameter: '',
operator: IN,
},
]);
};
const updateInFilters = (updatedFilter: ActionsFilterState) => {
setFilters((filters) =>
filters.map((filter) =>
filter.id === updatedFilter.id ? updatedFilter : filter,
),
);
};
const signalEndpointOptions = useMemo(() => {
if (signalEndpointsLoading) {
return [];
}
return signalEndpoints.map(({ id, name }) => ({
label: name,
key: `${id}`,
}));
}, [signalEndpointsLoading, signalEndpoints]);
const { lastSourcePayload, filterSuggestions } = useMemo(() => {
const lastSourcePayload = signalEndpointSignals[0]?.payload;
return {
lastSourcePayload,
filterSuggestions: Object.keys(lastSourcePayload || {}),
};
}, [signalEndpointSignals]);
return (
<ProjectActionsFormStep
name='When this'
resourceLink={
<RouterLink to='/integrations/signals'>
Create signal endpoint
</RouterLink>
}
>
<GeneralSelect
label='Source'
options={signalEndpointOptions}
value={`${sourceId}`}
onChange={(v) => {
setSourceId(parseInt(v));
}}
/>
<ConditionallyRender
condition={Boolean(sourceId)}
show={
<ProjectActionsPreviewPayload payload={lastSourcePayload} />
}
/>
<StyledDivider />
{filters.map((filter, index) => (
<ProjectActionsFilterItem
key={filter.id}
index={index}
filter={filter}
stateChanged={updateInFilters}
suggestions={filterSuggestions}
onDelete={() =>
setFilters((filters) =>
filters.filter(({ id }) => id !== filter.id),
)
}
/>
))}
<StyledButtonContainer>
<Button
startIcon={<Add />}
onClick={addFilter}
variant='outlined'
color='primary'
>
Add filter
</Button>
<HelpIcon
htmlTooltip
tooltip={
<StyledTooltip>
<p>
Filters allow you to add conditions to the
execution of the actions based on the source
payload.
</p>
<p>
If no filters are defined then the action will
always be triggered from the selected source, no
matter the payload.
</p>
</StyledTooltip>
}
/>
</StyledButtonContainer>
</ProjectActionsFormStep>
);
};

View File

@ -0,0 +1,92 @@
import { ArrowForwardIosSharp } from '@mui/icons-material';
import {
Accordion,
AccordionDetails,
AccordionSummary,
IconButton,
styled,
} from '@mui/material';
import { Suspense, lazy } from 'react';
const LazyReactJSONEditor = lazy(
() => import('component/common/ReactJSONEditor/ReactJSONEditor'),
);
const StyledNoSignalsSpan = styled('span')(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
marginTop: theme.spacing(1.5),
marginBottom: theme.spacing(-0.75),
height: theme.spacing(3),
}));
const StyledAccordion = styled(Accordion)({
backgroundColor: 'transparent',
boxShadow: 'none',
'&:before': {
display: 'none',
},
});
const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({
lineHeight: '1.5rem',
padding: 0,
marginBottom: theme.spacing(-2.25),
fontSize: theme.fontSizes.smallBody,
color: theme.palette.primary.main,
flexDirection: 'row-reverse',
'& .MuiAccordionSummary-expandIconWrapper.Mui-expanded': {
transform: 'rotate(90deg)',
},
}));
const StyledArrowForwardIosSharp = styled(ArrowForwardIosSharp)(
({ theme }) => ({
color: theme.palette.primary.main,
fontSize: theme.fontSizes.smallBody,
}),
);
const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({
marginTop: theme.spacing(2),
padding: 0,
}));
interface IProjectActionsPreviewPayloadProps {
payload?: unknown;
}
export const ProjectActionsPreviewPayload = ({
payload,
}: IProjectActionsPreviewPayloadProps) => {
if (!payload) {
return (
<StyledNoSignalsSpan>
No signals were received from this source yet.
</StyledNoSignalsSpan>
);
}
return (
<StyledAccordion>
<StyledAccordionSummary
expandIcon={
<IconButton>
<StyledArrowForwardIosSharp titleAccess='Toggle' />
</IconButton>
}
>
Preview payload
</StyledAccordionSummary>
<StyledAccordionDetails>
<Suspense fallback={null}>
<LazyReactJSONEditor
content={{ json: payload }}
readOnly
statusBar={false}
editorStyle='sidePanel'
/>
</Suspense>
</StyledAccordionDetails>
</StyledAccordion>
);
};

View File

@ -194,6 +194,8 @@ export const ProjectActionsModal = ({
setEnabled={setEnabled}
name={name}
setName={setName}
description={description}
setDescription={setDescription}
sourceId={sourceId}
setSourceId={setSourceId}
filters={filters}

View File

@ -14,6 +14,7 @@ const StyledCell = styled(Box)({
});
const StyledIcon = styled(Avatar)(({ theme }) => ({
background: 'transparent',
borderRadius: theme.shape.borderRadius,
overflow: 'hidden',
width: theme.spacing(3),

View File

@ -0,0 +1,66 @@
import { useMemo } from 'react';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import useSWR from 'swr';
import { IRole } from 'interfaces/role';
import { IServiceAccount } from 'interfaces/service-account';
import { IMatrixPermission } from 'interfaces/permissions';
import { IPermission } from 'interfaces/user';
interface IServiceAccountAccessMatrix {
root: IMatrixPermission[];
project: IMatrixPermission[];
environment: IMatrixPermission[];
}
interface IServiceAccountAccessMatrixResponse {
matrix: IServiceAccountAccessMatrix;
projectRoles: IRole[];
rootRole: IRole;
serviceAccount: IServiceAccount;
permissions: IPermission[];
}
interface IServiceAccountAccessMatrixOutput
extends Partial<IServiceAccountAccessMatrixResponse> {
permissions: IPermission[];
loading: boolean;
refetch: () => void;
error?: Error;
}
export const useServiceAccountAccessMatrix = (
id: number,
project?: string,
environment?: string,
): IServiceAccountAccessMatrixOutput => {
const queryParams = `${project ? `?project=${project}` : ''}${
environment ? `${project ? '&' : '?'}environment=${environment}` : ''
}`;
const url = `api/admin/service-account/${id}/permissions${queryParams}`;
const { data, error, mutate } = useSWR<IServiceAccountAccessMatrixResponse>(
formatApiPath(url),
fetcher,
);
return useMemo(
() => ({
matrix: data?.matrix,
projectRoles: data?.projectRoles,
rootRole: data?.rootRole,
serviceAccount: data?.serviceAccount,
permissions: data?.permissions || [],
loading: !error && !data,
refetch: () => mutate(),
error,
}),
[data, error, mutate],
);
};
const fetcher = (path: string) => {
return fetch(path)
.then(handleErrorResponses('Service account access matrix'))
.then((res) => res.json());
};