Skip to content

Commit

Permalink
Feat: context field search and filter improvements (#6732)
Browse files Browse the repository at this point in the history
Adds highlighting to search values 
Search also looks in `description`

behind a flag - it could possibly degrade performance when too many
items. Tested with 200 and it's ok but anything above might degrade:
Adds a Select/Unselect all button
Shows the selected values above the search 

Closes #
[1-2232](https://linear.app/unleash/issue/1-2232/context-field-ui-filter-and-search)



https://github.com/Unleash/unleash/assets/104830839/ba2fe56f-c5db-4ce7-bc3c-1e7988682984

---------

Signed-off-by: andreas-unleash <andreas@getunleash.ai>
  • Loading branch information
andreas-unleash committed Mar 29, 2024
1 parent 11f4155 commit c868b5a
Show file tree
Hide file tree
Showing 11 changed files with 240 additions and 34 deletions.
@@ -1,6 +1,5 @@
import { TextField, InputAdornment, Chip } from '@mui/material';
import { TextField, InputAdornment } from '@mui/material';
import Search from '@mui/icons-material/Search';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';

interface IConstraintValueSearchProps {
filter: string;
Expand All @@ -13,7 +12,7 @@ export const ConstraintValueSearch = ({
}: IConstraintValueSearchProps) => {
return (
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ width: '300px' }}>
<div style={{ width: '100%' }}>
<TextField
label='Search'
name='search'
Expand All @@ -35,16 +34,6 @@ export const ConstraintValueSearch = ({
}}
/>
</div>
<ConditionallyRender
condition={Boolean(filter)}
show={
<Chip
style={{ marginLeft: '1rem' }}
label={`filter active: ${filter}`}
onDelete={() => setFilter('')}
/>
}
/>
</div>
);
};
Expand Up @@ -4,8 +4,6 @@ import { styled } from '@mui/material';
const StyledHeader = styled('h3')(({ theme }) => ({
fontSize: theme.fontSizes.bodySize,
fontWeight: theme.typography.fontWeightRegular,
marginTop: theme.spacing(2),
marginBottom: theme.spacing(0.5),
}));

export const ConstraintFormHeader: React.FC<
Expand Down
Expand Up @@ -9,7 +9,7 @@ export const StyledContainer = styled('div')(({ theme }) => ({
borderRadius: theme.shape.borderRadius,

'&:hover': {
border: `2px solid ${theme.palette.primary.main}`,
border: `1px solid ${theme.palette.primary.main}`,
},
}));

Expand Down
Expand Up @@ -6,23 +6,35 @@ import {
} from './LegalValueLabel.styles';
import type React from 'react';
import { FormControlLabel } from '@mui/material';
import { Highlighter } from 'component/common/Highlighter/Highlighter';

interface ILegalValueTextProps {
legal: ILegalValue;
control: React.ReactElement;
filter?: string;
}

export const LegalValueLabel = ({ legal, control }: ILegalValueTextProps) => {
export const LegalValueLabel = ({
legal,
control,
filter,
}: ILegalValueTextProps) => {
return (
<StyledContainer>
<FormControlLabel
value={legal.value}
control={control}
label={
<>
<StyledValue>{legal.value}</StyledValue>
<StyledValue>
<Highlighter search={filter}>
{legal.value}
</Highlighter>
</StyledValue>
<StyledDescription>
{legal.description}
<Highlighter search={filter}>
{legal.description}
</Highlighter>
</StyledDescription>
</>
}
Expand All @@ -36,6 +48,9 @@ export const filterLegalValues = (
filter: string,
): ILegalValue[] => {
return legalValues.filter((legalValue) => {
return legalValue.value.includes(filter);
return (
legalValue.value.toLowerCase().includes(filter.toLowerCase()) ||
legalValue.description?.toLowerCase().includes(filter.toLowerCase())
);
});
};
@@ -0,0 +1,75 @@
import { filterLegalValues } from './LegalValueLabel';

describe('filterLegalValues function tests', () => {
const mockLegalValues = [
{ value: 'Apple', description: 'A fruit' },
{ value: 'Banana', description: 'Yellow fruit' },
{ value: 'Carrot', description: 'A vegetable' },
{ value: 'SE', description: 'Sweden' },
{ value: 'Eggplant', description: undefined },
];

test('Basic functionality with value property', () => {
const filter = 'apple';
const expected = [{ value: 'Apple', description: 'A fruit' }];
expect(filterLegalValues(mockLegalValues, filter)).toEqual(expected);
});

test('Filters based on description property', () => {
const filter = 'vegetable';
const expected = [{ value: 'Carrot', description: 'A vegetable' }];
expect(filterLegalValues(mockLegalValues, filter)).toEqual(expected);
});

test('Case insensitivity', () => {
const filter = 'BANANA';
const expected = [{ value: 'Banana', description: 'Yellow fruit' }];
expect(filterLegalValues(mockLegalValues, filter)).toEqual(expected);
});

test('No matches found', () => {
const filter = 'Zucchini';
expect(filterLegalValues(mockLegalValues, filter)).toEqual([]);
});

test('Empty filter string', () => {
const filter = '';
expect(filterLegalValues(mockLegalValues, filter)).toEqual(
mockLegalValues,
);
});

test('Special characters in filter', () => {
const filter = 'a fruit';
const expected = [{ value: 'Apple', description: 'A fruit' }];
expect(filterLegalValues(mockLegalValues, filter)).toEqual(expected);
});

test('Empty input array', () => {
const filter = 'anything';
expect(filterLegalValues([], filter)).toEqual([]);
});

test('Exact match', () => {
const filter = 'Carrot';
const expected = [{ value: 'Carrot', description: 'A vegetable' }];
expect(filterLegalValues(mockLegalValues, filter)).toEqual(expected);
});

test('Partial match', () => {
const filter = 'sw';
const expected = [{ value: 'SE', description: 'Sweden' }];
expect(filterLegalValues(mockLegalValues, filter)).toEqual(expected);
});

test('Combination of match and no match', () => {
const filter = 'a';
const expected = [
{ value: 'Apple', description: 'A fruit' },
{ value: 'Banana', description: 'Yellow fruit' },
{ value: 'Carrot', description: 'A vegetable' },
{ value: 'Eggplant', description: undefined },
];
expect(filterLegalValues(mockLegalValues, filter)).toEqual(expected);
});
});
@@ -1,6 +1,11 @@
import { render } from 'utils/testRenderer';
import { screen } from '@testing-library/react';
import { fireEvent, screen } from '@testing-library/react';
import { RestrictiveLegalValues } from './RestrictiveLegalValues';
import { vi } from 'vitest';

vi.mock('../../../../../../hooks/useUiFlag', () => ({
useUiFlag: vi.fn(() => true),
}));

test('should show alert when you have illegal legal values', async () => {
const contextDefinitionValues = [{ value: 'value1' }, { value: 'value2' }];
Expand Down Expand Up @@ -52,3 +57,65 @@ test('Should remove illegal legal values from internal value state when mounting

expect(localValues).toEqual(['value2']);
});

test('Should select all', async () => {
const contextDefinitionValues = [{ value: 'value1' }, { value: 'value2' }];
let localValues: string[] = [];

const setValuesWithRecord = (values: string[]) => {
localValues = values;
};

render(
<RestrictiveLegalValues
data={{
legalValues: contextDefinitionValues,
deletedLegalValues: [{ value: 'value3' }],
}}
constraintValues={[]}
values={localValues}
setValues={() => {}}
setValuesWithRecord={setValuesWithRecord}
error={''}
setError={() => {}}
/>,
);

const selectedAllButton = await screen.findByText(/Select all/i);

console.log(selectedAllButton);

fireEvent.click(selectedAllButton);
expect(localValues).toEqual(['value1', 'value2']);
});

test('Should unselect all', async () => {
const contextDefinitionValues = [{ value: 'value1' }, { value: 'value2' }];
let localValues: string[] = ['value1', 'value2'];

const setValuesWithRecord = (values: string[]) => {
localValues = values;
};

render(
<RestrictiveLegalValues
data={{
legalValues: contextDefinitionValues,
deletedLegalValues: [{ value: 'value3' }],
}}
constraintValues={[]}
values={localValues}
setValues={() => {}}
setValuesWithRecord={setValuesWithRecord}
error={''}
setError={() => {}}
/>,
);

const selectedAllButton = await screen.findByText(/Unselect all/i);

console.log(selectedAllButton);

fireEvent.click(selectedAllButton);
expect(localValues).toEqual([]);
});
@@ -1,14 +1,14 @@
import { useEffect, useState } from 'react';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Alert, Checkbox, styled } from '@mui/material';
import { useThemeStyles } from 'themes/themeStyles';
import { Alert, Button, Checkbox, Chip, Stack, styled } from '@mui/material';
import { ConstraintValueSearch } from 'component/common/ConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch';
import { ConstraintFormHeader } from '../ConstraintFormHeader/ConstraintFormHeader';
import type { ILegalValue } from 'interfaces/context';
import {
filterLegalValues,
LegalValueLabel,
} from '../LegalValueLabel/LegalValueLabel';
import { useUiFlag } from 'hooks/useUiFlag';

interface IRestrictiveLegalValuesProps {
data: {
Expand Down Expand Up @@ -60,6 +60,16 @@ const StyledValuesContainer = styled('div')(({ theme }) => ({
maxHeight: '378px',
overflow: 'auto',
}));
const StyledStack = styled(Stack)(({ theme }) => ({
marginTop: theme.spacing(2),
marginBottom: theme.spacing(0.5),
justifyContent: 'space-between',
}));

const ErrorText = styled('p')(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
color: theme.palette.error.main,
}));

export const RestrictiveLegalValues = ({
data,
Expand All @@ -77,7 +87,8 @@ export const RestrictiveLegalValues = ({

// Lazily initialise the values because there might be a lot of them.
const [valuesMap, setValuesMap] = useState(() => createValuesMap(values));
const { classes: styles } = useThemeStyles();

const newContextFieldsUI = useUiFlag('newContextFieldsUI');

const cleanDeletedLegalValues = (constraintValues: string[]): string[] => {
const deletedValuesSet = getLegalValueSet(deletedLegalValues);
Expand Down Expand Up @@ -116,6 +127,19 @@ export const RestrictiveLegalValues = ({
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),
]);
};

return (
<>
<ConditionallyRender
Expand All @@ -134,24 +158,54 @@ export const RestrictiveLegalValues = ({
</Alert>
}
/>

<ConstraintFormHeader>
Select values from a predefined set
</ConstraintFormHeader>
<StyledStack direction={'row'}>
<ConstraintFormHeader>
Select values from a predefined set
</ConstraintFormHeader>
<ConditionallyRender
condition={newContextFieldsUI}
show={
<Button variant={'text'} onClick={onSelectAll}>
{isAllSelected ? 'Unselect all' : 'Select all'}
</Button>
}
/>
</StyledStack>
<ConditionallyRender
condition={legalValues.length > 100}
show={
<ConstraintValueSearch
filter={filter}
setFilter={setFilter}
/>
<>
<ConditionallyRender
condition={
Boolean(newContextFieldsUI) && Boolean(values)
}
show={
<StyledValuesContainer sx={{ border: 0 }}>
{values.map((value) => {
return (
<Chip
key={value}
label={value}
onDelete={() => onChange(value)}
/>
);
})}
</StyledValuesContainer>
}
/>
<ConstraintValueSearch
filter={filter}
setFilter={setFilter}
/>
</>
}
/>
<StyledValuesContainer>
{filteredValues.map((match) => (
<LegalValueLabel
key={match.value}
legal={match}
filter={filter}
control={
<Checkbox
checked={Boolean(valuesMap[match.value])}
Expand All @@ -168,7 +222,7 @@ export const RestrictiveLegalValues = ({
</StyledValuesContainer>
<ConditionallyRender
condition={Boolean(error)}
show={<p className={styles.error}>{error}</p>}
show={<ErrorText>{error}</ErrorText>}
/>
</>
);
Expand Down

0 comments on commit c868b5a

Please sign in to comment.