Merge pull request #858 from Dispatcharr/table-refactor

feat: Implement table header pin toggle and refactor table preferences management (Closes #663)
This commit is contained in:
SergeantPanda 2026-01-15 13:44:41 -06:00 committed by GitHub
commit 82172e9615
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 217 additions and 29 deletions

View file

@ -7,9 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Table header pin toggle: Pin/unpin table headers to keep them visible while scrolling. Toggle available in channel table menu and UI Settings page. Setting persists across sessions and applies to all tables. (Closes #663)
### Changed
- Table preferences (header pin and table size) now managed together with centralized state management and localStorage persistence.
- Frontend tests GitHub workflow now uses Node.js 24 (matching Dockerfile) and runs on both `main` and `dev` branch pushes and pull requests for comprehensive CI coverage.
- Table preferences architecture refactored: Migrated `table-size` preference from individual `useLocalStorage` calls to centralized `useTablePreferences` hook. All table components now read preferences from the table instance (`table.tableSize`, `table.headerPinned`) instead of calling hooks directly, improving maintainability and providing consistent API across all tables.
### Fixed

View file

@ -236,7 +236,6 @@ export default function BackupManager() {
// Read user's preferences from settings
const [timeFormat] = useLocalStorage('time-format', '12h');
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
const [tableSize] = useLocalStorage('table-size', 'default');
const [userTimezone] = useLocalStorage('time-zone', getDefaultTimeZone());
const is12Hour = timeFormat === '12h';
@ -309,10 +308,10 @@ export default function BackupManager() {
{
id: 'actions',
header: 'Actions',
size: tableSize === 'compact' ? 75 : 100,
size: 100,
},
],
[tableSize]
[]
);
const renderHeaderCell = (header) => {

View file

@ -71,7 +71,6 @@ const StreamConnectionCard = ({
// Get Date-format from localStorage
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
const [tableSize] = useLocalStorage('table-size', 'default');
// Create a map of M3U account IDs to names for quick lookup
const m3uAccountsMap = useMemo(() => {
@ -296,7 +295,7 @@ const StreamConnectionCard = ({
{
id: 'actions',
header: 'Actions',
size: tableSize == 'compact' ? 75 : 100,
size: 100,
},
],
[]

View file

@ -1,18 +1,18 @@
import useSettingsStore from '../../../store/settings.jsx';
import useLocalStorage from '../../../hooks/useLocalStorage.jsx';
import useTablePreferences from '../../../hooks/useTablePreferences.jsx';
import {
buildTimeZoneOptions,
getDefaultTimeZone,
} from '../../../utils/dateTimeUtils.js';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { showNotification } from '../../../utils/notificationUtils.js';
import { Select } from '@mantine/core';
import { Select, Switch, Stack } from '@mantine/core';
import { saveTimeZoneSetting } from '../../../utils/forms/settings/UiSettingsFormUtils.js';
const UiSettingsForm = React.memo(() => {
const settings = useSettingsStore((s) => s.settings);
const [tableSize, setTableSize] = useLocalStorage('table-size', 'default');
const [timeFormat, setTimeFormat] = useLocalStorage('time-format', '12h');
const [dateFormat, setDateFormat] = useLocalStorage('date-format', 'mdy');
const [timeZone, setTimeZone] = useLocalStorage(
@ -20,6 +20,10 @@ const UiSettingsForm = React.memo(() => {
getDefaultTimeZone()
);
// Use shared table preferences hook
const { headerPinned, setHeaderPinned, tableSize, setTableSize } =
useTablePreferences();
const timeZoneOptions = useMemo(
() => buildTimeZoneOptions(timeZone),
[timeZone]
@ -74,11 +78,14 @@ const UiSettingsForm = React.memo(() => {
persistTimeZoneSetting(value);
}
break;
case 'header-pinned':
setHeaderPinned(value);
break;
}
};
return (
<>
<Stack gap="md">
<Select
label="Table Size"
value={tableSize}
@ -98,6 +105,14 @@ const UiSettingsForm = React.memo(() => {
},
]}
/>
<Switch
label="Pin Table Headers"
description="Keep table headers visible when scrolling"
checked={headerPinned}
onChange={(event) =>
onUISettingsChange('header-pinned', event.currentTarget.checked)
}
/>
<Select
label="Time format"
value={timeFormat}
@ -136,7 +151,7 @@ const UiSettingsForm = React.memo(() => {
onChange={(val) => onUISettingsChange('time-zone', val)}
data={timeZoneOptions}
/>
</>
</Stack>
);
});

View file

@ -114,6 +114,7 @@ const ChannelRowActions = React.memo(
({
theme,
row,
table,
editChannel,
deleteChannel,
handleWatchStream,
@ -123,7 +124,6 @@ const ChannelRowActions = React.memo(
// Extract the channel ID once to ensure consistency
const channelId = row.original.id;
const channelUuid = row.original.uuid;
const [tableSize, _] = useLocalStorage('table-size', 'default');
const authUser = useAuthStore((s) => s.user);
@ -149,6 +149,7 @@ const ChannelRowActions = React.memo(
createRecording(row.original);
}, [channelId]);
const tableSize = table?.tableSize ?? 'default';
const iconSize =
tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md';
@ -272,7 +273,6 @@ const ChannelsTable = ({ onReady }) => {
// store/settings
const env_mode = useSettingsStore((s) => s.environment.env_mode);
const showVideo = useVideoStore((s) => s.showVideo);
const [tableSize, _] = useLocalStorage('table-size', 'default');
// store/warnings
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
@ -429,9 +429,10 @@ const ChannelsTable = ({ onReady }) => {
setIsLoading(false);
hasFetchedData.current = true;
setTablePrefs({
setTablePrefs((prev) => ({
...prev,
pageSize: pagination.pageSize,
});
}));
setAllRowIds(ids);
// Signal ready after first successful data fetch AND EPG data is loaded
@ -949,13 +950,14 @@ const ChannelsTable = ({ onReady }) => {
},
{
id: 'actions',
size: tableSize == 'compact' ? 75 : 100,
size: 100,
enableResizing: false,
header: '',
cell: ({ row }) => (
cell: ({ row, table }) => (
<ChannelRowActions
theme={theme}
row={row}
table={table}
editChannel={editChannel}
deleteChannel={deleteChannel}
handleWatchStream={handleWatchStream}

View file

@ -28,6 +28,8 @@ import {
Filter,
Square,
SquareCheck,
Pin,
PinOff,
} from 'lucide-react';
import API from '../../../api';
import { notifications } from '@mantine/notifications';
@ -105,6 +107,7 @@ const ChannelTableHeader = ({
editChannel,
deleteChannels,
selectedTableIds,
table,
showDisabled,
setShowDisabled,
showOnlyStreamlessChannels,
@ -131,6 +134,9 @@ const ChannelTableHeader = ({
const authUser = useAuthStore((s) => s.user);
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
const headerPinned = table?.headerPinned ?? false;
const setHeaderPinned = table?.setHeaderPinned || (() => {});
const closeAssignChannelNumbersModal = () => {
setAssignNumbersModalOpen(false);
};
@ -229,6 +235,10 @@ const ChannelTableHeader = ({
setShowOnlyStreamlessChannels(!showOnlyStreamlessChannels);
};
const toggleHeaderPinned = () => {
setHeaderPinned(!headerPinned);
};
return (
<Group justify="space-between">
<Group gap={5} style={{ paddingLeft: 10 }}>
@ -346,6 +356,19 @@ const ChannelTableHeader = ({
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={
headerPinned ? <Pin size={18} /> : <PinOff size={18} />
}
onClick={toggleHeaderPinned}
>
<Text size="xs">
{headerPinned ? 'Unpin Headers' : 'Pin Headers'}
</Text>
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<ArrowDown01 size={18} />}
disabled={

View file

@ -4,10 +4,9 @@ import { useCallback, useState, useRef, useMemo } from 'react';
import { flexRender } from '@tanstack/react-table';
import table from '../../../helpers/table';
import CustomTableBody from './CustomTableBody';
import useLocalStorage from '../../../hooks/useLocalStorage';
const CustomTable = ({ table }) => {
const [tableSize, _] = useLocalStorage('table-size', 'default');
const tableSize = table?.tableSize ?? 'default';
// Get column sizing state for dependency tracking
const columnSizing = table.getState().columnSizing;
@ -34,7 +33,6 @@ const CustomTable = ({ table }) => {
minWidth: `${minTableWidth}px`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<CustomTableHeader
@ -47,6 +45,7 @@ const CustomTable = ({ table }) => {
}
selectedTableIds={table.selectedTableIds}
tableCellProps={table.tableCellProps}
headerPinned={table.headerPinned}
/>
<CustomTableBody
getRowModel={table.getRowModel}

View file

@ -9,6 +9,7 @@ const CustomTableHeader = ({
headerCellRenderFns,
onSelectAllChange,
tableCellProps,
headerPinned = true,
}) => {
const renderHeaderCell = (header) => {
if (headerCellRenderFns[header.id]) {
@ -59,15 +60,22 @@ const CustomTableHeader = ({
return width;
}, [headerGroups]);
// Memoize the style object to ensure it updates when headerPinned changes
const headerStyle = useMemo(
() => ({
position: headerPinned ? 'sticky' : 'relative',
top: headerPinned ? 0 : 'auto',
backgroundColor: '#3E3E45',
zIndex: headerPinned ? 10 : 1,
}),
[headerPinned]
);
return (
<Box
className="thead"
style={{
position: 'sticky',
top: 0,
backgroundColor: '#3E3E45',
zIndex: 10,
}}
style={headerStyle}
data-header-pinned={headerPinned ? 'true' : 'false'}
>
{getHeaderGroups().map((headerGroup) => (
<Box

View file

@ -1,6 +1,7 @@
import { Center, Checkbox } from '@mantine/core';
import CustomTable from './CustomTable';
import CustomTableHeader from './CustomTableHeader';
import useTablePreferences from '../../../hooks/useTablePreferences';
import {
useReactTable,
@ -27,6 +28,10 @@ const useTable = ({
const [lastClickedId, setLastClickedId] = useState(null);
const [isShiftKeyDown, setIsShiftKeyDown] = useState(false);
// Use shared table preferences hook
const { headerPinned, setHeaderPinned, tableSize, setTableSize } =
useTablePreferences();
// Event handlers for shift key detection with improved handling
const handleKeyDown = useCallback((e) => {
if (e.key === 'Shift') {
@ -244,8 +249,22 @@ const useTable = ({
expandedRowRenderer,
setSelectedTableIds,
isShiftKeyDown, // Include shift key state in the table instance
headerPinned,
setHeaderPinned,
tableSize,
setTableSize,
}),
[selectedTableIdsSet, expandedRowIds, allRowIds, isShiftKeyDown]
[
selectedTableIdsSet,
expandedRowIds,
allRowIds,
isShiftKeyDown,
options,
headerPinned,
setHeaderPinned,
tableSize,
setTableSize,
]
);
return {

View file

@ -68,8 +68,9 @@ const StreamRowActions = ({
handleWatchStream,
selectedChannelIds,
createChannelFromStream,
table,
}) => {
const [tableSize, _] = useLocalStorage('table-size', 'default');
const tableSize = table?.tableSize ?? 'default';
const channelSelectionStreams = useChannelsTableStore(
(state) =>
state.channels.find((chan) => chan.id === selectedChannelIds[0])?.streams
@ -268,7 +269,6 @@ const StreamsTable = ({ onReady }) => {
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
const showVideo = useVideoStore((s) => s.showVideo);
const [tableSize, _] = useLocalStorage('table-size', 'default');
// Warnings store for "remember choice" functionality
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
@ -286,7 +286,7 @@ const StreamsTable = ({ onReady }) => {
() => [
{
id: 'actions',
size: columnSizing.actions || (tableSize == 'compact' ? 60 : 80),
size: columnSizing.actions || 75,
},
{
id: 'select',
@ -354,7 +354,7 @@ const StreamsTable = ({ onReady }) => {
),
},
],
[channelGroups, playlists, columnSizing, tableSize]
[channelGroups, playlists, columnSizing]
);
/**

View file

@ -0,0 +1,117 @@
import { useState, useEffect, useCallback } from 'react';
const useTablePreferences = () => {
// Initialize all preferences from localStorage
const [headerPinned, setHeaderPinnedState] = useState(() => {
try {
const prefs = localStorage.getItem('table-preferences');
if (prefs) {
const parsed = JSON.parse(prefs);
return parsed.headerPinned ?? false;
}
} catch (e) {
console.error('Error reading headerPinned from localStorage:', e);
}
return false;
});
const [tableSize, setTableSizeState] = useState(() => {
try {
// Check new location first
const prefs = localStorage.getItem('table-preferences');
if (prefs) {
const parsed = JSON.parse(prefs);
if (parsed.tableSize) {
return parsed.tableSize;
}
}
// Fallback to old location for migration
const oldSize = localStorage.getItem('table-size');
if (oldSize) {
return JSON.parse(oldSize);
}
} catch (e) {
console.error('Error reading tableSize from localStorage:', e);
}
return 'default';
});
// Listen for changes from other components
useEffect(() => {
const handleCustomEvent = (e) => {
if (
e.detail.headerPinned !== undefined &&
e.detail.headerPinned !== headerPinned
) {
setHeaderPinnedState(e.detail.headerPinned);
}
if (
e.detail.tableSize !== undefined &&
e.detail.tableSize !== tableSize
) {
setTableSizeState(e.detail.tableSize);
}
};
window.addEventListener('table-preferences-changed', handleCustomEvent);
return () =>
window.removeEventListener(
'table-preferences-changed',
handleCustomEvent
);
}, [headerPinned, tableSize]);
// Function to update headerPinned and persist to localStorage
const setHeaderPinned = useCallback((value) => {
setHeaderPinnedState(value);
try {
// Read current prefs, update headerPinned, and save back
let prefs = {};
const stored = localStorage.getItem('table-preferences');
if (stored) {
prefs = JSON.parse(stored);
}
prefs.headerPinned = value;
localStorage.setItem('table-preferences', JSON.stringify(prefs));
// Dispatch custom event for same-page sync
window.dispatchEvent(
new CustomEvent('table-preferences-changed', {
detail: { headerPinned: value },
})
);
} catch (e) {
console.error('Error saving headerPinned to localStorage:', e);
}
}, []);
// Function to update tableSize and persist to localStorage
const setTableSize = useCallback((value) => {
setTableSizeState(value);
try {
// Read current prefs, update tableSize, and save back
let prefs = {};
const stored = localStorage.getItem('table-preferences');
if (stored) {
prefs = JSON.parse(stored);
}
prefs.tableSize = value;
localStorage.setItem('table-preferences', JSON.stringify(prefs));
// Dispatch custom event for same-page sync
window.dispatchEvent(
new CustomEvent('table-preferences-changed', {
detail: { tableSize: value },
})
);
} catch (e) {
console.error('Error saving tableSize to localStorage:', e);
}
}, []);
return { headerPinned, setHeaderPinned, tableSize, setTableSize };
};
export default useTablePreferences;