data-table.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  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 { DataTableFilterBadgeEditable } from './data-table-filter-badge-editable.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. const prevColumnFiltersRef = useRef(columnFilters);
  129. useEffect(() => {
  130. // If the defaultColumnVisibility changes externally (e.g. the user reset the table settings),
  131. // we want to reset the column visibility to the default.
  132. if (
  133. defaultColumnVisibility &&
  134. JSON.stringify(defaultColumnVisibility) !== JSON.stringify(columnVisibility)
  135. ) {
  136. setColumnVisibility(defaultColumnVisibility);
  137. }
  138. // We intentionally do not include `columnVisibility` in the dependency array
  139. }, [defaultColumnVisibility]);
  140. let tableOptions: TableOptions<TData> = {
  141. data,
  142. columns,
  143. getRowId: row => (row as { id: string }).id,
  144. getCoreRowModel: getCoreRowModel(),
  145. getPaginationRowModel: getPaginationRowModel(),
  146. manualPagination: true,
  147. manualSorting: true,
  148. manualFiltering: true,
  149. rowCount: totalItems,
  150. onPaginationChange: setPagination,
  151. onSortingChange: setSorting,
  152. onColumnVisibilityChange: setColumnVisibility,
  153. onColumnFiltersChange: setColumnFilters,
  154. onRowSelectionChange: setRowSelection,
  155. state: {
  156. pagination,
  157. sorting,
  158. columnVisibility,
  159. columnFilters,
  160. rowSelection,
  161. },
  162. };
  163. if (typeof setTableOptions === 'function') {
  164. tableOptions = setTableOptions(tableOptions);
  165. }
  166. const table = useReactTable(tableOptions);
  167. useEffect(() => {
  168. onPageChange?.(table, pagination.pageIndex + 1, pagination.pageSize);
  169. }, [pagination]);
  170. useEffect(() => {
  171. onSortChange?.(table, sorting);
  172. }, [sorting]);
  173. useEffect(() => {
  174. onColumnVisibilityChange?.(table, columnVisibility);
  175. }, [columnVisibility]);
  176. useEffect(() => {
  177. if (page && page > 1 && itemsPerPage && prevSearchTermRef.current !== searchTerm) {
  178. // Set the page back to 1 when searchTerm changes
  179. setPagination({
  180. ...pagination,
  181. pageIndex: 0,
  182. });
  183. }
  184. prevSearchTermRef.current = searchTerm;
  185. }, [onPageChange, searchTerm]);
  186. useEffect(() => {
  187. onFilterChange?.(table, columnFilters);
  188. if (
  189. page &&
  190. page > 1 &&
  191. itemsPerPage &&
  192. JSON.stringify(prevColumnFiltersRef.current) !== JSON.stringify(columnFilters)
  193. ) {
  194. // Set the page back to 1 when filters change
  195. setPagination({
  196. ...pagination,
  197. pageIndex: 0,
  198. });
  199. pagination.pageIndex;
  200. }
  201. prevColumnFiltersRef.current = columnFilters;
  202. }, [columnFilters]);
  203. const handleSearchChange = (value: string) => {
  204. setSearchTerm(value);
  205. onSearchTermChange?.(value);
  206. };
  207. const visibleColumnCount = Object.values(columnVisibility).filter(Boolean).length;
  208. return (
  209. <DataTableProvider
  210. columnFilters={columnFilters}
  211. setColumnFilters={setColumnFilters}
  212. searchTerm={searchTerm}
  213. setSearchTerm={setSearchTerm}
  214. sorting={sorting}
  215. setSorting={setSorting}
  216. pageId={pageId}
  217. onFilterChange={onFilterChange}
  218. onSearchTermChange={onSearchTermChange}
  219. onRefresh={onRefresh}
  220. isLoading={isLoading}
  221. table={table}
  222. >
  223. <div className="space-y-2 @container/table">
  224. <div className="flex items-center justify-between gap-2">
  225. <div className="flex items-center gap-2">
  226. {onSearchTermChange && (
  227. <Input
  228. placeholder={t`Filter...`}
  229. value={searchTerm}
  230. onChange={event => handleSearchChange(event.target.value)}
  231. className="w-64"
  232. />
  233. )}
  234. <Suspense>
  235. {Object.entries(facetedFilters ?? {}).map(([key, filter]) => (
  236. <DataTableFacetedFilter
  237. key={key}
  238. column={table.getColumn(key)}
  239. title={filter?.title}
  240. options={filter?.options}
  241. optionsFn={filter?.optionsFn}
  242. />
  243. ))}
  244. </Suspense>
  245. {onFilterChange && <AddFilterMenu columns={table.getAllColumns()} />}
  246. {pageId && onFilterChange && <MyViewsButton />}
  247. </div>
  248. <div className="flex items-center gap-2">
  249. {pageId && onFilterChange && <SaveViewButton />}
  250. {!disableViewOptions && <DataTableViewOptions table={table} />}
  251. {onRefresh && <RefreshButton onRefresh={onRefresh} isLoading={isLoading ?? false} />}
  252. </div>
  253. </div>
  254. {(pageId && onFilterChange && globalViews.length > 0) ||
  255. columnFilters.filter(f => !facetedFilters?.[f.id]).length > 0 ? (
  256. <div className="flex items-center justify-between bg-muted/40 rounded border border-border p-2 @container">
  257. <div className="flex items-center">
  258. {pageId && onFilterChange && <GlobalViewsBar />}
  259. </div>
  260. <div className="flex gap-1 flex-wrap items-center">
  261. {columnFilters
  262. .filter(f => !facetedFilters?.[f.id])
  263. .map(f => {
  264. const column = table.getColumn(f.id);
  265. const currency = activeChannel?.defaultCurrencyCode ?? 'USD';
  266. return (
  267. <DataTableFilterBadgeEditable
  268. key={f.id}
  269. filter={f}
  270. column={column}
  271. currencyCode={currency}
  272. dataType={
  273. (column?.columnDef.meta as any)?.fieldInfo?.type ?? 'String'
  274. }
  275. onRemove={() =>
  276. setColumnFilters(old => old.filter(x => x.id !== f.id))
  277. }
  278. />
  279. );
  280. })}
  281. {columnFilters.filter(f => !facetedFilters?.[f.id]).length > 0 && (
  282. <Button
  283. variant="ghost"
  284. size="sm"
  285. onClick={() => setColumnFilters([])}
  286. className="text-xs opacity-60 hover:opacity-100"
  287. >
  288. <Trans>Clear all</Trans>
  289. </Button>
  290. )}
  291. </div>
  292. </div>
  293. ) : null}
  294. <div className="rounded-md border my-2 relative shadow-sm">
  295. <Table>
  296. <TableHeader className="bg-muted/50">
  297. {table.getHeaderGroups().map(headerGroup => (
  298. <TableRow key={headerGroup.id}>
  299. {headerGroup.headers.map(header => {
  300. return (
  301. <TableHead key={header.id}>
  302. {header.isPlaceholder
  303. ? null
  304. : flexRender(
  305. header.column.columnDef.header,
  306. header.getContext(),
  307. )}
  308. </TableHead>
  309. );
  310. })}
  311. </TableRow>
  312. ))}
  313. </TableHeader>
  314. <TableBody>
  315. {isLoading && !data?.length ? (
  316. Array.from({ length: Math.min(pagination.pageSize, 100) }).map((_, index) => (
  317. <TableRow
  318. key={`skeleton-${index}`}
  319. className="animate-in fade-in duration-100"
  320. >
  321. {Array.from({ length: visibleColumnCount }).map((_, cellIndex) => (
  322. <TableCell
  323. key={`skeleton-cell-${index}-${cellIndex}`}
  324. className="h-12"
  325. >
  326. <Skeleton className="h-4 my-2 w-full" />
  327. </TableCell>
  328. ))}
  329. </TableRow>
  330. ))
  331. ) : table.getRowModel().rows?.length ? (
  332. table.getRowModel().rows.map(row => (
  333. <TableRow
  334. key={row.id}
  335. data-state={row.getIsSelected() && 'selected'}
  336. className="animate-in fade-in duration-100"
  337. >
  338. {row.getVisibleCells().map(cell => (
  339. <TableCell key={cell.id} className="h-12">
  340. {flexRender(cell.column.columnDef.cell, cell.getContext())}
  341. </TableCell>
  342. ))}
  343. </TableRow>
  344. ))
  345. ) : (
  346. <TableRow className="animate-in fade-in duration-100">
  347. <TableCell colSpan={columns.length} className="h-24 text-center">
  348. <Trans>No results</Trans>
  349. </TableCell>
  350. </TableRow>
  351. )}
  352. {children}
  353. </TableBody>
  354. </Table>
  355. <DataTableBulkActions bulkActions={bulkActions ?? []} table={table} />
  356. </div>
  357. {onPageChange && totalItems != null && <DataTablePagination table={table} />}
  358. </div>
  359. </DataTableProvider>
  360. );
  361. }