From f52a951149e38e06a6cd2532977a17971a38ec3b Mon Sep 17 00:00:00 2001 From: Ayon95 Date: Fri, 29 Mar 2024 23:37:16 -0400 Subject: [PATCH] add button to check out a checked-in booking --- src/features/bookings/BookingDetails.tsx | 18 ++++++ src/features/bookings/BookingRow.tsx | 25 ++++++-- .../bookings/__tests__/Booking.test.tsx | 56 +++++++++++++++++ .../bookings/__tests__/BookingRow.test.tsx | 33 ++++++++++ .../bookings/hooks/useCheckOutBooking.ts | 20 +++++++ src/pages/__tests__/Bookings.test.tsx | 60 ++++++++++++++++++- src/services/apiBookings.ts | 24 +++++++- src/test/mocks/domains/bookings.ts | 8 +++ src/types/bookings.ts | 2 + ..._update_access_for_authenticated_users.sql | 6 ++ 10 files changed, 246 insertions(+), 6 deletions(-) create mode 100644 src/features/bookings/__tests__/BookingRow.test.tsx create mode 100644 src/features/bookings/hooks/useCheckOutBooking.ts create mode 100644 supabase/migrations/20240329215436_add_booking_update_access_for_authenticated_users.sql diff --git a/src/features/bookings/BookingDetails.tsx b/src/features/bookings/BookingDetails.tsx index 965956a..2e6c0f4 100644 --- a/src/features/bookings/BookingDetails.tsx +++ b/src/features/bookings/BookingDetails.tsx @@ -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; @@ -22,6 +25,7 @@ const dateFormatOptions: Intl.DateTimeFormatOptions = { }; function BookingDetails({ booking }: BookingProps) { + const checkOutBookingMutation = useCheckOutBooking(); return (
@@ -92,6 +96,20 @@ function BookingDetails({ booking }: BookingProps) { }} /> + {booking.status === 'checked-in' && ( + + )} ); } diff --git a/src/features/bookings/BookingRow.tsx b/src/features/bookings/BookingRow.tsx index cd5ad9d..b4bb961 100644 --- a/src/features/bookings/BookingRow.tsx +++ b/src/features/bookings/BookingRow.tsx @@ -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 ( {cabin?.name} @@ -35,10 +38,24 @@ function BookingRow({ booking }: BookingRowProps) { {formatPrice(total_price)} - + Details + {status === 'checked-in' && ( + + checkOutBookingMutation.mutate({ + bookingId: id, + updatedData: { status: 'checked-out' }, + }) + } + disabled={checkOutBookingMutation.isLoading} + > + + Check out + + )} diff --git a/src/features/bookings/__tests__/Booking.test.tsx b/src/features/bookings/__tests__/Booking.test.tsx index d79da3f..74b70ca 100644 --- a/src/features/bookings/__tests__/Booking.test.tsx +++ b/src/features/bookings/__tests__/Booking.test.tsx @@ -1,5 +1,8 @@ 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'; @@ -7,6 +10,9 @@ 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', @@ -22,6 +28,7 @@ function setup(bookingId: number = 1) { } /> + ); } @@ -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(); + }); }); diff --git a/src/features/bookings/__tests__/BookingRow.test.tsx b/src/features/bookings/__tests__/BookingRow.test.tsx new file mode 100644 index 0000000..94a85bc --- /dev/null +++ b/src/features/bookings/__tests__/BookingRow.test.tsx @@ -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( + + + + + +
+
+ ); +} + +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(); + }); +}); diff --git a/src/features/bookings/hooks/useCheckOutBooking.ts b/src/features/bookings/hooks/useCheckOutBooking.ts new file mode 100644 index 0000000..488850a --- /dev/null +++ b/src/features/bookings/hooks/useCheckOutBooking.ts @@ -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), + }); +} diff --git a/src/pages/__tests__/Bookings.test.tsx b/src/pages/__tests__/Bookings.test.tsx index bf37a93..47b2def 100644 --- a/src/pages/__tests__/Bookings.test.tsx +++ b/src/pages/__tests__/Bookings.test.tsx @@ -1,6 +1,8 @@ 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'; @@ -8,10 +10,10 @@ 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( @@ -28,6 +30,7 @@ function setupWithLogin(initialPath: string = '') { renderWithQueryClient( + ); } @@ -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(); + }); }); diff --git a/src/services/apiBookings.ts b/src/services/apiBookings.ts index f2f7b51..c88fe56 100644 --- a/src/services/apiBookings.ts +++ b/src/services/apiBookings.ts @@ -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'; @@ -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; +} diff --git a/src/test/mocks/domains/bookings.ts b/src/test/mocks/domains/bookings.ts index fae7783..2404dbc 100644 --- a/src/test/mocks/domains/bookings.ts +++ b/src/test/mocks/domains/bookings.ts @@ -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) => { @@ -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)); + }), ]; diff --git a/src/types/bookings.ts b/src/types/bookings.ts index 5a67a9c..4ade559 100644 --- a/src/types/bookings.ts +++ b/src/types/bookings.ts @@ -23,3 +23,5 @@ export type BookingSort = { }; export type BookingStatus = 'unconfirmed' | 'checked-in' | 'checked-out'; + +export type BookingUpdateDTO = Partial, 'id' | 'created_at' | 'user_id'>>; diff --git a/supabase/migrations/20240329215436_add_booking_update_access_for_authenticated_users.sql b/supabase/migrations/20240329215436_add_booking_update_access_for_authenticated_users.sql new file mode 100644 index 0000000..a7ea74e --- /dev/null +++ b/supabase/migrations/20240329215436_add_booking_update_access_for_authenticated_users.sql @@ -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)); \ No newline at end of file