Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement booking pagination #19

Merged
merged 2 commits into from
Mar 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions src/features/bookings/hooks/useBookings.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';

import { getBookings } from '@/services/apiBookings';
import { BOOKINGS_QUERY_KEY } from '@/utils/constants';
import { BOOKINGS_QUERY_KEY, PAGE_SIZE } from '@/utils/constants';
import { BookingFilter, BookingSort, BookingSortableFields } from '@/types/bookings';
import { SortDirection } from '@mswjs/data/lib/query/queryTypes';

export function useBookings(userId: string | null | undefined) {
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();

const filterValue = searchParams.get('status');
const filter: BookingFilter | null =
Expand All @@ -16,15 +17,33 @@ export function useBookings(userId: string | null | undefined) {
const sortValue = searchParams.get('sort');
let sort: BookingSort | null = null;

const pageValue = searchParams.get('page');
const pageNum = pageValue ? Number.parseFloat(pageValue) : 1;

if (sortValue) {
const [sortBy, sortDirection] = sortValue.split('-');
sort = { field: sortBy as BookingSortableFields, direction: sortDirection as SortDirection };
}

return useQuery({
queryKey: [BOOKINGS_QUERY_KEY, userId, filter, sort],
queryFn: () => getBookings(userId, filter, sort),
const { data, error, isError, isLoading } = useQuery({
queryKey: [BOOKINGS_QUERY_KEY, userId, filter, sort, pageNum],
queryFn: () => getBookings(userId, { filter, sort, pageNum }),
staleTime: Infinity,
cacheTime: Infinity,
});

// Prefetching next page if data exists and not on the last page
if (data) {
const pageCount = Math.ceil(data.count / PAGE_SIZE);
if (pageNum < pageCount) {
queryClient.prefetchQuery({
queryKey: [BOOKINGS_QUERY_KEY, userId, filter, sort, pageNum + 1],
queryFn: () => getBookings(userId, { filter, sort, pageNum: pageNum + 1 }),
staleTime: Infinity,
cacheTime: Infinity,
});
}
}

return { data, error, isError, isLoading };
}
12 changes: 9 additions & 3 deletions src/pages/Bookings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import { useBookings } from '@/features/bookings/hooks/useBookings';
import Spinner from '@/ui/spinner/Spinner';
import BookingTable from '@/features/bookings/BookingTable';
import BookingFilterAndSortControls from '@/features/bookings/BookingFilterAndSortControls';
import Pagination from '@/ui/Pagination';

function Bookings() {
const { data: user } = useUser();
const { data: bookings, isLoading } = useBookings(user?.id);
const { data: bookingsData, isLoading, isError, error } = useBookings(user?.id);

return (
<>
Expand All @@ -18,8 +19,13 @@ function Bookings() {
</FlexRow>
{isLoading ? (
<Spinner />
) : bookings && bookings.length > 0 ? (
<BookingTable bookings={bookings} />
) : isError && error instanceof Error ? (
<p className="mt-1">{error.message}</p>
) : bookingsData && bookingsData.count > 0 ? (
<>
<BookingTable bookings={bookingsData.bookings} />
<Pagination totalCount={bookingsData.count} />
</>
) : (
<p className="mt-1">No bookings to show.</p>
)}
Expand Down
78 changes: 76 additions & 2 deletions src/pages/__tests__/Bookings.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';

import supabase from '@/services/supabase';
import { userSession } from '@/test/fixtures/authentication';
import { renderWithQueryClient } from '@/test/utils';
import Bookings from '../Bookings';
import { mockServer } from '@/test/mocks/server';
import { BOOKINGS_BASE_URL } from '@/utils/constants';
import { BOOKINGS_BASE_URL, PAGE_SIZE } from '@/utils/constants';
import { MemoryRouter } from 'react-router-dom';
import { bookings } from '@/test/fixtures/bookings';
import { cabins } from '@/test/fixtures/cabins';
import { guests } from '@/test/fixtures/guests';

function setup() {
renderWithQueryClient(
Expand Down Expand Up @@ -174,8 +178,11 @@ describe('Bookings', () => {
expect(thirdBookingCells[2]).toHaveTextContent(/apr 10, 2024/i);
});

// Pagination tests
// In bookings fixture, there are 3 bookings and page size is 2

it('should sort bookings by start date in descending order correctly', async () => {
setupWithLogin('?sort=start_date-desc');
setupWithLogin('?sort=start_date-desc&page=1');

const bookingRows = await screen.findAllByRole('row');

Expand All @@ -187,4 +194,71 @@ describe('Bookings', () => {
expect(secondBookingCells[2]).toHaveTextContent(/feb 21, 2024/i);
expect(thirdBookingCells[2]).toHaveTextContent(/feb 20, 2024/i);
});

it('should have pagination text', async () => {
setupWithLogin('?page=1');

const pageNum = 1;
const paginationTextElement = await screen.findByText(
(_, element) =>
element?.textContent === `Showing 1-${pageNum * PAGE_SIZE} of ${bookings.length} results`
);

expect(paginationTextElement).toBeInTheDocument();
});

it('pagination previous button should be disabled when on the first page', async () => {
setupWithLogin('?page=1');
const previousButton = await screen.findByRole('button', { name: /previous/i });
expect(previousButton).toBeDisabled();
});

it('pagination next button should be disabled when on the last page', async () => {
setupWithLogin('?page=2');
const nextButton = await screen.findByRole('button', { name: /next/i });
expect(nextButton).toBeDisabled();
});

it('should show the contents of the next page when next button is clicked', async () => {
setupWithLogin('?page=1');
const user = userEvent.setup();
const nextButton = await screen.findByRole('button', { name: /next/i });

await user.click(nextButton);

const pageNum = 2;
const paginationTextElement = screen.getByText(
(_, element) =>
element?.textContent ===
`Showing ${(pageNum - 1) * PAGE_SIZE + 1}-${bookings.length} of ${bookings.length} results`
);

const bookingRows = screen.getAllByRole('row');
const bookingCells = within(bookingRows[1]).getAllByRole('cell');

expect(paginationTextElement).toBeInTheDocument();
expect(bookingCells[0]).toHaveTextContent(cabins[1].name);
expect(bookingCells[1]).toHaveTextContent(`${guests[1].full_name}${guests[1].email}`);
});

it('should show the contents of the previous page when previous button is clicked', async () => {
setupWithLogin('?page=2');
const user = userEvent.setup();
const previousButton = await screen.findByRole('button', { name: /previous/i });

await user.click(previousButton);

const pageNum = 1;
const paginationTextElement = screen.getByText(
(_, element) =>
element?.textContent === `Showing 1-${pageNum * PAGE_SIZE} of ${bookings.length} results`
);

const bookingRows = screen.getAllByRole('row');
const firstBookingCells = within(bookingRows[1]).getAllByRole('cell');

expect(paginationTextElement).toBeInTheDocument();
expect(firstBookingCells[0]).toHaveTextContent(cabins[0].name);
expect(firstBookingCells[1]).toHaveTextContent(`${guests[0].full_name}${guests[0].email}`);
});
});
35 changes: 28 additions & 7 deletions src/services/apiBookings.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { BookingFilter, BookingSort } from '@/types/bookings';
import supabase from './supabase';
import { PAGE_SIZE } from '@/utils/constants';

export async function getBookings(
userId: string | null | undefined,
filter: BookingFilter | null,
sort: BookingSort | null
) {
if (!userId) return [];
type GetBookingsConfig = {
filter: BookingFilter | null;
sort: BookingSort | null;
pageNum: number | null;
};

export async function getBookings(userId: string | null | undefined, config: GetBookingsConfig) {
if (!userId) return { bookings: [], count: 0 };

const { filter, sort, pageNum } = config;

let query = supabase
.from('booking')
Expand Down Expand Up @@ -39,12 +44,28 @@ export async function getBookings(
query = query.order(sort.field, { ascending: sort.direction === 'asc' });
}

// Need to make an extra query to get the total count of bookings before pagination
// If a query is made with the count option, it returns a response object with a count property
// This causes problems when sending MSW mock responses
const { data: nonPaginatedBookings, error: nonPaginatedBookingsError } = await query;

if (nonPaginatedBookingsError) {
console.error(nonPaginatedBookingsError);
throw new Error('Bookings could not be loaded.');
}

if (pageNum) {
const from = (pageNum - 1) * PAGE_SIZE;
const to = from + PAGE_SIZE - 1;
query = query.range(from, to);
}

const { data: bookings, error } = await query;

if (error) {
console.error(error);
throw new Error('Bookings could not be loaded.');
}

return bookings;
return { bookings, count: nonPaginatedBookings ? nonPaginatedBookings.length : 0 };
}
17 changes: 14 additions & 3 deletions src/test/mocks/domains/bookings.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import { rest } from 'msw';
import { SortDirection } from '@mswjs/data/lib/query/queryTypes';

import { BOOKINGS_BASE_URL } from '@/utils/constants';
import { BOOKINGS_BASE_URL, PAGE_SIZE } from '@/utils/constants';
import { db } from '../db';

export const bookingHandlers = [
rest.get(BOOKINGS_BASE_URL, (req, res, ctx) => {
const url = new URL(req.url);

// the query param value will be like 'eq.unconfirmed', ?status=eq.unconfirmed
const statusFilterValue = url.searchParams.get('status')?.split('.')[1];

// the sort query param will be like order=total_price.desc
// When sending the request to msw server, the query string needs to be like ?sort=total_price-desc
// If no sort direction is specified, then 'desc' will be used
const sortValue = url.searchParams.get('order');

// Pagination query params will be like offset=0 and limit=5
const offsetValue = url.searchParams.get('offset');

if (statusFilterValue && sortValue) {
const [sortBy, sortDirection] = sortValue.split('.');
const where = { status: { equals: statusFilterValue } };
Expand Down Expand Up @@ -61,6 +64,14 @@ export const bookingHandlers = [
}
}

if (offsetValue) {
const bookings = db.booking.findMany({
skip: Number.parseFloat(offsetValue),
take: PAGE_SIZE,
});
return res(ctx.json(bookings));
}

const bookings = db.booking.getAll();
return res(ctx.json(bookings));
}),
Expand Down
5 changes: 5 additions & 0 deletions src/ui/Filter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@ export default function Filter<Field extends string>({ options, field }: FilterP
const currentFilter = searchParams.get(field);

function setFilterSearchParam(filterValue: string) {
// Reset page query param if it exists
if (searchParams.get('page')) searchParams.set('page', '1');

searchParams.set(field, filterValue);
setSearchParams(searchParams);
}

function removeFilterSearchParam() {
if (searchParams.get('page')) searchParams.set('page', '1');

searchParams.delete(field);
setSearchParams(searchParams);
}
Expand Down
Loading
Loading