models-table.tsx 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  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. type SortingState,
  9. type VisibilityState,
  10. } from '@tanstack/react-table'
  11. import { useMediaQuery } from '@/hooks'
  12. import { useTranslation } from 'react-i18next'
  13. import { cn } from '@/lib/utils'
  14. import { useTableUrlState } from '@/hooks/use-table-url-state'
  15. import {
  16. Table,
  17. TableBody,
  18. TableCell,
  19. TableHead,
  20. TableHeader,
  21. TableRow,
  22. } from '@/components/ui/table'
  23. import {
  24. DataTableToolbar,
  25. TableSkeleton,
  26. TableEmpty,
  27. MobileCardList,
  28. } from '@/components/data-table'
  29. import { DataTablePagination } from '@/components/data-table/pagination'
  30. import { PageFooterPortal } from '@/components/layout'
  31. import { getModels, searchModels, getVendors } from '../api'
  32. import {
  33. DEFAULT_PAGE_SIZE,
  34. getModelStatusOptions,
  35. getSyncStatusOptions,
  36. } from '../constants'
  37. import { modelsQueryKeys, vendorsQueryKeys } from '../lib'
  38. import { DataTableBulkActions } from './data-table-bulk-actions'
  39. import { useModelsColumns } from './models-columns'
  40. import { useModels } from './models-provider'
  41. const route = getRouteApi('/_authenticated/models/$section')
  42. export function ModelsTable() {
  43. const { t } = useTranslation()
  44. const { selectedVendor } = useModels()
  45. const isMobile = useMediaQuery('(max-width: 640px)')
  46. // Table state
  47. const [sorting, setSorting] = useState<SortingState>([])
  48. const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
  49. description: false,
  50. bound_channels: false,
  51. quota_types: false,
  52. })
  53. const [rowSelection, setRowSelection] = useState({})
  54. // URL state management
  55. const {
  56. globalFilter,
  57. onGlobalFilterChange,
  58. columnFilters,
  59. onColumnFiltersChange,
  60. pagination,
  61. onPaginationChange,
  62. ensurePageInRange,
  63. } = useTableUrlState({
  64. search: route.useSearch(),
  65. navigate: route.useNavigate(),
  66. pagination: {
  67. defaultPage: 1,
  68. defaultPageSize: isMobile ? 10 : DEFAULT_PAGE_SIZE,
  69. },
  70. globalFilter: { enabled: true, key: 'filter' },
  71. columnFilters: [
  72. { columnId: 'status', searchKey: 'status', type: 'array' },
  73. { columnId: 'vendor_id', searchKey: 'vendor', type: 'array' },
  74. { columnId: 'sync_official', searchKey: 'sync', type: 'array' },
  75. ],
  76. })
  77. // Extract filters from column filters
  78. const statusFilter =
  79. (columnFilters.find((f) => f.id === 'status')?.value as string[]) || []
  80. const vendorFilter =
  81. (columnFilters.find((f) => f.id === 'vendor_id')?.value as string[]) || []
  82. const syncFilter =
  83. (columnFilters.find((f) => f.id === 'sync_official')?.value as string[]) ||
  84. []
  85. // Fetch vendors for filter
  86. const { data: vendorsData } = useQuery({
  87. queryKey: vendorsQueryKeys.list(),
  88. queryFn: () => getVendors({ page_size: 1000 }),
  89. })
  90. const vendors = useMemo(
  91. () => vendorsData?.data?.items || [],
  92. [vendorsData?.data?.items]
  93. )
  94. const vendorOptions = useMemo(() => {
  95. return vendors.map((v) => ({
  96. label: v.name,
  97. value: String(v.id),
  98. }))
  99. }, [vendors])
  100. // Determine whether to use search or regular list API
  101. const shouldSearch = Boolean(globalFilter?.trim())
  102. // Apply selected vendor from context or filter
  103. const activeVendorFilter =
  104. selectedVendor ||
  105. (vendorFilter.length > 0 && !vendorFilter.includes('all')
  106. ? vendorFilter[0]
  107. : undefined)
  108. // Fetch models data
  109. // eslint-disable-next-line @tanstack/query/exhaustive-deps
  110. const { data, isLoading, isFetching } = useQuery({
  111. queryKey: modelsQueryKeys.list({
  112. keyword: globalFilter,
  113. vendor: activeVendorFilter,
  114. status:
  115. statusFilter.length > 0 && !statusFilter.includes('all')
  116. ? statusFilter[0]
  117. : undefined,
  118. sync_official:
  119. syncFilter.length > 0 && !syncFilter.includes('all')
  120. ? syncFilter[0]
  121. : undefined,
  122. p: pagination.pageIndex + 1,
  123. page_size: pagination.pageSize,
  124. }),
  125. queryFn: async () => {
  126. if (shouldSearch || activeVendorFilter) {
  127. return searchModels({
  128. keyword: globalFilter,
  129. vendor: activeVendorFilter,
  130. status:
  131. statusFilter.length > 0 && !statusFilter.includes('all')
  132. ? statusFilter[0]
  133. : undefined,
  134. sync_official:
  135. syncFilter.length > 0 && !syncFilter.includes('all')
  136. ? syncFilter[0]
  137. : undefined,
  138. p: pagination.pageIndex + 1,
  139. page_size: pagination.pageSize,
  140. })
  141. } else {
  142. return getModels({
  143. status:
  144. statusFilter.length > 0 && !statusFilter.includes('all')
  145. ? statusFilter[0]
  146. : undefined,
  147. sync_official:
  148. syncFilter.length > 0 && !syncFilter.includes('all')
  149. ? syncFilter[0]
  150. : undefined,
  151. p: pagination.pageIndex + 1,
  152. page_size: pagination.pageSize,
  153. })
  154. }
  155. },
  156. placeholderData: (previousData) => previousData,
  157. })
  158. const models = data?.data?.items || []
  159. const totalCount = data?.data?.total || 0
  160. const vendorCounts = data?.data?.vendor_counts
  161. // Columns configuration
  162. const columns = useModelsColumns(vendors)
  163. // React Table instance
  164. const table = useReactTable({
  165. data: models,
  166. columns,
  167. pageCount: Math.ceil(totalCount / pagination.pageSize),
  168. state: {
  169. sorting,
  170. columnFilters,
  171. columnVisibility,
  172. rowSelection,
  173. pagination,
  174. globalFilter,
  175. },
  176. enableRowSelection: true,
  177. onRowSelectionChange: setRowSelection,
  178. onSortingChange: setSorting,
  179. onColumnFiltersChange,
  180. onColumnVisibilityChange: setColumnVisibility,
  181. onPaginationChange,
  182. onGlobalFilterChange,
  183. getCoreRowModel: getCoreRowModel(),
  184. manualPagination: true,
  185. manualSorting: true,
  186. manualFiltering: true,
  187. })
  188. // Ensure page is in range when total count changes
  189. const pageCount = table.getPageCount()
  190. useEffect(() => {
  191. ensurePageInRange(pageCount)
  192. }, [pageCount, ensurePageInRange])
  193. // Prepare filter options
  194. const vendorFilterOptions = [
  195. {
  196. label: `${t('All Vendors')}${vendorCounts?.all ? ` (${vendorCounts.all})` : ''}`,
  197. value: 'all',
  198. },
  199. ...vendorOptions.map((option) => ({
  200. label: `${option.label}${vendorCounts?.[option.value] ? ` (${vendorCounts[option.value]})` : ''}`,
  201. value: option.value,
  202. })),
  203. ]
  204. return (
  205. <>
  206. <div className='space-y-3 sm:space-y-4'>
  207. <DataTableToolbar
  208. table={table}
  209. searchPlaceholder={t('Filter by model name...')}
  210. filters={[
  211. {
  212. columnId: 'status',
  213. title: t('Status'),
  214. options: [...getModelStatusOptions(t)],
  215. singleSelect: true,
  216. },
  217. {
  218. columnId: 'vendor_id',
  219. title: t('Vendor'),
  220. options: vendorFilterOptions,
  221. singleSelect: true,
  222. },
  223. {
  224. columnId: 'sync_official',
  225. title: t('Official Sync'),
  226. options: [...getSyncStatusOptions(t)],
  227. singleSelect: true,
  228. },
  229. ]}
  230. />
  231. {isMobile ? (
  232. <MobileCardList
  233. table={table}
  234. isLoading={isLoading}
  235. emptyTitle={t('No Models Found')}
  236. emptyDescription={t(
  237. 'No models available. Create your first model to get started.'
  238. )}
  239. />
  240. ) : (
  241. <>
  242. <div
  243. className={cn(
  244. 'overflow-hidden rounded-md border transition-opacity duration-150',
  245. isFetching && !isLoading && 'pointer-events-none opacity-50'
  246. )}
  247. >
  248. <Table>
  249. <TableHeader>
  250. {table.getHeaderGroups().map((headerGroup) => (
  251. <TableRow key={headerGroup.id}>
  252. {headerGroup.headers.map((header) => (
  253. <TableHead
  254. key={header.id}
  255. style={{ width: header.getSize() }}
  256. >
  257. {header.isPlaceholder
  258. ? null
  259. : flexRender(
  260. header.column.columnDef.header,
  261. header.getContext()
  262. )}
  263. </TableHead>
  264. ))}
  265. </TableRow>
  266. ))}
  267. </TableHeader>
  268. <TableBody>
  269. {isLoading ? (
  270. <TableSkeleton table={table} keyPrefix='model-skeleton' />
  271. ) : table.getRowModel().rows.length === 0 ? (
  272. <TableEmpty
  273. colSpan={columns.length}
  274. title={t('No Models Found')}
  275. description={t(
  276. 'No models available. Create your first model to get started.'
  277. )}
  278. />
  279. ) : (
  280. table.getRowModel().rows.map((row) => (
  281. <TableRow
  282. key={row.id}
  283. data-state={row.getIsSelected() && 'selected'}
  284. >
  285. {row.getVisibleCells().map((cell) => (
  286. <TableCell key={cell.id}>
  287. {flexRender(
  288. cell.column.columnDef.cell,
  289. cell.getContext()
  290. )}
  291. </TableCell>
  292. ))}
  293. </TableRow>
  294. ))
  295. )}
  296. </TableBody>
  297. </Table>
  298. </div>
  299. <DataTableBulkActions table={table} />
  300. </>
  301. )}
  302. </div>
  303. <PageFooterPortal>
  304. <DataTablePagination
  305. table={table as ReturnType<typeof useReactTable>}
  306. />
  307. </PageFooterPortal>
  308. </>
  309. )
  310. }