channels-table.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. import { useState, useMemo, useEffect } from 'react'
  2. import { useQuery } from '@tanstack/react-query'
  3. import { getRouteApi } from '@tanstack/react-router'
  4. import {
  5. flexRender,
  6. getCoreRowModel,
  7. useReactTable,
  8. getExpandedRowModel,
  9. type SortingState,
  10. type VisibilityState,
  11. type ExpandedState,
  12. type Row,
  13. } from '@tanstack/react-table'
  14. import { useDebounce, useMediaQuery } from '@/hooks'
  15. import { useTranslation } from 'react-i18next'
  16. import { getLobeIcon } from '@/lib/lobe-icon'
  17. import { cn } from '@/lib/utils'
  18. import { useTableUrlState } from '@/hooks/use-table-url-state'
  19. import { Input } from '@/components/ui/input'
  20. import {
  21. Table,
  22. TableBody,
  23. TableCell,
  24. TableHead,
  25. TableHeader,
  26. TableRow,
  27. } from '@/components/ui/table'
  28. import {
  29. DISABLED_ROW_DESKTOP,
  30. DISABLED_ROW_MOBILE,
  31. DataTableToolbar,
  32. TableSkeleton,
  33. TableEmpty,
  34. MobileCardList,
  35. } from '@/components/data-table'
  36. import { DataTablePagination } from '@/components/data-table/pagination'
  37. import { PageFooterPortal } from '@/components/layout'
  38. import { getChannels, searchChannels, getGroups } from '../api'
  39. import {
  40. DEFAULT_PAGE_SIZE,
  41. CHANNEL_STATUS,
  42. CHANNEL_STATUS_OPTIONS,
  43. } from '../constants'
  44. import {
  45. channelsQueryKeys,
  46. aggregateChannelsByTag,
  47. isTagAggregateRow,
  48. getChannelTypeIcon,
  49. getChannelTypeLabel,
  50. } from '../lib'
  51. import type { Channel } from '../types'
  52. import { useChannelsColumns } from './channels-columns'
  53. import { useChannels } from './channels-provider'
  54. import { DataTableBulkActions } from './data-table-bulk-actions'
  55. const route = getRouteApi('/_authenticated/channels/')
  56. function isDisabledChannelRow(channel: Channel) {
  57. return (
  58. !isTagAggregateRow(channel) && channel.status !== CHANNEL_STATUS.ENABLED
  59. )
  60. }
  61. export function ChannelsTable() {
  62. const { t } = useTranslation()
  63. const { enableTagMode, idSort } = useChannels()
  64. const isMobile = useMediaQuery('(max-width: 640px)')
  65. // Table state
  66. const [sorting, setSorting] = useState<SortingState>([])
  67. const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
  68. models: false,
  69. tag: false,
  70. })
  71. const [rowSelection, setRowSelection] = useState({})
  72. const [expanded, setExpanded] = useState<ExpandedState>({})
  73. // URL state management
  74. const {
  75. globalFilter,
  76. onGlobalFilterChange,
  77. columnFilters,
  78. onColumnFiltersChange,
  79. pagination,
  80. onPaginationChange,
  81. ensurePageInRange,
  82. } = useTableUrlState({
  83. search: route.useSearch(),
  84. navigate: route.useNavigate(),
  85. pagination: { defaultPage: 1, defaultPageSize: DEFAULT_PAGE_SIZE },
  86. globalFilter: { enabled: true, key: 'filter' },
  87. columnFilters: [
  88. { columnId: 'status', searchKey: 'status', type: 'array' },
  89. { columnId: 'type', searchKey: 'type', type: 'array' },
  90. { columnId: 'group', searchKey: 'group', type: 'array' },
  91. { columnId: 'model', searchKey: 'model', type: 'string' },
  92. ],
  93. })
  94. // Extract filters from column filters
  95. const statusFilter =
  96. (columnFilters.find((f) => f.id === 'status')?.value as string[]) || []
  97. const typeFilter =
  98. (columnFilters.find((f) => f.id === 'type')?.value as string[]) || []
  99. const groupFilter =
  100. (columnFilters.find((f) => f.id === 'group')?.value as string[]) || []
  101. const modelFilterFromUrl =
  102. (columnFilters.find((f) => f.id === 'model')?.value as string) || ''
  103. // Local state for immediate input feedback
  104. const [modelFilterInput, setModelFilterInput] = useState(modelFilterFromUrl)
  105. const debouncedModelFilter = useDebounce(modelFilterInput, 500)
  106. // Sync local input with URL when URL changes (e.g., from back/forward navigation)
  107. useEffect(() => {
  108. setModelFilterInput(modelFilterFromUrl)
  109. }, [modelFilterFromUrl])
  110. // Update URL when debounced value changes
  111. useEffect(() => {
  112. if (debouncedModelFilter !== modelFilterFromUrl) {
  113. onColumnFiltersChange((prev) => {
  114. const filtered = prev.filter((f) => f.id !== 'model')
  115. return debouncedModelFilter
  116. ? [...filtered, { id: 'model', value: debouncedModelFilter }]
  117. : filtered
  118. })
  119. }
  120. }, [debouncedModelFilter, modelFilterFromUrl, onColumnFiltersChange])
  121. const modelFilter = modelFilterFromUrl
  122. // Determine whether to use search or regular list API
  123. const shouldSearch = Boolean(globalFilter?.trim() || modelFilter.trim())
  124. // Fetch groups for filter
  125. const { data: groupsData } = useQuery({
  126. queryKey: ['groups'],
  127. queryFn: getGroups,
  128. })
  129. const groupOptions = useMemo(
  130. () =>
  131. (groupsData?.data || []).map((g) => ({
  132. label: g,
  133. value: g,
  134. })),
  135. [groupsData]
  136. )
  137. // Fetch channels data
  138. // eslint-disable-next-line @tanstack/query/exhaustive-deps
  139. const { data, isLoading, isFetching } = useQuery({
  140. queryKey: channelsQueryKeys.list({
  141. keyword: globalFilter,
  142. model: modelFilter,
  143. group:
  144. groupFilter.length > 0 && !groupFilter.includes('all')
  145. ? groupFilter[0]
  146. : undefined,
  147. status:
  148. statusFilter.length > 0 && !statusFilter.includes('all')
  149. ? statusFilter[0]
  150. : undefined,
  151. type:
  152. typeFilter.length > 0 && !typeFilter.includes('all')
  153. ? Number(typeFilter[0])
  154. : undefined,
  155. tag_mode: enableTagMode,
  156. id_sort: idSort,
  157. p: pagination.pageIndex + 1,
  158. page_size: pagination.pageSize,
  159. }),
  160. queryFn: async () => {
  161. if (shouldSearch) {
  162. return searchChannels({
  163. keyword: globalFilter,
  164. model: modelFilter,
  165. group:
  166. groupFilter.length > 0 && !groupFilter.includes('all')
  167. ? groupFilter[0]
  168. : undefined,
  169. status:
  170. statusFilter.length > 0 && !statusFilter.includes('all')
  171. ? statusFilter[0]
  172. : undefined,
  173. type:
  174. typeFilter.length > 0 && !typeFilter.includes('all')
  175. ? Number(typeFilter[0])
  176. : undefined,
  177. tag_mode: enableTagMode,
  178. id_sort: idSort,
  179. p: pagination.pageIndex + 1,
  180. page_size: pagination.pageSize,
  181. })
  182. } else {
  183. return getChannels({
  184. group:
  185. groupFilter.length > 0 && !groupFilter.includes('all')
  186. ? groupFilter[0]
  187. : undefined,
  188. status:
  189. statusFilter.length > 0 && !statusFilter.includes('all')
  190. ? statusFilter[0]
  191. : undefined,
  192. type:
  193. typeFilter.length > 0 && !typeFilter.includes('all')
  194. ? Number(typeFilter[0])
  195. : undefined,
  196. tag_mode: enableTagMode,
  197. id_sort: idSort,
  198. p: pagination.pageIndex + 1,
  199. page_size: pagination.pageSize,
  200. })
  201. }
  202. },
  203. placeholderData: (previousData) => previousData,
  204. })
  205. // Apply tag aggregation if tag mode is enabled
  206. const channels = useMemo(() => {
  207. const rawChannels = data?.data?.items || []
  208. if (enableTagMode && rawChannels.length > 0) {
  209. return aggregateChannelsByTag(rawChannels)
  210. }
  211. return rawChannels
  212. }, [data, enableTagMode])
  213. const totalCount = data?.data?.total || 0
  214. const typeCounts = data?.data?.type_counts
  215. // Columns configuration
  216. const columns = useChannelsColumns()
  217. // React Table instance
  218. const table = useReactTable({
  219. data: channels,
  220. columns,
  221. pageCount: Math.ceil(totalCount / pagination.pageSize),
  222. state: {
  223. sorting,
  224. columnFilters,
  225. columnVisibility,
  226. rowSelection,
  227. pagination,
  228. expanded,
  229. globalFilter,
  230. },
  231. enableRowSelection: (row: Row<Channel>) => !isTagAggregateRow(row.original),
  232. onRowSelectionChange: setRowSelection,
  233. onSortingChange: setSorting,
  234. onColumnFiltersChange,
  235. onColumnVisibilityChange: setColumnVisibility,
  236. onPaginationChange,
  237. onExpandedChange: setExpanded,
  238. onGlobalFilterChange,
  239. getCoreRowModel: getCoreRowModel(),
  240. getExpandedRowModel: getExpandedRowModel(),
  241. getSubRows: (row: Channel & { children?: Channel[] }) => row.children,
  242. manualPagination: true,
  243. manualSorting: true,
  244. manualFiltering: true,
  245. })
  246. // Ensure page is in range when total count changes
  247. const pageCount = table.getPageCount()
  248. useEffect(() => {
  249. ensurePageInRange(pageCount)
  250. }, [pageCount, ensurePageInRange])
  251. // Prepare filter options from existing channel types only.
  252. const typeFilterOptions = useMemo(() => {
  253. const counts = typeCounts || {}
  254. const typeIds = Object.entries(counts)
  255. .map(([type, count]) => ({
  256. type: Number(type),
  257. count: Number(count) || 0,
  258. }))
  259. .filter((item) => item.type > 0 && item.count > 0)
  260. .sort((a, b) => {
  261. const labelA = t(getChannelTypeLabel(a.type))
  262. const labelB = t(getChannelTypeLabel(b.type))
  263. return labelA.localeCompare(labelB)
  264. })
  265. const selectedType = typeFilter.find((value) => value !== 'all')
  266. if (selectedType) {
  267. const selectedTypeId = Number(selectedType)
  268. const alreadyIncluded = typeIds.some(
  269. (item) => item.type === selectedTypeId
  270. )
  271. if (selectedTypeId > 0 && !alreadyIncluded) {
  272. typeIds.push({
  273. type: selectedTypeId,
  274. count: Number(counts[selectedType]) || 0,
  275. })
  276. }
  277. }
  278. const totalTypes = Object.values(counts).reduce(
  279. (sum, count) => sum + (Number(count) || 0),
  280. 0
  281. )
  282. return [
  283. {
  284. label: 'All Types',
  285. value: 'all',
  286. count: totalTypes,
  287. },
  288. ...typeIds.map((item) => {
  289. const iconName = getChannelTypeIcon(item.type)
  290. return {
  291. label: getChannelTypeLabel(item.type),
  292. value: String(item.type),
  293. count: item.count,
  294. iconNode: getLobeIcon(`${iconName}.Color`, 16),
  295. }
  296. }),
  297. ]
  298. }, [t, typeCounts, typeFilter])
  299. const groupFilterOptions = [
  300. { label: t('All Groups'), value: 'all' },
  301. ...groupOptions,
  302. ]
  303. return (
  304. <>
  305. <div className='space-y-4'>
  306. <DataTableToolbar
  307. table={table}
  308. searchPlaceholder={t('Filter by name, ID, or key...')}
  309. additionalSearch={
  310. <Input
  311. placeholder={t('Filter by model...')}
  312. value={modelFilterInput}
  313. onChange={(e) => setModelFilterInput(e.target.value)}
  314. className='h-8 w-full sm:w-[150px] lg:w-[200px]'
  315. />
  316. }
  317. filters={[
  318. {
  319. columnId: 'status',
  320. title: t('Status'),
  321. options: [...CHANNEL_STATUS_OPTIONS],
  322. singleSelect: true,
  323. },
  324. {
  325. columnId: 'type',
  326. title: t('Type'),
  327. options: typeFilterOptions,
  328. singleSelect: true,
  329. },
  330. {
  331. columnId: 'group',
  332. title: t('Group'),
  333. options: groupFilterOptions,
  334. singleSelect: true,
  335. },
  336. ]}
  337. />
  338. {isMobile ? (
  339. <MobileCardList
  340. table={table}
  341. isLoading={isLoading}
  342. emptyTitle='No Channels Found'
  343. emptyDescription='No channels available. Create your first channel to get started.'
  344. getRowClassName={(row) =>
  345. isDisabledChannelRow(row.original) ? DISABLED_ROW_MOBILE : undefined
  346. }
  347. />
  348. ) : (
  349. <>
  350. <div
  351. className={cn(
  352. 'overflow-hidden rounded-md border transition-opacity duration-150',
  353. isFetching && !isLoading && 'pointer-events-none opacity-50'
  354. )}
  355. >
  356. <Table>
  357. <TableHeader>
  358. {table.getHeaderGroups().map((headerGroup) => (
  359. <TableRow key={headerGroup.id}>
  360. {headerGroup.headers.map((header) => (
  361. <TableHead
  362. key={header.id}
  363. style={{ width: header.getSize() }}
  364. >
  365. {header.isPlaceholder
  366. ? null
  367. : flexRender(
  368. header.column.columnDef.header,
  369. header.getContext()
  370. )}
  371. </TableHead>
  372. ))}
  373. </TableRow>
  374. ))}
  375. </TableHeader>
  376. <TableBody>
  377. {isLoading ? (
  378. <TableSkeleton table={table} keyPrefix='channel-skeleton' />
  379. ) : table.getRowModel().rows.length === 0 ? (
  380. <TableEmpty
  381. colSpan={columns.length}
  382. title={t('No Channels Found')}
  383. description={t(
  384. 'No channels available. Create your first channel to get started.'
  385. )}
  386. />
  387. ) : (
  388. table.getRowModel().rows.map((row) => (
  389. <TableRow
  390. key={row.id}
  391. data-state={row.getIsSelected() && 'selected'}
  392. className={cn(
  393. isDisabledChannelRow(row.original) &&
  394. DISABLED_ROW_DESKTOP
  395. )}
  396. >
  397. {row.getVisibleCells().map((cell) => (
  398. <TableCell key={cell.id}>
  399. {flexRender(
  400. cell.column.columnDef.cell,
  401. cell.getContext()
  402. )}
  403. </TableCell>
  404. ))}
  405. </TableRow>
  406. ))
  407. )}
  408. </TableBody>
  409. </Table>
  410. </div>
  411. <DataTableBulkActions table={table} />
  412. </>
  413. )}
  414. </div>
  415. <PageFooterPortal>
  416. <DataTablePagination table={table} />
  417. </PageFooterPortal>
  418. </>
  419. )
  420. }