From 481258484c79e21db974092da396e255f2c21ab4 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sat, 15 Jul 2023 20:27:35 -0700 Subject: [PATCH] Add reusable virtual table hook --- .../virtual-table/hooks/use-virtual-table.ts | 255 ++++++++++++++++++ .../components/virtual-table/index.tsx | 95 ++++--- src/renderer/store/list.store.ts | 32 +-- 3 files changed, 330 insertions(+), 52 deletions(-) create mode 100644 src/renderer/components/virtual-table/hooks/use-virtual-table.ts diff --git a/src/renderer/components/virtual-table/hooks/use-virtual-table.ts b/src/renderer/components/virtual-table/hooks/use-virtual-table.ts new file mode 100644 index 00000000..a6ea7f36 --- /dev/null +++ b/src/renderer/components/virtual-table/hooks/use-virtual-table.ts @@ -0,0 +1,255 @@ +import { + BodyScrollEvent, + ColDef, + GetRowIdParams, + GridReadyEvent, + IDatasource, + PaginationChangedEvent, + RowDoubleClickedEvent, + RowModelType, +} from '@ag-grid-community/core'; +import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; +import { QueryKey, useQueryClient } from '@tanstack/react-query'; +import debounce from 'lodash/debounce'; +import { MutableRefObject, useCallback, useMemo } from 'react'; +import { generatePath, useNavigate } from 'react-router'; +import { BasePaginatedResponse, LibraryItem } from '/@/renderer/api/types'; +import { getColumnDefs, VirtualTableProps } from '/@/renderer/components/virtual-table'; +import { SetContextMenuItems, useHandleTableContextMenu } from '/@/renderer/features/context-menu'; +import { AppRoute } from '/@/renderer/router/routes'; +import { ListDeterministicArgs, ListItemProps, ListTableProps } from '/@/renderer/store'; +import { ListDisplayType, ServerListItem, TablePagination } from '/@/renderer/types'; + +export type AgGridFetchFn = ( + args: { filter: TFilter; limit: number; startIndex: number }, + signal?: AbortSignal, +) => Promise; + +interface UseAgGridProps { + contextMenu: SetContextMenuItems; + fetch: { + filter: TFilter; + fn: AgGridFetchFn; + itemCount?: number; + queryKey: (id: string, query?: Record) => QueryKey; + server: ServerListItem | null; + }; + itemCount?: number; + itemType: LibraryItem; + pageKey: string; + properties: ListItemProps; + setTable: (args: { data: Partial } & ListDeterministicArgs) => void; + setTablePagination: (args: { data: Partial } & ListDeterministicArgs) => void; + tableRef: MutableRefObject; +} + +export const useVirtualTable = ({ + fetch, + tableRef, + properties, + setTable, + setTablePagination, + pageKey, + itemType, + contextMenu, + itemCount, +}: UseAgGridProps) => { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + const isPaginationEnabled = properties.display === ListDisplayType.TABLE_PAGINATED; + + const columnDefs: ColDef[] = useMemo(() => { + return getColumnDefs(properties.table.columns); + }, [properties.table.columns]); + + const defaultColumnDefs: ColDef = useMemo(() => { + return { + lockPinned: true, + lockVisible: true, + resizable: true, + }; + }, []); + + const onGridSizeChange = () => { + if (properties.table.autoFit) { + tableRef?.current?.api.sizeColumnsToFit(); + } + }; + + const onGridReady = useCallback( + (params: GridReadyEvent) => { + const dataSource: IDatasource = { + getRows: async (params) => { + const limit = params.endRow - params.startRow; + const startIndex = params.startRow; + + const queryKey = fetch.queryKey(fetch.server?.id || '', { + limit, + startIndex, + ...fetch.filter, + }); + + const results = (await queryClient.fetchQuery( + queryKey, + async ({ signal }) => { + const res = await fetch.fn( + { + filter: fetch.filter, + limit, + startIndex, + }, + signal, + ); + + return res; + }, + + { cacheTime: 1000 * 60 * 1 }, + )) as BasePaginatedResponse; + + params.successCallback(results?.items || [], results?.totalRecordCount || 0); + }, + rowCount: undefined, + }; + + params.api.setDatasource(dataSource); + params.api.ensureIndexVisible(properties.table.scrollOffset || 0, 'top'); + }, + [fetch, properties.table.scrollOffset, queryClient], + ); + + const onPaginationChanged = useCallback( + (event: PaginationChangedEvent) => { + if (!isPaginationEnabled || !event.api) return; + + try { + // Scroll to top of page on pagination change + const currentPageStartIndex = + properties.table.pagination.currentPage * + properties.table.pagination.itemsPerPage; + event.api?.ensureIndexVisible(currentPageStartIndex, 'top'); + } catch (err) { + console.log(err); + } + + setTablePagination({ + data: { + itemsPerPage: event.api.paginationGetPageSize(), + totalItems: event.api.paginationGetRowCount(), + totalPages: event.api.paginationGetTotalPages() + 1, + }, + key: pageKey, + }); + }, + [ + isPaginationEnabled, + setTablePagination, + pageKey, + properties.table.pagination.currentPage, + properties.table.pagination.itemsPerPage, + ], + ); + + const onColumnMoved = useCallback(() => { + const { columnApi } = tableRef?.current || {}; + const columnsOrder = columnApi?.getAllGridColumns(); + + if (!columnsOrder) return; + + const columnsInSettings = properties.table.columns; + const updatedColumns = []; + for (const column of columnsOrder) { + const columnInSettings = columnsInSettings.find( + (c) => c.column === column.getColDef().colId, + ); + + if (columnInSettings) { + updatedColumns.push({ + ...columnInSettings, + ...(!properties.table.autoFit && { + width: column.getActualWidth(), + }), + }); + } + } + + setTable({ data: { columns: updatedColumns }, key: pageKey }); + }, [pageKey, properties.table.autoFit, properties.table.columns, setTable, tableRef]); + + const onColumnResized = debounce(onColumnMoved, 200); + + const onBodyScrollEnd = (e: BodyScrollEvent) => { + const scrollOffset = Number((e.top / properties.table.rowHeight).toFixed(0)); + setTable({ data: { scrollOffset }, key: pageKey }); + }; + + const onCellContextMenu = useHandleTableContextMenu(itemType, contextMenu); + + const defaultTableProps: Partial = useMemo(() => { + return { + alwaysShowHorizontalScroll: true, + autoFitColumns: properties.table.autoFit, + blockLoadDebounceMillis: 200, + getRowId: (data: GetRowIdParams) => data.data.id, + infiniteInitialRowCount: itemCount || 100, + pagination: isPaginationEnabled, + paginationAutoPageSize: isPaginationEnabled, + paginationPageSize: properties.table.pagination.itemsPerPage || 100, + paginationProps: isPaginationEnabled + ? { + pageKey, + pagination: properties.table.pagination, + setPagination: setTablePagination, + } + : undefined, + rowBuffer: 20, + rowHeight: properties.table.rowHeight || 40, + rowModelType: 'infinite' as RowModelType, + suppressRowDrag: true, + }; + }, [ + isPaginationEnabled, + itemCount, + pageKey, + properties.table.autoFit, + properties.table.pagination, + properties.table.rowHeight, + setTablePagination, + ]); + + const onRowDoubleClicked = useCallback( + (e: RowDoubleClickedEvent) => { + switch (itemType) { + case LibraryItem.ALBUM: + navigate(generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: e.data.id })); + break; + case LibraryItem.ARTIST: + navigate( + generatePath(AppRoute.LIBRARY_ARTISTS_DETAIL, { artistId: e.data.id }), + ); + break; + case LibraryItem.PLAYLIST: + navigate(generatePath(AppRoute.PLAYLISTS_DETAIL, { playlistId: e.data.id })); + break; + default: + break; + } + }, + [itemType, navigate], + ); + + return { + columnDefs, + defaultColumnDefs, + onBodyScrollEnd, + onCellContextMenu, + onColumnMoved, + onColumnResized, + onGridReady, + onGridSizeChange, + onPaginationChanged, + onRowDoubleClicked, + ...defaultTableProps, + }; +}; diff --git a/src/renderer/components/virtual-table/index.tsx b/src/renderer/components/virtual-table/index.tsx index dfd24304..b713fa16 100644 --- a/src/renderer/components/virtual-table/index.tsx +++ b/src/renderer/components/virtual-table/index.tsx @@ -1,4 +1,3 @@ -/* eslint-disable import/no-cycle */ import { Ref, forwardRef, useRef, useEffect, useCallback, useMemo } from 'react'; import type { ICellRendererParams, @@ -19,6 +18,7 @@ import { useClickOutside, useMergedRef } from '@mantine/hooks'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; import formatDuration from 'format-duration'; +import { AnimatePresence } from 'framer-motion'; import { generatePath } from 'react-router'; import styled from 'styled-components'; import { AlbumArtistCell } from '/@/renderer/components/virtual-table/cells/album-artist-cell'; @@ -29,9 +29,10 @@ import { GenreCell } from '/@/renderer/components/virtual-table/cells/genre-cell import { GenericTableHeader } from '/@/renderer/components/virtual-table/headers/generic-table-header'; import { AppRoute } from '/@/renderer/router/routes'; import { PersistedTableColumn } from '/@/renderer/store/settings.store'; -import { TableColumn } from '/@/renderer/types'; +import { TableColumn, TablePagination as TablePaginationType } from '/@/renderer/types'; import { FavoriteCell } from '/@/renderer/components/virtual-table/cells/favorite-cell'; import { RatingCell } from '/@/renderer/components/virtual-table/cells/rating-cell'; +import { TablePagination } from '/@/renderer/components/virtual-table/table-pagination'; export * from './table-config-dropdown'; export * from './table-pagination'; @@ -352,10 +353,15 @@ export const getColumnDefs = (columns: PersistedTableColumn[]) => { return columnDefs; }; -interface VirtualTableProps extends AgGridReactProps { +export interface VirtualTableProps extends AgGridReactProps { autoFitColumns?: boolean; autoHeight?: boolean; deselectOnClickOutside?: boolean; + paginationProps?: { + pageKey: string; + pagination: TablePaginationType; + setPagination: any; + }; transparentHeader?: boolean; } @@ -370,6 +376,7 @@ export const VirtualTable = forwardRef( onNewColumnsLoaded, onGridReady, onGridSizeChanged, + paginationProps, ...rest }: VirtualTableProps, ref: Ref, @@ -453,40 +460,54 @@ export const VirtualTable = forwardRef( ); return ( - - - + <> + + + {paginationProps && ( + + + + )} + + ); }, ); diff --git a/src/renderer/store/list.store.ts b/src/renderer/store/list.store.ts index 9d53fbd3..51e4f6f9 100644 --- a/src/renderer/store/list.store.ts +++ b/src/renderer/store/list.store.ts @@ -29,17 +29,17 @@ export type ListKey = keyof ListState['item'] | string; type FilterType = AlbumListFilter | SongListFilter | AlbumArtistListFilter; -type ListTableProps = { +export type ListTableProps = { pagination: TablePagination; scrollOffset: number; } & DataTableProps; -type ListGridProps = { +export type ListGridProps = { itemsPerRow?: number; scrollOffset?: number; }; -type ItemProps = { +export type ListItemProps = { display: ListDisplayType; filter: TFilter; grid?: ListGridProps; @@ -48,31 +48,33 @@ type ItemProps = { export interface ListState { detail: { - [key: string]: Omit, 'display'>; + [key: string]: Omit, 'display'>; }; item: { - album: ItemProps; - albumArtist: ItemProps; - albumDetail: ItemProps; - song: ItemProps; + album: ListItemProps; + albumArtist: ListItemProps; + albumDetail: ListItemProps; + song: ListItemProps; }; } -type DeterministicArgs = { key: ListKey }; +export type ListDeterministicArgs = { key: ListKey }; export interface ListSlice extends ListState { _actions: { getFilter: (args: { id?: string; itemType: LibraryItem; key?: string }) => FilterType; resetFilter: () => void; - setDisplayType: (args: { data: ListDisplayType } & DeterministicArgs) => void; + setDisplayType: (args: { data: ListDisplayType } & ListDeterministicArgs) => void; setFilter: ( - args: { data: Partial; itemType: LibraryItem } & DeterministicArgs, + args: { data: Partial; itemType: LibraryItem } & ListDeterministicArgs, ) => FilterType; - setGrid: (args: { data: Partial } & DeterministicArgs) => void; + setGrid: (args: { data: Partial } & ListDeterministicArgs) => void; setStore: (data: Partial) => void; - setTable: (args: { data: Partial } & DeterministicArgs) => void; - setTableColumns: (args: { data: PersistedTableColumn[] } & DeterministicArgs) => void; - setTablePagination: (args: { data: Partial } & DeterministicArgs) => void; + setTable: (args: { data: Partial } & ListDeterministicArgs) => void; + setTableColumns: (args: { data: PersistedTableColumn[] } & ListDeterministicArgs) => void; + setTablePagination: ( + args: { data: Partial } & ListDeterministicArgs, + ) => void; }; }