mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-27 01:19:00 +02:00
- Deletes an unused file - Fixes a console.error due to a prop being passed on to the HTML component - Puts all editable constraint files within a separate directory.
399 lines
14 KiB
TypeScript
399 lines
14 KiB
TypeScript
import { IconButton, styled } from '@mui/material';
|
|
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
|
|
import { isStringOperator, type Operator } from 'constants/operators';
|
|
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
|
|
import { useCallback, useRef, type FC } from 'react';
|
|
import { operatorsForContext } from 'utils/operatorsForContext';
|
|
import { ConstraintOperatorSelect } from './ConstraintOperatorSelect.tsx';
|
|
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
|
|
import Delete from '@mui/icons-material/Delete';
|
|
import { ValueList } from './ValueList.tsx';
|
|
import { ReactComponent as CaseSensitiveIcon } from 'assets/icons/case-sensitive.svg';
|
|
import { ReactComponent as CaseInsensitiveIcon } from 'assets/icons/case-insensitive.svg';
|
|
import { AddValuesWidget } from './AddValuesWidget.tsx';
|
|
|
|
import { ReactComponent as EqualsIcon } from 'assets/icons/constraint-equals.svg';
|
|
import { ReactComponent as NotEqualsIcon } from 'assets/icons/constraint-not-equals.svg';
|
|
import { AddSingleValueWidget } from './AddSingleValueWidget.tsx';
|
|
import { ConstraintDateInput } from './ConstraintDateInput.tsx';
|
|
import {
|
|
LegalValuesSelector,
|
|
SingleLegalValueSelector,
|
|
} from './LegalValuesSelector.tsx';
|
|
import { useEditableConstraint } from './useEditableConstraint/useEditableConstraint.js';
|
|
import type { IConstraint } from 'interfaces/strategy';
|
|
import {
|
|
type EditableConstraint as EditableConstraintType,
|
|
isDateConstraint,
|
|
isMultiValueConstraint,
|
|
isNumberConstraint,
|
|
isSemVerConstraint,
|
|
} from './useEditableConstraint/editable-constraint-type.ts';
|
|
import type { ConstraintValidationResult } from './useEditableConstraint/constraint-validator.ts';
|
|
|
|
const Container = styled('article')(({ theme }) => ({
|
|
'--padding': theme.spacing(2),
|
|
backgroundColor: theme.palette.background.paper,
|
|
borderRadius: theme.shape.borderRadiusLarge,
|
|
border: `1px solid ${theme.palette.divider}`,
|
|
}));
|
|
|
|
const TopRow = styled('div')(({ theme }) => ({
|
|
'--gap': theme.spacing(1),
|
|
padding: 'var(--padding)',
|
|
display: 'flex',
|
|
flexFlow: 'row nowrap',
|
|
alignItems: 'flex-start',
|
|
justifyItems: 'space-between',
|
|
gap: 'var(--gap)',
|
|
}));
|
|
|
|
const ConstraintOptions = styled('div')(({ theme }) => ({
|
|
display: 'flex',
|
|
flexFlow: 'row wrap',
|
|
gap: 'var(--gap)',
|
|
alignSelf: 'flex-start',
|
|
}));
|
|
|
|
const OperatorOptions = styled(ConstraintOptions)(({ theme }) => ({
|
|
flexFlow: 'row wrap',
|
|
}));
|
|
|
|
const ConstraintDetails = styled('div')(({ theme }) => ({
|
|
display: 'flex',
|
|
gap: 'var(--gap)',
|
|
flexFlow: 'row wrap',
|
|
width: '100%',
|
|
height: 'min-content',
|
|
}));
|
|
|
|
const LegalValuesContainer = styled('div')(({ theme }) => ({
|
|
padding: 'var(--padding)',
|
|
borderTop: `1px dashed ${theme.palette.divider}`,
|
|
}));
|
|
|
|
const StyledSelect = styled(GeneralSelect)(({ theme }) => ({
|
|
fieldset: { border: 'none', borderRadius: 0 },
|
|
maxWidth: '25ch',
|
|
minWidth: '7ch',
|
|
':focus-within .MuiSelect-select': {
|
|
background: 'none',
|
|
},
|
|
':focus-within fieldset': { borderBottomStyle: 'solid' },
|
|
'label + &': {
|
|
// mui adds a margin top to 'standard' selects with labels
|
|
margin: 0,
|
|
},
|
|
'&::before': {
|
|
borderColor: theme.palette.divider,
|
|
},
|
|
'&&:hover::before': {
|
|
borderColor: theme.palette.primary.main,
|
|
},
|
|
}));
|
|
|
|
const StyledIconButton = styled(IconButton)(({ theme }) => ({
|
|
position: 'absolute',
|
|
right: theme.spacing(1),
|
|
}));
|
|
|
|
const StyledButton = styled('button')(({ theme }) => ({
|
|
display: 'grid',
|
|
placeItems: 'center',
|
|
padding: 0,
|
|
borderRadius: theme.shape.borderRadius,
|
|
fontSize: theme.fontSizes.smallerBody,
|
|
background: theme.palette.secondary.light,
|
|
border: `1px solid ${theme.palette.secondary.border}`,
|
|
color: theme.palette.secondary.dark,
|
|
fontWeight: theme.typography.fontWeightBold,
|
|
transition: 'all 0.03s ease',
|
|
'&:is(:hover, :focus-visible)': {
|
|
outline: `1px solid ${theme.palette.primary.main}`,
|
|
},
|
|
}));
|
|
|
|
const StyledEqualsIcon = styled(EqualsIcon)(({ theme }) => ({
|
|
path: {
|
|
fill: 'currentcolor',
|
|
},
|
|
}));
|
|
|
|
const StyledNotEqualsIcon = styled(NotEqualsIcon)(({ theme }) => ({
|
|
path: {
|
|
fill: theme.palette.text.disabled,
|
|
},
|
|
rect: {
|
|
fill: theme.palette.text.secondary,
|
|
},
|
|
}));
|
|
|
|
const ButtonPlaceholder = styled('div')(({ theme }) => ({
|
|
// this is a trick that lets us use absolute positioning for the button so
|
|
// that it can go over the operator context fields when necessary (narrow
|
|
// screens), but still retain necessary space for the button when it's all
|
|
// on one line.
|
|
width: theme.spacing(2),
|
|
}));
|
|
|
|
const StyledCaseInsensitiveIcon = styled(CaseInsensitiveIcon)(({ theme }) => ({
|
|
path: {
|
|
fill: theme.palette.text.disabled,
|
|
},
|
|
rect: {
|
|
fill: theme.palette.text.secondary,
|
|
},
|
|
}));
|
|
const StyledCaseSensitiveIcon = styled(CaseSensitiveIcon)(({ theme }) => ({
|
|
fill: 'currentcolor',
|
|
}));
|
|
|
|
const TopRowInput: FC<{
|
|
addValues: (value: string | string[]) => void;
|
|
clearValues: () => void;
|
|
localConstraint: EditableConstraintType;
|
|
validator: (value: string) => ConstraintValidationResult;
|
|
addValuesButtonRef: React.RefObject<HTMLButtonElement>;
|
|
}> = ({
|
|
addValues,
|
|
clearValues,
|
|
localConstraint,
|
|
validator,
|
|
addValuesButtonRef,
|
|
}) => {
|
|
if (isDateConstraint(localConstraint)) {
|
|
return (
|
|
<ConstraintDateInput
|
|
setValue={addValues}
|
|
value={localConstraint.value}
|
|
validator={validator}
|
|
/>
|
|
);
|
|
}
|
|
if (isSemVerConstraint(localConstraint)) {
|
|
return (
|
|
<AddSingleValueWidget
|
|
validator={validator}
|
|
onAddValue={addValues}
|
|
removeValue={clearValues}
|
|
currentValue={localConstraint.value}
|
|
helpText={'A semver value should be of the format X.Y.Z'}
|
|
inputType={'text'}
|
|
/>
|
|
);
|
|
}
|
|
if (isNumberConstraint(localConstraint)) {
|
|
return (
|
|
<AddSingleValueWidget
|
|
validator={validator}
|
|
onAddValue={addValues}
|
|
removeValue={clearValues}
|
|
currentValue={localConstraint.value}
|
|
helpText={'Add a single number'}
|
|
inputType={'number'}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AddValuesWidget
|
|
validator={validator}
|
|
helpText='Maximum 100 char length per value'
|
|
ref={addValuesButtonRef}
|
|
onAddValues={addValues}
|
|
/>
|
|
);
|
|
};
|
|
|
|
type Props = {
|
|
constraint: IConstraint;
|
|
onDelete: () => void;
|
|
onUpdate: (constraint: IConstraint) => void;
|
|
};
|
|
|
|
export const EditableConstraint: FC<Props> = ({
|
|
onDelete,
|
|
constraint,
|
|
onUpdate,
|
|
}) => {
|
|
const {
|
|
constraint: localConstraint,
|
|
updateConstraint,
|
|
validator,
|
|
legalValueData,
|
|
} = useEditableConstraint(constraint, onUpdate);
|
|
const addValues = useCallback(
|
|
(value: string | string[]) =>
|
|
updateConstraint({ type: 'add value(s)', payload: value }),
|
|
[updateConstraint],
|
|
);
|
|
const removeValue = useCallback(
|
|
(value: string) =>
|
|
updateConstraint({ type: 'remove value', payload: value }),
|
|
[updateConstraint],
|
|
);
|
|
const clearAll = useCallback(
|
|
() => updateConstraint({ type: 'clear values' }),
|
|
[updateConstraint],
|
|
);
|
|
const toggleValue = useCallback(
|
|
(value: string) =>
|
|
updateConstraint({ type: 'toggle value', payload: value }),
|
|
[updateConstraint],
|
|
);
|
|
const onOperatorChange = useCallback(
|
|
(operator: Operator) => {
|
|
updateConstraint({ type: 'set operator', payload: operator });
|
|
},
|
|
[updateConstraint],
|
|
);
|
|
|
|
const { context } = useUnleashContext();
|
|
const { contextName, operator } = localConstraint;
|
|
const showCaseSensitiveButton = isStringOperator(operator);
|
|
const deleteButtonRef = useRef<HTMLButtonElement>(null);
|
|
const addValuesButtonRef = useRef<HTMLButtonElement>(null);
|
|
|
|
if (!context) {
|
|
return null;
|
|
}
|
|
|
|
const constraintNameOptions = context.map((context) => {
|
|
return { key: context.name, label: context.name };
|
|
});
|
|
|
|
return (
|
|
<Container>
|
|
<TopRow>
|
|
<ConstraintDetails>
|
|
<ConstraintOptions>
|
|
<StyledSelect
|
|
visuallyHideLabel
|
|
id='context-field-select'
|
|
name='contextName'
|
|
label='Context Field'
|
|
options={constraintNameOptions}
|
|
value={contextName || ''}
|
|
onChange={(contextField) =>
|
|
updateConstraint({
|
|
type: 'set context field',
|
|
payload: contextField,
|
|
})
|
|
}
|
|
variant='standard'
|
|
/>
|
|
|
|
<OperatorOptions>
|
|
<HtmlTooltip
|
|
title={`Make the selected operator${localConstraint.inverted ? ' inclusive' : ' exclusive'}`}
|
|
arrow
|
|
describeChild
|
|
>
|
|
<StyledButton
|
|
type='button'
|
|
onClick={() =>
|
|
updateConstraint({
|
|
type: 'toggle inverted operator',
|
|
})
|
|
}
|
|
>
|
|
{localConstraint.inverted ? (
|
|
<StyledNotEqualsIcon aria-label='The constraint operator is exclusive.' />
|
|
) : (
|
|
<StyledEqualsIcon aria-label='The constraint operator is inclusive.' />
|
|
)}
|
|
</StyledButton>
|
|
</HtmlTooltip>
|
|
|
|
<ConstraintOperatorSelect
|
|
options={operatorsForContext(contextName)}
|
|
value={operator}
|
|
onChange={onOperatorChange}
|
|
inverted={localConstraint.inverted}
|
|
/>
|
|
|
|
{showCaseSensitiveButton ? (
|
|
<HtmlTooltip
|
|
title={`Make match${localConstraint.caseInsensitive ? ' ' : ' not '}case sensitive`}
|
|
arrow
|
|
describeChild
|
|
>
|
|
<StyledButton
|
|
type='button'
|
|
onClick={() =>
|
|
updateConstraint({
|
|
type: 'toggle case sensitivity',
|
|
})
|
|
}
|
|
>
|
|
{localConstraint.caseInsensitive ? (
|
|
<StyledCaseInsensitiveIcon aria-label='The match is not case sensitive.' />
|
|
) : (
|
|
<StyledCaseSensitiveIcon aria-label='The match is case sensitive.' />
|
|
)}
|
|
</StyledButton>
|
|
</HtmlTooltip>
|
|
) : null}
|
|
</OperatorOptions>
|
|
</ConstraintOptions>
|
|
<ValueList
|
|
values={
|
|
isMultiValueConstraint(localConstraint)
|
|
? Array.from(localConstraint.values)
|
|
: legalValueData && localConstraint.value
|
|
? [localConstraint.value]
|
|
: undefined
|
|
}
|
|
removeValue={removeValue}
|
|
getExternalFocusTarget={() =>
|
|
addValuesButtonRef.current ??
|
|
deleteButtonRef.current
|
|
}
|
|
>
|
|
{legalValueData ? null : (
|
|
<TopRowInput
|
|
localConstraint={localConstraint}
|
|
addValues={addValues}
|
|
clearValues={clearAll}
|
|
validator={validator}
|
|
addValuesButtonRef={addValuesButtonRef}
|
|
/>
|
|
)}
|
|
</ValueList>
|
|
</ConstraintDetails>
|
|
<ButtonPlaceholder />
|
|
<HtmlTooltip title='Delete constraint' arrow>
|
|
<StyledIconButton
|
|
type='button'
|
|
data-testid='DELETE_CONSTRAINT_BUTTON'
|
|
size='small'
|
|
onClick={onDelete}
|
|
ref={deleteButtonRef}
|
|
>
|
|
<Delete fontSize='inherit' />
|
|
</StyledIconButton>
|
|
</HtmlTooltip>
|
|
</TopRow>
|
|
{legalValueData ? (
|
|
<LegalValuesContainer>
|
|
{isMultiValueConstraint(localConstraint) ? (
|
|
<LegalValuesSelector
|
|
values={localConstraint.values}
|
|
toggleValue={toggleValue}
|
|
clearAll={clearAll}
|
|
addValues={addValues}
|
|
{...legalValueData}
|
|
/>
|
|
) : (
|
|
<SingleLegalValueSelector
|
|
toggleValue={toggleValue}
|
|
value={localConstraint.value}
|
|
{...legalValueData}
|
|
/>
|
|
)}
|
|
</LegalValuesContainer>
|
|
) : null}
|
|
</Container>
|
|
);
|
|
};
|