1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-04 00:18:40 +01: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:
Nuno Góis 2024-01-29 11:15:29 +00:00 committed by GitHub
parent 7da9232516
commit c1046079dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 475 additions and 3 deletions

View File

@ -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>
);
};

View File

@ -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,
};
};

View File

@ -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>
);
};

View File

@ -18,7 +18,7 @@ import { ProjectActionsFiltersCell } from './ProjectActionsFiltersCell';
import { ProjectActionsActorCell } from './ProjectActionsActorCell';
import { ProjectActionsActionsCell } from './ProjectActionsActionsCell';
import { ProjectActionsTableActionsCell } from './ProjectActionsTableActionsCell';
// import { ProjectActionsModal } from '../ProjectActionsModal/ProjectActionsModal';
import { ProjectActionsModal } from './ProjectActionsModal/ProjectActionsModal';
import { ProjectActionsDeleteDialog } from './ProjectActionsDeleteDialog';
import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts';
import { useIncomingWebhooks } from 'hooks/api/getters/useIncomingWebhooks/useIncomingWebhooks';
@ -237,11 +237,11 @@ export const ProjectActionsTable = ({
</TablePlaceholder>
}
/>
{/* <ProjectActionsModal
<ProjectActionsModal
action={selectedAction}
open={modalOpen}
setOpen={setModalOpen}
/> */}
/>
<ProjectActionsDeleteDialog
action={selectedAction}
open={deleteOpen}