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

chore: dynamic action parameters (#6554)

https://linear.app/unleash/issue/2-2046/implement-dynamic-action-parameters

Implements dynamic action parameters.

Also improves the action dropdown to better prepare for future actions.

<img width="771" alt="image"
src="https://github.com/Unleash/unleash/assets/14320932/ec3fcaf2-40c8-4dc8-8834-7a0d54671fd2">
This commit is contained in:
Nuno Góis 2024-03-14 15:25:23 +00:00 committed by GitHub
parent 05c014cde7
commit 7a3d2d6d87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 224 additions and 66 deletions

View File

@ -1,5 +1,4 @@
import { Alert, IconButton, Tooltip, styled } from '@mui/material'; import { Alert, IconButton, Tooltip, styled } from '@mui/material';
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
import Delete from '@mui/icons-material/Delete'; import Delete from '@mui/icons-material/Delete';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { ActionsActionState } from '../../useProjectActionsForm'; import { ActionsActionState } from '../../useProjectActionsForm';
@ -7,8 +6,9 @@ import { ProjectActionsFormItem } from '../ProjectActionsFormItem';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useServiceAccountAccessMatrix } from 'hooks/api/getters/useServiceAccountAccessMatrix/useServiceAccountAccessMatrix'; import { useServiceAccountAccessMatrix } from 'hooks/api/getters/useServiceAccountAccessMatrix/useServiceAccountAccessMatrix';
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { ACTIONS } from '@server/util/constants/actions';
import { ProjectActionsActionParameterAutocomplete } from './ProjectActionsActionParameter/ProjectActionsActionParameterAutocomplete'; import { ProjectActionsActionParameterAutocomplete } from './ProjectActionsActionParameter/ProjectActionsActionParameterAutocomplete';
import { ActionDefinitions } from './useActionDefinitions';
import { ProjectActionsActionSelect } from './ProjectActionsActionSelect';
const StyledItemBody = styled('div')(({ theme }) => ({ const StyledItemBody = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
@ -22,11 +22,13 @@ const StyledItemRow = styled('div')(({ theme }) => ({
alignItems: 'center', alignItems: 'center',
gap: theme.spacing(1), gap: theme.spacing(1),
width: '100%', width: '100%',
flexWrap: 'wrap',
})); }));
const StyledFieldContainer = styled('div')({ const StyledFieldContainer = styled('div')(({ theme }) => ({
flex: 1, flex: 1,
}); minWidth: theme.spacing(25),
}));
interface IProjectActionsItemProps { interface IProjectActionsItemProps {
action: ActionsActionState; action: ActionsActionState;
@ -34,8 +36,7 @@ interface IProjectActionsItemProps {
stateChanged: (action: ActionsActionState) => void; stateChanged: (action: ActionsActionState) => void;
actorId: number; actorId: number;
onDelete: () => void; onDelete: () => void;
featureToggles: string[]; actionDefinitions: ActionDefinitions;
environments: string[];
validated: boolean; validated: boolean;
} }
@ -45,8 +46,7 @@ export const ProjectActionsActionItem = ({
stateChanged, stateChanged,
actorId, actorId,
onDelete, onDelete,
featureToggles, actionDefinitions,
environments,
validated, validated,
}: IProjectActionsItemProps) => { }: IProjectActionsItemProps) => {
const { action: actionName, executionParams, error } = action; const { action: actionName, executionParams, error } = action;
@ -57,7 +57,7 @@ export const ProjectActionsActionItem = ({
executionParams.environment as string, executionParams.environment as string,
); );
const actionDefinition = ACTIONS.get(actionName); const actionDefinition = actionDefinitions.get(actionName);
const hasPermission = useMemo(() => { const hasPermission = useMemo(() => {
const requiredPermissions = actionDefinition?.permissions; const requiredPermissions = actionDefinition?.permissions;
@ -112,61 +112,51 @@ export const ProjectActionsActionItem = ({
</> </>
); );
const parameters =
actionDefinition?.parameters.filter(({ hidden }) => !hidden) || [];
return ( return (
<ProjectActionsFormItem index={index} header={header} separator='THEN'> <ProjectActionsFormItem index={index} header={header} separator='THEN'>
<StyledItemBody> <StyledItemBody>
<StyledItemRow> <StyledItemRow>
<StyledFieldContainer> <StyledFieldContainer>
<GeneralSelect <ProjectActionsActionSelect
label='Action'
name='action'
options={[...ACTIONS].map(([key, { label }]) => ({
key,
label,
}))}
value={actionName} value={actionName}
onChange={(selected) => onChange={(value) =>
stateChanged({ stateChanged({
...action, ...action,
action: selected, action: value,
}) })
} }
fullWidth actionDefinitions={actionDefinitions}
/>
</StyledFieldContainer>
<StyledFieldContainer>
<ProjectActionsActionParameterAutocomplete
label='Environment'
value={executionParams.environment as string}
onChange={(selected) =>
stateChanged({
...action,
executionParams: {
...executionParams,
environment: selected,
},
})
}
options={environments}
/>
</StyledFieldContainer>
<StyledFieldContainer>
<ProjectActionsActionParameterAutocomplete
label='Flag name'
value={executionParams.featureName as string}
onChange={(selected) =>
stateChanged({
...action,
executionParams: {
...executionParams,
featureName: selected,
},
})
}
options={featureToggles}
/> />
</StyledFieldContainer> </StyledFieldContainer>
</StyledItemRow> </StyledItemRow>
<ConditionallyRender
condition={parameters.length > 0}
show={
<StyledItemRow>
{parameters.map(({ name, label, options }) => (
<StyledFieldContainer key={name}>
<ProjectActionsActionParameterAutocomplete
label={label}
value={executionParams[name] as string}
onChange={(value) =>
stateChanged({
...action,
executionParams: {
...executionParams,
[name]: value,
},
})
}
options={options}
/>
</StyledFieldContainer>
))}
</StyledItemRow>
}
/>
<ConditionallyRender <ConditionallyRender
condition={validated && Boolean(error)} condition={validated && Boolean(error)}
show={<Alert severity='error'>{error}</Alert>} show={<Alert severity='error'>{error}</Alert>}

View File

@ -0,0 +1,57 @@
import { Autocomplete, TextField, styled } from '@mui/material';
import { ActionDefinitions } from './useActionDefinitions';
const StyledActionOption = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
'& > span:last-of-type': {
fontSize: theme.fontSizes.smallerBody,
color: theme.palette.text.secondary,
},
}));
interface IProjectActionsActionSelectProps {
value: string;
onChange: (value: string) => void;
actionDefinitions: ActionDefinitions;
}
export const ProjectActionsActionSelect = ({
value,
onChange,
actionDefinitions,
}: IProjectActionsActionSelectProps) => {
const renderActionOption = (
props: React.HTMLAttributes<HTMLLIElement>,
option: { label: string; description?: string },
) => (
<li {...props}>
<StyledActionOption>
<span>{option.label}</span>
<span>{option.description}</span>
</StyledActionOption>
</li>
);
const actionOptions = [...actionDefinitions].map(
([key, actionDefinition]) => ({
key,
...actionDefinition,
}),
);
return (
<Autocomplete
options={actionOptions}
autoHighlight
autoSelect
value={actionOptions.find(({ key }) => key === value)}
onChange={(_, value) => onChange(value ? value.key : '')}
renderOption={renderActionOption}
getOptionLabel={({ label }) => label}
renderInput={(params) => (
<TextField {...params} size='small' label='Action' />
)}
/>
);
};

View File

@ -9,8 +9,7 @@ import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
import Add from '@mui/icons-material/Add'; import Add from '@mui/icons-material/Add';
import { IServiceAccount } from 'interfaces/service-account'; import { IServiceAccount } from 'interfaces/service-account';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview'; import { useActionDefinitions } from './useActionDefinitions';
import { useFeatureSearch } from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
const StyledDivider = styled(Divider)(({ theme }) => ({ const StyledDivider = styled(Divider)(({ theme }) => ({
margin: theme.spacing(2, 0), margin: theme.spacing(2, 0),
@ -46,14 +45,7 @@ export const ProjectActionsFormStepActions = ({
validated, validated,
}: IProjectActionsFormStepActionsProps) => { }: IProjectActionsFormStepActionsProps) => {
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const { project } = useProjectOverview(projectId); const actionDefinitions = useActionDefinitions(projectId);
const { features } = useFeatureSearch({ project: `IS:${projectId}` });
const featureToggles = features.map(({ name }) => name).sort();
const environments = project.environments.map(
({ environment }) => environment,
);
const addAction = (projectId: string) => { const addAction = (projectId: string) => {
const id = uuidv4(); const id = uuidv4();
@ -122,8 +114,7 @@ export const ProjectActionsFormStepActions = ({
actions.filter((a) => a.id !== action.id), actions.filter((a) => a.id !== action.id),
) )
} }
featureToggles={featureToggles} actionDefinitions={actionDefinitions}
environments={environments}
validated={validated} validated={validated}
/> />
))} ))}

View File

@ -0,0 +1,67 @@
import { useEffect, useState } from 'react';
import { ActionDefinitionParameter } from '@server/util/constants/action-parameters';
import { ACTIONS, ActionDefinition } from '@server/util/constants/actions';
import { useFeatureSearch } from 'hooks/api/getters/useFeatureSearch/useFeatureSearch';
import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview';
type ActionDefinitionParameterWithOption = ActionDefinitionParameter & {
options: string[];
};
type ActionDefinitionWithParameterOptions = Omit<
ActionDefinition,
'parameters'
> & {
parameters: ActionDefinitionParameterWithOption[];
};
export type ActionDefinitions = Map<
string,
ActionDefinitionWithParameterOptions
>;
export const useActionDefinitions = (projectId: string): ActionDefinitions => {
const { project, loading: isProjectLoading } =
useProjectOverview(projectId);
const { features, loading: isFeaturesLoading } = useFeatureSearch({
project: `IS:${projectId}`,
});
const [actionDefinitions, setActionDefinitions] =
useState<ActionDefinitions>(new Map());
useEffect(() => {
if (isProjectLoading || isFeaturesLoading) return;
const optionsByType: Record<
ActionDefinitionParameter['type'],
string[]
> = {
project: [],
environment: project.environments.map(
({ environment }) => environment,
),
featureToggle: features.map(({ name }) => name).sort(),
};
const actionDefinitionsWithParameterOptions = new Map<
string,
ActionDefinitionWithParameterOptions
>(
[...ACTIONS].map(([key, action]) => [
key,
{
...action,
parameters: action.parameters.map((parameter) => ({
...parameter,
options: optionsByType[parameter.type],
})),
},
]),
);
setActionDefinitions(actionDefinitionsWithParameterOptions);
}, [projectId, project, features, isProjectLoading, isFeaturesLoading]);
return actionDefinitions;
};

View File

@ -0,0 +1,37 @@
type ActionDefinitionParameterType =
| 'project'
| 'featureToggle'
| 'environment';
export type ActionDefinitionParameter = {
name: string;
label: string;
type: ActionDefinitionParameterType;
hidden?: boolean;
optional?: boolean;
};
const projectParameter: ActionDefinitionParameter = {
name: 'project',
label: 'Project',
type: 'project',
hidden: true,
};
const environmentParameter: ActionDefinitionParameter = {
name: 'environment',
label: 'Environment',
type: 'environment',
};
const featureToggleParameter: ActionDefinitionParameter = {
name: 'featureName',
label: 'Feature toggle',
type: 'featureToggle',
};
export const toggleFeatureParameters = [
projectParameter,
environmentParameter,
featureToggleParameter,
];

View File

@ -1,6 +1,15 @@
type ActionDefinition = { import {
ActionDefinitionParameter,
toggleFeatureParameters,
} from './action-parameters';
export type ActionDefinition = {
label: string; label: string;
description?: string;
category?: string;
permissions: string[]; permissions: string[];
parameters: ActionDefinitionParameter[];
// TODO: Remove this in favor of parameters (filter by !optional)
required: string[]; required: string[];
}; };
@ -8,16 +17,23 @@ export const ACTIONS = new Map<string, ActionDefinition>([
[ [
'TOGGLE_FEATURE_ON', 'TOGGLE_FEATURE_ON',
{ {
label: 'Enable flag', label: 'Enable feature toggle',
description: 'Enables a feature toggle for a specific environment.',
category: 'Feature toggles',
permissions: ['UPDATE_FEATURE_ENVIRONMENT'], permissions: ['UPDATE_FEATURE_ENVIRONMENT'],
parameters: toggleFeatureParameters,
required: ['project', 'environment', 'featureName'], required: ['project', 'environment', 'featureName'],
}, },
], ],
[ [
'TOGGLE_FEATURE_OFF', 'TOGGLE_FEATURE_OFF',
{ {
label: 'Disable flag', label: 'Disable feature toggle',
description:
'Disables a feature toggle for a specific environment.',
category: 'Feature toggles',
permissions: ['UPDATE_FEATURE_ENVIRONMENT'], permissions: ['UPDATE_FEATURE_ENVIRONMENT'],
parameters: toggleFeatureParameters,
required: ['project', 'environment', 'featureName'], required: ['project', 'environment', 'featureName'],
}, },
], ],