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.    As always, did some manual tests and it seems to be working great!
This commit is contained in:
parent
4f638a1c8d
commit
bddc508582
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -9,7 +9,7 @@ import { ConditionallyRender } from '../../../../ConditionallyRender/Conditional
|
||||
import { IConstraint } from 'interfaces/strategy';
|
||||
|
||||
interface CaseSensitiveButtonProps {
|
||||
localConstraint: IConstraint;
|
||||
localConstraint: Pick<IConstraint, 'caseInsensitive'>;
|
||||
setCaseInsensitive: () => void;
|
||||
}
|
||||
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
import { ConditionallyRender } from '../../../../ConditionallyRender/ConditionallyRender';
|
||||
|
||||
interface InvertedOperatorButtonProps {
|
||||
localConstraint: IConstraint;
|
||||
localConstraint: Pick<IConstraint, 'inverted'>;
|
||||
setInvertedOperator: () => void;
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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'>
|
||||
|
@ -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) =>
|
||||
|
@ -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,
|
||||
},
|
||||
}),
|
||||
{},
|
||||
),
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user