paginated-list-data-table.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556
  1. import { DataTableColumnHeader } from '@/components/data-table/data-table-column-header.js';
  2. import { DataTable, FacetedFilter } from '@/components/data-table/data-table.js';
  3. import {
  4. FieldInfo,
  5. getObjectPathToPaginatedList,
  6. getTypeFieldInfo,
  7. } from '@/framework/document-introspection/get-document-structure.js';
  8. import { useListQueryFields } from '@/framework/document-introspection/hooks.js';
  9. import { api } from '@/graphql/api.js';
  10. import { useMutation, useQueryClient } from '@tanstack/react-query';
  11. import { useDebounce } from '@uidotdev/usehooks';
  12. import {
  13. DropdownMenu,
  14. DropdownMenuContent,
  15. DropdownMenuItem,
  16. DropdownMenuTrigger,
  17. } from '@/components/ui/dropdown-menu.js';
  18. import { DisplayComponent } from '@/framework/component-registry/dynamic-component.js';
  19. import { ResultOf } from '@/graphql/graphql.js';
  20. import { Trans, useLingui } from '@/lib/trans.js';
  21. import { TypedDocumentNode } from '@graphql-typed-document-node/core';
  22. import { useQuery } from '@tanstack/react-query';
  23. import {
  24. ColumnFiltersState,
  25. ColumnSort,
  26. createColumnHelper,
  27. SortingState,
  28. Table,
  29. } from '@tanstack/react-table';
  30. import { AccessorKeyColumnDef, ColumnDef, Row, TableOptions, VisibilityState } from '@tanstack/table-core';
  31. import { EllipsisIcon, TrashIcon } from 'lucide-react';
  32. import React, { useMemo } from 'react';
  33. import { toast } from 'sonner';
  34. import { Button } from '../ui/button.js';
  35. // Type that identifies a paginated list structure (has items array and totalItems)
  36. type IsPaginatedList<T> = T extends { items: any[]; totalItems: number } ? true : false;
  37. // Helper type to extract string keys from an object
  38. type StringKeys<T> = T extends object ? Extract<keyof T, string> : never;
  39. // Non-recursive approach to find paginated list paths with max 2 levels
  40. // Level 0: Direct top-level check
  41. type Level0PaginatedLists<T> = T extends object ? (IsPaginatedList<T> extends true ? '' : never) : never;
  42. // Level 1: One level deep
  43. type Level1PaginatedLists<T> = T extends object
  44. ? {
  45. [K in StringKeys<T>]: NonNullable<T[K]> extends object
  46. ? IsPaginatedList<NonNullable<T[K]>> extends true
  47. ? K
  48. : never
  49. : never;
  50. }[StringKeys<T>]
  51. : never;
  52. // Level 2: Two levels deep
  53. type Level2PaginatedLists<T> = T extends object
  54. ? {
  55. [K1 in StringKeys<T>]: NonNullable<T[K1]> extends object
  56. ? {
  57. [K2 in StringKeys<NonNullable<T[K1]>>]: NonNullable<NonNullable<T[K1]>[K2]> extends object
  58. ? IsPaginatedList<NonNullable<NonNullable<T[K1]>[K2]>> extends true
  59. ? `${K1}.${K2}`
  60. : never
  61. : never;
  62. }[StringKeys<NonNullable<T[K1]>>]
  63. : never;
  64. }[StringKeys<T>]
  65. : never;
  66. // Combine all levels
  67. type FindPaginatedListPaths<T> = Level0PaginatedLists<T> | Level1PaginatedLists<T> | Level2PaginatedLists<T>;
  68. // Extract all paths from a TypedDocumentNode
  69. export type PaginatedListPaths<T extends TypedDocumentNode<any, any>> =
  70. FindPaginatedListPaths<ResultOf<T>> extends infer Paths ? (Paths extends '' ? never : Paths) : never;
  71. export type PaginatedListItemFields<
  72. T extends TypedDocumentNode<any, any>,
  73. Path extends PaginatedListPaths<T> = PaginatedListPaths<T>,
  74. > =
  75. // split the path by '.' if it exists
  76. Path extends `${infer First}.${infer Rest}`
  77. ? NonNullable<ResultOf<T>[First]>[Rest]['items'][number]
  78. : Path extends keyof ResultOf<T>
  79. ? ResultOf<T>[Path] extends { items: Array<infer Item> }
  80. ? ResultOf<T>[Path]['items'][number]
  81. : never
  82. : never;
  83. export type PaginatedListKeys<
  84. T extends TypedDocumentNode<any, any>,
  85. Path extends PaginatedListPaths<T> = PaginatedListPaths<T>,
  86. > = {
  87. [K in keyof PaginatedListItemFields<T, Path>]: K;
  88. }[keyof PaginatedListItemFields<T, Path>];
  89. // Utility types to include keys inside `customFields` object for typing purposes
  90. export type CustomFieldKeysOfItem<Item> = Item extends { customFields?: infer CF }
  91. ? Extract<keyof CF, string>
  92. : never;
  93. export type AllItemFieldKeys<T extends TypedDocumentNode<any, any>> =
  94. | keyof PaginatedListItemFields<T>
  95. | CustomFieldKeysOfItem<PaginatedListItemFields<T>>;
  96. export type CustomizeColumnConfig<T extends TypedDocumentNode<any, any>> = {
  97. [Key in AllItemFieldKeys<T>]?: Partial<ColumnDef<PaginatedListItemFields<T>, any>>;
  98. };
  99. export type FacetedFilterConfig<T extends TypedDocumentNode<any, any>> = {
  100. [Key in AllItemFieldKeys<T>]?: FacetedFilter;
  101. };
  102. export type ListQueryFields<T extends TypedDocumentNode<any, any>> = {
  103. [Key in keyof ResultOf<T>]: ResultOf<T>[Key] extends { items: infer U }
  104. ? U extends any[]
  105. ? U[number]
  106. : never
  107. : never;
  108. }[keyof ResultOf<T>];
  109. export type ListQueryShape =
  110. | {
  111. [key: string]: {
  112. items: any[];
  113. totalItems: number;
  114. };
  115. }
  116. | {
  117. [key: string]: {
  118. [key: string]: {
  119. items: any[];
  120. totalItems: number;
  121. };
  122. };
  123. };
  124. export type ListQueryOptionsShape = {
  125. options?: {
  126. skip?: number;
  127. take?: number;
  128. sort?: {
  129. [key: string]: 'ASC' | 'DESC';
  130. };
  131. filter?: any;
  132. };
  133. [key: string]: any;
  134. };
  135. export type AdditionalColumns<T extends TypedDocumentNode<any, any>> = {
  136. [key: string]: ColumnDef<PaginatedListItemFields<T>>;
  137. };
  138. export interface PaginatedListContext {
  139. refetchPaginatedList: () => void;
  140. }
  141. export const PaginatedListContext = React.createContext<PaginatedListContext | undefined>(undefined);
  142. /**
  143. * @description
  144. * Returns the context for the paginated list data table. Must be used within a PaginatedListDataTable.
  145. *
  146. * @example
  147. * ```ts
  148. * const { refetchPaginatedList } = usePaginatedList();
  149. *
  150. * const mutation = useMutation({
  151. * mutationFn: api.mutate(updateFacetValueDocument),
  152. * onSuccess: () => {
  153. * refetchPaginatedList();
  154. * },
  155. * });
  156. * ```
  157. */
  158. export function usePaginatedList() {
  159. const context = React.useContext(PaginatedListContext);
  160. if (!context) {
  161. throw new Error('usePaginatedList must be used within a PaginatedListDataTable');
  162. }
  163. return context;
  164. }
  165. export interface RowAction<T> {
  166. label: React.ReactNode;
  167. onClick?: (row: Row<T>) => void;
  168. }
  169. export type PaginatedListRefresherRegisterFn = (refreshFn: () => void) => void;
  170. export interface PaginatedListDataTableProps<
  171. T extends TypedDocumentNode<U, V>,
  172. U extends ListQueryShape,
  173. V extends ListQueryOptionsShape,
  174. AC extends AdditionalColumns<T>,
  175. > {
  176. listQuery: T;
  177. deleteMutation?: TypedDocumentNode<any, any>;
  178. transformQueryKey?: (queryKey: any[]) => any[];
  179. transformVariables?: (variables: V) => V;
  180. customizeColumns?: CustomizeColumnConfig<T>;
  181. additionalColumns?: AC;
  182. defaultColumnOrder?: (keyof ListQueryFields<T> | keyof AC | CustomFieldKeysOfItem<ListQueryFields<T>>)[];
  183. defaultVisibility?: Partial<Record<AllItemFieldKeys<T>, boolean>>;
  184. onSearchTermChange?: (searchTerm: string) => NonNullable<V['options']>['filter'];
  185. page: number;
  186. itemsPerPage: number;
  187. sorting: SortingState;
  188. columnFilters?: ColumnFiltersState;
  189. onPageChange: (table: Table<any>, page: number, perPage: number) => void;
  190. onSortChange: (table: Table<any>, sorting: SortingState) => void;
  191. onFilterChange: (table: Table<any>, filters: ColumnFiltersState) => void;
  192. onColumnVisibilityChange?: (table: Table<any>, columnVisibility: VisibilityState) => void;
  193. facetedFilters?: FacetedFilterConfig<T>;
  194. rowActions?: RowAction<PaginatedListItemFields<T>>[];
  195. disableViewOptions?: boolean;
  196. transformData?: (data: PaginatedListItemFields<T>[]) => PaginatedListItemFields<T>[];
  197. setTableOptions?: (table: TableOptions<any>) => TableOptions<any>;
  198. /**
  199. * Register a function that allows you to assign a refresh function for
  200. * this list. The function can be assigned to a ref and then called when
  201. * the list needs to be refreshed.
  202. */
  203. registerRefresher?: PaginatedListRefresherRegisterFn;
  204. }
  205. export const PaginatedListDataTableKey = 'PaginatedListDataTable';
  206. export function PaginatedListDataTable<
  207. T extends TypedDocumentNode<U, V>,
  208. U extends Record<string, any> = any,
  209. V extends ListQueryOptionsShape = any,
  210. AC extends AdditionalColumns<T> = AdditionalColumns<T>,
  211. >({
  212. listQuery,
  213. deleteMutation,
  214. transformQueryKey,
  215. transformVariables,
  216. customizeColumns,
  217. additionalColumns,
  218. defaultVisibility,
  219. defaultColumnOrder,
  220. onSearchTermChange,
  221. page,
  222. itemsPerPage,
  223. sorting,
  224. columnFilters,
  225. onPageChange,
  226. onSortChange,
  227. onFilterChange,
  228. onColumnVisibilityChange,
  229. facetedFilters,
  230. rowActions,
  231. disableViewOptions,
  232. setTableOptions,
  233. transformData,
  234. registerRefresher,
  235. }: PaginatedListDataTableProps<T, U, V, AC>) {
  236. const [searchTerm, setSearchTerm] = React.useState<string>('');
  237. const debouncedSearchTerm = useDebounce(searchTerm, 500);
  238. const queryClient = useQueryClient();
  239. const sort = sorting?.reduce((acc: any, sort: ColumnSort) => {
  240. const direction = sort.desc ? 'DESC' : 'ASC';
  241. const field = sort.id;
  242. if (!field || !direction) {
  243. return acc;
  244. }
  245. return { ...acc, [field]: direction };
  246. }, {});
  247. const filter = columnFilters?.length
  248. ? {
  249. _and: columnFilters.map(f => {
  250. if (Array.isArray(f.value)) {
  251. return { [f.id]: { in: f.value } };
  252. }
  253. return { [f.id]: f.value };
  254. }),
  255. }
  256. : undefined;
  257. const defaultQueryKey = [
  258. PaginatedListDataTableKey,
  259. listQuery,
  260. page,
  261. itemsPerPage,
  262. sorting,
  263. filter,
  264. debouncedSearchTerm,
  265. ];
  266. const queryKey = transformQueryKey ? transformQueryKey(defaultQueryKey) : defaultQueryKey;
  267. function refetchPaginatedList() {
  268. queryClient.invalidateQueries({ queryKey });
  269. }
  270. registerRefresher?.(refetchPaginatedList);
  271. const { data } = useQuery({
  272. queryFn: () => {
  273. const searchFilter = onSearchTermChange ? onSearchTermChange(debouncedSearchTerm) : {};
  274. const mergedFilter = { ...filter, ...searchFilter };
  275. const variables = {
  276. options: {
  277. take: itemsPerPage,
  278. skip: (page - 1) * itemsPerPage,
  279. sort,
  280. filter: mergedFilter,
  281. },
  282. } as V;
  283. const transformedVariables = transformVariables ? transformVariables(variables) : variables;
  284. return api.query(listQuery, transformedVariables);
  285. },
  286. queryKey,
  287. });
  288. const fields = useListQueryFields(listQuery);
  289. const paginatedListObjectPath = getObjectPathToPaginatedList(listQuery);
  290. let listData = data as any;
  291. for (const path of paginatedListObjectPath) {
  292. listData = listData?.[path];
  293. }
  294. const columnHelper = createColumnHelper<PaginatedListItemFields<T>>();
  295. const { columns, customFieldColumnNames } = useMemo(() => {
  296. const columnConfigs: Array<{ fieldInfo: FieldInfo; isCustomField: boolean }> = [];
  297. const customFieldColumnNames: string[] = [];
  298. columnConfigs.push(
  299. ...fields // Filter out custom fields
  300. .filter(field => field.name !== 'customFields' && !field.type.endsWith('CustomFields'))
  301. .map(field => ({ fieldInfo: field, isCustomField: false })),
  302. );
  303. const customFieldColumn = fields.find(field => field.name === 'customFields');
  304. if (customFieldColumn && customFieldColumn.type !== 'JSON') {
  305. const customFieldFields = getTypeFieldInfo(customFieldColumn.type);
  306. columnConfigs.push(
  307. ...customFieldFields.map(field => ({ fieldInfo: field, isCustomField: true })),
  308. );
  309. customFieldColumnNames.push(...customFieldFields.map(field => field.name));
  310. }
  311. const queryBasedColumns = columnConfigs.map(({ fieldInfo, isCustomField }) => {
  312. const customConfig = customizeColumns?.[fieldInfo.name as unknown as AllItemFieldKeys<T>] ?? {};
  313. const { header, ...customConfigRest } = customConfig;
  314. const enableColumnFilter = fieldInfo.isScalar && !facetedFilters?.[fieldInfo.name];
  315. return columnHelper.accessor(fieldInfo.name as any, {
  316. id: fieldInfo.name,
  317. meta: { fieldInfo, isCustomField },
  318. enableColumnFilter,
  319. enableSorting: fieldInfo.isScalar,
  320. // Filtering is done on the server side, but we set this to 'equalsString' because
  321. // otherwise the TanStack Table with apply an "auto" function which somehow
  322. // prevents certain filters from working.
  323. filterFn: 'equalsString',
  324. cell: ({ cell, row }) => {
  325. const cellValue = cell.getValue();
  326. const value =
  327. cellValue ??
  328. (isCustomField ? row.original?.customFields?.[fieldInfo.name] : undefined);
  329. if (fieldInfo.list && Array.isArray(value)) {
  330. return value.join(', ');
  331. }
  332. if (
  333. (fieldInfo.type === 'DateTime' && typeof value === 'string') ||
  334. value instanceof Date
  335. ) {
  336. return <DisplayComponent id="vendure:dateTime" value={value} />;
  337. }
  338. if (fieldInfo.type === 'Boolean') {
  339. if (cell.column.id === 'enabled') {
  340. return <DisplayComponent id="vendure:booleanBadge" value={value} />;
  341. } else {
  342. return <DisplayComponent id="vendure:booleanCheckbox" value={value} />;
  343. }
  344. }
  345. if (fieldInfo.type === 'Asset') {
  346. return <DisplayComponent id="vendure:asset" value={value} />;
  347. }
  348. if (value !== null && typeof value === 'object') {
  349. return JSON.stringify(value);
  350. }
  351. return value;
  352. },
  353. header: headerContext => {
  354. return (
  355. <DataTableColumnHeader headerContext={headerContext} customConfig={customConfig} />
  356. );
  357. },
  358. ...customConfigRest,
  359. });
  360. });
  361. let finalColumns = [...queryBasedColumns];
  362. for (const [id, column] of Object.entries(additionalColumns ?? {})) {
  363. if (!id) {
  364. throw new Error('Column id is required');
  365. }
  366. finalColumns.push(columnHelper.accessor(id as any, { ...column, id }));
  367. }
  368. if (defaultColumnOrder) {
  369. // ensure the columns with ids matching the items in defaultColumnOrder
  370. // appear as the first columns in sequence, and leave the remainder in the
  371. // existing order
  372. const orderedColumns = finalColumns
  373. .filter(column => column.id && defaultColumnOrder.includes(column.id as any))
  374. .sort((a, b) => defaultColumnOrder.indexOf(a.id as any) - defaultColumnOrder.indexOf(b.id as any));
  375. const remainingColumns = finalColumns.filter(
  376. column => !column.id || !defaultColumnOrder.includes(column.id as any),
  377. );
  378. finalColumns = [...orderedColumns, ...remainingColumns];
  379. }
  380. if (rowActions || deleteMutation) {
  381. const rowActionColumn = getRowActions(rowActions, deleteMutation);
  382. if (rowActionColumn) {
  383. finalColumns.push(rowActionColumn);
  384. }
  385. }
  386. return { columns: finalColumns, customFieldColumnNames };
  387. }, [fields, customizeColumns, rowActions]);
  388. const columnVisibility = getColumnVisibility(fields, defaultVisibility, customFieldColumnNames);
  389. const transformedData =
  390. typeof transformData === 'function' ? transformData(listData?.items ?? []) : (listData?.items ?? []);
  391. return (
  392. <PaginatedListContext.Provider value={{ refetchPaginatedList }}>
  393. <DataTable
  394. columns={columns}
  395. data={transformedData}
  396. page={page}
  397. itemsPerPage={itemsPerPage}
  398. sorting={sorting}
  399. columnFilters={columnFilters}
  400. totalItems={listData?.totalItems ?? 0}
  401. onPageChange={onPageChange}
  402. onSortChange={onSortChange}
  403. onFilterChange={onFilterChange}
  404. onColumnVisibilityChange={onColumnVisibilityChange}
  405. onSearchTermChange={onSearchTermChange ? term => setSearchTerm(term) : undefined}
  406. defaultColumnVisibility={columnVisibility}
  407. facetedFilters={facetedFilters}
  408. disableViewOptions={disableViewOptions}
  409. setTableOptions={setTableOptions}
  410. onRefresh={refetchPaginatedList}
  411. />
  412. </PaginatedListContext.Provider>
  413. );
  414. }
  415. function getRowActions(
  416. rowActions?: RowAction<any>[],
  417. deleteMutation?: TypedDocumentNode<any, any>,
  418. ): AccessorKeyColumnDef<any> | undefined {
  419. return {
  420. id: 'actions',
  421. accessorKey: 'actions',
  422. header: 'Actions',
  423. enableColumnFilter: false,
  424. cell: ({ row }) => {
  425. return (
  426. <DropdownMenu>
  427. <DropdownMenuTrigger asChild>
  428. <Button variant="ghost" size="icon">
  429. <EllipsisIcon />
  430. </Button>
  431. </DropdownMenuTrigger>
  432. <DropdownMenuContent>
  433. {rowActions?.map((action, index) => (
  434. <DropdownMenuItem onClick={() => action.onClick?.(row)} key={index}>
  435. {action.label}
  436. </DropdownMenuItem>
  437. ))}
  438. {deleteMutation && (
  439. <DeleteMutationRowAction deleteMutation={deleteMutation} row={row} />
  440. )}
  441. </DropdownMenuContent>
  442. </DropdownMenu>
  443. );
  444. },
  445. };
  446. }
  447. function DeleteMutationRowAction({
  448. deleteMutation,
  449. row,
  450. }: {
  451. deleteMutation: TypedDocumentNode<any, any>;
  452. row: Row<{ id: string }>;
  453. }) {
  454. const { refetchPaginatedList } = usePaginatedList();
  455. const { i18n } = useLingui();
  456. const { mutate: deleteMutationFn } = useMutation({
  457. mutationFn: api.mutate(deleteMutation),
  458. onSuccess: (result: { [key: string]: { result: 'DELETED' | 'NOT_DELETED'; message: string } }) => {
  459. const unwrappedResult = Object.values(result)[0];
  460. if (unwrappedResult.result === 'DELETED') {
  461. refetchPaginatedList();
  462. toast.success(i18n.t('Deleted successfully'));
  463. } else {
  464. toast.error(i18n.t('Failed to delete'), {
  465. description: unwrappedResult.message,
  466. });
  467. }
  468. },
  469. onError: (err: Error) => {
  470. toast.error(i18n.t('Failed to delete'), {
  471. description: err.message,
  472. });
  473. },
  474. });
  475. return (
  476. <DropdownMenuItem onClick={() => deleteMutationFn({ id: row.original.id })}>
  477. <div className="flex items-center gap-2 text-destructive">
  478. <TrashIcon className="w-4 h-4 text-destructive" />
  479. <Trans>Delete</Trans>
  480. </div>
  481. </DropdownMenuItem>
  482. );
  483. }
  484. /**
  485. * Returns the default column visibility configuration.
  486. */
  487. function getColumnVisibility(
  488. fields: FieldInfo[],
  489. defaultVisibility?: Record<string, boolean | undefined>,
  490. customFieldColumnNames?: string[],
  491. ): Record<string, boolean> {
  492. const allDefaultsTrue = defaultVisibility && Object.values(defaultVisibility).every(v => v === true);
  493. const allDefaultsFalse = defaultVisibility && Object.values(defaultVisibility).every(v => v === false);
  494. return {
  495. id: false,
  496. createdAt: false,
  497. updatedAt: false,
  498. ...(allDefaultsTrue ? { ...Object.fromEntries(fields.map(f => [f.name, false])) } : {}),
  499. ...(allDefaultsFalse ? { ...Object.fromEntries(fields.map(f => [f.name, true])) } : {}),
  500. // Make custom fields hidden by default unless overridden
  501. ...(customFieldColumnNames
  502. ? { ...Object.fromEntries(customFieldColumnNames.map(f => [f, false])) }
  503. : {}),
  504. ...defaultVisibility,
  505. };
  506. }