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:
parent
8d6084de45
commit
d2324ee91f
@ -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();
|
||||
|
@ -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();
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
},
|
||||
}));
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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 />
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
@ -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();
|
||||
|
||||
|
1
frontend/src/constants/values.ts
Normal file
1
frontend/src/constants/values.ts
Normal file
@ -0,0 +1 @@
|
||||
export const ENV_LIMIT = 15;
|
@ -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,
|
||||
|
@ -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[];
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user