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:
parent
05c014cde7
commit
7a3d2d6d87
@ -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>}
|
||||||
|
@ -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' />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
@ -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;
|
||||||
|
};
|
37
src/lib/util/constants/action-parameters.ts
Normal file
37
src/lib/util/constants/action-parameters.ts
Normal 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,
|
||||||
|
];
|
@ -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'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
Loading…
Reference in New Issue
Block a user