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:
parent
11f4155d5a
commit
c868b5a868
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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<
|
||||
|
@ -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}`,
|
||||
},
|
||||
}));
|
||||
|
||||
|
@ -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())
|
||||
);
|
||||
});
|
||||
};
|
||||
|
@ -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' }];
|
||||
@ -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,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>}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
@ -78,6 +78,7 @@ export type UiFlags = {
|
||||
outdatedSdksBanner?: boolean;
|
||||
projectOverviewRefactor?: string;
|
||||
collectTrafficDataUsage?: boolean;
|
||||
newContextFieldsUI?: boolean;
|
||||
variantDependencies?: boolean;
|
||||
};
|
||||
|
||||
|
@ -128,6 +128,7 @@ exports[`should create default config 1`] = `
|
||||
},
|
||||
},
|
||||
"migrationLock": true,
|
||||
"newContextFieldsUI": false,
|
||||
"newStrategyConfigurationFeedback": false,
|
||||
"outdatedSdksBanner": false,
|
||||
"personalAccessTokensKillSwitch": false,
|
||||
|
@ -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,
|
||||
|
@ -52,6 +52,7 @@ process.nextTick(async () => {
|
||||
globalFrontendApiCache: true,
|
||||
returnGlobalFrontendApiCache: false,
|
||||
projectOverviewRefactor: true,
|
||||
newContextFieldsUI: true,
|
||||
variantDependencies: true,
|
||||
},
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user