From 7a3d2d6d87e486c78a0f9dd8335a772f3806e44e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Thu, 14 Mar 2024 15:25:23 +0000 Subject: [PATCH] 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. image --- .../ProjectActionsActionItem.tsx | 92 +++++++++---------- .../ProjectActionsActionSelect.tsx | 57 ++++++++++++ .../ProjectActionsFormStepActions.tsx | 15 +-- .../useActionDefinitions.ts | 67 ++++++++++++++ src/lib/util/constants/action-parameters.ts | 37 ++++++++ src/lib/util/constants/actions.ts | 22 ++++- 6 files changed, 224 insertions(+), 66 deletions(-) create mode 100644 frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/ProjectActionsFormStep/ProjectActionsFormStepActions/ProjectActionsActionSelect.tsx create mode 100644 frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/ProjectActionsFormStep/ProjectActionsFormStepActions/useActionDefinitions.ts create mode 100644 src/lib/util/constants/action-parameters.ts diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/ProjectActionsFormStep/ProjectActionsFormStepActions/ProjectActionsActionItem.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/ProjectActionsFormStep/ProjectActionsFormStepActions/ProjectActionsActionItem.tsx index a413e9d86c..8f2ddcf2cb 100644 --- a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/ProjectActionsFormStep/ProjectActionsFormStepActions/ProjectActionsActionItem.tsx +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/ProjectActionsFormStep/ProjectActionsFormStepActions/ProjectActionsActionItem.tsx @@ -1,5 +1,4 @@ import { Alert, IconButton, Tooltip, styled } from '@mui/material'; -import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect'; import Delete from '@mui/icons-material/Delete'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { ActionsActionState } from '../../useProjectActionsForm'; @@ -7,8 +6,9 @@ import { ProjectActionsFormItem } from '../ProjectActionsFormItem'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { useServiceAccountAccessMatrix } from 'hooks/api/getters/useServiceAccountAccessMatrix/useServiceAccountAccessMatrix'; import { useEffect, useMemo } from 'react'; -import { ACTIONS } from '@server/util/constants/actions'; import { ProjectActionsActionParameterAutocomplete } from './ProjectActionsActionParameter/ProjectActionsActionParameterAutocomplete'; +import { ActionDefinitions } from './useActionDefinitions'; +import { ProjectActionsActionSelect } from './ProjectActionsActionSelect'; const StyledItemBody = styled('div')(({ theme }) => ({ display: 'flex', @@ -22,11 +22,13 @@ const StyledItemRow = styled('div')(({ theme }) => ({ alignItems: 'center', gap: theme.spacing(1), width: '100%', + flexWrap: 'wrap', })); -const StyledFieldContainer = styled('div')({ +const StyledFieldContainer = styled('div')(({ theme }) => ({ flex: 1, -}); + minWidth: theme.spacing(25), +})); interface IProjectActionsItemProps { action: ActionsActionState; @@ -34,8 +36,7 @@ interface IProjectActionsItemProps { stateChanged: (action: ActionsActionState) => void; actorId: number; onDelete: () => void; - featureToggles: string[]; - environments: string[]; + actionDefinitions: ActionDefinitions; validated: boolean; } @@ -45,8 +46,7 @@ export const ProjectActionsActionItem = ({ stateChanged, actorId, onDelete, - featureToggles, - environments, + actionDefinitions, validated, }: IProjectActionsItemProps) => { const { action: actionName, executionParams, error } = action; @@ -57,7 +57,7 @@ export const ProjectActionsActionItem = ({ executionParams.environment as string, ); - const actionDefinition = ACTIONS.get(actionName); + const actionDefinition = actionDefinitions.get(actionName); const hasPermission = useMemo(() => { const requiredPermissions = actionDefinition?.permissions; @@ -112,61 +112,51 @@ export const ProjectActionsActionItem = ({ ); + const parameters = + actionDefinition?.parameters.filter(({ hidden }) => !hidden) || []; + return ( - ({ - key, - label, - }))} + + onChange={(value) => stateChanged({ ...action, - action: selected, + action: value, }) } - fullWidth - /> - - - - stateChanged({ - ...action, - executionParams: { - ...executionParams, - environment: selected, - }, - }) - } - options={environments} - /> - - - - stateChanged({ - ...action, - executionParams: { - ...executionParams, - featureName: selected, - }, - }) - } - options={featureToggles} + actionDefinitions={actionDefinitions} /> + 0} + show={ + + {parameters.map(({ name, label, options }) => ( + + + stateChanged({ + ...action, + executionParams: { + ...executionParams, + [name]: value, + }, + }) + } + options={options} + /> + + ))} + + } + /> {error}} diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/ProjectActionsFormStep/ProjectActionsFormStepActions/ProjectActionsActionSelect.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/ProjectActionsFormStep/ProjectActionsFormStepActions/ProjectActionsActionSelect.tsx new file mode 100644 index 0000000000..033d8aba7e --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/ProjectActionsFormStep/ProjectActionsFormStepActions/ProjectActionsActionSelect.tsx @@ -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, + option: { label: string; description?: string }, + ) => ( +
  • + + {option.label} + {option.description} + +
  • + ); + + const actionOptions = [...actionDefinitions].map( + ([key, actionDefinition]) => ({ + key, + ...actionDefinition, + }), + ); + + return ( + key === value)} + onChange={(_, value) => onChange(value ? value.key : '')} + renderOption={renderActionOption} + getOptionLabel={({ label }) => label} + renderInput={(params) => ( + + )} + /> + ); +}; diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/ProjectActionsFormStep/ProjectActionsFormStepActions/ProjectActionsFormStepActions.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/ProjectActionsFormStep/ProjectActionsFormStepActions/ProjectActionsFormStepActions.tsx index 057b35fed2..ad096a1ffb 100644 --- a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/ProjectActionsFormStep/ProjectActionsFormStepActions/ProjectActionsFormStepActions.tsx +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/ProjectActionsFormStep/ProjectActionsFormStepActions/ProjectActionsFormStepActions.tsx @@ -9,8 +9,7 @@ import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect'; import Add from '@mui/icons-material/Add'; import { IServiceAccount } from 'interfaces/service-account'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; -import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview'; -import { useFeatureSearch } from 'hooks/api/getters/useFeatureSearch/useFeatureSearch'; +import { useActionDefinitions } from './useActionDefinitions'; const StyledDivider = styled(Divider)(({ theme }) => ({ margin: theme.spacing(2, 0), @@ -46,14 +45,7 @@ export const ProjectActionsFormStepActions = ({ validated, }: IProjectActionsFormStepActionsProps) => { const projectId = useRequiredPathParam('projectId'); - const { project } = useProjectOverview(projectId); - const { features } = useFeatureSearch({ project: `IS:${projectId}` }); - - const featureToggles = features.map(({ name }) => name).sort(); - - const environments = project.environments.map( - ({ environment }) => environment, - ); + const actionDefinitions = useActionDefinitions(projectId); const addAction = (projectId: string) => { const id = uuidv4(); @@ -122,8 +114,7 @@ export const ProjectActionsFormStepActions = ({ actions.filter((a) => a.id !== action.id), ) } - featureToggles={featureToggles} - environments={environments} + actionDefinitions={actionDefinitions} validated={validated} /> ))} diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/ProjectActionsFormStep/ProjectActionsFormStepActions/useActionDefinitions.ts b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/ProjectActionsFormStep/ProjectActionsFormStepActions/useActionDefinitions.ts new file mode 100644 index 0000000000..6c3352e523 --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectActions/ProjectActionsTable/ProjectActionsModal/ProjectActionsForm/ProjectActionsFormStep/ProjectActionsFormStepActions/useActionDefinitions.ts @@ -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(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; +}; diff --git a/src/lib/util/constants/action-parameters.ts b/src/lib/util/constants/action-parameters.ts new file mode 100644 index 0000000000..5004f4dfcf --- /dev/null +++ b/src/lib/util/constants/action-parameters.ts @@ -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, +]; diff --git a/src/lib/util/constants/actions.ts b/src/lib/util/constants/actions.ts index 6222700ea6..1c038ed7cd 100644 --- a/src/lib/util/constants/actions.ts +++ b/src/lib/util/constants/actions.ts @@ -1,6 +1,15 @@ -type ActionDefinition = { +import { + ActionDefinitionParameter, + toggleFeatureParameters, +} from './action-parameters'; + +export type ActionDefinition = { label: string; + description?: string; + category?: string; permissions: string[]; + parameters: ActionDefinitionParameter[]; + // TODO: Remove this in favor of parameters (filter by !optional) required: string[]; }; @@ -8,16 +17,23 @@ export const ACTIONS = new Map([ [ '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'], + parameters: toggleFeatureParameters, required: ['project', 'environment', 'featureName'], }, ], [ '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'], + parameters: toggleFeatureParameters, required: ['project', 'environment', 'featureName'], }, ],