Browse Source

feat: add column visibility management to nodes table

- Add generic column management system with TypeScript support
- Implement ColumnVisibilityControl component with dropdown interface
- Add persistent column visibility settings via Zustand store
- Extend nodes table with additional columns (role, battery, uptime, etc.)
- Refactor table cell rendering to use helper functions
- Add comprehensive documentation and examples
- Support internationalization for column settings

Features:
- Toggle visibility of table columns individually
- Reset columns to default configuration
- Persistent settings across sessions
- Generic system reusable for other tables
- Proper TypeScript types throughout
- Existing UI component patterns maintained
pull/751/head
Truongsinh Tran-Nguyen 11 months ago
parent
commit
0a9e52b74d
  1. 6
      packages/web/public/i18n/locales/en/nodes.json
  2. 30
      packages/web/src/components/generic/ColumnVisibilityControl/ColumnVisibilityControl.tsx
  3. 94
      packages/web/src/components/generic/ColumnVisibilityControl/GenericColumnVisibilityControl.tsx
  4. 183
      packages/web/src/components/generic/ColumnVisibilityControl/README.md
  5. 2
      packages/web/src/components/generic/ColumnVisibilityControl/index.tsx
  6. 252
      packages/web/src/components/generic/Table/NodeCellHelpers.tsx
  7. 4
      packages/web/src/components/generic/Table/index.tsx
  8. 92
      packages/web/src/core/hooks/useColumnManager.ts
  9. 377
      packages/web/src/core/stores/appStore.ts
  10. 96
      packages/web/src/core/stores/createTableColumnStore.ts
  11. 139
      packages/web/src/pages/Nodes/index.tsx

6
packages/web/public/i18n/locales/en/nodes.json

@ -53,7 +53,11 @@
"never": "Never"
}
},
"columnSettings": {
"title": "Column Settings",
"description": "Choose which columns to display",
"reset": "Reset to Default"
},
"actions": {
"added": "Added",
"removed": "Removed",

30
packages/web/src/components/generic/ColumnVisibilityControl/ColumnVisibilityControl.tsx

@ -0,0 +1,30 @@
import { useColumnManager } from "@core/hooks/useColumnManager.ts";
import { useAppStore } from "@core/stores/appStore.ts";
import { useTranslation } from "react-i18next";
import { GenericColumnVisibilityControl } from "./GenericColumnVisibilityControl.tsx";
export const ColumnVisibilityControl = () => {
const { t } = useTranslation("nodes");
const { nodesTableColumns, updateColumnVisibility, resetColumnsToDefault } =
useAppStore();
const columnManager = useColumnManager({
columns: nodesTableColumns,
onUpdateColumn: (columnId, updates) => {
if ("visible" in updates && updates.visible !== undefined) {
updateColumnVisibility(columnId, updates.visible);
}
},
onResetColumns: resetColumnsToDefault,
});
return (
<GenericColumnVisibilityControl
columnManager={columnManager}
title={t("columnSettings.title", "Column Settings")}
resetLabel={t("columnSettings.reset", "Reset to Default")}
translateColumnTitle={(title) => (title.includes(".") ? t(title) : title)}
isColumnDisabled={(column) => column.id === "avatar"}
/>
);
};

94
packages/web/src/components/generic/ColumnVisibilityControl/GenericColumnVisibilityControl.tsx

@ -0,0 +1,94 @@
import { Button } from "@components/UI/Button.tsx";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@components/UI/DropdownMenu.tsx";
import type {
TableColumn,
UseColumnManagerReturn,
} from "@core/hooks/useColumnManager.ts";
import { SettingsIcon } from "lucide-react";
import type { ReactNode } from "react";
export interface GenericColumnVisibilityControlProps<
T extends TableColumn = TableColumn,
> {
columnManager: UseColumnManagerReturn<T>;
title?: string;
resetLabel?: string;
trigger?: ReactNode;
className?: string;
translateColumnTitle?: (title: string) => string;
isColumnDisabled?: (column: T) => boolean;
}
export function GenericColumnVisibilityControl<
T extends TableColumn = TableColumn,
>({
columnManager,
title = "Column Settings",
resetLabel = "Reset to Default",
trigger,
className,
translateColumnTitle = (title) => title,
isColumnDisabled = () => false,
}: GenericColumnVisibilityControlProps<T>) {
const {
allColumns,
visibleCount,
totalCount,
updateColumnVisibility,
resetColumns,
} = columnManager;
const defaultTrigger = (
<Button
variant="outline"
size="sm"
className="ml-2 h-8 px-2 text-xs"
title={title}
>
<SettingsIcon size={14} />
<span className="ml-1 hidden sm:inline">
Columns ({visibleCount}/{totalCount})
</span>
</Button>
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
{trigger || defaultTrigger}
</DropdownMenuTrigger>
<DropdownMenuContent className={`w-56 ${className || ""}`} align="end">
<DropdownMenuLabel>{title}</DropdownMenuLabel>
<DropdownMenuSeparator />
{allColumns.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
checked={column.visible}
onCheckedChange={(checked) =>
updateColumnVisibility(column.id, checked ?? false)
}
disabled={isColumnDisabled(column)}
>
{translateColumnTitle(column.title)}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={false}
onCheckedChange={() => resetColumns()}
>
{resetLabel}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

183
packages/web/src/components/generic/ColumnVisibilityControl/README.md

@ -0,0 +1,183 @@
# Generic Table Column Management System
This system provides reusable components and hooks for managing table column visibility and state across the application.
## Components
### 1. `useColumnManager` Hook
A generic hook for managing table column state.
```tsx
import { useColumnManager } from "@core/hooks/useColumnManager.ts";
const columnManager = useColumnManager({
columns: myColumns,
onUpdateColumn: (columnId, updates) => {
// Handle column updates
},
onResetColumns: () => {
// Reset to default columns
},
});
// Access column data
const { visibleColumns, hiddenColumns, visibleCount } = columnManager;
// Update column visibility
columnManager.updateColumnVisibility("columnId", true);
```
### 2. `GenericColumnVisibilityControl` Component
A reusable dropdown component for managing column visibility.
```tsx
import { GenericColumnVisibilityControl } from "@components/generic/ColumnVisibilityControl";
<GenericColumnVisibilityControl
columnManager={columnManager}
title="Column Settings"
resetLabel="Reset to Default"
translateColumnTitle={(title) => t(title)}
isColumnDisabled={(column) => column.id === "required"}
/>
```
### 3. `createTableColumnStore` Factory
A factory function for creating column management state within Zustand stores.
```tsx
import { createTableColumnStore } from "@core/stores/createTableColumnStore";
// Define your column type
interface MyTableColumn extends TableColumn {
customProperty?: string;
}
// Create the column store
const myTableColumnStore = createTableColumnStore({
defaultColumns: myDefaultColumns,
storeName: "myTable",
});
// Use in your Zustand store
const useMyStore = create<MyState>()(
persist(
(set, get) => {
const setWrapper = (fn: (state: any) => void) => {
set(produce<MyState>(fn));
};
const columnActions = myTableColumnStore.createActions(setWrapper, get);
return {
...myTableColumnStore.initialState,
...columnActions,
// ... other state and actions
};
}
)
);
```
## Usage Examples
### Example 1: Simple Table with Column Management
```tsx
import { useColumnManager } from "@core/hooks/useColumnManager.ts";
import { GenericColumnVisibilityControl } from "@components/generic/ColumnVisibilityControl";
const MyTableComponent = () => {
const [columns, setColumns] = useState(defaultColumns);
const columnManager = useColumnManager({
columns,
onUpdateColumn: (columnId, updates) => {
setColumns(prev => prev.map(col =>
col.id === columnId ? { ...col, ...updates } : col
));
},
onResetColumns: () => setColumns(defaultColumns),
});
return (
<div>
<div className="flex justify-end mb-4">
<GenericColumnVisibilityControl
columnManager={columnManager}
title="Manage Columns"
/>
</div>
<Table
headings={columnManager.visibleColumns.map(col => ({
title: col.title,
sortable: col.sortable
}))}
rows={data.map(row => ({
id: row.id,
cells: columnManager.visibleColumns.map(col =>
getCellData(row, col.key)
)
}))}
/>
</div>
);
};
```
### Example 2: Custom Column Types
```tsx
interface CustomTableColumn extends TableColumn {
width?: number;
align?: 'left' | 'center' | 'right';
format?: 'currency' | 'date' | 'text';
}
const customColumnStore = createTableColumnStore<CustomTableColumn>({
defaultColumns: [
{
id: "name",
key: "name",
title: "Name",
visible: true,
sortable: true,
width: 200,
align: 'left'
},
{
id: "amount",
key: "amount",
title: "Amount",
visible: true,
sortable: true,
width: 120,
align: 'right',
format: 'currency'
},
],
});
```
## Benefits
1. **Reusable**: Can be used across multiple tables in the application
2. **Type-safe**: Full TypeScript support with generic types
3. **Flexible**: Supports custom column properties and behaviors
4. **Consistent**: Uses existing UI components and patterns
5. **Persistent**: Column preferences can be saved to localStorage
6. **Accessible**: Proper ARIA labels and keyboard navigation
## Migration from Existing Systems
If you have existing column management code, you can migrate it step by step:
1. Replace custom column state with `useColumnManager`
2. Replace custom UI components with `GenericColumnVisibilityControl`
3. Optionally refactor stores to use `createTableColumnStore`
The existing `ColumnVisibilityControl` component has been updated to use this new system while maintaining backward compatibility.

2
packages/web/src/components/generic/ColumnVisibilityControl/index.tsx

@ -0,0 +1,2 @@
export { ColumnVisibilityControl } from "./ColumnVisibilityControl.tsx";
export { GenericColumnVisibilityControl } from "./GenericColumnVisibilityControl.tsx";

252
packages/web/src/components/generic/Table/NodeCellHelpers.tsx

@ -0,0 +1,252 @@
import { Mono } from "@components/generic/Mono.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { Avatar } from "@components/UI/Avatar.tsx";
import { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { LockIcon, LockOpenIcon } from "lucide-react";
import type { JSX } from "react";
import { base16 } from "rfc4648";
// Helper function to format position
const formatPosition = (position?: Protobuf.Mesh.Position) => {
if (!position || (!position.latitudeI && !position.longitudeI)) {
return "Unknown";
}
const lat = position.latitudeI ? (position.latitudeI * 1e-7).toFixed(6) : "0";
const lng = position.longitudeI
? (position.longitudeI * 1e-7).toFixed(6)
: "0";
return `${lat}, ${lng}`;
};
// Helper function to format battery level
const formatBatteryLevel = (
deviceMetrics?: Protobuf.Telemetry.DeviceMetrics,
) => {
if (!deviceMetrics?.batteryLevel) {
return "Unknown";
}
return `${deviceMetrics.batteryLevel}%`;
};
// Helper function to format uptime
const formatUptime = (deviceMetrics?: Protobuf.Telemetry.DeviceMetrics) => {
if (!deviceMetrics?.uptimeSeconds) {
return "Unknown";
}
const seconds = deviceMetrics.uptimeSeconds;
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return `${days}d ${hours}h`;
}
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
};
export const getNodeCellData = (
node: Protobuf.Mesh.NodeInfo,
columnKey: string,
t: (key: string) => string,
currentLanguage?: { code: string },
hasNodeError?: (num: number) => boolean,
handleNodeInfoDialog?: (num: number) => void,
): { content: JSX.Element; sortValue: string | number } => {
switch (columnKey) {
case "avatar":
return {
content: (
<Avatar
text={node.user?.shortName ?? t("unknown.shortName")}
showFavorite={node.isFavorite}
showError={hasNodeError?.(node.num) ?? false}
/>
),
sortValue: node.user?.shortName ?? "",
};
case "longName":
return {
content: (
<h1
onMouseDown={() => handleNodeInfoDialog?.(node.num)}
onKeyUp={(evt) => {
evt.key === "Enter" && handleNodeInfoDialog?.(node.num);
}}
className="cursor-pointer underline ml-2 whitespace-break-spaces"
>
{node.user?.longName ?? numberToHexUnpadded(node.num)}
</h1>
),
sortValue: node.user?.longName ?? numberToHexUnpadded(node.num),
};
case "shortName":
return {
content: <Mono>{node.user?.shortName ?? t("unknown.shortName")}</Mono>,
sortValue: node.user?.shortName ?? "",
};
case "nodeId":
return {
content: <Mono>{numberToHexUnpadded(node.num)}</Mono>,
sortValue: node.num,
};
case "connection": {
const connectionText =
node.hopsAway !== undefined
? node?.viaMqtt === false && node.hopsAway === 0
? t("nodesTable.connectionStatus.direct")
: `${node.hopsAway?.toString()} ${
(node.hopsAway ?? 0 > 1)
? t("unit.hop.plural")
: t("unit.hops_one")
} ${t("nodesTable.connectionStatus.away")}`
: t("nodesTable.connectionStatus.unknown");
const mqttText =
node?.viaMqtt === true ? t("nodesTable.connectionStatus.viaMqtt") : "";
return {
content: (
<Mono className="w-16">
{connectionText}
{mqttText}
</Mono>
),
sortValue: node.hopsAway ?? Number.MAX_SAFE_INTEGER,
};
}
case "lastHeard":
return {
content: (
<Mono>
{node.lastHeard === 0 ? (
<p>{t("nodesTable.lastHeardStatus.never")}</p>
) : (
<TimeAgo
timestamp={node.lastHeard * 1000}
locale={currentLanguage?.code}
/>
)}
</Mono>
),
sortValue: node.lastHeard,
};
case "encryption":
return {
content: (
<Mono>
{node.user?.publicKey && node.user?.publicKey.length > 0 ? (
<LockIcon className="text-green-600 mx-auto" />
) : (
<LockOpenIcon className="text-yellow-300 mx-auto" />
)}
</Mono>
),
sortValue: "",
};
case "snr":
return {
content: (
<Mono>
{node.snr}
{t("unit.dbm")}/{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}
%/{(node.snr + 10) * 5}
{t("unit.raw")}
</Mono>
),
sortValue: node.snr,
};
case "model": {
const modelName =
Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ?? "Unknown";
return {
content: <Mono>{modelName}</Mono>,
sortValue: modelName,
};
}
case "macAddress": {
const macAddress =
base16
.stringify(node.user?.macaddr ?? [])
.match(/.{1,2}/g)
?.join(":") ?? t("unknown.shortName");
return {
content: <Mono>{macAddress}</Mono>,
sortValue: macAddress,
};
}
case "role": {
const roleName =
Protobuf.Config.Config_DeviceConfig_Role[node.user?.role ?? 0] ??
"Unknown";
return {
content: <Mono>{roleName.replace(/_/g, " ")}</Mono>,
sortValue: roleName,
};
}
case "batteryLevel":
return {
content: <Mono>{formatBatteryLevel(node.deviceMetrics)}</Mono>,
sortValue: node.deviceMetrics?.batteryLevel ?? 0,
};
case "channelUtilization":
return {
content: (
<Mono>
{node.deviceMetrics?.channelUtilization
? `${node.deviceMetrics.channelUtilization.toFixed(1)}%`
: "Unknown"}
</Mono>
),
sortValue: node.deviceMetrics?.channelUtilization ?? 0,
};
case "airtimeUtilization":
return {
content: (
<Mono>
{node.deviceMetrics?.airUtilTx
? `${node.deviceMetrics.airUtilTx.toFixed(1)}%`
: "Unknown"}
</Mono>
),
sortValue: node.deviceMetrics?.airUtilTx ?? 0,
};
case "uptime":
return {
content: <Mono>{formatUptime(node.deviceMetrics)}</Mono>,
sortValue: node.deviceMetrics?.uptimeSeconds ?? 0,
};
case "position":
return {
content: (
<Mono className="text-xs">{formatPosition(node.position)}</Mono>
),
sortValue: formatPosition(node.position),
};
default:
return {
content: <Mono>Unknown</Mono>,
sortValue: "",
};
}
};

4
packages/web/src/components/generic/Table/index.tsx

@ -65,6 +65,10 @@ export const Table = ({ headings, rows }: TableProps) => {
const aCell = a.cells[columnIndex];
const bCell = b.cells[columnIndex];
if (!aCell || !bCell) {
return 0;
}
let aValue: string | number;
let bValue: string | number;

92
packages/web/src/core/hooks/useColumnManager.ts

@ -0,0 +1,92 @@
import { useCallback, useMemo } from "react";
export interface TableColumn {
id: string;
key: string;
title: string;
visible: boolean;
sortable: boolean;
}
export interface ColumnManagerConfig<T extends TableColumn = TableColumn> {
columns: T[];
onUpdateColumn: (columnId: string, updates: Partial<T>) => void;
onResetColumns: () => void;
}
export interface UseColumnManagerReturn<T extends TableColumn = TableColumn> {
allColumns: T[];
visibleColumns: T[];
hiddenColumns: T[];
visibleCount: number;
totalCount: number;
updateColumnVisibility: (columnId: string, visible: boolean) => void;
toggleColumnVisibility: (columnId: string) => void;
resetColumns: () => void;
isColumnVisible: (columnId: string) => boolean;
getColumn: (columnId: string) => T | undefined;
}
/**
* Generic hook for managing table column visibility and state
* Can be used with any table that needs column management
*/
export function useColumnManager<T extends TableColumn = TableColumn>({
columns,
onUpdateColumn,
onResetColumns,
}: ColumnManagerConfig<T>): UseColumnManagerReturn<T> {
const visibleColumns = useMemo(
() => columns.filter((col) => col.visible),
[columns],
);
const hiddenColumns = useMemo(
() => columns.filter((col) => !col.visible),
[columns],
);
const updateColumnVisibility = useCallback(
(columnId: string, visible: boolean) => {
onUpdateColumn(columnId, { visible } as Partial<T>);
},
[onUpdateColumn],
);
const toggleColumnVisibility = useCallback(
(columnId: string) => {
const column = columns.find((col) => col.id === columnId);
if (column) {
updateColumnVisibility(columnId, !column.visible);
}
},
[columns, updateColumnVisibility],
);
const isColumnVisible = useCallback(
(columnId: string) => {
return columns.find((col) => col.id === columnId)?.visible ?? false;
},
[columns],
);
const getColumn = useCallback(
(columnId: string) => {
return columns.find((col) => col.id === columnId);
},
[columns],
);
return {
allColumns: columns,
visibleColumns,
hiddenColumns,
visibleCount: visibleColumns.length,
totalCount: columns.length,
updateColumnVisibility,
toggleColumnVisibility,
resetColumns: onResetColumns,
isColumnVisible,
getColumn,
};
}

377
packages/web/src/core/stores/appStore.ts

@ -1,5 +1,8 @@
import type { TableColumn } from "@core/hooks/useColumnManager.ts";
import { produce } from "immer";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { createTableColumnStore } from "./createTableColumnStore.ts";
export interface RasterSource {
enabled: boolean;
@ -13,6 +16,118 @@ interface ErrorState {
message: string;
}
export interface NodesTableColumn extends TableColumn {
id: string;
key: string;
title: string;
visible: boolean;
sortable: boolean;
}
const defaultNodesTableColumns: NodesTableColumn[] = [
{ id: "avatar", key: "avatar", title: "", visible: true, sortable: false },
{
id: "longName",
key: "longName",
title: "nodesTable.headings.longName",
visible: true,
sortable: true,
},
{
id: "connection",
key: "connection",
title: "nodesTable.headings.connection",
visible: true,
sortable: true,
},
{
id: "lastHeard",
key: "lastHeard",
title: "nodesTable.headings.lastHeard",
visible: true,
sortable: true,
},
{
id: "encryption",
key: "encryption",
title: "nodesTable.headings.encryption",
visible: true,
sortable: false,
},
{ id: "snr", key: "snr", title: "unit.snr", visible: true, sortable: true },
{
id: "model",
key: "model",
title: "nodesTable.headings.model",
visible: true,
sortable: true,
},
{
id: "macAddress",
key: "macAddress",
title: "nodesTable.headings.macAddress",
visible: true,
sortable: true,
},
// Additional columns we can add
{
id: "shortName",
key: "shortName",
title: "Short Name",
visible: false,
sortable: true,
},
{
id: "nodeId",
key: "nodeId",
title: "Node ID",
visible: false,
sortable: true,
},
{ id: "role", key: "role", title: "Role", visible: false, sortable: true },
{
id: "batteryLevel",
key: "batteryLevel",
title: "Battery Level",
visible: false,
sortable: true,
},
{
id: "channelUtilization",
key: "channelUtilization",
title: "Channel Utilization",
visible: false,
sortable: true,
},
{
id: "airtimeUtilization",
key: "airtimeUtilization",
title: "Airtime Utilization",
visible: false,
sortable: true,
},
{
id: "uptime",
key: "uptime",
title: "Uptime",
visible: false,
sortable: true,
},
{
id: "position",
key: "position",
title: "Position",
visible: false,
sortable: false,
},
];
// Create the nodes table column store
const nodesTableColumnStore = createTableColumnStore({
defaultColumns: defaultNodesTableColumns,
storeName: "nodesTable",
});
interface AppState {
selectedDevice: number;
devices: {
@ -26,6 +141,9 @@ interface AppState {
nodeNumDetails: number;
errors: ErrorState[];
// Nodes table column management
nodesTableColumns: NodesTableColumn[];
setRasterSources: (sources: RasterSource[]) => void;
addRasterSource: (source: RasterSource) => void;
removeRasterSource: (index: number) => void;
@ -37,6 +155,15 @@ interface AppState {
setConnectDialogOpen: (open: boolean) => void;
setNodeNumDetails: (nodeNum: number) => void;
// Nodes table column management actions
updateColumnVisibility: (columnId: string, visible: boolean) => void;
updateNodesTableColumn: (
columnId: string,
updates: Partial<NodesTableColumn>,
) => void;
resetColumnsToDefault: () => void;
setNodesTableColumns: (columns: NodesTableColumn[]) => void;
// Error management
hasErrors: () => boolean;
getErrorMessage: (field: string) => string | undefined;
@ -47,114 +174,146 @@ interface AppState {
setNewErrors: (newErrors: ErrorState[]) => void;
}
export const useAppStore = create<AppState>()((set, get) => ({
selectedDevice: 0,
devices: [],
currentPage: "messages",
rasterSources: [],
commandPaletteOpen: false,
connectDialogOpen: false,
nodeNumToBeRemoved: 0,
nodeNumDetails: 0,
errors: [],
setRasterSources: (sources: RasterSource[]) => {
set(
produce<AppState>((draft) => {
draft.rasterSources = sources;
}),
);
},
addRasterSource: (source: RasterSource) => {
set(
produce<AppState>((draft) => {
draft.rasterSources.push(source);
}),
);
},
removeRasterSource: (index: number) => {
set(
produce<AppState>((draft) => {
draft.rasterSources.splice(index, 1);
}),
);
},
setSelectedDevice: (deviceId) =>
set(() => ({
selectedDevice: deviceId,
})),
addDevice: (device) =>
set((state) => ({
devices: [...state.devices, device],
})),
removeDevice: (deviceId) =>
set((state) => ({
devices: state.devices.filter((device) => device.id !== deviceId),
})),
setCommandPaletteOpen: (open: boolean) => {
set(
produce<AppState>((draft) => {
draft.commandPaletteOpen = open;
}),
);
},
setNodeNumToBeRemoved: (nodeNum) =>
set(() => ({
nodeNumToBeRemoved: nodeNum,
})),
setConnectDialogOpen: (open: boolean) => {
set(
produce<AppState>((draft) => {
draft.connectDialogOpen = open;
}),
);
},
export const useAppStore = create<AppState>()(
persist(
(set, get) => {
// Create a wrapper for set that matches the expected signature
const setWrapper = (fn: (state: any) => void) => {
set(produce<AppState>(fn));
};
setNodeNumDetails: (nodeNum) =>
set(() => ({
nodeNumDetails: nodeNum,
})),
hasErrors: () => {
const state = get();
return state.errors.length > 0;
},
getErrorMessage: (field: string) => {
const state = get();
return state.errors.find((err) => err.field === field)?.message;
},
hasFieldError: (field: string) => {
const state = get();
return state.errors.some((err) => err.field === field);
},
addError: (field: string, message: string) => {
set(
produce<AppState>((draft) => {
draft.errors = [
...draft.errors.filter((e) => e.field !== field),
{ field, message },
];
}),
);
},
removeError: (field: string) => {
set(
produce<AppState>((draft) => {
draft.errors = draft.errors.filter((e) => e.field !== field);
}),
);
},
clearErrors: () => {
set(
produce<AppState>((draft) => {
draft.errors = [];
}),
);
},
setNewErrors: (newErrors: ErrorState[]) => {
set(
produce<AppState>((draft) => {
draft.errors = newErrors;
// Get the column management actions from the generic store
const columnActions = nodesTableColumnStore.createActions(
setWrapper,
get,
);
return {
selectedDevice: 0,
devices: [],
currentPage: "messages",
rasterSources: [],
commandPaletteOpen: false,
connectDialogOpen: false,
nodeNumToBeRemoved: 0,
nodeNumDetails: 0,
errors: [],
nodesTableColumns: nodesTableColumnStore.initialState.columns,
setRasterSources: (sources: RasterSource[]) => {
set(
produce<AppState>((draft) => {
draft.rasterSources = sources;
}),
);
},
addRasterSource: (source: RasterSource) => {
set(
produce<AppState>((draft) => {
draft.rasterSources.push(source);
}),
);
},
removeRasterSource: (index: number) => {
set(
produce<AppState>((draft) => {
draft.rasterSources.splice(index, 1);
}),
);
},
setSelectedDevice: (deviceId) =>
set(() => ({
selectedDevice: deviceId,
})),
addDevice: (device) =>
set((state) => ({
devices: [...state.devices, device],
})),
removeDevice: (deviceId) =>
set((state) => ({
devices: state.devices.filter((device) => device.id !== deviceId),
})),
setCommandPaletteOpen: (open: boolean) => {
set(
produce<AppState>((draft) => {
draft.commandPaletteOpen = open;
}),
);
},
setNodeNumToBeRemoved: (nodeNum) =>
set(() => ({
nodeNumToBeRemoved: nodeNum,
})),
setConnectDialogOpen: (open: boolean) => {
set(
produce<AppState>((draft) => {
draft.connectDialogOpen = open;
}),
);
},
setNodeNumDetails: (nodeNum) =>
set(() => ({
nodeNumDetails: nodeNum,
})),
// Nodes table column management - delegate to generic actions
updateColumnVisibility: columnActions.updateColumnVisibility,
updateNodesTableColumn: columnActions.updateColumn,
resetColumnsToDefault: columnActions.resetColumnsToDefault,
setNodesTableColumns: columnActions.setColumns,
hasErrors: () => {
const state = get();
return state.errors.length > 0;
},
getErrorMessage: (field: string) => {
const state = get();
return state.errors.find((err) => err.field === field)?.message;
},
hasFieldError: (field: string) => {
const state = get();
return state.errors.some((err) => err.field === field);
},
addError: (field: string, message: string) => {
set(
produce<AppState>((draft) => {
draft.errors = [
...draft.errors.filter((e) => e.field !== field),
{ field, message },
];
}),
);
},
removeError: (field: string) => {
set(
produce<AppState>((draft) => {
draft.errors = draft.errors.filter((e) => e.field !== field);
}),
);
},
clearErrors: () => {
set(
produce<AppState>((draft) => {
draft.errors = [];
}),
);
},
setNewErrors: (newErrors: ErrorState[]) => {
set(
produce<AppState>((draft) => {
draft.errors = newErrors;
}),
);
},
};
},
{
name: "meshtastic-app-store",
partialize: (state) => ({
nodesTableColumns: state.nodesTableColumns,
rasterSources: state.rasterSources,
}),
);
},
}));
},
),
);

96
packages/web/src/core/stores/createTableColumnStore.ts

@ -0,0 +1,96 @@
import type { TableColumn } from "@core/hooks/useColumnManager.ts";
import { produce } from "immer";
export interface TableStoreConfig<T extends TableColumn> {
defaultColumns: T[];
storeName?: string;
}
export interface TableStoreActions<T extends TableColumn> {
updateColumnVisibility: (columnId: string, visible: boolean) => void;
updateColumn: (columnId: string, updates: Partial<T>) => void;
resetColumnsToDefault: () => void;
setColumns: (columns: T[]) => void;
}
export interface TableStoreState<T extends TableColumn> {
columns: T[];
}
/**
* Factory function to create column management state and actions
* Can be used within any Zustand store or as a standalone solution
*/
export function createTableColumnStore<T extends TableColumn>(
config: TableStoreConfig<T>,
) {
const { defaultColumns } = config;
// Initial state
const initialState: TableStoreState<T> = {
columns: defaultColumns,
};
// Actions factory
const createActions = (
set: (fn: (state: any) => void) => void,
_get?: () => any,
): TableStoreActions<T> => ({
updateColumnVisibility: (columnId: string, visible: boolean) => {
set(
produce((draft: any) => {
// Access the nodesTableColumns property instead of columns
const columns = draft.nodesTableColumns;
if (columns) {
const column = columns.find((col: T) => col.id === columnId);
if (column) {
column.visible = visible;
}
}
}),
);
},
updateColumn: (columnId: string, updates: Partial<T>) => {
set(
produce((draft: any) => {
const columns = draft.nodesTableColumns;
if (columns) {
const column = columns.find((col: T) => col.id === columnId);
if (column) {
Object.assign(column, updates);
}
}
}),
);
},
resetColumnsToDefault: () => {
set(
produce((draft: any) => {
draft.nodesTableColumns = defaultColumns;
}),
);
},
setColumns: (columns: T[]) => {
set(
produce((draft: any) => {
draft.nodesTableColumns = columns;
}),
);
},
});
return {
initialState,
createActions,
defaultColumns,
};
}
/**
* Utility type for extracting column store slice from a larger store
*/
export type TableColumnStoreSlice<T extends TableColumn> = TableStoreState<T> &
TableStoreActions<T>;

139
packages/web/src/pages/Nodes/index.tsx

@ -1,27 +1,24 @@
import { LocationResponseDialog } from "@app/components/Dialog/LocationResponseDialog.tsx";
import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteResponseDialog.tsx";
import { ColumnVisibilityControl } from "@components/generic/ColumnVisibilityControl/index.tsx";
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
import {
type FilterState,
useFilterNode,
} from "@components/generic/Filter/useFilterNode.ts";
import { Mono } from "@components/generic/Mono.tsx";
import {
type DataRow,
type Heading,
Table,
} from "@components/generic/Table/index.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { getNodeCellData } from "@components/generic/Table/NodeCellHelpers.tsx";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { Avatar } from "@components/UI/Avatar.tsx";
import { Input } from "@components/UI/Input.tsx";
import useLang from "@core/hooks/useLang.ts";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf, type Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { LockIcon, LockOpenIcon } from "lucide-react";
import type { Protobuf, Types } from "@meshtastic/core";
import {
type JSX,
useCallback,
@ -31,7 +28,6 @@ import {
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { base16 } from "rfc4648";
export interface DeleteNoteDialogProps {
open: boolean;
@ -43,7 +39,7 @@ const NodesPage = (): JSX.Element => {
const { currentLanguage } = useLang();
const { getNodes, hardware, connection, hasNodeError, setDialogOpen } =
useDevice();
const { setNodeNumDetails } = useAppStore();
const { setNodeNumDetails, nodesTableColumns } = useAppStore();
const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode();
const [selectedTraceroute, setSelectedTraceroute] = useState<
@ -105,121 +101,29 @@ const NodesPage = (): JSX.Element => {
};
}, [connection, handleLocation]);
const tableHeadings: Heading[] = [
{ title: "", sortable: false },
{ title: t("nodesTable.headings.longName"), sortable: true },
{ title: t("nodesTable.headings.connection"), sortable: true },
{ title: t("nodesTable.headings.lastHeard"), sortable: true },
{ title: t("nodesTable.headings.encryption"), sortable: false },
{ title: t("unit.snr"), sortable: true },
{ title: t("nodesTable.headings.model"), sortable: true },
{ title: t("nodesTable.headings.macAddress"), sortable: true },
];
// Get visible columns and create table headings
const visibleColumns = nodesTableColumns.filter((col) => col.visible);
const tableHeadings: Heading[] = visibleColumns.map((col) => ({
title: col.title.includes(".") ? t(col.title) : col.title,
sortable: col.sortable,
}));
const tableRows: DataRow[] = filteredNodes.map((node) => {
const macAddress =
base16
.stringify(node.user?.macaddr ?? [])
.match(/.{1,2}/g)
?.join(":") ?? t("unknown.shortName");
const cells = visibleColumns.map((column) =>
getNodeCellData(
node,
column.key,
t,
currentLanguage,
hasNodeError,
handleNodeInfoDialog,
),
);
return {
id: node.num,
isFavorite: node.isFavorite,
cells: [
{
content: (
<Avatar
text={node.user?.shortName ?? t("unknown.shortName")}
showFavorite={node.isFavorite}
showError={hasNodeError(node.num)}
/>
),
sortValue: node.user?.shortName ?? "", // Non-sortable column
},
{
content: (
<h1
onMouseDown={() => handleNodeInfoDialog(node.num)}
onKeyUp={(evt) => {
evt.key === "Enter" && handleNodeInfoDialog(node.num);
}}
className="cursor-pointer underline ml-2 whitespace-break-spaces"
>
{node.user?.longName ?? numberToHexUnpadded(node.num)}
</h1>
),
sortValue: node.user?.longName ?? numberToHexUnpadded(node.num),
},
{
content: (
<Mono className="w-16">
{node.hopsAway !== undefined
? node?.viaMqtt === false && node.hopsAway === 0
? t("nodesTable.connectionStatus.direct")
: `${node.hopsAway?.toString()} ${
(node.hopsAway ?? 0 > 1)
? t("unit.hop.plural")
: t("unit.hops_one")
} ${t("nodesTable.connectionStatus.away")}`
: t("nodesTable.connectionStatus.unknown")}
{node?.viaMqtt === true
? t("nodesTable.connectionStatus.viaMqtt")
: ""}
</Mono>
),
sortValue: node.hopsAway ?? Number.MAX_SAFE_INTEGER,
},
{
content: (
<Mono>
{node.lastHeard === 0 ? (
<p>{t("nodesTable.lastHeardStatus.never")}</p>
) : (
<TimeAgo
timestamp={node.lastHeard * 1000}
locale={currentLanguage?.code}
/>
)}
</Mono>
),
sortValue: node.lastHeard,
},
{
content: (
<Mono>
{node.user?.publicKey && node.user?.publicKey.length > 0 ? (
<LockIcon className="text-green-600 mx-auto" />
) : (
<LockOpenIcon className="text-yellow-300 mx-auto" />
)}
</Mono>
),
sortValue: "", // Non-sortable column
},
{
content: (
<Mono>
{node.snr}
{t("unit.dbm")}/{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}
%/{/* Percentage */}
{(node.snr + 10) * 5}
{t("unit.raw")}
</Mono>
),
sortValue: node.snr,
},
{
content: (
<Mono>{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}</Mono>
),
sortValue: Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0],
},
{
content: <Mono>{macAddress}</Mono>,
sortValue: macAddress,
},
],
cells,
};
});
@ -241,6 +145,7 @@ const NodesPage = (): JSX.Element => {
/>
</div>
<div className="flex justify-end">
<ColumnVisibilityControl />
<FilterControl
filterState={filterState}
defaultFilterValues={defaultFilterValues}

Loading…
Cancel
Save