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

@@ -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,24 +505,98 @@ export const ConnectionTable = (props: Props) => {
}, [connections]);
return (
<DataGrid
apiRef={apiRef}
hideFooter
rows={connRows}
columns={columns}
onRowClick={(e) => onShowDetail(e.row.connectionData)}
density="compact"
sx={{
border: "none",
"div:focus": { outline: "none !important" },
"& .MuiDataGrid-columnHeader": {
userSelect: "none",
},
}}
columnVisibilityModel={columnVisible}
onColumnVisibilityModelChange={(e) => setColumnVisible(e)}
onColumnResize={handleColumnResize}
disableColumnMenu={false}
/>
<ColumnManagerContext value={onOpenColumnManager}>
<Box
sx={{
display: "flex",
flexDirection: "column",
flex: 1,
minHeight: 0,
}}
>
<DataGrid
apiRef={apiRef}
hideFooter
rows={connRows}
columns={columns}
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={gridVisibilityModel}
onColumnVisibilityModelChange={handleColumnVisibilityChange}
onColumnResize={handleColumnResize}
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}
/>
</>
);
};