From db7a5444b375114dab0d59fbbbc009e9ef2be7a2 Mon Sep 17 00:00:00 2001 From: Noah Spannbauer Date: Mon, 16 Mar 2026 07:28:01 -0500 Subject: [PATCH] adding pagination to logbook table --- api/src/log/log.controller.spec.ts | 22 +-- api/src/log/log.controller.ts | 7 +- api/src/log/log.interceptor.ts | 22 ++- api/src/log/log.service.spec.ts | 20 ++- api/src/log/log.service.ts | 14 +- client/src/components/logbook/Logbook.tsx | 144 +++++++++++++++--- .../logbook/LogbookState.interface.ts | 5 +- client/src/components/logbook/reducer.ts | 32 +++- 8 files changed, 206 insertions(+), 60 deletions(-) diff --git a/api/src/log/log.controller.spec.ts b/api/src/log/log.controller.spec.ts index 4f28318..fe38434 100644 --- a/api/src/log/log.controller.spec.ts +++ b/api/src/log/log.controller.spec.ts @@ -13,7 +13,7 @@ describe('LogController', () => { create: jest.fn(), delete: jest.fn(), find: jest.fn(), - findAll: jest.fn(), + findLogsWithCount: jest.fn(), update: jest.fn() } @@ -69,32 +69,32 @@ describe('LogController', () => { } }) - it('findAll => should find all logs', async () => { + it('findLogsWithCount => should find all logs', async () => { const id: string = '95834f84-0a02-44d3-884e-a20237adeca0'; const log = { } as LogEntity; const logs = [log] - jest.spyOn(mockLogService, 'findAll').mockReturnValue(logs); + jest.spyOn(mockLogService, 'findLogsWithCount').mockReturnValue(logs); - const result = await controller.findAll(); + const result = await controller.findLogsWithCount(); expect(result).toEqual(logs); - expect(mockLogService.findAll).toHaveBeenCalled(); + expect(mockLogService.findLogsWithCount).toHaveBeenCalled(); }) - it('findAll => should fail to find all logs', async () => { - jest.spyOn(mockLogService, 'findAll').mockRejectedValue(new Error('Logs not found')) + it('findLogsWithCount => should fail to find logs with count', async () => { + jest.spyOn(mockLogService, 'findLogsWithCount').mockRejectedValue(new Error('Logs not found')) try { - await controller.findAll(); + await controller.findLogsWithCount(); - fail('findAll did not throw error'); + fail('findLogsWithCount did not throw error'); } catch (error) { expect(error).toBeInstanceOf(Error); - expect(mockLogService.findAll).toHaveBeenCalled(); - expect(mockLogService.findAll).rejects.toThrow('Logs not found') + expect(mockLogService.findLogsWithCount).toHaveBeenCalled(); + expect(mockLogService.findLogsWithCount).rejects.toThrow('Logs not found') } }) diff --git a/api/src/log/log.controller.ts b/api/src/log/log.controller.ts index 57e7fac..e7f1be8 100644 --- a/api/src/log/log.controller.ts +++ b/api/src/log/log.controller.ts @@ -7,6 +7,7 @@ import { Param, Post, Put, + Query, UseGuards, UseInterceptors } from '@nestjs/common'; @@ -47,13 +48,13 @@ export class LogController { @Get() @Public() - async findAll(): Promise { + async findLogsWithCount(@Query('skip') skip?, @Query('take') take?: number,): Promise<{ entities: LogEntity[], total: number, hasNextPage: boolean }> { try { - return await this.logService.findAll(); + return await this.logService.findLogsWithCount(skip, take) } catch (error) { const customError = error as CustomError; - throw new HttpException(customError.message, customError.statusCode); + throw new HttpException(customError.message, customError.statusCode) } } diff --git a/api/src/log/log.interceptor.ts b/api/src/log/log.interceptor.ts index 9013c3a..a431cb2 100644 --- a/api/src/log/log.interceptor.ts +++ b/api/src/log/log.interceptor.ts @@ -15,10 +15,10 @@ export class LogInterceptor implements NestInterceptor { ]); return handler.handle().pipe( - map((data: LogEntity[]) => { + map((data: { entities: LogEntity[], total: number, hasNextPage: boolean }) => { const req = context.switchToHttp().getRequest(); - const limitData = (data) => { - return data.map((log: LogEntity) => { + const limitData = (entities: LogEntity[]) => { + return entities.map((log: LogEntity) => { return { id: log.id, pilot: { @@ -41,17 +41,25 @@ export class LogInterceptor implements NestInterceptor { const rolesKeyName = Object.keys(jwtPayload).find((key) => key.includes('roles')); if (jwtPayload[rolesKeyName].includes('Flying.Read')) { - const logs = limitData(data); + const logs = limitData(data.entities); - return logs; + return { + entities: logs, + total: data.total, + hasNextPage: data.hasNextPage + }; } else { return data; } } else if (!req.headers.authorization && isPublic) { - const publicData = limitData(data) + const publicData = limitData(data.entities) const logs = publicData.slice(0, 5) - return logs; + return { + entities: logs, + total: data.total, + hasNextPage: data.hasNextPage + }; } }) ); diff --git a/api/src/log/log.service.spec.ts b/api/src/log/log.service.spec.ts index f0ee18c..4794311 100644 --- a/api/src/log/log.service.spec.ts +++ b/api/src/log/log.service.spec.ts @@ -17,7 +17,7 @@ describe('LogService', () => { const mockLogRepository = { delete: jest.fn(), - find: jest.fn(), + findAndCount: jest.fn(), findOne: jest.fn(), findOneBy: jest.fn(), save: jest.fn(), @@ -181,7 +181,7 @@ describe('LogService', () => { }) }) - it('findAll => should find all log entries', async () => { + it('findLogsWithCount => should find log entries with count', async () => { const log = { id: 'd685f1ca-28e0-40b9-8713-74467db12965', pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936', @@ -209,14 +209,18 @@ describe('LogService', () => { notes: 'This is a test', tracks: [] } as LogEntity; - const logs = [log] + const logs = [log]; + const count = 1 + const morePages = false + + jest.spyOn(mockLogRepository, 'findAndCount').mockReturnValue([logs, count, morePages]); - jest.spyOn(mockLogRepository, 'find').mockReturnValue(logs); + const {entities, total, hasNextPage} = await service.findLogsWithCount(); - const result = await service.findAll(); - - expect(result).toEqual(logs); - expect(mockLogRepository.find).toHaveBeenCalled(); + expect(entities).toEqual(logs); + expect(count).toEqual(total); + expect(hasNextPage).toEqual(morePages); + expect(mockLogRepository.findAndCount).toHaveBeenCalled(); }) it('update => should update a log entry', async () => { diff --git a/api/src/log/log.service.ts b/api/src/log/log.service.ts index 2b12158..db65ed9 100644 --- a/api/src/log/log.service.ts +++ b/api/src/log/log.service.ts @@ -25,10 +25,18 @@ export class LogService { return logEntity; } - async findAll(): Promise { - return await this.logRepository.find({ + async findLogsWithCount(skip?: number, take?: number): Promise<{entities: LogEntity[], total: number, hasNextPage: boolean}> { + const [entities, total] = await this.logRepository.findAndCount({ + take, + skip, relations: ['pilot', 'tracks'] - }); + }) + + return { + entities, + total, + hasNextPage: skip + take < total + } } async create(logDto: LogDto): Promise { diff --git a/client/src/components/logbook/Logbook.tsx b/client/src/components/logbook/Logbook.tsx index b320dfe..e80742f 100644 --- a/client/src/components/logbook/Logbook.tsx +++ b/client/src/components/logbook/Logbook.tsx @@ -12,16 +12,16 @@ import { UserRole } from '../../enums/userRole'; import { useBreakpoints } from '../../hooks/useBreakpoints/UseBreakpoints'; import { ScreenSize } from '../../enums/screenSize'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faAdd, faEllipsisVertical, faPen, faEye, faTrash, faMapLocationDot } from '@fortawesome/free-solid-svg-icons' -import { CellContext, ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, HeaderContext, useReactTable } from '@tanstack/react-table'; +import { faAdd, faEllipsisVertical, faPen, faEye, faTrash, faAngleLeft, faAngleRight, faAnglesLeft, faAnglesRight } from '@fortawesome/free-solid-svg-icons' +import { CellContext, ColumnDef, flexRender, getCoreRowModel, HeaderContext, PaginationState, useReactTable } from '@tanstack/react-table'; import { useLogbookContext } from '../../hooks/logbookContext/UseLogbookContext'; import LogbookDrawer from '../logbookDrawer/LogbookDrawer'; import Alert from '../alert/Alert'; const Logbook: React.FC = () => { const [state, dispatch] = useReducer(reducer, initialState); - const [columnVisibility, setColumnVisibility] = useState({}) - const logbookContext = useLogbookContext() + const [columnVisibility, setColumnVisibility] = useState({}); + const logbookContext = useLogbookContext(); const { isUserLoggedIn } = useOidc(); const { userRole } = useUserRole(); const { screenSize } = useBreakpoints(); @@ -338,31 +338,51 @@ const Logbook: React.FC = () => { actions ]; + const onPaginationChange = (updater: any) => { + const newPaginationState: PaginationState = typeof updater === 'function' ? updater(state.pagination) : updater; + + dispatch({ type: 'SET_PAGINATION', payload: newPaginationState }) + } + const table = useReactTable({ data: state.entries, columns: columns, getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - initialState: { - pagination: { - pageIndex: 0, - pageSize: 100 - } - }, + manualPagination: true, onColumnVisibilityChange: setColumnVisibility, + onPaginationChange: onPaginationChange, + rowCount: state.totalEntries, state: { columnVisibility: columnVisibility, + pagination: state.pagination } }); - const getLogbookEntries = async () => { + const { + firstPage, + getCanNextPage, + getCanPreviousPage, + getPageCount, + getState, + lastPage, + nextPage, + previousPage, + setPageIndex + } = table; + + const getLogbookEntries = async (pageIndex?: number, pageSize?: number) => { try { dispatch({ type: 'SET_IS_LOADING', payload: true }); - const response: AxiosResponse = await httpClient.get(`api/logs`); + const response: AxiosResponse = await httpClient.get(`api/logs`, { + params: { + skip: pageIndex, + take: pageSize + } + }); - if (response.data.length > 0) { - const entries: LogbookEntry[] = response.data; + if (response.data.entities.length > 0) { + const entries: LogbookEntry[] = response.data.entities; const entryColumns: string[] = Object.keys(entries[0]); const columnVisibility: {[key: string]: boolean} = {} @@ -381,7 +401,7 @@ const Logbook: React.FC = () => { entries.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); setColumnVisibility(columnVisibility) - dispatch({ type: 'SET_ENTRIES', payload: entries }); + dispatch({ type: 'SET_ENTRIES', payload: { entries: entries, totalEntries: response.data.total }}); if (!isUserLoggedIn && response.data.length >= 5) { dispatch({ type: 'SET_ALERT', payload: { severity: 'info', message: 'A limited number of log entries displayed. Sign in to view all log entries.'}}) @@ -448,7 +468,7 @@ const Logbook: React.FC = () => { payload: false }); logbookContext.dispatch({ type: 'SET_SELECTED_LOG_ID', payload: '' }) - await getLogbookEntries(); + await getLogbookEntries(state.pagination?.pageIndex, state.pagination?.pageSize); } catch (error) { const axiosError = error as AxiosError; @@ -468,11 +488,35 @@ const Logbook: React.FC = () => { }); }; + const onRowsPerPageChanged = (event: any) => { + console.log(event.target.value) + const newPaginationState: PaginationState = { + pageIndex: 0, + pageSize: event.target.value !== 'All' ? Number(event.target.value) : state.totalEntries + }; + console.log(newPaginationState) + dispatch({ type: 'SET_PAGINATION', payload: newPaginationState }) + } + useEffect(() => { if (!logbookContext.state.isDrawerOpen) { - getLogbookEntries(); + getLogbookEntries(state.pagination.pageIndex, state.pagination.pageSize); } - }, [logbookContext.state.isDrawerOpen]); + }, [logbookContext.state.isDrawerOpen, state.pagination.pageIndex, state.pagination.pageSize]); + + useEffect(() => { + const pages: number[] = []; + + if (state.pagination?.pageSize) { + const totalPages = getPageCount(); + + for(let i = 0; i < totalPages; i++) { + pages.push(i + 1) + } + } + + dispatch({ type: 'SET_PAGES', payload: pages }) + }, [state.totalEntries, state.pagination?.pageSize]) return ( <> @@ -504,8 +548,24 @@ const Logbook: React.FC = () => { )} {!state.isLoading && state.entries.length > 0 && screenSize !== ScreenSize.SM && ( -
-
+
+
+
+ + +
{table.getHeaderGroups().map((headerGroup, headerGroupIndex) => ( @@ -591,6 +651,48 @@ const Logbook: React.FC = () => {
+ {state.pagination.pageSize !== state.totalEntries && +
+
+ + + {state.pages.map((page, index) => { + return ( + + ) + })} + + +
+
+ }
)} {state.entries.length > 0 && screenSize === ScreenSize.SM && diff --git a/client/src/components/logbook/LogbookState.interface.ts b/client/src/components/logbook/LogbookState.interface.ts index 90cba7c..8887641 100644 --- a/client/src/components/logbook/LogbookState.interface.ts +++ b/client/src/components/logbook/LogbookState.interface.ts @@ -1,4 +1,4 @@ -import { ColumnDef } from '@tanstack/react-table'; +import { ColumnDef, PaginationState } from '@tanstack/react-table'; import { FormMode } from '../../enums/formMode'; import { Alert } from '../../interfaces/Alert.interface'; import { LogbookEntry } from './LogbookEntry.interface'; @@ -10,4 +10,7 @@ export interface LogbookState { isConfirmDialogLoading: boolean; isConfirmDialogOpen: boolean; isLoading: boolean; + pages: number[]; + pagination: PaginationState; + totalEntries: number; } diff --git a/client/src/components/logbook/reducer.ts b/client/src/components/logbook/reducer.ts index 829a3c9..87f5c57 100644 --- a/client/src/components/logbook/reducer.ts +++ b/client/src/components/logbook/reducer.ts @@ -1,4 +1,4 @@ -import { ColumnDef } from '@tanstack/react-table'; +import { ColumnDef, PaginationState } from '@tanstack/react-table'; import { FormMode } from '../../enums/formMode'; import { Alert } from '../../interfaces/Alert.interface'; import { LogbookEntry } from './LogbookEntry.interface'; @@ -7,11 +7,12 @@ import { LogbookState } from './LogbookState.interface'; type Action = | { type: 'SET_COLUMNS'; payload: ColumnDef[] } | { type: 'SET_IS_CONFIRMATION_DIALOG_OPEN'; payload: boolean } - | { type: 'SET_ENTRIES'; payload: LogbookEntry[] } + | { type: 'SET_ENTRIES'; payload: { entries: LogbookEntry[], totalEntries: number } } | { type: 'SET_ALERT'; payload: Alert | undefined } | { type: 'SET_IS_CONFIRMATION_DIALOG_LOADING'; payload: boolean } - | { type: 'SET_IS_LOADING'; payload: boolean }; - + | { type: 'SET_IS_LOADING'; payload: boolean } + | { type: 'SET_PAGES'; payload: number[] } + | { type: 'SET_PAGINATION'; payload: PaginationState }; export const initialState: LogbookState = { alert: undefined, @@ -19,7 +20,13 @@ export const initialState: LogbookState = { entries: [], isConfirmDialogLoading: false, isConfirmDialogOpen: false, - isLoading: false + isLoading: false, + pages: [], + pagination: { + pageIndex: 0, + pageSize: 10 + }, + totalEntries: 0 }; export const reducer = ( @@ -42,7 +49,8 @@ export const reducer = ( case 'SET_ENTRIES': { return { ...state, - entries: action.payload + entries: action.payload.entries, + totalEntries: action.payload.totalEntries }; } case 'SET_ALERT': { @@ -63,6 +71,18 @@ export const reducer = ( isLoading: action.payload }; } + case 'SET_PAGES': { + return { + ...state, + pages: action.payload + } + } + case 'SET_PAGINATION': { + return { + ...state, + pagination: action.payload + } + } default: { return state; } -- 2.49.1