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);
|
navigate(GO_BACK);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isNameEmpty = (name: string) => name.length;
|
const isNameNotEmpty = (name: string) => name.length;
|
||||||
const isNameUnique = (name: string) =>
|
const isNameUnique = (name: string) =>
|
||||||
!groups?.filter(group => group.name === name).length;
|
!groups?.filter(group => group.name === name).length;
|
||||||
const isValid = isNameEmpty(name) && isNameUnique(name);
|
const isValid = isNameNotEmpty(name) && isNameUnique(name);
|
||||||
|
|
||||||
const onSetName = (name: string) => {
|
const onSetName = (name: string) => {
|
||||||
clearErrors();
|
clearErrors();
|
||||||
|
@ -77,11 +77,11 @@ export const EditGroup = () => {
|
|||||||
navigate(GO_BACK);
|
navigate(GO_BACK);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isNameEmpty = (name: string) => name.length;
|
const isNameNotEmpty = (name: string) => name.length;
|
||||||
const isNameUnique = (name: string) =>
|
const isNameUnique = (name: string) =>
|
||||||
!groups?.filter(group => group.name === name && group.id !== groupId)
|
!groups?.filter(group => group.name === name && group.id !== groupId)
|
||||||
.length;
|
.length;
|
||||||
const isValid = isNameEmpty(name) && isNameUnique(name);
|
const isValid = isNameNotEmpty(name) && isNameUnique(name);
|
||||||
|
|
||||||
const onSetName = (name: string) => {
|
const onSetName = (name: string) => {
|
||||||
clearErrors();
|
clearErrors();
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { FC } from 'react';
|
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 { UG_DESC_ID, UG_NAME_ID } from 'utils/testIds';
|
||||||
import Input from 'component/common/Input/Input';
|
import Input from 'component/common/Input/Input';
|
||||||
import { IGroupUser } from 'interfaces/group';
|
import { IGroupUser } from 'interfaces/group';
|
||||||
@ -9,8 +9,8 @@ import { GroupFormUsersTable } from './GroupFormUsersTable/GroupFormUsersTable';
|
|||||||
import { ItemList } from 'component/common/ItemList/ItemList';
|
import { ItemList } from 'component/common/ItemList/ItemList';
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
import useAuthSettings from 'hooks/api/getters/useAuthSettings/useAuthSettings';
|
import useAuthSettings from 'hooks/api/getters/useAuthSettings/useAuthSettings';
|
||||||
import { HelpOutline } from '@mui/icons-material';
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
|
||||||
|
|
||||||
const StyledForm = styled('form')(() => ({
|
const StyledForm = styled('form')(() => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -59,12 +59,6 @@ const StyledDescriptionBlock = styled('div')(({ theme }) => ({
|
|||||||
borderRadius: theme.shape.borderRadiusMedium,
|
borderRadius: theme.shape.borderRadiusMedium,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const StyledHelpOutline = styled(HelpOutline)(({ theme }) => ({
|
|
||||||
fontSize: theme.fontSizes.smallBody,
|
|
||||||
marginLeft: '0.3rem',
|
|
||||||
color: theme.palette.grey[700],
|
|
||||||
}));
|
|
||||||
|
|
||||||
interface IGroupForm {
|
interface IGroupForm {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@ -155,17 +149,11 @@ export const GroupForm: FC<IGroupForm> = ({
|
|||||||
}
|
}
|
||||||
elseShow={() => (
|
elseShow={() => (
|
||||||
<StyledDescriptionBlock>
|
<StyledDescriptionBlock>
|
||||||
<div>
|
<Box sx={{ display: 'flex' }}>
|
||||||
You can enable SSO groups syncronization
|
You can enable SSO groups syncronization
|
||||||
if needed
|
if needed
|
||||||
<Tooltip
|
<HelpIcon tooltip="SSO groups syncronization allows SSO groups to be mapped to Unleash groups, so that user group membership is properly synchronized." />
|
||||||
title="You can enable SSO groups
|
</Box>
|
||||||
syncronization if needed"
|
|
||||||
arrow
|
|
||||||
>
|
|
||||||
<StyledHelpOutline />
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Link data-loading to={`/admin/auth`}>
|
<Link data-loading to={`/admin/auth`}>
|
||||||
<span data-loading>
|
<span data-loading>
|
||||||
View SSO configuration
|
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 { styled, Tooltip, TooltipProps } from '@mui/material';
|
||||||
import { Info } from '@mui/icons-material';
|
import { HelpOutline } from '@mui/icons-material';
|
||||||
import { useStyles } from 'component/common/HelpIcon/HelpIcon.styles';
|
|
||||||
import React from 'react';
|
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 {
|
interface IHelpIconProps {
|
||||||
tooltip: string;
|
tooltip: string;
|
||||||
placement?: TooltipProps['placement'];
|
placement?: TooltipProps['placement'];
|
||||||
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HelpIcon = ({ tooltip, placement }: IHelpIconProps) => {
|
export const HelpIcon = ({ tooltip, placement, children }: IHelpIconProps) => {
|
||||||
const { classes: styles } = useStyles();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip title={tooltip} placement={placement} arrow>
|
<Tooltip title={tooltip} placement={placement} arrow>
|
||||||
<span className={styles.container} tabIndex={0} aria-label="Help">
|
<StyledContainer tabIndex={0} aria-label="Help">
|
||||||
<Info className={styles.icon} />
|
{children ?? <HelpOutline />}
|
||||||
</span>
|
</StyledContainer>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -16,13 +16,14 @@ import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
|||||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
import { GO_BACK } from 'constants/navigate';
|
import { GO_BACK } from 'constants/navigate';
|
||||||
|
import { ENV_LIMIT } from 'constants/values';
|
||||||
|
|
||||||
const CreateEnvironment = () => {
|
const CreateEnvironment = () => {
|
||||||
const { setToastApiError, setToastData } = useToast();
|
const { setToastApiError, setToastData } = useToast();
|
||||||
const { uiConfig } = useUiConfig();
|
const { uiConfig } = useUiConfig();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { environments } = useEnvironments();
|
const { environments } = useEnvironments();
|
||||||
const canCreateMoreEnvs = environments.length < 7;
|
const canCreateMoreEnvs = environments.length < ENV_LIMIT;
|
||||||
const { createEnvironment, loading } = useEnvironmentApi();
|
const { createEnvironment, loading } = useEnvironmentApi();
|
||||||
const { refetch } = useProjectRolePermissions();
|
const { refetch } = useProjectRolePermissions();
|
||||||
const {
|
const {
|
||||||
@ -114,8 +115,9 @@ const CreateEnvironment = () => {
|
|||||||
>
|
>
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
<p>
|
<p>
|
||||||
Currently Unleash does not support more than 7
|
Currently Unleash does not support more than{' '}
|
||||||
environments. If you need more please reach out.
|
{ENV_LIMIT} environments. If you need more
|
||||||
|
please reach out.
|
||||||
</p>
|
</p>
|
||||||
</Alert>
|
</Alert>
|
||||||
<br />
|
<br />
|
||||||
|
@ -12,6 +12,10 @@ import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironmen
|
|||||||
import useToast from 'hooks/useToast';
|
import useToast from 'hooks/useToast';
|
||||||
import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch';
|
import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch';
|
||||||
import { EnvironmentActionCellPopover } from './EnvironmentActionCellPopover/EnvironmentActionCellPopover';
|
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 {
|
interface IEnvironmentTableActionsProps {
|
||||||
environment: IEnvironment;
|
environment: IEnvironment;
|
||||||
@ -22,13 +26,16 @@ export const EnvironmentActionCell = ({
|
|||||||
}: IEnvironmentTableActionsProps) => {
|
}: IEnvironmentTableActionsProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { setToastApiError, setToastData } = useToast();
|
const { setToastApiError, setToastData } = useToast();
|
||||||
const { refetchEnvironments } = useEnvironments();
|
const { environments, refetchEnvironments } = useEnvironments();
|
||||||
const { refetch: refetchPermissions } = useProjectRolePermissions();
|
const { refetch: refetchPermissions } = useProjectRolePermissions();
|
||||||
const { deleteEnvironment, toggleEnvironmentOn, toggleEnvironmentOff } =
|
const { deleteEnvironment, toggleEnvironmentOn, toggleEnvironmentOff } =
|
||||||
useEnvironmentApi();
|
useEnvironmentApi();
|
||||||
|
|
||||||
const [deleteModal, setDeleteModal] = useState(false);
|
const [deleteModal, setDeleteModal] = useState(false);
|
||||||
const [toggleModal, setToggleModal] = 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 [confirmName, setConfirmName] = useState('');
|
||||||
|
|
||||||
const handleDeleteEnvironment = async () => {
|
const handleDeleteEnvironment = async () => {
|
||||||
@ -102,7 +109,17 @@ export const EnvironmentActionCell = ({
|
|||||||
<EnvironmentActionCellPopover
|
<EnvironmentActionCellPopover
|
||||||
environment={environment}
|
environment={environment}
|
||||||
onEdit={() => navigate(`/environments/${environment.name}`)}
|
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)}
|
onDelete={() => setDeleteModal(true)}
|
||||||
/>
|
/>
|
||||||
<EnvironmentDeleteConfirm
|
<EnvironmentDeleteConfirm
|
||||||
@ -119,6 +136,20 @@ export const EnvironmentActionCell = ({
|
|||||||
setToggleDialog={setToggleModal}
|
setToggleDialog={setToggleModal}
|
||||||
handleConfirmToggleEnvironment={handleConfirmToggleEnvironment}
|
handleConfirmToggleEnvironment={handleConfirmToggleEnvironment}
|
||||||
/>
|
/>
|
||||||
|
<EnvironmentCloneModal
|
||||||
|
environment={environment}
|
||||||
|
open={cloneModal}
|
||||||
|
setOpen={setCloneModal}
|
||||||
|
newToken={(token: IApiToken) => {
|
||||||
|
setNewToken(token);
|
||||||
|
setTokenModal(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<EnvironmentTokenDialog
|
||||||
|
open={tokenModal}
|
||||||
|
setOpen={setTokenModal}
|
||||||
|
token={newToken}
|
||||||
|
/>
|
||||||
</ActionCell>
|
</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)}'`;
|
--data-raw '${JSON.stringify(getPersonalAPITokenPayload(), undefined, 2)}'`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDescriptionEmpty = (description: string) => description.length;
|
const isDescriptionNotEmpty = (description: string) => description.length;
|
||||||
const isDescriptionUnique = (description: string) =>
|
const isDescriptionUnique = (description: string) =>
|
||||||
!tokens?.some(token => token.description === description);
|
!tokens?.some(token => token.description === description);
|
||||||
const isValid =
|
const isValid =
|
||||||
isDescriptionEmpty(description) &&
|
isDescriptionNotEmpty(description) &&
|
||||||
isDescriptionUnique(description) &&
|
isDescriptionUnique(description) &&
|
||||||
expiresAt > new Date();
|
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,
|
ISortOrderPayload,
|
||||||
IEnvironmentEditPayload,
|
IEnvironmentEditPayload,
|
||||||
IEnvironment,
|
IEnvironment,
|
||||||
|
IEnvironmentClonePayload,
|
||||||
} from 'interfaces/environments';
|
} from 'interfaces/environments';
|
||||||
import useAPI from '../useApi/useApi';
|
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 changeSortOrder = async (payload: ISortOrderPayload) => {
|
||||||
const path = `api/admin/environments/sort-order`;
|
const path = `api/admin/environments/sort-order`;
|
||||||
const req = createRequest(
|
const req = createRequest(
|
||||||
@ -140,6 +155,7 @@ const useEnvironmentApi = () => {
|
|||||||
loading,
|
loading,
|
||||||
deleteEnvironment,
|
deleteEnvironment,
|
||||||
updateEnvironment,
|
updateEnvironment,
|
||||||
|
cloneEnvironment,
|
||||||
changeSortOrder,
|
changeSortOrder,
|
||||||
toggleEnvironmentOff,
|
toggleEnvironmentOff,
|
||||||
toggleEnvironmentOn,
|
toggleEnvironmentOn,
|
||||||
|
@ -22,6 +22,13 @@ export interface IEnvironmentEditPayload {
|
|||||||
type: string;
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IEnvironmentClonePayload {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
projects: string[];
|
||||||
|
clonePermissions: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IEnvironmentResponse {
|
export interface IEnvironmentResponse {
|
||||||
environments: IEnvironment[];
|
environments: IEnvironment[];
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user