1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

feat: simpler integration filters (#4766)

https://linear.app/unleash/issue/2-1407/remove-the-all-checkboxes-from-project-and-environment-filters

Simplifies integration event filters by removing the "ALL" checkboxes
from these components. Whether you opted to check the "ALL" checkbox, or
not to filter at all, the result is the same - The selected options
would act as a filter.

Includes some refactoring and clean up.


![image](https://github.com/Unleash/unleash/assets/14320932/2e30c5c5-12e1-4bc6-bd4a-8be4226d625d)
This commit is contained in:
Nuno Góis 2023-09-20 09:21:30 +01:00 committed by GitHub
parent d6e1c9190a
commit 39b0c089d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 47 additions and 214 deletions

View File

@ -1,5 +1,5 @@
import { Paper, styled } from '@mui/material'; import { Paper, styled } from '@mui/material';
import { FormControlLabel, TextField, Typography } from '@mui/material'; import { TextField, Typography } from '@mui/material';
import { forwardRef, type FC, type ReactNode, ComponentProps } from 'react'; import { forwardRef, type FC, type ReactNode, ComponentProps } from 'react';
export const StyledForm = styled('form')(({ theme }) => ({ export const StyledForm = styled('form')(({ theme }) => ({
@ -43,12 +43,6 @@ export const StyledTextField = styled(TextField)(({ theme }) => ({
width: '100%', width: '100%',
})); }));
export const StyledSelectAllFormControlLabel = styled(FormControlLabel)(
({ theme }) => ({
paddingBottom: theme.spacing(1),
})
);
export const StyledTitle = forwardRef< export const StyledTitle = forwardRef<
HTMLHeadingElement, HTMLHeadingElement,
{ children: ReactNode } { children: ReactNode }

View File

@ -367,7 +367,6 @@ export const IntegrationForm: VFC<IntegrationFormProps> = ({
selectedItems={formValues.events} selectedItems={formValues.events}
onChange={setEventValues} onChange={setEventValues}
entityName="event" entityName="event"
selectAllEnabled={false}
error={errors.events} error={errors.events}
description="Select which events you want your integration to be notified about." description="Select which events you want your integration to be notified about."
required required
@ -379,7 +378,8 @@ export const IntegrationForm: VFC<IntegrationFormProps> = ({
selectedItems={formValues.projects || []} selectedItems={formValues.projects || []}
onChange={setProjects} onChange={setProjects}
entityName="project" entityName="project"
selectAllEnabled={true} description="Selecting project(s) will filter events, so that your integration only receives events related to those specific projects."
note="If no projects are selected, the integration will receive events from all projects."
/> />
</div> </div>
<div> <div>
@ -388,8 +388,8 @@ export const IntegrationForm: VFC<IntegrationFormProps> = ({
selectedItems={formValues.environments || []} selectedItems={formValues.environments || []}
onChange={setEnvironments} onChange={setEnvironments}
entityName="environment" entityName="environment"
selectAllEnabled={true} description="Selecting environment(s) will filter events, so that your integration only receives events related to those specific environments. Global events that are not specific to an environment will still be received."
description="Global events that are not specific to an environment will still be received." note="If no environments are selected, the integration will receive events from all environments."
/> />
</div> </div>
</StyledConfigurationSection> </StyledConfigurationSection>

View File

@ -1,5 +1,4 @@
import { vi } from 'vitest'; import { vi } from 'vitest';
import React from 'react';
import { screen, waitFor, within } from '@testing-library/react'; import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { render } from 'utils/testRenderer'; import { render } from 'utils/testRenderer';
@ -21,8 +20,8 @@ const mockProps: IIntegrationMultiSelectorProps = {
selectedItems: [], selectedItems: [],
onChange, onChange,
onFocus, onFocus,
selectAllEnabled: true,
entityName: 'project', entityName: 'project',
description: 'some description',
}; };
const server = testServerSetup(); const server = testServerSetup();
@ -35,85 +34,18 @@ describe('AddonMultiSelector', () => {
}); });
it('renders with default state', () => { it('renders with default state', () => {
render( render(<IntegrationMultiSelector {...mockProps} />);
<IntegrationMultiSelector {...mockProps} selectedItems={['*']} />
);
const checkbox = screen.getByLabelText(
/all current and future projects/i
);
expect(checkbox).toBeChecked();
const selectInputContainer = screen.getByTestId('select-project-input');
const input = within(selectInputContainer).getByRole('combobox');
expect(input).toBeDisabled();
});
it('can toggle "ALL" checkbox', async () => {
const user = userEvent.setup();
const { rerender } = render(
<IntegrationMultiSelector {...mockProps} selectedItems={['*']} />
);
await user.click(screen.getByTestId('select-all-projects'));
expect(onChange).toHaveBeenCalledWith([]);
rerender(
<IntegrationMultiSelector {...mockProps} selectedItems={[]} />
);
await user.click(screen.getByTestId('select-all-projects'));
expect(onChange).toHaveBeenCalledWith(['*']);
});
it('renders with autocomplete enabled if default value is not a wildcard', () => {
render(
<IntegrationMultiSelector
{...mockProps}
selectedItems={['project1']}
/>
);
const checkbox = screen.getByLabelText(
/all current and future projects/i
);
expect(checkbox).not.toBeChecked();
const selectInputContainer = screen.getByTestId('select-project-input'); const selectInputContainer = screen.getByTestId('select-project-input');
const input = within(selectInputContainer).getByRole('combobox'); const input = within(selectInputContainer).getByRole('combobox');
expect(input).toBeEnabled(); expect(input).toBeEnabled();
}); });
describe('Select/Deselect projects in dropdown', () => {
it("doesn't show up for less than 3 options", async () => {
const user = userEvent.setup();
render(
<IntegrationMultiSelector
{...mockProps}
selectedItems={[]}
options={[
{ label: 'Project1', value: 'project1' },
{ label: 'Project2', value: 'project2' },
]}
/>
);
await user.click(screen.getByLabelText('Projects'));
const button = screen.queryByRole('button', {
name: /select all/i,
});
expect(button).not.toBeInTheDocument();
});
});
it('can filter options', async () => { it('can filter options', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render( render(
<IntegrationMultiSelector <IntegrationMultiSelector
{...mockProps} {...mockProps}
selectedItems={[]}
options={[ options={[
{ label: 'Alpha', value: 'alpha' }, { label: 'Alpha', value: 'alpha' },
{ label: 'Bravo', value: 'bravo' }, { label: 'Bravo', value: 'bravo' },
@ -147,22 +79,4 @@ describe('AddonMultiSelector', () => {
expect(screen.queryByText('Alpha')).not.toBeInTheDocument(); expect(screen.queryByText('Alpha')).not.toBeInTheDocument();
}); });
}); });
it('will load wildcard status from props', async () => {
const { rerender } = render(
<IntegrationMultiSelector {...mockProps} selectedItems={[]} />
);
expect(
screen.getByLabelText(/all current and future projects/i)
).not.toBeChecked();
rerender(
<IntegrationMultiSelector {...mockProps} selectedItems={['*']} />
);
expect(
screen.getByLabelText(/all current and future projects/i)
).toBeChecked();
});
}); });

View File

@ -1,7 +1,6 @@
import React, { ChangeEvent, Fragment, VFC } from 'react'; import { VFC } from 'react';
import { IAutocompleteBoxOption } from '../../../common/AutocompleteBox/AutocompleteBox'; import { IAutocompleteBoxOption } from '../../../common/AutocompleteBox/AutocompleteBox';
import { import {
AutocompleteRenderGroupParams,
AutocompleteRenderInputParams, AutocompleteRenderInputParams,
AutocompleteRenderOptionState, AutocompleteRenderOptionState,
} from '@mui/material/Autocomplete'; } from '@mui/material/Autocomplete';
@ -16,13 +15,7 @@ import {
} from '@mui/material'; } from '@mui/material';
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'; import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
import CheckBoxIcon from '@mui/icons-material/CheckBox'; import CheckBoxIcon from '@mui/icons-material/CheckBox';
import { ConditionallyRender } from '../../../common/ConditionallyRender/ConditionallyRender'; import { StyledHelpText, StyledTitle } from '../IntegrationForm.styles';
import { SelectAllButton } from '../../../admin/apiToken/ApiTokenForm/ProjectSelector/SelectProjectInput/SelectAllButton/SelectAllButton';
import {
StyledHelpText,
StyledSelectAllFormControlLabel,
StyledTitle,
} from '../IntegrationForm.styles';
export interface IIntegrationMultiSelectorProps { export interface IIntegrationMultiSelectorProps {
options: IAutocompleteBoxOption[]; options: IAutocompleteBoxOption[];
@ -31,13 +24,11 @@ export interface IIntegrationMultiSelectorProps {
error?: string; error?: string;
onFocus?: () => void; onFocus?: () => void;
entityName: string; entityName: string;
selectAllEnabled: boolean; description: string;
description?: string; note?: string;
required?: boolean; required?: boolean;
} }
const ALL_OPTIONS = '*';
const StyledCheckbox = styled(Checkbox)(() => ({ const StyledCheckbox = styled(Checkbox)(() => ({
marginRight: '0.2em', marginRight: '0.2em',
})); }));
@ -51,45 +42,30 @@ export const IntegrationMultiSelector: VFC<IIntegrationMultiSelectorProps> = ({
error, error,
onFocus, onFocus,
entityName, entityName,
selectAllEnabled = true,
description, description,
note,
required, required,
}) => { }) => {
const renderInput = (params: AutocompleteRenderInputParams) => ( const renderInput = (params: AutocompleteRenderInputParams) => (
<TextField <TextField
{...params} {...params}
error={Boolean(error)} error={Boolean(error)}
helperText={error} helperText={error || note}
variant="outlined" variant="outlined"
label={`${capitalize(entityName)}s`} label={
<>
{capitalize(`${entityName}s`)}
{required ? (
<Typography component="span">*</Typography>
) : null}
</>
}
placeholder={`Select ${entityName}s to filter by`} placeholder={`Select ${entityName}s to filter by`}
onFocus={onFocus} onFocus={onFocus}
data-testid={`select-${entityName}-input`} data-testid={`select-${entityName}-input`}
/> />
); );
const isAllSelected =
selectedItems.length > 0 &&
selectedItems.length === options.length &&
selectedItems[0] !== ALL_OPTIONS;
const isWildcardSelected = selectedItems.includes(ALL_OPTIONS);
const onAllItemsChange = (
e: ChangeEvent<HTMLInputElement>,
checked: boolean
) => {
if (checked) {
onChange([ALL_OPTIONS]);
} else {
onChange(selectedItems.includes(ALL_OPTIONS) ? [] : selectedItems);
}
};
const onSelectAllClick = () => {
const newItems = isAllSelected ? [] : options.map(({ value }) => value);
onChange(newItems);
};
const renderOption = ( const renderOption = (
props: object, props: object,
option: IAutocompleteBoxOption, option: IAutocompleteBoxOption,
@ -106,89 +82,30 @@ export const IntegrationMultiSelector: VFC<IIntegrationMultiSelectorProps> = ({
</li> </li>
); );
}; };
const renderGroup = ({ key, children }: AutocompleteRenderGroupParams) => (
<Fragment key={key}>
<ConditionallyRender
condition={options.length > 2 && selectAllEnabled}
show={
<SelectAllButton
isAllSelected={isAllSelected}
onClick={onSelectAllClick}
/>
}
/>
{children}
</Fragment>
);
const SelectAllFormControl = () => (
<StyledSelectAllFormControlLabel
data-testid={`select-all-${entityName}s`}
control={
<Checkbox
checked={isWildcardSelected}
onChange={onAllItemsChange}
/>
}
label={`ALL current and future ${entityName}s`}
/>
);
const DefaultHelpText = () => (
<StyledHelpText>
Selecting {entityName}(s) will filter events, so that your
integration only receives events related to those specific{' '}
{entityName}s.
</StyledHelpText>
);
return ( return (
<React.Fragment> <>
<StyledTitle> <StyledTitle>{capitalize(`${entityName}s`)}</StyledTitle>
{capitalize(`${entityName}s`)} <StyledHelpText>{description}</StyledHelpText>
{required ? (
<Typography component="span" color="error">
*
</Typography>
) : null}
</StyledTitle>
<ConditionallyRender
condition={selectAllEnabled}
show={<DefaultHelpText />}
/>
<ConditionallyRender
condition={description !== undefined}
show={<StyledHelpText>{description}</StyledHelpText>}
/>
<ConditionallyRender
condition={selectAllEnabled}
show={<SelectAllFormControl />}
/>
<Autocomplete <Autocomplete
size="small" size="small"
disabled={isWildcardSelected}
multiple multiple
limitTags={2} limitTags={2}
options={options} options={options}
disableCloseOnSelect disableCloseOnSelect
getOptionLabel={({ label }) => label} getOptionLabel={({ label }) => label}
fullWidth fullWidth
groupBy={() => 'Select/Deselect all'}
renderGroup={renderGroup}
PaperComponent={CustomPaper} PaperComponent={CustomPaper}
renderOption={renderOption} renderOption={renderOption}
renderInput={renderInput} renderInput={renderInput}
value={ value={options.filter(option =>
isWildcardSelected selectedItems.includes(option.value)
? options )}
: options.filter(option =>
selectedItems.includes(option.value)
)
}
onChange={(_, input) => { onChange={(_, input) => {
const state = input.map(({ value }) => value); const state = input.map(({ value }) => value);
onChange(state); onChange(state);
}} }}
/> />
</React.Fragment> </>
); );
}; };

View File

@ -49,9 +49,7 @@ export const IntegrationParameterTextField = ({
<> <>
{definition.displayName} {definition.displayName}
{definition.required ? ( {definition.required ? (
<Typography component="span" color="error"> <Typography component="span">*</Typography>
*
</Typography>
) : null} ) : null}
</> </>
} }

View File

@ -54,8 +54,10 @@ const dataDogDefinition: IAddonDefinition = {
{ {
name: 'customHeaders', name: 'customHeaders',
displayName: 'Extra HTTP Headers', displayName: 'Extra HTTP Headers',
placeholder: placeholder: `{
'{\n"ISTIO_USER_KEY": "hunter2",\n"SOME_OTHER_CUSTOM_HTTP_HEADER": "SOMEVALUE"\n}', "ISTIO_USER_KEY": "hunter2",
"SOME_OTHER_CUSTOM_HTTP_HEADER": "SOMEVALUE"
}`,
description: description:
'(Optional) Used to add extra HTTP Headers to the request the plugin fires off. This must be a valid json object of key-value pairs where both the key and the value are strings', '(Optional) Used to add extra HTTP Headers to the request the plugin fires off. This must be a valid json object of key-value pairs where both the key and the value are strings',
required: false, required: false,

View File

@ -69,8 +69,10 @@ const slackDefinition: IAddonDefinition = {
{ {
name: 'customHeaders', name: 'customHeaders',
displayName: 'Extra HTTP Headers', displayName: 'Extra HTTP Headers',
placeholder: placeholder: `{
'{\n"ISTIO_USER_KEY": "hunter2",\n"SOME_OTHER_CUSTOM_HTTP_HEADER": "SOMEVALUE"\n}', "ISTIO_USER_KEY": "hunter2",
"SOME_OTHER_CUSTOM_HTTP_HEADER": "SOMEVALUE"
}`,
description: `(Optional) Used to add extra HTTP Headers to the request the plugin fires off. This must be a valid json object of key-value pairs where both the key and the value are strings`, description: `(Optional) Used to add extra HTTP Headers to the request the plugin fires off. This must be a valid json object of key-value pairs where both the key and the value are strings`,
required: false, required: false,
sensitive: true, sensitive: true,

View File

@ -35,8 +35,10 @@ const teamsDefinition: IAddonDefinition = {
{ {
name: 'customHeaders', name: 'customHeaders',
displayName: 'Extra HTTP Headers', displayName: 'Extra HTTP Headers',
placeholder: placeholder: `{
'{\n"ISTIO_USER_KEY": "hunter2",\n"SOME_OTHER_CUSTOM_HTTP_HEADER": "SOMEVALUE"\n}', "ISTIO_USER_KEY": "hunter2",
"SOME_OTHER_CUSTOM_HTTP_HEADER": "SOMEVALUE"
}`,
description: `(Optional) Used to add extra HTTP Headers to the request the plugin fires off. This must be a valid json object of key-value pairs where both the key and the value are strings`, description: `(Optional) Used to add extra HTTP Headers to the request the plugin fires off. This must be a valid json object of key-value pairs where both the key and the value are strings`,
required: false, required: false,
sensitive: true, sensitive: true,

View File

@ -65,8 +65,12 @@ export const addonsSchema = {
{ {
name: 'bodyTemplate', name: 'bodyTemplate',
displayName: 'Body template', displayName: 'Body template',
placeholder: placeholder: `{
'{\n "event": "{{event.type}}",\n "createdBy": "{{event.createdBy}}",\n "featureToggle": "{{event.data.name}}",\n "timestamp": "{{event.data.createdAt}}"\n}', "event": "{{event.type}}",
"createdBy": "{{event.createdBy}}",
"featureToggle": "{{event.data.name}}",
"timestamp": "{{event.data.createdAt}}"
}`,
description: description:
"(Optional) You may format the body using a mustache template. If you don't specify anything, the format will similar to the events format (https://docs.getunleash.io/reference/api/legacy/unleash/admin/events)", "(Optional) You may format the body using a mustache template. If you don't specify anything, the format will similar to the events format (https://docs.getunleash.io/reference/api/legacy/unleash/admin/events)",
type: 'textfield', type: 'textfield',