data-table.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  1. 'use client';
  2. import { DataTablePagination } from '@/vdb/components/data-table/data-table-pagination.js';
  3. import { DataTableViewOptions } from '@/vdb/components/data-table/data-table-view-options.js';
  4. import { GlobalViewsBar } from '@/vdb/components/data-table/global-views-bar.js';
  5. import { MyViewsButton } from '@/vdb/components/data-table/my-views-button.js';
  6. import { RefreshButton } from '@/vdb/components/data-table/refresh-button.js';
  7. import { SaveViewButton } from '@/vdb/components/data-table/save-view-button.js';
  8. import { Button } from '@/vdb/components/ui/button.js';
  9. import { Input } from '@/vdb/components/ui/input.js';
  10. import { Skeleton } from '@/vdb/components/ui/skeleton.js';
  11. import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/vdb/components/ui/table.js';
  12. import { BulkAction } from '@/vdb/framework/extension-api/types/index.js';
  13. import { useChannel } from '@/vdb/hooks/use-channel.js';
  14. import { usePage } from '@/vdb/hooks/use-page.js';
  15. import { useSavedViews } from '@/vdb/hooks/use-saved-views.js';
  16. import { Trans, useLingui } from '@lingui/react/macro';
  17. import {
  18. ColumnDef,
  19. ColumnFilter,
  20. ColumnFiltersState,
  21. flexRender,
  22. getCoreRowModel,
  23. getPaginationRowModel,
  24. PaginationState,
  25. SortingState,
  26. Table as TableType,
  27. useReactTable,
  28. VisibilityState,
  29. } from '@tanstack/react-table';
  30. import { RowSelectionState, TableOptions } from '@tanstack/table-core';
  31. import React, { Suspense, useEffect, useRef } from 'react';
  32. import { AddFilterMenu } from './add-filter-menu.js';
  33. import { DataTableBulkActions } from './data-table-bulk-actions.js';
  34. import { DataTableProvider } from './data-table-context.js';
  35. import { DataTableFacetedFilter, DataTableFacetedFilterOption } from './data-table-faceted-filter.js';
  36. import { DataTableFilterBadge } from './data-table-filter-badge.js';
  37. export interface FacetedFilter {
  38. title: string;
  39. icon?: React.ComponentType<{ className?: string }>;
  40. optionsFn?: () => Promise<DataTableFacetedFilterOption[]>;
  41. options?: DataTableFacetedFilterOption[];
  42. }
  43. /**
  44. * @description
  45. * Props for configuring the {@link DataTable}.
  46. *
  47. * @docsCategory list-views
  48. * @docsPage DataTable
  49. * @since 3.4.0
  50. */
  51. interface DataTableProps<TData> {
  52. children?: React.ReactNode;
  53. columns: ColumnDef<TData, any>[];
  54. data: TData[];
  55. totalItems: number;
  56. isLoading?: boolean;
  57. page?: number;
  58. itemsPerPage?: number;
  59. sorting?: SortingState;
  60. columnFilters?: ColumnFiltersState;
  61. onPageChange?: (table: TableType<TData>, page: number, itemsPerPage: number) => void;
  62. onSortChange?: (table: TableType<TData>, sorting: SortingState) => void;
  63. onFilterChange?: (table: TableType<TData>, columnFilters: ColumnFilter[]) => void;
  64. onColumnVisibilityChange?: (table: TableType<TData>, columnVisibility: VisibilityState) => void;
  65. onSearchTermChange?: (searchTerm: string) => void;
  66. defaultColumnVisibility?: VisibilityState;
  67. facetedFilters?: { [key: string]: FacetedFilter | undefined };
  68. disableViewOptions?: boolean;
  69. bulkActions?: BulkAction[];
  70. /**
  71. * @description
  72. * This property allows full control over _all_ features of TanStack Table
  73. * when needed.
  74. */
  75. setTableOptions?: (table: TableOptions<TData>) => TableOptions<TData>;
  76. onRefresh?: () => void;
  77. }
  78. /**
  79. * @description
  80. * A data table which includes sorting, filtering, pagination, bulk actions, column controls etc.
  81. *
  82. * This is the building block of all data tables in the Dashboard.
  83. *
  84. * @docsCategory list-views
  85. * @docsPage DataTable
  86. * @since 3.4.0
  87. * @docsWeight 0
  88. */
  89. export function DataTable<TData>({
  90. children,
  91. columns,
  92. data,
  93. totalItems,
  94. isLoading,
  95. page,
  96. itemsPerPage,
  97. sorting: sortingInitialState,
  98. columnFilters: filtersInitialState,
  99. onPageChange,
  100. onSortChange,
  101. onFilterChange,
  102. onSearchTermChange,
  103. onColumnVisibilityChange,
  104. defaultColumnVisibility,
  105. facetedFilters,
  106. disableViewOptions,
  107. bulkActions,
  108. setTableOptions,
  109. onRefresh,
  110. }: Readonly<DataTableProps<TData>>) {
  111. const [sorting, setSorting] = React.useState<SortingState>(sortingInitialState || []);
  112. const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(filtersInitialState || []);
  113. const [searchTerm, setSearchTerm] = React.useState<string>('');
  114. const { activeChannel } = useChannel();
  115. const { pageId } = usePage();
  116. const savedViewsResult = useSavedViews();
  117. const globalViews = pageId && onFilterChange ? savedViewsResult.globalViews : [];
  118. const { t } = useLingui();
  119. const [pagination, setPagination] = React.useState<PaginationState>({
  120. pageIndex: (page ?? 1) - 1,
  121. pageSize: itemsPerPage ?? 10,
  122. });
  123. const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(
  124. defaultColumnVisibility ?? {},
  125. );
  126. const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
  127. const prevSearchTermRef = useRef(searchTerm);
  128. useEffect(() => {
  129. // If the defaultColumnVisibility changes externally (e.g. the user reset the table settings),
  130. // we want to reset the column visibility to the default.
  131. if (
  132. defaultColumnVisibility &&
  133. JSON.stringify(defaultColumnVisibility) !== JSON.stringify(columnVisibility)
  134. ) {
  135. setColumnVisibility(defaultColumnVisibility);
  136. }
  137. // We intentionally do not include `columnVisibility` in the dependency array
  138. }, [defaultColumnVisibility]);
  139. let tableOptions: TableOptions<TData> = {
  140. data,
  141. columns,
  142. getRowId: row => (row as { id: string }).id,
  143. getCoreRowModel: getCoreRowModel(),
  144. getPaginationRowModel: getPaginationRowModel(),
  145. manualPagination: true,
  146. manualSorting: true,
  147. manualFiltering: true,
  148. rowCount: totalItems,
  149. onPaginationChange: setPagination,
  150. onSortingChange: setSorting,
  151. onColumnVisibilityChange: setColumnVisibility,
  152. onColumnFiltersChange: setColumnFilters,
  153. onRowSelectionChange: setRowSelection,
  154. state: {
  155. pagination,
  156. sorting,
  157. columnVisibility,
  158. columnFilters,
  159. rowSelection,
  160. },
  161. };
  162. if (typeof setTableOptions === 'function') {
  163. tableOptions = setTableOptions(tableOptions);
  164. }
  165. const table = useReactTable(tableOptions);
  166. useEffect(() => {
  167. onPageChange?.(table, pagination.pageIndex + 1, pagination.pageSize);
  168. }, [pagination]);
  169. useEffect(() => {
  170. onSortChange?.(table, sorting);
  171. }, [sorting]);
  172. useEffect(() => {
  173. onFilterChange?.(table, columnFilters);
  174. }, [columnFilters]);
  175. useEffect(() => {
  176. onColumnVisibilityChange?.(table, columnVisibility);
  177. }, [columnVisibility]);
  178. useEffect(() => {
  179. if (page && page > 1 && itemsPerPage && prevSearchTermRef.current !== searchTerm) {
  180. onPageChange?.(table, 1, itemsPerPage);
  181. prevSearchTermRef.current = searchTerm;
  182. }
  183. }, [onPageChange, searchTerm]);
  184. const visibleColumnCount = Object.values(columnVisibility).filter(Boolean).length;
  185. const handleSearchChange = (value: string) => {
  186. setSearchTerm(value);
  187. onSearchTermChange?.(value);
  188. };
  189. return (
  190. <DataTableProvider
  191. columnFilters={columnFilters}
  192. setColumnFilters={setColumnFilters}
  193. searchTerm={searchTerm}
  194. setSearchTerm={setSearchTerm}
  195. sorting={sorting}
  196. setSorting={setSorting}
  197. pageId={pageId}
  198. onFilterChange={onFilterChange}
  199. onSearchTermChange={onSearchTermChange}
  200. onRefresh={onRefresh}
  201. isLoading={isLoading}
  202. table={table}
  203. >
  204. <div className="space-y-2">
  205. <div className="flex items-center justify-between gap-2">
  206. <div className="flex items-center gap-2">
  207. {onSearchTermChange && (
  208. <Input
  209. placeholder={t`Filter...`}
  210. value={searchTerm}
  211. onChange={event => handleSearchChange(event.target.value)}
  212. className="w-64"
  213. />
  214. )}
  215. <Suspense>
  216. {Object.entries(facetedFilters ?? {}).map(([key, filter]) => (
  217. <DataTableFacetedFilter
  218. key={key}
  219. column={table.getColumn(key)}
  220. title={filter?.title}
  221. options={filter?.options}
  222. optionsFn={filter?.optionsFn}
  223. />
  224. ))}
  225. </Suspense>
  226. {onFilterChange && <AddFilterMenu columns={table.getAllColumns()} />}
  227. {pageId && onFilterChange && <MyViewsButton />}
  228. </div>
  229. <div className="flex items-center gap-2">
  230. {pageId && onFilterChange && <SaveViewButton />}
  231. {!disableViewOptions && <DataTableViewOptions table={table} />}
  232. {onRefresh && <RefreshButton onRefresh={onRefresh} isLoading={isLoading ?? false} />}
  233. </div>
  234. </div>
  235. {(pageId && onFilterChange && globalViews.length > 0) ||
  236. columnFilters.filter(f => !facetedFilters?.[f.id]).length > 0 ? (
  237. <div className="flex items-center justify-between bg-muted/40 rounded border border-border p-2 @container">
  238. <div className="flex items-center">
  239. {pageId && onFilterChange && <GlobalViewsBar />}
  240. </div>
  241. <div className="flex gap-1 flex-wrap items-center">
  242. {columnFilters
  243. .filter(f => !facetedFilters?.[f.id])
  244. .map(f => {
  245. const column = table.getColumn(f.id);
  246. const currency = activeChannel?.defaultCurrencyCode ?? 'USD';
  247. return (
  248. <DataTableFilterBadge
  249. key={f.id}
  250. filter={f}
  251. currencyCode={currency}
  252. dataType={
  253. (column?.columnDef.meta as any)?.fieldInfo?.type ?? 'String'
  254. }
  255. onRemove={() =>
  256. setColumnFilters(old => old.filter(x => x.id !== f.id))
  257. }
  258. />
  259. );
  260. })}
  261. {columnFilters.filter(f => !facetedFilters?.[f.id]).length > 0 && (
  262. <Button
  263. variant="ghost"
  264. size="sm"
  265. onClick={() => setColumnFilters([])}
  266. className="text-xs opacity-60 hover:opacity-100"
  267. >
  268. <Trans>Clear all</Trans>
  269. </Button>
  270. )}
  271. </div>
  272. </div>
  273. ) : null}
  274. <div className="rounded-md border my-2 relative shadow-sm">
  275. <Table>
  276. <TableHeader className="bg-muted/50">
  277. {table.getHeaderGroups().map(headerGroup => (
  278. <TableRow key={headerGroup.id}>
  279. {headerGroup.headers.map(header => {
  280. return (
  281. <TableHead key={header.id}>
  282. {header.isPlaceholder
  283. ? null
  284. : flexRender(
  285. header.column.columnDef.header,
  286. header.getContext(),
  287. )}
  288. </TableHead>
  289. );
  290. })}
  291. </TableRow>
  292. ))}
  293. </TableHeader>
  294. <TableBody>
  295. {isLoading && !data?.length ? (
  296. Array.from({ length: Math.min(pagination.pageSize, 100) }).map((_, index) => (
  297. <TableRow
  298. key={`skeleton-${index}`}
  299. className="animate-in fade-in duration-100"
  300. >
  301. {Array.from({ length: visibleColumnCount }).map((_, cellIndex) => (
  302. <TableCell
  303. key={`skeleton-cell-${index}-${cellIndex}`}
  304. className="h-12"
  305. >
  306. <Skeleton className="h-4 my-2 w-full" />
  307. </TableCell>
  308. ))}
  309. </TableRow>
  310. ))
  311. ) : table.getRowModel().rows?.length ? (
  312. table.getRowModel().rows.map(row => (
  313. <TableRow
  314. key={row.id}
  315. data-state={row.getIsSelected() && 'selected'}
  316. className="animate-in fade-in duration-100"
  317. >
  318. {row.getVisibleCells().map(cell => (
  319. <TableCell key={cell.id} className="h-12">
  320. {flexRender(cell.column.columnDef.cell, cell.getContext())}
  321. </TableCell>
  322. ))}
  323. </TableRow>
  324. ))
  325. ) : (
  326. <TableRow className="animate-in fade-in duration-100">
  327. <TableCell colSpan={columns.length} className="h-24 text-center">
  328. <Trans>No results</Trans>
  329. </TableCell>
  330. </TableRow>
  331. )}
  332. {children}
  333. </TableBody>
  334. </Table>
  335. <DataTableBulkActions bulkActions={bulkActions ?? []} table={table} />
  336. </div>
  337. {onPageChange && totalItems != null && <DataTablePagination table={table} />}
  338. </div>
  339. </DataTableProvider>
  340. );
  341. }