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:
parent
235bf428fe
commit
4c5eb20e09
@ -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>
|
||||
);
|
||||
};
|
@ -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}>
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -10,6 +10,8 @@ export const DEFAULT_DATA = {
|
||||
enabled: true,
|
||||
parameters: {},
|
||||
events: [],
|
||||
projects: [],
|
||||
environments: [],
|
||||
} as unknown as IAddon; // TODO: improve type
|
||||
|
||||
export const CreateAddon = () => {
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user