committed by
GitHub
11 changed files with 1048 additions and 227 deletions
@ -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"} |
||||
|
/> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
); |
||||
|
} |
||||
@ -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. |
||||
@ -0,0 +1,2 @@ |
|||||
|
export { ColumnVisibilityControl } from "./ColumnVisibilityControl.tsx"; |
||||
|
export { GenericColumnVisibilityControl } from "./GenericColumnVisibilityControl.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: "", |
||||
|
}; |
||||
|
} |
||||
|
}; |
||||
@ -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, |
||||
|
}; |
||||
|
} |
||||
@ -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>; |
||||
Loading…
Reference in new issue