mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-20 16:51:10 +00:00
Merge pull request #886 from Dispatcharr/dynamic-tables
Editable Channel Table
This commit is contained in:
commit
037b6e62e3
11 changed files with 1186 additions and 109 deletions
|
|
@ -16,6 +16,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Added
|
||||
|
||||
- Editable Channel Table Mode:
|
||||
- Added a robust inline editing mode for the channels table, allowing users to quickly edit channel fields (name, number, group, EPG, logo) directly in the table without opening a modal.
|
||||
- EPG and logo columns support searchable dropdowns with instant filtering and keyboard navigation for fast assignment.
|
||||
- Drag-and-drop reordering of channels enabled when unlocked, with persistent order updates. (Closes #333)
|
||||
- Group column uses a searchable dropdown for quick group assignment, matching the UX of EPG and logo selectors.
|
||||
- All changes are saved via API with optimistic UI updates and error handling.
|
||||
- Stats page enhancements: Added "Now Playing" program information for active streams with smart polling that only fetches EPG data when programs are about to change (not on every stats refresh). Features include:
|
||||
- Currently playing program title displayed with live broadcast indicator (green Radio icon)
|
||||
- Expandable program descriptions via chevron button
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from drf_yasg.utils import swagger_auto_schema
|
|||
from drf_yasg import openapi
|
||||
from django.shortcuts import get_object_or_404, get_list_or_404
|
||||
from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.db.models import Count, F
|
||||
from django.db.models import Q
|
||||
import os, json, requests, logging, mimetypes
|
||||
from django.utils.http import http_date
|
||||
|
|
@ -1251,6 +1251,79 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
except Exception as e:
|
||||
return Response({"error": str(e)}, status=400)
|
||||
|
||||
@swagger_auto_schema(
|
||||
method="post",
|
||||
operation_description=(
|
||||
"Reorder a channel by moving it after another channel (or to the start if insert_after_id is null). "
|
||||
"The channel will receive the next whole number after the target channel, and all subsequent "
|
||||
"channels will be renumbered accordingly."
|
||||
),
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
"insert_after_id": openapi.Schema(
|
||||
type=openapi.TYPE_INTEGER,
|
||||
description="ID of the channel to insert after. Use null to move to the beginning.",
|
||||
nullable=True,
|
||||
),
|
||||
},
|
||||
),
|
||||
responses={
|
||||
200: "Channel reordered successfully",
|
||||
404: "Channel not found",
|
||||
400: "Invalid request",
|
||||
},
|
||||
)
|
||||
@action(detail=True, methods=["post"], url_path="reorder")
|
||||
def reorder(self, request, pk=None):
|
||||
"""
|
||||
Reorder a channel by moving it near a target position.
|
||||
Finds the first available channel number without unnecessarily shifting other channels.
|
||||
"""
|
||||
channel = self.get_object()
|
||||
insert_after_id = request.data.get("insert_after_id")
|
||||
old_channel_number = channel.channel_number
|
||||
|
||||
with transaction.atomic():
|
||||
if insert_after_id is None:
|
||||
# Move to the beginning - find first available number starting from 1
|
||||
new_channel_number = 1
|
||||
# Check if 1 is taken, find first gap
|
||||
occupied = set(Channel.objects.exclude(id=channel.id).values_list('channel_number', flat=True))
|
||||
while new_channel_number in occupied:
|
||||
new_channel_number += 1
|
||||
else:
|
||||
try:
|
||||
target_channel = Channel.objects.get(id=insert_after_id)
|
||||
target_number = target_channel.channel_number or 0
|
||||
desired_position = int(target_number) + 1
|
||||
|
||||
# Get all occupied channel numbers (excluding the channel being moved)
|
||||
occupied = set(Channel.objects.exclude(id=channel.id).values_list('channel_number', flat=True))
|
||||
|
||||
# Find the first available number at or after the desired position
|
||||
new_channel_number = desired_position
|
||||
while new_channel_number in occupied:
|
||||
new_channel_number += 1
|
||||
|
||||
except Channel.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Target channel not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
# Update the dragged channel's number
|
||||
channel.channel_number = new_channel_number
|
||||
channel.save(update_fields=['channel_number'])
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": f"Channel {channel.name} moved to position {new_channel_number}",
|
||||
"channel": self.get_serializer(channel).data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@swagger_auto_schema(
|
||||
method="post",
|
||||
operation_description="Associate multiple channels with EPG data without triggering a full refresh",
|
||||
|
|
|
|||
|
|
@ -568,6 +568,24 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async reorderChannel(channelId, insertAfterId) {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/channels/channels/${channelId}/reorder/`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
insert_after_id: insertAfterId,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to reorder channel', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async setChannelEPG(channelId, epgDataId) {
|
||||
try {
|
||||
const response = await request(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,17 @@ import React, {
|
|||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import API from '../../api';
|
||||
|
|
@ -61,9 +72,18 @@ import ChannelTableStreams from './ChannelTableStreams';
|
|||
import LazyLogo from '../LazyLogo';
|
||||
import useLocalStorage from '../../hooks/useLocalStorage';
|
||||
import useEPGsStore from '../../store/epgs';
|
||||
import { useChannelLogoSelection } from '../../hooks/useSmartLogos';
|
||||
import { CustomTable, useTable } from './CustomTable';
|
||||
import ChannelsTableOnboarding from './ChannelsTable/ChannelsTableOnboarding';
|
||||
import ChannelTableHeader from './ChannelsTable/ChannelTableHeader';
|
||||
import {
|
||||
EditableTextCell,
|
||||
EditableNumberCell,
|
||||
EditableGroupCell,
|
||||
EditableEPGCell,
|
||||
EditableLogoCell,
|
||||
} from './ChannelsTable/EditableCell';
|
||||
import { DraggableRow } from './ChannelsTable/DraggableRow';
|
||||
import useWarningsStore from '../../store/warnings';
|
||||
import ConfirmationDialog from '../ConfirmationDialog';
|
||||
import useAuthStore from '../../store/auth';
|
||||
|
|
@ -231,6 +251,10 @@ const ChannelsTable = ({ onReady }) => {
|
|||
const tvgsById = useEPGsStore((s) => s.tvgsById);
|
||||
const epgs = useEPGsStore((s) => s.epgs);
|
||||
const tvgsLoaded = useEPGsStore((s) => s.tvgsLoaded);
|
||||
|
||||
// Get channel logos for logo selection
|
||||
const { logos: channelLogos, ensureLogosLoaded } = useChannelLogoSelection();
|
||||
|
||||
const theme = useMantineTheme();
|
||||
const channelGroups = useChannelsStore((s) => s.channelGroups);
|
||||
const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup);
|
||||
|
|
@ -258,6 +282,7 @@ const ChannelsTable = ({ onReady }) => {
|
|||
const setChannelStreams = useChannelsTableStore((s) => s.setChannelStreams);
|
||||
const allRowIds = useChannelsTableStore((s) => s.allQueryIds);
|
||||
const setAllRowIds = useChannelsTableStore((s) => s.setAllQueryIds);
|
||||
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
|
||||
|
||||
// store/channels
|
||||
const channels = useChannelsStore((s) => s.channels);
|
||||
|
|
@ -320,6 +345,15 @@ const ChannelsTable = ({ onReady }) => {
|
|||
|
||||
const hasFetchedData = useRef(false);
|
||||
|
||||
// Drag-and-drop sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8, // Require 8px movement before dragging starts
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Column sizing state for resizable columns
|
||||
// Store in localStorage but with empty object as default
|
||||
const [columnSizing, setColumnSizing] = useLocalStorage(
|
||||
|
|
@ -746,6 +780,47 @@ const ChannelsTable = ({ onReady }) => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeIndex = rows.findIndex((row) => row.id === active.id);
|
||||
const overIndex = rows.findIndex((row) => row.id === over.id);
|
||||
|
||||
if (activeIndex === -1 || overIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeChannel = rows[activeIndex].original;
|
||||
const overChannel = rows[overIndex].original;
|
||||
|
||||
try {
|
||||
// Optimistically update the local state
|
||||
const reorderedData = [...data];
|
||||
const [movedItem] = reorderedData.splice(activeIndex, 1);
|
||||
reorderedData.splice(overIndex, 0, movedItem);
|
||||
useChannelsTableStore.setState({ channels: reorderedData });
|
||||
|
||||
// Call backend to reorder
|
||||
await API.reorderChannel(
|
||||
activeChannel.id,
|
||||
overIndex > activeIndex
|
||||
? overChannel.id
|
||||
: rows[overIndex - 1]?.original.id || null
|
||||
);
|
||||
|
||||
// Refetch to get updated channel numbers
|
||||
await API.requeryChannels();
|
||||
} catch (error) {
|
||||
// Revert on error
|
||||
console.error('Failed to reorder channel:', error);
|
||||
await API.requeryChannels();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* useEffect
|
||||
*/
|
||||
|
|
@ -817,22 +892,7 @@ const ChannelsTable = ({ onReady }) => {
|
|||
size: columnSizing.channel_number || 40,
|
||||
minSize: 30,
|
||||
maxSize: 100,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
// Format as integer if no decimal component
|
||||
const formattedValue =
|
||||
value !== null && value !== undefined
|
||||
? value === Math.floor(value)
|
||||
? Math.floor(value)
|
||||
: value
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Flex justify="flex-end" style={{ width: '100%' }}>
|
||||
{formattedValue}
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
cell: (props) => <EditableNumberCell {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
|
|
@ -840,73 +900,20 @@ const ChannelsTable = ({ onReady }) => {
|
|||
size: columnSizing.name || 200,
|
||||
minSize: 100,
|
||||
grow: true,
|
||||
cell: ({ getValue }) => (
|
||||
<Box
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{getValue()}
|
||||
</Box>
|
||||
),
|
||||
cell: (props) => <EditableTextCell {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'epg',
|
||||
header: 'EPG',
|
||||
accessorKey: 'epg_data_id',
|
||||
cell: ({ getValue }) => {
|
||||
const epgDataId = getValue();
|
||||
const epgObj = epgDataId ? tvgsById[epgDataId] : null;
|
||||
const tvgName = epgObj?.name;
|
||||
const tvgId = epgObj?.tvg_id;
|
||||
const epgName =
|
||||
epgObj && epgObj.epg_source
|
||||
? epgs[epgObj.epg_source]?.name || epgObj.epg_source
|
||||
: null;
|
||||
|
||||
const tooltip = epgObj
|
||||
? `${epgName ? `EPG Name: ${epgName}\n` : ''}${tvgName ? `TVG Name: ${tvgName}\n` : ''}${tvgId ? `TVG-ID: ${tvgId}` : ''}`.trim()
|
||||
: '';
|
||||
|
||||
// If channel has an EPG assignment but tvgsById hasn't loaded yet, show loading
|
||||
const isEpgDataPending = epgDataId && !epgObj && !tvgsLoaded;
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{epgObj && epgName ? (
|
||||
<Tooltip
|
||||
label={
|
||||
<span style={{ whiteSpace: 'pre-line' }}>{tooltip}</span>
|
||||
}
|
||||
withArrow
|
||||
position="top"
|
||||
>
|
||||
<span>
|
||||
{epgObj.epg_source} - {tvgId}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : epgObj ? (
|
||||
<span>{epgObj.name}</span>
|
||||
) : isEpgDataPending ? (
|
||||
<Skeleton
|
||||
height={14}
|
||||
width={(columnSizing.epg || 200) * 0.7}
|
||||
style={{ borderRadius: 4 }}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ color: '#888' }}>Not Assigned</span>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
cell: (props) => (
|
||||
<EditableEPGCell
|
||||
{...props}
|
||||
tvgsById={tvgsById}
|
||||
epgs={epgs}
|
||||
tvgsLoaded={tvgsLoaded}
|
||||
/>
|
||||
),
|
||||
size: columnSizing.epg || 200,
|
||||
minSize: 80,
|
||||
},
|
||||
|
|
@ -916,16 +923,8 @@ const ChannelsTable = ({ onReady }) => {
|
|||
channelGroups[row.channel_group_id]
|
||||
? channelGroups[row.channel_group_id].name
|
||||
: '',
|
||||
cell: ({ getValue }) => (
|
||||
<Box
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{getValue()}
|
||||
</Box>
|
||||
cell: (props) => (
|
||||
<EditableGroupCell {...props} channelGroups={channelGroups} />
|
||||
),
|
||||
size: columnSizing.channel_group || 175,
|
||||
minSize: 100,
|
||||
|
|
@ -941,19 +940,21 @@ const ChannelsTable = ({ onReady }) => {
|
|||
maxSize: 120,
|
||||
enableResizing: false,
|
||||
header: '',
|
||||
cell: ({ getValue }) => {
|
||||
const logoId = getValue();
|
||||
|
||||
return (
|
||||
<Center style={{ width: '100%' }}>
|
||||
<LazyLogo
|
||||
logoId={logoId}
|
||||
alt="logo"
|
||||
style={{ maxHeight: 18, maxWidth: 55 }}
|
||||
/>
|
||||
</Center>
|
||||
);
|
||||
},
|
||||
cell: (props) => (
|
||||
<Box
|
||||
onClick={() => {
|
||||
// Ensure logos are loaded when user tries to edit
|
||||
ensureLogosLoaded();
|
||||
}}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
<EditableLogoCell
|
||||
{...props}
|
||||
channelLogos={channelLogos}
|
||||
LazyLogo={LazyLogo}
|
||||
/>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
|
|
@ -1099,6 +1100,7 @@ const ChannelsTable = ({ onReady }) => {
|
|||
manualSorting: true,
|
||||
manualFiltering: true,
|
||||
enableRowSelection: true,
|
||||
enableDragDrop: true,
|
||||
onRowSelectionChange: onRowSelectionChange,
|
||||
state: {
|
||||
pagination,
|
||||
|
|
@ -1463,7 +1465,18 @@ const ChannelsTable = ({ onReady }) => {
|
|||
borderRadius: 'var(--mantine-radius-default)',
|
||||
}}
|
||||
>
|
||||
<CustomTable table={table} />
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={rows.map((row) => row.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<CustomTable table={table} />
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
|
|
|
|||
|
|
@ -30,10 +30,13 @@ import {
|
|||
SquareCheck,
|
||||
Pin,
|
||||
PinOff,
|
||||
Lock,
|
||||
LockOpen,
|
||||
} from 'lucide-react';
|
||||
import API from '../../../api';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import useChannelsStore from '../../../store/channels';
|
||||
import useChannelsTableStore from '../../../store/channelsTable';
|
||||
import useAuthStore from '../../../store/auth';
|
||||
import { USER_LEVELS } from '../../../constants';
|
||||
import AssignChannelNumbersForm from '../../forms/AssignChannelNumbers';
|
||||
|
|
@ -134,6 +137,8 @@ const ChannelTableHeader = ({
|
|||
const authUser = useAuthStore((s) => s.user);
|
||||
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
|
||||
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
|
||||
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
|
||||
const setIsUnlocked = useChannelsTableStore((s) => s.setIsUnlocked);
|
||||
|
||||
const headerPinned = table?.headerPinned ?? false;
|
||||
const setHeaderPinned = table?.setHeaderPinned || (() => {});
|
||||
|
|
@ -239,6 +244,10 @@ const ChannelTableHeader = ({
|
|||
setHeaderPinned(!headerPinned);
|
||||
};
|
||||
|
||||
const toggleUnlock = () => {
|
||||
setIsUnlocked(!isUnlocked);
|
||||
};
|
||||
|
||||
return (
|
||||
<Group justify="space-between">
|
||||
<Group gap={5} style={{ paddingLeft: 10 }}>
|
||||
|
|
@ -258,6 +267,23 @@ const ChannelTableHeader = ({
|
|||
<Tooltip label="Create Profile">
|
||||
<CreateProfilePopover />
|
||||
</Tooltip>
|
||||
|
||||
{isUnlocked && (
|
||||
<Text
|
||||
size="xs"
|
||||
c="yellow.5"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingLeft: 10,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<LockOpen size={14} />
|
||||
Editing Mode
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Box
|
||||
|
|
@ -367,6 +393,18 @@ const ChannelTableHeader = ({
|
|||
</Text>
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
isUnlocked ? <LockOpen size={18} /> : <Lock size={18} />
|
||||
}
|
||||
onClick={toggleUnlock}
|
||||
disabled={authUser.user_level != USER_LEVELS.ADMIN}
|
||||
>
|
||||
<Text size="xs">
|
||||
{isUnlocked ? 'Lock Table' : 'Unlock for Editing'}
|
||||
</Text>
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import React from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import { Box } from '@mantine/core';
|
||||
import useChannelsTableStore from '../../../store/channelsTable';
|
||||
|
||||
export const DraggableRow = ({ row, children }) => {
|
||||
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: row.id,
|
||||
disabled: !isUnlocked,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
position: 'relative',
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="tr">
|
||||
{isUnlocked && (
|
||||
<Box
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 24,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderRight: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<GripVertical size={16} opacity={0.5} />
|
||||
</Box>
|
||||
)}
|
||||
<div style={{ paddingLeft: isUnlocked ? 28 : 0, width: '100%' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
781
frontend/src/components/tables/ChannelsTable/EditableCell.jsx
Normal file
781
frontend/src/components/tables/ChannelsTable/EditableCell.jsx
Normal file
|
|
@ -0,0 +1,781 @@
|
|||
import React, {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextInput,
|
||||
Select,
|
||||
NumberInput,
|
||||
Tooltip,
|
||||
Center,
|
||||
Skeleton,
|
||||
} from '@mantine/core';
|
||||
import API from '../../../api';
|
||||
import useChannelsTableStore from '../../../store/channelsTable';
|
||||
|
||||
// Editable text cell
|
||||
export const EditableTextCell = ({ row, column, getValue }) => {
|
||||
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
|
||||
const initialValue = getValue() || '';
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const previousValue = useRef(initialValue);
|
||||
const isMounted = useRef(false);
|
||||
const debounceTimer = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const currentValue = getValue() || '';
|
||||
if (!isFocused && currentValue !== previousValue.current) {
|
||||
setValue(currentValue);
|
||||
previousValue.current = currentValue;
|
||||
}
|
||||
}, [getValue, isFocused]);
|
||||
|
||||
const saveValue = useCallback(
|
||||
async (newValue) => {
|
||||
// Don't save if not mounted, not unlocked, or value hasn't changed
|
||||
if (
|
||||
!isMounted.current ||
|
||||
!isUnlocked ||
|
||||
newValue === previousValue.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await API.updateChannel({
|
||||
id: row.original.id,
|
||||
[column.id]: newValue || null,
|
||||
});
|
||||
previousValue.current = newValue;
|
||||
|
||||
// Update the table store to reflect the change
|
||||
if (response) {
|
||||
useChannelsTableStore.getState().updateChannel(response);
|
||||
}
|
||||
} catch (error) {
|
||||
// Revert on error
|
||||
setValue(previousValue.current || '');
|
||||
}
|
||||
},
|
||||
[row.original.id, column.id, isUnlocked]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
const timer = debounceTimer.current;
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChange = (e) => {
|
||||
if (!isUnlocked) return;
|
||||
const newValue = e.currentTarget.value;
|
||||
setValue(newValue);
|
||||
|
||||
// Clear existing timer
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
|
||||
// Set new timer
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
saveValue(newValue);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
if (isUnlocked) {
|
||||
saveValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (isUnlocked) {
|
||||
setIsFocused(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isUnlocked || !isFocused) {
|
||||
return (
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
cursor: isUnlocked ? 'text' : 'default',
|
||||
padding: '0 4px',
|
||||
...(isUnlocked && {
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
autoFocus
|
||||
size="xs"
|
||||
variant="unstyled"
|
||||
styles={{
|
||||
root: {
|
||||
width: '100%',
|
||||
},
|
||||
input: {
|
||||
minHeight: 'unset',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
padding: '0 4px',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Editable number cell
|
||||
export const EditableNumberCell = ({ row, column, getValue }) => {
|
||||
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
|
||||
const initialValue = getValue();
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const previousValue = useRef(initialValue);
|
||||
const isMounted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const currentValue = getValue();
|
||||
if (!isFocused && currentValue !== previousValue.current) {
|
||||
setValue(currentValue);
|
||||
previousValue.current = currentValue;
|
||||
}
|
||||
}, [getValue, isFocused]);
|
||||
|
||||
const saveValue = useCallback(
|
||||
async (newValue) => {
|
||||
// Don't save if not mounted, not unlocked, or value hasn't changed
|
||||
if (
|
||||
!isMounted.current ||
|
||||
!isUnlocked ||
|
||||
newValue === previousValue.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For channel_number, don't save null/undefined values
|
||||
if (
|
||||
column.id === 'channel_number' &&
|
||||
(newValue === null || newValue === undefined || newValue === '')
|
||||
) {
|
||||
// Revert to previous value
|
||||
setValue(previousValue.current);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await API.updateChannel({
|
||||
id: row.original.id,
|
||||
[column.id]: newValue,
|
||||
});
|
||||
previousValue.current = newValue;
|
||||
|
||||
// Update the table store to reflect the change
|
||||
if (response) {
|
||||
useChannelsTableStore.getState().updateChannel(response);
|
||||
|
||||
// If channel_number was changed, refetch to reorder the table
|
||||
if (column.id === 'channel_number') {
|
||||
await API.requeryChannels();
|
||||
// Exit edit mode after resorting to avoid confusion
|
||||
setIsFocused(false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Revert on error
|
||||
setValue(previousValue.current);
|
||||
}
|
||||
},
|
||||
[row.original.id, column.id, isUnlocked]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChange = (newValue) => {
|
||||
if (!isUnlocked) return;
|
||||
setValue(newValue);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
if (isUnlocked) {
|
||||
saveValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (isUnlocked) {
|
||||
setIsFocused(true);
|
||||
}
|
||||
};
|
||||
|
||||
const formattedValue =
|
||||
value !== null && value !== undefined
|
||||
? value === Math.floor(value)
|
||||
? Math.floor(value)
|
||||
: value
|
||||
: '';
|
||||
|
||||
if (!isUnlocked || !isFocused) {
|
||||
return (
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
width: '100%',
|
||||
cursor: isUnlocked ? 'text' : 'default',
|
||||
padding: '0 4px',
|
||||
...(isUnlocked && {
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{formattedValue}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NumberInput
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
autoFocus
|
||||
size="xs"
|
||||
variant="unstyled"
|
||||
hideControls
|
||||
styles={{
|
||||
input: {
|
||||
minHeight: 'unset',
|
||||
height: '100%',
|
||||
padding: '0 4px',
|
||||
textAlign: 'right',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Editable select cell for groups
|
||||
export const EditableGroupCell = ({ row, getValue, channelGroups }) => {
|
||||
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
|
||||
const groupId = row.original.channel_group_id;
|
||||
const groupName = channelGroups[groupId]?.name || '';
|
||||
const previousGroupId = useRef(groupId);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
const saveValue = useCallback(
|
||||
async (newGroupId) => {
|
||||
// Don't save if not unlocked or value hasn't changed
|
||||
if (
|
||||
!isUnlocked ||
|
||||
String(newGroupId) === String(previousGroupId.current)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await API.updateChannel({
|
||||
id: row.original.id,
|
||||
channel_group_id: parseInt(newGroupId, 10),
|
||||
});
|
||||
previousGroupId.current = newGroupId;
|
||||
|
||||
// Update the table store to reflect the change
|
||||
if (response) {
|
||||
useChannelsTableStore.getState().updateChannel(response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update channel group:', error);
|
||||
}
|
||||
},
|
||||
[row.original.id, isUnlocked]
|
||||
);
|
||||
|
||||
const handleClick = () => {
|
||||
if (isUnlocked) {
|
||||
setIsFocused(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (newGroupId) => {
|
||||
saveValue(newGroupId);
|
||||
setIsFocused(false);
|
||||
setSearchValue('');
|
||||
};
|
||||
|
||||
const groupOptions = Object.values(channelGroups).map((group) => ({
|
||||
value: String(group.id),
|
||||
label: group.name,
|
||||
}));
|
||||
|
||||
if (!isUnlocked || !isFocused) {
|
||||
return (
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
cursor: isUnlocked ? 'pointer' : 'default',
|
||||
padding: '0 4px',
|
||||
...(isUnlocked && {
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{groupName}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={null}
|
||||
onChange={handleChange}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
data={groupOptions}
|
||||
size="xs"
|
||||
variant="unstyled"
|
||||
searchable
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
autoFocus
|
||||
placeholder={groupName}
|
||||
nothingFoundMessage="No groups found"
|
||||
styles={{
|
||||
input: {
|
||||
minHeight: 'unset',
|
||||
height: '100%',
|
||||
padding: '0 4px',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Editable select cell for EPG
|
||||
export const EditableEPGCell = ({
|
||||
row,
|
||||
getValue,
|
||||
tvgsById,
|
||||
epgs,
|
||||
tvgsLoaded,
|
||||
}) => {
|
||||
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
|
||||
const epgDataId = getValue();
|
||||
const previousEpgDataId = useRef(epgDataId);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
// Format display text
|
||||
const epgObj = epgDataId ? tvgsById[epgDataId] : null;
|
||||
const tvgId = epgObj?.tvg_id;
|
||||
const epgName =
|
||||
epgObj && epgObj.epg_source
|
||||
? epgs[epgObj.epg_source]?.name || epgObj.epg_source
|
||||
: null;
|
||||
const displayText =
|
||||
epgObj && epgName
|
||||
? `${epgObj.epg_source} - ${tvgId}`
|
||||
: epgObj
|
||||
? epgObj.name
|
||||
: 'Not Assigned';
|
||||
|
||||
// Show skeleton while EPG data is loading (only if channel has an EPG assignment)
|
||||
const isEpgDataPending = epgDataId && !epgObj && !tvgsLoaded;
|
||||
|
||||
const saveValue = useCallback(
|
||||
async (newEpgDataId) => {
|
||||
// Don't save if not unlocked or value hasn't changed
|
||||
if (
|
||||
!isUnlocked ||
|
||||
String(newEpgDataId) === String(previousEpgDataId.current)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await API.updateChannel({
|
||||
id: row.original.id,
|
||||
epg_data_id:
|
||||
newEpgDataId === 'null' ? null : parseInt(newEpgDataId, 10),
|
||||
});
|
||||
previousEpgDataId.current = newEpgDataId;
|
||||
|
||||
// Update the table store to reflect the change
|
||||
if (response) {
|
||||
useChannelsTableStore.getState().updateChannel(response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update EPG:', error);
|
||||
}
|
||||
},
|
||||
[row.original.id, isUnlocked]
|
||||
);
|
||||
|
||||
const handleClick = () => {
|
||||
if (isUnlocked) {
|
||||
setSearchValue(''); // Start with empty search
|
||||
setIsFocused(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (newEpgDataId) => {
|
||||
saveValue(newEpgDataId);
|
||||
setSearchValue('');
|
||||
setIsFocused(false);
|
||||
};
|
||||
|
||||
// Build EPG options
|
||||
const epgOptions = useMemo(() => {
|
||||
const options = [{ value: 'null', label: 'Not Assigned' }];
|
||||
|
||||
// Convert tvgsById to an array and sort by EPG source name, then by tvg_id
|
||||
const tvgsArray = Object.values(tvgsById);
|
||||
tvgsArray.sort((a, b) => {
|
||||
const aEpgName =
|
||||
a.epg_source && epgs[a.epg_source]
|
||||
? epgs[a.epg_source].name
|
||||
: a.epg_source || '';
|
||||
const bEpgName =
|
||||
b.epg_source && epgs[b.epg_source]
|
||||
? epgs[b.epg_source].name
|
||||
: b.epg_source || '';
|
||||
const epgCompare = aEpgName.localeCompare(bEpgName);
|
||||
if (epgCompare !== 0) return epgCompare;
|
||||
// Secondary sort by tvg_id
|
||||
return (a.tvg_id || '').localeCompare(b.tvg_id || '');
|
||||
});
|
||||
|
||||
tvgsArray.forEach((tvg) => {
|
||||
const epgSourceName =
|
||||
tvg.epg_source && epgs[tvg.epg_source]
|
||||
? epgs[tvg.epg_source].name
|
||||
: tvg.epg_source;
|
||||
const tvgName = tvg.name;
|
||||
// Create a comprehensive label: "EPG Name | TVG-ID | TVG Name"
|
||||
let label;
|
||||
if (epgSourceName && tvg.tvg_id) {
|
||||
label = `${epgSourceName} | ${tvg.tvg_id}`;
|
||||
if (tvgName && tvgName !== tvg.tvg_id) {
|
||||
label += ` | ${tvgName}`;
|
||||
}
|
||||
} else if (tvgName) {
|
||||
label = tvgName;
|
||||
} else {
|
||||
label = `ID: ${tvg.id}`;
|
||||
}
|
||||
|
||||
options.push({
|
||||
value: String(tvg.id),
|
||||
label: label,
|
||||
});
|
||||
});
|
||||
|
||||
return options;
|
||||
}, [tvgsById, epgs]);
|
||||
|
||||
// Build tooltip content
|
||||
const tooltip = epgObj
|
||||
? `${epgName ? `EPG Name: ${epgName}\n` : ''}${epgObj.name ? `TVG Name: ${epgObj.name}\n` : ''}${tvgId ? `TVG-ID: ${tvgId}` : ''}`.trim()
|
||||
: '';
|
||||
|
||||
if (!isUnlocked || !isFocused) {
|
||||
// If loading EPG data, show skeleton
|
||||
if (isEpgDataPending) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 4px',
|
||||
}}
|
||||
>
|
||||
<Skeleton
|
||||
height={18}
|
||||
width="70%"
|
||||
visible={true}
|
||||
animate={true}
|
||||
style={{ borderRadius: 4 }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
// Otherwise, show the normal EPG assignment cell
|
||||
return (
|
||||
<Tooltip
|
||||
label={<span style={{ whiteSpace: 'pre-line' }}>{tooltip}</span>}
|
||||
withArrow
|
||||
position="top"
|
||||
disabled={!epgObj}
|
||||
openDelay={500}
|
||||
>
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
cursor: isUnlocked ? 'pointer' : 'default',
|
||||
padding: '0 4px',
|
||||
...(isUnlocked && {
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{displayText}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={null}
|
||||
onChange={handleChange}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
data={epgOptions}
|
||||
size="xs"
|
||||
variant="unstyled"
|
||||
searchable
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
autoFocus
|
||||
placeholder={displayText}
|
||||
nothingFoundMessage="No EPG found"
|
||||
styles={{
|
||||
input: {
|
||||
minHeight: 'unset',
|
||||
height: '100%',
|
||||
padding: '0 4px',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Editable cell for Logo selection
|
||||
export const EditableLogoCell = ({ row, getValue, channelLogos, LazyLogo }) => {
|
||||
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
|
||||
const logoId = getValue();
|
||||
const previousLogoId = useRef(logoId);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
const saveValue = useCallback(
|
||||
async (newLogoId) => {
|
||||
// Don't save if not unlocked or value hasn't changed
|
||||
if (!isUnlocked || String(newLogoId) === String(previousLogoId.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await API.updateChannel({
|
||||
id: row.original.id,
|
||||
logo_id: newLogoId === 'null' ? null : parseInt(newLogoId, 10),
|
||||
});
|
||||
previousLogoId.current = newLogoId;
|
||||
|
||||
// Update the table store to reflect the change
|
||||
if (response) {
|
||||
useChannelsTableStore.getState().updateChannel(response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update logo:', error);
|
||||
}
|
||||
},
|
||||
[row.original.id, isUnlocked]
|
||||
);
|
||||
|
||||
const handleClick = () => {
|
||||
if (isUnlocked) {
|
||||
setSearchValue('');
|
||||
setIsFocused(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (newLogoId) => {
|
||||
saveValue(newLogoId);
|
||||
setSearchValue('');
|
||||
setIsFocused(false);
|
||||
};
|
||||
|
||||
// Build logo options with logo data
|
||||
const logoOptions = useMemo(() => {
|
||||
const options = [
|
||||
{
|
||||
value: 'null',
|
||||
label: 'Default',
|
||||
logo: null,
|
||||
},
|
||||
];
|
||||
|
||||
// Convert channelLogos object to array and sort by name
|
||||
const logosArray = Object.values(channelLogos);
|
||||
logosArray.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
|
||||
logosArray.forEach((logo) => {
|
||||
options.push({
|
||||
value: String(logo.id),
|
||||
label: logo.name || `Logo ${logo.id}`,
|
||||
logo: logo,
|
||||
});
|
||||
});
|
||||
|
||||
return options;
|
||||
}, [channelLogos]);
|
||||
|
||||
// Get display text for the current logo
|
||||
const displayText =
|
||||
logoId && channelLogos[logoId] ? channelLogos[logoId].name : 'Default';
|
||||
|
||||
// Custom option renderer to show logo images
|
||||
const renderOption = ({ option }) => {
|
||||
if (option.value === 'null') {
|
||||
return <div style={{ padding: '8px 12px' }}>Default</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '8px 12px',
|
||||
minHeight: '50px',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={option.logo?.cache_url}
|
||||
alt={option.label}
|
||||
style={{
|
||||
height: '40px',
|
||||
maxWidth: '100px',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: '13px' }}>{option.label}</span>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
if (!isUnlocked || !isFocused) {
|
||||
// When not editing, show the logo image
|
||||
return (
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
cursor: isUnlocked ? 'pointer' : 'default',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
...(isUnlocked && {
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{LazyLogo && (
|
||||
<LazyLogo
|
||||
logoId={logoId}
|
||||
alt="logo"
|
||||
style={{ maxHeight: 18, maxWidth: 55 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
value={null}
|
||||
onChange={handleChange}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
data={logoOptions}
|
||||
size="xs"
|
||||
variant="unstyled"
|
||||
searchable
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
autoFocus
|
||||
placeholder={displayText}
|
||||
nothingFoundMessage="No logos found"
|
||||
renderOption={renderOption}
|
||||
maxDropdownHeight={400}
|
||||
comboboxProps={{ width: 250, position: 'bottom-start' }}
|
||||
styles={{
|
||||
input: {
|
||||
minHeight: 'unset',
|
||||
height: '100%',
|
||||
padding: '0 4px',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
option: {
|
||||
padding: 0,
|
||||
},
|
||||
dropdown: {
|
||||
minWidth: '250px',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
import { Box, Flex } from '@mantine/core';
|
||||
import CustomTableHeader from './CustomTableHeader';
|
||||
import { useCallback, useState, useRef, useMemo } from 'react';
|
||||
import { flexRender } from '@tanstack/react-table';
|
||||
import table from '../../../helpers/table';
|
||||
import CustomTableBody from './CustomTableBody';
|
||||
|
||||
const CustomTable = ({ table }) => {
|
||||
|
|
@ -46,6 +44,7 @@ const CustomTable = ({ table }) => {
|
|||
selectedTableIds={table.selectedTableIds}
|
||||
tableCellProps={table.tableCellProps}
|
||||
headerPinned={table.headerPinned}
|
||||
enableDragDrop={table.enableDragDrop}
|
||||
/>
|
||||
<CustomTableBody
|
||||
getRowModel={table.getRowModel}
|
||||
|
|
@ -54,9 +53,10 @@ const CustomTable = ({ table }) => {
|
|||
expandedRowRenderer={table.expandedRowRenderer}
|
||||
renderBodyCell={table.renderBodyCell}
|
||||
getExpandedRowHeight={table.getExpandedRowHeight}
|
||||
getRowStyles={table.getRowStyles} // Pass the getRowStyles function
|
||||
getRowStyles={table.getRowStyles}
|
||||
tableBodyProps={table.tableBodyProps}
|
||||
tableCellProps={table.tableCellProps}
|
||||
enableDragDrop={table.enableDragDrop}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@ import { VariableSizeList as List } from 'react-window';
|
|||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { useMemo } from 'react';
|
||||
import table from '../../../helpers/table';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import useChannelsTableStore from '../../../store/channelsTable';
|
||||
|
||||
const CustomTableBody = ({
|
||||
getRowModel,
|
||||
|
|
@ -10,9 +14,10 @@ const CustomTableBody = ({
|
|||
expandedRowRenderer,
|
||||
renderBodyCell,
|
||||
getExpandedRowHeight,
|
||||
getRowStyles, // Add this prop to receive row styles
|
||||
getRowStyles,
|
||||
tableBodyProps,
|
||||
tableCellProps,
|
||||
enableDragDrop = false,
|
||||
}) => {
|
||||
const renderExpandedRow = (row) => {
|
||||
if (expandedRowRenderer) {
|
||||
|
|
@ -101,7 +106,12 @@ const CustomTableBody = ({
|
|||
delete customRowStyles.className; // Remove from object so it doesn't get applied as inline style
|
||||
|
||||
return (
|
||||
<Box style={style} key={`row-${row.id}`}>
|
||||
<DraggableRowWrapper
|
||||
row={row}
|
||||
key={`row-${row.id}`}
|
||||
style={style}
|
||||
enableDragDrop={enableDragDrop}
|
||||
>
|
||||
<Box
|
||||
key={`tr-${row.id}`}
|
||||
className={`tr ${index % 2 == 0 ? 'tr-even' : 'tr-odd'} ${customClassName}`}
|
||||
|
|
@ -145,11 +155,72 @@ const CustomTableBody = ({
|
|||
})}
|
||||
</Box>
|
||||
{expandedRowIds.includes(row.original.id) && renderExpandedRow(row)}
|
||||
</Box>
|
||||
</DraggableRowWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
return renderTableBodyContents();
|
||||
};
|
||||
|
||||
const DraggableRowWrapper = ({
|
||||
row,
|
||||
children,
|
||||
style = {},
|
||||
enableDragDrop = false,
|
||||
}) => {
|
||||
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
|
||||
const shouldEnableDrag = enableDragDrop && isUnlocked;
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: row.id,
|
||||
disabled: !shouldEnableDrag,
|
||||
});
|
||||
|
||||
const dragStyle = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
position: 'relative',
|
||||
...style,
|
||||
};
|
||||
|
||||
return (
|
||||
<Box ref={setNodeRef} style={dragStyle}>
|
||||
{shouldEnableDrag && (
|
||||
<Box
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 24,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderRight: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.05)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<GripVertical size={16} opacity={0.5} />
|
||||
</Box>
|
||||
)}
|
||||
<div style={{ paddingLeft: shouldEnableDrag ? 28 : 0, width: '100%' }}>
|
||||
{children}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomTableBody;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Box, Center, Checkbox, Flex } from '@mantine/core';
|
|||
import { flexRender } from '@tanstack/react-table';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import MultiSelectHeaderWrapper from './MultiSelectHeaderWrapper';
|
||||
import useChannelsTableStore from '../../../store/channelsTable';
|
||||
|
||||
const CustomTableHeader = ({
|
||||
getHeaderGroups,
|
||||
|
|
@ -11,7 +12,10 @@ const CustomTableHeader = ({
|
|||
onSelectAllChange,
|
||||
tableCellProps,
|
||||
headerPinned = true,
|
||||
enableDragDrop = false,
|
||||
}) => {
|
||||
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
|
||||
const shouldEnableDrag = enableDragDrop && isUnlocked;
|
||||
const renderHeaderCell = (header) => {
|
||||
let content;
|
||||
|
||||
|
|
@ -95,6 +99,7 @@ const CustomTableHeader = ({
|
|||
display: 'flex',
|
||||
width: '100%',
|
||||
minWidth: '100%', // Force full width
|
||||
paddingLeft: shouldEnableDrag ? 28 : 0,
|
||||
}}
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const useChannelsTableStore = create((set, get) => ({
|
|||
},
|
||||
selectedChannelIds: [],
|
||||
allQueryIds: [],
|
||||
isUnlocked: false,
|
||||
|
||||
queryChannels: ({ results, count }, params) => {
|
||||
set((state) => {
|
||||
|
|
@ -54,6 +55,18 @@ const useChannelsTableStore = create((set, get) => ({
|
|||
sorting,
|
||||
}));
|
||||
},
|
||||
|
||||
setIsUnlocked: (isUnlocked) => {
|
||||
set({ isUnlocked });
|
||||
},
|
||||
|
||||
updateChannel: (updatedChannel) => {
|
||||
set((state) => ({
|
||||
channels: state.channels.map((channel) =>
|
||||
channel.id === updatedChannel.id ? updatedChannel : channel
|
||||
),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
|
||||
export default useChannelsTableStore;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue