Merge pull request #379 from Dispatcharr:table-column-resizing

Implement column resizing and improve table layouts
This commit is contained in:
SergeantPanda 2025-09-13 12:16:41 -05:00 committed by GitHub
commit 33700ccc06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 248 additions and 43 deletions

View file

@ -306,6 +306,12 @@ const ChannelsTable = ({}) => {
const [isBulkDelete, setIsBulkDelete] = useState(false);
const [channelToDelete, setChannelToDelete] = useState(null);
// Column sizing state for resizable columns
const [columnSizing, setColumnSizing] = useLocalStorage(
'channels-table-column-sizing',
{}
);
// M3U and EPG URL configuration state
const [m3uParams, setM3uParams] = useState({
cachedlogos: true,
@ -697,14 +703,17 @@ const ChannelsTable = ({}) => {
{
id: 'expand',
size: 20,
enableResizing: false,
},
{
id: 'select',
size: 30,
enableResizing: false,
},
{
id: 'enabled',
size: 45,
enableResizing: false,
cell: ({ row, table }) => {
return (
<ChannelEnabledSwitch
@ -718,7 +727,9 @@ const ChannelsTable = ({}) => {
{
id: 'channel_number',
accessorKey: 'channel_number',
size: 40,
size: columnSizing.channel_number || 40,
minSize: 30,
maxSize: 100,
cell: ({ getValue }) => {
const value = getValue();
// Format as integer if no decimal component
@ -739,6 +750,8 @@ const ChannelsTable = ({}) => {
{
id: 'name',
accessorKey: 'name',
size: columnSizing.name || 200,
minSize: 100,
cell: ({ getValue }) => (
<Box
style={{
@ -793,7 +806,9 @@ const ChannelsTable = ({}) => {
</Box>
);
},
size: 120,
size: columnSizing.epg || 120,
minSize: 80,
maxSize: 200,
},
{
id: 'channel_group',
@ -812,7 +827,9 @@ const ChannelsTable = ({}) => {
{getValue()}
</Box>
),
size: 175,
size: columnSizing.channel_group || 175,
minSize: 100,
maxSize: 300,
},
{
id: 'logo',
@ -821,6 +838,9 @@ const ChannelsTable = ({}) => {
return row.logo_id;
},
size: 75,
minSize: 50,
maxSize: 120,
enableResizing: false,
header: '',
cell: ({ getValue }) => {
const logoId = getValue();
@ -839,6 +859,7 @@ const ChannelsTable = ({}) => {
{
id: 'actions',
size: tableSize == 'compact' ? 75 : 100,
enableResizing: false,
header: '',
cell: ({ row }) => (
<ChannelRowActions
@ -853,7 +874,7 @@ const ChannelsTable = ({}) => {
),
},
],
[selectedProfileId, channelGroups, logos, theme]
[selectedProfileId, channelGroups, logos, theme, columnSizing]
);
const renderHeaderCell = (header) => {
@ -953,6 +974,12 @@ const ChannelsTable = ({}) => {
manualFiltering: true,
enableRowSelection: true,
onRowSelectionChange: onRowSelectionChange,
state: {
columnSizing,
pagination,
sorting,
},
onColumnSizingChange: setColumnSizing,
getExpandedRowHeight: (row) => {
return 20 + 28 * row.original.streams.length;
},
@ -1301,7 +1328,7 @@ const ChannelsTable = ({}) => {
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
overflowX: 'auto',
border: 'solid 1px rgb(68,68,68)',
borderRadius: 'var(--mantine-radius-default)',
}}

View file

@ -1,6 +1,6 @@
import { Box, Flex } from '@mantine/core';
import CustomTableHeader from './CustomTableHeader';
import { useCallback, useState, useRef } from 'react';
import { useCallback, useState, useRef, useMemo } from 'react';
import { flexRender } from '@tanstack/react-table';
import table from '../../../helpers/table';
import CustomTableBody from './CustomTableBody';
@ -9,11 +9,28 @@ import useLocalStorage from '../../../hooks/useLocalStorage';
const CustomTable = ({ table }) => {
const [tableSize, _] = useLocalStorage('table-size', 'default');
// Get column sizing state for dependency tracking
const columnSizing = table.getState().columnSizing;
// Calculate minimum table width reactively based on column sizes
const minTableWidth = useMemo(() => {
const headerGroups = table.getHeaderGroups();
if (!headerGroups || headerGroups.length === 0) return 0;
const width =
headerGroups[0]?.headers.reduce((total, header) => {
return total + header.getSize();
}, 0) || 0;
return width;
}, [table, columnSizing]);
return (
<Box
className={`divTable table-striped table-size-${tableSize}`}
style={{
width: '100%',
minWidth: `${minTableWidth}px`,
display: 'flex',
flexDirection: 'column',
}}

View file

@ -1,6 +1,7 @@
import { Box, Flex } from '@mantine/core';
import { VariableSizeList as List } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import { useMemo } from 'react';
import table from '../../../helpers/table';
const CustomTableBody = ({
@ -23,6 +24,19 @@ const CustomTableBody = ({
const rows = getRowModel().rows;
// Calculate minimum width based only on fixed-size columns
const minTableWidth = useMemo(() => {
if (rows.length === 0) return 0;
return rows[0].getVisibleCells().reduce((total, cell) => {
// Only count columns with fixed sizes, flexible columns will expand
const columnSize = cell.column.columnDef.size
? cell.column.getSize()
: cell.column.columnDef.minSize || 150; // Default min for flexible columns
return total + columnSize;
}, 0);
}, [rows]);
const renderTableBodyContents = () => {
const virtualized = false;
@ -94,6 +108,7 @@ const CustomTableBody = ({
style={{
display: 'flex',
width: '100%',
minWidth: '100%', // Force full width
...(row.getIsSelected() && {
backgroundColor: '#163632',
}),
@ -101,16 +116,24 @@ const CustomTableBody = ({
}}
>
{row.getVisibleCells().map((cell) => {
const hasFixedSize = cell.column.columnDef.size;
const isFlexible = !hasFixedSize;
return (
<Box
className="td"
key={`td-${cell.id}`}
style={{
flex: cell.column.columnDef.size ? '0 0 auto' : '1 1 0',
width: cell.column.columnDef.size
? cell.column.getSize()
: undefined,
minWidth: 0,
...(cell.column.columnDef.grow
? {
flex: '1 1 0%',
minWidth: 0,
}
: {
flex: `0 0 ${cell.column.getSize ? cell.column.getSize() : 150}px`,
width: `${cell.column.getSize ? cell.column.getSize() : 150}px`,
maxWidth: `${cell.column.getSize ? cell.column.getSize() : 150}px`,
}),
...(tableCellProps && tableCellProps({ cell })),
}}
>

View file

@ -1,6 +1,6 @@
import { Box, Center, Checkbox, Flex } from '@mantine/core';
import { flexRender } from '@tanstack/react-table';
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
const CustomTableHeader = ({
getHeaderGroups,
@ -40,6 +40,25 @@ const CustomTableHeader = ({
}
};
// Get header groups for dependency tracking
const headerGroups = getHeaderGroups();
// Calculate minimum width based only on fixed-size columns
const minTableWidth = useMemo(() => {
if (!headerGroups || headerGroups.length === 0) return 0;
const width =
headerGroups[0]?.headers.reduce((total, header) => {
// Only count columns with fixed sizes, flexible columns will expand
const columnSize = header.column.columnDef.size
? header.getSize()
: header.column.columnDef.minSize || 150; // Default min for flexible columns
return total + columnSize;
}, 0) || 0;
return width;
}, [headerGroups]);
return (
<Box
className="thead"
@ -54,7 +73,11 @@ const CustomTableHeader = ({
<Box
className="tr"
key={headerGroup.id}
style={{ display: 'flex', width: '100%' }}
style={{
display: 'flex',
width: '100%',
minWidth: '100%', // Force full width
}}
>
{headerGroup.headers.map((header) => {
return (
@ -62,11 +85,17 @@ const CustomTableHeader = ({
className="th"
key={header.id}
style={{
flex: header.column.columnDef.size ? '0 0 auto' : '1 1 0',
width: header.column.columnDef.size
? header.getSize()
: undefined,
minWidth: 0,
...(header.column.columnDef.grow
? {
flex: '1 1 0%',
minWidth: 0,
}
: {
flex: `0 0 ${header.getSize ? header.getSize() : 150}px`,
width: `${header.getSize ? header.getSize() : 150}px`,
maxWidth: `${header.getSize ? header.getSize() : 150}px`,
}),
position: 'relative',
// ...(tableCellProps && tableCellProps({ cell: header })),
}}
>
@ -76,10 +105,46 @@ const CustomTableHeader = ({
...(header.column.columnDef.style &&
header.column.columnDef.style),
height: '100%',
paddingRight: header.column.getCanResize() ? '8px' : '0px', // Add padding for resize handle
}}
>
{renderHeaderCell(header)}
</Flex>
{header.column.getCanResize() && (
<div
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={`resizer ${
header.column.getIsResizing() ? 'isResizing' : ''
}`}
style={{
position: 'absolute',
right: 0,
top: 0,
height: '100%',
width: '8px', // Make it slightly wider
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none',
backgroundColor: header.column.getIsResizing()
? '#3b82f6'
: 'transparent',
opacity: header.column.getIsResizing() ? 1 : 0.3, // Make it more visible by default
transition: 'opacity 0.2s',
zIndex: 1000, // Ensure it's on top
}}
onMouseEnter={(e) => {
e.target.style.opacity = '1';
e.target.style.backgroundColor = '#6b7280';
}}
onMouseLeave={(e) => {
if (!header.column.getIsResizing()) {
e.target.style.opacity = '0.5';
e.target.style.backgroundColor = 'transparent';
}
}}
/>
)}
</Box>
);
})}

View file

@ -18,6 +18,8 @@ const useTable = ({
onRowSelectionChange = null,
getExpandedRowHeight = null,
state = [],
columnSizing,
setColumnSizing,
...options
}) => {
const [selectedTableIds, setSelectedTableIds] = useState([]);
@ -85,15 +87,21 @@ const useTable = ({
const table = useReactTable({
defaultColumn: {
size: undefined,
minSize: 0,
maxSize: Number.MAX_SAFE_INTEGER,
size: 150,
},
...options,
state: {
data: options.data,
...options.state,
selectedTableIds,
...(columnSizing && { columnSizing }),
},
onStateChange: options.onStateChange,
...(setColumnSizing && { onColumnSizingChange: setColumnSizing }),
getCoreRowModel: options.getCoreRowModel ?? getCoreRowModel(),
enableColumnResizing: true,
columnResizeMode: 'onChange',
});
const selectedTableIdsSet = useMemo(

View file

@ -187,14 +187,15 @@ const EPGsTable = () => {
size: 200,
},
{
header: 'Source Type',
header: 'Type',
accessorKey: 'source_type',
size: 150,
size: 100,
},
{
header: 'URL / API Key / File Path',
accessorKey: 'url',
enableSorting: false,
minSize: 250,
cell: ({ cell, row }) => {
const value =
cell.getValue() ||
@ -220,7 +221,7 @@ const EPGsTable = () => {
{
header: 'Status',
accessorKey: 'status',
size: 150,
size: 100,
cell: ({ row }) => {
const data = row.original;
@ -236,6 +237,8 @@ const EPGsTable = () => {
header: 'Status Message',
accessorKey: 'last_message',
enableSorting: false,
minSize: 250,
grow: true,
cell: ({ row }) => {
const data = row.original;

View file

@ -394,7 +394,7 @@ const LogosTable = () => {
{
header: 'Name',
accessorKey: 'name',
size: 200,
size: 250,
cell: ({ getValue }) => (
<Text fw={500} size="sm">
{getValue()}
@ -479,6 +479,7 @@ const LogosTable = () => {
{
header: 'URL',
accessorKey: 'url',
grow: true,
cell: ({ getValue }) => (
<Group gap={4} style={{ alignItems: 'center' }}>
<Box

View file

@ -431,10 +431,10 @@ const M3UTable = () => {
sortable: true,
},
{
header: 'Account Type',
header: 'Type',
accessorKey: 'account_type',
sortable: true,
size: 150,
size: 100,
cell: ({ cell }) => {
const value = cell.getValue();
return value === 'XC' ? 'XC' : 'M3U';
@ -443,6 +443,7 @@ const M3UTable = () => {
{
header: 'URL / File',
accessorKey: 'server_url',
size: 250,
cell: ({ cell, row }) => {
const value = cell.getValue() || row.original.file_path || '';
return (
@ -461,16 +462,10 @@ const M3UTable = () => {
);
},
},
{
header: 'Max Streams',
accessorKey: 'max_streams',
sortable: true,
size: 150,
},
{
header: 'Status',
accessorKey: 'status',
size: 150,
size: 100,
cell: ({ cell }) => {
const value = cell.getValue();
if (!value) return null;
@ -486,6 +481,8 @@ const M3UTable = () => {
{
header: 'Status Message',
accessorKey: 'last_message',
grow: true,
minSize: 250,
cell: ({ cell, row }) => {
const value = cell.getValue();
const data = row.original;
@ -569,6 +566,12 @@ const M3UTable = () => {
);
},
},
{
header: 'Max Streams',
accessorKey: 'max_streams',
sortable: true,
size: 125,
},
{
header: 'Updated',
accessorKey: 'updated_at',

View file

@ -72,7 +72,7 @@ const StreamProfiles = () => {
{
header: 'Name',
accessorKey: 'name',
size: 150,
size: 175,
cell: ({ cell }) => (
<div
style={{
@ -88,7 +88,7 @@ const StreamProfiles = () => {
{
header: 'Command',
accessorKey: 'command',
size: 150,
size: 100,
cell: ({ cell }) => (
<div
style={{
@ -104,7 +104,7 @@ const StreamProfiles = () => {
{
header: 'Parameters',
accessorKey: 'parameters',
// size: 200,
grow: true,
cell: ({ cell }) => (
<Tooltip label={cell.getValue()}>
<div

View file

@ -212,6 +212,10 @@ const StreamsTable = ({}) => {
channel_group: '',
m3u_account: '',
});
const [columnSizing, setColumnSizing] = useLocalStorage(
'streams-table-column-sizing',
{}
);
const debouncedFilters = useDebounce(filters, 500, () => {
// Reset to first page whenever filters change to avoid "Invalid page" errors
setPagination((prev) => ({
@ -256,15 +260,16 @@ const StreamsTable = ({}) => {
() => [
{
id: 'actions',
size: tableSize == 'compact' ? 60 : 80,
size: columnSizing.actions || (tableSize == 'compact' ? 60 : 80),
},
{
id: 'select',
size: 30,
size: columnSizing.select || 30,
},
{
header: 'Name',
accessorKey: 'name',
size: columnSizing.name || 200,
cell: ({ getValue }) => (
<Box
style={{
@ -283,6 +288,7 @@ const StreamsTable = ({}) => {
channelGroups[row.channel_group]
? channelGroups[row.channel_group].name
: '',
size: columnSizing.group || 150,
cell: ({ getValue }) => (
<Box
style={{
@ -297,7 +303,7 @@ const StreamsTable = ({}) => {
},
{
id: 'm3u',
size: 150,
size: columnSizing.m3u || 150,
accessorFn: (row) =>
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
cell: ({ getValue }) => (
@ -315,7 +321,7 @@ const StreamsTable = ({}) => {
),
},
],
[channelGroups, playlists]
[channelGroups, playlists, columnSizing, tableSize]
);
/**
@ -652,6 +658,8 @@ const StreamsTable = ({}) => {
filters,
pagination,
sorting,
columnSizing,
setColumnSizing,
onRowSelectionChange: onRowSelectionChange,
manualPagination: true,
manualSorting: true,
@ -855,7 +863,7 @@ const StreamsTable = ({}) => {
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
overflowX: 'auto',
border: 'solid 1px rgb(68,68,68)',
borderRadius: 'var(--mantine-radius-default)',
}}

View file

@ -61,11 +61,13 @@ const UserAgentsTable = () => {
{
header: 'Name',
accessorKey: 'name',
size: 125,
},
{
header: 'User-Agent',
accessorKey: 'user_agent',
enableSorting: false,
grow: true,
cell: ({ cell }) => (
<div
style={{

View file

@ -184,6 +184,7 @@ const UsersTable = () => {
{
header: 'Email',
accessorKey: 'email',
grow: true,
cell: ({ getValue }) => (
<Box
style={{

View file

@ -8,8 +8,8 @@ html {
}
.divTable {
width: 100%;
/* border: 1px solid lightgray; */
/* width: fit-content; */
/* display: flex;
flex-direction: column; */
}
@ -180,3 +180,35 @@ html {
-ms-user-select: text !important;
cursor: text !important;
}
/* Column resize handle styles */
.resizer {
position: absolute;
right: 0;
top: 0;
height: 100%;
width: 5px;
cursor: col-resize;
user-select: none;
touch-action: none;
background-color: transparent;
opacity: 0;
transition: all 0.2s ease;
}
.resizer:hover {
opacity: 1 !important;
background-color: #6b7280 !important;
}
.resizer.isResizing {
opacity: 1 !important;
background-color: #3b82f6 !important;
width: 3px;
}
/* Show resize handle on header hover */
.th:hover .resizer {
opacity: 0.5;
background-color: #6b7280;
}

View file

@ -5,9 +5,22 @@ import { Box } from '@mantine/core';
import { Allotment } from 'allotment';
import { USER_LEVELS } from '../constants';
import useAuthStore from '../store/auth';
import useLocalStorage from '../hooks/useLocalStorage';
const ChannelsPage = () => {
const authUser = useAuthStore((s) => s.user);
const [allotmentSizes, setAllotmentSizes] = useLocalStorage(
'channels-splitter-sizes',
[50, 50]
);
const handleSplitChange = (sizes) => {
setAllotmentSizes(sizes);
};
const handleResize = (sizes) => {
setAllotmentSizes(sizes);
};
if (!authUser.id) {
return <></>;
@ -30,10 +43,12 @@ const ChannelsPage = () => {
}}
>
<Allotment
defaultSizes={[50, 50]}
defaultSizes={allotmentSizes}
style={{ height: '100%', width: '100%', minWidth: '600px' }}
className="custom-allotment"
minSize={100}
onChange={handleSplitChange}
onResize={handleResize}
>
<div style={{ padding: 10, overflowX: 'auto', minWidth: '100px' }}>
<div style={{ minWidth: '600px' }}>