1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-31 13:47:02 +02:00

chore: actions filter constraints (#6389)

https://linear.app/unleash/issue/2-1952/adapt-actions-form-ui-to-unleash-constraint-operators

Implements the new action filters UI, now powered by constraint
operators.

This PR goes through some effort to not touch existing components too
much, especially since they are critical for activation strategies.
Instead, the new feature tries to adapt to the existing components and
styling them appropriately, while still re-using them. We can refactor
this at a later stage if needed.

This UI will face some more drastic changes in the near future due to
the feature rename, so I wanted to keep this PR mostly scoped to the
constraint operators before proceeding with more changes.


![image](https://github.com/Unleash/unleash/assets/14320932/f4bef4e0-33bd-463c-a252-e1cc0c22e843)


![image](https://github.com/Unleash/unleash/assets/14320932/192a3bd4-f11d-4619-b826-bbf5df80050c)


![image](https://github.com/Unleash/unleash/assets/14320932/fcfc0239-a28f-446d-a901-2e73f0add1a2)


As always, did some manual tests and it seems to be working great!
This commit is contained in:
Nuno Góis 2024-02-29 12:56:48 +00:00 committed by GitHub
parent 4f638a1c8d
commit bddc508582
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 352 additions and 72 deletions

View File

@ -87,7 +87,7 @@ export const FreeTextInput = ({
}; };
return ( return (
<div> <>
<ConstraintFormHeader style={{ marginBottom: 0 }}> <ConstraintFormHeader style={{ marginBottom: 0 }}>
Set values (maximum 100 char length per value) Set values (maximum 100 char length per value)
</ConstraintFormHeader> </ConstraintFormHeader>
@ -125,7 +125,7 @@ export const FreeTextInput = ({
removeValue={removeValue} removeValue={removeValue}
/> />
</div> </div>
</div> </>
); );
}; };

View File

@ -20,8 +20,8 @@ import {
import React from 'react'; import React from 'react';
interface IResolveInputProps { interface IResolveInputProps {
contextDefinition: IUnleashContextDefinition; contextDefinition: Pick<IUnleashContextDefinition, 'legalValues'>;
localConstraint: IConstraint; localConstraint: Pick<IConstraint, 'value' | 'values'>;
constraintValues: string[]; constraintValues: string[];
constraintValue: string; constraintValue: string;
setValue: (value: string) => void; setValue: (value: string) => void;

View File

@ -20,8 +20,8 @@ import {
import { nonEmptyArray } from 'utils/nonEmptyArray'; import { nonEmptyArray } from 'utils/nonEmptyArray';
interface IUseConstraintInputProps { interface IUseConstraintInputProps {
contextDefinition: IUnleashContextDefinition; contextDefinition: Pick<IUnleashContextDefinition, 'legalValues'>;
localConstraint: IConstraint; localConstraint: Pick<IConstraint, 'operator' | 'value' | 'values'>;
} }
interface IUseConstraintOutput { interface IUseConstraintOutput {

View File

@ -9,7 +9,7 @@ import { ConditionallyRender } from '../../../../ConditionallyRender/Conditional
import { IConstraint } from 'interfaces/strategy'; import { IConstraint } from 'interfaces/strategy';
interface CaseSensitiveButtonProps { interface CaseSensitiveButtonProps {
localConstraint: IConstraint; localConstraint: Pick<IConstraint, 'caseInsensitive'>;
setCaseInsensitive: () => void; setCaseInsensitive: () => void;
} }

View File

@ -9,7 +9,7 @@ import {
import { ConditionallyRender } from '../../../../ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from '../../../../ConditionallyRender/ConditionallyRender';
interface InvertedOperatorButtonProps { interface InvertedOperatorButtonProps {
localConstraint: IConstraint; localConstraint: Pick<IConstraint, 'inverted'>;
setInvertedOperator: () => void; setInvertedOperator: () => void;
} }

View File

@ -26,11 +26,29 @@ export const ProjectActionsFiltersCell = ({
<TooltipLink <TooltipLink
tooltip={ tooltip={
<> <>
{filters.map(([parameter, value]) => ( {filters.map(
([
parameter,
{
inverted,
operator,
caseInsensitive,
value,
values,
},
]) => (
<StyledItem key={parameter}> <StyledItem key={parameter}>
<strong>{parameter}</strong>: {value} <strong>{parameter}</strong>{' '}
{inverted ? 'NOT' : ''} {operator}{' '}
{caseInsensitive
? '(case insensitive)'
: ''}{' '}
<strong>
{values ? values.join(', ') : value}
</strong>
</StyledItem> </StyledItem>
))} ),
)}
</> </>
} }
> >

View File

@ -1,10 +1,73 @@
import { Badge, IconButton, Tooltip, styled } from '@mui/material'; import { IconButton, Tooltip, styled } from '@mui/material';
import { ActionsFilterState } from './useProjectActionsForm'; import { ActionsFilterState } from './useProjectActionsForm';
import { Delete } from '@mui/icons-material'; import { Delete } from '@mui/icons-material';
import Input from 'component/common/Input/Input'; import Input from 'component/common/Input/Input';
import { ProjectActionsFormItem } from './ProjectActionsFormItem'; import { ProjectActionsFormItem } from './ProjectActionsFormItem';
import { ConstraintOperatorSelect } from 'component/common/ConstraintAccordion/ConstraintOperatorSelect';
import {
Operator,
allOperators,
dateOperators,
inOperators,
stringOperators,
} from 'constants/operators';
import { useEffect, useState } from 'react';
import { oneOf } from 'utils/oneOf';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { CaseSensitiveButton } from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/CaseSensitiveButton/CaseSensitiveButton';
import { InvertedOperatorButton } from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/StyledToggleButton/InvertedOperatorButton/InvertedOperatorButton';
import { ResolveInput } from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/ResolveInput/ResolveInput';
import { useConstraintInput } from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/useConstraintInput/useConstraintInput';
import { useUiFlag } from 'hooks/useUiFlag';
const StyledDeleteButton = styled(IconButton)({
marginRight: '-6px',
});
const StyledFilter = styled('div')({
display: 'flex',
flexDirection: 'column',
width: '100%',
});
const StyledFilterHeader = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
[theme.breakpoints.down('sm')]: {
flexDirection: 'column',
alignItems: 'start',
gap: theme.spacing(2),
},
}));
const StyledOperatorOptions = styled('div')(({ theme }) => ({
width: '100%',
display: 'inline-flex',
flex: 1,
gap: theme.spacing(1),
}));
const StyledOperatorSelectWrapper = styled('div')(({ theme }) => ({
width: '100%',
'&&& > div': {
width: '100%',
},
}));
const StyledOperatorButtonWrapper = styled('div')(({ theme }) => ({
display: 'inline-flex',
flex: 1,
'&&& button': {
marginRight: 0,
'&:not(.operator-is-active)': {
backgroundColor: theme.palette.background.elevation2,
},
},
}));
const StyledInputContainer = styled('div')({ const StyledInputContainer = styled('div')({
width: '100%',
flex: 1, flex: 1,
}); });
@ -12,39 +75,141 @@ const StyledInput = styled(Input)({
width: '100%', width: '100%',
}); });
const StyledBadge = styled(Badge)(({ theme }) => ({ const StyledResolveInputWrapper = styled('div')(({ theme }) => ({
margin: theme.spacing(0, 1), '& > h3': {
fontSize: theme.fontSizes.mainHeader, margin: theme.spacing(1, 0, 0, 0),
fontSize: theme.fontSizes.smallBody,
},
'& > div': {
margin: 0,
maxWidth: 'unset',
'& > div': {
flex: 1,
},
'& .MuiFormControl-root': {
margin: theme.spacing(0.5, 0, 0, 0),
},
'&:not(:first-of-type)': {
marginTop: theme.spacing(1),
},
},
})); }));
interface IProjectActionsFilterItemProps {
filter: ActionsFilterState;
index: number;
stateChanged: (updatedFilter: ActionsFilterState) => void;
onDelete: () => void;
}
export const ProjectActionsFilterItem = ({ export const ProjectActionsFilterItem = ({
filter, filter,
index, index,
stateChanged, stateChanged,
onDelete, onDelete,
}: { }: IProjectActionsFilterItemProps) => {
filter: ActionsFilterState; const { parameter, inverted, operator, caseInsensitive, value, values } =
index: number; filter;
stateChanged: (updatedFilter: ActionsFilterState) => void;
onDelete: () => void;
}) => {
const { parameter, value } = filter;
const header = ( const header = (
<> <>
<span>Filter {index + 1}</span> <span>Filter {index + 1}</span>
<div> <div>
<Tooltip title='Delete filter' arrow> <Tooltip title='Delete filter' arrow>
<IconButton onClick={onDelete}> <StyledDeleteButton onClick={onDelete}>
<Delete /> <Delete />
</IconButton> </StyledDeleteButton>
</Tooltip> </Tooltip>
</div> </div>
</> </>
); );
// Adapted from `/frontend/src/component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditHeader/ConstraintAccordionEditHeader.tsx`
const [showCaseSensitiveButton, setShowCaseSensitiveButton] =
useState(false);
const caseInsensitiveInOperators = useUiFlag('caseInsensitiveInOperators');
const validOperators = allOperators.filter(
(operator) => !oneOf(dateOperators, operator),
);
const { input, validator, setError, error } = useConstraintInput({
contextDefinition: { legalValues: [] },
localConstraint: { operator, value, values },
});
const validate = () => {
stateChanged({
...filter,
error: undefined,
});
const [typeValidatorResult, err] = validator();
if (!typeValidatorResult) {
setError(err);
stateChanged({
...filter,
error: err,
});
}
};
useEffect(() => {
validate();
}, [value, error]);
useEffect(() => {
if (
oneOf(stringOperators, operator) ||
(oneOf(inOperators, operator) && caseInsensitiveInOperators)
) {
setShowCaseSensitiveButton(true);
} else {
setShowCaseSensitiveButton(false);
}
}, [operator, caseInsensitiveInOperators]);
const onOperatorChange = (operator: Operator) => {
if (
oneOf(stringOperators, operator) ||
(oneOf(inOperators, operator) && caseInsensitiveInOperators)
) {
setShowCaseSensitiveButton(true);
} else {
setShowCaseSensitiveButton(false);
}
stateChanged({
...filter,
operator,
});
};
const setValue = (value: string) => {
stateChanged({
...filter,
values: undefined,
value,
});
};
const setValues = (values: string[]) => {
stateChanged({
...filter,
value: undefined,
values,
});
};
const removeValue = (index: number) => {
const newValues = values?.filter((_, i) => i !== index) || [];
setValues(newValues);
};
return ( return (
<ProjectActionsFormItem index={index} header={header}> <ProjectActionsFormItem index={index} header={header}>
<StyledFilter>
<StyledFilterHeader>
<StyledInputContainer> <StyledInputContainer>
<StyledInput <StyledInput
label='Parameter' label='Parameter'
@ -57,19 +222,61 @@ export const ProjectActionsFilterItem = ({
} }
/> />
</StyledInputContainer> </StyledInputContainer>
<StyledBadge>=</StyledBadge> <StyledOperatorOptions>
<StyledInputContainer> <StyledOperatorButtonWrapper>
<StyledInput <InvertedOperatorButton
label='Value' localConstraint={{ inverted }}
value={value} setInvertedOperator={() =>
onChange={(e) =>
stateChanged({ stateChanged({
...filter, ...filter,
value: e.target.value, inverted: !inverted || undefined,
}) })
} }
/> />
</StyledInputContainer> </StyledOperatorButtonWrapper>
<StyledOperatorSelectWrapper>
<ConstraintOperatorSelect
options={validOperators}
value={operator}
onChange={onOperatorChange}
/>
</StyledOperatorSelectWrapper>
<ConditionallyRender
condition={showCaseSensitiveButton}
show={
<StyledOperatorButtonWrapper>
<CaseSensitiveButton
localConstraint={{ caseInsensitive }}
setCaseInsensitive={() =>
stateChanged({
...filter,
caseInsensitive:
!caseInsensitive ||
undefined,
})
}
/>
</StyledOperatorButtonWrapper>
}
/>
</StyledOperatorOptions>
</StyledFilterHeader>
<StyledResolveInputWrapper>
<ResolveInput
setValues={setValues}
setValuesWithRecord={setValues}
setValue={setValue}
setError={setError}
localConstraint={{ value, values }}
constraintValues={values || []}
constraintValue={value || ''}
input={input}
error={error}
contextDefinition={{ legalValues: [] }}
removeValue={removeValue}
/>
</StyledResolveInputWrapper>
</StyledFilter>
</ProjectActionsFormItem> </ProjectActionsFormItem>
); );
}; };

View File

@ -18,6 +18,8 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { ProjectActionsActionItem } from './ProjectActionsActionItem'; import { ProjectActionsActionItem } from './ProjectActionsActionItem';
import { ProjectActionsFilterItem } from './ProjectActionsFilterItem'; import { ProjectActionsFilterItem } from './ProjectActionsFilterItem';
import { ProjectActionsFormStep } from './ProjectActionsFormStep'; import { ProjectActionsFormStep } from './ProjectActionsFormStep';
import { IN } from 'constants/operators';
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
const StyledServiceAccountAlert = styled(Alert)(({ theme }) => ({ const StyledServiceAccountAlert = styled(Alert)(({ theme }) => ({
marginBottom: theme.spacing(4), marginBottom: theme.spacing(4),
@ -43,15 +45,10 @@ const StyledInput = styled(Input)(() => ({
width: '100%', width: '100%',
})); }));
const StyledSecondaryDescription = styled('p')(({ theme }) => ({
display: 'flex',
color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallBody,
marginBottom: theme.spacing(1),
}));
const StyledButtonContainer = styled('div')(({ theme }) => ({ const StyledButtonContainer = styled('div')(({ theme }) => ({
display: 'flex',
marginTop: theme.spacing(1), marginTop: theme.spacing(1),
gap: theme.spacing(1),
})); }));
const StyledDivider = styled(Divider)(({ theme }) => ({ const StyledDivider = styled(Divider)(({ theme }) => ({
@ -59,6 +56,12 @@ const StyledDivider = styled(Divider)(({ theme }) => ({
marginBottom: theme.spacing(2), marginBottom: theme.spacing(2),
})); }));
const StyledTooltip = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
}));
interface IProjectActionsFormProps { interface IProjectActionsFormProps {
enabled: boolean; enabled: boolean;
setEnabled: React.Dispatch<React.SetStateAction<boolean>>; setEnabled: React.Dispatch<React.SetStateAction<boolean>>;
@ -111,7 +114,7 @@ export const ProjectActionsForm = ({
{ {
id, id,
parameter: '', parameter: '',
value: '', operator: IN,
}, },
]); ]);
}; };
@ -232,10 +235,6 @@ export const ProjectActionsForm = ({
</ProjectActionsFormStep> </ProjectActionsFormStep>
<ProjectActionsFormStep name='When this' verticalConnector> <ProjectActionsFormStep name='When this' verticalConnector>
<StyledSecondaryDescription>
If no filters are defined then the action will be triggered
every time the incoming webhook is called.
</StyledSecondaryDescription>
{filters.map((filter, index) => ( {filters.map((filter, index) => (
<ProjectActionsFilterItem <ProjectActionsFilterItem
key={filter.id} key={filter.id}
@ -258,11 +257,28 @@ export const ProjectActionsForm = ({
> >
Add filter Add filter
</Button> </Button>
<HelpIcon
htmlTooltip
tooltip={
<StyledTooltip>
<p>
Filters allow you to add conditions to the
execution of the actions based on the source
payload.
</p>
<p>
If no filters are defined then the action
will always be triggered from the selected
source, no matter the payload.
</p>
</StyledTooltip>
}
/>
</StyledButtonContainer> </StyledButtonContainer>
</ProjectActionsFormStep> </ProjectActionsFormStep>
<ProjectActionsFormStep <ProjectActionsFormStep
name='Do these action(s)' name='Do these actions'
verticalConnector verticalConnector
resourceLink={ resourceLink={
<RouterLink to='/admin/service-accounts'> <RouterLink to='/admin/service-accounts'>

View File

@ -1,5 +1,5 @@
import { useActions } from 'hooks/api/getters/useActions/useActions'; import { useActions } from 'hooks/api/getters/useActions/useActions';
import { IAction, IActionSet } from 'interfaces/action'; import { IAction, IActionSet, ParameterMatch } from 'interfaces/action';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
@ -7,14 +7,15 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
enum ErrorField { enum ErrorField {
NAME = 'name', NAME = 'name',
TRIGGER = 'trigger', TRIGGER = 'trigger',
FILTERS = 'filters',
ACTOR = 'actor', ACTOR = 'actor',
ACTIONS = 'actions', ACTIONS = 'actions',
} }
export type ActionsFilterState = { export type ActionsFilterState = ParameterMatch & {
id: string; id: string;
parameter: string; parameter: string;
value: string; error?: string;
}; };
export type ActionsActionState = Omit< export type ActionsActionState = Omit<
@ -27,6 +28,7 @@ export type ActionsActionState = Omit<
const DEFAULT_PROJECT_ACTIONS_FORM_ERRORS = { const DEFAULT_PROJECT_ACTIONS_FORM_ERRORS = {
[ErrorField.NAME]: undefined, [ErrorField.NAME]: undefined,
[ErrorField.TRIGGER]: undefined, [ErrorField.TRIGGER]: undefined,
[ErrorField.FILTERS]: undefined,
[ErrorField.ACTOR]: undefined, [ErrorField.ACTOR]: undefined,
[ErrorField.ACTIONS]: undefined, [ErrorField.ACTIONS]: undefined,
}; };
@ -50,10 +52,17 @@ export const useProjectActionsForm = (action?: IActionSet) => {
setSourceId(action?.match?.sourceId ?? 0); setSourceId(action?.match?.sourceId ?? 0);
setFilters( setFilters(
Object.entries(action?.match?.payload ?? {}).map( Object.entries(action?.match?.payload ?? {}).map(
([parameter, value]) => ({ ([
parameter,
{ inverted, operator, caseInsensitive, value, values },
]) => ({
id: uuidv4(), id: uuidv4(),
parameter, parameter,
value: value as string, inverted,
operator,
caseInsensitive,
value,
values,
}), }),
), ),
); );
@ -87,6 +96,13 @@ export const useProjectActionsForm = (action?: IActionSet) => {
setErrors((errors) => ({ ...errors, [field]: error })); setErrors((errors) => ({ ...errors, [field]: error }));
}; };
useEffect(() => {
clearError(ErrorField.FILTERS);
if (filters.some(({ error }) => error)) {
setError(ErrorField.FILTERS, 'One or more filters have errors.');
}
}, [filters]);
const isEmpty = (value: string) => !value.length; const isEmpty = (value: string) => !value.length;
const isNameNotUnique = (value: string) => const isNameNotUnique = (value: string) =>

View File

@ -100,9 +100,25 @@ export const ProjectActionsModal = ({
payload: filters payload: filters
.filter((f) => f.parameter.length > 0) .filter((f) => f.parameter.length > 0)
.reduce( .reduce(
(acc, filter) => ({ (
acc,
{
parameter,
inverted,
operator,
caseInsensitive,
value,
values,
},
) => ({
...acc, ...acc,
[filter.parameter]: filter.value, [parameter]: {
inverted,
operator,
caseInsensitive,
value,
values,
},
}), }),
{}, {},
), ),

View File

@ -1,3 +1,5 @@
import { IConstraint } from './strategy';
export interface IActionSet { export interface IActionSet {
id: number; id: number;
enabled: boolean; enabled: boolean;
@ -12,10 +14,15 @@ export interface IActionSet {
type MatchSource = 'incoming-webhook'; type MatchSource = 'incoming-webhook';
export type ParameterMatch = Pick<
IConstraint,
'inverted' | 'operator' | 'caseInsensitive' | 'value' | 'values'
>;
export interface IMatch { export interface IMatch {
source: MatchSource; source: MatchSource;
sourceId: number; sourceId: number;
payload: Record<string, unknown>; payload: Record<string, ParameterMatch>;
} }
export interface IAction { export interface IAction {