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

feat: project actions UI form (#6115)

## About the changes
Add, delete, and update actions is already working. The UI still needs
some love, but it's functional

![image](https://github.com/Unleash/unleash/assets/455064/f990bc8c-902b-4e00-8a1a-3761c32780a3)

---------

Co-authored-by: Nuno Góis <github@nunogois.com>
This commit is contained in:
Gastón Fournier 2024-02-09 13:13:44 +01:00 committed by GitHub
parent 260ef70309
commit 924ea39ea2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 546 additions and 55 deletions

View File

@ -26,7 +26,7 @@ export const ProjectActionsActionsCell = ({
action,
onCreateAction,
}: IProjectActionsActionsCellProps) => {
const { actions } = action;
const { id: actionSetId, actions } = action;
if (actions.length === 0) {
if (!onCreateAction) return <TextCell>0 actions</TextCell>;
@ -38,21 +38,25 @@ export const ProjectActionsActionsCell = ({
<TooltipLink
tooltip={
<StyledActionItems>
{actions.map(({ id, action, executionParams }) => (
<div key={id}>
<strong>{action}</strong>
<StyledParameterList>
{Object.entries(executionParams).map(
([param, value]) => (
<li key={param}>
<strong>{param}</strong>:{' '}
{value}
</li>
),
)}
</StyledParameterList>
</div>
))}
{actions.map(
({ action, executionParams, sortOrder }) => (
<div
key={`${actionSetId}/${sortOrder}_${action}`}
>
<strong>{action}</strong>
<StyledParameterList>
{Object.entries(executionParams).map(
([param, value]) => (
<li key={param}>
<strong>{param}</strong>:{' '}
{value}
</li>
),
)}
</StyledParameterList>
</div>
),
)}
</StyledActionItems>
}
>

View File

@ -0,0 +1,134 @@
import { IconButton, Tooltip } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { IAction } from 'interfaces/action';
import { Fragment } from 'react';
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
import { Delete } from '@mui/icons-material';
import { useProjectEnvironments } from 'hooks/api/getters/useProjectEnvironments/useProjectEnvironments';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import mapValues from 'lodash.mapvalues';
import { useFeatureSearch } from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
import {
BoxSeparator,
Col,
InnerBoxHeader,
Row,
StyledInnerBox,
} from './InnerContainerBox';
export type UIAction = Omit<IAction, 'id' | 'createdAt' | 'createdByUserId'> & {
id: string;
};
export const ActionItem = ({
action,
index,
stateChanged,
onDelete,
}: {
action: UIAction;
index: number;
stateChanged: (action: UIAction) => void;
onDelete: () => void;
}) => {
const { id, action: actionName } = action;
const projectId = useRequiredPathParam('projectId');
const environments = useProjectEnvironments(projectId);
const { features } = useFeatureSearch(
mapValues(
{
project: `IS:${projectId}`,
},
(value) => (value ? `${value}` : undefined),
),
{},
);
return (
<Fragment>
<ConditionallyRender
condition={index > 0}
show={<BoxSeparator>THEN</BoxSeparator>}
/>
<StyledInnerBox>
<Row>
<span>Action {index + 1}</span>
<InnerBoxHeader>
<Tooltip title='Delete action' arrow>
<IconButton onClick={onDelete}>
<Delete />
</IconButton>
</Tooltip>
</InnerBoxHeader>
</Row>
<Row>
<Col>
<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
/>
</Col>
<Col>
<GeneralSelect
label='Environment'
name='environment'
options={environments.environments.map((env) => ({
label: env.name,
key: env.name,
}))}
value={action.executionParams.environment as string}
onChange={(selected) =>
stateChanged({
...action,
executionParams: {
...action.executionParams,
environment: selected,
},
})
}
fullWidth
/>
</Col>
<Col>
<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
/>
</Col>
</Row>
</StyledInnerBox>
</Fragment>
);
};

View File

@ -0,0 +1,80 @@
import { Badge, IconButton, Tooltip, styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { IActionFilter } from './useProjectActionsForm';
import { Fragment } from 'react';
import { Delete } from '@mui/icons-material';
import Input from 'component/common/Input/Input';
import {
BoxSeparator,
InnerBoxHeader,
Row,
StyledInnerBox,
} from './InnerContainerBox';
const StyledInput = styled(Input)(() => ({
width: '100%',
}));
const StyledBadge = styled(Badge)(({ theme }) => ({
color: 'primary',
margin: theme.spacing(1),
}));
export const FilterItem = ({
filter,
index,
stateChanged,
onDelete,
}: {
filter: IActionFilter;
index: number;
stateChanged: (updatedFilter: IActionFilter) => void;
onDelete: () => void;
}) => {
const { id, parameter, value } = filter;
return (
<Fragment>
<ConditionallyRender
condition={index > 0}
show={<BoxSeparator>AND</BoxSeparator>}
/>
<StyledInnerBox>
<Row>
<span>Filter {index + 1}</span>
<InnerBoxHeader>
<Tooltip title='Delete filter' arrow>
<IconButton type='button' onClick={onDelete}>
<Delete />
</IconButton>
</Tooltip>
</InnerBoxHeader>
</Row>
<Row>
<StyledInput
label='Parameter'
value={parameter}
onChange={(e) =>
stateChanged({
id,
parameter: e.target.value,
value,
})
}
/>
<StyledBadge>=</StyledBadge>
<StyledInput
label='Value'
value={value}
onChange={(e) =>
stateChanged({
id,
parameter,
value: e.target.value,
})
}
/>
</Row>
</StyledInnerBox>
</Fragment>
);
};

View File

@ -0,0 +1,57 @@
import { Box, styled } from '@mui/material';
export const StyledInnerBox = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
backgroundColor: theme.palette.background.default,
border: `1px solid ${theme.palette.divider}`,
padding: theme.spacing(2),
borderRadius: `${theme.shape.borderRadiusMedium}px`,
}));
export const InnerBoxHeader = styled('div')(({ theme }) => ({
marginLeft: 'auto',
whiteSpace: 'nowrap',
[theme.breakpoints.down('sm')]: {
display: 'none',
},
}));
// row for inner containers
export const Row = styled('div')({
display: 'flex',
flexDirection: 'row',
width: '100%',
});
export const Col = styled('div')({
flex: 1,
margin: '0 4px',
});
export const BoxSeparator: React.FC = ({ children }) => {
const StyledBoxContent = styled('div')(({ theme }) => ({
padding: theme.spacing(0.75, 1),
color: theme.palette.text.primary,
fontSize: theme.fontSizes.smallerBody,
backgroundColor: theme.palette.seen.primary,
borderRadius: theme.shape.borderRadius,
position: 'absolute',
zIndex: theme.zIndex.fab,
top: '50%',
left: theme.spacing(2),
transform: 'translateY(-50%)',
lineHeight: 1,
}));
return (
<Box
sx={{
height: 1.5,
position: 'relative',
width: '100%',
}}
>
<StyledBoxContent>{children}</StyledBoxContent>
</Box>
);
};

View File

@ -1,11 +1,24 @@
import { Alert, Link, styled } from '@mui/material';
import { Alert, Box, Button, Link, styled } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom';
import Input from 'component/common/Input/Input';
import { Badge } from 'component/common/Badge/Badge';
import { FormSwitch } from 'component/common/FormSwitch/FormSwitch';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { IAction, IActionSet } from 'interfaces/action';
import { ProjectActionsFormErrors } from './useProjectActionsForm';
import { IActionSet } from 'interfaces/action';
import {
IActionFilter,
ProjectActionsFormErrors,
} from './useProjectActionsForm';
import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts';
import { useIncomingWebhooks } from 'hooks/api/getters/useIncomingWebhooks/useIncomingWebhooks';
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 { Row } from './InnerContainerBox';
import { ActionItem, UIAction } from './ActionItem';
import { FilterItem } from './FilterItem';
const StyledServiceAccountAlert = styled(Alert)(({ theme }) => ({
marginBottom: theme.spacing(4),
@ -27,30 +40,31 @@ const StyledInputDescription = styled('p')(({ theme }) => ({
},
}));
const StyledInputSecondaryDescription = styled('p')(({ theme }) => ({
color: theme.palette.text.secondary,
marginBottom: theme.spacing(1),
}));
const StyledInput = styled(Input)(({ theme }) => ({
const StyledInput = styled(Input)(() => ({
width: '100%',
maxWidth: theme.spacing(50),
}));
const StyledSecondarySection = styled('div')(({ theme }) => ({
padding: theme.spacing(3),
backgroundColor: theme.palette.background.elevation2,
const StyledBadge = styled(Badge)(({ theme }) => ({
color: 'primary',
margin: 'auto',
marginBottom: theme.spacing(1.5),
}));
const StyledBox = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
backgroundColor: theme.palette.background.elevation1,
marginTop: theme.spacing(2),
padding: theme.spacing(2),
borderRadius: theme.shape.borderRadiusMedium,
marginTop: theme.spacing(4),
marginBottom: theme.spacing(2),
}));
const StyledInlineContainer = styled('div')(({ theme }) => ({
padding: theme.spacing(0, 4),
'& > p:not(:first-of-type)': {
marginTop: theme.spacing(2),
},
}));
const Step = ({ name, children }: any) => (
<StyledBox>
<StyledBadge color='secondary'>{name}</StyledBadge>
{children}
</StyledBox>
);
interface IProjectActionsFormProps {
action?: IActionSet;
@ -60,12 +74,12 @@ interface IProjectActionsFormProps {
setName: React.Dispatch<React.SetStateAction<string>>;
sourceId: number;
setSourceId: React.Dispatch<React.SetStateAction<number>>;
filters: Record<string, unknown>;
setFilters: React.Dispatch<React.SetStateAction<Record<string, unknown>>>;
filters: IActionFilter[];
setFilters: React.Dispatch<React.SetStateAction<IActionFilter[]>>;
actorId: number;
setActorId: React.Dispatch<React.SetStateAction<number>>;
actions: IAction[];
setActions: React.Dispatch<React.SetStateAction<IAction[]>>;
actions: UIAction[];
setActions: React.Dispatch<React.SetStateAction<UIAction[]>>;
errors: ProjectActionsFormErrors;
validateName: (name: string) => boolean;
validated: boolean;
@ -89,16 +103,83 @@ export const ProjectActionsForm = ({
validateName,
validated,
}: IProjectActionsFormProps) => {
const { serviceAccounts } = useServiceAccounts();
const { serviceAccounts, loading: serviceAccountsLoading } =
useServiceAccounts();
const { incomingWebhooks, loading: incomingWebhooksLoading } =
useIncomingWebhooks();
const handleOnBlur = (callback: Function) => {
setTimeout(() => callback(), 300);
};
const addFilter = () => {
const id = uuidv4();
setFilters((filters) => [
...filters,
{
id,
parameter: '',
value: '',
},
]);
};
const updateInFilters = (updatedFilter: IActionFilter) => {
setFilters((filters) =>
filters.map((filter) =>
filter.id === updatedFilter.id ? updatedFilter : filter,
),
);
};
const addAction = (projectId: string) => {
const id = uuidv4();
const action: UIAction = {
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: UIAction) => {
setActions((actions) =>
actions.map((action) =>
action.id === updatedAction.id ? updatedAction : action,
),
);
};
const incomingWebhookOptions = useMemo(() => {
if (incomingWebhooksLoading) {
return [];
}
return incomingWebhooks.map((webhook) => ({
label: webhook.name,
key: `${webhook.id}`,
}));
}, [incomingWebhooksLoading, incomingWebhooks]);
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);
// TODO: Need to add the remaining fields. Refer to the design
const projectId = useRequiredPathParam('projectId');
return (
<div>
<ConditionallyRender
@ -138,6 +219,100 @@ export const ProjectActionsForm = ({
onBlur={(e) => handleOnBlur(() => validateName(e.target.value))}
autoComplete='off'
/>
<Step name='Trigger'>
<StyledInputDescription>
Create incoming webhooks from&nbsp;
<RouterLink to='/integrations/incoming-webhooks'>
integrations section
</RouterLink>
.
</StyledInputDescription>
<GeneralSelect
label='Incoming webhook'
name='incoming-webhook'
options={incomingWebhookOptions}
value={`${sourceId}`}
onChange={(v) => {
setSourceId(parseInt(v));
}}
/>
</Step>
<Step name='When this'>
{filters.map((filter, index) => (
<FilterItem
key={filter.id}
index={index}
filter={filter}
stateChanged={updateInFilters}
onDelete={() =>
setFilters((filters) =>
filters.filter((f) => f.id !== filter.id),
)
}
/>
))}
<hr />
<Row>
<Button
type='button'
startIcon={<Add />}
onClick={addFilter}
variant='outlined'
color='primary'
>
Add filter
</Button>
</Row>
</Step>
<Step name='Do these action(s)'>
<StyledInputDescription>
Create service accounts from&nbsp;
<RouterLink to='/admin/service-accounts'>
service accounts section
</RouterLink>
.
</StyledInputDescription>
<GeneralSelect
label='Service account'
name='service-account'
options={serviceAccountOptions}
value={`${actorId}`}
onChange={(v) => {
setActorId(parseInt(v));
}}
/>
<hr />
{actions.map((action, index) => (
<ActionItem
index={index}
key={action.id}
action={action}
stateChanged={updateInActions}
onDelete={() =>
setActions((actions) =>
actions.filter((a) => a.id !== action.id),
)
}
/>
))}
<hr />
<Row>
<Button
type='button'
startIcon={<Add />}
onClick={() => addAction(projectId)}
variant='outlined'
color='primary'
>
Add action
</Button>
</Row>
</Step>
<ConditionallyRender
condition={showErrors}
show={() => (

View File

@ -1,14 +1,22 @@
import { useActions } from 'hooks/api/getters/useActions/useActions';
import { IAction, IActionSet } from 'interfaces/action';
import { IActionSet } from 'interfaces/action';
import { useEffect, useState } from 'react';
import { UIAction } from './ActionItem';
import { v4 as uuidv4 } from 'uuid';
enum ErrorField {
export enum ErrorField {
NAME = 'name',
TRIGGER = 'trigger',
ACTOR = 'actor',
ACTIONS = 'actions',
}
export interface IActionFilter {
id: string;
parameter: string;
value: string;
}
const DEFAULT_PROJECT_ACTIONS_FORM_ERRORS = {
[ErrorField.NAME]: undefined,
[ErrorField.TRIGGER]: undefined,
@ -24,14 +32,38 @@ export const useProjectActionsForm = (action?: IActionSet) => {
const [enabled, setEnabled] = useState(false);
const [name, setName] = useState('');
const [sourceId, setSourceId] = useState<number>(0);
const [filters, setFilters] = useState<Record<string, unknown>>({});
const [filters, setFilters] = useState<IActionFilter[]>([]);
const [actorId, setActorId] = useState<number>(0);
const [actions, setActions] = useState<IAction[]>([]);
const [actions, setActions] = useState<UIAction[]>([]);
const reloadForm = () => {
setEnabled(action?.enabled ?? true);
setName(action?.name || '');
setValidated(false);
if (action?.actorId) {
setActorId(action?.actorId);
}
if (action?.match) {
const { sourceId, payload } = action.match;
setSourceId(sourceId);
setFilters(
Object.entries(payload).map(([parameter, value]) => ({
id: uuidv4(),
parameter,
value: value as string,
})),
);
}
if (action?.actions) {
setActions(
action.actions.map((action) => ({
id: uuidv4(),
action: action.action,
sortOrder: action.sortOrder,
executionParams: action.executionParams,
})),
);
}
setErrors(DEFAULT_PROJECT_ACTIONS_FORM_ERRORS);
};
@ -94,7 +126,7 @@ export const useProjectActionsForm = (action?: IActionSet) => {
return true;
};
const validateActions = (actions: IAction[]) => {
const validateActions = (actions: UIAction[]) => {
if (actions.length === 0) {
setError(ErrorField.ACTIONS, 'At least one action is required.');
return false;

View File

@ -83,10 +83,22 @@ export const ProjectActionsModal = ({
match: {
source: 'incoming-webhook',
sourceId,
payload: filters,
payload: filters
.filter((f) => f.parameter.length > 0)
.reduce(
(acc, filter) => ({
...acc,
[filter.parameter]: filter.value,
}),
{},
),
},
actorId,
actions,
actions: actions.map(({ action, sortOrder, executionParams }) => ({
action,
sortOrder,
executionParams,
})),
};
const formatApiCode = () => `curl --location --request ${

View File

@ -19,10 +19,7 @@ export interface IMatch {
}
export interface IAction {
id: number;
action: string;
sortOrder: number;
executionParams: Record<string, unknown>;
createdAt: string;
createdByUserId: number;
}