mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-01-29 00:35:38 +08:00
* 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:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user