From ee2918039b38164317a672274f18f5abf9015b41 Mon Sep 17 00:00:00 2001 From: mohamedsalem401 Date: Thu, 15 Feb 2024 19:27:38 +0200 Subject: [PATCH] Add pagination for events table --- apps/webapp/package.json | 1 + apps/webapp/src/App.tsx | 26 +++-- .../components/api-data-table-pagination.tsx | 84 +++++++++++++++ apps/webapp/src/components/api-data-table.tsx | 102 ++++++++++++++++++ .../src/components/events/EventsTable.tsx | 28 +++-- apps/webapp/src/hooks/use-events-count.tsx | 20 ++++ .../webapp/src/hooks/use-query-pagination.tsx | 65 +++++++++++ apps/webapp/src/hooks/useEvents.tsx | 24 +++-- apps/webapp/src/lib/utils.ts | 8 +- apps/webapp/src/types/index.ts | 5 + .../api/src/@core/events/events.controller.ts | 33 +++++- .../api/src/@core/events/events.service.ts | 17 ++- .../src/@core/utils/dtos/pagination.dto.ts | 19 ++++ packages/api/swagger/swagger-spec.json | 45 +++++++- pnpm-lock.yaml | 26 +++++ 15 files changed, 470 insertions(+), 33 deletions(-) create mode 100644 apps/webapp/src/components/api-data-table-pagination.tsx create mode 100644 apps/webapp/src/components/api-data-table.tsx create mode 100644 apps/webapp/src/hooks/use-events-count.tsx create mode 100644 apps/webapp/src/hooks/use-query-pagination.tsx create mode 100644 packages/api/src/@core/utils/dtos/pagination.dto.ts diff --git a/apps/webapp/package.json b/apps/webapp/package.json index abb1de92e..e737d570b 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -51,6 +51,7 @@ "sonner": "^1.4.0", "tailwind-merge": "^2.0.0", "tailwindcss-animate": "^1.0.7", + "use-query-params": "^2.2.1", "zod": "^3.22.4", "zustand": "^4.4.7" }, diff --git a/apps/webapp/src/App.tsx b/apps/webapp/src/App.tsx index b59b0f2df..9355e2ca0 100644 --- a/apps/webapp/src/App.tsx +++ b/apps/webapp/src/App.tsx @@ -1,6 +1,8 @@ import './App.css'; import { ThemeProvider } from '@/components/theme-provider'; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import { QueryParamProvider } from 'use-query-params'; +import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6'; import LogsPage from './components/events'; import ConnectionsPage from './components/connections'; import TaskPage from './components/events/EventsTable'; @@ -33,17 +35,19 @@ function App() { - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + diff --git a/apps/webapp/src/components/api-data-table-pagination.tsx b/apps/webapp/src/components/api-data-table-pagination.tsx new file mode 100644 index 000000000..ce9da01a3 --- /dev/null +++ b/apps/webapp/src/components/api-data-table-pagination.tsx @@ -0,0 +1,84 @@ +import { ChevronLeftIcon, ChevronRightIcon, DoubleArrowLeftIcon, DoubleArrowRightIcon } from '@radix-ui/react-icons'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; +import { Button } from './ui/button'; + +import { type UseQueryPaginationReturn } from '@/hooks/use-query-pagination'; + +interface DataTablePaginationProps extends UseQueryPaginationReturn { + selected: number; + isLoading: boolean; +} + +export function ApiDataTablePagination(props: DataTablePaginationProps) { + return ( +
+
+ {props.selected} of {props.totalItems} row(s) selected. +
+
+
+

Rows per page

+ +
+
+ Page {props.page} of {props.totalPages} +
+
+ + + + +
+
+
+ ); +} diff --git a/apps/webapp/src/components/api-data-table.tsx b/apps/webapp/src/components/api-data-table.tsx new file mode 100644 index 000000000..9c5214159 --- /dev/null +++ b/apps/webapp/src/components/api-data-table.tsx @@ -0,0 +1,102 @@ +import { + ColumnDef, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getSortedRowModel, + SortingState, + useReactTable, + VisibilityState, +} from '@tanstack/react-table'; +import { useState } from 'react'; + +import { ApiDataTablePagination } from './api-data-table-pagination'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table'; +import { LoadingSpinner } from './connections/components/LoadingSpinner'; + +import { type UseQueryPaginationReturn } from '@/hooks/use-query-pagination'; + +interface DataTableProps extends UseQueryPaginationReturn { + columns: ColumnDef[]; + data: TData[]; + isLoading: boolean; +} +export function ApiDataTable({ + columns, + data, + isLoading, + ...paginationProps +}: DataTableProps) { + const [rowSelection, setRowSelection] = useState({}); + const [columnVisibility, setColumnVisibility] = useState({}); + const [sorting, setSorting] = useState([]); + + const table = useReactTable({ + data, + columns, + state: { + sorting, + columnVisibility, + rowSelection, + }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + onSortingChange: setSorting, + onColumnVisibilityChange: setColumnVisibility, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + }); + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + )) + ) : ( + + + No results. + + + )} + +
+ + {isLoading && ( +
+ +
+ )} +
+ +
+ ); +} \ No newline at end of file diff --git a/apps/webapp/src/components/events/EventsTable.tsx b/apps/webapp/src/components/events/EventsTable.tsx index f9af044a3..4b0ffaae2 100644 --- a/apps/webapp/src/components/events/EventsTable.tsx +++ b/apps/webapp/src/components/events/EventsTable.tsx @@ -1,11 +1,25 @@ import { columns } from "./components/columns" -import { DataTable } from "../shared/data-table" +import { ApiDataTable } from '../api-data-table'; import useEvents from "@/hooks/useEvents"; import { DataTableLoading } from "../shared/data-table-loading"; import { events as Event } from "api"; +import { useEventsCount } from '@/hooks/use-events-count'; +import { useQueryPagination } from '@/hooks/use-query-pagination'; export default function EventsTable() { - const { data: events, isLoading, error } = useEvents(); + const { data: eventsCount } = useEventsCount(); + + const pagination = useQueryPagination({ totalItems: eventsCount }); + + const { + data: events, + isLoading, + isFetching, + error, + } = useEvents({ + page: pagination.page, + pageSize: pagination.pageSize, + }); //TODO const transformedEvents = events?.map((event: Event) => ({ @@ -17,8 +31,8 @@ export default function EventsTable() { date: event.timestamp.toLocaleString(), // convert Date to string })); - const sortedTransformedEvents = transformedEvents?.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); - + // Already did it at api level + // const sortedTransformedEvents = transformedEvents?.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); if(isLoading){ return ( @@ -33,7 +47,9 @@ export default function EventsTable() { return ( <> - {sortedTransformedEvents && } + {transformedEvents && ( + + )} - ) + ); } \ No newline at end of file diff --git a/apps/webapp/src/hooks/use-events-count.tsx b/apps/webapp/src/hooks/use-events-count.tsx new file mode 100644 index 000000000..d6d2f2dd6 --- /dev/null +++ b/apps/webapp/src/hooks/use-events-count.tsx @@ -0,0 +1,20 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import config from '@/utils/config'; + +const fetchEventsCount = async (): Promise => { + const response = await fetch(`${config.API_URL}/events/count`); + + if (!response.ok) { + throw new Error('Network response was not ok'); + } + + return response.json(); +}; + +export const useEventsCount = () => { + return useQuery({ + queryKey: ['events count'], + queryFn: fetchEventsCount, + placeholderData: keepPreviousData, + }); +}; \ No newline at end of file diff --git a/apps/webapp/src/hooks/use-query-pagination.tsx b/apps/webapp/src/hooks/use-query-pagination.tsx new file mode 100644 index 000000000..e0edfd3ae --- /dev/null +++ b/apps/webapp/src/hooks/use-query-pagination.tsx @@ -0,0 +1,65 @@ +import { useCallback, useMemo } from 'react'; +import { useQueryParam } from 'use-query-params'; + +import { NumberParamWithDefault } from '@/lib/utils'; + +function getTotalPages(totalItems: number, pageSize: number) { + return Math.ceil(totalItems / pageSize); +} + +interface UseQueryPaginationProps { + totalItems?: number; +} + +const DEFAULT_PAGE = 1; +const DEFAULT_PAGE_SIZE = 10; + +export const useQueryPagination = ({ totalItems = 0 }: UseQueryPaginationProps = {}) => { + const [page, setPage] = useQueryParam('page', NumberParamWithDefault(DEFAULT_PAGE)); + const [pageSize, setPageSize] = useQueryParam('pageSize', NumberParamWithDefault(DEFAULT_PAGE_SIZE)); + + const totalPages = useMemo(() => { + return getTotalPages(totalItems, pageSize); + }, [totalItems, pageSize]); + + const nextEnabled = useMemo(() => page < totalPages, [page, totalPages]); + const previousEnabled = useMemo(() => page > 1, [page]); + + const setNextPage = useCallback(() => { + if (!nextEnabled) return; + setPage((page) => page + 1); + }, [nextEnabled, setPage]); + + const setPreviousPage = useCallback(() => { + if (!previousEnabled) return; + setPage((page) => page - 1); + }, [previousEnabled, setPage]); + + const resetPage = useCallback(() => { + setPage(DEFAULT_PAGE); + }, [setPage]); + + const handlePageSizeChange = useCallback( + (newPageSize: number) => { + setPageSize(newPageSize); + setPage(DEFAULT_PAGE); + }, + [setPageSize, setPage] + ); + + return { + page, + setPage, + resetPage, + pageSize, + setPageSize: handlePageSizeChange, + totalItems, + totalPages: totalPages === 0 ? 1 : totalPages, + nextEnabled, + previousEnabled, + setNextPage, + setPreviousPage, + }; +}; +export type UseQueryPaginationReturn = ReturnType; + diff --git a/apps/webapp/src/hooks/useEvents.tsx b/apps/webapp/src/hooks/useEvents.tsx index 4f56ffb8c..8a279eb52 100644 --- a/apps/webapp/src/hooks/useEvents.tsx +++ b/apps/webapp/src/hooks/useEvents.tsx @@ -1,20 +1,28 @@ +import { PaginationParams } from '@/types'; import config from '@/utils/config'; -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { events as Event } from 'api'; -const fetchEvents = async (): Promise => { - const response = await fetch(`${config.API_URL}/events`); +const fetchEvents = async (params: PaginationParams): Promise => { + const searchParams = new URLSearchParams({ + page: params.page.toString(), + pageSize: params.pageSize.toString(), + }); + + const response = await fetch(`${config.API_URL}/events?${searchParams.toString()}`); + if (!response.ok) { throw new Error('Network response was not ok'); } return response.json(); -} +}; -const useEvents = () => { +const useEvents = (params: PaginationParams) => { return useQuery({ - queryKey: ['events'], - queryFn: fetchEvents + queryKey: ['events', { page: params.page, pageSize: params.pageSize }], + queryFn: () => fetchEvents(params), + placeholderData: keepPreviousData, }); }; -export default useEvents; +export default useEvents; \ No newline at end of file diff --git a/apps/webapp/src/lib/utils.ts b/apps/webapp/src/lib/utils.ts index ec79801fe..38b5bd532 100644 --- a/apps/webapp/src/lib/utils.ts +++ b/apps/webapp/src/lib/utils.ts @@ -1,6 +1,12 @@ import { type ClassValue, clsx } from "clsx" import { twMerge } from "tailwind-merge" - +import { NumberParam, withDefault } from 'use-query-params'; + + export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export function NumberParamWithDefault(num: number) { + return withDefault(NumberParam, num); +} \ No newline at end of file diff --git a/apps/webapp/src/types/index.ts b/apps/webapp/src/types/index.ts index a02296a5f..9b5e2f66c 100644 --- a/apps/webapp/src/types/index.ts +++ b/apps/webapp/src/types/index.ts @@ -1,4 +1,9 @@ export interface HookBaseReturn { isLoading: boolean; error: Error | null; +} + +export interface PaginationParams { + page: number; + pageSize: number; } \ No newline at end of file diff --git a/packages/api/src/@core/events/events.controller.ts b/packages/api/src/@core/events/events.controller.ts index 0549f5169..fc541c7cd 100644 --- a/packages/api/src/@core/events/events.controller.ts +++ b/packages/api/src/@core/events/events.controller.ts @@ -1,8 +1,15 @@ -import { Controller, Get } from '@nestjs/common'; +import { + Controller, + Get, + Query, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; import { EventsService } from './events.service'; import { LoggerService } from '@@core/logger/logger.service'; -import { PrismaService } from '@@core/prisma/prisma.service'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { PaginationDto } from '@@core/utils/dtos/pagination.dto'; + @ApiTags('events') @Controller('events') export class EventsController { @@ -15,8 +22,26 @@ export class EventsController { @ApiOperation({ operationId: 'getEvents', summary: 'Retrieve Events' }) @ApiResponse({ status: 200 }) + @UsePipes( + new ValidationPipe({ + whitelist: true, + transform: true, + transformOptions: { + enableImplicitConversion: true, + }, + }), + ) @Get() - async getEvents() { - return await this.eventsService.findEvents(); + async getEvents(@Query() dto: PaginationDto) { + return await this.eventsService.findEvents(dto); + } + + @ApiOperation({ + operationId: 'getEventsCount', + summary: 'Retrieve Events Count', + }) + @Get('count') + async getEventsCount() { + return await this.eventsService.getEventsCount(); } } diff --git a/packages/api/src/@core/events/events.service.ts b/packages/api/src/@core/events/events.service.ts index e51df4514..0d2b0b171 100644 --- a/packages/api/src/@core/events/events.service.ts +++ b/packages/api/src/@core/events/events.service.ts @@ -1,5 +1,6 @@ import { LoggerService } from '@@core/logger/logger.service'; import { PrismaService } from '@@core/prisma/prisma.service'; +import { PaginationDto } from '@@core/utils/dtos/pagination.dto'; import { handleServiceError } from '@@core/utils/errors'; import { Injectable } from '@nestjs/common'; @@ -8,9 +9,21 @@ export class EventsService { constructor(private prisma: PrismaService, private logger: LoggerService) { this.logger.setContext(EventsService.name); } - async findEvents() { + async findEvents(dto: PaginationDto) { try { - return await this.prisma.events.findMany(); + return await this.prisma.events.findMany({ + orderBy: { timestamp: 'desc' }, + skip: (dto.page - 1) * dto.pageSize, + take: dto.pageSize, + }); + } catch (error) { + handleServiceError(error, this.logger); + } + } + + async getEventsCount() { + try { + return await this.prisma.events.count(); } catch (error) { handleServiceError(error, this.logger); } diff --git a/packages/api/src/@core/utils/dtos/pagination.dto.ts b/packages/api/src/@core/utils/dtos/pagination.dto.ts new file mode 100644 index 000000000..5b72e00bc --- /dev/null +++ b/packages/api/src/@core/utils/dtos/pagination.dto.ts @@ -0,0 +1,19 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNumber, IsPositive, IsOptional } from 'class-validator'; + +const DEFAULT_PAGE = 1; +const DEFAULT_PAGE_SIZE = 10; + +export class PaginationDto { + @IsNumber() + @IsPositive() + @IsOptional() + @ApiPropertyOptional() + page: number = DEFAULT_PAGE; + + @IsNumber() + @IsPositive() + @IsOptional() + @ApiPropertyOptional() + pageSize: number = DEFAULT_PAGE_SIZE; +} diff --git a/packages/api/swagger/swagger-spec.json b/packages/api/swagger/swagger-spec.json index 705bd9469..970ec6e57 100644 --- a/packages/api/swagger/swagger-spec.json +++ b/packages/api/swagger/swagger-spec.json @@ -555,7 +555,28 @@ "get": { "operationId": "getEvents", "summary": "Retrieve Events", - "parameters": [], + "parameters": [ + { + "name": "page", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "default": 1, + "type": "number" + } + }, + { + "name": "pageSize", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "default": 10, + "type": "number" + } + } + ], "responses": { "200": { "description": "" @@ -566,6 +587,28 @@ ] } }, + "/events/count": { + "get": { + "operationId": "getEventsCount", + "summary": "Retrieve Events Count", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "number" + } + } + } + } + }, + "tags": [ + "events" + ] + } + }, "/magic-link/create": { "post": { "operationId": "createMagicLink", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d2f03753..d2b8a9b58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -266,6 +266,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.1) + use-query-params: + specifier: ^2.2.1 + version: 2.2.1(react-dom@18.2.0)(react-router-dom@6.22.0)(react@18.2.0) zod: specifier: ^3.22.4 version: 3.22.4 @@ -11345,6 +11348,10 @@ packages: randombytes: 2.1.0 dev: true + /serialize-query-params@2.0.2: + resolution: {integrity: sha512-1chMo1dST4pFA9RDXAtF0Rbjaut4is7bzFbI1Z26IuMub68pNCILku85aYmeFhvnY//BXUPUhoRMjYcsT93J/Q==} + dev: false + /serve-static@1.15.0: resolution: {integrity: sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==} engines: {node: '>= 0.8.0'} @@ -12564,6 +12571,25 @@ packages: tslib: 2.6.2 dev: false + /use-query-params@2.2.1(react-dom@18.2.0)(react-router-dom@6.22.0)(react@18.2.0): + resolution: {integrity: sha512-i6alcyLB8w9i3ZK3caNftdb+UnbfBRNPDnc89CNQWkGRmDrm/gfydHvMBfVsQJRq3NoHOM2dt/ceBWG2397v1Q==} + peerDependencies: + '@reach/router': ^1.2.1 + react: '>=16.8.0' + react-dom: '>=16.8.0' + react-router-dom: '>=5' + peerDependenciesMeta: + '@reach/router': + optional: true + react-router-dom: + optional: true + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-router-dom: 6.22.0(react-dom@18.2.0)(react@18.2.0) + serialize-query-params: 2.0.2 + dev: false + /use-sidecar@1.1.2(@types/react@18.2.55)(react@18.2.0): resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==} engines: {node: '>=10'}