| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449 |
- import { useState, useMemo, useEffect } from 'react'
- import { useQuery } from '@tanstack/react-query'
- import { getRouteApi } from '@tanstack/react-router'
- import {
- flexRender,
- getCoreRowModel,
- useReactTable,
- getExpandedRowModel,
- type SortingState,
- type VisibilityState,
- type ExpandedState,
- type Row,
- } from '@tanstack/react-table'
- import { useDebounce, useMediaQuery } from '@/hooks'
- import { useTranslation } from 'react-i18next'
- import { getLobeIcon } from '@/lib/lobe-icon'
- import { cn } from '@/lib/utils'
- import { useTableUrlState } from '@/hooks/use-table-url-state'
- import { Input } from '@/components/ui/input'
- import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
- } from '@/components/ui/table'
- import {
- DISABLED_ROW_DESKTOP,
- DISABLED_ROW_MOBILE,
- DataTableToolbar,
- TableSkeleton,
- TableEmpty,
- MobileCardList,
- } from '@/components/data-table'
- import { DataTablePagination } from '@/components/data-table/pagination'
- import { PageFooterPortal } from '@/components/layout'
- import { getChannels, searchChannels, getGroups } from '../api'
- import {
- DEFAULT_PAGE_SIZE,
- CHANNEL_STATUS,
- CHANNEL_STATUS_OPTIONS,
- } from '../constants'
- import {
- channelsQueryKeys,
- aggregateChannelsByTag,
- isTagAggregateRow,
- getChannelTypeIcon,
- getChannelTypeLabel,
- } from '../lib'
- import type { Channel } from '../types'
- import { useChannelsColumns } from './channels-columns'
- import { useChannels } from './channels-provider'
- import { DataTableBulkActions } from './data-table-bulk-actions'
- const route = getRouteApi('/_authenticated/channels/')
- function isDisabledChannelRow(channel: Channel) {
- return (
- !isTagAggregateRow(channel) && channel.status !== CHANNEL_STATUS.ENABLED
- )
- }
- export function ChannelsTable() {
- const { t } = useTranslation()
- const { enableTagMode, idSort } = useChannels()
- const isMobile = useMediaQuery('(max-width: 640px)')
- // Table state
- const [sorting, setSorting] = useState<SortingState>([])
- const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
- models: false,
- tag: false,
- })
- const [rowSelection, setRowSelection] = useState({})
- const [expanded, setExpanded] = useState<ExpandedState>({})
- // URL state management
- const {
- globalFilter,
- onGlobalFilterChange,
- columnFilters,
- onColumnFiltersChange,
- pagination,
- onPaginationChange,
- ensurePageInRange,
- } = useTableUrlState({
- search: route.useSearch(),
- navigate: route.useNavigate(),
- pagination: { defaultPage: 1, defaultPageSize: DEFAULT_PAGE_SIZE },
- globalFilter: { enabled: true, key: 'filter' },
- columnFilters: [
- { columnId: 'status', searchKey: 'status', type: 'array' },
- { columnId: 'type', searchKey: 'type', type: 'array' },
- { columnId: 'group', searchKey: 'group', type: 'array' },
- { columnId: 'model', searchKey: 'model', type: 'string' },
- ],
- })
- // Extract filters from column filters
- const statusFilter =
- (columnFilters.find((f) => f.id === 'status')?.value as string[]) || []
- const typeFilter =
- (columnFilters.find((f) => f.id === 'type')?.value as string[]) || []
- const groupFilter =
- (columnFilters.find((f) => f.id === 'group')?.value as string[]) || []
- const modelFilterFromUrl =
- (columnFilters.find((f) => f.id === 'model')?.value as string) || ''
- // Local state for immediate input feedback
- const [modelFilterInput, setModelFilterInput] = useState(modelFilterFromUrl)
- const debouncedModelFilter = useDebounce(modelFilterInput, 500)
- // Sync local input with URL when URL changes (e.g., from back/forward navigation)
- useEffect(() => {
- setModelFilterInput(modelFilterFromUrl)
- }, [modelFilterFromUrl])
- // Update URL when debounced value changes
- useEffect(() => {
- if (debouncedModelFilter !== modelFilterFromUrl) {
- onColumnFiltersChange((prev) => {
- const filtered = prev.filter((f) => f.id !== 'model')
- return debouncedModelFilter
- ? [...filtered, { id: 'model', value: debouncedModelFilter }]
- : filtered
- })
- }
- }, [debouncedModelFilter, modelFilterFromUrl, onColumnFiltersChange])
- const modelFilter = modelFilterFromUrl
- // Determine whether to use search or regular list API
- const shouldSearch = Boolean(globalFilter?.trim() || modelFilter.trim())
- // Fetch groups for filter
- const { data: groupsData } = useQuery({
- queryKey: ['groups'],
- queryFn: getGroups,
- })
- const groupOptions = useMemo(
- () =>
- (groupsData?.data || []).map((g) => ({
- label: g,
- value: g,
- })),
- [groupsData]
- )
- // Fetch channels data
- // eslint-disable-next-line @tanstack/query/exhaustive-deps
- const { data, isLoading, isFetching } = useQuery({
- queryKey: channelsQueryKeys.list({
- keyword: globalFilter,
- model: modelFilter,
- group:
- groupFilter.length > 0 && !groupFilter.includes('all')
- ? groupFilter[0]
- : undefined,
- status:
- statusFilter.length > 0 && !statusFilter.includes('all')
- ? statusFilter[0]
- : undefined,
- type:
- typeFilter.length > 0 && !typeFilter.includes('all')
- ? Number(typeFilter[0])
- : undefined,
- tag_mode: enableTagMode,
- id_sort: idSort,
- p: pagination.pageIndex + 1,
- page_size: pagination.pageSize,
- }),
- queryFn: async () => {
- if (shouldSearch) {
- return searchChannels({
- keyword: globalFilter,
- model: modelFilter,
- group:
- groupFilter.length > 0 && !groupFilter.includes('all')
- ? groupFilter[0]
- : undefined,
- status:
- statusFilter.length > 0 && !statusFilter.includes('all')
- ? statusFilter[0]
- : undefined,
- type:
- typeFilter.length > 0 && !typeFilter.includes('all')
- ? Number(typeFilter[0])
- : undefined,
- tag_mode: enableTagMode,
- id_sort: idSort,
- p: pagination.pageIndex + 1,
- page_size: pagination.pageSize,
- })
- } else {
- return getChannels({
- group:
- groupFilter.length > 0 && !groupFilter.includes('all')
- ? groupFilter[0]
- : undefined,
- status:
- statusFilter.length > 0 && !statusFilter.includes('all')
- ? statusFilter[0]
- : undefined,
- type:
- typeFilter.length > 0 && !typeFilter.includes('all')
- ? Number(typeFilter[0])
- : undefined,
- tag_mode: enableTagMode,
- id_sort: idSort,
- p: pagination.pageIndex + 1,
- page_size: pagination.pageSize,
- })
- }
- },
- placeholderData: (previousData) => previousData,
- })
- // Apply tag aggregation if tag mode is enabled
- const channels = useMemo(() => {
- const rawChannels = data?.data?.items || []
- if (enableTagMode && rawChannels.length > 0) {
- return aggregateChannelsByTag(rawChannels)
- }
- return rawChannels
- }, [data, enableTagMode])
- const totalCount = data?.data?.total || 0
- const typeCounts = data?.data?.type_counts
- // Columns configuration
- const columns = useChannelsColumns()
- // React Table instance
- const table = useReactTable({
- data: channels,
- columns,
- pageCount: Math.ceil(totalCount / pagination.pageSize),
- state: {
- sorting,
- columnFilters,
- columnVisibility,
- rowSelection,
- pagination,
- expanded,
- globalFilter,
- },
- enableRowSelection: (row: Row<Channel>) => !isTagAggregateRow(row.original),
- onRowSelectionChange: setRowSelection,
- onSortingChange: setSorting,
- onColumnFiltersChange,
- onColumnVisibilityChange: setColumnVisibility,
- onPaginationChange,
- onExpandedChange: setExpanded,
- onGlobalFilterChange,
- getCoreRowModel: getCoreRowModel(),
- getExpandedRowModel: getExpandedRowModel(),
- getSubRows: (row: Channel & { children?: Channel[] }) => row.children,
- manualPagination: true,
- manualSorting: true,
- manualFiltering: true,
- })
- // Ensure page is in range when total count changes
- const pageCount = table.getPageCount()
- useEffect(() => {
- ensurePageInRange(pageCount)
- }, [pageCount, ensurePageInRange])
- // Prepare filter options from existing channel types only.
- const typeFilterOptions = useMemo(() => {
- const counts = typeCounts || {}
- const typeIds = Object.entries(counts)
- .map(([type, count]) => ({
- type: Number(type),
- count: Number(count) || 0,
- }))
- .filter((item) => item.type > 0 && item.count > 0)
- .sort((a, b) => {
- const labelA = t(getChannelTypeLabel(a.type))
- const labelB = t(getChannelTypeLabel(b.type))
- return labelA.localeCompare(labelB)
- })
- const selectedType = typeFilter.find((value) => value !== 'all')
- if (selectedType) {
- const selectedTypeId = Number(selectedType)
- const alreadyIncluded = typeIds.some(
- (item) => item.type === selectedTypeId
- )
- if (selectedTypeId > 0 && !alreadyIncluded) {
- typeIds.push({
- type: selectedTypeId,
- count: Number(counts[selectedType]) || 0,
- })
- }
- }
- const totalTypes = Object.values(counts).reduce(
- (sum, count) => sum + (Number(count) || 0),
- 0
- )
- return [
- {
- label: 'All Types',
- value: 'all',
- count: totalTypes,
- },
- ...typeIds.map((item) => {
- const iconName = getChannelTypeIcon(item.type)
- return {
- label: getChannelTypeLabel(item.type),
- value: String(item.type),
- count: item.count,
- iconNode: getLobeIcon(`${iconName}.Color`, 16),
- }
- }),
- ]
- }, [t, typeCounts, typeFilter])
- const groupFilterOptions = [
- { label: t('All Groups'), value: 'all' },
- ...groupOptions,
- ]
- return (
- <>
- <div className='space-y-4'>
- <DataTableToolbar
- table={table}
- searchPlaceholder={t('Filter by name, ID, or key...')}
- additionalSearch={
- <Input
- placeholder={t('Filter by model...')}
- value={modelFilterInput}
- onChange={(e) => setModelFilterInput(e.target.value)}
- className='h-8 w-full sm:w-[150px] lg:w-[200px]'
- />
- }
- filters={[
- {
- columnId: 'status',
- title: t('Status'),
- options: [...CHANNEL_STATUS_OPTIONS],
- singleSelect: true,
- },
- {
- columnId: 'type',
- title: t('Type'),
- options: typeFilterOptions,
- singleSelect: true,
- },
- {
- columnId: 'group',
- title: t('Group'),
- options: groupFilterOptions,
- singleSelect: true,
- },
- ]}
- />
- {isMobile ? (
- <MobileCardList
- table={table}
- isLoading={isLoading}
- emptyTitle='No Channels Found'
- emptyDescription='No channels available. Create your first channel to get started.'
- getRowClassName={(row) =>
- isDisabledChannelRow(row.original) ? DISABLED_ROW_MOBILE : undefined
- }
- />
- ) : (
- <>
- <div
- className={cn(
- 'overflow-hidden rounded-md border transition-opacity duration-150',
- isFetching && !isLoading && 'pointer-events-none opacity-50'
- )}
- >
- <Table>
- <TableHeader>
- {table.getHeaderGroups().map((headerGroup) => (
- <TableRow key={headerGroup.id}>
- {headerGroup.headers.map((header) => (
- <TableHead
- key={header.id}
- style={{ width: header.getSize() }}
- >
- {header.isPlaceholder
- ? null
- : flexRender(
- header.column.columnDef.header,
- header.getContext()
- )}
- </TableHead>
- ))}
- </TableRow>
- ))}
- </TableHeader>
- <TableBody>
- {isLoading ? (
- <TableSkeleton table={table} keyPrefix='channel-skeleton' />
- ) : table.getRowModel().rows.length === 0 ? (
- <TableEmpty
- colSpan={columns.length}
- title={t('No Channels Found')}
- description={t(
- 'No channels available. Create your first channel to get started.'
- )}
- />
- ) : (
- table.getRowModel().rows.map((row) => (
- <TableRow
- key={row.id}
- data-state={row.getIsSelected() && 'selected'}
- className={cn(
- isDisabledChannelRow(row.original) &&
- DISABLED_ROW_DESKTOP
- )}
- >
- {row.getVisibleCells().map((cell) => (
- <TableCell key={cell.id}>
- {flexRender(
- cell.column.columnDef.cell,
- cell.getContext()
- )}
- </TableCell>
- ))}
- </TableRow>
- ))
- )}
- </TableBody>
- </Table>
- </div>
- <DataTableBulkActions table={table} />
- </>
- )}
- </div>
- <PageFooterPortal>
- <DataTablePagination table={table} />
- </PageFooterPortal>
- </>
- )
- }
|