From c1046079dde817af584587184b678c974122dc31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Mon, 29 Jan 2024 11:15:29 +0000 Subject: [PATCH] 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. --- .../ProjectActionsForm/ProjectActionsForm.tsx | 157 ++++++++++++++++ .../useProjectActionsForm.ts | 138 ++++++++++++++ .../ProjectActionsModal.tsx | 177 ++++++++++++++++++ .../ProjectActionsTable.tsx | 6 +- 4 files changed, 475 insertions(+), 3 deletions(-) create mode 100644 frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/ProjectActionsForm.tsx create mode 100644 frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/useProjectActionsForm.ts create mode 100644 frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsModal.tsx diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/ProjectActionsForm.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/ProjectActionsForm.tsx new file mode 100644 index 0000000000..a85c1a9744 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/ProjectActionsForm.tsx @@ -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>; + name: string; + setName: React.Dispatch>; + sourceId: number; + setSourceId: React.Dispatch>; + filters: Record; + setFilters: React.Dispatch>>; + actorId: number; + setActorId: React.Dispatch>; + actions: IAction[]; + setActions: React.Dispatch>; + 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 ( +
+ + Heads up! In order to create an action + you need to create a service account first. Please{' '} + + go ahead and create one + + . + + } + /> + + + Action status + + + + What is your new action name? + + { + validateName(e.target.value); + setName(e.target.value); + }} + onBlur={(e) => handleOnBlur(() => validateName(e.target.value))} + autoComplete='off' + /> + ( + +
    + {Object.values(errors) + .filter(Boolean) + .map((error) => ( +
  • {error}
  • + ))} +
+
+ )} + /> +
+ ); +}; diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/useProjectActionsForm.ts b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/useProjectActionsForm.ts new file mode 100644 index 0000000000..021e24517e --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/useProjectActionsForm.ts @@ -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; + +export const useProjectActionsForm = (action?: IActionSet) => { + const { actions: actionSets } = useActions(); + + const [enabled, setEnabled] = useState(false); + const [name, setName] = useState(''); + const [sourceId, setSourceId] = useState(0); + const [filters, setFilters] = useState>({}); + const [actorId, setActorId] = useState(0); + const [actions, setActions] = useState([]); + + const reloadForm = () => { + setEnabled(action?.enabled ?? true); + setName(action?.name || ''); + setValidated(false); + setErrors(DEFAULT_PROJECT_ACTIONS_FORM_ERRORS); + }; + + useEffect(() => { + reloadForm(); + }, [action]); + + const [errors, setErrors] = useState( + 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, + }; +}; diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsModal.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsModal.tsx new file mode 100644 index 0000000000..545e110af6 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsModal.tsx @@ -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>; +} + +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) => { + 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 ( + { + setOpen(false); + }} + label={title} + > + + + + + + { + setOpen(false); + }} + > + Cancel + + + + + + ); +}; diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsTable.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsTable.tsx index 9f0208a1e8..7e3f012d18 100644 --- a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsTable.tsx +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsTable.tsx @@ -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 = ({ } /> - {/* */} + />