1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-27 01:19:00 +02:00
unleash.unleash/frontend/src/component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/EditableConstraint.tsx
Thomas Heartman a2723ec0c0
chore remove and clean some stuff (#9993)
- 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.
2025-05-15 07:23:33 +00:00

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>
);
};