mirror of
https://github.com/Unleash/unleash.git
synced 2025-08-09 13:47:13 +02:00
chore: actions modal form (#6057)
https://linear.app/unleash/issue/2-1882/ui-add-actions-modal-and-form Adds actions modal and form, allowing users to create and edit actions. The main thing that is missing is adding the remaining fields, which will be included in a later PR.
This commit is contained in:
parent
7da9232516
commit
c1046079dd
@ -0,0 +1,157 @@
|
|||||||
|
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';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { IAction, IActionSet } from 'interfaces/action';
|
||||||
|
import { ProjectActionsFormErrors } from './useProjectActionsForm';
|
||||||
|
import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts';
|
||||||
|
|
||||||
|
const StyledServiceAccountAlert = styled(Alert)(({ theme }) => ({
|
||||||
|
marginBottom: theme.spacing(4),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledRaisedSection = styled('div')(({ theme }) => ({
|
||||||
|
background: theme.palette.background.elevation1,
|
||||||
|
padding: theme.spacing(2, 3),
|
||||||
|
borderRadius: theme.shape.borderRadiusLarge,
|
||||||
|
marginBottom: theme.spacing(4),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledInputDescription = styled('p')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
'&:not(:first-of-type)': {
|
||||||
|
marginTop: theme.spacing(3),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledInputSecondaryDescription = styled('p')(({ theme }) => ({
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledInput = styled(Input)(({ theme }) => ({
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: theme.spacing(50),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledSecondarySection = styled('div')(({ theme }) => ({
|
||||||
|
padding: theme.spacing(3),
|
||||||
|
backgroundColor: theme.palette.background.elevation2,
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IProjectActionsFormProps {
|
||||||
|
action?: IActionSet;
|
||||||
|
enabled: boolean;
|
||||||
|
setEnabled: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
name: string;
|
||||||
|
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>>>;
|
||||||
|
actorId: number;
|
||||||
|
setActorId: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
actions: IAction[];
|
||||||
|
setActions: React.Dispatch<React.SetStateAction<IAction[]>>;
|
||||||
|
errors: ProjectActionsFormErrors;
|
||||||
|
validateName: (name: string) => boolean;
|
||||||
|
validated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectActionsForm = ({
|
||||||
|
action,
|
||||||
|
enabled,
|
||||||
|
setEnabled,
|
||||||
|
name,
|
||||||
|
setName,
|
||||||
|
sourceId,
|
||||||
|
setSourceId,
|
||||||
|
filters,
|
||||||
|
setFilters,
|
||||||
|
actorId,
|
||||||
|
setActorId,
|
||||||
|
actions,
|
||||||
|
setActions,
|
||||||
|
errors,
|
||||||
|
validateName,
|
||||||
|
validated,
|
||||||
|
}: IProjectActionsFormProps) => {
|
||||||
|
const { serviceAccounts } = useServiceAccounts();
|
||||||
|
|
||||||
|
const handleOnBlur = (callback: Function) => {
|
||||||
|
setTimeout(() => callback(), 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showErrors = validated && Object.values(errors).some(Boolean);
|
||||||
|
|
||||||
|
// TODO: Need to add the remaining fields. Refer to the design
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={serviceAccounts.length === 0}
|
||||||
|
show={
|
||||||
|
<StyledServiceAccountAlert color='warning'>
|
||||||
|
<strong>Heads up!</strong> In order to create an action
|
||||||
|
you need to create a service account first. Please{' '}
|
||||||
|
<Link
|
||||||
|
to='/admin/service-accounts'
|
||||||
|
component={RouterLink}
|
||||||
|
>
|
||||||
|
go ahead and create one
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</StyledServiceAccountAlert>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StyledRaisedSection>
|
||||||
|
<FormSwitch checked={enabled} setChecked={setEnabled}>
|
||||||
|
Action status
|
||||||
|
</FormSwitch>
|
||||||
|
</StyledRaisedSection>
|
||||||
|
<StyledInputDescription>
|
||||||
|
What is your new action name?
|
||||||
|
</StyledInputDescription>
|
||||||
|
<StyledInput
|
||||||
|
autoFocus
|
||||||
|
label='Action name'
|
||||||
|
error={Boolean(errors.name)}
|
||||||
|
errorText={errors.name}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
validateName(e.target.value);
|
||||||
|
setName(e.target.value);
|
||||||
|
}}
|
||||||
|
onBlur={(e) => handleOnBlur(() => validateName(e.target.value))}
|
||||||
|
autoComplete='off'
|
||||||
|
/>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={showErrors}
|
||||||
|
show={() => (
|
||||||
|
<Alert severity='error' icon={false}>
|
||||||
|
<ul>
|
||||||
|
{Object.values(errors)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((error) => (
|
||||||
|
<li key={error}>{error}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,138 @@
|
|||||||
|
import { useActions } from 'hooks/api/getters/useActions/useActions';
|
||||||
|
import { IAction, IActionSet } from 'interfaces/action';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
enum ErrorField {
|
||||||
|
NAME = 'name',
|
||||||
|
TRIGGER = 'trigger',
|
||||||
|
ACTOR = 'actor',
|
||||||
|
ACTIONS = 'actions',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PROJECT_ACTIONS_FORM_ERRORS = {
|
||||||
|
[ErrorField.NAME]: undefined,
|
||||||
|
[ErrorField.TRIGGER]: undefined,
|
||||||
|
[ErrorField.ACTOR]: undefined,
|
||||||
|
[ErrorField.ACTIONS]: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectActionsFormErrors = Record<ErrorField, string | undefined>;
|
||||||
|
|
||||||
|
export const useProjectActionsForm = (action?: IActionSet) => {
|
||||||
|
const { actions: actionSets } = useActions();
|
||||||
|
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [sourceId, setSourceId] = useState<number>(0);
|
||||||
|
const [filters, setFilters] = useState<Record<string, unknown>>({});
|
||||||
|
const [actorId, setActorId] = useState<number>(0);
|
||||||
|
const [actions, setActions] = useState<IAction[]>([]);
|
||||||
|
|
||||||
|
const reloadForm = () => {
|
||||||
|
setEnabled(action?.enabled ?? true);
|
||||||
|
setName(action?.name || '');
|
||||||
|
setValidated(false);
|
||||||
|
setErrors(DEFAULT_PROJECT_ACTIONS_FORM_ERRORS);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reloadForm();
|
||||||
|
}, [action]);
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<ProjectActionsFormErrors>(
|
||||||
|
DEFAULT_PROJECT_ACTIONS_FORM_ERRORS,
|
||||||
|
);
|
||||||
|
const [validated, setValidated] = useState(false);
|
||||||
|
|
||||||
|
const clearError = (field: ErrorField) => {
|
||||||
|
setErrors((errors) => ({ ...errors, [field]: undefined }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setError = (field: ErrorField, error: string) => {
|
||||||
|
setErrors((errors) => ({ ...errors, [field]: error }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEmpty = (value: string) => !value.length;
|
||||||
|
|
||||||
|
const isNameNotUnique = (value: string) =>
|
||||||
|
actionSets?.some(({ id, name }) => id !== action?.id && name === value);
|
||||||
|
|
||||||
|
const isIdEmpty = (value: number) => value === 0;
|
||||||
|
|
||||||
|
const validateName = (name: string) => {
|
||||||
|
if (isEmpty(name)) {
|
||||||
|
setError(ErrorField.NAME, 'Name is required.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNameNotUnique(name)) {
|
||||||
|
setError(ErrorField.NAME, 'Name must be unique.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearError(ErrorField.NAME);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateSourceId = (sourceId: number) => {
|
||||||
|
if (isIdEmpty(sourceId)) {
|
||||||
|
setError(ErrorField.TRIGGER, 'Incoming webhook is required.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearError(ErrorField.TRIGGER);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateActorId = (sourceId: number) => {
|
||||||
|
if (isIdEmpty(sourceId)) {
|
||||||
|
setError(ErrorField.ACTOR, 'Service account is required.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearError(ErrorField.ACTOR);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateActions = (actions: IAction[]) => {
|
||||||
|
if (actions.length === 0) {
|
||||||
|
setError(ErrorField.ACTIONS, 'At least one action is required.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearError(ErrorField.ACTIONS);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
const validName = validateName(name);
|
||||||
|
const validSourceId = validateSourceId(sourceId);
|
||||||
|
const validActorId = validateActorId(actorId);
|
||||||
|
const validActions = validateActions(actions);
|
||||||
|
|
||||||
|
setValidated(true);
|
||||||
|
|
||||||
|
return validName && validSourceId && validActorId && validActions;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
setEnabled,
|
||||||
|
name,
|
||||||
|
setName,
|
||||||
|
sourceId,
|
||||||
|
setSourceId,
|
||||||
|
filters,
|
||||||
|
setFilters,
|
||||||
|
actorId,
|
||||||
|
setActorId,
|
||||||
|
actions,
|
||||||
|
setActions,
|
||||||
|
errors,
|
||||||
|
setErrors,
|
||||||
|
validated,
|
||||||
|
validateName,
|
||||||
|
validate,
|
||||||
|
reloadForm,
|
||||||
|
};
|
||||||
|
};
|
@ -0,0 +1,177 @@
|
|||||||
|
import { FormEvent, useEffect } from 'react';
|
||||||
|
import { Button, styled } from '@mui/material';
|
||||||
|
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
|
||||||
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
|
||||||
|
import useToast from 'hooks/useToast';
|
||||||
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
|
import { IActionSet } from 'interfaces/action';
|
||||||
|
import { useActions } from 'hooks/api/getters/useActions/useActions';
|
||||||
|
import {
|
||||||
|
ActionSetPayload,
|
||||||
|
useActionsApi,
|
||||||
|
} from 'hooks/api/actions/useActionsApi/useActionsApi';
|
||||||
|
import { ProjectActionsForm } from './ProjectActionsForm/ProjectActionsForm';
|
||||||
|
import { useProjectActionsForm } from './ProjectActionsForm/useProjectActionsForm';
|
||||||
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
|
|
||||||
|
const StyledForm = styled('form')(() => ({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledButtonContainer = styled('div')(({ theme }) => ({
|
||||||
|
marginTop: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
paddingTop: theme.spacing(4),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledCancelButton = styled(Button)(({ theme }) => ({
|
||||||
|
marginLeft: theme.spacing(3),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IProjectActionsModalProps {
|
||||||
|
action?: IActionSet;
|
||||||
|
open: boolean;
|
||||||
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectActionsModal = ({
|
||||||
|
action,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
}: IProjectActionsModalProps) => {
|
||||||
|
const projectId = useRequiredPathParam('projectId');
|
||||||
|
const { refetch } = useActions();
|
||||||
|
const { addActionSet, updateActionSet, loading } = useActionsApi();
|
||||||
|
const { setToastData, setToastApiError } = useToast();
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
|
||||||
|
const {
|
||||||
|
enabled,
|
||||||
|
setEnabled,
|
||||||
|
name,
|
||||||
|
setName,
|
||||||
|
sourceId,
|
||||||
|
setSourceId,
|
||||||
|
filters,
|
||||||
|
setFilters,
|
||||||
|
actorId,
|
||||||
|
setActorId,
|
||||||
|
actions,
|
||||||
|
setActions,
|
||||||
|
errors,
|
||||||
|
validateName,
|
||||||
|
validate,
|
||||||
|
validated,
|
||||||
|
reloadForm,
|
||||||
|
} = useProjectActionsForm(action);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reloadForm();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const editing = action !== undefined;
|
||||||
|
const title = `${editing ? 'Edit' : 'New'} action`;
|
||||||
|
|
||||||
|
const payload: ActionSetPayload = {
|
||||||
|
project: projectId,
|
||||||
|
enabled,
|
||||||
|
name,
|
||||||
|
match: {
|
||||||
|
source: 'incoming-webhook',
|
||||||
|
sourceId,
|
||||||
|
payload: filters,
|
||||||
|
},
|
||||||
|
actorId,
|
||||||
|
actions,
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatApiCode = () => `curl --location --request ${
|
||||||
|
editing ? 'PUT' : 'POST'
|
||||||
|
} '${uiConfig.unleashUrl}/api/admin/actions${editing ? `/${action.id}` : ''}' \\
|
||||||
|
--header 'Authorization: INSERT_API_KEY' \\
|
||||||
|
--header 'Content-Type: application/json' \\
|
||||||
|
--data-raw '${JSON.stringify(payload, undefined, 2)}'`;
|
||||||
|
|
||||||
|
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!validate()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await updateActionSet(action.id, payload);
|
||||||
|
} else {
|
||||||
|
await addActionSet(payload);
|
||||||
|
}
|
||||||
|
setToastData({
|
||||||
|
title: `action ${editing ? 'updated' : 'added'} successfully`,
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
refetch();
|
||||||
|
setOpen(false);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setToastApiError(formatUnknownError(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarModal
|
||||||
|
open={open}
|
||||||
|
onClose={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
label={title}
|
||||||
|
>
|
||||||
|
<FormTemplate
|
||||||
|
loading={loading}
|
||||||
|
modal
|
||||||
|
title={title}
|
||||||
|
description='Actions allow you to configure automations based on specific triggers, like incoming webhooks.'
|
||||||
|
documentationLink='https://docs.getunleash.io/reference/actions'
|
||||||
|
documentationLinkLabel='Actions documentation'
|
||||||
|
formatApiCode={formatApiCode}
|
||||||
|
>
|
||||||
|
<StyledForm onSubmit={onSubmit}>
|
||||||
|
<ProjectActionsForm
|
||||||
|
action={action}
|
||||||
|
enabled={enabled}
|
||||||
|
setEnabled={setEnabled}
|
||||||
|
name={name}
|
||||||
|
setName={setName}
|
||||||
|
sourceId={sourceId}
|
||||||
|
setSourceId={setSourceId}
|
||||||
|
filters={filters}
|
||||||
|
setFilters={setFilters}
|
||||||
|
actorId={actorId}
|
||||||
|
setActorId={setActorId}
|
||||||
|
actions={actions}
|
||||||
|
setActions={setActions}
|
||||||
|
errors={errors}
|
||||||
|
validateName={validateName}
|
||||||
|
validated={validated}
|
||||||
|
/>
|
||||||
|
<StyledButtonContainer>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
variant='contained'
|
||||||
|
color='primary'
|
||||||
|
>
|
||||||
|
{editing ? 'Save' : 'Add'} action
|
||||||
|
</Button>
|
||||||
|
<StyledCancelButton
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</StyledCancelButton>
|
||||||
|
</StyledButtonContainer>
|
||||||
|
</StyledForm>
|
||||||
|
</FormTemplate>
|
||||||
|
</SidebarModal>
|
||||||
|
);
|
||||||
|
};
|
@ -18,7 +18,7 @@ import { ProjectActionsFiltersCell } from './ProjectActionsFiltersCell';
|
|||||||
import { ProjectActionsActorCell } from './ProjectActionsActorCell';
|
import { ProjectActionsActorCell } from './ProjectActionsActorCell';
|
||||||
import { ProjectActionsActionsCell } from './ProjectActionsActionsCell';
|
import { ProjectActionsActionsCell } from './ProjectActionsActionsCell';
|
||||||
import { ProjectActionsTableActionsCell } from './ProjectActionsTableActionsCell';
|
import { ProjectActionsTableActionsCell } from './ProjectActionsTableActionsCell';
|
||||||
// import { ProjectActionsModal } from '../ProjectActionsModal/ProjectActionsModal';
|
import { ProjectActionsModal } from './ProjectActionsModal/ProjectActionsModal';
|
||||||
import { ProjectActionsDeleteDialog } from './ProjectActionsDeleteDialog';
|
import { ProjectActionsDeleteDialog } from './ProjectActionsDeleteDialog';
|
||||||
import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts';
|
import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts';
|
||||||
import { useIncomingWebhooks } from 'hooks/api/getters/useIncomingWebhooks/useIncomingWebhooks';
|
import { useIncomingWebhooks } from 'hooks/api/getters/useIncomingWebhooks/useIncomingWebhooks';
|
||||||
@ -237,11 +237,11 @@ export const ProjectActionsTable = ({
|
|||||||
</TablePlaceholder>
|
</TablePlaceholder>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* <ProjectActionsModal
|
<ProjectActionsModal
|
||||||
action={selectedAction}
|
action={selectedAction}
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
setOpen={setModalOpen}
|
setOpen={setModalOpen}
|
||||||
/> */}
|
/>
|
||||||
<ProjectActionsDeleteDialog
|
<ProjectActionsDeleteDialog
|
||||||
action={selectedAction}
|
action={selectedAction}
|
||||||
open={deleteOpen}
|
open={deleteOpen}
|
||||||
|
Loading…
Reference in New Issue
Block a user