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 Search from '@mui/icons-material/Search';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
interface IConstraintValueSearchProps { interface IConstraintValueSearchProps {
filter: string; filter: string;
@ -13,7 +12,7 @@ export const ConstraintValueSearch = ({
}: IConstraintValueSearchProps) => { }: IConstraintValueSearchProps) => {
return ( return (
<div style={{ display: 'flex', alignItems: 'center' }}> <div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ width: '300px' }}> <div style={{ width: '100%' }}>
<TextField <TextField
label='Search' label='Search'
name='search' name='search'
@ -35,16 +34,6 @@ export const ConstraintValueSearch = ({
}} }}
/> />
</div> </div>
<ConditionallyRender
condition={Boolean(filter)}
show={
<Chip
style={{ marginLeft: '1rem' }}
label={`filter active: ${filter}`}
onDelete={() => setFilter('')}
/>
}
/>
</div> </div>
); );
}; };

View File

@ -4,8 +4,6 @@ import { styled } from '@mui/material';
const StyledHeader = styled('h3')(({ theme }) => ({ const StyledHeader = styled('h3')(({ theme }) => ({
fontSize: theme.fontSizes.bodySize, fontSize: theme.fontSizes.bodySize,
fontWeight: theme.typography.fontWeightRegular, fontWeight: theme.typography.fontWeightRegular,
marginTop: theme.spacing(2),
marginBottom: theme.spacing(0.5),
})); }));
export const ConstraintFormHeader: React.FC< export const ConstraintFormHeader: React.FC<

View File

@ -9,7 +9,7 @@ export const StyledContainer = styled('div')(({ theme }) => ({
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadius,
'&:hover': { '&: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'; } from './LegalValueLabel.styles';
import type React from 'react'; import type React from 'react';
import { FormControlLabel } from '@mui/material'; import { FormControlLabel } from '@mui/material';
import { Highlighter } from 'component/common/Highlighter/Highlighter';
interface ILegalValueTextProps { interface ILegalValueTextProps {
legal: ILegalValue; legal: ILegalValue;
control: React.ReactElement; control: React.ReactElement;
filter?: string;
} }
export const LegalValueLabel = ({ legal, control }: ILegalValueTextProps) => { export const LegalValueLabel = ({
legal,
control,
filter,
}: ILegalValueTextProps) => {
return ( return (
<StyledContainer> <StyledContainer>
<FormControlLabel <FormControlLabel
@ -20,9 +26,15 @@ export const LegalValueLabel = ({ legal, control }: ILegalValueTextProps) => {
control={control} control={control}
label={ label={
<> <>
<StyledValue>{legal.value}</StyledValue> <StyledValue>
<Highlighter search={filter}>
{legal.value}
</Highlighter>
</StyledValue>
<StyledDescription> <StyledDescription>
<Highlighter search={filter}>
{legal.description} {legal.description}
</Highlighter>
</StyledDescription> </StyledDescription>
</> </>
} }
@ -36,6 +48,9 @@ export const filterLegalValues = (
filter: string, filter: string,
): ILegalValue[] => { ): ILegalValue[] => {
return legalValues.filter((legalValue) => { 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 { render } from 'utils/testRenderer';
import { screen } from '@testing-library/react'; import { fireEvent, screen } from '@testing-library/react';
import { RestrictiveLegalValues } from './RestrictiveLegalValues'; 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 () => { test('should show alert when you have illegal legal values', async () => {
const contextDefinitionValues = [{ value: 'value1' }, { value: 'value2' }]; 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']); 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 { useEffect, useState } from 'react';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Alert, Checkbox, styled } from '@mui/material'; import { Alert, Button, Checkbox, Chip, Stack, styled } from '@mui/material';
import { useThemeStyles } from 'themes/themeStyles';
import { ConstraintValueSearch } from 'component/common/ConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch'; import { ConstraintValueSearch } from 'component/common/ConstraintAccordion/ConstraintValueSearch/ConstraintValueSearch';
import { ConstraintFormHeader } from '../ConstraintFormHeader/ConstraintFormHeader'; import { ConstraintFormHeader } from '../ConstraintFormHeader/ConstraintFormHeader';
import type { ILegalValue } from 'interfaces/context'; import type { ILegalValue } from 'interfaces/context';
@ -9,6 +8,7 @@ import {
filterLegalValues, filterLegalValues,
LegalValueLabel, LegalValueLabel,
} from '../LegalValueLabel/LegalValueLabel'; } from '../LegalValueLabel/LegalValueLabel';
import { useUiFlag } from 'hooks/useUiFlag';
interface IRestrictiveLegalValuesProps { interface IRestrictiveLegalValuesProps {
data: { data: {
@ -60,6 +60,16 @@ const StyledValuesContainer = styled('div')(({ theme }) => ({
maxHeight: '378px', maxHeight: '378px',
overflow: 'auto', 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 = ({ export const RestrictiveLegalValues = ({
data, data,
@ -77,7 +87,8 @@ export const RestrictiveLegalValues = ({
// Lazily initialise the values because there might be a lot of them. // Lazily initialise the values because there might be a lot of them.
const [valuesMap, setValuesMap] = useState(() => createValuesMap(values)); const [valuesMap, setValuesMap] = useState(() => createValuesMap(values));
const { classes: styles } = useThemeStyles();
const newContextFieldsUI = useUiFlag('newContextFieldsUI');
const cleanDeletedLegalValues = (constraintValues: string[]): string[] => { const cleanDeletedLegalValues = (constraintValues: string[]): string[] => {
const deletedValuesSet = getLegalValueSet(deletedLegalValues); const deletedValuesSet = getLegalValueSet(deletedLegalValues);
@ -116,6 +127,19 @@ export const RestrictiveLegalValues = ({
setValuesWithRecord([...cleanDeletedLegalValues(values), legalValue]); 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 ( return (
<> <>
<ConditionallyRender <ConditionallyRender
@ -134,17 +158,46 @@ export const RestrictiveLegalValues = ({
</Alert> </Alert>
} }
/> />
<StyledStack direction={'row'}>
<ConstraintFormHeader> <ConstraintFormHeader>
Select values from a predefined set Select values from a predefined set
</ConstraintFormHeader> </ConstraintFormHeader>
<ConditionallyRender
condition={newContextFieldsUI}
show={
<Button variant={'text'} onClick={onSelectAll}>
{isAllSelected ? 'Unselect all' : 'Select all'}
</Button>
}
/>
</StyledStack>
<ConditionallyRender <ConditionallyRender
condition={legalValues.length > 100} condition={legalValues.length > 100}
show={ show={
<>
<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 <ConstraintValueSearch
filter={filter} filter={filter}
setFilter={setFilter} setFilter={setFilter}
/> />
</>
} }
/> />
<StyledValuesContainer> <StyledValuesContainer>
@ -152,6 +205,7 @@ export const RestrictiveLegalValues = ({
<LegalValueLabel <LegalValueLabel
key={match.value} key={match.value}
legal={match} legal={match}
filter={filter}
control={ control={
<Checkbox <Checkbox
checked={Boolean(valuesMap[match.value])} checked={Boolean(valuesMap[match.value])}
@ -168,7 +222,7 @@ export const RestrictiveLegalValues = ({
</StyledValuesContainer> </StyledValuesContainer>
<ConditionallyRender <ConditionallyRender
condition={Boolean(error)} 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; outdatedSdksBanner?: boolean;
projectOverviewRefactor?: string; projectOverviewRefactor?: string;
collectTrafficDataUsage?: boolean; collectTrafficDataUsage?: boolean;
newContextFieldsUI?: boolean;
variantDependencies?: boolean; variantDependencies?: boolean;
}; };

View File

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

View File

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

View File

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