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';