1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-03 01:18:43 +02:00

Feat: context field search and filter improvements (#6732)

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>
This commit is contained in:
andreas-unleash 2024-03-29 15:44:34 +02:00 committed by GitHub
parent 11f4155d5a
commit c868b5a868
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 240 additions and 34 deletions

View File

@ -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;
@ -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'
@ -35,16 +34,6 @@ export const ConstraintValueSearch = ({
}}
/>
</div>
<ConditionallyRender
condition={Boolean(filter)}
show={
<Chip
style={{ marginLeft: '1rem' }}
label={`filter active: ${filter}`}
onDelete={() => setFilter('')}
/>
}
/>
</div>
);
};

View File

@ -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<

View File

@ -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}`,
},
}));

View File

@ -6,13 +6,19 @@ 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
@ -20,9 +26,15 @@ export const LegalValueLabel = ({ legal, control }: ILegalValueTextProps) => {
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>
</>
}
@ -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())
);
});
};

View File

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

View File

@ -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' }];
@ -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([]);
});

View File

@ -1,7 +1,6 @@
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';
@ -9,6 +8,7 @@ import {
filterLegalValues,
LegalValueLabel,
} from '../LegalValueLabel/LegalValueLabel';
import { useUiFlag } from 'hooks/useUiFlag';
interface IRestrictiveLegalValuesProps {
data: {
@ -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,
@ -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);
@ -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
@ -134,17 +158,46 @@ 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>
@ -152,6 +205,7 @@ export const RestrictiveLegalValues = ({
<LegalValueLabel
key={match.value}
legal={match}
filter={filter}
control={
<Checkbox
checked={Boolean(valuesMap[match.value])}
@ -168,7 +222,7 @@ export const RestrictiveLegalValues = ({
</StyledValuesContainer>
<ConditionallyRender
condition={Boolean(error)}
show={<p className={styles.error}>{error}</p>}
show={<ErrorText>{error}</ErrorText>}
/>
</>
);

View File

@ -78,6 +78,7 @@ export type UiFlags = {
outdatedSdksBanner?: boolean;
projectOverviewRefactor?: string;
collectTrafficDataUsage?: boolean;
newContextFieldsUI?: boolean;
variantDependencies?: boolean;
};

View File

@ -128,6 +128,7 @@ exports[`should create default config 1`] = `
},
},
"migrationLock": true,
"newContextFieldsUI": false,
"newStrategyConfigurationFeedback": false,
"outdatedSdksBanner": false,
"personalAccessTokensKillSwitch": false,

View File

@ -55,7 +55,8 @@ export type IFlagKey =
| 'globalFrontendApiCache'
| 'returnGlobalFrontendApiCache'
| 'projectOverviewRefactor'
| 'variantDependencies';
| 'variantDependencies'
| 'newContextFieldsUI';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
@ -268,6 +269,10 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_PROJECT_OVERVIEW_REFACTOR,
false,
),
newContextFieldsUI: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_NEW_CONTEXT_FIELDS_UI,
false,
),
variantDependencies: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_VARIANT_DEPENDENCIES,
false,

View File

@ -52,6 +52,7 @@ process.nextTick(async () => {
globalFrontendApiCache: true,
returnGlobalFrontendApiCache: false,
projectOverviewRefactor: true,
newContextFieldsUI: true,
variantDependencies: true,
},
},