1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

Task/filter addon by project and environment (#1133)

* feat: add project and environments filters for addons

Co-authored-by: Simon Hornby <liquidwicked64@gmail.com>
This commit is contained in:
Christopher Kolstad 2022-07-14 12:55:37 +02:00 committed by GitHub
parent 235bf428fe
commit 4c5eb20e09
6 changed files with 415 additions and 74 deletions

View File

@ -1,45 +0,0 @@
import React from 'react';
import { Grid, FormControlLabel, Checkbox } from '@mui/material';
import { styles as themeStyles } from 'component/common';
import { IAddonProvider } from 'interfaces/addons';
interface IAddonProps {
provider?: IAddonProvider;
checkedEvents: string[];
setEventValue: (
name: string
) => (event: React.ChangeEvent<HTMLInputElement>) => void;
error?: string;
}
export const AddonEvents = ({
provider,
checkedEvents,
setEventValue,
error,
}: IAddonProps) => {
if (!provider) return null;
return (
<React.Fragment>
<h4>Events</h4>
<span className={themeStyles.error}>{error}</span>
<Grid container spacing={0}>
{provider.events.map(e => (
<Grid item xs={4} key={e}>
<FormControlLabel
control={
<Checkbox
checked={checkedEvents.includes(e)}
onChange={setEventValue(e)}
/>
}
label={e}
/>
</Grid>
))}
</Grid>
</React.Fragment>
);
};

View File

@ -1,19 +1,17 @@
import {
useState,
useEffect,
ChangeEvent,
VFC,
import React, {
ChangeEventHandler,
FormEventHandler,
MouseEventHandler,
useEffect,
useState,
VFC,
} from 'react';
import { TextField, FormControlLabel, Switch, Button } from '@mui/material';
import { Button, FormControlLabel, Switch, TextField } from '@mui/material';
import produce from 'immer';
import { styles as themeStyles } from 'component/common';
import { trim } from 'component/common/util';
import { IAddon, IAddonProvider } from 'interfaces/addons';
import { AddonParameters } from './AddonParameters/AddonParameters';
import { AddonEvents } from './AddonEvents/AddonEvents';
import cloneDeep from 'lodash.clonedeep';
import { PageContent } from 'component/common/PageContent/PageContent';
import { useNavigate } from 'react-router-dom';
@ -21,6 +19,9 @@ import useAddonsApi from 'hooks/api/actions/useAddonsApi/useAddonsApi';
import useToast from 'hooks/useToast';
import { makeStyles } from 'tss-react/mui';
import { formatUnknownError } from 'utils/formatUnknownError';
import useProjects from '../../../hooks/api/getters/useProjects/useProjects';
import { useEnvironments } from '../../../hooks/api/getters/useEnvironments/useEnvironments';
import { AddonMultiSelector } from './AddonMultiSelector/AddonMultiSelector';
const useStyles = makeStyles()(theme => ({
nameInput: {
@ -52,12 +53,27 @@ export const AddonForm: VFC<IAddonFormProps> = ({
const { setToastData, setToastApiError } = useToast();
const navigate = useNavigate();
const { classes: styles } = useStyles();
const { projects: availableProjects } = useProjects();
const selectableProjects = availableProjects.map(project => ({
value: project.id,
label: project.name,
}));
const { environments: availableEnvironments } = useEnvironments();
const selectableEnvironments = availableEnvironments.map(environment => ({
value: environment.name,
label: environment.name,
}));
const selectableEvents = provider?.events.map(event => ({
value: event,
label: event,
}));
const [formValues, setFormValues] = useState(initialValues);
const [errors, setErrors] = useState<{
containsErrors: boolean;
parameters: Record<string, string>;
events?: string;
projects?: string;
environments?: string;
general?: string;
description?: string;
}>({
@ -106,22 +122,39 @@ export const AddonForm: VFC<IAddonFormProps> = ({
);
};
const setEventValue =
(name: string) => (event: ChangeEvent<HTMLInputElement>) => {
setFormValues(
produce(draft => {
if (event.target.checked) {
draft.events.push(name);
} else {
draft.events = draft.events.filter(e => e !== name);
}
})
);
setErrors(prev => ({
...prev,
events: undefined,
}));
};
const setEventValues = (events: string[]) => {
setFormValues(
produce(draft => {
draft.events = events;
})
);
setErrors(prev => ({
...prev,
events: undefined,
}));
};
const setProjects = (projects: string[]) => {
setFormValues(
produce(draft => {
draft.projects = projects;
})
);
setErrors(prev => ({
...prev,
projects: undefined,
}));
};
const setEnvironments = (environments: string[]) => {
setFormValues(
produce(draft => {
draft.environments = environments;
})
);
setErrors(prev => ({
...prev,
environments: undefined,
}));
};
const onCancel = () => {
navigate(-1);
@ -234,12 +267,32 @@ export const AddonForm: VFC<IAddonFormProps> = ({
variant="outlined"
/>
</section>
<section className={styles.formSection}>
<AddonEvents
provider={provider}
checkedEvents={formValues.events}
setEventValue={setEventValue}
error={errors.events}
<AddonMultiSelector
options={selectableEvents || []}
selectedItems={formValues.events}
onChange={setEventValues}
entityName={'event'}
selectAllEnabled={false}
/>
</section>
<section className={styles.formSection}>
<AddonMultiSelector
options={selectableProjects}
selectedItems={formValues.projects || []}
onChange={setProjects}
entityName={'project'}
selectAllEnabled={true}
/>
</section>
<section className={styles.formSection}>
<AddonMultiSelector
options={selectableEnvironments}
selectedItems={formValues.environments || []}
onChange={setEnvironments}
entityName={'environment'}
selectAllEnabled={true}
/>
</section>
<section className={styles.formSection}>

View File

@ -0,0 +1,143 @@
import { vi } from 'vitest';
import React from 'react';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from 'utils/testRenderer';
import {
IAddonMultiSelectorProps,
AddonMultiSelector,
} from './AddonMultiSelector';
const onChange = vi.fn();
const onFocus = vi.fn();
const mockProps: IAddonMultiSelectorProps = {
options: [
{ label: 'Project1', value: 'project1' },
{ label: 'Project2', value: 'project2' },
{ label: 'Project3', value: 'project3' },
],
selectedItems: [],
onChange,
onFocus,
selectAllEnabled: true,
entityName: 'project',
};
describe('AddonMultiSelector', () => {
beforeEach(() => {
onChange.mockClear();
onFocus.mockClear();
});
it('renders with default state', () => {
render(<AddonMultiSelector {...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();
render(<AddonMultiSelector {...mockProps} selectedItems={['*']} />);
await user.click(screen.getByTestId('select-all-projects'));
expect(
screen.getByLabelText(/all current and future projects/i)
).not.toBeChecked();
expect(screen.getByLabelText('Projects')).toBeEnabled();
await user.click(screen.getByTestId('select-all-projects'));
expect(
screen.getByLabelText(/all current and future projects/i)
).toBeChecked();
expect(screen.getByLabelText('Projects')).toBeDisabled();
});
it('renders with autocomplete enabled if default value is not a wildcard', () => {
render(
<AddonMultiSelector {...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 input = within(selectInputContainer).getByRole('combobox');
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(
<AddonMultiSelector
{...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 () => {
const user = userEvent.setup();
render(
<AddonMultiSelector
{...mockProps}
selectedItems={[]}
options={[
{ label: 'Alpha', value: 'alpha' },
{ label: 'Bravo', value: 'bravo' },
{ label: 'Charlie', value: 'charlie' },
{ label: 'Alpaca', value: 'alpaca' },
]}
/>
);
const input = await screen.findByLabelText('Projects');
await user.type(input, 'alp');
await waitFor(() => {
expect(screen.getByText('Alpha')).toBeVisible();
});
await waitFor(() => {
expect(screen.queryByText('Bravo')).not.toBeInTheDocument();
});
await waitFor(() => {
expect(screen.queryByText('Charlie')).not.toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText('Alpaca')).toBeVisible();
});
await user.clear(input);
await user.type(input, 'bravo');
await waitFor(() => {
expect(screen.getByText('Bravo')).toBeVisible();
});
await waitFor(() => {
expect(screen.queryByText('Alpha')).not.toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,184 @@
import React, { ChangeEvent, Fragment, useState, VFC } from 'react';
import { IAutocompleteBoxOption } from '../../../common/AutocompleteBox/AutocompleteBox';
import { styles as themeStyles } from 'component/common';
import {
AutocompleteRenderGroupParams,
AutocompleteRenderInputParams,
AutocompleteRenderOptionState,
} from '@mui/material/Autocomplete';
import { styled } from '@mui/system';
import {
Autocomplete,
Box,
capitalize,
Checkbox,
FormControlLabel,
Paper,
TextField,
} from '@mui/material';
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
import CheckBoxIcon from '@mui/icons-material/CheckBox';
import { ConditionallyRender } from '../../../common/ConditionallyRender/ConditionallyRender';
import { SelectAllButton } from '../../../admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton';
export interface IAddonMultiSelectorProps {
options: IAutocompleteBoxOption[];
selectedItems: string[];
onChange: (value: string[]) => void;
error?: string;
onFocus?: () => void;
entityName: string;
selectAllEnabled: boolean;
}
const ALL_OPTIONS = '*';
const StyledCheckbox = styled(Checkbox)(() => ({
marginRight: '0.2em',
}));
const CustomPaper = ({ ...props }) => <Paper elevation={8} {...props} />;
export const AddonMultiSelector: VFC<IAddonMultiSelectorProps> = ({
options,
selectedItems,
onChange,
error,
onFocus,
entityName,
selectAllEnabled = true,
}) => {
const [isWildcardSelected, selectWildcard] = useState(
selectedItems.includes(ALL_OPTIONS)
);
const renderInput = (params: AutocompleteRenderInputParams) => (
<TextField
{...params}
error={Boolean(error)}
helperText={error}
variant="outlined"
label={`${capitalize(entityName)}s`}
placeholder={`Select ${entityName}s to filter by`}
onFocus={onFocus}
data-testid={`select-${entityName}-input`}
/>
);
const isAllSelected =
selectedItems.length > 0 &&
selectedItems.length === options.length &&
selectedItems[0] !== ALL_OPTIONS;
const onAllItemsChange = (
e: ChangeEvent<HTMLInputElement>,
checked: boolean
) => {
if (checked) {
selectWildcard(true);
onChange([ALL_OPTIONS]);
} else {
selectWildcard(false);
onChange(selectedItems.includes(ALL_OPTIONS) ? [] : selectedItems);
}
};
const onSelectAllClick = () => {
const newItems = isAllSelected ? [] : options.map(({ value }) => value);
onChange(newItems);
};
const renderOption = (
props: object,
option: IAutocompleteBoxOption,
{ selected }: AutocompleteRenderOptionState
) => {
return (
<li {...props}>
<StyledCheckbox
icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
checkedIcon={<CheckBoxIcon fontSize="small" />}
checked={selected}
/>
{option.label}
</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 = () => (
<Box sx={{ mt: 1, mb: 0.25, ml: 1.5 }}>
<FormControlLabel
data-testid={`select-all-${entityName}s`}
control={
<Checkbox
checked={isWildcardSelected}
onChange={onAllItemsChange}
/>
}
label={`ALL current and future ${entityName}s`}
/>
</Box>
);
const HelpText = () => (
<p>
Selecting {entityName}(s) here will filter events so that your addon
will only receive events that are tagged with one of your{' '}
{entityName}s.
</p>
);
return (
<React.Fragment>
<h4>{capitalize(entityName)}s</h4>
<ConditionallyRender
condition={selectAllEnabled}
show={<HelpText />}
/>
<span className={themeStyles.error}>{error}</span>
<br />
<Box sx={{ mt: -1, mb: 3 }}>
<ConditionallyRender
condition={selectAllEnabled}
show={<SelectAllFormControl />}
/>
<Autocomplete
disabled={isWildcardSelected}
multiple
limitTags={2}
options={options}
disableCloseOnSelect
getOptionLabel={({ label }) => label}
fullWidth
groupBy={() => 'Select/Deselect all'}
renderGroup={renderGroup}
PaperComponent={CustomPaper}
renderOption={renderOption}
renderInput={renderInput}
value={
isWildcardSelected
? options
: options.filter(option =>
selectedItems.includes(option.value)
)
}
onChange={(_, input) => {
const state = input.map(({ value }) => value);
onChange(state);
}}
/>
</Box>
</React.Fragment>
);
};

View File

@ -10,6 +10,8 @@ export const DEFAULT_DATA = {
enabled: true,
parameters: {},
events: [],
projects: [],
environments: [],
} as unknown as IAddon; // TODO: improve type
export const CreateAddon = () => {

View File

@ -5,6 +5,8 @@ export interface IAddon {
parameters: Record<string, any>;
id: number;
events: string[];
projects?: string[];
environments?: string[];
enabled: boolean;
description: string;
}
@ -34,6 +36,8 @@ export interface IAddonConfig {
parameters: Record<string, any>;
id: number;
events: string[];
projects?: string[];
environments?: string[];
enabled: boolean;
description: string;
}