From d2324ee91f98679320df85bbe496beb8264c4d35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Fri, 28 Oct 2022 09:15:46 +0100 Subject: [PATCH] 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 * Update frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentCloneModal/EnvironmentCloneModal.tsx Co-authored-by: Simon Hornby * Update frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentCloneModal/EnvironmentCloneModal.tsx Co-authored-by: Simon Hornby * Update frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentCloneModal/EnvironmentCloneModal.tsx Co-authored-by: Simon Hornby * address PR comments Co-authored-by: Simon Hornby --- .../admin/groups/CreateGroup/CreateGroup.tsx | 4 +- .../admin/groups/EditGroup/EditGroup.tsx | 4 +- .../admin/groups/GroupForm/GroupForm.tsx | 22 +- .../common/HelpIcon/HelpIcon.styles.ts | 22 - .../component/common/HelpIcon/HelpIcon.tsx | 37 +- .../CreateEnvironment/CreateEnvironment.tsx | 8 +- .../EnvironmentActionCell.tsx | 35 +- .../EnvironmentCloneModal.tsx | 385 ++++++++++++++++++ .../EnvironmentProjectSelect.tsx | 161 ++++++++ .../EnvironmentTokenDialog.tsx | 37 ++ .../CreatePersonalAPIToken.tsx | 4 +- frontend/src/constants/values.ts | 1 + .../useEnvironmentApi/useEnvironmentApi.ts | 16 + frontend/src/interfaces/environments.ts | 7 + 14 files changed, 683 insertions(+), 60 deletions(-) delete mode 100644 frontend/src/component/common/HelpIcon/HelpIcon.styles.ts create mode 100644 frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentCloneModal/EnvironmentCloneModal.tsx create mode 100644 frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentCloneModal/EnvironmentProjectSelect/EnvironmentProjectSelect.tsx create mode 100644 frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentTokenDialog/EnvironmentTokenDialog.tsx create mode 100644 frontend/src/constants/values.ts diff --git a/frontend/src/component/admin/groups/CreateGroup/CreateGroup.tsx b/frontend/src/component/admin/groups/CreateGroup/CreateGroup.tsx index 83a83ca4ce..83a45eeed8 100644 --- a/frontend/src/component/admin/groups/CreateGroup/CreateGroup.tsx +++ b/frontend/src/component/admin/groups/CreateGroup/CreateGroup.tsx @@ -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(); diff --git a/frontend/src/component/admin/groups/EditGroup/EditGroup.tsx b/frontend/src/component/admin/groups/EditGroup/EditGroup.tsx index ad2a9d09e9..7796e6fd70 100644 --- a/frontend/src/component/admin/groups/EditGroup/EditGroup.tsx +++ b/frontend/src/component/admin/groups/EditGroup/EditGroup.tsx @@ -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(); diff --git a/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx b/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx index 9108797fd5..06d65f35dd 100644 --- a/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx +++ b/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx @@ -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 = ({ } elseShow={() => ( -
+ You can enable SSO groups syncronization if needed - - - -
+ + View SSO configuration diff --git a/frontend/src/component/common/HelpIcon/HelpIcon.styles.ts b/frontend/src/component/common/HelpIcon/HelpIcon.styles.ts deleted file mode 100644 index a017afcda9..0000000000 --- a/frontend/src/component/common/HelpIcon/HelpIcon.styles.ts +++ /dev/null @@ -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, - }, -})); diff --git a/frontend/src/component/common/HelpIcon/HelpIcon.tsx b/frontend/src/component/common/HelpIcon/HelpIcon.tsx index 09b12df9e8..80be16871e 100644 --- a/frontend/src/component/common/HelpIcon/HelpIcon.tsx +++ b/frontend/src/component/common/HelpIcon/HelpIcon.tsx @@ -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 ( - - - + + {children ?? } + ); }; diff --git a/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.tsx b/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.tsx index 31901bcc9b..58b510333b 100644 --- a/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.tsx +++ b/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.tsx @@ -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 = () => { >

- 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.


diff --git a/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentActionCell.tsx b/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentActionCell.tsx index 847c3b5c35..0bc7dea930 100644 --- a/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentActionCell.tsx +++ b/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentActionCell.tsx @@ -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(); const [confirmName, setConfirmName] = useState(''); const handleDeleteEnvironment = async () => { @@ -102,7 +109,17 @@ export const EnvironmentActionCell = ({ 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)} /> + { + setNewToken(token); + setTokenModal(true); + }} + /> + ); }; diff --git a/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentCloneModal/EnvironmentCloneModal.tsx b/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentCloneModal/EnvironmentCloneModal.tsx new file mode 100644 index 0000000000..e6d8ecf817 --- /dev/null +++ b/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentCloneModal/EnvironmentCloneModal.tsx @@ -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>; + 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([]); + const [tokenProjects, setTokenProjects] = useState(['*']); + const [clonePermissions, setClonePermissions] = useState(true); + const [apiTokenGeneration, setApiTokenGeneration] = + useState(APITokenGeneration.LATER); + const [errors, setErrors] = useState({}); + + 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) => { + 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 ( + { + setOpen(false); + }} + label={`Clone ${environment.name} environment`} + > + + +
+ + What is your new environment name? (Can't be changed + later) + + onSetName(e.target.value)} + required + /> + + What type of environment do you want to create? + + setType(e.currentTarget.value)} + value={type} + /> + + Select which projects you want to clone the + environment configuration in? + + + + + Keep the users permission for this environment? + + + If you turn it off, the permission for this + environment across all projects and feature toggles + will remain only for admin and editor roles. + + + setClonePermissions(e.target.checked) + } + checked={clonePermissions} + /> + } + /> + + + API Token + + + In order to connect your SDKs to your newly + cloned environment, you will also need an API + token.{' '} + + Read more about API tokens + + . + + + + setApiTokenGeneration( + e.target.value as APITokenGeneration + ) + } + name="api-token-generation" + > + } + label="Generate an API token later" + /> + } + label="Generate an API token now" + /> + + + + + A new Server-side SDK (CLIENT) API + token will be generated for the + cloned environment, so you can get + started right away. + + + Which projects do you want this + token to give access to? + + + clearError(ErrorField.PROJECTS) + } + /> + + } + /> + +
+ + + + { + setOpen(false); + }} + > + Cancel + + +
+
+
+ ); +}; diff --git a/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentCloneModal/EnvironmentProjectSelect/EnvironmentProjectSelect.tsx b/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentCloneModal/EnvironmentProjectSelect/EnvironmentProjectSelect.tsx new file mode 100644 index 0000000000..a82661f5da --- /dev/null +++ b/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentCloneModal/EnvironmentProjectSelect/EnvironmentProjectSelect.tsx @@ -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, + option: IProjectBase, + selected: boolean +) => ( +
  • + } + checkedIcon={} + style={{ marginRight: 8 }} + checked={selected} + /> + + {option.name} + {option.description} + +
  • +); + +const renderTags = (value: IProjectBase[]) => ( + + {value.length > 1 ? `${value.length} projects selected` : value[0].name} + +); + +interface IProjectBase { + id: string; + name: string; + description: string; +} + +interface IEnvironmentProjectSelectProps { + projects: string[]; + setProjects: React.Dispatch>; +} + +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) => ( + + 2} + show={ + + } + /> + {children} + + ); + + return ( + + { + 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 => ( + + )} + renderTags={value => renderTags(value)} + groupBy={() => 'Select/Deselect all'} + renderGroup={renderGroup} + /> + + ); +}; diff --git a/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentTokenDialog/EnvironmentTokenDialog.tsx b/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentTokenDialog/EnvironmentTokenDialog.tsx new file mode 100644 index 0000000000..b08e19d229 --- /dev/null +++ b/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentTokenDialog/EnvironmentTokenDialog.tsx @@ -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>; + token?: IApiToken; +} + +export const EnvironmentTokenDialog = ({ + open, + setOpen, + token, +}: IEnvironmentTokenDialogProps) => ( + { + if (!muiCloseReason) { + setOpen(false); + } + }} + title="New API token created" + > + + Your new token has been created successfully. + + + You can also find it as "{token?.username}" in the{' '} + API access page. + + + +); diff --git a/frontend/src/component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/CreatePersonalAPIToken.tsx b/frontend/src/component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/CreatePersonalAPIToken.tsx index b4d12f0df9..87c87caf48 100644 --- a/frontend/src/component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/CreatePersonalAPIToken.tsx +++ b/frontend/src/component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/CreatePersonalAPIToken.tsx @@ -212,11 +212,11 @@ export const CreatePersonalAPIToken: FC = ({ --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(); diff --git a/frontend/src/constants/values.ts b/frontend/src/constants/values.ts new file mode 100644 index 0000000000..ef121a05f0 --- /dev/null +++ b/frontend/src/constants/values.ts @@ -0,0 +1 @@ +export const ENV_LIMIT = 15; diff --git a/frontend/src/hooks/api/actions/useEnvironmentApi/useEnvironmentApi.ts b/frontend/src/hooks/api/actions/useEnvironmentApi/useEnvironmentApi.ts index 9798db294e..b19cd22c0f 100644 --- a/frontend/src/hooks/api/actions/useEnvironmentApi/useEnvironmentApi.ts +++ b/frontend/src/hooks/api/actions/useEnvironmentApi/useEnvironmentApi.ts @@ -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, diff --git a/frontend/src/interfaces/environments.ts b/frontend/src/interfaces/environments.ts index ad7f2047fd..a1125fb32f 100644 --- a/frontend/src/interfaces/environments.ts +++ b/frontend/src/interfaces/environments.ts @@ -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[]; }