mirror of
https://github.com/Unleash/unleash.git
synced 2025-05-12 01:17:04 +02: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. 
This commit is contained in:
parent
85e9c934a9
commit
64593c57cf
@ -59,10 +59,10 @@ const StyledCard = styled('div', {
|
|||||||
'&:after': {
|
'&:after': {
|
||||||
content: '""',
|
content: '""',
|
||||||
width: 'auto',
|
width: 'auto',
|
||||||
height: theme.spacing(0.75),
|
height: theme.spacing(1),
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
zIndex: -1,
|
zIndex: -1,
|
||||||
bottom: theme.spacing(-0.75),
|
bottom: theme.spacing(-1),
|
||||||
left: theme.spacing(1),
|
left: theme.spacing(1),
|
||||||
right: theme.spacing(1),
|
right: theme.spacing(1),
|
||||||
borderBottomLeftRadius: `${theme.shape.borderRadiusMedium}px`,
|
borderBottomLeftRadius: `${theme.shape.borderRadiusMedium}px`,
|
||||||
|
@ -32,6 +32,7 @@ interface IIntegrationIconProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const StyledAvatar = styled(Avatar)(({ theme }) => ({
|
const StyledAvatar = styled(Avatar)(({ theme }) => ({
|
||||||
|
background: 'transparent',
|
||||||
marginRight: theme.spacing(2),
|
marginRight: theme.spacing(2),
|
||||||
borderRadius: theme.shape.borderRadius,
|
borderRadius: theme.shape.borderRadius,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@ -52,7 +53,7 @@ const StyledCustomIcon = styled(Icon)({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const StyledSignalsIcon = styled(StyledCustomIcon)(({ theme }) => ({
|
const StyledSignalsIcon = styled(StyledCustomIcon)(({ theme }) => ({
|
||||||
background: theme.palette.primary.main,
|
background: theme.palette.background.alternative,
|
||||||
color: theme.palette.primary.contrastText,
|
color: theme.palette.primary.contrastText,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -9,6 +9,7 @@ const StyledCell = styled('div')(({ theme }) => ({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: theme.spacing(1),
|
gap: theme.spacing(1),
|
||||||
|
padding: theme.spacing(2),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const StyledActionItems = styled('div')(({ theme }) => ({
|
const StyledActionItems = styled('div')(({ theme }) => ({
|
||||||
|
@ -3,18 +3,32 @@ import { TooltipLink } from 'component/common/TooltipLink/TooltipLink';
|
|||||||
import { useActionEvents } from 'hooks/api/getters/useActionEvents/useActionEvents';
|
import { useActionEvents } from 'hooks/api/getters/useActionEvents/useActionEvents';
|
||||||
import { ProjectActionsEventsDetails } from '../ProjectActionsEventsModal/ProjectActionsEventsDetails/ProjectActionsEventsDetails';
|
import { ProjectActionsEventsDetails } from '../ProjectActionsEventsModal/ProjectActionsEventsDetails/ProjectActionsEventsDetails';
|
||||||
import { CircularProgress, styled } from '@mui/material';
|
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 }) => ({
|
const StyledTooltipLink = styled(TooltipLink)(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
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,
|
color: theme.palette.success.main,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const StyledFailedIcon = styled(ErrorIcon)(({ theme }) => ({
|
export const StyledFailedIcon = styled(ErrorOutline)(({ theme }) => ({
|
||||||
color: theme.palette.error.main,
|
color: theme.palette.error.main,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -29,6 +43,7 @@ export const ProjectActionsLastEvent = ({
|
|||||||
const { actionEvents } = useActionEvents(id, project, 1, {
|
const { actionEvents } = useActionEvents(id, project, 1, {
|
||||||
refreshInterval: 5000,
|
refreshInterval: 5000,
|
||||||
});
|
});
|
||||||
|
const { locationSettings } = useLocationSettings();
|
||||||
|
|
||||||
if (actionEvents.length === 0) {
|
if (actionEvents.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@ -51,7 +66,20 @@ export const ProjectActionsLastEvent = ({
|
|||||||
maxWidth: 500,
|
maxWidth: 500,
|
||||||
maxHeight: 600,
|
maxHeight: 600,
|
||||||
}}
|
}}
|
||||||
tooltip={<ProjectActionsEventsDetails {...actionSetEvent} />}
|
tooltip={
|
||||||
|
<>
|
||||||
|
<StyledTitle>
|
||||||
|
<StyledLastEventSpan>Last event</StyledLastEventSpan>
|
||||||
|
<span>
|
||||||
|
{formatDateYMDHMS(
|
||||||
|
actionSetEvent.createdAt,
|
||||||
|
locationSettings?.locale,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</StyledTitle>
|
||||||
|
<ProjectActionsEventsDetails {...actionSetEvent} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</StyledTooltipLink>
|
</StyledTooltipLink>
|
||||||
|
@ -2,6 +2,7 @@ import { Alert, styled } from '@mui/material';
|
|||||||
import { IActionSetEvent } from 'interfaces/action';
|
import { IActionSetEvent } from 'interfaces/action';
|
||||||
import { ProjectActionsEventsDetailsAction } from './ProjectActionsEventsDetailsAction';
|
import { ProjectActionsEventsDetailsAction } from './ProjectActionsEventsDetailsAction';
|
||||||
import { ProjectActionsEventsDetailsSource } from './ProjectActionsEventsDetailsSource/ProjectActionsEventsDetailsSource';
|
import { ProjectActionsEventsDetailsSource } from './ProjectActionsEventsDetailsSource/ProjectActionsEventsDetailsSource';
|
||||||
|
import { CheckCircleOutline } from '@mui/icons-material';
|
||||||
|
|
||||||
const StyledDetails = styled('div')(({ theme }) => ({
|
const StyledDetails = styled('div')(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -28,7 +29,10 @@ export const ProjectActionsEventsDetails = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledDetails>
|
<StyledDetails>
|
||||||
<StyledAlert severity={state === 'failed' ? 'error' : 'success'}>
|
<StyledAlert
|
||||||
|
severity={state === 'failed' ? 'error' : 'success'}
|
||||||
|
icon={state === 'success' ? <CheckCircleOutline /> : undefined}
|
||||||
|
>
|
||||||
{stateText}
|
{stateText}
|
||||||
</StyledAlert>
|
</StyledAlert>
|
||||||
<ProjectActionsEventsDetailsSource signal={signal} />
|
<ProjectActionsEventsDetailsSource signal={signal} />
|
||||||
|
@ -76,13 +76,11 @@ export const ProjectActionsEventsDetailsAction = ({
|
|||||||
const actionState =
|
const actionState =
|
||||||
state === 'success' ? (
|
state === 'success' ? (
|
||||||
<StyledSuccessIcon />
|
<StyledSuccessIcon />
|
||||||
) : state === 'failed' ? (
|
|
||||||
<StyledFailedIcon />
|
|
||||||
) : state === 'started' ? (
|
) : state === 'started' ? (
|
||||||
<CircularProgress size={20} />
|
<CircularProgress size={20} />
|
||||||
) : (
|
) : state === 'not started' ? (
|
||||||
<span>Not started</span>
|
<span>Not started</span>
|
||||||
);
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledAction state={state}>
|
<StyledAction state={state}>
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { CircularProgress, styled } from '@mui/material';
|
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';
|
import { IActionSetEvent } from 'interfaces/action';
|
||||||
|
|
||||||
export const StyledSuccessIcon = styled(CheckCircle)(({ theme }) => ({
|
export const StyledSuccessIcon = styled(CheckCircleOutline)(({ theme }) => ({
|
||||||
color: theme.palette.success.main,
|
color: theme.palette.success.main,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const StyledFailedIcon = styled(ErrorIcon)(({ theme }) => ({
|
export const StyledFailedIcon = styled(ErrorOutline)(({ theme }) => ({
|
||||||
color: theme.palette.error.main,
|
color: theme.palette.error.main,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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 { Link as RouterLink } from 'react-router-dom';
|
||||||
import Input from 'component/common/Input/Input';
|
import Input from 'component/common/Input/Input';
|
||||||
import { FormSwitch } from 'component/common/FormSwitch/FormSwitch';
|
import { FormSwitch } from 'component/common/FormSwitch/FormSwitch';
|
||||||
@ -9,17 +9,8 @@ import {
|
|||||||
ProjectActionsFormErrors,
|
ProjectActionsFormErrors,
|
||||||
} from './useProjectActionsForm';
|
} from './useProjectActionsForm';
|
||||||
import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts';
|
import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts';
|
||||||
import { useSignalEndpoints } from 'hooks/api/getters/useSignalEndpoints/useSignalEndpoints';
|
import { ProjectActionsFormStepSource } from './ProjectActionsFormStep/ProjectActionsFormStepSource/ProjectActionsFormStepSource';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { ProjectActionsFormStepActions } from './ProjectActionsFormStep/ProjectActionsFormStepActions/ProjectActionsFormStepActions';
|
||||||
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';
|
|
||||||
|
|
||||||
const StyledServiceAccountAlert = styled(Alert)(({ theme }) => ({
|
const StyledServiceAccountAlert = styled(Alert)(({ theme }) => ({
|
||||||
marginBottom: theme.spacing(4),
|
marginBottom: theme.spacing(4),
|
||||||
@ -45,21 +36,8 @@ const StyledInput = styled(Input)(() => ({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const StyledButtonContainer = styled('div')(({ theme }) => ({
|
const StyledAlert = styled(Alert)(({ theme }) => ({
|
||||||
display: 'flex',
|
marginTop: theme.spacing(3),
|
||||||
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),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
interface IProjectActionsFormProps {
|
interface IProjectActionsFormProps {
|
||||||
@ -67,6 +45,8 @@ interface IProjectActionsFormProps {
|
|||||||
setEnabled: React.Dispatch<React.SetStateAction<boolean>>;
|
setEnabled: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
name: string;
|
name: string;
|
||||||
setName: React.Dispatch<React.SetStateAction<string>>;
|
setName: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
description: string;
|
||||||
|
setDescription: React.Dispatch<React.SetStateAction<string>>;
|
||||||
sourceId: number;
|
sourceId: number;
|
||||||
setSourceId: React.Dispatch<React.SetStateAction<number>>;
|
setSourceId: React.Dispatch<React.SetStateAction<number>>;
|
||||||
filters: ActionsFilterState[];
|
filters: ActionsFilterState[];
|
||||||
@ -85,6 +65,8 @@ export const ProjectActionsForm = ({
|
|||||||
setEnabled,
|
setEnabled,
|
||||||
name,
|
name,
|
||||||
setName,
|
setName,
|
||||||
|
description,
|
||||||
|
setDescription,
|
||||||
sourceId,
|
sourceId,
|
||||||
setSourceId,
|
setSourceId,
|
||||||
filters,
|
filters,
|
||||||
@ -97,82 +79,13 @@ export const ProjectActionsForm = ({
|
|||||||
validateName,
|
validateName,
|
||||||
validated,
|
validated,
|
||||||
}: IProjectActionsFormProps) => {
|
}: IProjectActionsFormProps) => {
|
||||||
const projectId = useRequiredPathParam('projectId');
|
|
||||||
const { serviceAccounts, loading: serviceAccountsLoading } =
|
const { serviceAccounts, loading: serviceAccountsLoading } =
|
||||||
useServiceAccounts();
|
useServiceAccounts();
|
||||||
const { signalEndpoints, loading: signalEndpointsLoading } =
|
|
||||||
useSignalEndpoints();
|
|
||||||
|
|
||||||
const handleOnBlur = (callback: Function) => {
|
const handleOnBlur = (callback: Function) => {
|
||||||
setTimeout(() => callback(), 300);
|
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);
|
const showErrors = validated && Object.values(errors).some(Boolean);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -214,116 +127,36 @@ export const ProjectActionsForm = ({
|
|||||||
onBlur={(e) => handleOnBlur(() => validateName(e.target.value))}
|
onBlur={(e) => handleOnBlur(() => validateName(e.target.value))}
|
||||||
autoComplete='off'
|
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
|
<ProjectActionsFormStepSource
|
||||||
name='Trigger'
|
sourceId={sourceId}
|
||||||
resourceLink={
|
setSourceId={setSourceId}
|
||||||
<RouterLink to='/integrations/signals'>
|
filters={filters}
|
||||||
Create signal endpoint
|
setFilters={setFilters}
|
||||||
</RouterLink>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<GeneralSelect
|
|
||||||
label='Source'
|
|
||||||
options={signalEndpointOptions}
|
|
||||||
value={`${sourceId}`}
|
|
||||||
onChange={(v) => {
|
|
||||||
setSourceId(parseInt(v));
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</ProjectActionsFormStep>
|
|
||||||
|
|
||||||
<ProjectActionsFormStep name='When this' verticalConnector>
|
<ProjectActionsFormStepActions
|
||||||
{filters.map((filter, index) => (
|
serviceAccounts={serviceAccounts}
|
||||||
<ProjectActionsFilterItem
|
serviceAccountsLoading={serviceAccountsLoading}
|
||||||
key={filter.id}
|
actions={actions}
|
||||||
index={index}
|
setActions={setActions}
|
||||||
filter={filter}
|
actorId={actorId}
|
||||||
stateChanged={updateInFilters}
|
setActorId={setActorId}
|
||||||
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>
|
|
||||||
|
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={showErrors}
|
condition={showErrors}
|
||||||
show={() => (
|
show={() => (
|
||||||
<Alert severity='error' icon={false}>
|
<StyledAlert severity='error' icon={false}>
|
||||||
<ul>
|
<ul>
|
||||||
{Object.values(errors)
|
{Object.values(errors)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@ -331,7 +164,7 @@ export const ProjectActionsForm = ({
|
|||||||
<li key={error}>{error}</li>
|
<li key={error}>{error}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</Alert>
|
</StyledAlert>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -1,8 +1,14 @@
|
|||||||
import { IconButton, Tooltip, styled } from '@mui/material';
|
import {
|
||||||
import { ActionsFilterState } from './useProjectActionsForm';
|
Autocomplete,
|
||||||
|
IconButton,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
styled,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { ActionsFilterState } from '../../useProjectActionsForm';
|
||||||
import { Delete } from '@mui/icons-material';
|
import { Delete } from '@mui/icons-material';
|
||||||
import Input from 'component/common/Input/Input';
|
import Input from 'component/common/Input/Input';
|
||||||
import { ProjectActionsFormItem } from './ProjectActionsFormItem';
|
import { ProjectActionsFormItem } from '../ProjectActionsFormItem';
|
||||||
import { ConstraintOperatorSelect } from 'component/common/ConstraintAccordion/ConstraintOperatorSelect';
|
import { ConstraintOperatorSelect } from 'component/common/ConstraintAccordion/ConstraintOperatorSelect';
|
||||||
import {
|
import {
|
||||||
Operator,
|
Operator,
|
||||||
@ -99,6 +105,7 @@ interface IProjectActionsFilterItemProps {
|
|||||||
filter: ActionsFilterState;
|
filter: ActionsFilterState;
|
||||||
index: number;
|
index: number;
|
||||||
stateChanged: (updatedFilter: ActionsFilterState) => void;
|
stateChanged: (updatedFilter: ActionsFilterState) => void;
|
||||||
|
suggestions: string[];
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,6 +113,7 @@ export const ProjectActionsFilterItem = ({
|
|||||||
filter,
|
filter,
|
||||||
index,
|
index,
|
||||||
stateChanged,
|
stateChanged,
|
||||||
|
suggestions,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: IProjectActionsFilterItemProps) => {
|
}: IProjectActionsFilterItemProps) => {
|
||||||
const { parameter, inverted, operator, caseInsensitive, value, values } =
|
const { parameter, inverted, operator, caseInsensitive, value, values } =
|
||||||
@ -211,15 +219,23 @@ export const ProjectActionsFilterItem = ({
|
|||||||
<StyledFilter>
|
<StyledFilter>
|
||||||
<StyledFilterHeader>
|
<StyledFilterHeader>
|
||||||
<StyledInputContainer>
|
<StyledInputContainer>
|
||||||
<StyledInput
|
<Autocomplete
|
||||||
label='Parameter'
|
freeSolo
|
||||||
|
options={suggestions}
|
||||||
value={parameter}
|
value={parameter}
|
||||||
onChange={(e) =>
|
onInputChange={(_, parameter) =>
|
||||||
stateChanged({
|
stateChanged({
|
||||||
...filter,
|
...filter,
|
||||||
parameter: e.target.value,
|
parameter,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
size='small'
|
||||||
|
label='Parameter'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</StyledInputContainer>
|
</StyledInputContainer>
|
||||||
<StyledOperatorOptions>
|
<StyledOperatorOptions>
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -194,6 +194,8 @@ export const ProjectActionsModal = ({
|
|||||||
setEnabled={setEnabled}
|
setEnabled={setEnabled}
|
||||||
name={name}
|
name={name}
|
||||||
setName={setName}
|
setName={setName}
|
||||||
|
description={description}
|
||||||
|
setDescription={setDescription}
|
||||||
sourceId={sourceId}
|
sourceId={sourceId}
|
||||||
setSourceId={setSourceId}
|
setSourceId={setSourceId}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
@ -14,6 +14,7 @@ const StyledCell = styled(Box)({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const StyledIcon = styled(Avatar)(({ theme }) => ({
|
const StyledIcon = styled(Avatar)(({ theme }) => ({
|
||||||
|
background: 'transparent',
|
||||||
borderRadius: theme.shape.borderRadius,
|
borderRadius: theme.shape.borderRadius,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
width: theme.spacing(3),
|
width: theme.spacing(3),
|
||||||
|
@ -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());
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user