diff --git a/frontend/src/component/common/HtmlTooltip/HtmlTooltip.tsx b/frontend/src/component/common/HtmlTooltip/HtmlTooltip.tsx new file mode 100644 index 0000000000..0ca3324d12 --- /dev/null +++ b/frontend/src/component/common/HtmlTooltip/HtmlTooltip.tsx @@ -0,0 +1,19 @@ +import { styled, Tooltip, tooltipClasses, TooltipProps } from '@mui/material'; + +const StyledHtmlTooltip = styled(({ className, ...props }: TooltipProps) => ( + +))(({ 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) => ( + {props.children} +); diff --git a/frontend/src/component/common/Table/TableCell/TableCell.tsx b/frontend/src/component/common/Table/TableCell/TableCell.tsx index f43275d934..38d739bca5 100644 --- a/frontend/src/component/common/Table/TableCell/TableCell.tsx +++ b/frontend/src/component/common/Table/TableCell/TableCell.tsx @@ -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 = ({ className, ...props }) => { - const { classes: styles } = useStyles(); +export const TableCell: FC = forwardRef( + ({ className, ...props }, ref: ForwardedRef) => { + const { classes: styles } = useStyles(); - return ( - - ); -}; + return ( + + ); + } +); diff --git a/frontend/src/component/environments/EnvironmentDeleteConfirm/EnvironmentDeleteConfirm.styles.ts b/frontend/src/component/environments/EnvironmentDeleteConfirm/EnvironmentDeleteConfirm.styles.ts deleted file mode 100644 index 75e0fdb3ea..0000000000 --- a/frontend/src/component/environments/EnvironmentDeleteConfirm/EnvironmentDeleteConfirm.styles.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { makeStyles } from 'tss-react/mui'; - -export const useStyles = makeStyles()(theme => ({ - deleteParagraph: { - marginTop: '2rem', - }, - environmentDeleteInput: { - marginTop: '1rem', - }, -})); diff --git a/frontend/src/component/environments/EnvironmentDeleteConfirm/EnvironmentDeleteConfirm.tsx b/frontend/src/component/environments/EnvironmentDeleteConfirm/EnvironmentDeleteConfirm.tsx deleted file mode 100644 index 3c8ef945c0..0000000000 --- a/frontend/src/component/environments/EnvironmentDeleteConfirm/EnvironmentDeleteConfirm.tsx +++ /dev/null @@ -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>; - handleDeleteEnvironment: () => Promise; - confirmName: string; - setConfirmName: React.Dispatch>; -} - -const EnvironmentDeleteConfirm = ({ - env, - open, - setDeldialogue, - handleDeleteEnvironment, - confirmName, - setConfirmName, -}: IEnviromentDeleteConfirmProps) => { - const { classes: styles } = useStyles(); - - const handleChange = (e: React.ChangeEvent) => - setConfirmName(e.currentTarget.value); - - const handleCancel = () => { - setDeldialogue(false); - setConfirmName(''); - }; - - const formId = 'delete-environment-confirmation-form'; - - return ( - - - Danger. Deleting this environment will result in removing all - strategies that are active in this environment across all - feature toggles. - - - -

- In order to delete this environment, please enter the id of the - environment in the textfield below: {env?.name} -

- -
- -
-
- ); -}; - -export default EnvironmentDeleteConfirm; diff --git a/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentActionCell.tsx b/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentActionCell.tsx index 0bc7dea930..3a55d20f23 100644 --- a/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentActionCell.tsx +++ b/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentActionCell.tsx @@ -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(); - 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 ( - setToggleModal(true)} - /> - 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)} /> - - { setNewToken(token); - setTokenModal(true); + setTokenDialog(true); }} /> diff --git a/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentActionCellPopover/EnvironmentActionCellPopover.tsx b/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentActionCellPopover/EnvironmentActionCellPopover.tsx index ec95cea7d4..ae3a8940be 100644 --- a/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentActionCellPopover/EnvironmentActionCellPopover.tsx +++ b/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentActionCellPopover/EnvironmentActionCellPopover.tsx @@ -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 = ({ } /> - + {({ hasAccess }) => ( { + onDeprecateToggle(); + handleClose(); + }} + disabled={!hasAccess || environment.protected} + > + + } + elseShow={} + /> + + + + {environment.enabled + ? 'Deprecate' + : 'Undeprecate'} + + + + )} + + + {({ hasAccess }) => ( + { onDelete(); handleClose(); }} disabled={!hasAccess || environment.protected} > - + - + Delete - + )} diff --git a/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentCloneModal/EnvironmentCloneModal.tsx b/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentCloneModal/EnvironmentCloneModal.tsx index e14249318a..bdb732a0f5 100644 --- a/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentCloneModal/EnvironmentCloneModal.tsx +++ b/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentCloneModal/EnvironmentCloneModal.tsx @@ -142,6 +142,7 @@ export const EnvironmentCloneModal = ({ setProjects([]); setTokenProjects(['*']); setClonePermissions(true); + setApiTokenGeneration(APITokenGeneration.LATER); setErrors({}); }, [environment]); diff --git a/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentDeleteDialog/EnvironmentDeleteDialog.tsx b/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentDeleteDialog/EnvironmentDeleteDialog.tsx new file mode 100644 index 0000000000..dc1c1bd215 --- /dev/null +++ b/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentDeleteDialog/EnvironmentDeleteDialog.tsx @@ -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>; + onConfirm: () => void; +} + +export const EnvironmentDeleteDialog = ({ + environment, + open, + setOpen, + onConfirm, +}: IEnvironmentDeleteDialogProps) => { + const [confirmName, setConfirmName] = useState(''); + + useEffect(() => { + setConfirmName(''); + }, [open]); + + return ( + { + setOpen(false); + }} + > + + Danger! Deleting this environment will result + in removing all strategies that are active in this environment + across all feature toggles. + + + + + + In order to delete this environment, please enter the id of the + environment in the textfield below:{' '} + {environment.name} + + setConfirmName(e.target.value)} + /> + + ); +}; diff --git a/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentDeprecateToggleDialog/EnvironmentDeprecateToggleDialog.tsx b/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentDeprecateToggleDialog/EnvironmentDeprecateToggleDialog.tsx new file mode 100644 index 0000000000..bff8359329 --- /dev/null +++ b/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentDeprecateToggleDialog/EnvironmentDeprecateToggleDialog.tsx @@ -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>; + onConfirm: () => void; +} + +export const EnvironmentDeprecateToggleDialog = ({ + environment, + open, + setOpen, + onConfirm, +}: IEnvironmentDeprecateToggleDialogProps) => { + const { enabled } = environment; + const actionName = enabled ? 'Deprecate' : 'Undeprecate'; + + return ( + { + setOpen(false); + }} + > + + 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. + + } + elseShow={ + + Undeprecating an environment will no longer mark it as + deprecated. An undeprecated environment will be set as + visible by default for new projects. + + } + /> + + + + ); +}; diff --git a/frontend/src/component/environments/EnvironmentTable/EnvironmentIconCell/EnvironmentIconCell.tsx b/frontend/src/component/environments/EnvironmentTable/EnvironmentIconCell/EnvironmentIconCell.tsx index 66c3b2dd8a..425c949a46 100644 --- a/frontend/src/component/environments/EnvironmentTable/EnvironmentIconCell/EnvironmentIconCell.tsx +++ b/frontend/src/component/environments/EnvironmentTable/EnvironmentIconCell/EnvironmentIconCell.tsx @@ -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 = ({ + environment, +}) => ( + + + + + + ); - -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 ( - - - - - } - /> - - - ); -}; diff --git a/frontend/src/component/environments/EnvironmentTable/EnvironmentNameCell/EnvironmentNameCell.tsx b/frontend/src/component/environments/EnvironmentTable/EnvironmentNameCell/EnvironmentNameCell.tsx index d66ed118dd..235981fa02 100644 --- a/frontend/src/component/environments/EnvironmentTable/EnvironmentNameCell/EnvironmentNameCell.tsx +++ b/frontend/src/component/environments/EnvironmentTable/EnvironmentNameCell/EnvironmentNameCell.tsx @@ -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 ( {environment.name} - Disabled} - /> Predefined} /> + + + Deprecated environment + + + This environment is not auto-enabled for new + projects. The project owner will need to + manually enable it in the project. + + + } + describeChild + > + Deprecated + + } + /> ); }; diff --git a/frontend/src/component/environments/EnvironmentTable/EnvironmentRow/EnvironmentRow.tsx b/frontend/src/component/environments/EnvironmentTable/EnvironmentRow/EnvironmentRow.tsx index e5f16f07b7..7c21e64fb0 100644 --- a/frontend/src/component/environments/EnvironmentTable/EnvironmentRow/EnvironmentRow.tsx +++ b/frontend/src/component/environments/EnvironmentTable/EnvironmentRow/EnvironmentRow.tsx @@ -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 ( - - {row.cells.map((cell: any) => ( + const dragItemRef = useDragItem( + row.index, + moveListItem, + dragHandleRef + ); + + const renderCell = (cell: any, ref: ForwardedRef) => { + if (draggable && cell.column.isDragHandle) { + return ( + + {cell.render('Cell')} + + ); + } else { + return ( {cell.render('Cell')} - ))} - + ); + } + }; + + return ( + + {row.cells.map((cell: any) => renderCell(cell, dragHandleRef))} + ); }; diff --git a/frontend/src/component/environments/EnvironmentTable/EnvironmentTable.tsx b/frontend/src/component/environments/EnvironmentTable/EnvironmentTable.tsx index e659a17514..d4002d50b6 100644 --- a/frontend/src/component/environments/EnvironmentTable/EnvironmentTable.tsx +++ b/frontend/src/component/environments/EnvironmentTable/EnvironmentTable.tsx @@ -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. - +
{rows.map(row => { @@ -138,22 +141,45 @@ const COLUMNS = [ { id: 'Icon', width: '1%', - Cell: () => , + Cell: ({ row: { original } }: { row: { original: IEnvironment } }) => ( + + ), disableGlobalFilter: true, + isDragHandle: true, }, { Header: 'Name', accessor: 'name', - Cell: ({ row: { original } }: any) => ( + Cell: ({ row: { original } }: { row: { original: IEnvironment } }) => ( ), + 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 } }) => ( ), disableGlobalFilter: true, diff --git a/frontend/src/component/environments/EnvironmentTable/EnvironmentTableSingle.tsx b/frontend/src/component/environments/EnvironmentTable/EnvironmentTableSingle.tsx new file mode 100644 index 0000000000..73e39278ff --- /dev/null +++ b/frontend/src/component/environments/EnvironmentTable/EnvironmentTableSingle.tsx @@ -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 }; + }) => , + }, + { + 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; + }) => ( + + {value} + 0 + )} + > + {original.enabledToggleCount === 1 + ? '1 toggle enabled' + : `${original.enabledToggleCount} toggles enabled`} + + } + /> + + ), + }, + ], + [warnEnabledToggles] + ); + + const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = + useTable({ + columns: COLUMNS as any, + data: [environment], + disableSortBy: true, + }); + + return ( + + + + {rows.map(row => { + prepareRow(row); + return ( + + {row.cells.map(cell => ( + + {cell.render('Cell')} + + ))} + + ); + })} + + + ); +}; diff --git a/frontend/src/component/environments/EnvironmentToggleConfirm/EnvironmentToggleConfirm.tsx b/frontend/src/component/environments/EnvironmentToggleConfirm/EnvironmentToggleConfirm.tsx deleted file mode 100644 index e47cc73c76..0000000000 --- a/frontend/src/component/environments/EnvironmentToggleConfirm/EnvironmentToggleConfirm.tsx +++ /dev/null @@ -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>; - handleConfirmToggleEnvironment: () => void; -} - -const EnvironmentToggleConfirm = ({ - env, - open, - setToggleDialog, - handleConfirmToggleEnvironment, -}: IEnvironmentToggleConfirmProps) => { - let text = env.enabled ? 'disable' : 'enable'; - - const handleCancel = () => { - setToggleDialog(false); - }; - - return ( - - - 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. - - } - elseShow={ - - Enabling an environment will allow you to add new - activation strategies to this environment. - - } - /> - - - - ); -}; - -export default EnvironmentToggleConfirm; diff --git a/frontend/src/component/project/ProjectEnvironment/EnvironmentDisableConfirm/EnvironmentDisableConfirm.styles.ts b/frontend/src/component/project/ProjectEnvironment/EnvironmentDisableConfirm/EnvironmentDisableConfirm.styles.ts deleted file mode 100644 index 75e0fdb3ea..0000000000 --- a/frontend/src/component/project/ProjectEnvironment/EnvironmentDisableConfirm/EnvironmentDisableConfirm.styles.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { makeStyles } from 'tss-react/mui'; - -export const useStyles = makeStyles()(theme => ({ - deleteParagraph: { - marginTop: '2rem', - }, - environmentDeleteInput: { - marginTop: '1rem', - }, -})); diff --git a/frontend/src/component/project/ProjectEnvironment/EnvironmentDisableConfirm/EnvironmentDisableConfirm.tsx b/frontend/src/component/project/ProjectEnvironment/EnvironmentDisableConfirm/EnvironmentDisableConfirm.tsx deleted file mode 100644 index d8ff18cc50..0000000000 --- a/frontend/src/component/project/ProjectEnvironment/EnvironmentDisableConfirm/EnvironmentDisableConfirm.tsx +++ /dev/null @@ -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; - handleCancelDisableEnvironment: () => void; - confirmName: string; - setConfirmName: React.Dispatch>; -} - -const EnvironmentDisableConfirm = ({ - env, - open, - handleDisableEnvironment, - handleCancelDisableEnvironment, - confirmName, - setConfirmName, -}: IEnvironmentDisableConfirmProps) => { - const { classes: styles } = useStyles(); - - const handleChange = (e: React.ChangeEvent) => - setConfirmName(e.currentTarget.value); - - const formId = 'disable-environment-confirmation-form'; - - return ( - handleDisableEnvironment()} - disabledPrimaryButton={env?.name !== confirmName} - onClose={handleCancelDisableEnvironment} - formId={formId} - > - - Danger. Disabling an environment can impact client applications - connected to the environment and result in feature toggles being - disabled. - - -

- In order to disable this environment, please enter the id of the - environment in the textfield below: {env?.name} -

- -
- - -
- ); -}; - -export default EnvironmentDisableConfirm; diff --git a/frontend/src/component/project/ProjectEnvironment/EnvironmentHideDialog/EnvironmentHideDialog.tsx b/frontend/src/component/project/ProjectEnvironment/EnvironmentHideDialog/EnvironmentHideDialog.tsx new file mode 100644 index 0000000000..03f35a5343 --- /dev/null +++ b/frontend/src/component/project/ProjectEnvironment/EnvironmentHideDialog/EnvironmentHideDialog.tsx @@ -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>; + onConfirm: () => void; +} + +export const EnvironmentHideDialog = ({ + environment, + open, + setOpen, + onConfirm, +}: IEnvironmentHideDialogProps) => { + const [confirmName, setConfirmName] = useState(''); + + useEffect(() => { + setConfirmName(''); + }, [open]); + + return ( + { + setOpen(false); + }} + > + + Danger! 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. + + + + + + In order to hide this environment, please enter the id of the + environment in the textfield below:{' '} + {environment?.name} + + setConfirmName(e.target.value)} + /> + + ); +}; diff --git a/frontend/src/component/project/ProjectEnvironment/EnvironmentHideDialog/ProjectEnvironmentTableSingle/ProjectEnvironmentTableSingle.tsx b/frontend/src/component/project/ProjectEnvironment/EnvironmentHideDialog/ProjectEnvironmentTableSingle/ProjectEnvironmentTableSingle.tsx new file mode 100644 index 0000000000..fd90ae7d4a --- /dev/null +++ b/frontend/src/component/project/ProjectEnvironment/EnvironmentHideDialog/ProjectEnvironmentTableSingle/ProjectEnvironmentTableSingle.tsx @@ -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 }; + }) => , + }, + { + Header: 'Name', + accessor: 'name', + Cell: TextCell, + }, + { + Header: 'Type', + accessor: 'type', + Cell: TextCell, + }, + { + Header: 'Toggles enabled', + accessor: 'projectEnabledToggleCount', + Cell: ({ value }: { value: number }) => ( + + 0}> + {value === 1 ? '1 toggle' : `${value} toggles`} + + + ), + align: 'center', + }, + ], + [warnEnabledToggles] + ); + + const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = + useTable({ + columns: COLUMNS as any, + data: [environment], + disableSortBy: true, + }); + + return ( + + + + {rows.map(row => { + prepareRow(row); + return ( + + {row.cells.map(cell => ( + + {cell.render('Cell')} + + ))} + + ); + })} + + + ); +}; diff --git a/frontend/src/component/project/ProjectEnvironment/ProjectEnvironment.tsx b/frontend/src/component/project/ProjectEnvironment/ProjectEnvironment.tsx index 7686110379..bdc3ca635b 100644 --- a/frontend/src/component/project/ProjectEnvironment/ProjectEnvironment.tsx +++ b/frontend/src/component/project/ProjectEnvironment/ProjectEnvironment.tsx @@ -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([]); 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(); - const [confirmName, setConfirmName] = useState(''); + const [selectedEnvironment, setSelectedEnvironment] = + useState(); + 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( + () => + 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) => ( -
- - - - {/* This is ugly - but regular {" "} doesn't work here*/} -

-   environment is{' '} - {env.enabled ? 'enabled' : 'disabled'} -

-
- ); - const envIsDisabled = (projectName: string) => { return isOss() && projectName === 'default'; }; - const renderEnvironments = () => { - return ( - - {envs.map(env => ( - toggleEnv(env)} - /> - } - /> - ))} - - ); - }; + const COLUMNS = useMemo( + () => [ + { + Header: 'Name', + accessor: 'name', + Cell: ({ row: { original } }: any) => ( + + ), + 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) => ( + + toggleEnv(original)} + /> + + ), + disableGlobalFilter: true, + }, + ], + [projectEnvironments] + ); - return ( - } - isLoading={loading} - > + /> + ); + + return ( + { condition={Boolean(error)} show={renderError()} /> - - Important! In order for your application to - retrieve configured activation strategies for a - specific environment, the application -
must use an environment specific API key. You - can look up the environment-specific API keys{' '} - here. + + Important! 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{' '} + API tokens here.

Your administrator can configure an - environment-specific API key to be used in the SDK. - If you are an administrator you can{' '} - create a new API key. -
+ environment-specific API token to be used in the + SDK. If you are an administrator you can{' '} + + create a new API token here + + . + + +
+ + + {rows.map(row => { + prepareRow(row); + return ( + + {row.cells.map(cell => ( + + {cell.render('Cell')} + + ))} + + ); + })} + +
+
No environments available.} - elseShow={renderEnvironments()} - /> - 0} + show={ + + No environments found matching + “ + {globalFilter} + ” + + } + elseShow={ + + No environments available. Get + started by adding one. + + } + /> } - confirmName={confirmName} - setConfirmName={setConfirmName} + /> + } diff --git a/frontend/src/component/project/ProjectEnvironment/getEnabledEnvs.test.ts b/frontend/src/component/project/ProjectEnvironment/getEnabledEnvs.test.ts index cfff0f89dd..250857aba4 100644 --- a/frontend/src/component/project/ProjectEnvironment/getEnabledEnvs.test.ts +++ b/frontend/src/component/project/ProjectEnvironment/getEnabledEnvs.test.ts @@ -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, }; }; diff --git a/frontend/src/hooks/api/getters/useProjectEnvironments/useProjectEnvironments.ts b/frontend/src/hooks/api/getters/useProjectEnvironments/useProjectEnvironments.ts new file mode 100644 index 0000000000..6af0d59b92 --- /dev/null +++ b/frontend/src/hooks/api/getters/useProjectEnvironments/useProjectEnvironments.ts @@ -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; +} + +export const useProjectEnvironments = ( + projectId: string +): IUseProjectEnvironmentsOutput => { + const { data, error, mutate } = useSWR( + 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 => { + const res: IEnvironmentResponse = await fetch(path) + .then(handleErrorResponses('Environments')) + .then(res => res.json()); + + return res.environments.sort((a, b) => { + return a.sortOrder - b.sortOrder; + }); +}; diff --git a/frontend/src/hooks/useDragItem.ts b/frontend/src/hooks/useDragItem.ts index 540c07389c..f94c859410 100644 --- a/frontend/src/hooks/useDragItem.ts +++ b/frontend/src/hooks/useDragItem.ts @@ -6,18 +6,21 @@ export type MoveListItem = ( save?: boolean ) => void; -export const useDragItem = ( +export const useDragItem = ( listItemIndex: number, - moveListItem: MoveListItem -): RefObject => { - const ref = useRef(null); + moveListItem: MoveListItem, + handle?: RefObject +): RefObject => { + const ref = useRef(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; diff --git a/frontend/src/interfaces/environments.ts b/frontend/src/interfaces/environments.ts index a1125fb32f..e43f5132a6 100644 --- a/frontend/src/interfaces/environments.ts +++ b/frontend/src/interfaces/environments.ts @@ -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 { diff --git a/src/lib/db/environment-store.ts b/src/lib/db/environment-store.ts index 7902a1a615..c7bde31ae9 100644 --- a/src/lib/db/environment-store.ts +++ b/src/lib/db/environment-store.ts @@ -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 { + let qB = this.db(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 { + let qB = this.db(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 { const result = await this.db.raw( `SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE name = ?) AS present`, diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index 41358e2b79..3be259752d 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -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, diff --git a/src/lib/openapi/spec/environment-project-schema.ts b/src/lib/openapi/spec/environment-project-schema.ts new file mode 100644 index 0000000000..26b0303a74 --- /dev/null +++ b/src/lib/openapi/spec/environment-project-schema.ts @@ -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 +>; diff --git a/src/lib/openapi/spec/environment-schema.ts b/src/lib/openapi/spec/environment-schema.ts index 3fec0e4b83..1d03356e3a 100644 --- a/src/lib/openapi/spec/environment-schema.ts +++ b/src/lib/openapi/spec/environment-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/environments-project-schema.ts b/src/lib/openapi/spec/environments-project-schema.ts new file mode 100644 index 0000000000..eac5c53bdc --- /dev/null +++ b/src/lib/openapi/spec/environments-project-schema.ts @@ -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 +>; diff --git a/src/lib/routes/admin-api/environments.ts b/src/lib/routes/admin-api/environments.ts index d9e764fe1b..e58f10a361 100644 --- a/src/lib/routes/admin-api/environments.ts +++ b/src/lib/routes/admin-api/environments.ts @@ -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, + res: Response, + ): Promise { + this.openApiService.respondWithValidation( + 200, + res, + environmentsProjectSchema.$id, + { + version: 1, + environments: await this.service.getProjectEnvironments( + req.params.projectId, + ), + }, + ); + } } diff --git a/src/lib/services/environment-service.ts b/src/lib/services/environment-service.ts index 891eca0a07..17a794c6e6 100644 --- a/src/lib/services/environment-service.ts +++ b/src/lib/services/environment-service.ts @@ -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 { - return this.environmentStore.getAll(); + return this.environmentStore.getAllWithCounts(); } async get(name: string): Promise { return this.environmentStore.get(name); } + async getProjectEnvironments( + projectId: string, + ): Promise { + return this.environmentStore.getProjectEnvironments(projectId); + } + async updateSortOrder(sortOrder: ISortOrder): Promise { await sortOrderSchema.validateAsync(sortOrder); await Promise.all( diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 00771d6d50..60d58173a6 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -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 { diff --git a/src/lib/types/stores/environment-store.ts b/src/lib/types/stores/environment-store.ts index e82db20cef..1e5694ed42 100644 --- a/src/lib/types/stores/environment-store.ts +++ b/src/lib/types/stores/environment-store.ts @@ -1,4 +1,8 @@ -import { IEnvironment, IEnvironmentCreate } from '../model'; +import { + IEnvironment, + IEnvironmentCreate, + IProjectEnvironment, +} from '../model'; import { Store } from './store'; export interface IEnvironmentStore extends Store { @@ -19,4 +23,6 @@ export interface IEnvironmentStore extends Store { disable(environments: IEnvironment[]): Promise; enable(environments: IEnvironment[]): Promise; count(): Promise; + getAllWithCounts(): Promise; + getProjectEnvironments(projectId: string): Promise; } diff --git a/src/test/e2e/api/admin/environment.test.ts b/src/test/e2e/api/admin/environment.test.ts index 840396bb46..fb74aa85c5 100644 --- a/src/test/e2e/api/admin/environment.test.ts +++ b/src/test/e2e/api/admin/environment.test.ts @@ -29,6 +29,9 @@ test('Can list all existing environments', async () => { sortOrder: 1, type: 'production', protected: true, + projectCount: 1, + apiTokenCount: 0, + enabledToggleCount: 0, }); }); }); diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 0e51e16351..5878a4a290 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -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", diff --git a/src/test/fixtures/fake-environment-store.ts b/src/test/fixtures/fake-environment-store.ts index fe896e06ac..39b21fd5ee 100644 --- a/src/test/fixtures/fake-environment-store.ts +++ b/src/test/fixtures/fake-environment-store.ts @@ -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 { return this.environments.find((e) => e.name === key); } + + async getAllWithCounts(): Promise { + return Promise.resolve(this.environments); + } + + async getProjectEnvironments( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + projectId: string, + ): Promise { + return Promise.reject(new Error('Not implemented')); + } } module.exports = FakeEnvironmentStore;