-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #21 from Ayon95/settings_page
Implement settings update functionality
- Loading branch information
Showing
16 changed files
with
513 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
import { z } from 'zod'; | ||
import { useForm } from 'react-hook-form'; | ||
import { zodResolver } from '@hookform/resolvers/zod'; | ||
|
||
import { SettingsUpdateDTO } from '@/types/settings'; | ||
import Form from '@/ui/form/Form'; | ||
import FormControl from '@/ui/form/FormControl'; | ||
import { useUpdateSettings } from './hooks/useUpdateSettings'; | ||
import { Button } from '@/ui/button/Button'; | ||
import SpinnerMini from '@/ui/spinner/SpinnerMini'; | ||
import { getModifiedFormFieldValues } from '@/utils/helpers'; | ||
import { Tables } from '@/types/database'; | ||
|
||
interface UpdateSettingsFormProps { | ||
settings: Tables<'settings'>; | ||
} | ||
|
||
const formSchema = z.object({ | ||
min_booking_length: z.coerce | ||
.number() | ||
.int({ message: 'Min nights per booking must be a whole number' }) | ||
.positive({ message: 'Min nights per booking must be greater than 0' }), | ||
max_booking_length: z.coerce | ||
.number() | ||
.int({ message: 'Max nights per booking must be a whole number' }) | ||
.positive({ message: 'Max nights per booking must be greater than 0' }), | ||
max_guests_per_booking: z.coerce | ||
.number() | ||
.int({ message: 'Max guest per booking must be a whole number' }) | ||
.positive({ message: 'Max guests per booking must be greater than 0' }), | ||
breakfast_price: z.coerce | ||
.number() | ||
.positive({ message: 'Breakfast price must be greater than 0' }), | ||
}); | ||
|
||
type FormData = z.infer<typeof formSchema>; | ||
|
||
function UpdateSettingsForm({ settings }: UpdateSettingsFormProps) { | ||
const { min_booking_length, max_booking_length, max_guests_per_booking, breakfast_price } = | ||
settings; | ||
|
||
const form = useForm<FormData>({ | ||
resolver: zodResolver(formSchema), | ||
defaultValues: { | ||
min_booking_length, | ||
max_booking_length, | ||
max_guests_per_booking, | ||
breakfast_price, | ||
}, | ||
}); | ||
|
||
const formErrors = form.formState.errors; | ||
const dirtyFields = form.formState.dirtyFields; | ||
|
||
const updateSettingsMutation = useUpdateSettings(); | ||
|
||
function handleSubmit(formData: FormData) { | ||
const updatedData: SettingsUpdateDTO = getModifiedFormFieldValues(dirtyFields, formData); | ||
updateSettingsMutation.mutate({ settingsId: settings.id, updatedData }); | ||
} | ||
|
||
return ( | ||
<Form | ||
onSubmit={form.handleSubmit(handleSubmit)} | ||
aria-labelledby="updateSettingsFormLabel" | ||
noValidate | ||
> | ||
<span className="sr-only" id="updateSettingsFormLabel"> | ||
Update hotel settings | ||
</span> | ||
<div className="multi-col-input-container"> | ||
<FormControl | ||
labelInfo={{ label: 'Min nights per booking', inputId: 'minBookingLength' }} | ||
error={formErrors.min_booking_length?.message} | ||
> | ||
<input | ||
{...form.register('min_booking_length')} | ||
type="number" | ||
id="minBookingLength" | ||
aria-invalid={formErrors.min_booking_length ? 'true' : 'false'} | ||
disabled={updateSettingsMutation.isLoading} | ||
/> | ||
</FormControl> | ||
<FormControl | ||
labelInfo={{ label: 'Max nights per booking', inputId: 'maxBookingLength' }} | ||
error={formErrors.max_booking_length?.message} | ||
> | ||
<input | ||
{...form.register('max_booking_length')} | ||
type="number" | ||
id="maxBookingLength" | ||
aria-invalid={formErrors.max_booking_length ? 'true' : 'false'} | ||
disabled={updateSettingsMutation.isLoading} | ||
/> | ||
</FormControl> | ||
</div> | ||
<FormControl | ||
labelInfo={{ label: 'Max guests per booking', inputId: 'maxGuestsPerBooking' }} | ||
error={formErrors.max_guests_per_booking?.message} | ||
> | ||
<input | ||
{...form.register('max_guests_per_booking')} | ||
type="number" | ||
id="maxGuestsPerBooking" | ||
aria-invalid={formErrors.max_guests_per_booking ? 'true' : 'false'} | ||
disabled={updateSettingsMutation.isLoading} | ||
/> | ||
</FormControl> | ||
<FormControl | ||
labelInfo={{ label: 'Breakfast price', inputId: 'breakfastPrice' }} | ||
error={formErrors.breakfast_price?.message} | ||
> | ||
<input | ||
{...form.register('breakfast_price')} | ||
type="number" | ||
id="breakfastPrice" | ||
aria-invalid={formErrors.breakfast_price ? 'true' : 'false'} | ||
disabled={updateSettingsMutation.isLoading} | ||
/> | ||
</FormControl> | ||
<Button $size="large" disabled={updateSettingsMutation.isLoading}> | ||
{updateSettingsMutation.isLoading ? <SpinnerMini /> : 'Update Settings'} | ||
</Button> | ||
{updateSettingsMutation.isError && <p>{updateSettingsMutation.error.message}</p>} | ||
</Form> | ||
); | ||
} | ||
|
||
export default UpdateSettingsForm; |
94 changes: 94 additions & 0 deletions
94
src/features/settings/__tests__/UpdateSettingsForm.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
import { screen } from '@testing-library/react'; | ||
import userEvent from '@testing-library/user-event'; | ||
|
||
import { renderWithQueryClient } from '@/test/utils'; | ||
import UpdateSettingsForm from '../UpdateSettingsForm'; | ||
import { settings } from '@/test/fixtures/settings'; | ||
|
||
const formTitleRegex = /update hotel settings/i; | ||
const submitButtonRegex = /update settings/i; | ||
|
||
function setup() { | ||
renderWithQueryClient(<UpdateSettingsForm settings={settings} />); | ||
} | ||
|
||
describe('UpdateSettingsForm', () => { | ||
it('should render a form element', () => { | ||
setup(); | ||
const updateSettingsForm = screen.getByRole('form', { name: formTitleRegex }); | ||
expect(updateSettingsForm).toBeInTheDocument(); | ||
}); | ||
|
||
it('should have all the necessary form fields and submit button', () => { | ||
setup(); | ||
|
||
const minNightsPerBookingInput = screen.getByLabelText(/min nights per booking/i); | ||
const maxNightsPerBookingInput = screen.getByLabelText(/max nights per booking/i); | ||
const maxGuestsPerBookingInput = screen.getByLabelText(/max guests per booking/i); | ||
const breakfastPriceInput = screen.getByLabelText(/breakfast price/i); | ||
const submitButton = screen.getByRole('button', { name: submitButtonRegex }); | ||
|
||
[ | ||
minNightsPerBookingInput, | ||
maxNightsPerBookingInput, | ||
maxGuestsPerBookingInput, | ||
breakfastPriceInput, | ||
submitButton, | ||
].forEach(element => { | ||
expect(element).toBeInTheDocument(); | ||
}); | ||
}); | ||
|
||
it('should have form fields pre-filled with settings data', () => { | ||
setup(); | ||
|
||
const minNightsPerBookingInput = screen.getByDisplayValue(settings.min_booking_length); | ||
const maxNightsPerBookingInput = screen.getByDisplayValue(settings.max_booking_length); | ||
const maxGuestsPerBookingInput = screen.getByDisplayValue(settings.max_guests_per_booking); | ||
const breakfastPriceInput = screen.getByDisplayValue(settings.breakfast_price); | ||
|
||
[ | ||
minNightsPerBookingInput, | ||
maxNightsPerBookingInput, | ||
maxGuestsPerBookingInput, | ||
breakfastPriceInput, | ||
].forEach(element => { | ||
expect(element).toBeInTheDocument(); | ||
}); | ||
}); | ||
|
||
it('should show error messages if input values are not provided', async () => { | ||
setup(); | ||
const user = userEvent.setup(); | ||
|
||
await user.clear(screen.getByLabelText(/min nights per booking/i)); | ||
await user.clear(screen.getByLabelText(/max nights per booking/i)); | ||
await user.clear(screen.getByLabelText(/max guests per booking/i)); | ||
await user.clear(screen.getByLabelText(/breakfast price/i)); | ||
|
||
await user.click(screen.getByRole('button', { name: submitButtonRegex })); | ||
|
||
const minNightsPerBookingError = screen.getByText( | ||
/min nights per booking must be greater than 0/i | ||
); | ||
|
||
const maxNightsPerBookingError = screen.getByText( | ||
/max nights per booking must be greater than 0/i | ||
); | ||
|
||
const maxGuestsPerBookingError = screen.getByText( | ||
/max guests per booking must be greater than 0/i | ||
); | ||
|
||
const breakfastPriceError = screen.getByText(/breakfast price must be greater than 0/i); | ||
|
||
[ | ||
minNightsPerBookingError, | ||
maxNightsPerBookingError, | ||
maxGuestsPerBookingError, | ||
breakfastPriceError, | ||
].forEach(element => { | ||
expect(element).toBeInTheDocument(); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
import { getSettings } from '@/services/apiSettings'; | ||
import { SETTINGS_QUERY_KEY } from '@/utils/constants'; | ||
import { useQuery } from '@tanstack/react-query'; | ||
|
||
export function useSettings(userId: string | null | undefined) { | ||
return useQuery({ | ||
queryKey: [SETTINGS_QUERY_KEY, userId], | ||
queryFn: () => getSettings(userId), | ||
staleTime: Infinity, | ||
cacheTime: Infinity, | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
import { useMutation, useQueryClient } from '@tanstack/react-query'; | ||
import toast from 'react-hot-toast'; | ||
|
||
import { updateSettings } from '@/services/apiSettings'; | ||
import { SETTINGS_QUERY_KEY } from '@/utils/constants'; | ||
|
||
export function useUpdateSettings() { | ||
const queryClient = useQueryClient(); | ||
|
||
return useMutation({ | ||
mutationFn: updateSettings, | ||
onSuccess: () => { | ||
toast.success('Settings updated successfully!'); | ||
queryClient.invalidateQueries({ queryKey: [SETTINGS_QUERY_KEY] }); | ||
}, | ||
onError: (error: Error) => toast.error(error.message), | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,24 @@ | ||
import { useUser } from '@/features/authentication/hooks/useUser'; | ||
import UpdateSettingsForm from '@/features/settings/UpdateSettingsForm'; | ||
import { useSettings } from '@/features/settings/hooks/useSettings'; | ||
import Heading from '@/ui/Heading'; | ||
import Spinner from '@/ui/spinner/Spinner'; | ||
|
||
function Settings() { | ||
return <Heading as="h1">Update hotel settings</Heading>; | ||
const { data: user } = useUser(); | ||
const { data: settings, isLoading } = useSettings(user?.id); | ||
return ( | ||
<> | ||
<Heading as="h1">Update hotel settings</Heading> | ||
{isLoading ? ( | ||
<Spinner /> | ||
) : settings ? ( | ||
<UpdateSettingsForm settings={settings} /> | ||
) : ( | ||
<p className="mt-1">Settings could not be loaded.</p> | ||
)} | ||
</> | ||
); | ||
} | ||
|
||
export default Settings; |
Oops, something went wrong.