1
0
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:
Nuno Góis 2022-11-11 10:24:56 +00:00 committed by GitHub
parent b9db7952fb
commit 2fa154a3e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1307 additions and 494 deletions

View 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>
);

View File

@ -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}
/>
);
}
);

View File

@ -1,10 +0,0 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
deleteParagraph: {
marginTop: '2rem',
},
environmentDeleteInput: {
marginTop: '1rem',
},
}));

View File

@ -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;

View File

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

View File

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

View File

@ -142,6 +142,7 @@ export const EnvironmentCloneModal = ({
setProjects([]);
setTokenProjects(['*']);
setClonePermissions(true);
setApiTokenGeneration(APITokenGeneration.LATER);
setErrors({});
}, [environment]);

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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,

View File

@ -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>
);
};

View File

@ -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;

View File

@ -1,10 +0,0 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
deleteParagraph: {
marginTop: '2rem',
},
environmentDeleteInput: {
marginTop: '1rem',
},
}));

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
&nbsp; 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
&ldquo;
{globalFilter}
&rdquo;
</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>
}

View File

@ -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,
};
};

View File

@ -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;
});
};

View File

@ -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;

View File

@ -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 {

View File

@ -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`,

View File

@ -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,

View 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
>;

View File

@ -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;

View 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
>;

View File

@ -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,
),
},
);
}
}

View File

@ -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(

View File

@ -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 {

View File

@ -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[]>;
}

View File

@ -29,6 +29,9 @@ test('Can list all existing environments', async () => {
sortOrder: 1,
type: 'production',
protected: true,
projectCount: 1,
apiTokenCount: 0,
enabledToggleCount: 0,
});
});
});

View File

@ -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",

View File

@ -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;