1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-14 00:15:52 +01:00

refactor: replace react-dnd with custom implementation (#988)

* refactor: replace react-dnd with custom implementation

* refactor: add TextCell, IconCell, and ActionCell

* refactor: port environments list to react-table

* refactor: change OfflineBolt to PowerSettingsNew

* refactor: simplify environment toast text

* refactor: improve IToast type type

* refactor: improve useSearchHighlightContext naming

* refactor: clarify enableDragAndDrop logic
This commit is contained in:
olav 2022-05-25 11:40:20 +02:00 committed by GitHub
parent c073908027
commit 34f848ce8a
30 changed files with 585 additions and 559 deletions

View File

@ -83,8 +83,6 @@
"prop-types": "15.8.1",
"react": "17.0.2",
"react-chartjs-2": "4.1.0",
"react-dnd": "15.1.2",
"react-dnd-html5-backend": "15.1.3",
"react-dom": "17.0.2",
"react-hooks-global-state": "1.0.2",
"react-router-dom": "6.3.0",

View File

@ -4,5 +4,6 @@ const SearchHighlightContext = createContext('');
export const SearchHighlightProvider = SearchHighlightContext.Provider;
export const useSearchHighlightContext = () =>
useContext(SearchHighlightContext);
export const useSearchHighlightContext = (): { searchQuery: string } => {
return { searchQuery: useContext(SearchHighlightContext) };
};

View File

@ -0,0 +1,17 @@
import { Box } from '@mui/material';
import { ReactNode } from 'react';
interface IContextActionsCellProps {
children: ReactNode;
}
export const ActionCell = ({ children }: IContextActionsCellProps) => {
return (
<Box
data-loading
sx={{ display: 'flex', justifyContent: 'flex-end', px: 2 }}
>
{children}
</Box>
);
};

View File

@ -1,8 +1,8 @@
import { VFC } from 'react';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { formatDateYMD, formatDateYMDHMS } from 'utils/formatDate';
import { Box, Tooltip } from '@mui/material';
import { formatDateYMD } from 'utils/formatDate';
import { parseISO } from 'date-fns';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
interface IDateCellProps {
value?: Date | string | null;
@ -11,22 +11,11 @@ interface IDateCellProps {
export const DateCell: VFC<IDateCellProps> = ({ value }) => {
const { locationSettings } = useLocationSettings();
if (!value) {
return <Box sx={{ py: 1.5, px: 2 }} />;
}
const date = value
? value instanceof Date
? formatDateYMD(value, locationSettings.locale)
: formatDateYMD(parseISO(value), locationSettings.locale)
: undefined;
const date = value instanceof Date ? value : parseISO(value);
return (
<Box sx={{ py: 1.5, px: 2 }}>
<Tooltip
title={formatDateYMDHMS(date, locationSettings.locale)}
arrow
>
<span data-loading role="tooltip">
{formatDateYMD(date, locationSettings.locale)}
</span>
</Tooltip>
</Box>
);
return <TextCell>{date}</TextCell>;
};

View File

@ -0,0 +1,23 @@
import { Box } from '@mui/material';
import { ReactNode } from 'react';
interface IIconCellProps {
icon: ReactNode;
}
export const IconCell = ({ icon }: IIconCellProps) => {
return (
<Box
data-loading
sx={{
pl: 2,
pr: 1,
display: 'flex',
alignItems: 'center',
minHeight: 60,
}}
>
{icon}
</Box>
);
};

View File

@ -20,7 +20,7 @@ export const LinkCell: FC<ILinkCellProps> = ({
children,
}) => {
const { classes: styles } = useStyles();
const search = useSearchHighlightContext();
const { searchQuery } = useSearchHighlightContext();
const content = (
<div className={styles.container}>
@ -32,7 +32,7 @@ export const LinkCell: FC<ILinkCellProps> = ({
lineClamp: Boolean(subtitle) ? 1 : 2,
}}
>
<Highlighter search={search}>{title}</Highlighter>
<Highlighter search={searchQuery}>{title}</Highlighter>
{children}
</span>
<ConditionallyRender
@ -44,7 +44,7 @@ export const LinkCell: FC<ILinkCellProps> = ({
component="span"
data-loading
>
<Highlighter search={search}>
<Highlighter search={searchQuery}>
{subtitle}
</Highlighter>
</Typography>

View File

@ -0,0 +1,20 @@
import { VFC, ReactNode } from 'react';
import { Box } from '@mui/material';
interface IDateCellProps {
children?: ReactNode;
}
export const TextCell: VFC<IDateCellProps> = ({ children }) => {
if (!children) {
return <Box sx={{ py: 1.5, px: 2 }} />;
}
return (
<Box sx={{ py: 1.5, px: 2 }}>
<span data-loading role="tooltip">
{children}
</span>
</Box>
);
};

View File

@ -1,12 +1,12 @@
import { VFC } from 'react';
import { useNavigate } from 'react-router-dom';
import { Delete, Edit } from '@mui/icons-material';
import { Box } from '@mui/material';
import {
DELETE_CONTEXT_FIELD,
UPDATE_CONTEXT_FIELD,
} from 'component/providers/AccessProvider/permissions';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
interface IContextActionsCellProps {
name: string;
@ -20,14 +20,7 @@ export const ContextActionsCell: VFC<IContextActionsCellProps> = ({
const navigate = useNavigate();
return (
<Box
data-loading
sx={{
display: 'flex',
px: 2,
justifyContent: 'flex-end',
}}
>
<ActionCell>
<PermissionIconButton
permission={UPDATE_CONTEXT_FIELD}
onClick={() => navigate(`/context/edit/${name}`)}
@ -50,6 +43,6 @@ export const ContextActionsCell: VFC<IContextActionsCellProps> = ({
>
<Delete />
</PermissionIconButton>
</Box>
</ActionCell>
);
};

View File

@ -23,7 +23,7 @@ import { sortTypes } from 'utils/sortTypes';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { ContextActionsCell } from './ContextActionsCell/ContextActionsCell';
import { Adjust } from '@mui/icons-material';
import { Box } from '@mui/material';
import { IconCell } from 'component/common/Table/cells/IconCell/IconCell';
const ContextList: VFC = () => {
const [showDelDialogue, setShowDelDialogue] = useState(false);
@ -53,19 +53,7 @@ const ContextList: VFC = () => {
() => [
{
id: 'Icon',
Cell: () => (
<Box
data-loading
sx={{
pl: 2,
pr: 1,
display: 'flex',
alignItems: 'center',
}}
>
<Adjust color="disabled" />
</Box>
),
Cell: () => <IconCell icon={<Adjust color="disabled" />} />,
},
{
Header: 'Name',

View File

@ -0,0 +1,22 @@
import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
import { Add } from '@mui/icons-material';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useNavigate } from 'react-router-dom';
export const CreateEnvironmentButton = () => {
const { uiConfig } = useUiConfig();
const navigate = useNavigate();
return (
<ResponsiveButton
onClick={() => navigate('/environments/create')}
maxWidth="700px"
Icon={Add}
permission={ADMIN}
disabled={!Boolean(uiConfig.flags.EEA)}
>
New Environment
</ResponsiveButton>
);
};

View File

@ -0,0 +1,193 @@
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
import {
DELETE_ENVIRONMENT,
UPDATE_ENVIRONMENT,
} from 'component/providers/AccessProvider/permissions';
import {
Edit,
Delete,
OfflineBolt,
DragIndicator,
PowerSettingsNew,
} from '@mui/icons-material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { IconButton, Tooltip } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import AccessContext from 'contexts/AccessContext';
import { useContext, 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 { useId } from 'hooks/useId';
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
interface IEnvironmentTableActionsProps {
environment: IEnvironment;
}
export const EnvironmentActionCell = ({
environment,
}: IEnvironmentTableActionsProps) => {
const navigate = useNavigate();
const { hasAccess } = useContext(AccessContext);
const updatePermission = hasAccess(UPDATE_ENVIRONMENT);
const { searchQuery } = useSearchHighlightContext();
const { setToastApiError, setToastData } = useToast();
const { refetchEnvironments } = useEnvironments();
const { refetch: refetchPermissions } = useProjectRolePermissions();
const { deleteEnvironment, toggleEnvironmentOn, toggleEnvironmentOff } =
useEnvironmentApi();
const [deleteModal, setDeleteModal] = useState(false);
const [toggleModal, setToggleModal] = useState(false);
const [confirmName, setConfirmName] = useState('');
const handleDeleteEnvironment = async () => {
try {
await deleteEnvironment(environment.name);
refetchPermissions();
setToastData({
type: 'success',
title: 'Project environment deleted',
text: 'You have successfully deleted the project environment.',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
} finally {
setDeleteModal(false);
setConfirmName('');
await refetchEnvironments();
}
};
const handleConfirmToggleEnvironment = () => {
return environment.enabled
? handleToggleEnvironmentOff()
: handleToggleEnvironmentOn();
};
const handleToggleEnvironmentOn = async () => {
try {
await toggleEnvironmentOn(environment.name);
setToggleModal(false);
setToastData({
type: 'success',
title: 'Project environment enabled',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
} finally {
await refetchEnvironments();
}
};
const handleToggleEnvironmentOff = async () => {
try {
await toggleEnvironmentOff(environment.name);
setToggleModal(false);
setToastData({
type: 'success',
title: 'Project environment disabled',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
} finally {
await refetchEnvironments();
}
};
const toggleIconTooltip = environment.enabled
? 'Disable environment'
: 'Enable environment';
const editId = useId();
const deleteId = useId();
// 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 (
<ActionCell>
<ConditionallyRender
condition={enableDragAndDrop}
show={
<IconButton size="large">
<DragIndicator titleAccess="Drag" cursor="grab" />
</IconButton>
}
/>
<ConditionallyRender
condition={updatePermission}
show={
<Tooltip title={toggleIconTooltip} arrow>
<IconButton
onClick={() => setToggleModal(true)}
size="large"
>
<PowerSettingsNew />
</IconButton>
</Tooltip>
}
/>
<ConditionallyRender
condition={updatePermission}
show={
<Tooltip title="Edit environment" arrow>
<span id={editId}>
<IconButton
aria-describedby={editId}
disabled={environment.protected}
onClick={() => {
navigate(
`/environments/${environment.name}`
);
}}
size="large"
>
<Edit />
</IconButton>
</span>
</Tooltip>
}
/>
<ConditionallyRender
condition={hasAccess(DELETE_ENVIRONMENT)}
show={
<Tooltip title="Delete environment" arrow>
<span id={deleteId}>
<IconButton
aria-describedby={deleteId}
disabled={environment.protected}
onClick={() => setDeleteModal(true)}
size="large"
>
<Delete />
</IconButton>
</span>
</Tooltip>
}
/>
<EnvironmentDeleteConfirm
env={environment}
setDeldialogue={setDeleteModal}
open={deleteModal}
handleDeleteEnvironment={handleDeleteEnvironment}
confirmName={confirmName}
setConfirmName={setConfirmName}
/>
<EnvironmentToggleConfirm
env={environment}
open={toggleModal}
setToggleDialog={setToggleModal}
handleConfirmToggleEnvironment={handleConfirmToggleEnvironment}
/>
</ActionCell>
);
};

View File

@ -1,6 +1,6 @@
import { CloudCircle } from '@mui/icons-material';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { useStyles } from './EnvironmentCard.styles';
import { useStyles } from 'component/environments/EnvironmentCard/EnvironmentCard.styles';
interface IEnvironmentProps {
name: string;

View File

@ -3,13 +3,12 @@ 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 '../EnvironmentCard/EnvironmentCard';
import { useStyles } from './EnvironmentDeleteConfirm.styles';
import EnvironmentCard from 'component/environments/EnvironmentCard/EnvironmentCard';
import { useStyles } from 'component/environments/EnvironmentDeleteConfirm/EnvironmentDeleteConfirm.styles';
interface IEnviromentDeleteConfirmProps {
env: IEnvironment;
open: boolean;
setSelectedEnv: React.Dispatch<React.SetStateAction<IEnvironment>>;
setDeldialogue: React.Dispatch<React.SetStateAction<boolean>>;
handleDeleteEnvironment: () => Promise<void>;
confirmName: string;

View File

@ -1,202 +0,0 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { List } from '@mui/material';
import { Add } from '@mui/icons-material';
import useToast from 'hooks/useToast';
import { IEnvironment, ISortOrderPayload } from 'interfaces/environments';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
import { PageContent } from 'component/common/PageContent/PageContent';
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
import useEnvironmentApi from 'hooks/api/actions/useEnvironmentApi/useEnvironmentApi';
import useProjectRolePermissions from 'hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { formatUnknownError } from 'utils/formatUnknownError';
import EnvironmentListItem from './EnvironmentListItem/EnvironmentListItem';
import EnvironmentToggleConfirm from './EnvironmentToggleConfirm/EnvironmentToggleConfirm';
import EnvironmentDeleteConfirm from './EnvironmentDeleteConfirm/EnvironmentDeleteConfirm';
const EnvironmentList = () => {
const defaultEnv = {
name: '',
type: '',
sortOrder: 0,
createdAt: '',
enabled: true,
protected: false,
};
const { environments, mutateEnvironments, refetchEnvironments } =
useEnvironments();
const { uiConfig } = useUiConfig();
const { refetch: refetchProjectRolePermissions } =
useProjectRolePermissions();
const [selectedEnv, setSelectedEnv] = useState(defaultEnv);
const [delDialog, setDeldialogue] = useState(false);
const [toggleDialog, setToggleDialog] = useState(false);
const [confirmName, setConfirmName] = useState('');
const navigate = useNavigate();
const { setToastApiError, setToastData } = useToast();
const {
deleteEnvironment,
changeSortOrder,
toggleEnvironmentOn,
toggleEnvironmentOff,
} = useEnvironmentApi();
const moveListItem = (dragIndex: number, hoverIndex: number) => {
const newEnvList = [...environments];
if (newEnvList.length === 0) return newEnvList;
const item = newEnvList.splice(dragIndex, 1)[0];
newEnvList.splice(hoverIndex, 0, item);
mutateEnvironments(newEnvList);
return newEnvList;
};
const moveListItemApi = async (dragIndex: number, hoverIndex: number) => {
const newEnvList = moveListItem(dragIndex, hoverIndex);
const sortOrder = newEnvList.reduce(
(acc: ISortOrderPayload, env: IEnvironment, index: number) => {
acc[env.name] = index + 1;
return acc;
},
{}
);
try {
await sortOrderAPICall(sortOrder);
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
const sortOrderAPICall = async (sortOrder: ISortOrderPayload) => {
try {
await changeSortOrder(sortOrder);
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
const handleDeleteEnvironment = async () => {
try {
await deleteEnvironment(selectedEnv.name);
refetchProjectRolePermissions();
setToastData({
type: 'success',
title: 'Project environment deleted',
text: 'You have successfully deleted the project environment.',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
} finally {
setDeldialogue(false);
setSelectedEnv(defaultEnv);
setConfirmName('');
refetchEnvironments();
}
};
const handleConfirmToggleEnvironment = () => {
if (selectedEnv.enabled) {
return handleToggleEnvironmentOff();
}
handleToggleEnvironmentOn();
};
const handleToggleEnvironmentOn = async () => {
try {
await toggleEnvironmentOn(selectedEnv.name);
setToggleDialog(false);
setToastData({
type: 'success',
title: 'Project environment enabled',
text: 'Your environment is enabled',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
} finally {
refetchEnvironments();
}
};
const handleToggleEnvironmentOff = async () => {
try {
await toggleEnvironmentOff(selectedEnv.name);
setToggleDialog(false);
setToastData({
type: 'success',
title: 'Project environment disabled',
text: 'Your environment is disabled.',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
} finally {
refetchEnvironments();
}
};
const environmentList = () =>
environments.map((env: IEnvironment, index: number) => (
<EnvironmentListItem
key={env.name}
env={env}
setDeldialogue={setDeldialogue}
setSelectedEnv={setSelectedEnv}
setToggleDialog={setToggleDialog}
index={index}
moveListItem={moveListItem}
moveListItemApi={moveListItemApi}
/>
));
const navigateToCreateEnvironment = () => {
navigate('/environments/create');
};
return (
<PageContent
header={
<PageHeader
title="Environments"
actions={
<>
<ResponsiveButton
onClick={navigateToCreateEnvironment}
maxWidth="700px"
Icon={Add}
permission={ADMIN}
disabled={!Boolean(uiConfig.flags.EEA)}
>
New Environment
</ResponsiveButton>
</>
}
/>
}
>
<List>{environmentList()}</List>
<EnvironmentDeleteConfirm
env={selectedEnv}
setSelectedEnv={setSelectedEnv}
setDeldialogue={setDeldialogue}
open={delDialog}
handleDeleteEnvironment={handleDeleteEnvironment}
confirmName={confirmName}
setConfirmName={setConfirmName}
/>
<EnvironmentToggleConfirm
env={selectedEnv}
open={toggleDialog}
setToggleDialog={setToggleDialog}
handleConfirmToggleEnvironment={handleConfirmToggleEnvironment}
/>
</PageContent>
);
};
export default EnvironmentList;

View File

@ -1,219 +0,0 @@
import {
ListItem,
ListItemIcon,
ListItemText,
Tooltip,
IconButton,
} from '@mui/material';
import {
CloudCircle,
Delete,
DragIndicator,
Edit,
OfflineBolt,
} from '@mui/icons-material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { IEnvironment } from 'interfaces/environments';
import React, { useContext, useRef } from 'react';
import AccessContext from 'contexts/AccessContext';
import {
DELETE_ENVIRONMENT,
UPDATE_ENVIRONMENT,
} from 'component/providers/AccessProvider/permissions';
import { useDrag, useDrop, DropTargetMonitor } from 'react-dnd';
import { XYCoord, Identifier } from 'dnd-core';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { useNavigate } from 'react-router-dom';
import { StatusBadge } from 'component/common/StatusBadge/StatusBadge';
interface IEnvironmentListItemProps {
env: IEnvironment;
setSelectedEnv: React.Dispatch<React.SetStateAction<IEnvironment>>;
setDeldialogue: React.Dispatch<React.SetStateAction<boolean>>;
setToggleDialog: React.Dispatch<React.SetStateAction<boolean>>;
index: number;
moveListItem: (dragIndex: number, hoverIndex: number) => IEnvironment[];
moveListItemApi: (dragIndex: number, hoverIndex: number) => Promise<void>;
}
interface IDragItem {
index: number;
id: string;
type: string;
}
interface ICollectedProps {
handlerId: Identifier | null;
}
const EnvironmentListItem = ({
env,
setSelectedEnv,
setDeldialogue,
index,
moveListItem,
moveListItemApi,
setToggleDialog,
}: IEnvironmentListItemProps) => {
const navigate = useNavigate();
const ref = useRef<HTMLLIElement>(null);
const ACCEPT_TYPE = 'LIST_ITEM';
const [{ isDragging }, drag] = useDrag({
type: ACCEPT_TYPE,
item: () => {
return { env, index };
},
collect: (monitor: any) => ({
isDragging: monitor.isDragging(),
}),
});
const [{ handlerId }, drop] = useDrop<IDragItem, unknown, ICollectedProps>({
accept: ACCEPT_TYPE,
collect(monitor) {
return {
handlerId: monitor.getHandlerId(),
};
},
drop(item: IDragItem, monitor: DropTargetMonitor) {
const dragIndex = item.index;
const hoverIndex = index;
moveListItemApi(dragIndex, hoverIndex);
},
hover(item: IDragItem, monitor: DropTargetMonitor) {
if (!ref.current) {
return;
}
const dragIndex = item.index;
const hoverIndex = index;
if (dragIndex === hoverIndex) {
return;
}
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const hoverMiddleY =
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
const hoverClientY =
(clientOffset as XYCoord).y - hoverBoundingRect.top;
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
return;
}
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
return;
}
moveListItem(dragIndex, hoverIndex);
item.index = hoverIndex;
},
});
const opacity = isDragging ? 0 : 1;
const { hasAccess } = useContext(AccessContext);
const updatePermission = hasAccess(UPDATE_ENVIRONMENT);
const tooltipText = env.enabled ? 'Disable' : 'Enable';
if (updatePermission) {
drag(drop(ref));
}
return (
<ListItem
style={{ position: 'relative', opacity }}
ref={ref}
data-handler-id={handlerId}
>
<ListItemIcon>
<CloudCircle />
</ListItemIcon>
<ListItemText
primary={
<>
<strong>
<StringTruncator
text={env.name}
maxWidth={'125'}
maxLength={25}
/>
</strong>
<ConditionallyRender
condition={!env.enabled}
show={
<StatusBadge severity="warning">
Disabled
</StatusBadge>
}
/>
</>
}
/>
<ConditionallyRender
condition={updatePermission}
show={
<IconButton size="large">
<DragIndicator titleAccess="Drag" cursor="grab" />
</IconButton>
}
/>
<ConditionallyRender
condition={updatePermission}
show={
<Tooltip title={`${tooltipText} environment`} arrow>
<IconButton
onClick={() => {
setSelectedEnv(env);
setToggleDialog(prev => !prev);
}}
size="large"
>
<OfflineBolt />
</IconButton>
</Tooltip>
}
/>
<ConditionallyRender
condition={updatePermission}
show={
<Tooltip title="Edit environment" arrow>
<IconButton
disabled={env.protected}
onClick={() => {
navigate(`/environments/${env.name}`);
}}
size="large"
>
<Edit />
</IconButton>
</Tooltip>
}
/>
<ConditionallyRender
condition={hasAccess(DELETE_ENVIRONMENT)}
show={
<Tooltip title="Delete environment" arrow>
<IconButton
disabled={env.protected}
onClick={() => {
setDeldialogue(true);
setSelectedEnv(env);
}}
size="large"
>
<Delete />
</IconButton>
</Tooltip>
}
/>
</ListItem>
);
};
export default EnvironmentListItem;

View File

@ -0,0 +1,22 @@
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { IEnvironment } from 'interfaces/environments';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { StatusBadge } from 'component/common/StatusBadge/StatusBadge';
interface IEnvironmentNameCellProps {
environment: IEnvironment;
}
export const EnvironmentNameCell = ({
environment,
}: IEnvironmentNameCellProps) => {
return (
<TextCell>
{environment.name}
<ConditionallyRender
condition={!environment.enabled}
show={<StatusBadge severity="warning">Disabled</StatusBadge>}
/>
</TextCell>
);
};

View File

@ -0,0 +1,26 @@
import { useDragItem, MoveListItem } from 'hooks/useDragItem';
import { Row } from 'react-table';
import { TableRow } from '@mui/material';
import { TableCell } from 'component/common/Table';
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
interface IEnvironmentRowProps {
row: Row;
moveListItem: MoveListItem;
}
export const EnvironmentRow = ({ row, moveListItem }: IEnvironmentRowProps) => {
const dragItemRef = useDragItem(row.index, moveListItem);
const { searchQuery } = useSearchHighlightContext();
const draggable = !searchQuery;
return (
<TableRow hover ref={draggable ? dragItemRef : undefined}>
{row.cells.map((cell: any) => (
<TableCell {...cell.getCellProps()}>
{cell.render('Cell')}
</TableCell>
))}
</TableRow>
);
};

View File

@ -0,0 +1,128 @@
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
import { CreateEnvironmentButton } from 'component/environments/CreateEnvironmentButton/CreateEnvironmentButton';
import { useTable, useGlobalFilter } from 'react-table';
import {
TableSearch,
SortableTableHeader,
Table,
} from 'component/common/Table';
import { useCallback } from 'react';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { TableBody } from '@mui/material';
import { CloudCircle } from '@mui/icons-material';
import { IconCell } from 'component/common/Table/cells/IconCell/IconCell';
import { EnvironmentActionCell } from 'component/environments/EnvironmentActionCell/EnvironmentActionCell';
import { EnvironmentNameCell } from 'component/environments/EnvironmentNameCell/EnvironmentNameCell';
import { EnvironmentRow } from 'component/environments/EnvironmentRow/EnvironmentRow';
import { MoveListItem } from 'hooks/useDragItem';
import useToast from 'hooks/useToast';
import useEnvironmentApi, {
createSortOrderPayload,
} from 'hooks/api/actions/useEnvironmentApi/useEnvironmentApi';
import { formatUnknownError } from 'utils/formatUnknownError';
export const EnvironmentTable = () => {
const { changeSortOrder } = useEnvironmentApi();
const { setToastApiError } = useToast();
const { environments, mutateEnvironments } = useEnvironments();
const moveListItem: MoveListItem = useCallback(
async (dragIndex: number, dropIndex: number, save = false) => {
const copy = [...environments];
const tmp = copy[dragIndex];
copy.splice(dragIndex, 1);
copy.splice(dropIndex, 0, tmp);
await mutateEnvironments(copy);
if (save) {
try {
await changeSortOrder(createSortOrderPayload(copy));
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
}
},
[changeSortOrder, environments, mutateEnvironments, setToastApiError]
);
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
state: { globalFilter },
setGlobalFilter,
} = useTable(
{
columns: COLUMNS as any,
data: environments,
autoResetGlobalFilter: false,
},
useGlobalFilter
);
const headerSearch = (
<TableSearch initialValue={globalFilter} onChange={setGlobalFilter} />
);
const headerActions = (
<>
{headerSearch}
<PageHeader.Divider />
<CreateEnvironmentButton />
</>
);
const header = <PageHeader title="Environments" actions={headerActions} />;
return (
<PageContent header={header}>
<SearchHighlightProvider value={globalFilter}>
<Table {...getTableProps()}>
<SortableTableHeader headerGroups={headerGroups as any} />
<TableBody {...getTableBodyProps()}>
{rows.map(row => {
prepareRow(row);
return (
<EnvironmentRow
row={row as any}
moveListItem={moveListItem}
key={row.original.name}
/>
);
})}
</TableBody>
</Table>
</SearchHighlightProvider>
</PageContent>
);
};
const COLUMNS = [
{
id: 'Icon',
canSort: false,
Cell: () => <IconCell icon={<CloudCircle color="disabled" />} />,
},
{
Header: 'Name',
accessor: 'name',
width: '100%',
canSort: false,
Cell: (props: any) => (
<EnvironmentNameCell environment={props.row.original} />
),
},
{
Header: 'Actions',
id: 'Actions',
align: 'center',
canSort: false,
Cell: (props: any) => (
<EnvironmentActionCell environment={props.row.original} />
),
},
];

View File

@ -4,7 +4,7 @@ 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 '../EnvironmentCard/EnvironmentCard';
import EnvironmentCard from 'component/environments/EnvironmentCard/EnvironmentCard';
interface IEnvironmentToggleConfirmProps {
env: IEnvironment;

View File

@ -18,7 +18,6 @@ import ForgottenPassword from 'component/user/ForgottenPassword/ForgottenPasswor
import { ProjectListNew } from 'component/project/ProjectList/ProjectList';
import Project from 'component/project/Project/Project';
import RedirectArchive from 'component/archive/RedirectArchive';
import EnvironmentList from 'component/environments/EnvironmentList/EnvironmentList';
import { FeatureView } from 'component/feature/FeatureView/FeatureView';
import ProjectRoles from 'component/admin/projectRoles/ProjectRoles/ProjectRoles';
import CreateProjectRole from 'component/admin/projectRoles/CreateProjectRole/CreateProjectRole';
@ -52,6 +51,7 @@ import { CreateSegment } from 'component/segments/CreateSegment/CreateSegment';
import { EditSegment } from 'component/segments/EditSegment/EditSegment';
import { SegmentsList } from 'component/segments/SegmentList/SegmentList';
import { IRoute } from 'interfaces/route';
import { EnvironmentTable } from 'component/environments/EnvironmentTable/EnvironmentTable';
export const routes: IRoute[] = [
// Splash
@ -266,7 +266,7 @@ export const routes: IRoute[] = [
{
path: '/environments',
title: 'Environments',
component: EnvironmentList,
component: EnvironmentTable,
type: 'protected',
flag: EEA,
menu: { mobile: true, advanced: true },

View File

@ -10,12 +10,12 @@ interface IUIContext {
export const createEmptyToast = (): IToast => {
return {
type: 'success',
title: '',
text: '',
components: [],
show: false,
persist: false,
type: '',
};
};

View File

@ -2,6 +2,7 @@ import {
IEnvironmentPayload,
ISortOrderPayload,
IEnvironmentEditPayload,
IEnvironment,
} from 'interfaces/environments';
import useAPI from '../useApi/useApi';
@ -145,4 +146,13 @@ const useEnvironmentApi = () => {
};
};
export const createSortOrderPayload = (
environments: Readonly<IEnvironment[]>
): ISortOrderPayload => {
return environments.reduce((payload, env, index) => {
payload[env.name] = index + 1;
return payload;
}, {} as ISortOrderPayload);
};
export default useEnvironmentApi;

View File

@ -13,13 +13,13 @@ interface IUseEnvironmentsOutput {
}
export const useEnvironments = (): IUseEnvironmentsOutput => {
const { data, error, mutate } = useSWR<IEnvironmentResponse>(
const { data, error, mutate } = useSWR<IEnvironment[]>(
formatApiPath(`api/admin/environments`),
fetcher
);
const environments = useMemo(() => {
return data?.environments || [];
return data || [];
}, [data]);
const refetchEnvironments = useCallback(async () => {
@ -28,7 +28,7 @@ export const useEnvironments = (): IUseEnvironmentsOutput => {
const mutateEnvironments = useCallback(
async (environments: IEnvironment[]) => {
await mutate({ environments }, false);
await mutate(environments, false);
},
[mutate]
);
@ -42,8 +42,12 @@ export const useEnvironments = (): IUseEnvironmentsOutput => {
};
};
const fetcher = (path: string): Promise<IEnvironmentResponse> => {
return fetch(path)
const fetcher = async (path: string): Promise<IEnvironment[]> => {
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

@ -0,0 +1,72 @@
import { useRef, useEffect, RefObject } from 'react';
export type MoveListItem = (
dragIndex: number,
dropIndex: number,
save?: boolean
) => void;
export const useDragItem = (
listItemIndex: number,
moveListItem: MoveListItem
): RefObject<HTMLTableRowElement> => {
const ref = useRef<HTMLTableRowElement>(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);
}
}, [listItemIndex, moveListItem]);
return ref;
};
const addEventListeners = (
el: HTMLTableRowElement,
moveListItem: MoveListItem
): (() => void) => {
const moveDraggedElement = (save: boolean) => {
if (globalDraggedElement) {
moveListItem(
Number(globalDraggedElement.dataset.index),
Number(el.dataset.index),
save
);
}
};
const onDragStart = () => {
globalDraggedElement = el;
};
const onDragEnter = () => {
moveDraggedElement(false);
};
const onDragOver = (event: DragEvent) => {
event.preventDefault();
};
const onDrop = () => {
moveDraggedElement(true);
globalDraggedElement = null;
};
el.addEventListener('dragstart', onDragStart);
el.addEventListener('dragenter', onDragEnter);
el.addEventListener('dragover', onDragOver);
el.addEventListener('drop', onDrop);
return () => {
el.removeEventListener('dragstart', onDragStart);
el.removeEventListener('dragenter', onDragEnter);
el.removeEventListener('dragover', onDragOver);
el.removeEventListener('drop', onDrop);
};
};
// The element being dragged in the browser.
let globalDraggedElement: HTMLTableRowElement | null;

View File

@ -4,8 +4,6 @@ import 'regenerator-runtime/runtime';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { ThemeProvider } from 'themes/ThemeProvider';
import { App } from 'component/App';
import { ScrollTop } from 'component/common/ScrollTop/ScrollTop';
@ -17,7 +15,6 @@ import { AnnouncerProvider } from 'component/common/Announcer/AnnouncerProvider/
import { InstanceStatus } from 'component/common/InstanceStatus/InstanceStatus';
ReactDOM.render(
<DndProvider backend={HTML5Backend}>
<UIProvider>
<AccessProvider>
<BrowserRouter basename={basePath}>
@ -33,7 +30,6 @@ ReactDOM.render(
</ThemeProvider>
</BrowserRouter>
</AccessProvider>
</UIProvider>
</DndProvider>,
</UIProvider>,
document.getElementById('app')
);

View File

@ -1,5 +1,5 @@
export interface IToast {
type: string;
type: 'success' | 'error';
title: string;
text?: string;
components?: JSX.Element[];

View File

@ -1,5 +0,0 @@
{
"globalDependencies": {
"react-dnd": "registry:dt/react-dnd#2.0.2+20161111212335",
}
}

View File

@ -1538,21 +1538,6 @@
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.5.tgz#db5a11bf66bdab39569719555b0f76e138d7bd64"
integrity sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw==
"@react-dnd/asap@4.0.1":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-4.0.1.tgz#5291850a6b58ce6f2da25352a64f1b0674871aab"
integrity sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==
"@react-dnd/invariant@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@react-dnd/invariant/-/invariant-3.0.1.tgz#7e70be19ea21b539e8bf1da28466f4f05df2a4cc"
integrity sha512-blqduwV86oiKw2Gr44wbe3pj3Z/OsXirc7ybCv9F/pLAR+Aih8F3rjeJzK0ANgtYKv5lCpkGVoZAeKitKDaD/g==
"@react-dnd/shallowequal@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@react-dnd/shallowequal/-/shallowequal-3.0.1.tgz#8056fe046a8d10a275e321ec0557ae652d7a4d06"
integrity sha512-XjDVbs3ZU16CO1h5Q3Ew2RPJqmZBDE/EVf1LYp6ePEffs3V/MX9ZbL5bJr8qiK5SbGmUMuDoaFgyKacYz8prRA==
"@rollup/pluginutils@^4.2.1":
version "4.2.1"
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.2.1.tgz#e6c6c3aba0744edce3fb2074922d3776c0af2a6d"
@ -3040,15 +3025,6 @@ dir-glob@^3.0.1:
dependencies:
path-type "^4.0.0"
dnd-core@15.1.2:
version "15.1.2"
resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-15.1.2.tgz#0983bce555c4985f58b731ffe1faed31e1ea7f6f"
integrity sha512-EOec1LyJUuGRFg0LDa55rSRAUe97uNVKVkUo8iyvzQlcECYTuPblVQfRWXWj1OyPseFIeebWpNmKFy0h6BcF1A==
dependencies:
"@react-dnd/asap" "4.0.1"
"@react-dnd/invariant" "3.0.1"
redux "^4.1.2"
doctrine@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
@ -5149,24 +5125,6 @@ react-chartjs-2@4.1.0:
resolved "https://registry.yarnpkg.com/react-chartjs-2/-/react-chartjs-2-4.1.0.tgz#2a123df16d3a987c54eb4e810ed766d3c03adf8d"
integrity sha512-AsUihxEp8Jm1oBhbEovE+w50m9PVNhz1sfwEIT4hZduRC0m14gHWHd0cUaxkFDb8HNkdMIGzsNlmVqKiOpU74g==
react-dnd-html5-backend@15.1.3:
version "15.1.3"
resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-15.1.3.tgz#57b4f47e0f23923e7c243d2d0eefe490069115a9"
integrity sha512-HH/8nOEmrrcRGHMqJR91FOwhnLlx5SRLXmsQwZT3IPcBjx88WT+0pWC5A4tDOYDdoooh9k+KMPvWfxooR5TcOA==
dependencies:
dnd-core "15.1.2"
react-dnd@15.1.2:
version "15.1.2"
resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-15.1.2.tgz#211b30fd842326209c63f26f1bdf1bc52eef4f64"
integrity sha512-EaSbMD9iFJDY/o48T3c8wn3uWU+2uxfFojhesZN3LhigJoAIvH2iOjxofSA9KbqhAKP6V9P853G6XG8JngKVtA==
dependencies:
"@react-dnd/invariant" "3.0.1"
"@react-dnd/shallowequal" "3.0.1"
dnd-core "15.1.2"
fast-deep-equal "^3.1.3"
hoist-non-react-statics "^3.3.2"
react-dom@17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
@ -5303,13 +5261,6 @@ redent@^3.0.0:
indent-string "^4.0.0"
strip-indent "^3.0.0"
redux@^4.1.2:
version "4.2.0"
resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.0.tgz#46f10d6e29b6666df758780437651eeb2b969f13"
integrity sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA==
dependencies:
"@babel/runtime" "^7.9.2"
reflect-metadata@0.1.13:
version "0.1.13"
resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08"