1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-04 00:18:40 +01:00

Feat clone environment modal (#2245)

* add clone environment modal base skeleton (WIP)

* refactor HelpIcon common component, fix group form

* add more fields to clone env modal, multi project selector

* implement initial payload signature

* reflect latest design decisions

* misc ui fixes

* update UI to the new designs, change back clone option to use flag

* set env limit to 15

* Update frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentCloneModal/EnvironmentCloneModal.tsx

Co-authored-by: Simon Hornby <liquidwicked64@gmail.com>

* Update frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentCloneModal/EnvironmentCloneModal.tsx

Co-authored-by: Simon Hornby <liquidwicked64@gmail.com>

* Update frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentCloneModal/EnvironmentCloneModal.tsx

Co-authored-by: Simon Hornby <liquidwicked64@gmail.com>

* Update frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentCloneModal/EnvironmentCloneModal.tsx

Co-authored-by: Simon Hornby <liquidwicked64@gmail.com>

* address PR comments

Co-authored-by: Simon Hornby <liquidwicked64@gmail.com>
This commit is contained in:
Nuno Góis 2022-10-28 09:15:46 +01:00 committed by GitHub
parent 8d6084de45
commit d2324ee91f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 683 additions and 60 deletions

View File

@ -69,10 +69,10 @@ export const CreateGroup = () => {
navigate(GO_BACK);
};
const isNameEmpty = (name: string) => name.length;
const isNameNotEmpty = (name: string) => name.length;
const isNameUnique = (name: string) =>
!groups?.filter(group => group.name === name).length;
const isValid = isNameEmpty(name) && isNameUnique(name);
const isValid = isNameNotEmpty(name) && isNameUnique(name);
const onSetName = (name: string) => {
clearErrors();

View File

@ -77,11 +77,11 @@ export const EditGroup = () => {
navigate(GO_BACK);
};
const isNameEmpty = (name: string) => name.length;
const isNameNotEmpty = (name: string) => name.length;
const isNameUnique = (name: string) =>
!groups?.filter(group => group.name === name && group.id !== groupId)
.length;
const isValid = isNameEmpty(name) && isNameUnique(name);
const isValid = isNameNotEmpty(name) && isNameUnique(name);
const onSetName = (name: string) => {
clearErrors();

View File

@ -1,5 +1,5 @@
import React, { FC } from 'react';
import { Button, styled, Tooltip } from '@mui/material';
import { Box, Button, styled } from '@mui/material';
import { UG_DESC_ID, UG_NAME_ID } from 'utils/testIds';
import Input from 'component/common/Input/Input';
import { IGroupUser } from 'interfaces/group';
@ -9,8 +9,8 @@ import { GroupFormUsersTable } from './GroupFormUsersTable/GroupFormUsersTable';
import { ItemList } from 'component/common/ItemList/ItemList';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import useAuthSettings from 'hooks/api/getters/useAuthSettings/useAuthSettings';
import { HelpOutline } from '@mui/icons-material';
import { Link } from 'react-router-dom';
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
const StyledForm = styled('form')(() => ({
display: 'flex',
@ -59,12 +59,6 @@ const StyledDescriptionBlock = styled('div')(({ theme }) => ({
borderRadius: theme.shape.borderRadiusMedium,
}));
const StyledHelpOutline = styled(HelpOutline)(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
marginLeft: '0.3rem',
color: theme.palette.grey[700],
}));
interface IGroupForm {
name: string;
description: string;
@ -155,17 +149,11 @@ export const GroupForm: FC<IGroupForm> = ({
}
elseShow={() => (
<StyledDescriptionBlock>
<div>
<Box sx={{ display: 'flex' }}>
You can enable SSO groups syncronization
if needed
<Tooltip
title="You can enable SSO groups
syncronization if needed"
arrow
>
<StyledHelpOutline />
</Tooltip>
</div>
<HelpIcon tooltip="SSO groups syncronization allows SSO groups to be mapped to Unleash groups, so that user group membership is properly synchronized." />
</Box>
<Link data-loading to={`/admin/auth`}>
<span data-loading>
View SSO configuration

View File

@ -1,22 +0,0 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
container: {
display: 'inline-grid',
alignItems: 'center',
outline: 0,
'&:is(:focus-visible, :active) > *, &:hover > *': {
outlineStyle: 'solid',
outlineWidth: 2,
outlineOffset: 0,
outlineColor: theme.palette.primary.main,
borderRadius: '100%',
color: theme.palette.primary.main,
},
},
icon: {
fontSize: '1rem',
color: theme.palette.inactiveIcon,
},
}));

View File

@ -1,21 +1,38 @@
import { Tooltip, TooltipProps } from '@mui/material';
import { Info } from '@mui/icons-material';
import { useStyles } from 'component/common/HelpIcon/HelpIcon.styles';
import React from 'react';
import { styled, Tooltip, TooltipProps } from '@mui/material';
import { HelpOutline } from '@mui/icons-material';
const StyledContainer = styled('span')(({ theme }) => ({
display: 'inline-grid',
alignItems: 'center',
outline: 0,
cursor: 'pointer',
'&:is(:focus-visible, :active) > *, &:hover > *': {
outlineStyle: 'solid',
outlineWidth: 2,
outlineOffset: 0,
outlineColor: theme.palette.primary.main,
borderRadius: '100%',
color: theme.palette.primary.main,
},
'& svg': {
fontSize: theme.fontSizes.mainHeader,
color: theme.palette.neutral.main,
marginLeft: theme.spacing(0.5),
},
}));
interface IHelpIconProps {
tooltip: string;
placement?: TooltipProps['placement'];
children?: React.ReactNode;
}
export const HelpIcon = ({ tooltip, placement }: IHelpIconProps) => {
const { classes: styles } = useStyles();
export const HelpIcon = ({ tooltip, placement, children }: IHelpIconProps) => {
return (
<Tooltip title={tooltip} placement={placement} arrow>
<span className={styles.container} tabIndex={0} aria-label="Help">
<Info className={styles.icon} />
</span>
<StyledContainer tabIndex={0} aria-label="Help">
{children ?? <HelpOutline />}
</StyledContainer>
</Tooltip>
);
};

View File

@ -16,13 +16,14 @@ import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { formatUnknownError } from 'utils/formatUnknownError';
import { GO_BACK } from 'constants/navigate';
import { ENV_LIMIT } from 'constants/values';
const CreateEnvironment = () => {
const { setToastApiError, setToastData } = useToast();
const { uiConfig } = useUiConfig();
const navigate = useNavigate();
const { environments } = useEnvironments();
const canCreateMoreEnvs = environments.length < 7;
const canCreateMoreEnvs = environments.length < ENV_LIMIT;
const { createEnvironment, loading } = useEnvironmentApi();
const { refetch } = useProjectRolePermissions();
const {
@ -114,8 +115,9 @@ const CreateEnvironment = () => {
>
<Alert severity="error">
<p>
Currently Unleash does not support more than 7
environments. If you need more please reach out.
Currently Unleash does not support more than{' '}
{ENV_LIMIT} environments. If you need more
please reach out.
</p>
</Alert>
<br />

View File

@ -12,6 +12,10 @@ import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironmen
import useToast from 'hooks/useToast';
import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch';
import { EnvironmentActionCellPopover } from './EnvironmentActionCellPopover/EnvironmentActionCellPopover';
import { EnvironmentCloneModal } from './EnvironmentCloneModal/EnvironmentCloneModal';
import { IApiToken } from 'hooks/api/getters/useApiTokens/useApiTokens';
import { EnvironmentTokenDialog } from './EnvironmentTokenDialog/EnvironmentTokenDialog';
import { ENV_LIMIT } from 'constants/values';
interface IEnvironmentTableActionsProps {
environment: IEnvironment;
@ -22,13 +26,16 @@ export const EnvironmentActionCell = ({
}: IEnvironmentTableActionsProps) => {
const navigate = useNavigate();
const { setToastApiError, setToastData } = useToast();
const { refetchEnvironments } = useEnvironments();
const { environments, refetchEnvironments } = useEnvironments();
const { refetch: refetchPermissions } = useProjectRolePermissions();
const { deleteEnvironment, toggleEnvironmentOn, toggleEnvironmentOff } =
useEnvironmentApi();
const [deleteModal, setDeleteModal] = useState(false);
const [toggleModal, setToggleModal] = useState(false);
const [cloneModal, setCloneModal] = useState(false);
const [tokenModal, setTokenModal] = useState(false);
const [newToken, setNewToken] = useState<IApiToken>();
const [confirmName, setConfirmName] = useState('');
const handleDeleteEnvironment = async () => {
@ -102,7 +109,17 @@ export const EnvironmentActionCell = ({
<EnvironmentActionCellPopover
environment={environment}
onEdit={() => navigate(`/environments/${environment.name}`)}
onClone={() => console.log('TODO: CLONE')}
onClone={() => {
if (environments.length < ENV_LIMIT) {
setCloneModal(true);
} else {
setToastData({
type: 'error',
title: 'Environment limit reached',
text: `You have reached the maximum number of environments (${ENV_LIMIT}). Please reach out if you need more.`,
});
}
}}
onDelete={() => setDeleteModal(true)}
/>
<EnvironmentDeleteConfirm
@ -119,6 +136,20 @@ export const EnvironmentActionCell = ({
setToggleDialog={setToggleModal}
handleConfirmToggleEnvironment={handleConfirmToggleEnvironment}
/>
<EnvironmentCloneModal
environment={environment}
open={cloneModal}
setOpen={setCloneModal}
newToken={(token: IApiToken) => {
setNewToken(token);
setTokenModal(true);
}}
/>
<EnvironmentTokenDialog
open={tokenModal}
setOpen={setTokenModal}
token={newToken}
/>
</ActionCell>
);
};

View File

@ -0,0 +1,385 @@
import {
Button,
FormControl,
FormControlLabel,
Link,
Radio,
RadioGroup,
styled,
Switch,
} from '@mui/material';
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import useToast from 'hooks/useToast';
import { FormEvent, useEffect, useState } from 'react';
import { formatUnknownError } from 'utils/formatUnknownError';
import Input from 'component/common/Input/Input';
import {
IEnvironment,
IEnvironmentClonePayload,
} from 'interfaces/environments';
import useEnvironmentApi from 'hooks/api/actions/useEnvironmentApi/useEnvironmentApi';
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
import EnvironmentTypeSelector from 'component/environments/EnvironmentForm/EnvironmentTypeSelector/EnvironmentTypeSelector';
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
import { EnvironmentProjectSelect } from './EnvironmentProjectSelect/EnvironmentProjectSelect';
import { SelectProjectInput } from 'component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput';
import useProjects from 'hooks/api/getters/useProjects/useProjects';
import useApiTokensApi, {
IApiTokenCreate,
} from 'hooks/api/actions/useApiTokensApi/useApiTokensApi';
import { IApiToken } from 'hooks/api/getters/useApiTokens/useApiTokens';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
const StyledForm = styled('form')(() => ({
display: 'flex',
flexDirection: 'column',
height: '100%',
}));
const StyledInputDescription = styled('p')(({ theme }) => ({
display: 'flex',
color: theme.palette.text.primary,
marginBottom: theme.spacing(1),
'&:not(:first-of-type)': {
marginTop: theme.spacing(4),
},
}));
const StyledInputSecondaryDescription = styled('p')(({ theme }) => ({
color: theme.palette.text.secondary,
marginBottom: theme.spacing(1),
}));
const StyledInput = styled(Input)(({ theme }) => ({
width: '100%',
maxWidth: theme.spacing(50),
}));
const StyledSecondaryContainer = styled('div')(({ theme }) => ({
padding: theme.spacing(3),
backgroundColor: theme.palette.secondaryContainer,
borderRadius: theme.shape.borderRadiusMedium,
marginTop: theme.spacing(4),
}));
const StyledInlineContainer = styled('div')(({ theme }) => ({
padding: theme.spacing(0, 4),
'& > p:not(:first-of-type)': {
marginTop: theme.spacing(2),
},
}));
const StyledButtonContainer = styled('div')(({ theme }) => ({
marginTop: 'auto',
display: 'flex',
justifyContent: 'flex-end',
[theme.breakpoints.down('sm')]: {
marginTop: theme.spacing(4),
},
}));
const StyledCancelButton = styled(Button)(({ theme }) => ({
marginLeft: theme.spacing(3),
}));
enum APITokenGeneration {
LATER = 'later',
NOW = 'now',
}
enum ErrorField {
NAME = 'name',
PROJECTS = 'projects',
}
interface ICreatePersonalAPITokenErrors {
[ErrorField.NAME]?: string;
[ErrorField.PROJECTS]?: string;
}
interface ICreatePersonalAPITokenProps {
environment: IEnvironment;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
newToken: (token: IApiToken) => void;
}
export const EnvironmentCloneModal = ({
environment,
open,
setOpen,
newToken,
}: ICreatePersonalAPITokenProps) => {
const { environments, refetchEnvironments } = useEnvironments();
const { cloneEnvironment, loading } = useEnvironmentApi();
const { createToken } = useApiTokensApi();
const { projects: allProjects } = useProjects();
const { setToastData, setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
const [name, setName] = useState(`${environment.name}_clone`);
const [type, setType] = useState('development');
const [projects, setProjects] = useState<string[]>([]);
const [tokenProjects, setTokenProjects] = useState<string[]>(['*']);
const [clonePermissions, setClonePermissions] = useState(true);
const [apiTokenGeneration, setApiTokenGeneration] =
useState<APITokenGeneration>(APITokenGeneration.LATER);
const [errors, setErrors] = useState<ICreatePersonalAPITokenErrors>({});
const clearError = (field: ErrorField) => {
setErrors(errors => ({ ...errors, [field]: undefined }));
};
const setError = (field: ErrorField, error: string) => {
setErrors(errors => ({ ...errors, [field]: error }));
};
useEffect(() => {
setName(getUniqueName(environment.name));
setType('development');
setProjects([]);
setTokenProjects(['*']);
setClonePermissions(true);
setErrors({});
}, [environment]);
const getUniqueName = (name: string) => {
let uniqueName = `${name}_clone`;
let number = 2;
while (!isNameUnique(uniqueName)) {
uniqueName = `${environment.name}_clone_${number}`;
number++;
}
return uniqueName;
};
const getCloneEnvironmentPayload = (): IEnvironmentClonePayload => ({
name,
type,
projects,
clonePermissions,
});
const getApiTokenCreatePayload = (): IApiTokenCreate => ({
username: `${name}_token`,
type: 'CLIENT',
environment: name,
projects: tokenProjects,
});
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
try {
await cloneEnvironment(
environment.name,
getCloneEnvironmentPayload()
);
const response = await createToken(getApiTokenCreatePayload());
if (apiTokenGeneration === APITokenGeneration.NOW) {
const token = await response.json();
newToken(token);
}
setToastData({
title: 'Environment successfully cloned!',
type: 'success',
});
refetchEnvironments();
setOpen(false);
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
const formatApiCode = () => {
return `curl --location --request POST '${
uiConfig.unleashUrl
}/api/admin/environments/${environment.name}/clone' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(getCloneEnvironmentPayload(), undefined, 2)}'`;
};
const isNameNotEmpty = (name: string) => name.length;
const isNameUnique = (name: string) =>
!environments?.some(environment => environment.name === name);
const isValid =
isNameNotEmpty(name) && isNameUnique(name) && tokenProjects.length;
const onSetName = (name: string) => {
clearError(ErrorField.NAME);
if (!isNameUnique(name)) {
setError(
ErrorField.NAME,
'An environment with that name already exists.'
);
}
setName(name);
};
const selectableProjects = allProjects.map(project => ({
value: project.id,
label: project.name,
}));
return (
<SidebarModal
open={open}
onClose={() => {
setOpen(false);
}}
label={`Clone ${environment.name} environment`}
>
<FormTemplate
loading={loading}
modal
title={`Clone ${environment.name} environment`}
description="Cloning an environment will clone all feature toggles and their configuration (activation strategies, segments, status, etc) into a new environment."
documentationLink="https://docs.getunleash.io/user_guide/environments#cloning-environments"
documentationLinkLabel="Cloning environments documentation"
formatApiCode={formatApiCode}
>
<StyledForm onSubmit={handleSubmit}>
<div>
<StyledInputDescription>
What is your new environment name? (Can't be changed
later)
</StyledInputDescription>
<StyledInput
autoFocus
label="Environment name"
error={Boolean(errors.name)}
errorText={errors.name}
value={name}
onChange={e => onSetName(e.target.value)}
required
/>
<StyledInputDescription>
What type of environment do you want to create?
</StyledInputDescription>
<EnvironmentTypeSelector
onChange={e => setType(e.currentTarget.value)}
value={type}
/>
<StyledInputDescription>
Select which projects you want to clone the
environment configuration in?
<HelpIcon tooltip="The cloned environment will keep the feature toggle state for the selected projects, where it will be enabled by default." />
</StyledInputDescription>
<EnvironmentProjectSelect
projects={projects}
setProjects={setProjects}
/>
<StyledInputDescription>
Keep the users permission for this environment?
</StyledInputDescription>
<StyledInputSecondaryDescription>
If you turn it off, the permission for this
environment across all projects and feature toggles
will remain only for admin and editor roles.
</StyledInputSecondaryDescription>
<FormControlLabel
label={clonePermissions ? 'Yes' : 'No'}
control={
<Switch
onChange={e =>
setClonePermissions(e.target.checked)
}
checked={clonePermissions}
/>
}
/>
<StyledSecondaryContainer>
<StyledInputDescription>
API Token
</StyledInputDescription>
<StyledInputSecondaryDescription>
In order to connect your SDKs to your newly
cloned environment, you will also need an API
token.{' '}
<Link
href="https://docs.getunleash.io/reference/api-tokens-and-client-keys"
target="_blank"
>
Read more about API tokens
</Link>
.
</StyledInputSecondaryDescription>
<FormControl>
<RadioGroup
value={apiTokenGeneration}
onChange={e =>
setApiTokenGeneration(
e.target.value as APITokenGeneration
)
}
name="api-token-generation"
>
<FormControlLabel
value={APITokenGeneration.LATER}
control={<Radio />}
label="Generate an API token later"
/>
<FormControlLabel
value={APITokenGeneration.NOW}
control={<Radio />}
label="Generate an API token now"
/>
</RadioGroup>
</FormControl>
<ConditionallyRender
condition={
apiTokenGeneration ===
APITokenGeneration.NOW
}
show={
<StyledInlineContainer>
<StyledInputSecondaryDescription>
A new Server-side SDK (CLIENT) API
token will be generated for the
cloned environment, so you can get
started right away.
</StyledInputSecondaryDescription>
<StyledInputDescription>
Which projects do you want this
token to give access to?
</StyledInputDescription>
<SelectProjectInput
options={selectableProjects}
defaultValue={tokenProjects}
onChange={setTokenProjects}
error={errors.projects}
onFocus={() =>
clearError(ErrorField.PROJECTS)
}
/>
</StyledInlineContainer>
}
/>
</StyledSecondaryContainer>
</div>
<StyledButtonContainer>
<Button
type="submit"
variant="contained"
color="primary"
disabled={!isValid}
>
Clone environment
</Button>
<StyledCancelButton
onClick={() => {
setOpen(false);
}}
>
Cancel
</StyledCancelButton>
</StyledButtonContainer>
</StyledForm>
</FormTemplate>
</SidebarModal>
);
};

View File

@ -0,0 +1,161 @@
import {
Autocomplete,
AutocompleteRenderGroupParams,
Checkbox,
styled,
TextField,
} from '@mui/material';
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
import CheckBoxIcon from '@mui/icons-material/CheckBox';
import { caseInsensitiveSearch } from 'utils/search';
import useProjects from 'hooks/api/getters/useProjects/useProjects';
import { Fragment } from 'react';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { SelectAllButton } from 'component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton';
const StyledOption = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
color: theme.palette.text.secondary,
'& > span:first-of-type': {
color: theme.palette.text.primary,
},
}));
const StyledTags = styled('div')(({ theme }) => ({
paddingLeft: theme.spacing(1),
}));
const StyledGroupFormUsersSelect = styled('div')(({ theme }) => ({
display: 'flex',
marginBottom: theme.spacing(3),
'& > div:first-of-type': {
width: '100%',
maxWidth: theme.spacing(50),
marginRight: theme.spacing(1),
},
}));
const renderOption = (
props: React.HTMLAttributes<HTMLLIElement>,
option: IProjectBase,
selected: boolean
) => (
<li {...props}>
<Checkbox
icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
checkedIcon={<CheckBoxIcon fontSize="small" />}
style={{ marginRight: 8 }}
checked={selected}
/>
<StyledOption>
<span>{option.name}</span>
<span>{option.description}</span>
</StyledOption>
</li>
);
const renderTags = (value: IProjectBase[]) => (
<StyledTags>
{value.length > 1 ? `${value.length} projects selected` : value[0].name}
</StyledTags>
);
interface IProjectBase {
id: string;
name: string;
description: string;
}
interface IEnvironmentProjectSelectProps {
projects: string[];
setProjects: React.Dispatch<React.SetStateAction<string[]>>;
}
export const EnvironmentProjectSelect = ({
projects,
setProjects,
}: IEnvironmentProjectSelectProps) => {
const { projects: projectsAll } = useProjects();
const projectOptions = projectsAll
.sort((a, b) => a.name.localeCompare(b.name))
.map(({ id, name, description }) => ({
id,
name,
description,
})) as IProjectBase[];
const selectedProjects = projectOptions.filter(({ id }) =>
projects.includes(id)
);
const isAllSelected =
projects.length > 0 && projects.length === projectOptions.length;
const onSelectAllClick = () => {
const newProjects = isAllSelected
? []
: projectOptions.map(({ id }) => id);
setProjects(newProjects);
};
const renderGroup = ({ key, children }: AutocompleteRenderGroupParams) => (
<Fragment key={key}>
<ConditionallyRender
condition={projectOptions.length > 2}
show={
<SelectAllButton
isAllSelected={isAllSelected}
onClick={onSelectAllClick}
/>
}
/>
{children}
</Fragment>
);
return (
<StyledGroupFormUsersSelect>
<Autocomplete
size="small"
multiple
limitTags={1}
openOnFocus
disableCloseOnSelect
value={selectedProjects}
onChange={(event, newValue, reason) => {
if (
event.type === 'keydown' &&
(event as React.KeyboardEvent).key === 'Backspace' &&
reason === 'removeOption'
) {
return;
}
setProjects(newValue.map(({ id }) => id));
}}
options={projectOptions}
renderOption={(props, option, { selected }) =>
renderOption(props, option, selected)
}
filterOptions={(options, { inputValue }) =>
options.filter(
({ name, description }) =>
caseInsensitiveSearch(inputValue, name) ||
caseInsensitiveSearch(inputValue, description)
)
}
isOptionEqualToValue={(option, value) => option.id === value.id}
getOptionLabel={option =>
option.name || option.description || ''
}
renderInput={params => (
<TextField {...params} label="Projects" />
)}
renderTags={value => renderTags(value)}
groupBy={() => 'Select/Deselect all'}
renderGroup={renderGroup}
/>
</StyledGroupFormUsersSelect>
);
};

View File

@ -0,0 +1,37 @@
import { Typography } from '@mui/material';
import { UserToken } from 'component/admin/apiToken/ConfirmToken/UserToken/UserToken';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { IApiToken } from 'hooks/api/getters/useApiTokens/useApiTokens';
import { Link } from 'react-router-dom';
interface IEnvironmentTokenDialogProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
token?: IApiToken;
}
export const EnvironmentTokenDialog = ({
open,
setOpen,
token,
}: IEnvironmentTokenDialogProps) => (
<Dialogue
open={open}
secondaryButtonText="Close"
onClose={(_, muiCloseReason?: string) => {
if (!muiCloseReason) {
setOpen(false);
}
}}
title="New API token created"
>
<Typography variant="body1">
Your new token has been created successfully.
</Typography>
<Typography variant="body1">
You can also find it as "<strong>{token?.username}</strong>" in the{' '}
<Link to="/admin/api">API access page</Link>.
</Typography>
<UserToken token={token?.secret || ''} />
</Dialogue>
);

View File

@ -212,11 +212,11 @@ export const CreatePersonalAPIToken: FC<ICreatePersonalAPITokenProps> = ({
--data-raw '${JSON.stringify(getPersonalAPITokenPayload(), undefined, 2)}'`;
};
const isDescriptionEmpty = (description: string) => description.length;
const isDescriptionNotEmpty = (description: string) => description.length;
const isDescriptionUnique = (description: string) =>
!tokens?.some(token => token.description === description);
const isValid =
isDescriptionEmpty(description) &&
isDescriptionNotEmpty(description) &&
isDescriptionUnique(description) &&
expiresAt > new Date();

View File

@ -0,0 +1 @@
export const ENV_LIMIT = 15;

View File

@ -3,6 +3,7 @@ import {
ISortOrderPayload,
IEnvironmentEditPayload,
IEnvironment,
IEnvironmentClonePayload,
} from 'interfaces/environments';
import useAPI from '../useApi/useApi';
@ -82,6 +83,20 @@ const useEnvironmentApi = () => {
}
};
const cloneEnvironment = async (
name: string,
payload: IEnvironmentClonePayload
) => {
const path = `api/admin/environments/${name}/clone`;
const req = createRequest(
path,
{ method: 'POST', body: JSON.stringify(payload) },
'cloneEnvironment'
);
return await makeRequest(req.caller, req.id);
};
const changeSortOrder = async (payload: ISortOrderPayload) => {
const path = `api/admin/environments/sort-order`;
const req = createRequest(
@ -140,6 +155,7 @@ const useEnvironmentApi = () => {
loading,
deleteEnvironment,
updateEnvironment,
cloneEnvironment,
changeSortOrder,
toggleEnvironmentOff,
toggleEnvironmentOn,

View File

@ -22,6 +22,13 @@ export interface IEnvironmentEditPayload {
type: string;
}
export interface IEnvironmentClonePayload {
name: string;
type: string;
projects: string[];
clonePermissions: boolean;
}
export interface IEnvironmentResponse {
environments: IEnvironment[];
}