mirror of
https://github.com/Unleash/unleash.git
synced 2025-05-03 01:18:43 +02: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 { TableCell as MUITableCell, TableCellProps } from '@mui/material';
|
||||
import { useStyles } from './TableCell.styles';
|
||||
|
||||
export const TableCell: FC<TableCellProps> = ({ className, ...props }) => {
|
||||
const { classes: styles } = useStyles();
|
||||
export const TableCell: FC<TableCellProps> = forwardRef(
|
||||
({ className, ...props }, ref: ForwardedRef<unknown>) => {
|
||||
const { classes: styles } = useStyles();
|
||||
|
||||
return (
|
||||
<MUITableCell
|
||||
className={classnames(styles.tableCell, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<MUITableCell
|
||||
className={classnames(styles.tableCell, className)}
|
||||
{...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 { UPDATE_ENVIRONMENT } from 'component/providers/AccessProvider/permissions';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import { IEnvironment } from 'interfaces/environments';
|
||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||
import EnvironmentToggleConfirm from '../../EnvironmentToggleConfirm/EnvironmentToggleConfirm';
|
||||
import EnvironmentDeleteConfirm from '../../EnvironmentDeleteConfirm/EnvironmentDeleteConfirm';
|
||||
import useEnvironmentApi from 'hooks/api/actions/useEnvironmentApi/useEnvironmentApi';
|
||||
import useProjectRolePermissions from 'hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions';
|
||||
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
|
||||
import useToast from 'hooks/useToast';
|
||||
import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch';
|
||||
import { EnvironmentActionCellPopover } from './EnvironmentActionCellPopover/EnvironmentActionCellPopover';
|
||||
import { EnvironmentCloneModal } from './EnvironmentCloneModal/EnvironmentCloneModal';
|
||||
import { IApiToken } from 'hooks/api/getters/useApiTokens/useApiTokens';
|
||||
import { EnvironmentTokenDialog } from './EnvironmentTokenDialog/EnvironmentTokenDialog';
|
||||
import { ENV_LIMIT } from 'constants/values';
|
||||
import { EnvironmentDeprecateToggleDialog } from './EnvironmentDeprecateToggleDialog/EnvironmentDeprecateToggleDialog';
|
||||
import { EnvironmentDeleteDialog } from './EnvironmentDeleteDialog/EnvironmentDeleteDialog';
|
||||
|
||||
interface IEnvironmentTableActionsProps {
|
||||
environment: IEnvironment;
|
||||
@ -31,14 +29,13 @@ export const EnvironmentActionCell = ({
|
||||
const { deleteEnvironment, toggleEnvironmentOn, toggleEnvironmentOff } =
|
||||
useEnvironmentApi();
|
||||
|
||||
const [deleteModal, setDeleteModal] = useState(false);
|
||||
const [toggleModal, setToggleModal] = useState(false);
|
||||
const [deleteDialog, setDeleteDialog] = useState(false);
|
||||
const [deprecateToggleDialog, setDeprecateToggleDialog] = useState(false);
|
||||
const [cloneModal, setCloneModal] = useState(false);
|
||||
const [tokenModal, setTokenModal] = useState(false);
|
||||
const [tokenDialog, setTokenDialog] = useState(false);
|
||||
const [newToken, setNewToken] = useState<IApiToken>();
|
||||
const [confirmName, setConfirmName] = useState('');
|
||||
|
||||
const handleDeleteEnvironment = async () => {
|
||||
const onDeleteConfirm = async () => {
|
||||
try {
|
||||
await deleteEnvironment(environment.name);
|
||||
refetchPermissions();
|
||||
@ -50,65 +47,40 @@ export const EnvironmentActionCell = ({
|
||||
} catch (error: unknown) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
} finally {
|
||||
setDeleteModal(false);
|
||||
setConfirmName('');
|
||||
setDeleteDialog(false);
|
||||
await refetchEnvironments();
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmToggleEnvironment = () => {
|
||||
return environment.enabled
|
||||
? handleToggleEnvironmentOff()
|
||||
: handleToggleEnvironmentOn();
|
||||
};
|
||||
|
||||
const handleToggleEnvironmentOn = async () => {
|
||||
const onDeprecateToggleConfirm = async () => {
|
||||
try {
|
||||
setToggleModal(false);
|
||||
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);
|
||||
setToastData({
|
||||
type: 'success',
|
||||
title: 'Project environment disabled',
|
||||
});
|
||||
if (environment.enabled) {
|
||||
await toggleEnvironmentOff(environment.name);
|
||||
setToastData({
|
||||
type: 'success',
|
||||
title: 'Environment deprecated successfully',
|
||||
});
|
||||
} else {
|
||||
await toggleEnvironmentOn(environment.name);
|
||||
setToastData({
|
||||
type: 'success',
|
||||
title: 'Environment undeprecated successfully',
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
} finally {
|
||||
setDeprecateToggleDialog(false);
|
||||
await refetchEnvironments();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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
|
||||
environment={environment}
|
||||
onEdit={() => navigate(`/environments/${environment.name}`)}
|
||||
onDeprecateToggle={() => setDeprecateToggleDialog(true)}
|
||||
onClone={() => {
|
||||
if (environments.length < ENV_LIMIT) {
|
||||
setCloneModal(true);
|
||||
@ -120,21 +92,19 @@ export const EnvironmentActionCell = ({
|
||||
});
|
||||
}
|
||||
}}
|
||||
onDelete={() => setDeleteModal(true)}
|
||||
onDelete={() => setDeleteDialog(true)}
|
||||
/>
|
||||
<EnvironmentDeleteConfirm
|
||||
env={environment}
|
||||
setDeldialogue={setDeleteModal}
|
||||
open={deleteModal}
|
||||
handleDeleteEnvironment={handleDeleteEnvironment}
|
||||
confirmName={confirmName}
|
||||
setConfirmName={setConfirmName}
|
||||
<EnvironmentDeleteDialog
|
||||
environment={environment}
|
||||
open={deleteDialog}
|
||||
setOpen={setDeleteDialog}
|
||||
onConfirm={onDeleteConfirm}
|
||||
/>
|
||||
<EnvironmentToggleConfirm
|
||||
env={environment}
|
||||
open={toggleModal}
|
||||
setToggleDialog={setToggleModal}
|
||||
handleConfirmToggleEnvironment={handleConfirmToggleEnvironment}
|
||||
<EnvironmentDeprecateToggleDialog
|
||||
environment={environment}
|
||||
open={deprecateToggleDialog}
|
||||
setOpen={setDeprecateToggleDialog}
|
||||
onConfirm={onDeprecateToggleConfirm}
|
||||
/>
|
||||
<EnvironmentCloneModal
|
||||
environment={environment}
|
||||
@ -142,12 +112,12 @@ export const EnvironmentActionCell = ({
|
||||
setOpen={setCloneModal}
|
||||
newToken={(token: IApiToken) => {
|
||||
setNewToken(token);
|
||||
setTokenModal(true);
|
||||
setTokenDialog(true);
|
||||
}}
|
||||
/>
|
||||
<EnvironmentTokenDialog
|
||||
open={tokenModal}
|
||||
setOpen={setTokenModal}
|
||||
open={tokenDialog}
|
||||
setOpen={setTokenDialog}
|
||||
token={newToken}
|
||||
/>
|
||||
</ActionCell>
|
||||
|
@ -18,7 +18,13 @@ import {
|
||||
DELETE_ENVIRONMENT,
|
||||
UPDATE_ENVIRONMENT,
|
||||
} 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 { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
|
||||
@ -30,9 +36,18 @@ const StyledMenuItem = styled(MenuItem)(({ theme }) => ({
|
||||
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 {
|
||||
environment: IEnvironment;
|
||||
onEdit: () => void;
|
||||
onDeprecateToggle: () => void;
|
||||
onClone: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
@ -40,6 +55,7 @@ interface IEnvironmentActionCellPopoverProps {
|
||||
export const EnvironmentActionCellPopover = ({
|
||||
environment,
|
||||
onEdit,
|
||||
onDeprecateToggle,
|
||||
onClone,
|
||||
onDelete,
|
||||
}: IEnvironmentActionCellPopoverProps) => {
|
||||
@ -127,24 +143,50 @@ export const EnvironmentActionCellPopover = ({
|
||||
</PermissionHOC>
|
||||
}
|
||||
/>
|
||||
<PermissionHOC permission={DELETE_ENVIRONMENT}>
|
||||
<PermissionHOC permission={UPDATE_ENVIRONMENT}>
|
||||
{({ hasAccess }) => (
|
||||
<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={() => {
|
||||
onDelete();
|
||||
handleClose();
|
||||
}}
|
||||
disabled={!hasAccess || environment.protected}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<StyledListItemIconNegative>
|
||||
<Delete />
|
||||
</ListItemIcon>
|
||||
</StyledListItemIconNegative>
|
||||
<ListItemText>
|
||||
<Typography variant="body2">
|
||||
Delete
|
||||
</Typography>
|
||||
</ListItemText>
|
||||
</StyledMenuItem>
|
||||
</StyledMenuItemNegative>
|
||||
)}
|
||||
</PermissionHOC>
|
||||
</StyledMenuList>
|
||||
|
@ -142,6 +142,7 @@ export const EnvironmentCloneModal = ({
|
||||
setProjects([]);
|
||||
setTokenProjects(['*']);
|
||||
setClonePermissions(true);
|
||||
setApiTokenGeneration(APITokenGeneration.LATER);
|
||||
setErrors({});
|
||||
}, [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 { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||
import { Box, IconButton } from '@mui/material';
|
||||
import { CloudCircle, DragIndicator } from '@mui/icons-material';
|
||||
import { UPDATE_ENVIRONMENT } from 'component/providers/AccessProvider/permissions';
|
||||
import AccessContext from 'contexts/AccessContext';
|
||||
import { IEnvironment } from 'interfaces/environments';
|
||||
|
||||
const DragIcon = styled(IconButton)(
|
||||
({ theme }) => `
|
||||
padding: ${theme.spacing(0, 1, 0, 0)};
|
||||
cursor: inherit;
|
||||
transition: color 0.2s ease-in-out;
|
||||
`
|
||||
const StyledCell = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
paddingLeft: theme.spacing(0.5),
|
||||
minWidth: theme.spacing(6.5),
|
||||
}));
|
||||
|
||||
const DragIcon = styled(IconButton)(({ theme }) => ({
|
||||
padding: theme.spacing(1.5, 0),
|
||||
cursor: 'inherit',
|
||||
transition: 'color 0.2s ease-in-out',
|
||||
display: 'none',
|
||||
color: theme.palette.neutral.main,
|
||||
}));
|
||||
|
||||
const StyledCloudCircle = styled(CloudCircle, {
|
||||
shouldForwardProp: prop => prop !== 'deprecated',
|
||||
})<{ deprecated?: boolean }>(({ theme, deprecated }) => ({
|
||||
color: deprecated
|
||||
? theme.palette.neutral.border
|
||||
: theme.palette.primary.main,
|
||||
}));
|
||||
|
||||
interface IEnvironmentIconCellProps {
|
||||
environment: IEnvironment;
|
||||
}
|
||||
|
||||
export const EnvironmentIconCell: VFC<IEnvironmentIconCellProps> = ({
|
||||
environment,
|
||||
}) => (
|
||||
<StyledCell>
|
||||
<DragIcon size="large" disableRipple className="drag-icon">
|
||||
<DragIndicator titleAccess="Drag to reorder" />
|
||||
</DragIcon>
|
||||
<StyledCloudCircle deprecated={!environment.enabled} />
|
||||
</StyledCell>
|
||||
);
|
||||
|
||||
export const EnvironmentIconCell: VFC = () => {
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
const updatePermission = hasAccess(UPDATE_ENVIRONMENT);
|
||||
const { searchQuery } = useSearchHighlightContext();
|
||||
|
||||
// Allow drag and drop if the user is permitted to reorder environments.
|
||||
// Disable drag and drop while searching since some rows may be hidden.
|
||||
const enableDragAndDrop = updatePermission && !searchQuery;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', pl: 2 }}>
|
||||
<ConditionallyRender
|
||||
condition={enableDragAndDrop}
|
||||
show={
|
||||
<DragIcon size="large" disableRipple disabled>
|
||||
<DragIndicator
|
||||
titleAccess="Drag to reorder"
|
||||
cursor="grab"
|
||||
/>
|
||||
</DragIcon>
|
||||
}
|
||||
/>
|
||||
<CloudCircle color="disabled" />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
@ -4,7 +4,17 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
|
||||
import { Badge } from 'component/common/Badge/Badge';
|
||||
import { Highlighter } from 'component/common/Highlighter/Highlighter';
|
||||
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 }) => ({
|
||||
marginLeft: theme.spacing(1),
|
||||
@ -22,14 +32,33 @@ export const EnvironmentNameCell = ({
|
||||
return (
|
||||
<TextCell>
|
||||
<Highlighter search={searchQuery}>{environment.name}</Highlighter>
|
||||
<ConditionallyRender
|
||||
condition={!environment.enabled}
|
||||
show={<StyledBadge color="warning">Disabled</StyledBadge>}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={environment.protected}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
@ -1,11 +1,20 @@
|
||||
import { useDragItem, MoveListItem } from 'hooks/useDragItem';
|
||||
import { MoveListItem, useDragItem } from 'hooks/useDragItem';
|
||||
import { Row } from 'react-table';
|
||||
import { TableRow } from '@mui/material';
|
||||
import { styled, TableRow } from '@mui/material';
|
||||
import { TableCell } from 'component/common/Table';
|
||||
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||
import { UPDATE_ENVIRONMENT } from 'component/providers/AccessProvider/permissions';
|
||||
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 {
|
||||
row: Row;
|
||||
@ -14,17 +23,39 @@ interface IEnvironmentRowProps {
|
||||
|
||||
export const EnvironmentRow = ({ row, moveListItem }: IEnvironmentRowProps) => {
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
const dragItemRef = useDragItem(row.index, moveListItem);
|
||||
const dragHandleRef = useRef(null);
|
||||
const { searchQuery } = useSearchHighlightContext();
|
||||
const draggable = !searchQuery && hasAccess(UPDATE_ENVIRONMENT);
|
||||
|
||||
return (
|
||||
<TableRow hover ref={draggable ? dragItemRef : undefined}>
|
||||
{row.cells.map((cell: any) => (
|
||||
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 (
|
||||
<TableCell {...cell.getCellProps()}>
|
||||
{cell.render('Cell')}
|
||||
</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 { EnvironmentIconCell } from './EnvironmentIconCell/EnvironmentIconCell';
|
||||
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 }) => ({
|
||||
marginBottom: theme.spacing(4),
|
||||
@ -93,7 +96,7 @@ export const EnvironmentTable = () => {
|
||||
inside each feature toggle.
|
||||
</StyledAlert>
|
||||
<SearchHighlightProvider value={globalFilter}>
|
||||
<Table {...getTableProps()}>
|
||||
<Table {...getTableProps()} rowHeight="compact">
|
||||
<SortableTableHeader headerGroups={headerGroups as any} />
|
||||
<TableBody {...getTableBodyProps()}>
|
||||
{rows.map(row => {
|
||||
@ -138,22 +141,45 @@ const COLUMNS = [
|
||||
{
|
||||
id: 'Icon',
|
||||
width: '1%',
|
||||
Cell: () => <EnvironmentIconCell />,
|
||||
Cell: ({ row: { original } }: { row: { original: IEnvironment } }) => (
|
||||
<EnvironmentIconCell environment={original} />
|
||||
),
|
||||
disableGlobalFilter: true,
|
||||
isDragHandle: true,
|
||||
},
|
||||
{
|
||||
Header: 'Name',
|
||||
accessor: 'name',
|
||||
Cell: ({ row: { original } }: any) => (
|
||||
Cell: ({ row: { original } }: { row: { original: IEnvironment } }) => (
|
||||
<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',
|
||||
id: 'Actions',
|
||||
align: 'center',
|
||||
width: '1%',
|
||||
Cell: ({ row: { original } }: any) => (
|
||||
Cell: ({ row: { original } }: { row: { original: IEnvironment } }) => (
|
||||
<EnvironmentActionCell environment={original} />
|
||||
),
|
||||
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 { useStyles } from './ProjectEnvironment.styles';
|
||||
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 useToast from 'hooks/useToast';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
|
||||
import useProject, {
|
||||
useProjectNameOrId,
|
||||
} 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 EnvironmentDisableConfirm from './EnvironmentDisableConfirm/EnvironmentDisableConfirm';
|
||||
import { Link } from 'react-router-dom';
|
||||
import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch';
|
||||
import { IProjectEnvironment } from 'interfaces/environments';
|
||||
import { getEnabledEnvs } from './helpers';
|
||||
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
|
||||
import { useThemeStyles } from 'themes/themeStyles';
|
||||
import { usePageTitle } from 'hooks/usePageTitle';
|
||||
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 projectId = useRequiredPathParam('projectId');
|
||||
@ -29,30 +44,31 @@ const ProjectEnvironmentList = () => {
|
||||
usePageTitle(`Project environments – ${projectName}`);
|
||||
|
||||
// api state
|
||||
const [envs, setEnvs] = useState<IProjectEnvironment[]>([]);
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
const { uiConfig } = useUiConfig();
|
||||
const { environments, loading, error, refetchEnvironments } =
|
||||
useEnvironments();
|
||||
useProjectEnvironments(projectId);
|
||||
const { project, refetch: refetchProject } = useProject(projectId);
|
||||
const { removeEnvironmentFromProject, addEnvironmentToProject } =
|
||||
useProjectApi();
|
||||
const { classes: themeStyles } = useThemeStyles();
|
||||
|
||||
// local state
|
||||
const [selectedEnv, setSelectedEnv] = useState<IProjectEnvironment>();
|
||||
const [confirmName, setConfirmName] = useState('');
|
||||
const [selectedEnvironment, setSelectedEnvironment] =
|
||||
useState<IProjectEnvironment>();
|
||||
const [hideDialog, setHideDialog] = useState(false);
|
||||
const { classes: styles } = useStyles();
|
||||
const { isOss } = useUiConfig();
|
||||
|
||||
useEffect(() => {
|
||||
const envs = environments.map(e => ({
|
||||
name: e.name,
|
||||
enabled: project?.environments.includes(e.name),
|
||||
}));
|
||||
|
||||
setEnvs(envs);
|
||||
}, [environments, project?.environments]);
|
||||
const projectEnvironments = useMemo<IProjectEnvironment[]>(
|
||||
() =>
|
||||
environments.map(environment => ({
|
||||
...environment,
|
||||
projectVisible: project?.environments.includes(
|
||||
environment.name
|
||||
),
|
||||
})),
|
||||
[environments, project?.environments]
|
||||
);
|
||||
|
||||
const refetch = () => {
|
||||
refetchEnvironments();
|
||||
@ -70,119 +86,147 @@ const ProjectEnvironmentList = () => {
|
||||
};
|
||||
|
||||
const errorMsg = (enable: boolean): string => {
|
||||
return `Got an API error when trying to ${
|
||||
enable ? 'enable' : 'disable'
|
||||
} the environment.`;
|
||||
return `Got an API error when trying to set the environment as ${
|
||||
enable ? 'visible' : 'hidden'
|
||||
}`;
|
||||
};
|
||||
|
||||
const toggleEnv = async (env: IProjectEnvironment) => {
|
||||
if (env.enabled) {
|
||||
const enabledEnvs = getEnabledEnvs(envs);
|
||||
if (env.projectVisible) {
|
||||
const enabledEnvs = getEnabledEnvs(projectEnvironments);
|
||||
|
||||
if (enabledEnvs > 1) {
|
||||
setSelectedEnv(env);
|
||||
setSelectedEnvironment(env);
|
||||
setHideDialog(true);
|
||||
return;
|
||||
}
|
||||
setToastData({
|
||||
title: 'One environment must be active',
|
||||
text: 'You must always have at least one active environment per project',
|
||||
title: 'One environment must be visible',
|
||||
text: 'You must always have at least one visible environment per project',
|
||||
type: 'error',
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await addEnvironmentToProject(projectId, env.name);
|
||||
refetch();
|
||||
setToastData({
|
||||
title: 'Environment enabled',
|
||||
text: 'Environment successfully enabled. You can now use it to segment strategies in your feature toggles.',
|
||||
title: 'Environment set as visible',
|
||||
text: 'Environment successfully set as visible.',
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
setToastApiError(errorMsg(true));
|
||||
}
|
||||
}
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleDisableEnvironment = async () => {
|
||||
if (selectedEnv && confirmName === selectedEnv.name) {
|
||||
const onHideConfirm = async () => {
|
||||
if (selectedEnvironment) {
|
||||
try {
|
||||
await removeEnvironmentFromProject(projectId, selectedEnv.name);
|
||||
setSelectedEnv(undefined);
|
||||
setConfirmName('');
|
||||
await removeEnvironmentFromProject(
|
||||
projectId,
|
||||
selectedEnvironment.name
|
||||
);
|
||||
refetch();
|
||||
setToastData({
|
||||
title: 'Environment disabled',
|
||||
text: 'Environment successfully disabled.',
|
||||
title: 'Environment set as hidden',
|
||||
text: 'Environment successfully set as hidden.',
|
||||
type: 'success',
|
||||
});
|
||||
} catch (e) {
|
||||
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) => {
|
||||
return isOss() && projectName === 'default';
|
||||
};
|
||||
|
||||
const renderEnvironments = () => {
|
||||
return (
|
||||
<FormGroup>
|
||||
{envs.map(env => (
|
||||
<FormControlLabel
|
||||
key={env.name}
|
||||
label={genLabel(env)}
|
||||
control={
|
||||
<PermissionSwitch
|
||||
tooltip={`${
|
||||
env.enabled ? 'Disable' : 'Enable'
|
||||
} environment`}
|
||||
size="medium"
|
||||
disabled={envIsDisabled(env.name)}
|
||||
projectId={projectId}
|
||||
permission={UPDATE_PROJECT}
|
||||
checked={env.enabled}
|
||||
onChange={() => toggleEnv(env)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</FormGroup>
|
||||
);
|
||||
};
|
||||
const COLUMNS = useMemo(
|
||||
() => [
|
||||
{
|
||||
Header: 'Name',
|
||||
accessor: 'name',
|
||||
Cell: ({ row: { original } }: any) => (
|
||||
<EnvironmentNameCell environment={original} />
|
||||
),
|
||||
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
|
||||
tooltip={
|
||||
original.projectVisible
|
||||
? 'Hide environment and disable feature toggles'
|
||||
: 'Make it visible'
|
||||
}
|
||||
size="medium"
|
||||
disabled={envIsDisabled(original.name)}
|
||||
projectId={projectId}
|
||||
permission={UPDATE_PROJECT}
|
||||
checked={original.projectVisible}
|
||||
onChange={() => toggleEnv(original)}
|
||||
/>
|
||||
</ActionCell>
|
||||
),
|
||||
disableGlobalFilter: true,
|
||||
},
|
||||
],
|
||||
[projectEnvironments]
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContent
|
||||
header={
|
||||
<PageHeader
|
||||
titleElement={`Configure environments for "${project?.name}" project`}
|
||||
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}
|
||||
/>
|
||||
}
|
||||
isLoading={loading}
|
||||
>
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContent header={header} isLoading={loading}>
|
||||
<ConditionallyRender
|
||||
condition={uiConfig.flags.E}
|
||||
show={
|
||||
@ -191,34 +235,76 @@ const ProjectEnvironmentList = () => {
|
||||
condition={Boolean(error)}
|
||||
show={renderError()}
|
||||
/>
|
||||
<Alert severity="info" style={{ marginBottom: '20px' }}>
|
||||
<b>Important!</b> In order for your application to
|
||||
retrieve configured activation strategies for a
|
||||
specific environment, the application
|
||||
<br /> must use an environment specific API key. You
|
||||
can look up the environment-specific API keys{' '}
|
||||
<Link to="/admin/api">here.</Link>
|
||||
<StyledAlert severity="info">
|
||||
<strong>Important!</strong> In order for your
|
||||
application to retrieve configured activation
|
||||
strategies for a specific environment, the
|
||||
application must use an environment specific API
|
||||
token. You can look up the environment-specific{' '}
|
||||
<Link to="/admin/api">API tokens here</Link>.
|
||||
<br />
|
||||
<br />
|
||||
Your administrator can configure an
|
||||
environment-specific API key to be used in the SDK.
|
||||
If you are an administrator you can{' '}
|
||||
<Link to="/admin/api">create a new API key.</Link>
|
||||
</Alert>
|
||||
environment-specific API token to be used in the
|
||||
SDK. If you are an administrator you can{' '}
|
||||
<Link to="/admin/api">
|
||||
create a new API token here
|
||||
</Link>
|
||||
.
|
||||
</StyledAlert>
|
||||
<SearchHighlightProvider value={globalFilter}>
|
||||
<Table {...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>
|
||||
</Table>
|
||||
</SearchHighlightProvider>
|
||||
<ConditionallyRender
|
||||
condition={environments.length < 1 && !loading}
|
||||
show={<div>No environments available.</div>}
|
||||
elseShow={renderEnvironments()}
|
||||
/>
|
||||
<EnvironmentDisableConfirm
|
||||
env={selectedEnv}
|
||||
open={Boolean(selectedEnv)}
|
||||
handleDisableEnvironment={handleDisableEnvironment}
|
||||
handleCancelDisableEnvironment={
|
||||
handleCancelDisableEnvironment
|
||||
condition={rows.length === 0}
|
||||
show={
|
||||
<ConditionallyRender
|
||||
condition={globalFilter?.length > 0}
|
||||
show={
|
||||
<TablePlaceholder>
|
||||
No environments found matching
|
||||
“
|
||||
{globalFilter}
|
||||
”
|
||||
</TablePlaceholder>
|
||||
}
|
||||
elseShow={
|
||||
<TablePlaceholder>
|
||||
No environments available. Get
|
||||
started by adding one.
|
||||
</TablePlaceholder>
|
||||
}
|
||||
/>
|
||||
}
|
||||
confirmName={confirmName}
|
||||
setConfirmName={setConfirmName}
|
||||
/>
|
||||
<EnvironmentHideDialog
|
||||
environment={selectedEnvironment}
|
||||
open={hideDialog}
|
||||
setOpen={setHideDialog}
|
||||
onConfirm={onHideConfirm}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
@ -1,8 +1,13 @@
|
||||
import { IProjectEnvironment } from 'interfaces/environments';
|
||||
import { getEnabledEnvs } from './helpers';
|
||||
|
||||
const generateEnv = (enabled: boolean, name: string) => {
|
||||
const generateEnv = (enabled: boolean, name: string): IProjectEnvironment => {
|
||||
return {
|
||||
name,
|
||||
type: 'development',
|
||||
createdAt: new Date().toISOString(),
|
||||
sortOrder: 0,
|
||||
protected: false,
|
||||
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
|
||||
) => void;
|
||||
|
||||
export const useDragItem = (
|
||||
export const useDragItem = <T extends HTMLElement>(
|
||||
listItemIndex: number,
|
||||
moveListItem: MoveListItem
|
||||
): RefObject<HTMLTableRowElement> => {
|
||||
const ref = useRef<HTMLTableRowElement>(null);
|
||||
moveListItem: MoveListItem,
|
||||
handle?: RefObject<HTMLElement>
|
||||
): RefObject<T> => {
|
||||
const ref = useRef<T>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.draggable = true;
|
||||
ref.current.style.cursor = 'grab';
|
||||
ref.current.dataset.index = String(listItemIndex);
|
||||
return addEventListeners(ref.current, moveListItem);
|
||||
return addEventListeners(
|
||||
ref.current,
|
||||
moveListItem,
|
||||
handle?.current ?? undefined
|
||||
);
|
||||
}
|
||||
}, [listItemIndex, moveListItem]);
|
||||
|
||||
@ -25,8 +28,9 @@ export const useDragItem = (
|
||||
};
|
||||
|
||||
const addEventListeners = (
|
||||
el: HTMLTableRowElement,
|
||||
moveListItem: MoveListItem
|
||||
el: HTMLElement,
|
||||
moveListItem: MoveListItem,
|
||||
handle?: HTMLElement
|
||||
): (() => void) => {
|
||||
const moveDraggedElement = (save: boolean) => {
|
||||
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 = () => {
|
||||
el.draggable = true;
|
||||
globalDraggedElement = el;
|
||||
};
|
||||
|
||||
@ -55,12 +72,16 @@ const addEventListeners = (
|
||||
globalDraggedElement = null;
|
||||
};
|
||||
|
||||
handleEl.addEventListener('mouseenter', onMouseEnter);
|
||||
handleEl.addEventListener('mouseleave', onMouseLeave);
|
||||
el.addEventListener('dragstart', onDragStart);
|
||||
el.addEventListener('dragenter', onDragEnter);
|
||||
el.addEventListener('dragover', onDragOver);
|
||||
el.addEventListener('drop', onDrop);
|
||||
|
||||
return () => {
|
||||
handleEl.removeEventListener('mouseenter', onMouseEnter);
|
||||
handleEl.removeEventListener('mouseleave', onMouseLeave);
|
||||
el.removeEventListener('dragstart', onDragStart);
|
||||
el.removeEventListener('dragenter', onDragEnter);
|
||||
el.removeEventListener('dragover', onDragOver);
|
||||
@ -69,4 +90,4 @@ const addEventListeners = (
|
||||
};
|
||||
|
||||
// The element being dragged in the browser.
|
||||
let globalDraggedElement: HTMLTableRowElement | null;
|
||||
let globalDraggedElement: HTMLElement | null;
|
||||
|
@ -5,11 +5,15 @@ export interface IEnvironment {
|
||||
sortOrder: number;
|
||||
enabled: boolean;
|
||||
protected: boolean;
|
||||
projectCount?: number;
|
||||
apiTokenCount?: number;
|
||||
enabledToggleCount?: number;
|
||||
}
|
||||
|
||||
export interface IProjectEnvironment {
|
||||
enabled: boolean;
|
||||
name: string;
|
||||
export interface IProjectEnvironment extends IEnvironment {
|
||||
projectVisible?: boolean;
|
||||
projectApiTokenCount?: number;
|
||||
projectEnabledToggleCount?: number;
|
||||
}
|
||||
|
||||
export interface IEnvironmentPayload {
|
||||
|
@ -3,7 +3,11 @@ import { Knex } from 'knex';
|
||||
import { Logger, LogProvider } from '../logger';
|
||||
import metricsHelper from '../util/metrics-helper';
|
||||
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 { IEnvironmentStore } from '../types/stores/environment-store';
|
||||
import { snakeCaseKeys } from '../util/snakeCase';
|
||||
@ -17,6 +21,17 @@ interface IEnvironmentsTable {
|
||||
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 = [
|
||||
'type',
|
||||
'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 {
|
||||
return {
|
||||
name: env.name,
|
||||
@ -112,6 +156,54 @@ export default class EnvironmentStore implements IEnvironmentStore {
|
||||
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> {
|
||||
const result = await this.db.raw(
|
||||
`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 { emailSchema } from './spec/email-schema';
|
||||
import { environmentSchema } from './spec/environment-schema';
|
||||
import { environmentProjectSchema } from './spec/environment-project-schema';
|
||||
import { environmentsSchema } from './spec/environments-schema';
|
||||
import { environmentsProjectSchema } from './spec/environments-project-schema';
|
||||
import { eventSchema } from './spec/event-schema';
|
||||
import { eventsSchema } from './spec/events-schema';
|
||||
import { featureEnvironmentMetricsSchema } from './spec/feature-environment-metrics-schema';
|
||||
@ -153,7 +155,9 @@ export const schemas = {
|
||||
edgeTokenSchema,
|
||||
emailSchema,
|
||||
environmentSchema,
|
||||
environmentProjectSchema,
|
||||
environmentsSchema,
|
||||
environmentsProjectSchema,
|
||||
eventSchema,
|
||||
eventsSchema,
|
||||
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: {
|
||||
type: 'number',
|
||||
},
|
||||
projectCount: {
|
||||
type: 'number',
|
||||
nullable: true,
|
||||
},
|
||||
apiTokenCount: {
|
||||
type: 'number',
|
||||
nullable: true,
|
||||
},
|
||||
enabledToggleCount: {
|
||||
type: 'number',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
components: {},
|
||||
} 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';
|
||||
import { SortOrderSchema } from '../../openapi/spec/sort-order-schema';
|
||||
import { emptyResponse } from '../../openapi/util/standard-responses';
|
||||
import {
|
||||
environmentsProjectSchema,
|
||||
EnvironmentsProjectSchema,
|
||||
} from '../../openapi/spec/environments-project-schema';
|
||||
|
||||
interface EnvironmentParam {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ProjectParam {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export class EnvironmentsController extends Controller {
|
||||
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({
|
||||
method: 'put',
|
||||
path: '/sort-order',
|
||||
@ -167,4 +191,21 @@ export class EnvironmentsController extends Controller {
|
||||
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 { IUnleashConfig } from '../types/option';
|
||||
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 NameExistsError from '../error/name-exists-error';
|
||||
import { sortOrderSchema } from './state-schema';
|
||||
@ -46,13 +46,19 @@ export default class EnvironmentService {
|
||||
}
|
||||
|
||||
async getAll(): Promise<IEnvironment[]> {
|
||||
return this.environmentStore.getAll();
|
||||
return this.environmentStore.getAllWithCounts();
|
||||
}
|
||||
|
||||
async get(name: string): Promise<IEnvironment> {
|
||||
return this.environmentStore.get(name);
|
||||
}
|
||||
|
||||
async getProjectEnvironments(
|
||||
projectId: string,
|
||||
): Promise<IProjectEnvironment[]> {
|
||||
return this.environmentStore.getProjectEnvironments(projectId);
|
||||
}
|
||||
|
||||
async updateSortOrder(sortOrder: ISortOrder): Promise<void> {
|
||||
await sortOrderSchema.validateAsync(sortOrder);
|
||||
await Promise.all(
|
||||
|
@ -122,6 +122,14 @@ export interface IEnvironment {
|
||||
sortOrder: number;
|
||||
enabled: boolean;
|
||||
protected: boolean;
|
||||
projectCount?: number;
|
||||
apiTokenCount?: number;
|
||||
enabledToggleCount?: number;
|
||||
}
|
||||
|
||||
export interface IProjectEnvironment extends IEnvironment {
|
||||
projectApiTokenCount?: number;
|
||||
projectEnabledToggleCount?: number;
|
||||
}
|
||||
|
||||
export interface IEnvironmentCreate {
|
||||
|
@ -1,4 +1,8 @@
|
||||
import { IEnvironment, IEnvironmentCreate } from '../model';
|
||||
import {
|
||||
IEnvironment,
|
||||
IEnvironmentCreate,
|
||||
IProjectEnvironment,
|
||||
} from '../model';
|
||||
import { Store } from './store';
|
||||
|
||||
export interface IEnvironmentStore extends Store<IEnvironment, string> {
|
||||
@ -19,4 +23,6 @@ export interface IEnvironmentStore extends Store<IEnvironment, string> {
|
||||
disable(environments: IEnvironment[]): Promise<void>;
|
||||
enable(environments: IEnvironment[]): Promise<void>;
|
||||
count(): Promise<number>;
|
||||
getAllWithCounts(): Promise<IEnvironment[]>;
|
||||
getProjectEnvironments(projectId: string): Promise<IProjectEnvironment[]>;
|
||||
}
|
||||
|
@ -29,6 +29,9 @@ test('Can list all existing environments', async () => {
|
||||
sortOrder: 1,
|
||||
type: 'production',
|
||||
protected: true,
|
||||
projectCount: 1,
|
||||
apiTokenCount: 0,
|
||||
enabledToggleCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -828,7 +828,7 @@ exports[`should serve the OpenAPI spec 1`] = `
|
||||
],
|
||||
"type": "object",
|
||||
},
|
||||
"environmentSchema": {
|
||||
"environmentProjectSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
@ -837,6 +837,14 @@ exports[`should serve the OpenAPI spec 1`] = `
|
||||
"name": {
|
||||
"type": "string",
|
||||
},
|
||||
"projectApiTokenCount": {
|
||||
"nullable": true,
|
||||
"type": "number",
|
||||
},
|
||||
"projectEnabledToggleCount": {
|
||||
"nullable": true,
|
||||
"type": "number",
|
||||
},
|
||||
"protected": {
|
||||
"type": "boolean",
|
||||
},
|
||||
@ -854,6 +862,63 @@ exports[`should serve the OpenAPI spec 1`] = `
|
||||
],
|
||||
"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": {
|
||||
"additionalProperties": false,
|
||||
"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": {
|
||||
"put": {
|
||||
"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 { IEnvironmentStore } from '../../lib/types/stores/environment-store';
|
||||
|
||||
@ -124,6 +124,17 @@ export default class FakeEnvironmentStore implements IEnvironmentStore {
|
||||
async get(key: string): Promise<IEnvironment> {
|
||||
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;
|
||||
|
Loading…
Reference in New Issue
Block a user