1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-04 01:18:20 +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 (
<div>
<>
<ConstraintFormHeader style={{ marginBottom: 0 }}>
Set values (maximum 100 char length per value)
</ConstraintFormHeader>
@ -125,7 +125,7 @@ export const FreeTextInput = ({
removeValue={removeValue}
/>
</div>
</div>
</>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@ -26,11 +26,29 @@ export const ProjectActionsFiltersCell = ({
<TooltipLink
tooltip={
<>
{filters.map(([parameter, value]) => (
<StyledItem key={parameter}>
<strong>{parameter}</strong>: {value}
</StyledItem>
))}
{filters.map(
([
parameter,
{
inverted,
operator,
caseInsensitive,
value,
values,
},
]) => (
<StyledItem key={parameter}>
<strong>{parameter}</strong>{' '}
{inverted ? 'NOT' : ''} {operator}{' '}
{caseInsensitive
? '(case insensitive)'
: ''}{' '}
<strong>
{values ? values.join(', ') : value}
</strong>
</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 { Delete } from '@mui/icons-material';
import Input from 'component/common/Input/Input';
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')({
width: '100%',
flex: 1,
});
@ -12,64 +75,208 @@ const StyledInput = styled(Input)({
width: '100%',
});
const StyledBadge = styled(Badge)(({ theme }) => ({
margin: theme.spacing(0, 1),
fontSize: theme.fontSizes.mainHeader,
const StyledResolveInputWrapper = styled('div')(({ theme }) => ({
'& > h3': {
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 = ({
filter,
index,
stateChanged,
onDelete,
}: {
filter: ActionsFilterState;
index: number;
stateChanged: (updatedFilter: ActionsFilterState) => void;
onDelete: () => void;
}) => {
const { parameter, value } = filter;
}: IProjectActionsFilterItemProps) => {
const { parameter, inverted, operator, caseInsensitive, value, values } =
filter;
const header = (
<>
<span>Filter {index + 1}</span>
<div>
<Tooltip title='Delete filter' arrow>
<IconButton onClick={onDelete}>
<StyledDeleteButton onClick={onDelete}>
<Delete />
</IconButton>
</StyledDeleteButton>
</Tooltip>
</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 (
<ProjectActionsFormItem index={index} header={header}>
<StyledInputContainer>
<StyledInput
label='Parameter'
value={parameter}
onChange={(e) =>
stateChanged({
...filter,
parameter: e.target.value,
})
}
/>
</StyledInputContainer>
<StyledBadge>=</StyledBadge>
<StyledInputContainer>
<StyledInput
label='Value'
value={value}
onChange={(e) =>
stateChanged({
...filter,
value: e.target.value,
})
}
/>
</StyledInputContainer>
<StyledFilter>
<StyledFilterHeader>
<StyledInputContainer>
<StyledInput
label='Parameter'
value={parameter}
onChange={(e) =>
stateChanged({
...filter,
parameter: e.target.value,
})
}
/>
</StyledInputContainer>
<StyledOperatorOptions>
<StyledOperatorButtonWrapper>
<InvertedOperatorButton
localConstraint={{ inverted }}
setInvertedOperator={() =>
stateChanged({
...filter,
inverted: !inverted || undefined,
})
}
/>
</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>
);
};

View File

@ -18,6 +18,8 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { ProjectActionsActionItem } from './ProjectActionsActionItem';
import { ProjectActionsFilterItem } from './ProjectActionsFilterItem';
import { ProjectActionsFormStep } from './ProjectActionsFormStep';
import { IN } from 'constants/operators';
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
const StyledServiceAccountAlert = styled(Alert)(({ theme }) => ({
marginBottom: theme.spacing(4),
@ -43,15 +45,10 @@ const StyledInput = styled(Input)(() => ({
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 }) => ({
display: 'flex',
marginTop: theme.spacing(1),
gap: theme.spacing(1),
}));
const StyledDivider = styled(Divider)(({ theme }) => ({
@ -59,6 +56,12 @@ const StyledDivider = styled(Divider)(({ theme }) => ({
marginBottom: theme.spacing(2),
}));
const StyledTooltip = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
}));
interface IProjectActionsFormProps {
enabled: boolean;
setEnabled: React.Dispatch<React.SetStateAction<boolean>>;
@ -111,7 +114,7 @@ export const ProjectActionsForm = ({
{
id,
parameter: '',
value: '',
operator: IN,
},
]);
};
@ -232,10 +235,6 @@ export const ProjectActionsForm = ({
</ProjectActionsFormStep>
<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) => (
<ProjectActionsFilterItem
key={filter.id}
@ -258,11 +257,28 @@ export const ProjectActionsForm = ({
>
Add filter
</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>
</ProjectActionsFormStep>
<ProjectActionsFormStep
name='Do these action(s)'
name='Do these actions'
verticalConnector
resourceLink={
<RouterLink to='/admin/service-accounts'>

View File

@ -1,5 +1,5 @@
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 { v4 as uuidv4 } from 'uuid';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
@ -7,14 +7,15 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
enum ErrorField {
NAME = 'name',
TRIGGER = 'trigger',
FILTERS = 'filters',
ACTOR = 'actor',
ACTIONS = 'actions',
}
export type ActionsFilterState = {
export type ActionsFilterState = ParameterMatch & {
id: string;
parameter: string;
value: string;
error?: string;
};
export type ActionsActionState = Omit<
@ -27,6 +28,7 @@ export type ActionsActionState = Omit<
const DEFAULT_PROJECT_ACTIONS_FORM_ERRORS = {
[ErrorField.NAME]: undefined,
[ErrorField.TRIGGER]: undefined,
[ErrorField.FILTERS]: undefined,
[ErrorField.ACTOR]: undefined,
[ErrorField.ACTIONS]: undefined,
};
@ -50,10 +52,17 @@ export const useProjectActionsForm = (action?: IActionSet) => {
setSourceId(action?.match?.sourceId ?? 0);
setFilters(
Object.entries(action?.match?.payload ?? {}).map(
([parameter, value]) => ({
([
parameter,
{ inverted, operator, caseInsensitive, value, values },
]) => ({
id: uuidv4(),
parameter,
value: value as string,
inverted,
operator,
caseInsensitive,
value,
values,
}),
),
);
@ -87,6 +96,13 @@ export const useProjectActionsForm = (action?: IActionSet) => {
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 isNameNotUnique = (value: string) =>

View File

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

View File

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