diff --git a/frontend/src/component/addons/AddonForm/AddonEvents/AddonEvents.tsx b/frontend/src/component/addons/AddonForm/AddonEvents/AddonEvents.tsx deleted file mode 100644 index 39e737a3dc..0000000000 --- a/frontend/src/component/addons/AddonForm/AddonEvents/AddonEvents.tsx +++ /dev/null @@ -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) => void; - error?: string; -} - -export const AddonEvents = ({ - provider, - checkedEvents, - setEventValue, - error, -}: IAddonProps) => { - if (!provider) return null; - - return ( - -

Events

- {error} - - {provider.events.map(e => ( - - - } - label={e} - /> - - ))} - -
- ); -}; diff --git a/frontend/src/component/addons/AddonForm/AddonForm.tsx b/frontend/src/component/addons/AddonForm/AddonForm.tsx index 3fe0123605..541d0d5528 100644 --- a/frontend/src/component/addons/AddonForm/AddonForm.tsx +++ b/frontend/src/component/addons/AddonForm/AddonForm.tsx @@ -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 = ({ 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; events?: string; + projects?: string; + environments?: string; general?: string; description?: string; }>({ @@ -106,22 +122,39 @@ export const AddonForm: VFC = ({ ); }; - const setEventValue = - (name: string) => (event: ChangeEvent) => { - 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 = ({ variant="outlined" /> +
- +
+
+ +
+
+
diff --git a/frontend/src/component/addons/AddonForm/AddonMultiSelector/AddonMultiSelector.test.tsx b/frontend/src/component/addons/AddonForm/AddonMultiSelector/AddonMultiSelector.test.tsx new file mode 100644 index 0000000000..186520ea37 --- /dev/null +++ b/frontend/src/component/addons/AddonForm/AddonMultiSelector/AddonMultiSelector.test.tsx @@ -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(); + + 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(); + + 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( + + ); + + 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( + + ); + 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( + + ); + 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(); + }); + }); +}); diff --git a/frontend/src/component/addons/AddonForm/AddonMultiSelector/AddonMultiSelector.tsx b/frontend/src/component/addons/AddonForm/AddonMultiSelector/AddonMultiSelector.tsx new file mode 100644 index 0000000000..9aaa1b5092 --- /dev/null +++ b/frontend/src/component/addons/AddonForm/AddonMultiSelector/AddonMultiSelector.tsx @@ -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 }) => ; + +export const AddonMultiSelector: VFC = ({ + options, + selectedItems, + onChange, + error, + onFocus, + entityName, + selectAllEnabled = true, +}) => { + const [isWildcardSelected, selectWildcard] = useState( + selectedItems.includes(ALL_OPTIONS) + ); + const renderInput = (params: AutocompleteRenderInputParams) => ( + + ); + + const isAllSelected = + selectedItems.length > 0 && + selectedItems.length === options.length && + selectedItems[0] !== ALL_OPTIONS; + + const onAllItemsChange = ( + e: ChangeEvent, + 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 ( +
  • + } + checkedIcon={} + checked={selected} + /> + {option.label} +
  • + ); + }; + const renderGroup = ({ key, children }: AutocompleteRenderGroupParams) => ( + + 2 && selectAllEnabled} + show={ + + } + /> + {children} + + ); + const SelectAllFormControl = () => ( + + + } + label={`ALL current and future ${entityName}s`} + /> + + ); + + const HelpText = () => ( +

    + Selecting {entityName}(s) here will filter events so that your addon + will only receive events that are tagged with one of your{' '} + {entityName}s. +

    + ); + + return ( + +

    {capitalize(entityName)}s

    + } + /> + {error} +
    + + } + /> + 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); + }} + /> + +
    + ); +}; diff --git a/frontend/src/component/addons/CreateAddon/CreateAddon.tsx b/frontend/src/component/addons/CreateAddon/CreateAddon.tsx index 6654e5a056..30548882f1 100644 --- a/frontend/src/component/addons/CreateAddon/CreateAddon.tsx +++ b/frontend/src/component/addons/CreateAddon/CreateAddon.tsx @@ -10,6 +10,8 @@ export const DEFAULT_DATA = { enabled: true, parameters: {}, events: [], + projects: [], + environments: [], } as unknown as IAddon; // TODO: improve type export const CreateAddon = () => { diff --git a/frontend/src/interfaces/addons.ts b/frontend/src/interfaces/addons.ts index 585ebcfba4..b0c1151f85 100644 --- a/frontend/src/interfaces/addons.ts +++ b/frontend/src/interfaces/addons.ts @@ -5,6 +5,8 @@ export interface IAddon { parameters: Record; id: number; events: string[]; + projects?: string[]; + environments?: string[]; enabled: boolean; description: string; } @@ -34,6 +36,8 @@ export interface IAddonConfig { parameters: Record; id: number; events: string[]; + projects?: string[]; + environments?: string[]; enabled: boolean; description: string; }