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

Add button to check out a checked-in booking #24

Merged
merged 1 commit into from
Mar 30, 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
18 changes: 18 additions & 0 deletions src/features/bookings/BookingDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import { Booking, BookingStatus } from '@/types/bookings';
import { formatDate } from '@/utils/helpers';
import BookingGuestDetailsTable from './BookingGuestDetailsTable';
import BookingPaymentSummary from './BookingPaymentSummary';
import { Button } from '@/ui/button/Button';
import { useCheckOutBooking } from './hooks/useCheckOutBooking';
import SpinnerMini from '@/ui/spinner/SpinnerMini';

interface BookingProps {
booking: Booking;
Expand All @@ -22,6 +25,7 @@ const dateFormatOptions: Intl.DateTimeFormatOptions = {
};

function BookingDetails({ booking }: BookingProps) {
const checkOutBookingMutation = useCheckOutBooking();
return (
<Container>
<Header>
Expand Down Expand Up @@ -92,6 +96,20 @@ function BookingDetails({ booking }: BookingProps) {
}}
/>
</Grid>
{booking.status === 'checked-in' && (
<Button
className="mt-3"
onClick={() =>
checkOutBookingMutation.mutate({
bookingId: booking.id,
updatedData: { status: 'checked-out' },
})
}
disabled={checkOutBookingMutation.isLoading}
>
{checkOutBookingMutation.isLoading ? <SpinnerMini /> : 'Check Out'}
</Button>
)}
</Container>
);
}
Expand Down
25 changes: 21 additions & 4 deletions src/features/bookings/BookingRow.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import styled from 'styled-components';
import { HiEye } from 'react-icons/hi2';
import { HiArrowUpOnSquare, HiEye } from 'react-icons/hi2';

import { Booking, BookingStatus } from '@/types/bookings';
import Table from '@/ui/Table';
import { formatPrice, formatDate } from '@/utils/helpers';
import Badge from '@/ui/Badge';
import { LinkButtonIconText } from '@/ui/button/ButtonIconText';
import { ButtonIconText, LinkButtonIconText } from '@/ui/button/ButtonIconText';
import { bookingStatusToBadgeColorMap } from '@/utils/constants';
import { useCheckOutBooking } from './hooks/useCheckOutBooking';

interface BookingRowProps {
booking: Booking;
}

function BookingRow({ booking }: BookingRowProps) {
const { cabin, guest, start_date, end_date, num_nights, total_price, status } = booking;
const { id, cabin, guest, start_date, end_date, num_nights, total_price, status } = booking;
const checkOutBookingMutation = useCheckOutBooking();

return (
<Table.Row>
<DarkNumericTextCell label="cabin">{cabin?.name}</DarkNumericTextCell>
Expand All @@ -35,10 +38,24 @@ function BookingRow({ booking }: BookingRowProps) {
<DarkNumericTextCell label="price">{formatPrice(total_price)}</DarkNumericTextCell>
<Table.Cell>
<ActionButtonsContainer>
<LinkButtonIconText to={`/bookings/${booking.id}`}>
<LinkButtonIconText to={`/bookings/${id}`}>
<HiEye />
<span>Details</span>
</LinkButtonIconText>
{status === 'checked-in' && (
<ButtonIconText
onClick={() =>
checkOutBookingMutation.mutate({
bookingId: id,
updatedData: { status: 'checked-out' },
})
}
disabled={checkOutBookingMutation.isLoading}
>
<HiArrowUpOnSquare />
<span>Check out</span>
</ButtonIconText>
)}
</ActionButtonsContainer>
</Table.Cell>
</Table.Row>
Expand Down
56 changes: 56 additions & 0 deletions src/features/bookings/__tests__/Booking.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { Toaster } from 'react-hot-toast';
import { rest } from 'msw';

import Booking from '@/pages/Booking';
import supabase from '@/services/supabase';
import { userSession } from '@/test/fixtures/authentication';
import { renderWithQueryClient } from '@/test/utils';
import { bookings } from '@/test/fixtures/bookings';
import { formatDate, formatPrice } from '@/utils/helpers';
import { db } from '@/test/mocks/db';
import { mockServer } from '@/test/mocks/server';
import { BOOKINGS_BASE_URL } from '@/utils/constants';

const dateFormatOptions: Intl.DateTimeFormatOptions = {
weekday: 'short',
Expand All @@ -22,6 +28,7 @@ function setup(bookingId: number = 1) {
<Routes>
<Route path="bookings/:bookingId" element={<Booking />} />
</Routes>
<Toaster />
</MemoryRouter>
);
}
Expand Down Expand Up @@ -140,4 +147,53 @@ describe('Booking', () => {
element => expect(element).toBeInTheDocument()
);
});

it('should have a checkout button if the booking status is checked-in', async () => {
setup(2);
const checkoutButton = await screen.findByRole('button', { name: /check out/i });
expect(checkoutButton).toBeInTheDocument();
});

it('should show success message and updated status if a checked-in booking is checked-out', async () => {
setup(2);
const user = userEvent.setup();
const checkoutButton = await screen.findByRole('button', { name: /check out/i });

await user.click(checkoutButton);

const successMessage = await screen.findByText(
`Booking #${bookings[1].id} updated successfully!`
);
const checkedOutStatus = screen.getByText(/checked-out/i);

expect(successMessage).toBeInTheDocument();
expect(checkedOutStatus).toBeInTheDocument();

// Resetting status of the updated booking

db.booking.update({
where: { id: { equals: bookings[1].id } },
data: { status: 'checked-in' },
});
});

it('should show update error message if a checked-in booking cannot be updated', async () => {
mockServer.use(
rest.patch(BOOKINGS_BASE_URL, (_req, res) => {
return res.networkError('A network error occurred');
})
);

setup(2);
const user = userEvent.setup();
const checkoutButton = await screen.findByRole('button', { name: /check out/i });

await user.click(checkoutButton);

const errorMessage = screen.getByText(/booking could not be updated/i);
const checkedOutStatus = screen.queryByText(/checked-out/i);

expect(errorMessage).toBeInTheDocument();
expect(checkedOutStatus).not.toBeInTheDocument();
});
});
33 changes: 33 additions & 0 deletions src/features/bookings/__tests__/BookingRow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { MemoryRouter } from 'react-router-dom';
import { screen } from '@testing-library/react';

import { renderWithQueryClient } from '@/test/utils';
import BookingRow from '../BookingRow';
import { Booking } from '@/types/bookings';
import { bookings } from '@/test/fixtures/bookings';

function setup(booking: Booking) {
renderWithQueryClient(
<MemoryRouter>
<table>
<tbody>
<BookingRow booking={booking} />
</tbody>
</table>
</MemoryRouter>
);
}

describe('BookingRow', () => {
it('should have a checkout button if the booking status is checked-in', () => {
setup(bookings[1]);
const checkoutButton = screen.getByRole('button', { name: /check out/i });
expect(checkoutButton).toBeInTheDocument();
});

it('should not have a checkout button if the booking status is not checked-in', () => {
setup(bookings[0]);
const checkoutButton = screen.queryByRole('button', { name: /check out/i });
expect(checkoutButton).not.toBeInTheDocument();
});
});
20 changes: 20 additions & 0 deletions src/features/bookings/hooks/useCheckOutBooking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';

import { updateBooking } from '@/services/apiBookings';
import { BOOKINGS_QUERY_KEY, BOOKING_QUERY_KEY } from '@/utils/constants';
import { Tables } from '@/types/database';

export function useCheckOutBooking() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: updateBooking,
onSuccess: (data: Tables<'booking'>) => {
toast.success(`Booking #${data.id} updated successfully!`);
queryClient.invalidateQueries({ queryKey: [BOOKINGS_QUERY_KEY] });
queryClient.invalidateQueries({ queryKey: [BOOKING_QUERY_KEY, data.id.toString()] });
},
onError: (error: Error) => toast.error(error.message),
});
}
60 changes: 59 additions & 1 deletion src/pages/__tests__/Bookings.test.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';
import { Toaster } from 'react-hot-toast';
import { MemoryRouter } from 'react-router-dom';

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, 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';
import { db } from '@/test/mocks/db';

function setup() {
renderWithQueryClient(
Expand All @@ -28,6 +30,7 @@ function setupWithLogin(initialPath: string = '') {
renderWithQueryClient(
<MemoryRouter initialEntries={[initialPath]}>
<Bookings />
<Toaster />
</MemoryRouter>
);
}
Expand Down Expand Up @@ -261,4 +264,59 @@ describe('Bookings', () => {
expect(firstBookingCells[0]).toHaveTextContent(cabins[0].name);
expect(firstBookingCells[1]).toHaveTextContent(`${guests[0].full_name}${guests[0].email}`);
});

it('should show updated booking and a success message if a checked-in booking is checked out', async () => {
setupWithLogin();
const user = userEvent.setup();

const bookingRows = await screen.findAllByRole('row');
const checkoutButton = within(bookingRows[2]).getByRole('button', { name: /check out/i });

await user.click(checkoutButton);

const successMessage = await screen.findByText(
`Booking #${bookings[1].id} updated successfully!`
);

const updatedBookingRows = screen.getAllByRole('row');
const checkedOutStatusCell = within(updatedBookingRows[2]).getByRole('cell', {
name: /checked-out/i,
});

expect(successMessage).toBeInTheDocument();
expect(checkedOutStatusCell).toBeInTheDocument();

// Resetting status of the updated booking

db.booking.update({
where: { id: { equals: bookings[1].id } },
data: { status: 'checked-in' },
});
});

it('should show update error message if a booking cannot be updated', async () => {
mockServer.use(
rest.patch(BOOKINGS_BASE_URL, (_req, res) => {
return res.networkError('A network error occurred');
})
);

setupWithLogin();
const user = userEvent.setup();

const bookingRows = await screen.findAllByRole('row');
const checkoutButton = within(bookingRows[2]).getByRole('button', { name: /check out/i });

await user.click(checkoutButton);

const errorMessage = await screen.findByText(/booking could not be updated/i);

const updatedBookingRows = screen.getAllByRole('row');
const checkedOutStatusCell = within(updatedBookingRows[2]).queryByRole('cell', {
name: /checked-out/i,
});

expect(errorMessage).toBeInTheDocument();
expect(checkedOutStatusCell).not.toBeInTheDocument();
});
});
24 changes: 23 additions & 1 deletion src/services/apiBookings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BookingFilter, BookingSort } from '@/types/bookings';
import { BookingFilter, BookingSort, BookingUpdateDTO } from '@/types/bookings';
import supabase from './supabase';
import { PAGE_SIZE } from '@/utils/constants';

Expand Down Expand Up @@ -82,3 +82,25 @@ export async function getBooking(bookingId: string | undefined, userId: string |

return booking;
}

export async function updateBooking({
bookingId,
updatedData,
}: {
bookingId: number;
updatedData: BookingUpdateDTO;
}) {
const { data, error } = await supabase
.from('booking')
.update(updatedData)
.eq('id', bookingId)
.select()
.single();

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

return data;
}
8 changes: 8 additions & 0 deletions src/test/mocks/domains/bookings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { SortDirection } from '@mswjs/data/lib/query/queryTypes';

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

export const bookingHandlers = [
rest.get(BOOKINGS_BASE_URL, (req, res, ctx) => {
Expand Down Expand Up @@ -88,4 +89,11 @@ export const bookingHandlers = [
const bookings = db.booking.getAll();
return res(ctx.json(bookings));
}),
rest.patch(BOOKINGS_BASE_URL, async (req, res, ctx) => {
const bookingId = getIdFromQueryString(req.url);
const data = await req.json();

const updatedBooking = db.booking.update({ where: { id: { equals: bookingId } }, data });
return res(ctx.json(updatedBooking), ctx.status(204));
}),
];
2 changes: 2 additions & 0 deletions src/types/bookings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@ export type BookingSort = {
};

export type BookingStatus = 'unconfirmed' | 'checked-in' | 'checked-out';

export type BookingUpdateDTO = Partial<Omit<Tables<'booking'>, 'id' | 'created_at' | 'user_id'>>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
create policy "Enable update access for matching authenticated users"
on "public"."booking"
as permissive
for update
to authenticated
using ((auth.uid() = user_id));
Loading