Skip to content
This repository has been archived by the owner on Oct 27, 2022. It is now read-only.

Commit

Permalink
feat: multi-project api access tokens (#857)
Browse files Browse the repository at this point in the history
* fix: general select component typings

* custom multi-select for projects

* autocomplete element for token projects

* project multi-select with error handling

* projects in tokens list update

* multi-project tokens - select all button

* fix conflicting typescript changes

* improve multi-projects tokens form after review

* refactor multi-project select code structure

* test api token list projects column element

* simplify test renderer
  • Loading branch information
Tymek authored Apr 8, 2022
1 parent 0eb2464 commit 1860369
Show file tree
Hide file tree
Showing 18 changed files with 494 additions and 200 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,10 @@ export const useStyles = makeStyles(theme => ({
position: 'absolute',
top: '-8px',
},
selectOptionsLink: {
cursor: 'pointer',
},
selectOptionCheckbox: {
marginRight: '0.2rem',
},
}));
44 changes: 21 additions & 23 deletions src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<React.SetStateAction<string>>;
setProject: React.Dispatch<React.SetStateAction<string>>;
setProjects: React.Dispatch<React.SetStateAction<string[]>>;
setEnvironment: React.Dispatch<React.SetStateAction<string | undefined>>;
handleSubmit: (e: any) => void;
handleCancel: () => void;
errors: { [key: string]: string };
mode: 'Create' | 'Edit';
clearErrors: () => void;
clearErrors: (error?: ApiTokenFormErrorType) => void;
}

const ApiTokenForm: React.FC<IApiTokenFormProps> = ({
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
Expand All @@ -79,7 +78,7 @@ const ApiTokenForm: React.FC<IApiTokenFormProps> = ({
label="Username"
error={errors.username !== undefined}
errorText={errors.username}
onFocus={() => clearErrors()}
onFocus={() => clearErrors('username')}
autoFocus
/>
<p className={styles.inputDescription}>
Expand All @@ -93,21 +92,19 @@ const ApiTokenForm: React.FC<IApiTokenFormProps> = ({
id="api_key_type"
name="type"
IconComponent={KeyboardArrowDownOutlined}
fullWidth
className={styles.selectInput}
/>
<p className={styles.inputDescription}>
Which project do you want to give access to?
</p>
<GeneralSelect
<SelectProjectInput
disabled={type === TYPE_ADMIN}
value={project}
options={selectableProjects}
onChange={e => 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')}
/>
<p className={styles.inputDescription}>
Which environment should the token have access to?
Expand All @@ -121,6 +118,7 @@ const ApiTokenForm: React.FC<IApiTokenFormProps> = ({
id="api_key_environment"
name="environment"
IconComponent={KeyboardArrowDownOutlined}
fullWidth
className={styles.selectInput}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { makeStyles } from '@material-ui/core/styles';

export const useStyles = makeStyles(theme => ({
selectOptionsLink: {
cursor: 'pointer',
},
}));
Original file line number Diff line number Diff line change
@@ -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<SelectAllButtonProps> = ({
isAllSelected,
onClick,
}) => {
const styles = useStyles();

return (
<Box sx={{ ml: 3.5, my: 0.5 }}>
<Link onClick={onClick} className={styles.selectOptionsLink}>
{isAllSelected ? 'Deselect all' : 'Select all'}
</Link>
</Box>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { makeStyles } from '@material-ui/core/styles';

export const useStyles = makeStyles(theme => ({
selectOptionCheckbox: {
marginRight: '0.2rem',
},
}));
Original file line number Diff line number Diff line change
@@ -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 }) => <Paper elevation={8} {...props} />;

interface ISelectProjectInputProps {
disabled?: boolean;
options: IAutocompleteBoxOption[];
defaultValue: string[];
onChange: (value: string[]) => void;
onFocus?: () => void;
error?: string;
}

export const SelectProjectInput: VFC<ISelectProjectInputProps> = ({
options,
defaultValue = [ALL_PROJECTS],
onChange,
disabled,
error,
onFocus,
}) => {
const styles = useStyles();
const [projects, setProjects] = useState<string[]>(
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<HTMLInputElement>,
checked: boolean
) => {
if (checked) {
selectWildcard(true);
onChange([ALL_PROJECTS]);
} else {
selectWildcard(false);
onChange(projects.includes(ALL_PROJECTS) ? [] : projects);
}
};

const renderOption = (
option: IAutocompleteBoxOption,
{ selected }: AutocompleteRenderOptionState
) => (
<>
<Checkbox
icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
checkedIcon={<CheckBoxIcon fontSize="small" />}
checked={selected}
className={styles.selectOptionCheckbox}
/>
{option.label}
</>
);

const renderGroup = ({ key, children }: AutocompleteRenderGroupParams) => (
<Fragment key={key}>
<SelectAllButton
isAllSelected={isAllSelected}
onClick={() => {
setProjects(
isAllSelected ? [] : options.map(({ value }) => value)
);
}}
/>
{children}
</Fragment>
);

const renderInput = (params: AutocompleteRenderInputParams) => (
<TextField
{...params}
error={!!error}
helperText={error}
variant="outlined"
label="Projects"
placeholder="Select one or more projects"
onFocus={onFocus}
/>
);

return (
<Box sx={{ mt: -1, mb: 3 }}>
<Box sx={{ mt: 1, mb: 0.25, ml: 1.5 }}>
<FormControlLabel
disabled={disabled}
control={
<Checkbox
checked={disabled || isWildcardSelected}
onChange={onAllProjectsChange}
/>
}
label="ALL current and future projects"
/>
</Box>
<Autocomplete
disabled={disabled || isWildcardSelected}
multiple
limitTags={2}
options={options}
disableCloseOnSelect
getOptionLabel={({ label }) => 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);
}}
/>
</Box>
);
};
30 changes: 15 additions & 15 deletions src/component/admin/apiToken/ApiTokenList/ApiTokenList.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -89,14 +91,6 @@ export const ApiTokenList = () => {
});
};

const renderProject = (projectId: string) => {
if (!projectId || projectId === '*') {
return projectId;
} else {
return <Link to={`/projects/${projectId}`}>{projectId}</Link>;
}
};

const renderApiTokens = (tokens: IApiToken[]) => {
return (
<Table size="small">
Expand All @@ -118,7 +112,7 @@ export const ApiTokenList = () => {
<TableCell
className={`${styles.center} ${styles.hideXS}`}
>
Project
Projects
</TableCell>
<TableCell
className={`${styles.center} ${styles.hideXS}`}
Expand Down Expand Up @@ -169,7 +163,10 @@ export const ApiTokenList = () => {
<TableCell
className={`${styles.center} ${styles.hideXS}`}
>
{renderProject(item.project)}
<ProjectsList
project={item.project}
projects={item.projects}
/>
</TableCell>
<TableCell
className={`${styles.center} ${styles.hideXS}`}
Expand All @@ -181,8 +178,11 @@ export const ApiTokenList = () => {
<br />
<b>Env:</b> {item.environment}
<br />
<b>Project:</b>{' '}
{renderProject(item.project)}
<b>Projects:</b>{' '}
<ProjectsList
project={item.project}
projects={item.projects}
/>
</TableCell>
</>
}
Expand Down
Loading

0 comments on commit 1860369

Please sign in to comment.