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

Implement settings update functionality #21

Merged
merged 2 commits into from
Mar 23, 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
129 changes: 129 additions & 0 deletions src/features/settings/UpdateSettingsForm.tsx
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 src/features/settings/__tests__/UpdateSettingsForm.test.tsx
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();
});
});
});
12 changes: 12 additions & 0 deletions src/features/settings/hooks/useSettings.ts
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,
});
}
18 changes: 18 additions & 0 deletions src/features/settings/hooks/useUpdateSettings.ts
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),
});
}
19 changes: 18 additions & 1 deletion src/pages/Settings.tsx
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;
Loading
Loading