diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsActionsCell.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsActionsCell.tsx index 4a3b1b3bca..19f6903416 100644 --- a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsActionsCell.tsx +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsActionsCell.tsx @@ -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 0 actions; @@ -38,21 +38,25 @@ export const ProjectActionsActionsCell = ({ - {actions.map(({ id, action, executionParams }) => ( -
- {action} - - {Object.entries(executionParams).map( - ([param, value]) => ( -
  • - {param}:{' '} - {value} -
  • - ), - )} -
    -
    - ))} + {actions.map( + ({ action, executionParams, sortOrder }) => ( +
    + {action} + + {Object.entries(executionParams).map( + ([param, value]) => ( +
  • + {param}:{' '} + {value} +
  • + ), + )} +
    +
    + ), + )} } > diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/ActionItem.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/ActionItem.tsx new file mode 100644 index 0000000000..910c1657fc --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/ActionItem.tsx @@ -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 & { + 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 ( + + 0} + show={THEN} + /> + + + Action {index + 1} + + + + + + + + + + + + stateChanged({ + ...action, + action: selected, + }) + } + fullWidth + /> + + + ({ + label: env.name, + key: env.name, + }))} + value={action.executionParams.environment as string} + onChange={(selected) => + stateChanged({ + ...action, + executionParams: { + ...action.executionParams, + environment: selected, + }, + }) + } + fullWidth + /> + + + ({ + label: feature.name, + key: feature.name, + }))} + value={action.executionParams.featureName as string} + onChange={(selected) => + stateChanged({ + ...action, + executionParams: { + ...action.executionParams, + featureName: selected, + }, + }) + } + fullWidth + /> + + + + + ); +}; diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/FilterItem.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/FilterItem.tsx new file mode 100644 index 0000000000..7f5271b6a8 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/FilterItem.tsx @@ -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 ( + + 0} + show={AND} + /> + + + Filter {index + 1} + + + + + + + + + + + stateChanged({ + id, + parameter: e.target.value, + value, + }) + } + /> + = + + stateChanged({ + id, + parameter, + value: e.target.value, + }) + } + /> + + + + ); +}; diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/InnerContainerBox.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/InnerContainerBox.tsx new file mode 100644 index 0000000000..d2fc1d85f6 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/InnerContainerBox.tsx @@ -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 ( + + {children} + + ); +}; 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 index a85c1a9744..bad5c72d95 100644 --- 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 @@ -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) => ( + + {name} + {children} + +); interface IProjectActionsFormProps { action?: IActionSet; @@ -60,12 +74,12 @@ interface IProjectActionsFormProps { setName: React.Dispatch>; sourceId: number; setSourceId: React.Dispatch>; - filters: Record; - setFilters: React.Dispatch>>; + filters: IActionFilter[]; + setFilters: React.Dispatch>; actorId: number; setActorId: React.Dispatch>; - actions: IAction[]; - setActions: React.Dispatch>; + actions: UIAction[]; + setActions: React.Dispatch>; 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 (
    handleOnBlur(() => validateName(e.target.value))} autoComplete='off' /> + + + + Create incoming webhooks from  + + integrations section + + . + + { + setSourceId(parseInt(v)); + }} + /> + + + + {filters.map((filter, index) => ( + + setFilters((filters) => + filters.filter((f) => f.id !== filter.id), + ) + } + /> + ))} + +
    + + + +
    + + + + Create service accounts from  + + service accounts section + + . + + { + setActorId(parseInt(v)); + }} + /> +
    + {actions.map((action, index) => ( + + setActions((actions) => + actions.filter((a) => a.id !== action.id), + ) + } + /> + ))} +
    + + + +
    + ( 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 index 021e24517e..53105d3e67 100644 --- 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 @@ -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(0); - const [filters, setFilters] = useState>({}); + const [filters, setFilters] = useState([]); const [actorId, setActorId] = useState(0); - const [actions, setActions] = useState([]); + const [actions, setActions] = useState([]); 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; 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 index 545e110af6..9d955abe0f 100644 --- a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsModal.tsx +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsModal.tsx @@ -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 ${ diff --git a/frontend/src/interfaces/action.ts b/frontend/src/interfaces/action.ts index d521c81f06..2bb98bb26e 100644 --- a/frontend/src/interfaces/action.ts +++ b/frontend/src/interfaces/action.ts @@ -19,10 +19,7 @@ export interface IMatch { } export interface IAction { - id: number; action: string; sortOrder: number; executionParams: Record; - createdAt: string; - createdByUserId: number; }