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

feat: 1-3652/legal value selector visual update (#9829)

Handles the visual changes for the legal value selector widget.

Before:
<img width="792" alt="image"
src="https://github.com/user-attachments/assets/0965d577-c4cf-4c1d-9fe7-f8f90d683988"
/>

After:
<img width="769" alt="image"
src="https://github.com/user-attachments/assets/33bdf40c-8bbb-4650-a6ba-c4b9e62f8cbd"
/>

I'm still working on improving the functionality of selecting from the
search input and not losing focus when you select/deselect an item (both
of these work (mostly) as expected on hosted, so we've introduced a
regression somewhere).
This commit is contained in:
Thomas Heartman 2025-04-24 12:17:05 +02:00 committed by GitHub
parent b05e12d028
commit 3bb54c5a9d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 314 additions and 36 deletions

View File

@ -21,6 +21,8 @@ import {
type Input,
} from '../useConstraintInput/useConstraintInput';
import type React from 'react';
import { useUiFlag } from 'hooks/useUiFlag';
import { LegalValuesSelector } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints/LegalValuesSelector';
interface IResolveInputProps {
contextDefinition: Pick<IUnleashContextDefinition, 'legalValues'>;
@ -75,12 +77,23 @@ export const ResolveInput = ({
removeValue,
error,
}: IResolveInputProps) => {
const useNewLegalValueInput = useUiFlag('addEditStrategy');
const resolveInput = () => {
switch (input) {
case IN_OPERATORS_LEGAL_VALUES:
case STRING_OPERATORS_LEGAL_VALUES:
return (
<>
return useNewLegalValueInput ? (
<LegalValuesSelector
data={resolveLegalValues(
constraintValues,
contextDefinition.legalValues,
)}
constraintValues={constraintValues}
values={localConstraint.values || []}
setValuesWithRecord={setValuesWithRecord}
setValues={setValues}
/>
) : (
<RestrictiveLegalValues
data={resolveLegalValues(
constraintValues,
@ -93,7 +106,6 @@ export const ResolveInput = ({
error={error}
setError={setError}
/>
</>
);
case NUM_OPERATORS_LEGAL_VALUES:
return (

View File

@ -82,6 +82,10 @@ const ErrorText = styled('p')(({ theme }) => ({
color: theme.palette.error.main,
}));
/**
* @deprecated use `/component/feature/FeatureStrategy/FeatureStrategyConstraints/LegalValuesSelector.tsx`
* Remove with flag `addEditStrategy`
*/
export const RestrictiveLegalValues = ({
data,
values,

View File

@ -2,7 +2,9 @@ import { useState } from 'react';
import { Chip, styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { ConstraintValueSearch } from '../../../ConstraintValueSearch/ConstraintValueSearch';
import { ConstraintValueSearch as NewConstraintValueSearch } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints/ConstraintValueSearch';
import { useUiFlag } from 'hooks/useUiFlag';
import { ConstraintValueSearch } from 'component/common/NewConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch';
interface IMultipleValuesProps {
values: string[] | undefined;
@ -16,8 +18,14 @@ const StyledChip = styled(Chip)(({ theme }) => ({
margin: theme.spacing(0, 1, 1, 0),
}));
const SearchWrapper = styled('div')(({ theme }) => ({
width: '300px',
marginBottom: theme.spacing(2),
}));
export const MultipleValues = ({ values }: IMultipleValuesProps) => {
const [filter, setFilter] = useState('');
const useNewSearchComponent = useUiFlag('addEditStrategy');
if (!values || values.length === 0) return null;
@ -26,10 +34,19 @@ export const MultipleValues = ({ values }: IMultipleValuesProps) => {
<ConditionallyRender
condition={values.length > 20}
show={
useNewSearchComponent ? (
<SearchWrapper>
<NewConstraintValueSearch
filter={filter}
setFilter={setFilter}
/>
</SearchWrapper>
) : (
<ConstraintValueSearch
filter={filter}
setFilter={setFilter}
/>
)
}
/>
{values

View File

@ -7,6 +7,10 @@ interface IConstraintValueSearchProps {
setFilter: React.Dispatch<React.SetStateAction<string>>;
}
/**
* @deprecated use `/component/feature/FeatureStrategy/FeatureStrategyConstraints/LegalValuesSelector.tsx`
* Remove with flag `addEditStrategy`
*/
export const ConstraintValueSearch = ({
filter,
setFilter,

View File

@ -0,0 +1,47 @@
import { TextField, InputAdornment } from '@mui/material';
import Search from '@mui/icons-material/Search';
import { useId } from 'react';
import { ScreenReaderOnly } from 'component/common/ScreenReaderOnly/ScreenReaderOnly';
interface IConstraintValueSearchProps {
filter: string;
setFilter: React.Dispatch<React.SetStateAction<string>>;
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
}
export const ConstraintValueSearch = ({
filter,
setFilter,
onKeyDown,
}: IConstraintValueSearchProps) => {
const inputId = useId();
return (
<div
style={{ display: 'flex', alignItems: 'center', minWidth: '120px' }}
>
<ScreenReaderOnly>
<label htmlFor={inputId}>Search</label>
</ScreenReaderOnly>
<TextField
id={inputId}
name='search'
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder='Filter values'
onKeyDown={onKeyDown}
sx={{
width: '100%',
}}
variant='outlined'
size='small'
InputProps={{
startAdornment: (
<InputAdornment position='start'>
<Search />
</InputAdornment>
),
}}
/>
</div>
);
};

View File

@ -2,7 +2,6 @@ import { IconButton, styled } from '@mui/material';
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
import { DateSingleValue } from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/DateSingleValue/DateSingleValue';
import { FreeTextInput } from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/FreeTextInput/FreeTextInput';
import { RestrictiveLegalValues } from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/RestrictiveLegalValues/RestrictiveLegalValues';
import { SingleLegalValue } from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleLegalValue/SingleLegalValue';
import { SingleValue } from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/SingleValue/SingleValue';
import {
@ -44,6 +43,7 @@ import { ReactComponent as CaseSensitiveIcon } from 'assets/icons/case-sensitive
import { ReactComponent as CaseInsensitiveIcon } from 'assets/icons/case-insensitive.svg';
import { ScreenReaderOnly } from 'component/common/ScreenReaderOnly/ScreenReaderOnly';
import { AddValuesWidget } from './AddValuesWidget';
import { LegalValuesSelector } from './LegalValuesSelector';
const Container = styled('article')(({ theme }) => ({
'--padding': theme.spacing(2),
@ -97,7 +97,6 @@ const ConstraintDetails = styled('div')(({ theme }) => ({
const InputContainer = styled('div')(({ theme }) => ({
padding: 'var(--padding)',
paddingTop: 0,
}));
const StyledSelect = styled(GeneralSelect)(({ theme }) => ({
@ -261,8 +260,7 @@ export const EditableConstraint: FC<Props> = ({
case IN_OPERATORS_LEGAL_VALUES:
case STRING_OPERATORS_LEGAL_VALUES:
return (
<>
<RestrictiveLegalValues
<LegalValuesSelector
data={resolveLegalValues(
constraintValues,
contextDefinition.legalValues,
@ -271,10 +269,7 @@ export const EditableConstraint: FC<Props> = ({
values={localConstraint.values || []}
setValuesWithRecord={setValuesWithRecord}
setValues={setValues}
error={error}
setError={setError}
/>
</>
);
case NUM_OPERATORS_LEGAL_VALUES:
return (

View File

@ -0,0 +1,199 @@
import { useEffect, useState } from 'react';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Alert, Button, Checkbox, styled } from '@mui/material';
import type { ILegalValue } from 'interfaces/context';
import {
filterLegalValues,
LegalValueLabel,
} from 'component/common/NewConstraintAccordion/ConstraintAccordionEdit/ConstraintAccordionEditBody/LegalValueLabel/LegalValueLabel';
import { ConstraintValueSearch } from './ConstraintValueSearch';
interface IRestrictiveLegalValuesProps {
data: {
legalValues: ILegalValue[];
deletedLegalValues: ILegalValue[];
};
constraintValues: string[];
values: string[];
setValues: (values: string[]) => void;
setValuesWithRecord: (values: string[]) => void;
beforeValues?: JSX.Element;
}
interface IValuesMap {
[key: string]: boolean;
}
const createValuesMap = (values: string[]): IValuesMap => {
return values.reduce((result: IValuesMap, currentValue: string) => {
if (!result[currentValue]) {
result[currentValue] = true;
}
return result;
}, {});
};
export const getLegalValueSet = (values: ILegalValue[]) => {
return new Set(values.map(({ value }) => value));
};
export const getIllegalValues = (
constraintValues: string[],
deletedLegalValues: ILegalValue[],
) => {
const deletedValuesSet = getLegalValueSet(deletedLegalValues);
return constraintValues.filter(
(value) => value && deletedValuesSet.has(value),
);
};
const StyledValuesContainer = styled('div')(({ theme }) => ({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
gap: theme.spacing(1),
maxHeight: '378px',
overflow: 'auto',
}));
const Row = styled('div')(({ theme }) => ({
display: 'flex',
flexFlow: 'row wrap',
alignItems: 'center',
gap: theme.spacing(1),
}));
const LegalValuesSelectorWidget = styled('article')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
}));
export const LegalValuesSelector = ({
data,
values,
setValues,
setValuesWithRecord,
constraintValues,
}: IRestrictiveLegalValuesProps) => {
const [filter, setFilter] = useState('');
const { legalValues, deletedLegalValues } = data;
const filteredValues = filterLegalValues(legalValues, filter);
// Lazily initialise the values because there might be a lot of them.
const [valuesMap, setValuesMap] = useState(() => createValuesMap(values));
const cleanDeletedLegalValues = (constraintValues: string[]): string[] => {
const deletedValuesSet = getLegalValueSet(deletedLegalValues);
return (
constraintValues?.filter((value) => !deletedValuesSet.has(value)) ||
[]
);
};
const illegalValues = getIllegalValues(
constraintValues,
deletedLegalValues,
);
useEffect(() => {
setValuesMap(createValuesMap(values));
}, [values, setValuesMap, createValuesMap]);
useEffect(() => {
if (illegalValues.length > 0) {
setValues(cleanDeletedLegalValues(values));
}
}, []);
const onChange = (legalValue: string) => {
if (valuesMap[legalValue]) {
const index = values.findIndex((value) => value === legalValue);
const newValues = [...values];
newValues.splice(index, 1);
setValuesWithRecord(newValues);
return;
}
setValuesWithRecord([...cleanDeletedLegalValues(values), legalValue]);
};
const isAllSelected = legalValues.every((value) =>
values.includes(value.value),
);
const onSelectAll = () => {
if (isAllSelected) {
return setValuesWithRecord([]);
}
setValuesWithRecord([
...legalValues.map((legalValue) => legalValue.value),
]);
};
const handleSearchKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' && filteredValues.length > 0) {
const firstValue = filteredValues[0].value;
onChange(firstValue);
}
};
return (
<LegalValuesSelectorWidget>
<ConditionallyRender
condition={Boolean(illegalValues && illegalValues.length > 0)}
show={
<Alert severity='warning'>
This constraint is using legal values that have been
deleted as valid options. If you save changes on this
constraint and then save the strategy the following
values will be removed:
<ul>
{illegalValues?.map((value) => (
<li key={value}>{value}</li>
))}
</ul>
</Alert>
}
/>
<p>Select values from a predefined set</p>
<Row>
<ConstraintValueSearch
onKeyDown={handleSearchKeyDown}
filter={filter}
setFilter={setFilter}
/>
<Button
sx={{
whiteSpace: 'nowrap',
}}
variant={'text'}
onClick={onSelectAll}
>
{isAllSelected ? 'Unselect all' : 'Select all'}
</Button>
</Row>
<StyledValuesContainer>
{filteredValues.map((match) => (
<LegalValueLabel
key={match.value}
legal={match}
filter={filter}
control={
<Checkbox
checked={Boolean(valuesMap[match.value])}
onChange={() => onChange(match.value)}
name='legal-value'
color='primary'
disabled={deletedLegalValues
.map(({ value }) => value)
.includes(match.value)}
/>
}
/>
))}
</StyledValuesContainer>
</LegalValuesSelectorWidget>
);
};