diff --git a/packages/web/public/i18n/locales/en/nodes.json b/packages/web/public/i18n/locales/en/nodes.json index 63290da3..7fd9a3c2 100644 --- a/packages/web/public/i18n/locales/en/nodes.json +++ b/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", diff --git a/packages/web/src/components/generic/ColumnVisibilityControl/ColumnVisibilityControl.tsx b/packages/web/src/components/generic/ColumnVisibilityControl/ColumnVisibilityControl.tsx new file mode 100644 index 00000000..fd3446df --- /dev/null +++ b/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 ( + (title.includes(".") ? t(title) : title)} + isColumnDisabled={(column) => column.id === "avatar"} + /> + ); +}; diff --git a/packages/web/src/components/generic/ColumnVisibilityControl/GenericColumnVisibilityControl.tsx b/packages/web/src/components/generic/ColumnVisibilityControl/GenericColumnVisibilityControl.tsx new file mode 100644 index 00000000..013ac3f2 --- /dev/null +++ b/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; + 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) { + const { + allColumns, + visibleCount, + totalCount, + updateColumnVisibility, + resetColumns, + } = columnManager; + + const defaultTrigger = ( + + ); + + return ( + + + {trigger || defaultTrigger} + + + {title} + + + {allColumns.map((column) => ( + + updateColumnVisibility(column.id, checked ?? false) + } + disabled={isColumnDisabled(column)} + > + {translateColumnTitle(column.title)} + + ))} + + + resetColumns()} + > + {resetLabel} + + + + ); +} diff --git a/packages/web/src/components/generic/ColumnVisibilityControl/README.md b/packages/web/src/components/generic/ColumnVisibilityControl/README.md new file mode 100644 index 00000000..2dd4257a --- /dev/null +++ b/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"; + + 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()( + persist( + (set, get) => { + const setWrapper = (fn: (state: any) => void) => { + set(produce(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 ( +
+
+ +
+ + ({ + title: col.title, + sortable: col.sortable + }))} + rows={data.map(row => ({ + id: row.id, + cells: columnManager.visibleColumns.map(col => + getCellData(row, col.key) + ) + }))} + /> + + ); +}; +``` + +### Example 2: Custom Column Types + +```tsx +interface CustomTableColumn extends TableColumn { + width?: number; + align?: 'left' | 'center' | 'right'; + format?: 'currency' | 'date' | 'text'; +} + +const customColumnStore = createTableColumnStore({ + 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. diff --git a/packages/web/src/components/generic/ColumnVisibilityControl/index.tsx b/packages/web/src/components/generic/ColumnVisibilityControl/index.tsx new file mode 100644 index 00000000..232329ee --- /dev/null +++ b/packages/web/src/components/generic/ColumnVisibilityControl/index.tsx @@ -0,0 +1,2 @@ +export { ColumnVisibilityControl } from "./ColumnVisibilityControl.tsx"; +export { GenericColumnVisibilityControl } from "./GenericColumnVisibilityControl.tsx"; diff --git a/packages/web/src/components/generic/Table/NodeCellHelpers.tsx b/packages/web/src/components/generic/Table/NodeCellHelpers.tsx new file mode 100644 index 00000000..ee9444f9 --- /dev/null +++ b/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: ( + + ), + sortValue: node.user?.shortName ?? "", + }; + + case "longName": + return { + content: ( +

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)} +

+ ), + sortValue: node.user?.longName ?? numberToHexUnpadded(node.num), + }; + + case "shortName": + return { + content: {node.user?.shortName ?? t("unknown.shortName")}, + sortValue: node.user?.shortName ?? "", + }; + + case "nodeId": + return { + content: {numberToHexUnpadded(node.num)}, + 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: ( + + {connectionText} + {mqttText} + + ), + sortValue: node.hopsAway ?? Number.MAX_SAFE_INTEGER, + }; + } + + case "lastHeard": + return { + content: ( + + {node.lastHeard === 0 ? ( +

{t("nodesTable.lastHeardStatus.never")}

+ ) : ( + + )} +
+ ), + sortValue: node.lastHeard, + }; + + case "encryption": + return { + content: ( + + {node.user?.publicKey && node.user?.publicKey.length > 0 ? ( + + ) : ( + + )} + + ), + sortValue: "", + }; + + case "snr": + return { + content: ( + + {node.snr} + {t("unit.dbm")}/{Math.min(Math.max((node.snr + 10) * 5, 0), 100)} + %/{(node.snr + 10) * 5} + {t("unit.raw")} + + ), + sortValue: node.snr, + }; + + case "model": { + const modelName = + Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ?? "Unknown"; + return { + content: {modelName}, + sortValue: modelName, + }; + } + + case "macAddress": { + const macAddress = + base16 + .stringify(node.user?.macaddr ?? []) + .match(/.{1,2}/g) + ?.join(":") ?? t("unknown.shortName"); + + return { + content: {macAddress}, + sortValue: macAddress, + }; + } + + case "role": { + const roleName = + Protobuf.Config.Config_DeviceConfig_Role[node.user?.role ?? 0] ?? + "Unknown"; + return { + content: {roleName.replace(/_/g, " ")}, + sortValue: roleName, + }; + } + + case "batteryLevel": + return { + content: {formatBatteryLevel(node.deviceMetrics)}, + sortValue: node.deviceMetrics?.batteryLevel ?? 0, + }; + + case "channelUtilization": + return { + content: ( + + {node.deviceMetrics?.channelUtilization + ? `${node.deviceMetrics.channelUtilization.toFixed(1)}%` + : "Unknown"} + + ), + sortValue: node.deviceMetrics?.channelUtilization ?? 0, + }; + + case "airtimeUtilization": + return { + content: ( + + {node.deviceMetrics?.airUtilTx + ? `${node.deviceMetrics.airUtilTx.toFixed(1)}%` + : "Unknown"} + + ), + sortValue: node.deviceMetrics?.airUtilTx ?? 0, + }; + + case "uptime": + return { + content: {formatUptime(node.deviceMetrics)}, + sortValue: node.deviceMetrics?.uptimeSeconds ?? 0, + }; + + case "position": + return { + content: ( + {formatPosition(node.position)} + ), + sortValue: formatPosition(node.position), + }; + + default: + return { + content: Unknown, + sortValue: "", + }; + } +}; diff --git a/packages/web/src/components/generic/Table/index.tsx b/packages/web/src/components/generic/Table/index.tsx index b85c071b..d74ee147 100755 --- a/packages/web/src/components/generic/Table/index.tsx +++ b/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; diff --git a/packages/web/src/core/hooks/useColumnManager.ts b/packages/web/src/core/hooks/useColumnManager.ts new file mode 100644 index 00000000..cc2fe273 --- /dev/null +++ b/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 { + columns: T[]; + onUpdateColumn: (columnId: string, updates: Partial) => void; + onResetColumns: () => void; +} + +export interface UseColumnManagerReturn { + 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({ + columns, + onUpdateColumn, + onResetColumns, +}: ColumnManagerConfig): UseColumnManagerReturn { + 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); + }, + [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, + }; +} diff --git a/packages/web/src/core/stores/appStore.ts b/packages/web/src/core/stores/appStore.ts index bdf2d67f..e8386348 100644 --- a/packages/web/src/core/stores/appStore.ts +++ b/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, + ) => 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()((set, get) => ({ - selectedDevice: 0, - devices: [], - currentPage: "messages", - rasterSources: [], - commandPaletteOpen: false, - connectDialogOpen: false, - nodeNumToBeRemoved: 0, - nodeNumDetails: 0, - errors: [], - - setRasterSources: (sources: RasterSource[]) => { - set( - produce((draft) => { - draft.rasterSources = sources; - }), - ); - }, - addRasterSource: (source: RasterSource) => { - set( - produce((draft) => { - draft.rasterSources.push(source); - }), - ); - }, - removeRasterSource: (index: number) => { - set( - produce((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((draft) => { - draft.commandPaletteOpen = open; - }), - ); - }, - setNodeNumToBeRemoved: (nodeNum) => - set(() => ({ - nodeNumToBeRemoved: nodeNum, - })), - setConnectDialogOpen: (open: boolean) => { - set( - produce((draft) => { - draft.connectDialogOpen = open; - }), - ); - }, +export const useAppStore = create()( + persist( + (set, get) => { + // Create a wrapper for set that matches the expected signature + const setWrapper = (fn: (state: any) => void) => { + set(produce(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((draft) => { - draft.errors = [ - ...draft.errors.filter((e) => e.field !== field), - { field, message }, - ]; - }), - ); - }, - removeError: (field: string) => { - set( - produce((draft) => { - draft.errors = draft.errors.filter((e) => e.field !== field); - }), - ); - }, - clearErrors: () => { - set( - produce((draft) => { - draft.errors = []; - }), - ); - }, - setNewErrors: (newErrors: ErrorState[]) => { - set( - produce((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((draft) => { + draft.rasterSources = sources; + }), + ); + }, + addRasterSource: (source: RasterSource) => { + set( + produce((draft) => { + draft.rasterSources.push(source); + }), + ); + }, + removeRasterSource: (index: number) => { + set( + produce((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((draft) => { + draft.commandPaletteOpen = open; + }), + ); + }, + setNodeNumToBeRemoved: (nodeNum) => + set(() => ({ + nodeNumToBeRemoved: nodeNum, + })), + setConnectDialogOpen: (open: boolean) => { + set( + produce((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((draft) => { + draft.errors = [ + ...draft.errors.filter((e) => e.field !== field), + { field, message }, + ]; + }), + ); + }, + removeError: (field: string) => { + set( + produce((draft) => { + draft.errors = draft.errors.filter((e) => e.field !== field); + }), + ); + }, + clearErrors: () => { + set( + produce((draft) => { + draft.errors = []; + }), + ); + }, + setNewErrors: (newErrors: ErrorState[]) => { + set( + produce((draft) => { + draft.errors = newErrors; + }), + ); + }, + }; + }, + { + name: "meshtastic-app-store", + partialize: (state) => ({ + nodesTableColumns: state.nodesTableColumns, + rasterSources: state.rasterSources, }), - ); - }, -})); + }, + ), +); diff --git a/packages/web/src/core/stores/createTableColumnStore.ts b/packages/web/src/core/stores/createTableColumnStore.ts new file mode 100644 index 00000000..7b94f1cc --- /dev/null +++ b/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 { + defaultColumns: T[]; + storeName?: string; +} + +export interface TableStoreActions { + updateColumnVisibility: (columnId: string, visible: boolean) => void; + updateColumn: (columnId: string, updates: Partial) => void; + resetColumnsToDefault: () => void; + setColumns: (columns: T[]) => void; +} + +export interface TableStoreState { + 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( + config: TableStoreConfig, +) { + const { defaultColumns } = config; + + // Initial state + const initialState: TableStoreState = { + columns: defaultColumns, + }; + + // Actions factory + const createActions = ( + set: (fn: (state: any) => void) => void, + _get?: () => any, + ): TableStoreActions => ({ + 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) => { + 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 = TableStoreState & + TableStoreActions; diff --git a/packages/web/src/pages/Nodes/index.tsx b/packages/web/src/pages/Nodes/index.tsx index 929e4bb4..699c0e56 100644 --- a/packages/web/src/pages/Nodes/index.tsx +++ b/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: ( - - ), - sortValue: node.user?.shortName ?? "", // Non-sortable column - }, - { - content: ( -

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)} -

- ), - sortValue: node.user?.longName ?? numberToHexUnpadded(node.num), - }, - { - content: ( - - {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") - : ""} - - ), - sortValue: node.hopsAway ?? Number.MAX_SAFE_INTEGER, - }, - { - content: ( - - {node.lastHeard === 0 ? ( -

{t("nodesTable.lastHeardStatus.never")}

- ) : ( - - )} -
- ), - sortValue: node.lastHeard, - }, - { - content: ( - - {node.user?.publicKey && node.user?.publicKey.length > 0 ? ( - - ) : ( - - )} - - ), - sortValue: "", // Non-sortable column - }, - { - content: ( - - {node.snr} - {t("unit.dbm")}/{Math.min(Math.max((node.snr + 10) * 5, 0), 100)} - %/{/* Percentage */} - {(node.snr + 10) * 5} - {t("unit.raw")} - - ), - sortValue: node.snr, - }, - { - content: ( - {Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]} - ), - sortValue: Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0], - }, - { - content: {macAddress}, - sortValue: macAddress, - }, - ], + cells, }; }); @@ -241,6 +145,7 @@ const NodesPage = (): JSX.Element => { />
+