mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
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:
commit
82172e9615
11 changed files with 217 additions and 29 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
[]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
|
|||
117
frontend/src/hooks/useTablePreferences.jsx
Normal file
117
frontend/src/hooks/useTablePreferences.jsx
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue