Skip to content

Commit

Permalink
add booking table
Browse files Browse the repository at this point in the history
  • Loading branch information
Ayon95 committed Feb 29, 2024
1 parent 27ad55f commit 8318efb
Show file tree
Hide file tree
Showing 22 changed files with 433 additions and 7 deletions.
48 changes: 48 additions & 0 deletions src/features/bookings/BookingRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import styled from 'styled-components';

import { Booking, BookingStatus } from '@/types/bookings';
import Table from '@/ui/Table';
import { formatPrice, formatDate } from '@/utils/helpers';
import Badge from '@/ui/Badge';

interface BookingRowProps {
booking: Booking;
}

const bookingStatusToBadgeColorMap: Record<BookingStatus, string> = {
unconfirmed: 'blue',
'checked-in': 'green',
'checked-out': 'silver',
};

function BookingRow({ booking }: BookingRowProps) {
const { cabin, guest, start_date, end_date, num_nights, total_price, status } = booking;
return (
<Table.Row>
<DarkNumericTextCell label="cabin">{cabin?.name}</DarkNumericTextCell>
<Table.Cell label="guest">
<div>
<div className="fw-semi-bold">{guest?.full_name}</div>
<div>{guest?.email}</div>
</div>
</Table.Cell>
<Table.Cell label="duration">
<div>
<div className="fw-semi-bold">{num_nights} night stay</div>
{formatDate(start_date)} - {formatDate(end_date)}
</div>
</Table.Cell>
<Table.Cell label="status">
<Badge type={bookingStatusToBadgeColorMap[status as BookingStatus]}>{status}</Badge>
</Table.Cell>
<DarkNumericTextCell label="price">{formatPrice(total_price)}</DarkNumericTextCell>
</Table.Row>
);
}

export default BookingRow;

const DarkNumericTextCell = styled(Table.Cell)`
font-family: 'Sono', monospace;
color: var(--color-grey-600);
`;
30 changes: 30 additions & 0 deletions src/features/bookings/BookingTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Booking } from '@/types/bookings';
import Table from '@/ui/Table';
import BookingRow from './BookingRow';

interface BookingTableProps {
bookings: Booking[];
}

function BookingTable({ bookings }: BookingTableProps) {
return (
<Table id="bookingTable" caption="Bookings">
<Table.Head>
<Table.Row>
<Table.HeaderCell>Cabin</Table.HeaderCell>
<Table.HeaderCell>Guest</Table.HeaderCell>
<Table.HeaderCell>Duration</Table.HeaderCell>
<Table.HeaderCell>Status</Table.HeaderCell>
<Table.HeaderCell>Price</Table.HeaderCell>
</Table.Row>
</Table.Head>
<Table.Body>
{bookings.map(booking => (
<BookingRow key={booking.id} booking={booking} />
))}
</Table.Body>
</Table>
);
}

export default BookingTable;
47 changes: 47 additions & 0 deletions src/features/bookings/__tests__/BookingTable.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { screen, within } from '@testing-library/react';

import { renderWithQueryClient } from '@/test/utils';
import BookingTable from '../BookingTable';
import { bookings } from '@/test/fixtures/bookings';
import { formatDate } from '@/utils/helpers';

function setup() {
renderWithQueryClient(<BookingTable bookings={bookings} />);
}

describe('BookingTable', () => {
it('should have all column headers in the correct order', () => {
setup();
const columnHeaders = screen.getAllByRole('columnheader');

expect(columnHeaders[0]).toHaveTextContent(/cabin/i);
expect(columnHeaders[1]).toHaveTextContent(/guest/i);
expect(columnHeaders[2]).toHaveTextContent(/duration/i);
expect(columnHeaders[3]).toHaveTextContent(/status/i);
expect(columnHeaders[4]).toHaveTextContent(/price/i);
});

it('should have rows with booking details in the correct order', () => {
setup();
const rows = screen.getAllByRole('row');

// The first row is the column header row
[rows[1], rows[2]].forEach((row, i) => {
const bookingCells = within(row).getAllByRole('cell');
const booking = bookings[i];
// Even though the type that Supabase returns indicates that cabin and guest can be nullable, it can't because a booking must be associated with a cabin and a guest
const cabin = booking.cabin!;
const guest = booking.guest!;

expect(bookingCells[0]).toHaveTextContent(cabin.name);
expect(bookingCells[1]).toHaveTextContent(`${guest.full_name}${guest.email}`);
expect(bookingCells[2]).toHaveTextContent(
`${booking.num_nights} night stay${formatDate(booking.start_date)} - ${formatDate(
booking.end_date
)}`
);
expect(bookingCells[3]).toHaveTextContent(booking.status);
expect(bookingCells[4]).toHaveTextContent(`$${booking.total_price}.00`);
});
});
});
12 changes: 12 additions & 0 deletions src/features/bookings/hooks/useBookings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { getBookings } from '@/services/apiBookings';
import { BOOKINGS_QUERY_KEY } from '@/utils/constants';
import { useQuery } from '@tanstack/react-query';

export function useBookings(userId: string | null | undefined) {
return useQuery({
queryKey: [BOOKINGS_QUERY_KEY, userId],
queryFn: () => getBookings(userId),
staleTime: Infinity,
cacheTime: Infinity,
});
}
22 changes: 19 additions & 3 deletions src/pages/Bookings.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
import FlexRow from '@/ui/FlexRow';
import Heading from '../ui/Heading';
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';

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

return (
<FlexRow>
<Heading as="h1">Bookings</Heading>
</FlexRow>
<>
<FlexRow>
<Heading as="h1">Bookings</Heading>
</FlexRow>
{isLoading ? (
<Spinner />
) : bookings && bookings.length > 0 ? (
<BookingTable bookings={bookings} />
) : (
<p className="mt-1">No bookings to show.</p>
)}
</>
);
}

Expand Down
64 changes: 64 additions & 0 deletions src/pages/__tests__/Bookings.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { screen } from '@testing-library/react';
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';

function setup() {
renderWithQueryClient(<Bookings />);
}

function setupWithLogin() {
// Setting logged in user
vi.spyOn(supabase.auth, 'getSession').mockResolvedValue(userSession);

renderWithQueryClient(<Bookings />);
}

afterEach(() => {
vi.restoreAllMocks();
});

describe('Bookings', () => {
it('should have page title', () => {
setup();
const titleElement = screen.getByRole('heading', { name: /bookings/i });
expect(titleElement).toBeInTheDocument();
});

it('should show an appropriate message and not show booking table if there is no logged in user', async () => {
setup();

const noBookingsTextElement = await screen.findByText(/no bookings to show/i);
const bookingTable = screen.queryByRole('table', { name: /bookings/i });

expect(noBookingsTextElement).toBeInTheDocument();
expect(bookingTable).not.toBeInTheDocument();
});

it('should show an appropriate message and not show booking table if there are no bookings', async () => {
mockServer.use(
rest.get(BOOKINGS_BASE_URL, (_req, res, ctx) => {
return res(ctx.json([]));
})
);

setupWithLogin();

const noBookingsTextElement = await screen.findByText(/no bookings to show/i);
const bookingTable = screen.queryByRole('table', { name: /bookings/i });

expect(noBookingsTextElement).toBeInTheDocument();
expect(bookingTable).not.toBeInTheDocument();
});

it('should show booking table if there is a logged in user and there are bookings', async () => {
setupWithLogin();
const bookingTable = await screen.findByRole('table', { name: /bookings/i });
expect(bookingTable).toBeInTheDocument();
});
});
19 changes: 19 additions & 0 deletions src/services/apiBookings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import supabase from './supabase';

export async function getBookings(userId: string | null | undefined) {
if (!userId) return [];

const { data: bookings, error } = await 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 (error) {
console.error(error);
throw new Error('Bookings could not be loaded.');
}

return bookings;
}
4 changes: 4 additions & 0 deletions src/styles/GlobalStyles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,10 @@ FOR DARK MODE
text-align: center;
}
.fw-semi-bold {
font-weight: 600;
}
.full-width {
width: 100%;
}
Expand Down
45 changes: 45 additions & 0 deletions src/test/fixtures/bookings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Booking } from '@/types/bookings';
import { cabins } from './cabins';
import { guests } from './guests';

export const bookings: Booking[] = [
{
id: 1,
created_at: '2024-02-20T23:44:26.426083+00:00',
start_date: '2024-02-20T18:42:34',
end_date: '2024-02-22T18:42:39',
num_nights: 2,
num_guests: cabins[0].max_capacity,
status: 'checked-out',
total_price: cabins[0].regular_price + 50,
cabin: {
id: cabins[0].id,
name: cabins[0].name,
},
guest: {
id: guests[0].id,
full_name: guests[0].full_name,
email: guests[0].email,
},
},

{
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',
num_nights: 4,
num_guests: cabins[1].max_capacity,
status: 'checked-out',
total_price: cabins[1].regular_price + 100,
cabin: {
id: cabins[1].id,
name: cabins[1].name,
},
guest: {
id: guests[1].id,
full_name: guests[1].full_name,
email: guests[1].email,
},
},
];
26 changes: 26 additions & 0 deletions src/test/fixtures/guests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Tables } from '@/types/database';
import { user } from './authentication';

export const guests: Tables<'guest'>[] = [
{
id: 1,
full_name: 'John Doe',
email: '[email protected]',
national_id: null,
nationality: 'Canada',
country_flag: null,
user_id: user.id,
created_at: '2024-02-20 20:42:58.03428+00',
},

{
id: 2,
full_name: 'Mary Smith',
email: '[email protected]',
national_id: null,
nationality: 'Canada',
country_flag: null,
user_id: user.id,
created_at: '2024-02-20 20:42:58.03428+00',
},
];
7 changes: 7 additions & 0 deletions src/test/mocks/db.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { factory } from '@mswjs/data';
import Cabin from './models/cabin';
import { cabins } from '../fixtures/cabins';
import Booking from './models/booking';
import { bookings } from '../fixtures/bookings';

export const db = factory({
cabin: Cabin,
booking: Booking,
});

// Seed database with initial data

for (const cabin of cabins) {
db.cabin.create(cabin);
}

for (const booking of bookings) {
db.booking.create(booking);
}
11 changes: 11 additions & 0 deletions src/test/mocks/domains/bookings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { rest } from 'msw';

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

export const bookingHandlers = [
rest.get(BOOKINGS_BASE_URL, (_req, res, ctx) => {
const bookings = db.booking.getAll();
return res(ctx.json(bookings));
}),
];
3 changes: 2 additions & 1 deletion src/test/mocks/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { bookingHandlers } from './domains/bookings';
import { cabinHandlers } from './domains/cabins';

export const handlers = [...cabinHandlers];
export const handlers = [...cabinHandlers, ...bookingHandlers];
17 changes: 17 additions & 0 deletions src/test/mocks/models/booking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { nullable, primaryKey } from '@mswjs/data';

const Booking = {
id: primaryKey(Number),
created_at: () => new Date().toISOString(),
start_date: String,
end_date: String,
num_nights: Number,
num_guests: Number,
status: String,
total_price: Number,
// Making it nullable to satisfy the type returned by Supabase
cabin: nullable({ id: Number, name: String }),
guest: nullable({ id: Number, full_name: String, email: String }),
};

export default Booking;
Loading

0 comments on commit 8318efb

Please sign in to comment.