feat(connections): enable persistent column visibility and ordering #5235 (#5249)

* feat(connections): enable persistent column visibility and ordering

fix(connections): smooth column manager access in table view

fix(connections): integrate column manager into column menu

- Move the Connections column manager trigger into the DataGrid column menu, reusing existing i18n strings
- Clean up the page toolbar by removing the standalone button and lifting dialog state to the parent page

fix(connections): unify column order handling and resolve lint warnings

fix(connections): unify column order handling and enforce 0 ESLint warnings

- extract reconcileColumnOrder helper and reuse it across:
  - initial normalization (useEffect)
  - manager drag order handler (handleManagerOrderChange)
- derive columnOptions directly from the computed, sorted columns to keep a single source of truth and prevent dialog/grid drift
- connections.tsx: remove direct setState in useEffect; gate dialog open with `isTableLayout && isColumnManagerOpen`; clean up unused imports
- connection-column-manager.tsx: replace deprecated `primaryTypographyProps` with `slotProps`
- run full lint; project now has 0 warnings on main configuration

* feat(connection-table): safeguard column visibility

---------

Co-authored-by: Slinetrac <realakayuki@gmail.com>
This commit is contained in:
XiaoBuHaly
2025-11-09 00:15:01 +08:00
committed by GitHub
parent c8aa72186e
commit af4463fef1
18 changed files with 600 additions and 37 deletions

View File

@@ -0,0 +1,191 @@
import {
closestCenter,
DndContext,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import { arrayMove, SortableContext, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { DragIndicatorRounded } from "@mui/icons-material";
import {
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
List,
ListItem,
ListItemText,
} from "@mui/material";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
interface ColumnOption {
field: string;
label: string;
visible: boolean;
}
interface Props {
open: boolean;
columns: ColumnOption[];
onClose: () => void;
onToggle: (field: string, visible: boolean) => void;
onOrderChange: (order: string[]) => void;
onReset: () => void;
}
export const ConnectionColumnManager = ({
open,
columns,
onClose,
onToggle,
onOrderChange,
onReset,
}: Props) => {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 6 },
}),
);
const { t } = useTranslation();
const items = useMemo(() => columns.map((column) => column.field), [columns]);
const visibleCount = useMemo(
() => columns.filter((column) => column.visible).length,
[columns],
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const order = columns.map((column) => column.field);
const oldIndex = order.indexOf(active.id as string);
const newIndex = order.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
onOrderChange(arrayMove(order, oldIndex, newIndex));
},
[columns, onOrderChange],
);
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>
{t("connections.components.columnManager.title")}
</DialogTitle>
<DialogContent sx={{ pt: 1 }}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={items}>
<List
dense
disablePadding
sx={{ display: "flex", flexDirection: "column", gap: 1 }}
>
{columns.map((column) => (
<SortableColumnItem
key={column.field}
column={column}
onToggle={onToggle}
dragHandleLabel={t(
"connections.components.columnManager.dragHandle",
)}
disableToggle={column.visible && visibleCount <= 1}
/>
))}
</List>
</SortableContext>
</DndContext>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button variant="text" onClick={onReset}>
{t("shared.actions.resetToDefault")}
</Button>
<Button variant="contained" onClick={onClose}>
{t("shared.actions.close")}
</Button>
</DialogActions>
</Dialog>
);
};
interface SortableColumnItemProps {
column: ColumnOption;
onToggle: (field: string, visible: boolean) => void;
dragHandleLabel: string;
disableToggle?: boolean;
}
const SortableColumnItem = ({
column,
onToggle,
dragHandleLabel,
disableToggle = false,
}: SortableColumnItemProps) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: column.field });
const style = useMemo(
() => ({
transform: CSS.Transform.toString(transform),
transition,
}),
[transform, transition],
);
return (
<ListItem
ref={setNodeRef}
disableGutters
sx={{
px: 1,
py: 0.5,
borderRadius: 1,
border: (theme) => `1px solid ${theme.palette.divider}`,
backgroundColor: isDragging ? "action.hover" : "transparent",
display: "flex",
alignItems: "center",
gap: 1,
}}
style={style}
>
<Checkbox
edge="start"
checked={column.visible}
disabled={disableToggle}
onChange={(event) => onToggle(column.field, event.target.checked)}
/>
<ListItemText
primary={column.label}
slotProps={{ primary: { variant: "body2" } }}
sx={{ mr: 1 }}
/>
<IconButton
edge="end"
size="small"
sx={{ cursor: isDragging ? "grabbing" : "grab" }}
aria-label={dragHandleLabel}
{...attributes}
{...listeners}
>
<DragIndicatorRounded fontSize="small" />
</IconButton>
</ListItem>
);
};

View File

@@ -1,24 +1,63 @@
import { Box } from "@mui/material";
import {
DataGrid,
GridColDef,
GridColumnOrderChangeParams,
GridColumnResizeParams,
GridColumnVisibilityModel,
useGridApiRef,
GridColumnMenuItemProps,
GridColumnMenuHideItem,
useGridRootProps,
} from "@mui/x-data-grid";
import dayjs from "dayjs";
import { useLocalStorage } from "foxact/use-local-storage";
import { useLayoutEffect, useMemo, useState } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
createContext,
use,
} from "react";
import type { MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import parseTraffic from "@/utils/parse-traffic";
import { truncateStr } from "@/utils/truncate-str";
import { ConnectionColumnManager } from "./connection-column-manager";
const ColumnManagerContext = createContext<() => void>(() => {});
/**
* Reconcile stored column order with base columns to handle added/removed fields
*/
const reconcileColumnOrder = (
storedOrder: string[],
baseFields: string[],
): string[] => {
const filtered = storedOrder.filter((field) => baseFields.includes(field));
const missing = baseFields.filter((field) => !filtered.includes(field));
return [...filtered, ...missing];
};
interface Props {
connections: IConnectionsItem[];
onShowDetail: (data: IConnectionsItem) => void;
columnManagerOpen: boolean;
onOpenColumnManager: () => void;
onCloseColumnManager: () => void;
}
export const ConnectionTable = (props: Props) => {
const { connections, onShowDetail } = props;
const {
connections,
onShowDetail,
columnManagerOpen,
onOpenColumnManager,
onCloseColumnManager,
} = props;
const { t } = useTranslation();
const apiRef = useGridApiRef();
useLayoutEffect(() => {
@@ -145,10 +184,6 @@ export const ConnectionTable = (props: Props) => {
};
}, [apiRef]);
const [columnVisible, setColumnVisible] = useState<
Partial<Record<keyof IConnectionsItem, boolean>>
>({});
const [columnWidths, setColumnWidths] = useLocalStorage<
Record<string, number>
>(
@@ -158,7 +193,43 @@ export const ConnectionTable = (props: Props) => {
{},
);
const columns = useMemo<GridColDef[]>(() => {
const [columnVisibilityModel, setColumnVisibilityModel] = useLocalStorage<
Partial<Record<string, boolean>>
>(
"connection-table-visibility",
{},
{
serializer: JSON.stringify,
deserializer: (value) => {
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === "object") return parsed;
} catch (err) {
console.warn("Failed to parse connection-table-visibility", err);
}
return {};
},
},
);
const [columnOrder, setColumnOrder] = useLocalStorage<string[]>(
"connection-table-order",
[],
{
serializer: JSON.stringify,
deserializer: (value) => {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) return parsed;
} catch (err) {
console.warn("Failed to parse connection-table-order", err);
}
return [];
},
},
);
const baseColumns = useMemo<GridColDef[]>(() => {
return [
{
field: "host",
@@ -248,6 +319,49 @@ export const ConnectionTable = (props: Props) => {
];
}, [columnWidths, t]);
useEffect(() => {
setColumnOrder((prevValue) => {
const baseFields = baseColumns.map((col) => col.field);
const prev = Array.isArray(prevValue) ? prevValue : [];
const reconciled = reconcileColumnOrder(prev, baseFields);
if (
reconciled.length === prev.length &&
reconciled.every((field, i) => field === prev[i])
) {
return prevValue;
}
return reconciled;
});
}, [baseColumns, setColumnOrder]);
const columns = useMemo<GridColDef[]>(() => {
const order = Array.isArray(columnOrder) ? columnOrder : [];
const orderMap = new Map(order.map((field, index) => [field, index]));
return [...baseColumns].sort((a, b) => {
const aIndex = orderMap.has(a.field)
? (orderMap.get(a.field) as number)
: Number.MAX_SAFE_INTEGER;
const bIndex = orderMap.has(b.field)
? (orderMap.get(b.field) as number)
: Number.MAX_SAFE_INTEGER;
if (aIndex === bIndex) {
return order.indexOf(a.field) - order.indexOf(b.field);
}
return aIndex - bIndex;
});
}, [baseColumns, columnOrder]);
const visibleColumnsCount = useMemo(() => {
return columns.reduce((count, column) => {
return (columnVisibilityModel?.[column.field] ?? true) !== false
? count + 1
: count;
}, 0);
}, [columns, columnVisibilityModel]);
const handleColumnResize = (params: GridColumnResizeParams) => {
const { colDef, width } = params;
setColumnWidths((prev) => ({
@@ -256,6 +370,111 @@ export const ConnectionTable = (props: Props) => {
}));
};
const handleColumnVisibilityChange = useCallback(
(model: GridColumnVisibilityModel) => {
const hiddenFields = new Set<string>();
Object.entries(model).forEach(([field, value]) => {
if (value === false) {
hiddenFields.add(field);
}
});
const nextVisibleCount = columns.reduce((count, column) => {
return hiddenFields.has(column.field) ? count : count + 1;
}, 0);
if (nextVisibleCount === 0) {
return;
}
setColumnVisibilityModel(() => {
const sanitized: Partial<Record<string, boolean>> = {};
hiddenFields.forEach((field) => {
sanitized[field] = false;
});
return sanitized;
});
},
[columns, setColumnVisibilityModel],
);
const handleToggleColumn = useCallback(
(field: string, visible: boolean) => {
if (!visible && visibleColumnsCount <= 1) {
return;
}
setColumnVisibilityModel((prev) => {
const next = { ...(prev ?? {}) };
if (visible) {
delete next[field];
} else {
next[field] = false;
}
return next;
});
},
[setColumnVisibilityModel, visibleColumnsCount],
);
const handleColumnOrderChange = useCallback(
(params: GridColumnOrderChangeParams) => {
setColumnOrder((prevValue) => {
const baseFields = baseColumns.map((col) => col.field);
const currentOrder = Array.isArray(prevValue)
? [...prevValue]
: [...baseFields];
const field = params.column.field;
const currentIndex = currentOrder.indexOf(field);
if (currentIndex === -1) return currentOrder;
currentOrder.splice(currentIndex, 1);
const targetIndex = Math.min(
Math.max(params.targetIndex, 0),
currentOrder.length,
);
currentOrder.splice(targetIndex, 0, field);
return currentOrder;
});
},
[baseColumns, setColumnOrder],
);
const handleManagerOrderChange = useCallback(
(order: string[]) => {
setColumnOrder(() => {
const baseFields = baseColumns.map((col) => col.field);
return reconcileColumnOrder(order, baseFields);
});
},
[baseColumns, setColumnOrder],
);
const handleResetColumns = useCallback(() => {
setColumnVisibilityModel({});
setColumnOrder(baseColumns.map((col) => col.field));
}, [baseColumns, setColumnOrder, setColumnVisibilityModel]);
const gridVisibilityModel = useMemo(() => {
const result: GridColumnVisibilityModel = {};
if (!columnVisibilityModel) return result;
Object.entries(columnVisibilityModel).forEach(([field, value]) => {
if (typeof value === "boolean") {
result[field] = value;
}
});
return result;
}, [columnVisibilityModel]);
const columnOptions = useMemo(() => {
return columns.map((column) => ({
field: column.field,
label: column.headerName ?? column.field,
visible: (columnVisibilityModel?.[column.field] ?? true) !== false,
}));
}, [columns, columnVisibilityModel]);
const connRows = useMemo(() => {
return connections.map((each) => {
const { metadata, rulePayload } = each;
@@ -286,6 +505,15 @@ export const ConnectionTable = (props: Props) => {
}, [connections]);
return (
<ColumnManagerContext value={onOpenColumnManager}>
<Box
sx={{
display: "flex",
flexDirection: "column",
flex: 1,
minHeight: 0,
}}
>
<DataGrid
apiRef={apiRef}
hideFooter
@@ -294,16 +522,81 @@ export const ConnectionTable = (props: Props) => {
onRowClick={(e) => onShowDetail(e.row.connectionData)}
density="compact"
sx={{
flex: 1,
border: "none",
minHeight: 0,
"div:focus": { outline: "none !important" },
"& .MuiDataGrid-columnHeader": {
userSelect: "none",
},
}}
columnVisibilityModel={columnVisible}
onColumnVisibilityModelChange={(e) => setColumnVisible(e)}
columnVisibilityModel={gridVisibilityModel}
onColumnVisibilityModelChange={handleColumnVisibilityChange}
onColumnResize={handleColumnResize}
disableColumnMenu={false}
onColumnOrderChange={handleColumnOrderChange}
slotProps={{
columnMenu: {
slots: {
columnMenuColumnsItem: ConnectionColumnMenuColumnsItem,
},
},
}}
/>
</Box>
<ConnectionColumnManager
open={columnManagerOpen}
columns={columnOptions}
onClose={onCloseColumnManager}
onToggle={handleToggleColumn}
onOrderChange={handleManagerOrderChange}
onReset={handleResetColumns}
/>
</ColumnManagerContext>
);
};
type ConnectionColumnMenuManageItemProps = GridColumnMenuItemProps & {
onOpenColumnManager: () => void;
};
const ConnectionColumnMenuManageItem = (
props: ConnectionColumnMenuManageItemProps,
) => {
const { onClick, onOpenColumnManager } = props;
const rootProps = useGridRootProps();
const { t } = useTranslation();
const handleClick = useCallback(
(event: MouseEvent<HTMLElement>) => {
onClick(event);
onOpenColumnManager();
},
[onClick, onOpenColumnManager],
);
if (rootProps.disableColumnSelector) {
return null;
}
const MenuItem = rootProps.slots.baseMenuItem;
const Icon = rootProps.slots.columnMenuManageColumnsIcon;
return (
<MenuItem onClick={handleClick} iconStart={<Icon fontSize="small" />}>
{t("connections.components.columnManager.title")}
</MenuItem>
);
};
const ConnectionColumnMenuColumnsItem = (props: GridColumnMenuItemProps) => {
const onOpenColumnManager = use(ColumnManagerContext);
return (
<>
<GridColumnMenuHideItem {...props} />
<ConnectionColumnMenuManageItem
{...props}
onOpenColumnManager={onOpenColumnManager}
/>
</>
);
};

View File

@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "إغلاق الاتصال"
},
"columnManager": {
"title": "الأعمدة",
"dragHandle": "Drag handle"
}
}
}

View File

@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "Verbindung schließen"
},
"columnManager": {
"title": "Spalten",
"dragHandle": "Drag handle"
}
}
}

View File

@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "Close Connection"
},
"columnManager": {
"title": "Columns",
"dragHandle": "Drag handle"
}
}
}

View File

@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "Cerrar conexión"
},
"columnManager": {
"title": "Columnas",
"dragHandle": "Drag handle"
}
}
}

View File

@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "بستن اتصال"
},
"columnManager": {
"title": "ستون‌ها",
"dragHandle": "Drag handle"
}
}
}

View File

@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "Tutup Koneksi"
},
"columnManager": {
"title": "Kolom",
"dragHandle": "Drag handle"
}
}
}

View File

@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "接続を閉じる"
},
"columnManager": {
"title": "列",
"dragHandle": "Drag handle"
}
}
}

View File

@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "연결 닫기"
},
"columnManager": {
"title": "열",
"dragHandle": "Drag handle"
}
}
}

View File

@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "Закрыть соединение"
},
"columnManager": {
"title": "Столбцы",
"dragHandle": "Drag handle"
}
}
}

View File

@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "Bağlantıyı Kapat"
},
"columnManager": {
"title": "Sütunlar",
"dragHandle": "Drag handle"
}
}
}

View File

@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "Тоташуны ябу"
},
"columnManager": {
"title": "Баганалар",
"dragHandle": "Drag handle"
}
}
}

View File

@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "关闭连接"
},
"columnManager": {
"title": "列设置",
"dragHandle": "拖拽控件"
}
}
}

View File

@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "關閉連線"
},
"columnManager": {
"title": "欄位設定",
"dragHandle": "Drag handle"
}
}
}

View File

@@ -86,6 +86,7 @@ const ConnectionsPage = () => {
const [isPaused, setIsPaused] = useState(false);
const [frozenData, setFrozenData] = useState<IConnections | null>(null);
const [isColumnManagerOpen, setIsColumnManagerOpen] = useState(false);
// 使用全局连接数据
const displayData = useMemo(() => {
@@ -110,14 +111,17 @@ const ConnectionsPage = () => {
const [filterConn] = useMemo(() => {
const orderFunc = orderFunctionMap[curOrderOpt];
let conns = displayData.connections?.filter((conn) => {
let conns: IConnectionsItem[] = (displayData.connections ?? []).filter(
(conn) => {
const { host, destinationIP, process } = conn.metadata;
return (
match(host || "") || match(destinationIP || "") || match(process || "")
match(host || "") ||
match(destinationIP || "") ||
match(process || "")
);
});
if (orderFunc) conns = orderFunc(conns ?? []);
},
);
if (orderFunc) conns = orderFunc(conns);
return [conns];
}, [displayData, match, curOrderOpt]);
@@ -145,6 +149,8 @@ const ConnectionsPage = () => {
});
}, [connections]);
const hasTableData = filterConn.length > 0;
return (
<BasePage
full
@@ -214,9 +220,10 @@ const ConnectionsPage = () => {
pt: 1,
mb: 0.5,
mx: "10px",
height: "36px",
minHeight: "36px",
display: "flex",
alignItems: "center",
gap: 1,
userSelect: "text",
position: "sticky",
top: 0,
@@ -235,15 +242,29 @@ const ConnectionsPage = () => {
))}
</BaseStyledSelect>
)}
<Box
sx={{
flex: 1,
display: "flex",
alignItems: "center",
"& > *": {
flex: 1,
},
}}
>
<BaseSearchBox onSearch={handleSearch} />
</Box>
</Box>
{!filterConn || filterConn.length === 0 ? (
{!hasTableData ? (
<BaseEmpty />
) : isTableLayout ? (
<ConnectionTable
connections={filterConn}
onShowDetail={(detail) => detailRef.current?.open(detail)}
columnManagerOpen={isTableLayout && isColumnManagerOpen}
onOpenColumnManager={() => setIsColumnManagerOpen(true)}
onCloseColumnManager={() => setIsColumnManagerOpen(false)}
/>
) : (
<Virtuoso

View File

@@ -18,6 +18,8 @@ export const translationKeys = [
"connections.components.order.uploadSpeed",
"connections.components.order.downloadSpeed",
"connections.components.actions.closeConnection",
"connections.components.columnManager.title",
"connections.components.columnManager.dragHandle",
"home.page.tooltips.lightweightMode",
"home.page.tooltips.manual",
"home.page.tooltips.settings",

View File

@@ -8,6 +8,10 @@ export interface TranslationResources {
actions: {
closeConnection: string;
};
columnManager: {
dragHandle: string;
title: string;
};
fields: {
chains: string;
destination: string;