diff --git a/src/features/bookings/BookingFilterAndSortControls.tsx b/src/features/bookings/BookingFilterAndSortControls.tsx new file mode 100644 index 0000000..21faf91 --- /dev/null +++ b/src/features/bookings/BookingFilterAndSortControls.tsx @@ -0,0 +1,40 @@ +import { BookingFields, BookingSortableFields } from '@/types/bookings'; +import { SortDirections } from '@/types/sort'; +import Filter from '@/ui/Filter'; +import SortDropdown from '@/ui/SortDropdown'; +import styled from 'styled-components'; + +type SortOptions = { + label: string; + value: `${BookingSortableFields}-${SortDirections}`; +}[]; + +const filterOptions = [ + { label: 'Unconfirmed', value: 'unconfirmed' }, + { label: 'Checked-in', value: 'checked-in' }, + { label: 'Checked-out', value: 'checked-out' }, +]; + +const sortOptions: SortOptions = [ + { label: 'Date (latest to oldest)', value: 'start_date-desc' }, + { label: 'Date (oldest to latest)', value: 'start_date-asc' }, + { label: 'Price (highest to lowest)', value: 'total_price-desc' }, + { label: 'Price (lowest to highest)', value: 'total_price-asc' }, +]; + +function BookingFilterAndSortControls() { + return ( + + options={filterOptions} field="status" /> + + + ); +} + +export default BookingFilterAndSortControls; + +const Container = styled.div` + & > *:first-child { + margin-bottom: 1rem; + } +`; diff --git a/src/features/bookings/hooks/useBookings.ts b/src/features/bookings/hooks/useBookings.ts index 66c1973..a6ae0c4 100644 --- a/src/features/bookings/hooks/useBookings.ts +++ b/src/features/bookings/hooks/useBookings.ts @@ -1,11 +1,29 @@ +import { useSearchParams } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; + import { getBookings } from '@/services/apiBookings'; import { BOOKINGS_QUERY_KEY } from '@/utils/constants'; -import { useQuery } from '@tanstack/react-query'; +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 filterValue = searchParams.get('status'); + const filter: BookingFilter | null = + !filterValue || filterValue === 'all' ? null : { field: 'status', value: filterValue }; + + const sortValue = searchParams.get('sort'); + let sort: BookingSort | null = null; + + if (sortValue) { + const [sortBy, sortDirection] = sortValue.split('-'); + sort = { field: sortBy as BookingSortableFields, direction: sortDirection as SortDirection }; + } + return useQuery({ - queryKey: [BOOKINGS_QUERY_KEY, userId], - queryFn: () => getBookings(userId), + queryKey: [BOOKINGS_QUERY_KEY, userId, filter, sort], + queryFn: () => getBookings(userId, filter, sort), staleTime: Infinity, cacheTime: Infinity, }); diff --git a/src/pages/Bookings.tsx b/src/pages/Bookings.tsx index 693f774..a0147de 100644 --- a/src/pages/Bookings.tsx +++ b/src/pages/Bookings.tsx @@ -4,6 +4,7 @@ import { useUser } from '@/features/authentication/hooks/useUser'; 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'; function Bookings() { const { data: user } = useUser(); @@ -13,6 +14,7 @@ function Bookings() { <> Bookings + {isLoading ? ( diff --git a/src/pages/__tests__/Bookings.test.tsx b/src/pages/__tests__/Bookings.test.tsx index d12d456..5aef10d 100644 --- a/src/pages/__tests__/Bookings.test.tsx +++ b/src/pages/__tests__/Bookings.test.tsx @@ -1,4 +1,4 @@ -import { screen } from '@testing-library/react'; +import { screen, within } from '@testing-library/react'; import { rest } from 'msw'; import supabase from '@/services/supabase'; @@ -7,16 +7,25 @@ import { renderWithQueryClient } from '@/test/utils'; import Bookings from '../Bookings'; import { mockServer } from '@/test/mocks/server'; import { BOOKINGS_BASE_URL } from '@/utils/constants'; +import { MemoryRouter } from 'react-router-dom'; function setup() { - renderWithQueryClient(); + renderWithQueryClient( + + + + ); } -function setupWithLogin() { +function setupWithLogin(initialPath: string = '') { // Setting logged in user vi.spyOn(supabase.auth, 'getSession').mockResolvedValue(userSession); - renderWithQueryClient(); + renderWithQueryClient( + + + + ); } afterEach(() => { @@ -61,4 +70,121 @@ describe('Bookings', () => { const bookingTable = await screen.findByRole('table', { name: /bookings/i }); expect(bookingTable).toBeInTheDocument(); }); + + it('should show booking status filter controls', () => { + setup(); + + const elements = [ + screen.getByRole('button', { name: /all/i }), + screen.getByRole('button', { name: /unconfirmed/i }), + screen.getByRole('button', { name: /^checked-in$/i }), + screen.getByRole('button', { name: /^checked-out$/i }), + ]; + + elements.forEach(element => expect(element).toBeInTheDocument()); + }); + + it('should show a select control with sort options', () => { + setup(); + + const sortBySelect = screen.getByLabelText(/sort by/i); + + expect(sortBySelect).toBeInTheDocument(); + + const options = [ + within(sortBySelect).getByRole('option', { name: /sort by/i }), + within(sortBySelect).getByRole('option', { name: /^date \(latest to oldest\)$/i }), + within(sortBySelect).getByRole('option', { name: /^date \(oldest to latest\)$/i }), + within(sortBySelect).getByRole('option', { name: /^price \(highest to lowest\)$/i }), + within(sortBySelect).getByRole('option', { name: /^price \(lowest to highest\)$/i }), + ]; + + options.forEach(option => expect(option).toBeInTheDocument()); + }); + + it('should only show bookings with unconfirmed status if unconfirmed filter is selected', async () => { + setupWithLogin('?status=unconfirmed'); + + const bookingRows = await screen.findAllByRole('row'); + + // In bookings fixture, there is one booking with unconfirmed status + // The first row in bookingRows is the column header row + expect(bookingRows.length - 1).toEqual(1); + }); + + it('should only show bookings with checked-in status if checked-in filter is selected', async () => { + setupWithLogin('?status=checked-in'); + + const bookingRows = await screen.findAllByRole('row'); + + // In bookings fixture, there is one booking with checked-in status + expect(bookingRows.length - 1).toEqual(1); + }); + + it('should only show bookings with checked-out status if checked-out filter is selected', async () => { + setupWithLogin('?status=checked-out'); + + const bookingRows = await screen.findAllByRole('row'); + + // In bookings fixture, there is one booking with checked-out status + expect(bookingRows.length - 1).toEqual(1); + }); + + it('should sort bookings by price in ascending order correctly', async () => { + setupWithLogin('?sort=total_price-asc'); + + const bookingRows = await screen.findAllByRole('row'); + + // The first row in bookingRows is the column header row + + const firstBookingCells = within(bookingRows[1]).getAllByRole('cell'); + const secondBookingCells = within(bookingRows[2]).getAllByRole('cell'); + const thirdBookingCells = within(bookingRows[3]).getAllByRole('cell'); + + expect(firstBookingCells[4]).toHaveTextContent('$300.00'); + expect(secondBookingCells[4]).toHaveTextContent('$600.00'); + expect(thirdBookingCells[4]).toHaveTextContent('$650.00'); + }); + + it('should sort bookings by price in descending order correctly', async () => { + setupWithLogin('?sort=total_price-desc'); + + const bookingRows = await screen.findAllByRole('row'); + + const firstBookingCells = within(bookingRows[1]).getAllByRole('cell'); + const secondBookingCells = within(bookingRows[2]).getAllByRole('cell'); + const thirdBookingCells = within(bookingRows[3]).getAllByRole('cell'); + + expect(firstBookingCells[4]).toHaveTextContent('$650.00'); + expect(secondBookingCells[4]).toHaveTextContent('$600.00'); + expect(thirdBookingCells[4]).toHaveTextContent('$300.00'); + }); + + it('should sort bookings by start date in ascending order correctly', async () => { + setupWithLogin('?sort=start_date-asc'); + + const bookingRows = await screen.findAllByRole('row'); + + const firstBookingCells = within(bookingRows[1]).getAllByRole('cell'); + const secondBookingCells = within(bookingRows[2]).getAllByRole('cell'); + const thirdBookingCells = within(bookingRows[3]).getAllByRole('cell'); + + expect(firstBookingCells[2]).toHaveTextContent(/feb 20, 2024/i); + expect(secondBookingCells[2]).toHaveTextContent(/feb 21, 2024/i); + expect(thirdBookingCells[2]).toHaveTextContent(/apr 10, 2024/i); + }); + + it('should sort bookings by start date in descending order correctly', async () => { + setupWithLogin('?sort=start_date-desc'); + + const bookingRows = await screen.findAllByRole('row'); + + const firstBookingCells = within(bookingRows[1]).getAllByRole('cell'); + const secondBookingCells = within(bookingRows[2]).getAllByRole('cell'); + const thirdBookingCells = within(bookingRows[3]).getAllByRole('cell'); + + expect(firstBookingCells[2]).toHaveTextContent(/apr 10, 2024/i); + expect(secondBookingCells[2]).toHaveTextContent(/feb 21, 2024/i); + expect(thirdBookingCells[2]).toHaveTextContent(/feb 20, 2024/i); + }); }); diff --git a/src/services/apiBookings.ts b/src/services/apiBookings.ts index 6a48842..9d8fb9b 100644 --- a/src/services/apiBookings.ts +++ b/src/services/apiBookings.ts @@ -1,15 +1,46 @@ +import { BookingFilter, BookingSort } from '@/types/bookings'; import supabase from './supabase'; -export async function getBookings(userId: string | null | undefined) { +export async function getBookings( + userId: string | null | undefined, + filter: BookingFilter | null, + sort: BookingSort | null +) { if (!userId) return []; - const { data: bookings, error } = await supabase + let query = supabase .from('booking') .select( 'id, created_at, start_date, end_date, num_nights, num_guests, status, total_price, cabin(name, id), guest(id, full_name, email)' ) .eq('user_id', userId); + if (filter) { + switch (filter.comparisonMethod) { + case 'gt': + query = query.gt(filter.field, filter.value); + break; + case 'gte': + query = query.gte(filter.field, filter.value); + break; + case 'lt': + query = query.lt(filter.field, filter.value); + break; + case 'lte': + query = query.lte(filter.field, filter.value); + break; + default: + query = query.eq(filter.field, filter.value); + break; + } + } + + if (sort) { + query = query.order(sort.field, { ascending: sort.direction === 'asc' }); + } + + const { data: bookings, error } = await query; + if (error) { console.error(error); throw new Error('Bookings could not be loaded.'); diff --git a/src/test/fixtures/bookings.ts b/src/test/fixtures/bookings.ts index 8f4988e..4bd4c2e 100644 --- a/src/test/fixtures/bookings.ts +++ b/src/test/fixtures/bookings.ts @@ -26,11 +26,11 @@ export const bookings: Booking[] = [ { id: 2, created_at: '2024-02-20T23:44:26.426083+00:00', - start_date: '2024-02-20T18:42:34', - end_date: '2024-02-24T18:42:39', + start_date: '2024-02-21T18:42:34', + end_date: '2024-02-25T18:42:39', num_nights: 4, num_guests: cabins[1].max_capacity, - status: 'checked-out', + status: 'checked-in', total_price: cabins[1].regular_price + 100, cabin: { id: cabins[1].id, @@ -42,4 +42,24 @@ export const bookings: Booking[] = [ email: guests[1].email, }, }, + + { + id: 3, + created_at: '2024-03-01T23:44:26.426083+00:00', + start_date: '2024-04-10T18:42:34', + end_date: '2024-04-15T18:42:39', + num_nights: 5, + num_guests: cabins[1].max_capacity, + status: 'unconfirmed', + total_price: cabins[1].regular_price + 150, + cabin: { + id: cabins[1].id, + name: cabins[1].name, + }, + guest: { + id: guests[1].id, + full_name: guests[1].full_name, + email: guests[1].email, + }, + }, ]; diff --git a/src/test/mocks/domains/bookings.ts b/src/test/mocks/domains/bookings.ts index 8fc1d3e..1cc09d8 100644 --- a/src/test/mocks/domains/bookings.ts +++ b/src/test/mocks/domains/bookings.ts @@ -1,10 +1,66 @@ import { rest } from 'msw'; +import { SortDirection } from '@mswjs/data/lib/query/queryTypes'; import { BOOKINGS_BASE_URL } from '@/utils/constants'; import { db } from '../db'; export const bookingHandlers = [ - rest.get(BOOKINGS_BASE_URL, (_req, res, ctx) => { + 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'); + + if (statusFilterValue && sortValue) { + const [sortBy, sortDirection] = sortValue.split('.'); + const where = { status: { equals: statusFilterValue } }; + + if (sortBy === 'start_date') { + const bookings = db.booking.findMany({ + where, + orderBy: { start_date: sortDirection as SortDirection }, + }); + return res(ctx.json(bookings)); + } + + if (sortBy === 'total_price') { + const bookings = db.booking.findMany({ + where, + orderBy: { total_price: sortDirection as SortDirection }, + }); + return res(ctx.json(bookings)); + } + + const bookings = db.booking.findMany({ where }); + return res(ctx.json(bookings)); + } + + if (statusFilterValue) { + const bookings = db.booking.findMany({ where: { status: { equals: statusFilterValue } } }); + return res(ctx.json(bookings)); + } + + if (sortValue) { + const [sortBy, sortDirection] = sortValue.split('.'); + + if (sortBy === 'start_date') { + const bookings = db.booking.findMany({ + orderBy: { start_date: sortDirection as SortDirection }, + }); + return res(ctx.json(bookings)); + } + + if (sortBy === 'total_price') { + const bookings = db.booking.findMany({ + orderBy: { total_price: sortDirection as SortDirection }, + }); + return res(ctx.json(bookings)); + } + } + const bookings = db.booking.getAll(); return res(ctx.json(bookings)); }), diff --git a/src/types/bookings.ts b/src/types/bookings.ts index a1f544a..e12b863 100644 --- a/src/types/bookings.ts +++ b/src/types/bookings.ts @@ -1,3 +1,4 @@ +import { SortDirection } from '@mswjs/data/lib/query/queryTypes'; import { Tables } from './database'; export type Booking = Pick< @@ -14,4 +15,19 @@ export type Booking = Pick< guest: Pick, 'id' | 'full_name' | 'email'> | null; }; +export type BookingFields = keyof Booking; + +export type BookingFilter = { + field: BookingFields; + value: string; + comparisonMethod?: 'eq' | 'gt' | 'gte' | 'lt' | 'lte'; +}; + +export type BookingSortableFields = keyof Pick; + +export type BookingSort = { + field: BookingSortableFields; + direction: SortDirection; +}; + export type BookingStatus = 'unconfirmed' | 'checked-in' | 'checked-out';