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

refactor: project actions (#6203)

https://linear.app/unleash/issue/2-1938/refactor-project-actions

Refactors project actions to not include the project in the payload.

Includes other misc scouting.
This commit is contained in:
Nuno Góis 2024-02-12 17:10:33 +00:00 committed by GitHub
parent c224d7dc4c
commit 9511e64027
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 132 additions and 147 deletions

View File

@ -99,8 +99,6 @@ export const useIncomingWebhooksForm = (incomingWebhook?: IIncomingWebhook) => {
return false;
}
// TODO call backend to check if token name is unique
clearError(ErrorField.TOKEN_NAME);
return true;
};

View File

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

View File

@ -9,7 +9,7 @@ export const StyledInnerBox = styled(Box)(({ theme }) => ({
borderRadius: `${theme.shape.borderRadiusMedium}px`,
}));
export const InnerBoxHeader = styled('div')(({ theme }) => ({
export const StyledInnerBoxHeader = styled('div')(({ theme }) => ({
marginLeft: 'auto',
whiteSpace: 'nowrap',
[theme.breakpoints.down('sm')]: {
@ -18,18 +18,17 @@ export const InnerBoxHeader = styled('div')(({ theme }) => ({
}));
// row for inner containers
export const Row = styled('div')({
export const StyledRow = styled('div')({
display: 'flex',
flexDirection: 'row',
width: '100%',
});
export const Col = styled('div')({
export const StyledCol = 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,
@ -43,6 +42,8 @@ export const BoxSeparator: React.FC = ({ children }) => {
transform: 'translateY(-50%)',
lineHeight: 1,
}));
export const BoxSeparator: React.FC = ({ children }) => {
return (
<Box
sx={{

View File

@ -1,48 +1,35 @@
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,
StyledCol,
StyledInnerBoxHeader,
StyledRow,
StyledInnerBox,
} from './InnerContainerBox';
import { ActionsActionState } from './useProjectActionsForm';
export type UIAction = Omit<IAction, 'id' | 'createdAt' | 'createdByUserId'> & {
id: string;
};
export const ActionItem = ({
export const ProjectActionsActionItem = ({
action,
index,
stateChanged,
onDelete,
}: {
action: UIAction;
action: ActionsActionState;
index: number;
stateChanged: (action: UIAction) => void;
stateChanged: (action: ActionsActionState) => void;
onDelete: () => void;
}) => {
const { id, action: actionName } = action;
const { action: actionName } = action;
const projectId = useRequiredPathParam('projectId');
const environments = useProjectEnvironments(projectId);
const { features } = useFeatureSearch(
mapValues(
{
project: `IS:${projectId}`,
},
(value) => (value ? `${value}` : undefined),
),
{},
);
const { features } = useFeatureSearch({ project: `IS:${projectId}` });
return (
<Fragment>
<ConditionallyRender
@ -50,18 +37,18 @@ export const ActionItem = ({
show={<BoxSeparator>THEN</BoxSeparator>}
/>
<StyledInnerBox>
<Row>
<StyledRow>
<span>Action {index + 1}</span>
<InnerBoxHeader>
<StyledInnerBoxHeader>
<Tooltip title='Delete action' arrow>
<IconButton onClick={onDelete}>
<Delete />
</IconButton>
</Tooltip>
</InnerBoxHeader>
</Row>
<Row>
<Col>
</StyledInnerBoxHeader>
</StyledRow>
<StyledRow>
<StyledCol>
<GeneralSelect
label='Action'
name='action'
@ -84,8 +71,8 @@ export const ActionItem = ({
}
fullWidth
/>
</Col>
<Col>
</StyledCol>
<StyledCol>
<GeneralSelect
label='Environment'
name='environment'
@ -105,8 +92,8 @@ export const ActionItem = ({
}
fullWidth
/>
</Col>
<Col>
</StyledCol>
<StyledCol>
<GeneralSelect
label='Flag name'
name='flag'
@ -126,8 +113,8 @@ export const ActionItem = ({
}
fullWidth
/>
</Col>
</Row>
</StyledCol>
</StyledRow>
</StyledInnerBox>
</Fragment>
);

View File

@ -1,13 +1,13 @@
import { Badge, IconButton, Tooltip, styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { IActionFilter } from './useProjectActionsForm';
import { ActionsFilterState } from './useProjectActionsForm';
import { Fragment } from 'react';
import { Delete } from '@mui/icons-material';
import Input from 'component/common/Input/Input';
import {
BoxSeparator,
InnerBoxHeader,
Row,
StyledInnerBoxHeader,
StyledRow,
StyledInnerBox,
} from './InnerContainerBox';
@ -20,18 +20,18 @@ const StyledBadge = styled(Badge)(({ theme }) => ({
margin: theme.spacing(1),
}));
export const FilterItem = ({
export const ProjectActionsFilterItem = ({
filter,
index,
stateChanged,
onDelete,
}: {
filter: IActionFilter;
filter: ActionsFilterState;
index: number;
stateChanged: (updatedFilter: IActionFilter) => void;
stateChanged: (updatedFilter: ActionsFilterState) => void;
onDelete: () => void;
}) => {
const { id, parameter, value } = filter;
const { parameter, value } = filter;
return (
<Fragment>
<ConditionallyRender
@ -39,25 +39,24 @@ export const FilterItem = ({
show={<BoxSeparator>AND</BoxSeparator>}
/>
<StyledInnerBox>
<Row>
<StyledRow>
<span>Filter {index + 1}</span>
<InnerBoxHeader>
<StyledInnerBoxHeader>
<Tooltip title='Delete filter' arrow>
<IconButton type='button' onClick={onDelete}>
<Delete />
</IconButton>
</Tooltip>
</InnerBoxHeader>
</Row>
<Row>
</StyledInnerBoxHeader>
</StyledRow>
<StyledRow>
<StyledInput
label='Parameter'
value={parameter}
onChange={(e) =>
stateChanged({
id,
...filter,
parameter: e.target.value,
value,
})
}
/>
@ -67,13 +66,12 @@ export const FilterItem = ({
value={value}
onChange={(e) =>
stateChanged({
id,
parameter,
...filter,
value: e.target.value,
})
}
/>
</Row>
</StyledRow>
</StyledInnerBox>
</Fragment>
);

View File

@ -4,9 +4,9 @@ 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 { IActionSet } from 'interfaces/action';
import {
IActionFilter,
ActionsFilterState,
ActionsActionState,
ProjectActionsFormErrors,
} from './useProjectActionsForm';
import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts';
@ -16,9 +16,9 @@ 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';
import { StyledRow } from './InnerContainerBox';
import { ProjectActionsActionItem } from './ProjectActionsActionItem';
import { ProjectActionsFilterItem } from './ProjectActionsFilterItem';
const StyledServiceAccountAlert = styled(Alert)(({ theme }) => ({
marginBottom: theme.spacing(4),
@ -67,26 +67,24 @@ const Step = ({ name, children }: any) => (
);
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: IActionFilter[];
setFilters: React.Dispatch<React.SetStateAction<IActionFilter[]>>;
filters: ActionsFilterState[];
setFilters: React.Dispatch<React.SetStateAction<ActionsFilterState[]>>;
actorId: number;
setActorId: React.Dispatch<React.SetStateAction<number>>;
actions: UIAction[];
setActions: React.Dispatch<React.SetStateAction<UIAction[]>>;
actions: ActionsActionState[];
setActions: React.Dispatch<React.SetStateAction<ActionsActionState[]>>;
errors: ProjectActionsFormErrors;
validateName: (name: string) => boolean;
validated: boolean;
}
export const ProjectActionsForm = ({
action,
enabled,
setEnabled,
name,
@ -124,7 +122,7 @@ export const ProjectActionsForm = ({
]);
};
const updateInFilters = (updatedFilter: IActionFilter) => {
const updateInFilters = (updatedFilter: ActionsFilterState) => {
setFilters((filters) =>
filters.map((filter) =>
filter.id === updatedFilter.id ? updatedFilter : filter,
@ -134,7 +132,7 @@ export const ProjectActionsForm = ({
const addAction = (projectId: string) => {
const id = uuidv4();
const action: UIAction = {
const action: ActionsActionState = {
id,
action: '',
sortOrder:
@ -148,7 +146,7 @@ export const ProjectActionsForm = ({
setActions([...actions, action]);
};
const updateInActions = (updatedAction: UIAction) => {
const updateInActions = (updatedAction: ActionsActionState) => {
setActions((actions) =>
actions.map((action) =>
action.id === updatedAction.id ? updatedAction : action,
@ -241,7 +239,7 @@ export const ProjectActionsForm = ({
<Step name='When this'>
{filters.map((filter, index) => (
<FilterItem
<ProjectActionsFilterItem
key={filter.id}
index={index}
filter={filter}
@ -255,7 +253,7 @@ export const ProjectActionsForm = ({
))}
<hr />
<Row>
<StyledRow>
<Button
type='button'
startIcon={<Add />}
@ -265,7 +263,7 @@ export const ProjectActionsForm = ({
>
Add filter
</Button>
</Row>
</StyledRow>
</Step>
<Step name='Do these action(s)'>
@ -287,7 +285,7 @@ export const ProjectActionsForm = ({
/>
<hr />
{actions.map((action, index) => (
<ActionItem
<ProjectActionsActionItem
index={index}
key={action.id}
action={action}
@ -300,7 +298,7 @@ export const ProjectActionsForm = ({
/>
))}
<hr />
<Row>
<StyledRow>
<Button
type='button'
startIcon={<Add />}
@ -310,7 +308,7 @@ export const ProjectActionsForm = ({
>
Add action
</Button>
</Row>
</StyledRow>
</Step>
<ConditionallyRender

View File

@ -1,22 +1,28 @@
import { useActions } from 'hooks/api/getters/useActions/useActions';
import { IActionSet } from 'interfaces/action';
import { IAction, IActionSet } from 'interfaces/action';
import { useEffect, useState } from 'react';
import { UIAction } from './ActionItem';
import { v4 as uuidv4 } from 'uuid';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
export enum ErrorField {
enum ErrorField {
NAME = 'name',
TRIGGER = 'trigger',
ACTOR = 'actor',
ACTIONS = 'actions',
}
export interface IActionFilter {
export type ActionsFilterState = {
id: string;
parameter: string;
value: string;
}
};
export type ActionsActionState = Omit<
IAction,
'id' | 'createdAt' | 'createdByUserId'
> & {
id: string;
};
const DEFAULT_PROJECT_ACTIONS_FORM_ERRORS = {
[ErrorField.NAME]: undefined,
@ -34,38 +40,33 @@ export const useProjectActionsForm = (action?: IActionSet) => {
const [enabled, setEnabled] = useState(false);
const [name, setName] = useState('');
const [sourceId, setSourceId] = useState<number>(0);
const [filters, setFilters] = useState<IActionFilter[]>([]);
const [filters, setFilters] = useState<ActionsFilterState[]>([]);
const [actorId, setActorId] = useState<number>(0);
const [actions, setActions] = useState<UIAction[]>([]);
const [actions, setActions] = useState<ActionsActionState[]>([]);
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);
setSourceId(action?.match?.sourceId ?? 0);
setFilters(
Object.entries(payload).map(([parameter, value]) => ({
Object.entries(action?.match?.payload ?? {}).map(
([parameter, value]) => ({
id: uuidv4(),
parameter,
value: value as string,
})),
}),
),
);
}
if (action?.actions) {
setActorId(action?.actorId ?? 0);
setActions(
action.actions.map((action) => ({
action?.actions?.map((action) => ({
id: uuidv4(),
action: action.action,
sortOrder: action.sortOrder,
executionParams: action.executionParams,
})),
})) ?? [],
);
}
setValidated(false);
setErrors(DEFAULT_PROJECT_ACTIONS_FORM_ERRORS);
};
@ -128,7 +129,7 @@ export const useProjectActionsForm = (action?: IActionSet) => {
return true;
};
const validateActions = (actions: UIAction[]) => {
const validateActions = (actions: ActionsActionState[]) => {
if (actions.length === 0) {
setError(ErrorField.ACTIONS, 'At least one action is required.');
return false;

View File

@ -77,7 +77,6 @@ export const ProjectActionsModal = ({
const title = `${editing ? 'Edit' : 'New'} action`;
const payload: ActionSetPayload = {
project: projectId,
enabled,
name,
match: {
@ -151,7 +150,6 @@ export const ProjectActionsModal = ({
>
<StyledForm onSubmit={onSubmit}>
<ProjectActionsForm
action={action}
enabled={enabled}
setEnabled={setEnabled}
name={name}

View File

@ -8,7 +8,7 @@ export type ActionPayload = Omit<
export type ActionSetPayload = Omit<
IActionSet,
'id' | 'createdAt' | 'createdByUserId'
'id' | 'project' | 'actions' | 'createdAt' | 'createdByUserId'
> & {
actions: ActionPayload[];
};

View File

@ -6,6 +6,10 @@ import useUiConfig from '../useUiConfig/useUiConfig';
import { IActionSet } from 'interfaces/action';
import { useUiFlag } from 'hooks/useUiFlag';
const DEFAULT_DATA = {
actions: [],
};
export const useActions = (project: string) => {
const { isEnterprise } = useUiConfig();
const actionsEnabled = useUiFlag('automatedActions');
@ -14,14 +18,14 @@ export const useActions = (project: string) => {
actions: IActionSet[];
}>(
isEnterprise() && actionsEnabled,
{ actions: [] },
DEFAULT_DATA,
formatApiPath(`api/admin/projects/${project}/actions`),
fetcher,
);
return useMemo(
() => ({
actions: (data?.actions ?? []) as IActionSet[],
actions: data?.actions ?? [],
loading: !error && !data,
refetch: () => mutate(),
error,

View File

@ -16,7 +16,9 @@ export const useIncomingWebhooks = () => {
const { isEnterprise } = useUiConfig();
const incomingWebhooksEnabled = useUiFlag('incomingWebhooks');
const { data, error, mutate } = useConditionalSWR(
const { data, error, mutate } = useConditionalSWR<{
incomingWebhooks: IIncomingWebhook[];
}>(
isEnterprise() && incomingWebhooksEnabled,
DEFAULT_DATA,
formatApiPath(ENDPOINT),
@ -25,8 +27,7 @@ export const useIncomingWebhooks = () => {
return useMemo(
() => ({
incomingWebhooks: (data?.incomingWebhooks ??
[]) as IIncomingWebhook[],
incomingWebhooks: data?.incomingWebhooks ?? [],
loading: !error && !data,
refetch: () => mutate(),
error,

View File

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