diff --git a/package.json b/package.json index c5115ecaa4..c9ed3da00c 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "@material-ui/lab": "4.0.0-alpha.61", "@testing-library/dom": "8.12.0", "@testing-library/jest-dom": "5.16.3", - "@testing-library/react": "13.0.0", + "@testing-library/react": "^12.1.4", "@testing-library/react-hooks": "^7.0.2", "@testing-library/user-event": "14.0.4", "@types/debounce": "1.2.1", diff --git a/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.styles.ts b/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.styles.ts index a4a89fac41..d771e686ac 100644 --- a/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.styles.ts +++ b/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.styles.ts @@ -43,4 +43,10 @@ export const useStyles = makeStyles(theme => ({ position: 'absolute', top: '-8px', }, + selectOptionsLink: { + cursor: 'pointer', + }, + selectOptionCheckbox: { + marginRight: '0.2rem', + }, })); diff --git a/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.tsx b/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.tsx index f62f70c493..369baf8308 100644 --- a/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.tsx +++ b/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.tsx @@ -6,54 +6,53 @@ import useProjects from 'hooks/api/getters/useProjects/useProjects'; import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect'; import Input from 'component/common/Input/Input'; import { useStyles } from './ApiTokenForm.styles'; +import { SelectProjectInput } from './SelectProjectInput/SelectProjectInput'; +import { ApiTokenFormErrorType } from '../hooks/useApiTokenForm'; interface IApiTokenFormProps { username: string; type: string; - project: string; + projects: string[]; environment?: string; setTokenType: (value: string) => void; setUsername: React.Dispatch>; - setProject: React.Dispatch>; + setProjects: React.Dispatch>; setEnvironment: React.Dispatch>; handleSubmit: (e: any) => void; handleCancel: () => void; errors: { [key: string]: string }; mode: 'Create' | 'Edit'; - clearErrors: () => void; + clearErrors: (error?: ApiTokenFormErrorType) => void; } + const ApiTokenForm: React.FC = ({ children, username, type, - project, + projects, environment, setUsername, setTokenType, - setProject, + setProjects, setEnvironment, handleSubmit, handleCancel, errors, clearErrors, - mode, }) => { const TYPE_ADMIN = 'ADMIN'; const styles = useStyles(); const { environments } = useEnvironments(); - const { projects } = useProjects(); + const { projects: availableProjects } = useProjects(); const selectableTypes = [ { key: 'CLIENT', label: 'Client', title: 'Client SDK token' }, { key: 'ADMIN', label: 'Admin', title: 'Admin API token' }, ]; - const selectableProjects = [{ id: '*', name: 'ALL' }, ...projects].map( - i => ({ - key: i.id, - label: i.name, - title: i.name, - }) - ); + const selectableProjects = availableProjects.map(i => ({ + value: i.id, + label: i.name, + })); const selectableEnvs = type === TYPE_ADMIN @@ -79,7 +78,7 @@ const ApiTokenForm: React.FC = ({ label="Username" error={errors.username !== undefined} errorText={errors.username} - onFocus={() => clearErrors()} + onFocus={() => clearErrors('username')} autoFocus />

@@ -93,21 +92,19 @@ const ApiTokenForm: React.FC = ({ id="api_key_type" name="type" IconComponent={KeyboardArrowDownOutlined} + fullWidth className={styles.selectInput} />

Which project do you want to give access to?

- setProject(e.target.value as string)} - label="Project" - id="api_key_project" - name="project" - IconComponent={KeyboardArrowDownOutlined} - className={styles.selectInput} + defaultValue={projects} + onChange={setProjects} + error={errors?.projects} + onFocus={() => clearErrors('projects')} />

Which environment should the token have access to? @@ -121,6 +118,7 @@ const ApiTokenForm: React.FC = ({ id="api_key_environment" name="environment" IconComponent={KeyboardArrowDownOutlined} + fullWidth className={styles.selectInput} /> diff --git a/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton.styles.ts b/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton.styles.ts new file mode 100644 index 0000000000..84685e2fe5 --- /dev/null +++ b/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton.styles.ts @@ -0,0 +1,7 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + selectOptionsLink: { + cursor: 'pointer', + }, +})); diff --git a/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton.tsx b/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton.tsx new file mode 100644 index 0000000000..6828f5d365 --- /dev/null +++ b/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton.tsx @@ -0,0 +1,23 @@ +import React, { FC } from 'react'; +import { Box, Link } from '@material-ui/core'; +import { useStyles } from './SelectAllButton.styles'; + +type SelectAllButtonProps = { + isAllSelected: boolean; + onClick: () => void; +}; + +export const SelectAllButton: FC = ({ + isAllSelected, + onClick, +}) => { + const styles = useStyles(); + + return ( + + + {isAllSelected ? 'Deselect all' : 'Select all'} + + + ); +}; diff --git a/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.styles.ts b/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.styles.ts new file mode 100644 index 0000000000..5e5fc582ef --- /dev/null +++ b/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.styles.ts @@ -0,0 +1,7 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + selectOptionCheckbox: { + marginRight: '0.2rem', + }, +})); diff --git a/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.tsx b/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.tsx new file mode 100644 index 0000000000..b7a0c53e06 --- /dev/null +++ b/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.tsx @@ -0,0 +1,148 @@ +import React, { Fragment, useState, ChangeEvent, VFC } from 'react'; +import { + Checkbox, + FormControlLabel, + TextField, + Box, + Paper, +} from '@material-ui/core'; +import { + Autocomplete, + AutocompleteRenderGroupParams, + AutocompleteRenderInputParams, + AutocompleteRenderOptionState, +} from '@material-ui/lab'; +import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'; +import CheckBoxIcon from '@material-ui/icons/CheckBox'; +import { IAutocompleteBoxOption } from 'component/common/AutocompleteBox/AutocompleteBox'; +import { useStyles } from '../ApiTokenForm.styles'; +import { SelectAllButton } from './SelectAllButton/SelectAllButton'; + +const ALL_PROJECTS = '*'; + +// Fix for shadow under Autocomplete - match with Select input +const CustomPaper = ({ ...props }) => ; + +interface ISelectProjectInputProps { + disabled?: boolean; + options: IAutocompleteBoxOption[]; + defaultValue: string[]; + onChange: (value: string[]) => void; + onFocus?: () => void; + error?: string; +} + +export const SelectProjectInput: VFC = ({ + options, + defaultValue = [ALL_PROJECTS], + onChange, + disabled, + error, + onFocus, +}) => { + const styles = useStyles(); + const [projects, setProjects] = useState( + typeof defaultValue === 'string' ? [defaultValue] : defaultValue + ); + const [isWildcardSelected, selectWildcard] = useState( + typeof defaultValue === 'string' || defaultValue.includes(ALL_PROJECTS) + ); + const isAllSelected = projects.length === options.length; + + const onAllProjectsChange = ( + e: ChangeEvent, + checked: boolean + ) => { + if (checked) { + selectWildcard(true); + onChange([ALL_PROJECTS]); + } else { + selectWildcard(false); + onChange(projects.includes(ALL_PROJECTS) ? [] : projects); + } + }; + + const renderOption = ( + option: IAutocompleteBoxOption, + { selected }: AutocompleteRenderOptionState + ) => ( + <> + } + checkedIcon={} + checked={selected} + className={styles.selectOptionCheckbox} + /> + {option.label} + + ); + + const renderGroup = ({ key, children }: AutocompleteRenderGroupParams) => ( + + { + setProjects( + isAllSelected ? [] : options.map(({ value }) => value) + ); + }} + /> + {children} + + ); + + const renderInput = (params: AutocompleteRenderInputParams) => ( + + ); + + return ( + + + + } + label="ALL current and future projects" + /> + + label} + groupBy={() => 'Select/Deselect all'} + renderGroup={renderGroup} + fullWidth + PaperComponent={CustomPaper} + renderOption={renderOption} + renderInput={renderInput} + value={ + isWildcardSelected || disabled + ? options + : options.filter(option => + projects.includes(option.value) + ) + } + onChange={(_, input) => { + const state = input.map(({ value }) => value); + setProjects(state); + onChange(state); + }} + /> + + ); +}; diff --git a/src/component/admin/apiToken/ApiTokenList/ApiTokenList.tsx b/src/component/admin/apiToken/ApiTokenList/ApiTokenList.tsx index df778cbc70..210cb64969 100644 --- a/src/component/admin/apiToken/ApiTokenList/ApiTokenList.tsx +++ b/src/component/admin/apiToken/ApiTokenList/ApiTokenList.tsx @@ -1,5 +1,5 @@ -import { useContext, useState } from 'react'; -import { Link, useHistory } from 'react-router-dom'; +import { Fragment, useContext, useState } from 'react'; +import { useHistory } from 'react-router-dom'; import { Button, IconButton, @@ -32,13 +32,15 @@ import { Alert } from '@material-ui/lab'; import copy from 'copy-to-clipboard'; import { useLocationSettings } from 'hooks/useLocationSettings'; import { formatDateYMD } from 'utils/formatDate'; +import { ProjectsList } from './ProjectsList/ProjectsList'; interface IApiToken { createdAt: Date; username: string; secret: string; type: string; - project: string; + project?: string; + projects?: string | string[]; environment: string; } @@ -89,14 +91,6 @@ export const ApiTokenList = () => { }); }; - const renderProject = (projectId: string) => { - if (!projectId || projectId === '*') { - return projectId; - } else { - return {projectId}; - } - }; - const renderApiTokens = (tokens: IApiToken[]) => { return ( @@ -118,7 +112,7 @@ export const ApiTokenList = () => { - Project + Projects { - {renderProject(item.project)} + {
Env: {item.environment}
- Project:{' '} - {renderProject(item.project)} + Projects:{' '} +
} diff --git a/src/component/admin/apiToken/ApiTokenList/ProjectsList/ProjectsList.test.tsx b/src/component/admin/apiToken/ApiTokenList/ProjectsList/ProjectsList.test.tsx new file mode 100644 index 0000000000..1e5eee6299 --- /dev/null +++ b/src/component/admin/apiToken/ApiTokenList/ProjectsList/ProjectsList.test.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { render } from 'utils/testRenderer'; +import { screen } from '@testing-library/react'; +import { ProjectsList } from './ProjectsList'; + +describe('ProjectsList', () => { + it('should prioritize new "projects" array over deprecated "project"', async () => { + render( + + ); + + const links = await screen.findAllByRole('link'); + expect(links).toHaveLength(2); + expect(links[0]).toHaveTextContent('project1'); + expect(links[1]).toHaveTextContent('project2'); + expect(links[0]).toHaveAttribute('href', '/projects/project1'); + expect(links[1]).toHaveAttribute('href', '/projects/project2'); + }); + + it('should render correctly with single "project"', async () => { + render(); + + const links = await screen.findAllByRole('link'); + expect(links).toHaveLength(1); + expect(links[0]).toHaveTextContent('project'); + }); + + it('should have comma between project links', async () => { + const { container } = render(); + + expect(container.textContent).toContain(', '); + }); + + it('should render asterisk if no projects are passed', async () => { + const { container } = render(); + + expect(container.textContent).toEqual('*'); + }); + + it('should render asterisk if empty projects array is passed', async () => { + const { container } = render(); + + expect(container.textContent).toEqual('*'); + }); +}); diff --git a/src/component/admin/apiToken/ApiTokenList/ProjectsList/ProjectsList.tsx b/src/component/admin/apiToken/ApiTokenList/ProjectsList/ProjectsList.tsx new file mode 100644 index 0000000000..c7d69d8a3f --- /dev/null +++ b/src/component/admin/apiToken/ApiTokenList/ProjectsList/ProjectsList.tsx @@ -0,0 +1,33 @@ +import React, { Fragment, VFC } from 'react'; +import { Link } from 'react-router-dom'; + +interface IProjectsListProps { + project?: string; + projects?: string | string[]; +} + +export const ProjectsList: VFC = ({ + projects, + project, +}) => { + let fields = projects && Array.isArray(projects) ? projects : [project]; + + if (fields.length === 0) { + return <>*; + } + + return ( + <> + {fields.map((item, index) => ( + + {index > 0 && ', '} + {!item || item === '*' ? ( + '*' + ) : ( + {item} + )} + + ))} + + ); +}; diff --git a/src/component/admin/apiToken/CreateApiToken/CreateApiToken.tsx b/src/component/admin/apiToken/CreateApiToken/CreateApiToken.tsx index 16da7b9df2..6c1318847f 100644 --- a/src/component/admin/apiToken/CreateApiToken/CreateApiToken.tsx +++ b/src/component/admin/apiToken/CreateApiToken/CreateApiToken.tsx @@ -23,11 +23,11 @@ export const CreateApiToken = () => { getApiTokenPayload, username, type, - project, + projects, environment, setUsername, setTokenType, - setProject, + setProjects, setEnvironment, isValid, errors, @@ -84,12 +84,12 @@ export const CreateApiToken = () => { { const { environments } = useEnvironments(); @@ -7,9 +10,11 @@ export const useApiTokenForm = () => { const [username, setUsername] = useState(''); const [type, setType] = useState('CLIENT'); - const [project, setProject] = useState('*'); + const [projects, setProjects] = useState(['*']); const [environment, setEnvironment] = useState(); - const [errors, setErrors] = useState({}); + const [errors, setErrors] = useState< + Partial> + >({}); useEffect(() => { setEnvironment(type === 'ADMIN' ? '*' : initialEnvironment); @@ -18,7 +23,7 @@ export const useApiTokenForm = () => { const setTokenType = (value: string) => { if (value === 'ADMIN') { setType(value); - setProject('*'); + setProjects(['*']); setEnvironment('*'); } else { setType(value); @@ -26,37 +31,44 @@ export const useApiTokenForm = () => { } }; - const getApiTokenPayload = () => { - return { - username: username, - type: type, - project: project, - environment: environment, - }; - }; + const getApiTokenPayload = (): IApiTokenCreate => ({ + username, + type, + environment, + projects, + }); const isValid = () => { + const newErrors: Partial> = {}; if (!username) { - setErrors({ username: 'Username is required.' }); - return false; - } else { - setErrors({}); - return true; + newErrors['username'] = 'Username is required'; + } + if (projects.length === 0) { + newErrors['projects'] = 'At least one project is required'; } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; }; - const clearErrors = () => { - setErrors({}); + const clearErrors = (error?: ApiTokenFormErrorType) => { + if (error) { + const newErrors = { ...errors }; + delete newErrors[error]; + setErrors(newErrors); + } else { + setErrors({}); + } }; return { username, type, - project, + projects, environment, setUsername, setTokenType, - setProject, + setProjects, setEnvironment, getApiTokenPayload, isValid, diff --git a/src/component/common/GeneralSelect/GeneralSelect.tsx b/src/component/common/GeneralSelect/GeneralSelect.tsx index 31e9cadf90..84e60fa4a3 100644 --- a/src/component/common/GeneralSelect/GeneralSelect.tsx +++ b/src/component/common/GeneralSelect/GeneralSelect.tsx @@ -17,17 +17,13 @@ export interface ISelectOption { } export interface ISelectMenuProps extends SelectProps { - name: string; - id: string; + name?: string; value?: string; label?: string; - autoFocus?: boolean; options: ISelectOption[]; - style?: object; onChange?: OnGeneralSelectChange; disabled?: boolean; fullWidth?: boolean; - className?: string; classes?: any; defaultValue?: string; } @@ -43,10 +39,8 @@ const GeneralSelect: React.FC = ({ label = '', options, onChange, - defaultValue, id, disabled = false, - autoFocus, className, classes, fullWidth, @@ -74,13 +68,11 @@ const GeneralSelect: React.FC = ({ > {label}