mirror of
https://github.com/Unleash/unleash.git
synced 2025-11-10 01:19:53 +01:00
Update environments (#2339)
https://linear.app/unleash/issue/2-357/update-environments-pages
This commit is contained in:
parent
b9db7952fb
commit
2fa154a3e4
19
frontend/src/component/common/HtmlTooltip/HtmlTooltip.tsx
Normal file
19
frontend/src/component/common/HtmlTooltip/HtmlTooltip.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { styled, Tooltip, tooltipClasses, TooltipProps } from '@mui/material';
|
||||||
|
|
||||||
|
const StyledHtmlTooltip = styled(({ className, ...props }: TooltipProps) => (
|
||||||
|
<Tooltip {...props} classes={{ popper: className }} />
|
||||||
|
))(({ theme }) => ({
|
||||||
|
[`& .${tooltipClasses.tooltip}`]: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
padding: theme.spacing(1, 1.5),
|
||||||
|
borderRadius: theme.shape.borderRadius,
|
||||||
|
boxShadow: theme.shadows[2],
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const HtmlTooltip = (props: TooltipProps) => (
|
||||||
|
<StyledHtmlTooltip {...props}>{props.children}</StyledHtmlTooltip>
|
||||||
|
);
|
||||||
@ -1,15 +1,18 @@
|
|||||||
import { FC } from 'react';
|
import { FC, ForwardedRef, forwardRef } from 'react';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { TableCell as MUITableCell, TableCellProps } from '@mui/material';
|
import { TableCell as MUITableCell, TableCellProps } from '@mui/material';
|
||||||
import { useStyles } from './TableCell.styles';
|
import { useStyles } from './TableCell.styles';
|
||||||
|
|
||||||
export const TableCell: FC<TableCellProps> = ({ className, ...props }) => {
|
export const TableCell: FC<TableCellProps> = forwardRef(
|
||||||
|
({ className, ...props }, ref: ForwardedRef<unknown>) => {
|
||||||
const { classes: styles } = useStyles();
|
const { classes: styles } = useStyles();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MUITableCell
|
<MUITableCell
|
||||||
className={classnames(styles.tableCell, className)}
|
className={classnames(styles.tableCell, className)}
|
||||||
{...props}
|
{...props}
|
||||||
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
import { makeStyles } from 'tss-react/mui';
|
|
||||||
|
|
||||||
export const useStyles = makeStyles()(theme => ({
|
|
||||||
deleteParagraph: {
|
|
||||||
marginTop: '2rem',
|
|
||||||
},
|
|
||||||
environmentDeleteInput: {
|
|
||||||
marginTop: '1rem',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
import { Alert } from '@mui/material';
|
|
||||||
import React from 'react';
|
|
||||||
import { IEnvironment } from 'interfaces/environments';
|
|
||||||
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
|
||||||
import Input from 'component/common/Input/Input';
|
|
||||||
import EnvironmentCard from 'component/environments/EnvironmentCard/EnvironmentCard';
|
|
||||||
import { useStyles } from 'component/environments/EnvironmentDeleteConfirm/EnvironmentDeleteConfirm.styles';
|
|
||||||
|
|
||||||
interface IEnviromentDeleteConfirmProps {
|
|
||||||
env: IEnvironment;
|
|
||||||
open: boolean;
|
|
||||||
setDeldialogue: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
handleDeleteEnvironment: () => Promise<void>;
|
|
||||||
confirmName: string;
|
|
||||||
setConfirmName: React.Dispatch<React.SetStateAction<string>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EnvironmentDeleteConfirm = ({
|
|
||||||
env,
|
|
||||||
open,
|
|
||||||
setDeldialogue,
|
|
||||||
handleDeleteEnvironment,
|
|
||||||
confirmName,
|
|
||||||
setConfirmName,
|
|
||||||
}: IEnviromentDeleteConfirmProps) => {
|
|
||||||
const { classes: styles } = useStyles();
|
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setConfirmName(e.currentTarget.value);
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
setDeldialogue(false);
|
|
||||||
setConfirmName('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const formId = 'delete-environment-confirmation-form';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialogue
|
|
||||||
title="Are you sure you want to delete this environment?"
|
|
||||||
open={open}
|
|
||||||
primaryButtonText="Delete environment"
|
|
||||||
secondaryButtonText="Cancel"
|
|
||||||
onClick={handleDeleteEnvironment}
|
|
||||||
disabledPrimaryButton={env?.name !== confirmName}
|
|
||||||
onClose={handleCancel}
|
|
||||||
formId={formId}
|
|
||||||
>
|
|
||||||
<Alert severity="error">
|
|
||||||
Danger. Deleting this environment will result in removing all
|
|
||||||
strategies that are active in this environment across all
|
|
||||||
feature toggles.
|
|
||||||
</Alert>
|
|
||||||
<EnvironmentCard name={env?.name} type={env?.type} />
|
|
||||||
|
|
||||||
<p className={styles.deleteParagraph}>
|
|
||||||
In order to delete this environment, please enter the id of the
|
|
||||||
environment in the textfield below: <strong>{env?.name}</strong>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form id={formId}>
|
|
||||||
<Input
|
|
||||||
autoFocus
|
|
||||||
onChange={handleChange}
|
|
||||||
value={confirmName}
|
|
||||||
label="Environment name"
|
|
||||||
className={styles.environmentDeleteInput}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Dialogue>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EnvironmentDeleteConfirm;
|
|
||||||
@ -1,21 +1,19 @@
|
|||||||
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
|
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
|
||||||
import { UPDATE_ENVIRONMENT } from 'component/providers/AccessProvider/permissions';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { IEnvironment } from 'interfaces/environments';
|
import { IEnvironment } from 'interfaces/environments';
|
||||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
import EnvironmentToggleConfirm from '../../EnvironmentToggleConfirm/EnvironmentToggleConfirm';
|
|
||||||
import EnvironmentDeleteConfirm from '../../EnvironmentDeleteConfirm/EnvironmentDeleteConfirm';
|
|
||||||
import useEnvironmentApi from 'hooks/api/actions/useEnvironmentApi/useEnvironmentApi';
|
import useEnvironmentApi from 'hooks/api/actions/useEnvironmentApi/useEnvironmentApi';
|
||||||
import useProjectRolePermissions from 'hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions';
|
import useProjectRolePermissions from 'hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions';
|
||||||
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
|
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
|
||||||
import useToast from 'hooks/useToast';
|
import useToast from 'hooks/useToast';
|
||||||
import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch';
|
|
||||||
import { EnvironmentActionCellPopover } from './EnvironmentActionCellPopover/EnvironmentActionCellPopover';
|
import { EnvironmentActionCellPopover } from './EnvironmentActionCellPopover/EnvironmentActionCellPopover';
|
||||||
import { EnvironmentCloneModal } from './EnvironmentCloneModal/EnvironmentCloneModal';
|
import { EnvironmentCloneModal } from './EnvironmentCloneModal/EnvironmentCloneModal';
|
||||||
import { IApiToken } from 'hooks/api/getters/useApiTokens/useApiTokens';
|
import { IApiToken } from 'hooks/api/getters/useApiTokens/useApiTokens';
|
||||||
import { EnvironmentTokenDialog } from './EnvironmentTokenDialog/EnvironmentTokenDialog';
|
import { EnvironmentTokenDialog } from './EnvironmentTokenDialog/EnvironmentTokenDialog';
|
||||||
import { ENV_LIMIT } from 'constants/values';
|
import { ENV_LIMIT } from 'constants/values';
|
||||||
|
import { EnvironmentDeprecateToggleDialog } from './EnvironmentDeprecateToggleDialog/EnvironmentDeprecateToggleDialog';
|
||||||
|
import { EnvironmentDeleteDialog } from './EnvironmentDeleteDialog/EnvironmentDeleteDialog';
|
||||||
|
|
||||||
interface IEnvironmentTableActionsProps {
|
interface IEnvironmentTableActionsProps {
|
||||||
environment: IEnvironment;
|
environment: IEnvironment;
|
||||||
@ -31,14 +29,13 @@ export const EnvironmentActionCell = ({
|
|||||||
const { deleteEnvironment, toggleEnvironmentOn, toggleEnvironmentOff } =
|
const { deleteEnvironment, toggleEnvironmentOn, toggleEnvironmentOff } =
|
||||||
useEnvironmentApi();
|
useEnvironmentApi();
|
||||||
|
|
||||||
const [deleteModal, setDeleteModal] = useState(false);
|
const [deleteDialog, setDeleteDialog] = useState(false);
|
||||||
const [toggleModal, setToggleModal] = useState(false);
|
const [deprecateToggleDialog, setDeprecateToggleDialog] = useState(false);
|
||||||
const [cloneModal, setCloneModal] = useState(false);
|
const [cloneModal, setCloneModal] = useState(false);
|
||||||
const [tokenModal, setTokenModal] = useState(false);
|
const [tokenDialog, setTokenDialog] = useState(false);
|
||||||
const [newToken, setNewToken] = useState<IApiToken>();
|
const [newToken, setNewToken] = useState<IApiToken>();
|
||||||
const [confirmName, setConfirmName] = useState('');
|
|
||||||
|
|
||||||
const handleDeleteEnvironment = async () => {
|
const onDeleteConfirm = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteEnvironment(environment.name);
|
await deleteEnvironment(environment.name);
|
||||||
refetchPermissions();
|
refetchPermissions();
|
||||||
@ -50,65 +47,40 @@ export const EnvironmentActionCell = ({
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setToastApiError(formatUnknownError(error));
|
setToastApiError(formatUnknownError(error));
|
||||||
} finally {
|
} finally {
|
||||||
setDeleteModal(false);
|
setDeleteDialog(false);
|
||||||
setConfirmName('');
|
|
||||||
await refetchEnvironments();
|
await refetchEnvironments();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmToggleEnvironment = () => {
|
const onDeprecateToggleConfirm = async () => {
|
||||||
return environment.enabled
|
|
||||||
? handleToggleEnvironmentOff()
|
|
||||||
: handleToggleEnvironmentOn();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleEnvironmentOn = async () => {
|
|
||||||
try {
|
try {
|
||||||
setToggleModal(false);
|
if (environment.enabled) {
|
||||||
await toggleEnvironmentOn(environment.name);
|
|
||||||
setToastData({
|
|
||||||
type: 'success',
|
|
||||||
title: 'Project environment enabled',
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
setToastApiError(formatUnknownError(error));
|
|
||||||
} finally {
|
|
||||||
await refetchEnvironments();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleEnvironmentOff = async () => {
|
|
||||||
try {
|
|
||||||
setToggleModal(false);
|
|
||||||
await toggleEnvironmentOff(environment.name);
|
await toggleEnvironmentOff(environment.name);
|
||||||
setToastData({
|
setToastData({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: 'Project environment disabled',
|
title: 'Environment deprecated successfully',
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
await toggleEnvironmentOn(environment.name);
|
||||||
|
setToastData({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Environment undeprecated successfully',
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setToastApiError(formatUnknownError(error));
|
setToastApiError(formatUnknownError(error));
|
||||||
} finally {
|
} finally {
|
||||||
|
setDeprecateToggleDialog(false);
|
||||||
await refetchEnvironments();
|
await refetchEnvironments();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionCell>
|
<ActionCell>
|
||||||
<PermissionSwitch
|
|
||||||
permission={UPDATE_ENVIRONMENT}
|
|
||||||
checked={environment.enabled}
|
|
||||||
disabled={environment.protected}
|
|
||||||
tooltip={
|
|
||||||
environment.enabled
|
|
||||||
? `Disable environment ${environment.name}`
|
|
||||||
: `Enable environment ${environment.name}`
|
|
||||||
}
|
|
||||||
onClick={() => setToggleModal(true)}
|
|
||||||
/>
|
|
||||||
<ActionCell.Divider />
|
|
||||||
<EnvironmentActionCellPopover
|
<EnvironmentActionCellPopover
|
||||||
environment={environment}
|
environment={environment}
|
||||||
onEdit={() => navigate(`/environments/${environment.name}`)}
|
onEdit={() => navigate(`/environments/${environment.name}`)}
|
||||||
|
onDeprecateToggle={() => setDeprecateToggleDialog(true)}
|
||||||
onClone={() => {
|
onClone={() => {
|
||||||
if (environments.length < ENV_LIMIT) {
|
if (environments.length < ENV_LIMIT) {
|
||||||
setCloneModal(true);
|
setCloneModal(true);
|
||||||
@ -120,21 +92,19 @@ export const EnvironmentActionCell = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDelete={() => setDeleteModal(true)}
|
onDelete={() => setDeleteDialog(true)}
|
||||||
/>
|
/>
|
||||||
<EnvironmentDeleteConfirm
|
<EnvironmentDeleteDialog
|
||||||
env={environment}
|
environment={environment}
|
||||||
setDeldialogue={setDeleteModal}
|
open={deleteDialog}
|
||||||
open={deleteModal}
|
setOpen={setDeleteDialog}
|
||||||
handleDeleteEnvironment={handleDeleteEnvironment}
|
onConfirm={onDeleteConfirm}
|
||||||
confirmName={confirmName}
|
|
||||||
setConfirmName={setConfirmName}
|
|
||||||
/>
|
/>
|
||||||
<EnvironmentToggleConfirm
|
<EnvironmentDeprecateToggleDialog
|
||||||
env={environment}
|
environment={environment}
|
||||||
open={toggleModal}
|
open={deprecateToggleDialog}
|
||||||
setToggleDialog={setToggleModal}
|
setOpen={setDeprecateToggleDialog}
|
||||||
handleConfirmToggleEnvironment={handleConfirmToggleEnvironment}
|
onConfirm={onDeprecateToggleConfirm}
|
||||||
/>
|
/>
|
||||||
<EnvironmentCloneModal
|
<EnvironmentCloneModal
|
||||||
environment={environment}
|
environment={environment}
|
||||||
@ -142,12 +112,12 @@ export const EnvironmentActionCell = ({
|
|||||||
setOpen={setCloneModal}
|
setOpen={setCloneModal}
|
||||||
newToken={(token: IApiToken) => {
|
newToken={(token: IApiToken) => {
|
||||||
setNewToken(token);
|
setNewToken(token);
|
||||||
setTokenModal(true);
|
setTokenDialog(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<EnvironmentTokenDialog
|
<EnvironmentTokenDialog
|
||||||
open={tokenModal}
|
open={tokenDialog}
|
||||||
setOpen={setTokenModal}
|
setOpen={setTokenDialog}
|
||||||
token={newToken}
|
token={newToken}
|
||||||
/>
|
/>
|
||||||
</ActionCell>
|
</ActionCell>
|
||||||
|
|||||||
@ -18,7 +18,13 @@ import {
|
|||||||
DELETE_ENVIRONMENT,
|
DELETE_ENVIRONMENT,
|
||||||
UPDATE_ENVIRONMENT,
|
UPDATE_ENVIRONMENT,
|
||||||
} from 'component/providers/AccessProvider/permissions';
|
} from 'component/providers/AccessProvider/permissions';
|
||||||
import { Delete, Edit, AddToPhotos as CopyIcon } from '@mui/icons-material';
|
import {
|
||||||
|
Delete,
|
||||||
|
Edit,
|
||||||
|
AddToPhotos as CopyIcon,
|
||||||
|
VisibilityOffOutlined,
|
||||||
|
VisibilityOutlined,
|
||||||
|
} from '@mui/icons-material';
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
|
||||||
@ -30,9 +36,18 @@ const StyledMenuItem = styled(MenuItem)(({ theme }) => ({
|
|||||||
borderRadius: theme.shape.borderRadius,
|
borderRadius: theme.shape.borderRadius,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const StyledMenuItemNegative = styled(StyledMenuItem)(({ theme }) => ({
|
||||||
|
color: theme.palette.error.main,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledListItemIconNegative = styled(ListItemIcon)(({ theme }) => ({
|
||||||
|
color: theme.palette.error.main,
|
||||||
|
}));
|
||||||
|
|
||||||
interface IEnvironmentActionCellPopoverProps {
|
interface IEnvironmentActionCellPopoverProps {
|
||||||
environment: IEnvironment;
|
environment: IEnvironment;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
|
onDeprecateToggle: () => void;
|
||||||
onClone: () => void;
|
onClone: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
@ -40,6 +55,7 @@ interface IEnvironmentActionCellPopoverProps {
|
|||||||
export const EnvironmentActionCellPopover = ({
|
export const EnvironmentActionCellPopover = ({
|
||||||
environment,
|
environment,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onDeprecateToggle,
|
||||||
onClone,
|
onClone,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: IEnvironmentActionCellPopoverProps) => {
|
}: IEnvironmentActionCellPopoverProps) => {
|
||||||
@ -127,24 +143,50 @@ export const EnvironmentActionCellPopover = ({
|
|||||||
</PermissionHOC>
|
</PermissionHOC>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<PermissionHOC permission={DELETE_ENVIRONMENT}>
|
<PermissionHOC permission={UPDATE_ENVIRONMENT}>
|
||||||
{({ hasAccess }) => (
|
{({ hasAccess }) => (
|
||||||
<StyledMenuItem
|
<StyledMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
onDeprecateToggle();
|
||||||
|
handleClose();
|
||||||
|
}}
|
||||||
|
disabled={!hasAccess || environment.protected}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={environment.enabled}
|
||||||
|
show={<VisibilityOffOutlined />}
|
||||||
|
elseShow={<VisibilityOutlined />}
|
||||||
|
/>
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{environment.enabled
|
||||||
|
? 'Deprecate'
|
||||||
|
: 'Undeprecate'}
|
||||||
|
</Typography>
|
||||||
|
</ListItemText>
|
||||||
|
</StyledMenuItem>
|
||||||
|
)}
|
||||||
|
</PermissionHOC>
|
||||||
|
<PermissionHOC permission={DELETE_ENVIRONMENT}>
|
||||||
|
{({ hasAccess }) => (
|
||||||
|
<StyledMenuItemNegative
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onDelete();
|
onDelete();
|
||||||
handleClose();
|
handleClose();
|
||||||
}}
|
}}
|
||||||
disabled={!hasAccess || environment.protected}
|
disabled={!hasAccess || environment.protected}
|
||||||
>
|
>
|
||||||
<ListItemIcon>
|
<StyledListItemIconNegative>
|
||||||
<Delete />
|
<Delete />
|
||||||
</ListItemIcon>
|
</StyledListItemIconNegative>
|
||||||
<ListItemText>
|
<ListItemText>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
Delete
|
Delete
|
||||||
</Typography>
|
</Typography>
|
||||||
</ListItemText>
|
</ListItemText>
|
||||||
</StyledMenuItem>
|
</StyledMenuItemNegative>
|
||||||
)}
|
)}
|
||||||
</PermissionHOC>
|
</PermissionHOC>
|
||||||
</StyledMenuList>
|
</StyledMenuList>
|
||||||
|
|||||||
@ -142,6 +142,7 @@ export const EnvironmentCloneModal = ({
|
|||||||
setProjects([]);
|
setProjects([]);
|
||||||
setTokenProjects(['*']);
|
setTokenProjects(['*']);
|
||||||
setClonePermissions(true);
|
setClonePermissions(true);
|
||||||
|
setApiTokenGeneration(APITokenGeneration.LATER);
|
||||||
setErrors({});
|
setErrors({});
|
||||||
}, [environment]);
|
}, [environment]);
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,71 @@
|
|||||||
|
import { styled, Alert } from '@mui/material';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { IEnvironment } from 'interfaces/environments';
|
||||||
|
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||||
|
import Input from 'component/common/Input/Input';
|
||||||
|
import { EnvironmentTableSingle } from 'component/environments/EnvironmentTable/EnvironmentTableSingle';
|
||||||
|
|
||||||
|
const StyledLabel = styled('p')(({ theme }) => ({
|
||||||
|
marginTop: theme.spacing(3),
|
||||||
|
marginBottom: theme.spacing(1.5),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledInput = styled(Input)(() => ({
|
||||||
|
width: '100%',
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IEnvironmentDeleteDialogProps {
|
||||||
|
environment: IEnvironment;
|
||||||
|
open: boolean;
|
||||||
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EnvironmentDeleteDialog = ({
|
||||||
|
environment,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
onConfirm,
|
||||||
|
}: IEnvironmentDeleteDialogProps) => {
|
||||||
|
const [confirmName, setConfirmName] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setConfirmName('');
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialogue
|
||||||
|
title="Delete environment?"
|
||||||
|
open={open}
|
||||||
|
primaryButtonText="Delete environment"
|
||||||
|
disabledPrimaryButton={environment.name !== confirmName}
|
||||||
|
secondaryButtonText="Close"
|
||||||
|
onClick={onConfirm}
|
||||||
|
onClose={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Alert severity="error">
|
||||||
|
<strong>Danger!</strong> Deleting this environment will result
|
||||||
|
in removing all strategies that are active in this environment
|
||||||
|
across all feature toggles.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<EnvironmentTableSingle
|
||||||
|
environment={environment}
|
||||||
|
warnEnabledToggles
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StyledLabel>
|
||||||
|
In order to delete this environment, please enter the id of the
|
||||||
|
environment in the textfield below:{' '}
|
||||||
|
<strong>{environment.name}</strong>
|
||||||
|
</StyledLabel>
|
||||||
|
<StyledInput
|
||||||
|
label="Environment name"
|
||||||
|
value={confirmName}
|
||||||
|
onChange={e => setConfirmName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Dialogue>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
import { Alert } from '@mui/material';
|
||||||
|
import React from 'react';
|
||||||
|
import { IEnvironment } from 'interfaces/environments';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||||
|
import { EnvironmentTableSingle } from 'component/environments/EnvironmentTable/EnvironmentTableSingle';
|
||||||
|
|
||||||
|
interface IEnvironmentDeprecateToggleDialogProps {
|
||||||
|
environment: IEnvironment;
|
||||||
|
open: boolean;
|
||||||
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EnvironmentDeprecateToggleDialog = ({
|
||||||
|
environment,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
onConfirm,
|
||||||
|
}: IEnvironmentDeprecateToggleDialogProps) => {
|
||||||
|
const { enabled } = environment;
|
||||||
|
const actionName = enabled ? 'Deprecate' : 'Undeprecate';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialogue
|
||||||
|
title={`${actionName} environment?`}
|
||||||
|
open={open}
|
||||||
|
primaryButtonText={actionName}
|
||||||
|
secondaryButtonText="Close"
|
||||||
|
onClick={onConfirm}
|
||||||
|
onClose={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={enabled}
|
||||||
|
show={
|
||||||
|
<Alert severity="info">
|
||||||
|
Deprecating an environment will mark it as deprecated.
|
||||||
|
Deprecated environments are not set as visible by
|
||||||
|
default for new projects. Project owners are still able
|
||||||
|
to override this setting in the project.
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
|
<Alert severity="info">
|
||||||
|
Undeprecating an environment will no longer mark it as
|
||||||
|
deprecated. An undeprecated environment will be set as
|
||||||
|
visible by default for new projects.
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EnvironmentTableSingle environment={environment} />
|
||||||
|
</Dialogue>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,42 +1,44 @@
|
|||||||
import { useContext, VFC } from 'react';
|
import { VFC } from 'react';
|
||||||
import { styled } from '@mui/material';
|
import { styled } from '@mui/material';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
|
||||||
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
|
||||||
import { Box, IconButton } from '@mui/material';
|
import { Box, IconButton } from '@mui/material';
|
||||||
import { CloudCircle, DragIndicator } from '@mui/icons-material';
|
import { CloudCircle, DragIndicator } from '@mui/icons-material';
|
||||||
import { UPDATE_ENVIRONMENT } from 'component/providers/AccessProvider/permissions';
|
import { IEnvironment } from 'interfaces/environments';
|
||||||
import AccessContext from 'contexts/AccessContext';
|
|
||||||
|
|
||||||
const DragIcon = styled(IconButton)(
|
const StyledCell = styled(Box)(({ theme }) => ({
|
||||||
({ theme }) => `
|
display: 'flex',
|
||||||
padding: ${theme.spacing(0, 1, 0, 0)};
|
alignItems: 'center',
|
||||||
cursor: inherit;
|
justifyContent: 'flex-end',
|
||||||
transition: color 0.2s ease-in-out;
|
paddingLeft: theme.spacing(0.5),
|
||||||
`
|
minWidth: theme.spacing(6.5),
|
||||||
);
|
}));
|
||||||
|
|
||||||
export const EnvironmentIconCell: VFC = () => {
|
const DragIcon = styled(IconButton)(({ theme }) => ({
|
||||||
const { hasAccess } = useContext(AccessContext);
|
padding: theme.spacing(1.5, 0),
|
||||||
const updatePermission = hasAccess(UPDATE_ENVIRONMENT);
|
cursor: 'inherit',
|
||||||
const { searchQuery } = useSearchHighlightContext();
|
transition: 'color 0.2s ease-in-out',
|
||||||
|
display: 'none',
|
||||||
|
color: theme.palette.neutral.main,
|
||||||
|
}));
|
||||||
|
|
||||||
// Allow drag and drop if the user is permitted to reorder environments.
|
const StyledCloudCircle = styled(CloudCircle, {
|
||||||
// Disable drag and drop while searching since some rows may be hidden.
|
shouldForwardProp: prop => prop !== 'deprecated',
|
||||||
const enableDragAndDrop = updatePermission && !searchQuery;
|
})<{ deprecated?: boolean }>(({ theme, deprecated }) => ({
|
||||||
return (
|
color: deprecated
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', pl: 2 }}>
|
? theme.palette.neutral.border
|
||||||
<ConditionallyRender
|
: theme.palette.primary.main,
|
||||||
condition={enableDragAndDrop}
|
}));
|
||||||
show={
|
|
||||||
<DragIcon size="large" disableRipple disabled>
|
interface IEnvironmentIconCellProps {
|
||||||
<DragIndicator
|
environment: IEnvironment;
|
||||||
titleAccess="Drag to reorder"
|
}
|
||||||
cursor="grab"
|
|
||||||
/>
|
export const EnvironmentIconCell: VFC<IEnvironmentIconCellProps> = ({
|
||||||
|
environment,
|
||||||
|
}) => (
|
||||||
|
<StyledCell>
|
||||||
|
<DragIcon size="large" disableRipple className="drag-icon">
|
||||||
|
<DragIndicator titleAccess="Drag to reorder" />
|
||||||
</DragIcon>
|
</DragIcon>
|
||||||
}
|
<StyledCloudCircle deprecated={!environment.enabled} />
|
||||||
/>
|
</StyledCell>
|
||||||
<CloudCircle color="disabled" />
|
);
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@ -4,7 +4,17 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
|
|||||||
import { Badge } from 'component/common/Badge/Badge';
|
import { Badge } from 'component/common/Badge/Badge';
|
||||||
import { Highlighter } from 'component/common/Highlighter/Highlighter';
|
import { Highlighter } from 'component/common/Highlighter/Highlighter';
|
||||||
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||||
import { styled } from '@mui/material';
|
import { styled, Typography } from '@mui/material';
|
||||||
|
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
|
||||||
|
|
||||||
|
const StyledTooltipTitle = styled(Typography)(({ theme }) => ({
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: theme.fontSizes.smallerBody,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledTooltipDescription = styled(Typography)(({ theme }) => ({
|
||||||
|
fontSize: theme.fontSizes.smallerBody,
|
||||||
|
}));
|
||||||
|
|
||||||
const StyledBadge = styled(Badge)(({ theme }) => ({
|
const StyledBadge = styled(Badge)(({ theme }) => ({
|
||||||
marginLeft: theme.spacing(1),
|
marginLeft: theme.spacing(1),
|
||||||
@ -22,14 +32,33 @@ export const EnvironmentNameCell = ({
|
|||||||
return (
|
return (
|
||||||
<TextCell>
|
<TextCell>
|
||||||
<Highlighter search={searchQuery}>{environment.name}</Highlighter>
|
<Highlighter search={searchQuery}>{environment.name}</Highlighter>
|
||||||
<ConditionallyRender
|
|
||||||
condition={!environment.enabled}
|
|
||||||
show={<StyledBadge color="warning">Disabled</StyledBadge>}
|
|
||||||
/>
|
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={environment.protected}
|
condition={environment.protected}
|
||||||
show={<StyledBadge color="success">Predefined</StyledBadge>}
|
show={<StyledBadge color="success">Predefined</StyledBadge>}
|
||||||
/>
|
/>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={!environment.enabled}
|
||||||
|
show={
|
||||||
|
<HtmlTooltip
|
||||||
|
sx={{ maxWidth: '270px' }}
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
<StyledTooltipTitle>
|
||||||
|
Deprecated environment
|
||||||
|
</StyledTooltipTitle>
|
||||||
|
<StyledTooltipDescription>
|
||||||
|
This environment is not auto-enabled for new
|
||||||
|
projects. The project owner will need to
|
||||||
|
manually enable it in the project.
|
||||||
|
</StyledTooltipDescription>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
describeChild
|
||||||
|
>
|
||||||
|
<StyledBadge color="neutral">Deprecated</StyledBadge>
|
||||||
|
</HtmlTooltip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</TextCell>
|
</TextCell>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,11 +1,20 @@
|
|||||||
import { useDragItem, MoveListItem } from 'hooks/useDragItem';
|
import { MoveListItem, useDragItem } from 'hooks/useDragItem';
|
||||||
import { Row } from 'react-table';
|
import { Row } from 'react-table';
|
||||||
import { TableRow } from '@mui/material';
|
import { styled, TableRow } from '@mui/material';
|
||||||
import { TableCell } from 'component/common/Table';
|
import { TableCell } from 'component/common/Table';
|
||||||
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||||
import { UPDATE_ENVIRONMENT } from 'component/providers/AccessProvider/permissions';
|
import { UPDATE_ENVIRONMENT } from 'component/providers/AccessProvider/permissions';
|
||||||
import AccessContext from 'contexts/AccessContext';
|
import AccessContext from 'contexts/AccessContext';
|
||||||
import { useContext } from 'react';
|
import { ForwardedRef, useContext, useRef } from 'react';
|
||||||
|
|
||||||
|
const StyledTableRow = styled(TableRow)(() => ({
|
||||||
|
'&:hover': {
|
||||||
|
'.drag-handle .drag-icon': {
|
||||||
|
display: 'inherit',
|
||||||
|
cursor: 'grab',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
interface IEnvironmentRowProps {
|
interface IEnvironmentRowProps {
|
||||||
row: Row;
|
row: Row;
|
||||||
@ -14,17 +23,39 @@ interface IEnvironmentRowProps {
|
|||||||
|
|
||||||
export const EnvironmentRow = ({ row, moveListItem }: IEnvironmentRowProps) => {
|
export const EnvironmentRow = ({ row, moveListItem }: IEnvironmentRowProps) => {
|
||||||
const { hasAccess } = useContext(AccessContext);
|
const { hasAccess } = useContext(AccessContext);
|
||||||
const dragItemRef = useDragItem(row.index, moveListItem);
|
const dragHandleRef = useRef(null);
|
||||||
const { searchQuery } = useSearchHighlightContext();
|
const { searchQuery } = useSearchHighlightContext();
|
||||||
const draggable = !searchQuery && hasAccess(UPDATE_ENVIRONMENT);
|
const draggable = !searchQuery && hasAccess(UPDATE_ENVIRONMENT);
|
||||||
|
|
||||||
|
const dragItemRef = useDragItem<HTMLTableRowElement>(
|
||||||
|
row.index,
|
||||||
|
moveListItem,
|
||||||
|
dragHandleRef
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderCell = (cell: any, ref: ForwardedRef<HTMLElement>) => {
|
||||||
|
if (draggable && cell.column.isDragHandle) {
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
{...cell.getCellProps()}
|
||||||
|
ref={ref}
|
||||||
|
className="drag-handle"
|
||||||
|
>
|
||||||
|
{cell.render('Cell')}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
return (
|
return (
|
||||||
<TableRow hover ref={draggable ? dragItemRef : undefined}>
|
|
||||||
{row.cells.map((cell: any) => (
|
|
||||||
<TableCell {...cell.getCellProps()}>
|
<TableCell {...cell.getCellProps()}>
|
||||||
{cell.render('Cell')}
|
{cell.render('Cell')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
);
|
||||||
</TableRow>
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledTableRow hover ref={draggable ? dragItemRef : undefined}>
|
||||||
|
{row.cells.map((cell: any) => renderCell(cell, dragHandleRef))}
|
||||||
|
</StyledTableRow>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -23,6 +23,9 @@ import { EnvironmentNameCell } from './EnvironmentNameCell/EnvironmentNameCell';
|
|||||||
import { EnvironmentActionCell } from './EnvironmentActionCell/EnvironmentActionCell';
|
import { EnvironmentActionCell } from './EnvironmentActionCell/EnvironmentActionCell';
|
||||||
import { EnvironmentIconCell } from './EnvironmentIconCell/EnvironmentIconCell';
|
import { EnvironmentIconCell } from './EnvironmentIconCell/EnvironmentIconCell';
|
||||||
import { Search } from 'component/common/Search/Search';
|
import { Search } from 'component/common/Search/Search';
|
||||||
|
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
|
||||||
|
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
||||||
|
import { IEnvironment } from 'interfaces/environments';
|
||||||
|
|
||||||
const StyledAlert = styled(Alert)(({ theme }) => ({
|
const StyledAlert = styled(Alert)(({ theme }) => ({
|
||||||
marginBottom: theme.spacing(4),
|
marginBottom: theme.spacing(4),
|
||||||
@ -93,7 +96,7 @@ export const EnvironmentTable = () => {
|
|||||||
inside each feature toggle.
|
inside each feature toggle.
|
||||||
</StyledAlert>
|
</StyledAlert>
|
||||||
<SearchHighlightProvider value={globalFilter}>
|
<SearchHighlightProvider value={globalFilter}>
|
||||||
<Table {...getTableProps()}>
|
<Table {...getTableProps()} rowHeight="compact">
|
||||||
<SortableTableHeader headerGroups={headerGroups as any} />
|
<SortableTableHeader headerGroups={headerGroups as any} />
|
||||||
<TableBody {...getTableBodyProps()}>
|
<TableBody {...getTableBodyProps()}>
|
||||||
{rows.map(row => {
|
{rows.map(row => {
|
||||||
@ -138,22 +141,45 @@ const COLUMNS = [
|
|||||||
{
|
{
|
||||||
id: 'Icon',
|
id: 'Icon',
|
||||||
width: '1%',
|
width: '1%',
|
||||||
Cell: () => <EnvironmentIconCell />,
|
Cell: ({ row: { original } }: { row: { original: IEnvironment } }) => (
|
||||||
|
<EnvironmentIconCell environment={original} />
|
||||||
|
),
|
||||||
disableGlobalFilter: true,
|
disableGlobalFilter: true,
|
||||||
|
isDragHandle: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: 'Name',
|
Header: 'Name',
|
||||||
accessor: 'name',
|
accessor: 'name',
|
||||||
Cell: ({ row: { original } }: any) => (
|
Cell: ({ row: { original } }: { row: { original: IEnvironment } }) => (
|
||||||
<EnvironmentNameCell environment={original} />
|
<EnvironmentNameCell environment={original} />
|
||||||
),
|
),
|
||||||
|
minWidth: 350,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Type',
|
||||||
|
accessor: 'type',
|
||||||
|
Cell: HighlightCell,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Visible in',
|
||||||
|
accessor: (row: IEnvironment) =>
|
||||||
|
row.projectCount === 1
|
||||||
|
? '1 project'
|
||||||
|
: `${row.projectCount} projects`,
|
||||||
|
Cell: TextCell,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'API Tokens',
|
||||||
|
accessor: (row: IEnvironment) =>
|
||||||
|
row.apiTokenCount === 1 ? '1 token' : `${row.apiTokenCount} tokens`,
|
||||||
|
Cell: TextCell,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: 'Actions',
|
Header: 'Actions',
|
||||||
id: 'Actions',
|
id: 'Actions',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: '1%',
|
width: '1%',
|
||||||
Cell: ({ row: { original } }: any) => (
|
Cell: ({ row: { original } }: { row: { original: IEnvironment } }) => (
|
||||||
<EnvironmentActionCell environment={original} />
|
<EnvironmentActionCell environment={original} />
|
||||||
),
|
),
|
||||||
disableGlobalFilter: true,
|
disableGlobalFilter: true,
|
||||||
|
|||||||
@ -0,0 +1,113 @@
|
|||||||
|
import { styled, TableBody, TableRow } from '@mui/material';
|
||||||
|
import { IEnvironment } from 'interfaces/environments';
|
||||||
|
import { useTable } from 'react-table';
|
||||||
|
import { SortableTableHeader, Table, TableCell } from 'component/common/Table';
|
||||||
|
import { EnvironmentIconCell } from 'component/environments/EnvironmentTable/EnvironmentIconCell/EnvironmentIconCell';
|
||||||
|
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
const StyledTable = styled(Table)(({ theme }) => ({
|
||||||
|
marginTop: theme.spacing(3),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledToggleWarning = styled('p', {
|
||||||
|
shouldForwardProp: prop => prop !== 'warning',
|
||||||
|
})<{ warning?: boolean }>(({ theme, warning }) => ({
|
||||||
|
color: warning ? theme.palette.error.dark : theme.palette.text.primary,
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IEnvironmentTableSingleProps {
|
||||||
|
environment: IEnvironment;
|
||||||
|
warnEnabledToggles?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EnvironmentTableSingle = ({
|
||||||
|
environment,
|
||||||
|
warnEnabledToggles,
|
||||||
|
}: IEnvironmentTableSingleProps) => {
|
||||||
|
const COLUMNS = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'Icon',
|
||||||
|
width: '1%',
|
||||||
|
Cell: ({
|
||||||
|
row: { original },
|
||||||
|
}: {
|
||||||
|
row: { original: IEnvironment };
|
||||||
|
}) => <EnvironmentIconCell environment={original} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Name',
|
||||||
|
accessor: 'name',
|
||||||
|
Cell: TextCell,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Type',
|
||||||
|
accessor: 'type',
|
||||||
|
Cell: TextCell,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Visible in',
|
||||||
|
accessor: (row: IEnvironment) =>
|
||||||
|
row.projectCount === 1
|
||||||
|
? '1 project'
|
||||||
|
: `${row.projectCount} projects`,
|
||||||
|
Cell: ({
|
||||||
|
row: { original },
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
row: { original: IEnvironment };
|
||||||
|
value: string;
|
||||||
|
}) => (
|
||||||
|
<TextCell>
|
||||||
|
{value}
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={Boolean(warnEnabledToggles)}
|
||||||
|
show={
|
||||||
|
<StyledToggleWarning
|
||||||
|
warning={Boolean(
|
||||||
|
original.enabledToggleCount &&
|
||||||
|
original.enabledToggleCount > 0
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{original.enabledToggleCount === 1
|
||||||
|
? '1 toggle enabled'
|
||||||
|
: `${original.enabledToggleCount} toggles enabled`}
|
||||||
|
</StyledToggleWarning>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TextCell>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[warnEnabledToggles]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
|
||||||
|
useTable({
|
||||||
|
columns: COLUMNS as any,
|
||||||
|
data: [environment],
|
||||||
|
disableSortBy: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledTable {...getTableProps()} rowHeight="compact">
|
||||||
|
<SortableTableHeader headerGroups={headerGroups as any} />
|
||||||
|
<TableBody {...getTableBodyProps()}>
|
||||||
|
{rows.map(row => {
|
||||||
|
prepareRow(row);
|
||||||
|
return (
|
||||||
|
<TableRow hover {...row.getRowProps()}>
|
||||||
|
{row.cells.map(cell => (
|
||||||
|
<TableCell {...cell.getCellProps()}>
|
||||||
|
{cell.render('Cell')}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</StyledTable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,60 +0,0 @@
|
|||||||
import { capitalize } from '@mui/material';
|
|
||||||
import { Alert } from '@mui/material';
|
|
||||||
import React from 'react';
|
|
||||||
import { IEnvironment } from 'interfaces/environments';
|
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
|
||||||
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
|
||||||
import EnvironmentCard from 'component/environments/EnvironmentCard/EnvironmentCard';
|
|
||||||
|
|
||||||
interface IEnvironmentToggleConfirmProps {
|
|
||||||
env: IEnvironment;
|
|
||||||
open: boolean;
|
|
||||||
setToggleDialog: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
handleConfirmToggleEnvironment: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EnvironmentToggleConfirm = ({
|
|
||||||
env,
|
|
||||||
open,
|
|
||||||
setToggleDialog,
|
|
||||||
handleConfirmToggleEnvironment,
|
|
||||||
}: IEnvironmentToggleConfirmProps) => {
|
|
||||||
let text = env.enabled ? 'disable' : 'enable';
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
setToggleDialog(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialogue
|
|
||||||
title={`Are you sure you want to ${text} this environment?`}
|
|
||||||
open={open}
|
|
||||||
primaryButtonText={capitalize(text)}
|
|
||||||
secondaryButtonText="Cancel"
|
|
||||||
onClick={handleConfirmToggleEnvironment}
|
|
||||||
onClose={handleCancel}
|
|
||||||
>
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={env.enabled}
|
|
||||||
show={
|
|
||||||
<Alert severity="info">
|
|
||||||
Disabling an environment will not effect any strategies
|
|
||||||
that already exist in that environment, but it will make
|
|
||||||
it unavailable as a selection option for new activation
|
|
||||||
strategies.
|
|
||||||
</Alert>
|
|
||||||
}
|
|
||||||
elseShow={
|
|
||||||
<Alert severity="info">
|
|
||||||
Enabling an environment will allow you to add new
|
|
||||||
activation strategies to this environment.
|
|
||||||
</Alert>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EnvironmentCard name={env?.name} type={env?.type} />
|
|
||||||
</Dialogue>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EnvironmentToggleConfirm;
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import { makeStyles } from 'tss-react/mui';
|
|
||||||
|
|
||||||
export const useStyles = makeStyles()(theme => ({
|
|
||||||
deleteParagraph: {
|
|
||||||
marginTop: '2rem',
|
|
||||||
},
|
|
||||||
environmentDeleteInput: {
|
|
||||||
marginTop: '1rem',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
import { Alert } from '@mui/material';
|
|
||||||
import React from 'react';
|
|
||||||
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
|
||||||
import Input from 'component/common/Input/Input';
|
|
||||||
import { useStyles } from './EnvironmentDisableConfirm.styles';
|
|
||||||
import { IProjectEnvironment } from 'interfaces/environments';
|
|
||||||
|
|
||||||
interface IEnvironmentDisableConfirmProps {
|
|
||||||
env?: IProjectEnvironment;
|
|
||||||
open: boolean;
|
|
||||||
handleDisableEnvironment: () => Promise<void>;
|
|
||||||
handleCancelDisableEnvironment: () => void;
|
|
||||||
confirmName: string;
|
|
||||||
setConfirmName: React.Dispatch<React.SetStateAction<string>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EnvironmentDisableConfirm = ({
|
|
||||||
env,
|
|
||||||
open,
|
|
||||||
handleDisableEnvironment,
|
|
||||||
handleCancelDisableEnvironment,
|
|
||||||
confirmName,
|
|
||||||
setConfirmName,
|
|
||||||
}: IEnvironmentDisableConfirmProps) => {
|
|
||||||
const { classes: styles } = useStyles();
|
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setConfirmName(e.currentTarget.value);
|
|
||||||
|
|
||||||
const formId = 'disable-environment-confirmation-form';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialogue
|
|
||||||
title="Are you sure you want to disable this environment?"
|
|
||||||
open={open}
|
|
||||||
primaryButtonText="Disable environment"
|
|
||||||
secondaryButtonText="Cancel"
|
|
||||||
onClick={() => handleDisableEnvironment()}
|
|
||||||
disabledPrimaryButton={env?.name !== confirmName}
|
|
||||||
onClose={handleCancelDisableEnvironment}
|
|
||||||
formId={formId}
|
|
||||||
>
|
|
||||||
<Alert severity="error">
|
|
||||||
Danger. Disabling an environment can impact client applications
|
|
||||||
connected to the environment and result in feature toggles being
|
|
||||||
disabled.
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<p className={styles.deleteParagraph}>
|
|
||||||
In order to disable this environment, please enter the id of the
|
|
||||||
environment in the textfield below: <strong>{env?.name}</strong>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form id={formId}>
|
|
||||||
<Input
|
|
||||||
autoFocus
|
|
||||||
onChange={handleChange}
|
|
||||||
value={confirmName}
|
|
||||||
label="Environment name"
|
|
||||||
className={styles.environmentDeleteInput}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Dialogue>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EnvironmentDisableConfirm;
|
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
import { styled, Alert } from '@mui/material';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { IProjectEnvironment } from 'interfaces/environments';
|
||||||
|
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||||
|
import Input from 'component/common/Input/Input';
|
||||||
|
import { ProjectEnvironmentTableSingle } from './ProjectEnvironmentTableSingle/ProjectEnvironmentTableSingle';
|
||||||
|
|
||||||
|
const StyledLabel = styled('p')(({ theme }) => ({
|
||||||
|
marginTop: theme.spacing(3),
|
||||||
|
marginBottom: theme.spacing(1.5),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledInput = styled(Input)(() => ({
|
||||||
|
width: '100%',
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IEnvironmentHideDialogProps {
|
||||||
|
environment?: IProjectEnvironment;
|
||||||
|
open: boolean;
|
||||||
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EnvironmentHideDialog = ({
|
||||||
|
environment,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
onConfirm,
|
||||||
|
}: IEnvironmentHideDialogProps) => {
|
||||||
|
const [confirmName, setConfirmName] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setConfirmName('');
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialogue
|
||||||
|
title="Hide environment and disable feature toggles?"
|
||||||
|
open={open}
|
||||||
|
primaryButtonText="Hide environment and disable feature toggles"
|
||||||
|
disabledPrimaryButton={environment?.name !== confirmName}
|
||||||
|
secondaryButtonText="Close"
|
||||||
|
onClick={onConfirm}
|
||||||
|
onClose={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Alert severity="error">
|
||||||
|
<strong>Danger!</strong> Hiding an environment will disable all
|
||||||
|
the feature toggles that are enabled in this environment and it
|
||||||
|
can impact client applications connected to the environment.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<ProjectEnvironmentTableSingle environment={environment!} />
|
||||||
|
|
||||||
|
<StyledLabel>
|
||||||
|
In order to hide this environment, please enter the id of the
|
||||||
|
environment in the textfield below:{' '}
|
||||||
|
<strong>{environment?.name}</strong>
|
||||||
|
</StyledLabel>
|
||||||
|
<StyledInput
|
||||||
|
label="Environment name"
|
||||||
|
value={confirmName}
|
||||||
|
onChange={e => setConfirmName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Dialogue>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
import { styled, TableBody, TableRow } from '@mui/material';
|
||||||
|
import { IProjectEnvironment } from 'interfaces/environments';
|
||||||
|
import { useTable } from 'react-table';
|
||||||
|
import { SortableTableHeader, Table, TableCell } from 'component/common/Table';
|
||||||
|
import { EnvironmentIconCell } from 'component/environments/EnvironmentTable/EnvironmentIconCell/EnvironmentIconCell';
|
||||||
|
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
const StyledTable = styled(Table)(({ theme }) => ({
|
||||||
|
marginTop: theme.spacing(3),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledToggleWarning = styled('p', {
|
||||||
|
shouldForwardProp: prop => prop !== 'warning',
|
||||||
|
})<{ warning?: boolean }>(({ theme, warning }) => ({
|
||||||
|
color: warning ? theme.palette.error.dark : theme.palette.text.primary,
|
||||||
|
textAlign: 'center',
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IProjectEnvironmentTableSingleProps {
|
||||||
|
environment: IProjectEnvironment;
|
||||||
|
warnEnabledToggles?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectEnvironmentTableSingle = ({
|
||||||
|
environment,
|
||||||
|
warnEnabledToggles,
|
||||||
|
}: IProjectEnvironmentTableSingleProps) => {
|
||||||
|
const COLUMNS = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'Icon',
|
||||||
|
width: '1%',
|
||||||
|
Cell: ({
|
||||||
|
row: { original },
|
||||||
|
}: {
|
||||||
|
row: { original: IProjectEnvironment };
|
||||||
|
}) => <EnvironmentIconCell environment={original} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Name',
|
||||||
|
accessor: 'name',
|
||||||
|
Cell: TextCell,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Type',
|
||||||
|
accessor: 'type',
|
||||||
|
Cell: TextCell,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Toggles enabled',
|
||||||
|
accessor: 'projectEnabledToggleCount',
|
||||||
|
Cell: ({ value }: { value: number }) => (
|
||||||
|
<TextCell>
|
||||||
|
<StyledToggleWarning warning={value > 0}>
|
||||||
|
{value === 1 ? '1 toggle' : `${value} toggles`}
|
||||||
|
</StyledToggleWarning>
|
||||||
|
</TextCell>
|
||||||
|
),
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[warnEnabledToggles]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
|
||||||
|
useTable({
|
||||||
|
columns: COLUMNS as any,
|
||||||
|
data: [environment],
|
||||||
|
disableSortBy: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledTable {...getTableProps()} rowHeight="compact">
|
||||||
|
<SortableTableHeader headerGroups={headerGroups as any} />
|
||||||
|
<TableBody {...getTableBodyProps()}>
|
||||||
|
{rows.map(row => {
|
||||||
|
prepareRow(row);
|
||||||
|
return (
|
||||||
|
<TableRow hover {...row.getRowProps()}>
|
||||||
|
{row.cells.map(cell => (
|
||||||
|
<TableCell {...cell.getCellProps()}>
|
||||||
|
{cell.render('Cell')}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</StyledTable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { useStyles } from './ProjectEnvironment.styles';
|
import { useStyles } from './ProjectEnvironment.styles';
|
||||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||||
@ -7,21 +7,36 @@ import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions';
|
|||||||
import ApiError from 'component/common/ApiError/ApiError';
|
import ApiError from 'component/common/ApiError/ApiError';
|
||||||
import useToast from 'hooks/useToast';
|
import useToast from 'hooks/useToast';
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
|
|
||||||
import useProject, {
|
import useProject, {
|
||||||
useProjectNameOrId,
|
useProjectNameOrId,
|
||||||
} from 'hooks/api/getters/useProject/useProject';
|
} from 'hooks/api/getters/useProject/useProject';
|
||||||
import { FormControlLabel, FormGroup, Alert } from '@mui/material';
|
import { Alert, styled, TableBody, TableRow } from '@mui/material';
|
||||||
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
|
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
|
||||||
import EnvironmentDisableConfirm from './EnvironmentDisableConfirm/EnvironmentDisableConfirm';
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch';
|
import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch';
|
||||||
import { IProjectEnvironment } from 'interfaces/environments';
|
import { IProjectEnvironment } from 'interfaces/environments';
|
||||||
import { getEnabledEnvs } from './helpers';
|
import { getEnabledEnvs } from './helpers';
|
||||||
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
|
|
||||||
import { useThemeStyles } from 'themes/themeStyles';
|
|
||||||
import { usePageTitle } from 'hooks/usePageTitle';
|
import { usePageTitle } from 'hooks/usePageTitle';
|
||||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
|
import { useTable, useGlobalFilter } from 'react-table';
|
||||||
|
import {
|
||||||
|
SortableTableHeader,
|
||||||
|
Table,
|
||||||
|
TableCell,
|
||||||
|
TablePlaceholder,
|
||||||
|
} from 'component/common/Table';
|
||||||
|
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||||
|
import { Search } from 'component/common/Search/Search';
|
||||||
|
import { EnvironmentNameCell } from 'component/environments/EnvironmentTable/EnvironmentNameCell/EnvironmentNameCell';
|
||||||
|
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
|
||||||
|
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
|
||||||
|
import { EnvironmentHideDialog } from './EnvironmentHideDialog/EnvironmentHideDialog';
|
||||||
|
import { useProjectEnvironments } from 'hooks/api/getters/useProjectEnvironments/useProjectEnvironments';
|
||||||
|
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
||||||
|
|
||||||
|
const StyledAlert = styled(Alert)(({ theme }) => ({
|
||||||
|
marginBottom: theme.spacing(4),
|
||||||
|
}));
|
||||||
|
|
||||||
const ProjectEnvironmentList = () => {
|
const ProjectEnvironmentList = () => {
|
||||||
const projectId = useRequiredPathParam('projectId');
|
const projectId = useRequiredPathParam('projectId');
|
||||||
@ -29,30 +44,31 @@ const ProjectEnvironmentList = () => {
|
|||||||
usePageTitle(`Project environments – ${projectName}`);
|
usePageTitle(`Project environments – ${projectName}`);
|
||||||
|
|
||||||
// api state
|
// api state
|
||||||
const [envs, setEnvs] = useState<IProjectEnvironment[]>([]);
|
|
||||||
const { setToastData, setToastApiError } = useToast();
|
const { setToastData, setToastApiError } = useToast();
|
||||||
const { uiConfig } = useUiConfig();
|
const { uiConfig } = useUiConfig();
|
||||||
const { environments, loading, error, refetchEnvironments } =
|
const { environments, loading, error, refetchEnvironments } =
|
||||||
useEnvironments();
|
useProjectEnvironments(projectId);
|
||||||
const { project, refetch: refetchProject } = useProject(projectId);
|
const { project, refetch: refetchProject } = useProject(projectId);
|
||||||
const { removeEnvironmentFromProject, addEnvironmentToProject } =
|
const { removeEnvironmentFromProject, addEnvironmentToProject } =
|
||||||
useProjectApi();
|
useProjectApi();
|
||||||
const { classes: themeStyles } = useThemeStyles();
|
|
||||||
|
|
||||||
// local state
|
// local state
|
||||||
const [selectedEnv, setSelectedEnv] = useState<IProjectEnvironment>();
|
const [selectedEnvironment, setSelectedEnvironment] =
|
||||||
const [confirmName, setConfirmName] = useState('');
|
useState<IProjectEnvironment>();
|
||||||
|
const [hideDialog, setHideDialog] = useState(false);
|
||||||
const { classes: styles } = useStyles();
|
const { classes: styles } = useStyles();
|
||||||
const { isOss } = useUiConfig();
|
const { isOss } = useUiConfig();
|
||||||
|
|
||||||
useEffect(() => {
|
const projectEnvironments = useMemo<IProjectEnvironment[]>(
|
||||||
const envs = environments.map(e => ({
|
() =>
|
||||||
name: e.name,
|
environments.map(environment => ({
|
||||||
enabled: project?.environments.includes(e.name),
|
...environment,
|
||||||
}));
|
projectVisible: project?.environments.includes(
|
||||||
|
environment.name
|
||||||
setEnvs(envs);
|
),
|
||||||
}, [environments, project?.environments]);
|
})),
|
||||||
|
[environments, project?.environments]
|
||||||
|
);
|
||||||
|
|
||||||
const refetch = () => {
|
const refetch = () => {
|
||||||
refetchEnvironments();
|
refetchEnvironments();
|
||||||
@ -70,119 +86,147 @@ const ProjectEnvironmentList = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const errorMsg = (enable: boolean): string => {
|
const errorMsg = (enable: boolean): string => {
|
||||||
return `Got an API error when trying to ${
|
return `Got an API error when trying to set the environment as ${
|
||||||
enable ? 'enable' : 'disable'
|
enable ? 'visible' : 'hidden'
|
||||||
} the environment.`;
|
}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleEnv = async (env: IProjectEnvironment) => {
|
const toggleEnv = async (env: IProjectEnvironment) => {
|
||||||
if (env.enabled) {
|
if (env.projectVisible) {
|
||||||
const enabledEnvs = getEnabledEnvs(envs);
|
const enabledEnvs = getEnabledEnvs(projectEnvironments);
|
||||||
|
|
||||||
if (enabledEnvs > 1) {
|
if (enabledEnvs > 1) {
|
||||||
setSelectedEnv(env);
|
setSelectedEnvironment(env);
|
||||||
|
setHideDialog(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setToastData({
|
setToastData({
|
||||||
title: 'One environment must be active',
|
title: 'One environment must be visible',
|
||||||
text: 'You must always have at least one active environment per project',
|
text: 'You must always have at least one visible environment per project',
|
||||||
type: 'error',
|
type: 'error',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
await addEnvironmentToProject(projectId, env.name);
|
await addEnvironmentToProject(projectId, env.name);
|
||||||
|
refetch();
|
||||||
setToastData({
|
setToastData({
|
||||||
title: 'Environment enabled',
|
title: 'Environment set as visible',
|
||||||
text: 'Environment successfully enabled. You can now use it to segment strategies in your feature toggles.',
|
text: 'Environment successfully set as visible.',
|
||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setToastApiError(errorMsg(true));
|
setToastApiError(errorMsg(true));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
refetch();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDisableEnvironment = async () => {
|
const onHideConfirm = async () => {
|
||||||
if (selectedEnv && confirmName === selectedEnv.name) {
|
if (selectedEnvironment) {
|
||||||
try {
|
try {
|
||||||
await removeEnvironmentFromProject(projectId, selectedEnv.name);
|
await removeEnvironmentFromProject(
|
||||||
setSelectedEnv(undefined);
|
projectId,
|
||||||
setConfirmName('');
|
selectedEnvironment.name
|
||||||
|
);
|
||||||
|
refetch();
|
||||||
setToastData({
|
setToastData({
|
||||||
title: 'Environment disabled',
|
title: 'Environment set as hidden',
|
||||||
text: 'Environment successfully disabled.',
|
text: 'Environment successfully set as hidden.',
|
||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setToastApiError(errorMsg(false));
|
setToastApiError(errorMsg(false));
|
||||||
|
} finally {
|
||||||
|
setHideDialog(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
refetch();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelDisableEnvironment = () => {
|
|
||||||
setSelectedEnv(undefined);
|
|
||||||
setConfirmName('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const genLabel = (env: IProjectEnvironment) => (
|
|
||||||
<div className={themeStyles.flexRow}>
|
|
||||||
<code>
|
|
||||||
<StringTruncator
|
|
||||||
text={env.name}
|
|
||||||
maxLength={50}
|
|
||||||
maxWidth="150"
|
|
||||||
/>
|
|
||||||
</code>
|
|
||||||
{/* This is ugly - but regular {" "} doesn't work here*/}
|
|
||||||
<p>
|
|
||||||
environment is{' '}
|
|
||||||
<strong>{env.enabled ? 'enabled' : 'disabled'}</strong>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const envIsDisabled = (projectName: string) => {
|
const envIsDisabled = (projectName: string) => {
|
||||||
return isOss() && projectName === 'default';
|
return isOss() && projectName === 'default';
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderEnvironments = () => {
|
const COLUMNS = useMemo(
|
||||||
return (
|
() => [
|
||||||
<FormGroup>
|
{
|
||||||
{envs.map(env => (
|
Header: 'Name',
|
||||||
<FormControlLabel
|
accessor: 'name',
|
||||||
key={env.name}
|
Cell: ({ row: { original } }: any) => (
|
||||||
label={genLabel(env)}
|
<EnvironmentNameCell environment={original} />
|
||||||
control={
|
),
|
||||||
|
minWidth: 350,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Type',
|
||||||
|
accessor: 'type',
|
||||||
|
Cell: HighlightCell,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Project API tokens',
|
||||||
|
accessor: (row: IProjectEnvironment) =>
|
||||||
|
row.projectApiTokenCount === 1
|
||||||
|
? '1 token'
|
||||||
|
: `${row.projectApiTokenCount} tokens`,
|
||||||
|
Cell: TextCell,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Visible in project',
|
||||||
|
accessor: 'enabled',
|
||||||
|
align: 'center',
|
||||||
|
width: 1,
|
||||||
|
Cell: ({ row: { original } }: any) => (
|
||||||
|
<ActionCell>
|
||||||
<PermissionSwitch
|
<PermissionSwitch
|
||||||
tooltip={`${
|
tooltip={
|
||||||
env.enabled ? 'Disable' : 'Enable'
|
original.projectVisible
|
||||||
} environment`}
|
? 'Hide environment and disable feature toggles'
|
||||||
|
: 'Make it visible'
|
||||||
|
}
|
||||||
size="medium"
|
size="medium"
|
||||||
disabled={envIsDisabled(env.name)}
|
disabled={envIsDisabled(original.name)}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
permission={UPDATE_PROJECT}
|
permission={UPDATE_PROJECT}
|
||||||
checked={env.enabled}
|
checked={original.projectVisible}
|
||||||
onChange={() => toggleEnv(env)}
|
onChange={() => toggleEnv(original)}
|
||||||
|
/>
|
||||||
|
</ActionCell>
|
||||||
|
),
|
||||||
|
disableGlobalFilter: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[projectEnvironments]
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
getTableProps,
|
||||||
|
getTableBodyProps,
|
||||||
|
headerGroups,
|
||||||
|
rows,
|
||||||
|
prepareRow,
|
||||||
|
state: { globalFilter },
|
||||||
|
setGlobalFilter,
|
||||||
|
} = useTable(
|
||||||
|
{
|
||||||
|
columns: COLUMNS as any,
|
||||||
|
data: projectEnvironments,
|
||||||
|
disableSortBy: true,
|
||||||
|
},
|
||||||
|
useGlobalFilter
|
||||||
|
);
|
||||||
|
|
||||||
|
const header = (
|
||||||
|
<PageHeader
|
||||||
|
title={`Environments (${rows.length})`}
|
||||||
|
actions={
|
||||||
|
<Search
|
||||||
|
initialValue={globalFilter}
|
||||||
|
onChange={setGlobalFilter}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</FormGroup>
|
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContent
|
<PageContent header={header} isLoading={loading}>
|
||||||
header={
|
|
||||||
<PageHeader
|
|
||||||
titleElement={`Configure environments for "${project?.name}" project`}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
isLoading={loading}
|
|
||||||
>
|
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={uiConfig.flags.E}
|
condition={uiConfig.flags.E}
|
||||||
show={
|
show={
|
||||||
@ -191,34 +235,76 @@ const ProjectEnvironmentList = () => {
|
|||||||
condition={Boolean(error)}
|
condition={Boolean(error)}
|
||||||
show={renderError()}
|
show={renderError()}
|
||||||
/>
|
/>
|
||||||
<Alert severity="info" style={{ marginBottom: '20px' }}>
|
<StyledAlert severity="info">
|
||||||
<b>Important!</b> In order for your application to
|
<strong>Important!</strong> In order for your
|
||||||
retrieve configured activation strategies for a
|
application to retrieve configured activation
|
||||||
specific environment, the application
|
strategies for a specific environment, the
|
||||||
<br /> must use an environment specific API key. You
|
application must use an environment specific API
|
||||||
can look up the environment-specific API keys{' '}
|
token. You can look up the environment-specific{' '}
|
||||||
<Link to="/admin/api">here.</Link>
|
<Link to="/admin/api">API tokens here</Link>.
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
Your administrator can configure an
|
Your administrator can configure an
|
||||||
environment-specific API key to be used in the SDK.
|
environment-specific API token to be used in the
|
||||||
If you are an administrator you can{' '}
|
SDK. If you are an administrator you can{' '}
|
||||||
<Link to="/admin/api">create a new API key.</Link>
|
<Link to="/admin/api">
|
||||||
</Alert>
|
create a new API token here
|
||||||
<ConditionallyRender
|
</Link>
|
||||||
condition={environments.length < 1 && !loading}
|
.
|
||||||
show={<div>No environments available.</div>}
|
</StyledAlert>
|
||||||
elseShow={renderEnvironments()}
|
<SearchHighlightProvider value={globalFilter}>
|
||||||
|
<Table {...getTableProps()} rowHeight="compact">
|
||||||
|
<SortableTableHeader
|
||||||
|
headerGroups={headerGroups as any}
|
||||||
/>
|
/>
|
||||||
<EnvironmentDisableConfirm
|
<TableBody {...getTableBodyProps()}>
|
||||||
env={selectedEnv}
|
{rows.map(row => {
|
||||||
open={Boolean(selectedEnv)}
|
prepareRow(row);
|
||||||
handleDisableEnvironment={handleDisableEnvironment}
|
return (
|
||||||
handleCancelDisableEnvironment={
|
<TableRow
|
||||||
handleCancelDisableEnvironment
|
hover
|
||||||
|
{...row.getRowProps()}
|
||||||
|
>
|
||||||
|
{row.cells.map(cell => (
|
||||||
|
<TableCell
|
||||||
|
{...cell.getCellProps()}
|
||||||
|
>
|
||||||
|
{cell.render('Cell')}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</SearchHighlightProvider>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={rows.length === 0}
|
||||||
|
show={
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={globalFilter?.length > 0}
|
||||||
|
show={
|
||||||
|
<TablePlaceholder>
|
||||||
|
No environments found matching
|
||||||
|
“
|
||||||
|
{globalFilter}
|
||||||
|
”
|
||||||
|
</TablePlaceholder>
|
||||||
}
|
}
|
||||||
confirmName={confirmName}
|
elseShow={
|
||||||
setConfirmName={setConfirmName}
|
<TablePlaceholder>
|
||||||
|
No environments available. Get
|
||||||
|
started by adding one.
|
||||||
|
</TablePlaceholder>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<EnvironmentHideDialog
|
||||||
|
environment={selectedEnvironment}
|
||||||
|
open={hideDialog}
|
||||||
|
setOpen={setHideDialog}
|
||||||
|
onConfirm={onHideConfirm}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
|
import { IProjectEnvironment } from 'interfaces/environments';
|
||||||
import { getEnabledEnvs } from './helpers';
|
import { getEnabledEnvs } from './helpers';
|
||||||
|
|
||||||
const generateEnv = (enabled: boolean, name: string) => {
|
const generateEnv = (enabled: boolean, name: string): IProjectEnvironment => {
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
|
type: 'development',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
sortOrder: 0,
|
||||||
|
protected: false,
|
||||||
enabled,
|
enabled,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,49 @@
|
|||||||
|
import useSWR from 'swr';
|
||||||
|
import { useMemo, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
IEnvironmentResponse,
|
||||||
|
IProjectEnvironment,
|
||||||
|
} from 'interfaces/environments';
|
||||||
|
import { formatApiPath } from 'utils/formatPath';
|
||||||
|
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||||
|
|
||||||
|
interface IUseProjectEnvironmentsOutput {
|
||||||
|
environments: IProjectEnvironment[];
|
||||||
|
loading: boolean;
|
||||||
|
error?: Error;
|
||||||
|
refetchEnvironments: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useProjectEnvironments = (
|
||||||
|
projectId: string
|
||||||
|
): IUseProjectEnvironmentsOutput => {
|
||||||
|
const { data, error, mutate } = useSWR<IProjectEnvironment[]>(
|
||||||
|
formatApiPath(`api/admin/environments/project/${projectId}`),
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
const environments = useMemo(() => {
|
||||||
|
return data || [];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const refetchEnvironments = useCallback(async () => {
|
||||||
|
await mutate();
|
||||||
|
}, [mutate]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
environments,
|
||||||
|
refetchEnvironments,
|
||||||
|
loading: !error && !data,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetcher = async (path: string): Promise<IProjectEnvironment[]> => {
|
||||||
|
const res: IEnvironmentResponse = await fetch(path)
|
||||||
|
.then(handleErrorResponses('Environments'))
|
||||||
|
.then(res => res.json());
|
||||||
|
|
||||||
|
return res.environments.sort((a, b) => {
|
||||||
|
return a.sortOrder - b.sortOrder;
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -6,18 +6,21 @@ export type MoveListItem = (
|
|||||||
save?: boolean
|
save?: boolean
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
export const useDragItem = (
|
export const useDragItem = <T extends HTMLElement>(
|
||||||
listItemIndex: number,
|
listItemIndex: number,
|
||||||
moveListItem: MoveListItem
|
moveListItem: MoveListItem,
|
||||||
): RefObject<HTMLTableRowElement> => {
|
handle?: RefObject<HTMLElement>
|
||||||
const ref = useRef<HTMLTableRowElement>(null);
|
): RefObject<T> => {
|
||||||
|
const ref = useRef<T>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ref.current) {
|
if (ref.current) {
|
||||||
ref.current.draggable = true;
|
|
||||||
ref.current.style.cursor = 'grab';
|
|
||||||
ref.current.dataset.index = String(listItemIndex);
|
ref.current.dataset.index = String(listItemIndex);
|
||||||
return addEventListeners(ref.current, moveListItem);
|
return addEventListeners(
|
||||||
|
ref.current,
|
||||||
|
moveListItem,
|
||||||
|
handle?.current ?? undefined
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [listItemIndex, moveListItem]);
|
}, [listItemIndex, moveListItem]);
|
||||||
|
|
||||||
@ -25,8 +28,9 @@ export const useDragItem = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addEventListeners = (
|
const addEventListeners = (
|
||||||
el: HTMLTableRowElement,
|
el: HTMLElement,
|
||||||
moveListItem: MoveListItem
|
moveListItem: MoveListItem,
|
||||||
|
handle?: HTMLElement
|
||||||
): (() => void) => {
|
): (() => void) => {
|
||||||
const moveDraggedElement = (save: boolean) => {
|
const moveDraggedElement = (save: boolean) => {
|
||||||
if (globalDraggedElement) {
|
if (globalDraggedElement) {
|
||||||
@ -38,7 +42,20 @@ const addEventListeners = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEl = handle ?? el;
|
||||||
|
|
||||||
|
const onMouseEnter = (e: MouseEvent) => {
|
||||||
|
if (e.target === handleEl) {
|
||||||
|
el.draggable = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseLeave = () => {
|
||||||
|
el.draggable = false;
|
||||||
|
};
|
||||||
|
|
||||||
const onDragStart = () => {
|
const onDragStart = () => {
|
||||||
|
el.draggable = true;
|
||||||
globalDraggedElement = el;
|
globalDraggedElement = el;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -55,12 +72,16 @@ const addEventListeners = (
|
|||||||
globalDraggedElement = null;
|
globalDraggedElement = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleEl.addEventListener('mouseenter', onMouseEnter);
|
||||||
|
handleEl.addEventListener('mouseleave', onMouseLeave);
|
||||||
el.addEventListener('dragstart', onDragStart);
|
el.addEventListener('dragstart', onDragStart);
|
||||||
el.addEventListener('dragenter', onDragEnter);
|
el.addEventListener('dragenter', onDragEnter);
|
||||||
el.addEventListener('dragover', onDragOver);
|
el.addEventListener('dragover', onDragOver);
|
||||||
el.addEventListener('drop', onDrop);
|
el.addEventListener('drop', onDrop);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
handleEl.removeEventListener('mouseenter', onMouseEnter);
|
||||||
|
handleEl.removeEventListener('mouseleave', onMouseLeave);
|
||||||
el.removeEventListener('dragstart', onDragStart);
|
el.removeEventListener('dragstart', onDragStart);
|
||||||
el.removeEventListener('dragenter', onDragEnter);
|
el.removeEventListener('dragenter', onDragEnter);
|
||||||
el.removeEventListener('dragover', onDragOver);
|
el.removeEventListener('dragover', onDragOver);
|
||||||
@ -69,4 +90,4 @@ const addEventListeners = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
// The element being dragged in the browser.
|
// The element being dragged in the browser.
|
||||||
let globalDraggedElement: HTMLTableRowElement | null;
|
let globalDraggedElement: HTMLElement | null;
|
||||||
|
|||||||
@ -5,11 +5,15 @@ export interface IEnvironment {
|
|||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
protected: boolean;
|
protected: boolean;
|
||||||
|
projectCount?: number;
|
||||||
|
apiTokenCount?: number;
|
||||||
|
enabledToggleCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IProjectEnvironment {
|
export interface IProjectEnvironment extends IEnvironment {
|
||||||
enabled: boolean;
|
projectVisible?: boolean;
|
||||||
name: string;
|
projectApiTokenCount?: number;
|
||||||
|
projectEnabledToggleCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IEnvironmentPayload {
|
export interface IEnvironmentPayload {
|
||||||
|
|||||||
@ -3,7 +3,11 @@ import { Knex } from 'knex';
|
|||||||
import { Logger, LogProvider } from '../logger';
|
import { Logger, LogProvider } from '../logger';
|
||||||
import metricsHelper from '../util/metrics-helper';
|
import metricsHelper from '../util/metrics-helper';
|
||||||
import { DB_TIME } from '../metric-events';
|
import { DB_TIME } from '../metric-events';
|
||||||
import { IEnvironment, IEnvironmentCreate } from '../types/model';
|
import {
|
||||||
|
IEnvironment,
|
||||||
|
IEnvironmentCreate,
|
||||||
|
IProjectEnvironment,
|
||||||
|
} from '../types/model';
|
||||||
import NotFoundError from '../error/notfound-error';
|
import NotFoundError from '../error/notfound-error';
|
||||||
import { IEnvironmentStore } from '../types/stores/environment-store';
|
import { IEnvironmentStore } from '../types/stores/environment-store';
|
||||||
import { snakeCaseKeys } from '../util/snakeCase';
|
import { snakeCaseKeys } from '../util/snakeCase';
|
||||||
@ -17,6 +21,17 @@ interface IEnvironmentsTable {
|
|||||||
protected: boolean;
|
protected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IEnvironmentsWithCountsTable extends IEnvironmentsTable {
|
||||||
|
project_count?: string;
|
||||||
|
api_token_count?: string;
|
||||||
|
enabled_toggle_count?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IEnvironmentsWithProjectCountsTable extends IEnvironmentsTable {
|
||||||
|
project_api_token_count?: string;
|
||||||
|
project_enabled_toggle_count?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
'type',
|
'type',
|
||||||
'name',
|
'name',
|
||||||
@ -36,6 +51,35 @@ function mapRow(row: IEnvironmentsTable): IEnvironment {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapRowWithCounts(
|
||||||
|
row: IEnvironmentsWithCountsTable,
|
||||||
|
): IProjectEnvironment {
|
||||||
|
return {
|
||||||
|
...mapRow(row),
|
||||||
|
projectCount: row.project_count ? parseInt(row.project_count, 10) : 0,
|
||||||
|
apiTokenCount: row.api_token_count
|
||||||
|
? parseInt(row.api_token_count, 10)
|
||||||
|
: 0,
|
||||||
|
enabledToggleCount: row.enabled_toggle_count
|
||||||
|
? parseInt(row.enabled_toggle_count, 10)
|
||||||
|
: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRowWithProjectCounts(
|
||||||
|
row: IEnvironmentsWithProjectCountsTable,
|
||||||
|
): IProjectEnvironment {
|
||||||
|
return {
|
||||||
|
...mapRow(row),
|
||||||
|
projectApiTokenCount: row.project_api_token_count
|
||||||
|
? parseInt(row.project_api_token_count, 10)
|
||||||
|
: 0,
|
||||||
|
projectEnabledToggleCount: row.project_enabled_toggle_count
|
||||||
|
? parseInt(row.project_enabled_toggle_count, 10)
|
||||||
|
: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function fieldToRow(env: IEnvironment): IEnvironmentsTable {
|
function fieldToRow(env: IEnvironment): IEnvironmentsTable {
|
||||||
return {
|
return {
|
||||||
name: env.name,
|
name: env.name,
|
||||||
@ -112,6 +156,54 @@ export default class EnvironmentStore implements IEnvironmentStore {
|
|||||||
return rows.map(mapRow);
|
return rows.map(mapRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAllWithCounts(query?: Object): Promise<IEnvironment[]> {
|
||||||
|
let qB = this.db<IEnvironmentsWithCountsTable>(TABLE)
|
||||||
|
.select(
|
||||||
|
'*',
|
||||||
|
this.db.raw(
|
||||||
|
'(SELECT COUNT(*) FROM project_environments WHERE project_environments.environment_name = environments.name) as project_count',
|
||||||
|
),
|
||||||
|
this.db.raw(
|
||||||
|
'(SELECT COUNT(*) FROM api_tokens WHERE api_tokens.environment = environments.name) as api_token_count',
|
||||||
|
),
|
||||||
|
this.db.raw(
|
||||||
|
'(SELECT COUNT(*) FROM feature_environments WHERE enabled=true AND feature_environments.environment = environments.name) as enabled_toggle_count',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy([
|
||||||
|
{ column: 'sort_order', order: 'asc' },
|
||||||
|
{ column: 'created_at', order: 'asc' },
|
||||||
|
]);
|
||||||
|
if (query) {
|
||||||
|
qB = qB.where(query);
|
||||||
|
}
|
||||||
|
const rows = await qB;
|
||||||
|
return rows.map(mapRowWithCounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProjectEnvironments(
|
||||||
|
projectId: string,
|
||||||
|
): Promise<IProjectEnvironment[]> {
|
||||||
|
let qB = this.db<IEnvironmentsWithProjectCountsTable>(TABLE)
|
||||||
|
.select(
|
||||||
|
'*',
|
||||||
|
this.db.raw(
|
||||||
|
'(SELECT COUNT(*) FROM api_tokens LEFT JOIN api_token_project ON api_tokens.secret = api_token_project.secret WHERE api_tokens.environment = environments.name AND (project = :projectId OR project IS null)) as project_api_token_count',
|
||||||
|
{ projectId },
|
||||||
|
),
|
||||||
|
this.db.raw(
|
||||||
|
'(SELECT COUNT(*) FROM feature_environments INNER JOIN features on feature_environments.feature_name = features.name WHERE enabled=true AND feature_environments.environment = environments.name AND project = :projectId) as project_enabled_toggle_count',
|
||||||
|
{ projectId },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy([
|
||||||
|
{ column: 'sort_order', order: 'asc' },
|
||||||
|
{ column: 'created_at', order: 'asc' },
|
||||||
|
]);
|
||||||
|
const rows = await qB;
|
||||||
|
return rows.map(mapRowWithProjectCounts);
|
||||||
|
}
|
||||||
|
|
||||||
async exists(name: string): Promise<boolean> {
|
async exists(name: string): Promise<boolean> {
|
||||||
const result = await this.db.raw(
|
const result = await this.db.raw(
|
||||||
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE name = ?) AS present`,
|
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE name = ?) AS present`,
|
||||||
|
|||||||
@ -26,7 +26,9 @@ import { dateSchema } from './spec/date-schema';
|
|||||||
import { edgeTokenSchema } from './spec/edge-token-schema';
|
import { edgeTokenSchema } from './spec/edge-token-schema';
|
||||||
import { emailSchema } from './spec/email-schema';
|
import { emailSchema } from './spec/email-schema';
|
||||||
import { environmentSchema } from './spec/environment-schema';
|
import { environmentSchema } from './spec/environment-schema';
|
||||||
|
import { environmentProjectSchema } from './spec/environment-project-schema';
|
||||||
import { environmentsSchema } from './spec/environments-schema';
|
import { environmentsSchema } from './spec/environments-schema';
|
||||||
|
import { environmentsProjectSchema } from './spec/environments-project-schema';
|
||||||
import { eventSchema } from './spec/event-schema';
|
import { eventSchema } from './spec/event-schema';
|
||||||
import { eventsSchema } from './spec/events-schema';
|
import { eventsSchema } from './spec/events-schema';
|
||||||
import { featureEnvironmentMetricsSchema } from './spec/feature-environment-metrics-schema';
|
import { featureEnvironmentMetricsSchema } from './spec/feature-environment-metrics-schema';
|
||||||
@ -153,7 +155,9 @@ export const schemas = {
|
|||||||
edgeTokenSchema,
|
edgeTokenSchema,
|
||||||
emailSchema,
|
emailSchema,
|
||||||
environmentSchema,
|
environmentSchema,
|
||||||
|
environmentProjectSchema,
|
||||||
environmentsSchema,
|
environmentsSchema,
|
||||||
|
environmentsProjectSchema,
|
||||||
eventSchema,
|
eventSchema,
|
||||||
eventsSchema,
|
eventsSchema,
|
||||||
featureEnvironmentMetricsSchema,
|
featureEnvironmentMetricsSchema,
|
||||||
|
|||||||
38
src/lib/openapi/spec/environment-project-schema.ts
Normal file
38
src/lib/openapi/spec/environment-project-schema.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { FromSchema } from 'json-schema-to-ts';
|
||||||
|
|
||||||
|
export const environmentProjectSchema = {
|
||||||
|
$id: '#/components/schemas/environmentProjectSchema',
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['name', 'type', 'enabled'],
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
enabled: {
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
protected: {
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
sortOrder: {
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
projectApiTokenCount: {
|
||||||
|
type: 'number',
|
||||||
|
nullable: true,
|
||||||
|
},
|
||||||
|
projectEnabledToggleCount: {
|
||||||
|
type: 'number',
|
||||||
|
nullable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type EnvironmentProjectSchema = FromSchema<
|
||||||
|
typeof environmentProjectSchema
|
||||||
|
>;
|
||||||
@ -21,6 +21,18 @@ export const environmentSchema = {
|
|||||||
sortOrder: {
|
sortOrder: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
},
|
},
|
||||||
|
projectCount: {
|
||||||
|
type: 'number',
|
||||||
|
nullable: true,
|
||||||
|
},
|
||||||
|
apiTokenCount: {
|
||||||
|
type: 'number',
|
||||||
|
nullable: true,
|
||||||
|
},
|
||||||
|
enabledToggleCount: {
|
||||||
|
type: 'number',
|
||||||
|
nullable: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
components: {},
|
components: {},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
29
src/lib/openapi/spec/environments-project-schema.ts
Normal file
29
src/lib/openapi/spec/environments-project-schema.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { FromSchema } from 'json-schema-to-ts';
|
||||||
|
import { environmentProjectSchema } from './environment-project-schema';
|
||||||
|
|
||||||
|
export const environmentsProjectSchema = {
|
||||||
|
$id: '#/components/schemas/environmentsProjectSchema',
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['version', 'environments'],
|
||||||
|
properties: {
|
||||||
|
version: {
|
||||||
|
type: 'integer',
|
||||||
|
},
|
||||||
|
environments: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
$ref: '#/components/schemas/environmentProjectSchema',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
schemas: {
|
||||||
|
environmentProjectSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type EnvironmentsProjectSchema = FromSchema<
|
||||||
|
typeof environmentsProjectSchema
|
||||||
|
>;
|
||||||
@ -18,11 +18,19 @@ import {
|
|||||||
} from '../../openapi/spec/environment-schema';
|
} from '../../openapi/spec/environment-schema';
|
||||||
import { SortOrderSchema } from '../../openapi/spec/sort-order-schema';
|
import { SortOrderSchema } from '../../openapi/spec/sort-order-schema';
|
||||||
import { emptyResponse } from '../../openapi/util/standard-responses';
|
import { emptyResponse } from '../../openapi/util/standard-responses';
|
||||||
|
import {
|
||||||
|
environmentsProjectSchema,
|
||||||
|
EnvironmentsProjectSchema,
|
||||||
|
} from '../../openapi/spec/environments-project-schema';
|
||||||
|
|
||||||
interface EnvironmentParam {
|
interface EnvironmentParam {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProjectParam {
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class EnvironmentsController extends Controller {
|
export class EnvironmentsController extends Controller {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
@ -72,6 +80,22 @@ export class EnvironmentsController extends Controller {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.route({
|
||||||
|
method: 'get',
|
||||||
|
path: '/project/:projectId',
|
||||||
|
handler: this.getProjectEnvironments,
|
||||||
|
permission: NONE,
|
||||||
|
middleware: [
|
||||||
|
openApiService.validPath({
|
||||||
|
tags: ['Environments'],
|
||||||
|
operationId: 'getProjectEnvironments',
|
||||||
|
responses: {
|
||||||
|
200: createResponseSchema('environmentsProjectSchema'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
this.route({
|
this.route({
|
||||||
method: 'put',
|
method: 'put',
|
||||||
path: '/sort-order',
|
path: '/sort-order',
|
||||||
@ -167,4 +191,21 @@ export class EnvironmentsController extends Controller {
|
|||||||
await this.service.get(req.params.name),
|
await this.service.get(req.params.name),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getProjectEnvironments(
|
||||||
|
req: Request<ProjectParam>,
|
||||||
|
res: Response<EnvironmentsProjectSchema>,
|
||||||
|
): Promise<void> {
|
||||||
|
this.openApiService.respondWithValidation(
|
||||||
|
200,
|
||||||
|
res,
|
||||||
|
environmentsProjectSchema.$id,
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
environments: await this.service.getProjectEnvironments(
|
||||||
|
req.params.projectId,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { IUnleashStores } from '../types/stores';
|
import { IUnleashStores } from '../types/stores';
|
||||||
import { IUnleashConfig } from '../types/option';
|
import { IUnleashConfig } from '../types/option';
|
||||||
import { Logger } from '../logger';
|
import { Logger } from '../logger';
|
||||||
import { IEnvironment, ISortOrder } from '../types/model';
|
import { IEnvironment, IProjectEnvironment, ISortOrder } from '../types/model';
|
||||||
import { UNIQUE_CONSTRAINT_VIOLATION } from '../error/db-error';
|
import { UNIQUE_CONSTRAINT_VIOLATION } from '../error/db-error';
|
||||||
import NameExistsError from '../error/name-exists-error';
|
import NameExistsError from '../error/name-exists-error';
|
||||||
import { sortOrderSchema } from './state-schema';
|
import { sortOrderSchema } from './state-schema';
|
||||||
@ -46,13 +46,19 @@ export default class EnvironmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getAll(): Promise<IEnvironment[]> {
|
async getAll(): Promise<IEnvironment[]> {
|
||||||
return this.environmentStore.getAll();
|
return this.environmentStore.getAllWithCounts();
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(name: string): Promise<IEnvironment> {
|
async get(name: string): Promise<IEnvironment> {
|
||||||
return this.environmentStore.get(name);
|
return this.environmentStore.get(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getProjectEnvironments(
|
||||||
|
projectId: string,
|
||||||
|
): Promise<IProjectEnvironment[]> {
|
||||||
|
return this.environmentStore.getProjectEnvironments(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
async updateSortOrder(sortOrder: ISortOrder): Promise<void> {
|
async updateSortOrder(sortOrder: ISortOrder): Promise<void> {
|
||||||
await sortOrderSchema.validateAsync(sortOrder);
|
await sortOrderSchema.validateAsync(sortOrder);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
|
|||||||
@ -122,6 +122,14 @@ export interface IEnvironment {
|
|||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
protected: boolean;
|
protected: boolean;
|
||||||
|
projectCount?: number;
|
||||||
|
apiTokenCount?: number;
|
||||||
|
enabledToggleCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IProjectEnvironment extends IEnvironment {
|
||||||
|
projectApiTokenCount?: number;
|
||||||
|
projectEnabledToggleCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IEnvironmentCreate {
|
export interface IEnvironmentCreate {
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
import { IEnvironment, IEnvironmentCreate } from '../model';
|
import {
|
||||||
|
IEnvironment,
|
||||||
|
IEnvironmentCreate,
|
||||||
|
IProjectEnvironment,
|
||||||
|
} from '../model';
|
||||||
import { Store } from './store';
|
import { Store } from './store';
|
||||||
|
|
||||||
export interface IEnvironmentStore extends Store<IEnvironment, string> {
|
export interface IEnvironmentStore extends Store<IEnvironment, string> {
|
||||||
@ -19,4 +23,6 @@ export interface IEnvironmentStore extends Store<IEnvironment, string> {
|
|||||||
disable(environments: IEnvironment[]): Promise<void>;
|
disable(environments: IEnvironment[]): Promise<void>;
|
||||||
enable(environments: IEnvironment[]): Promise<void>;
|
enable(environments: IEnvironment[]): Promise<void>;
|
||||||
count(): Promise<number>;
|
count(): Promise<number>;
|
||||||
|
getAllWithCounts(): Promise<IEnvironment[]>;
|
||||||
|
getProjectEnvironments(projectId: string): Promise<IProjectEnvironment[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,9 @@ test('Can list all existing environments', async () => {
|
|||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
type: 'production',
|
type: 'production',
|
||||||
protected: true,
|
protected: true,
|
||||||
|
projectCount: 1,
|
||||||
|
apiTokenCount: 0,
|
||||||
|
enabledToggleCount: 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -828,7 +828,7 @@ exports[`should serve the OpenAPI spec 1`] = `
|
|||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
},
|
},
|
||||||
"environmentSchema": {
|
"environmentProjectSchema": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": {
|
"enabled": {
|
||||||
@ -837,6 +837,14 @@ exports[`should serve the OpenAPI spec 1`] = `
|
|||||||
"name": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
},
|
},
|
||||||
|
"projectApiTokenCount": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "number",
|
||||||
|
},
|
||||||
|
"projectEnabledToggleCount": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "number",
|
||||||
|
},
|
||||||
"protected": {
|
"protected": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
},
|
},
|
||||||
@ -854,6 +862,63 @@ exports[`should serve the OpenAPI spec 1`] = `
|
|||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
},
|
},
|
||||||
|
"environmentSchema": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"apiTokenCount": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "number",
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
},
|
||||||
|
"enabledToggleCount": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "number",
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"projectCount": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "number",
|
||||||
|
},
|
||||||
|
"protected": {
|
||||||
|
"type": "boolean",
|
||||||
|
},
|
||||||
|
"sortOrder": {
|
||||||
|
"type": "number",
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"type",
|
||||||
|
"enabled",
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
},
|
||||||
|
"environmentsProjectSchema": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"environments": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/environmentProjectSchema",
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "integer",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"version",
|
||||||
|
"environments",
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
},
|
||||||
"environmentsSchema": {
|
"environmentsSchema": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -4202,6 +4267,36 @@ exports[`should serve the OpenAPI spec 1`] = `
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/admin/environments/project/{projectId}": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getProjectEnvironments",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "projectId",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/environmentsProjectSchema",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"description": "environmentsProjectSchema",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Environments",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
"/api/admin/environments/sort-order": {
|
"/api/admin/environments/sort-order": {
|
||||||
"put": {
|
"put": {
|
||||||
"operationId": "updateSortOrder",
|
"operationId": "updateSortOrder",
|
||||||
|
|||||||
13
src/test/fixtures/fake-environment-store.ts
vendored
13
src/test/fixtures/fake-environment-store.ts
vendored
@ -1,4 +1,4 @@
|
|||||||
import { IEnvironment } from '../../lib/types/model';
|
import { IEnvironment, IProjectEnvironment } from '../../lib/types/model';
|
||||||
import NotFoundError from '../../lib/error/notfound-error';
|
import NotFoundError from '../../lib/error/notfound-error';
|
||||||
import { IEnvironmentStore } from '../../lib/types/stores/environment-store';
|
import { IEnvironmentStore } from '../../lib/types/stores/environment-store';
|
||||||
|
|
||||||
@ -124,6 +124,17 @@ export default class FakeEnvironmentStore implements IEnvironmentStore {
|
|||||||
async get(key: string): Promise<IEnvironment> {
|
async get(key: string): Promise<IEnvironment> {
|
||||||
return this.environments.find((e) => e.name === key);
|
return this.environments.find((e) => e.name === key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAllWithCounts(): Promise<IEnvironment[]> {
|
||||||
|
return Promise.resolve(this.environments);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProjectEnvironments(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
projectId: string,
|
||||||
|
): Promise<IProjectEnvironment[]> {
|
||||||
|
return Promise.reject(new Error('Not implemented'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = FakeEnvironmentStore;
|
module.exports = FakeEnvironmentStore;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user