Skip to content

Commit d52dfe3

Browse files
committed
Validate the appointment schedule form
To avoid writing validation rules on both the frontend and backend, we're using Laravel Precognition to silently validate data on the backend, using the respective FormRequest
1 parent 4982751 commit d52dfe3

File tree

7 files changed

+253
-53
lines changed

7 files changed

+253
-53
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Http\Requests\StoreAppointmentSchedule;
6+
use App\Models\Animal;
7+
use App\TimeOfDay;
8+
use Inertia\Inertia;
9+
10+
class PublicController extends Controller
11+
{
12+
public function home()
13+
{
14+
return Inertia::render('client/NewAppointment', [
15+
// TODO Cache animal types
16+
'animalTypes' => Animal::distinct('type')->orderBy('type')->pluck('type'),
17+
'timeOfDay' => TimeOfDay::selectable(),
18+
]);
19+
}
20+
21+
public function scheduleAppointment(StoreAppointmentSchedule $appointmentSchedule)
22+
{
23+
dd($appointmentSchedule);
24+
}
25+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
namespace App\Http\Requests;
4+
5+
use App\TimeOfDay;
6+
use Illuminate\Foundation\Http\FormRequest;
7+
use Illuminate\Validation\Rule;
8+
9+
class StoreAppointmentSchedule extends FormRequest
10+
{
11+
/**
12+
* Determine if the user is authorized to make this request.
13+
*/
14+
public function authorize(): bool
15+
{
16+
return true;
17+
}
18+
19+
/**
20+
* Get the validation rules that apply to the request.
21+
*
22+
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
23+
*/
24+
public function rules(): array
25+
{
26+
return [
27+
'client.name' => ['required', 'string', 'between:2,50'],
28+
'client.email' => ['required', 'email'],
29+
'animal.name' => ['required', 'string', 'between:2,50'],
30+
'animal.type' => ['required', 'string', 'between:3,20'],
31+
'animal.ageMonths' => ['required', 'numeric', 'integer', 'max:1200'],
32+
'appointment.preferredDate' => ['required', Rule::date()->todayOrAfter()],
33+
'appointment.preferredTime' => ['required', 'array', 'between:1,2'],
34+
'appointment.preferredTime.*' => ['required', Rule::enum(TimeOfDay::class)->only(TimeOfDay::selectable())],
35+
'appointment.symptoms' => ['required', 'string', 'between:10,255'],
36+
];
37+
}
38+
}

app/TimeOfDay.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,17 @@ enum TimeOfDay: string
77
case Morning = 'morning';
88
case Afternoon = 'afternoon';
99
case AllDay = 'all_day';
10+
11+
/**
12+
* Time of day that can be selected by users
13+
*
14+
* @return TimeOfDay[]
15+
*/
16+
public static function selectable(): array
17+
{
18+
return [
19+
self::Morning,
20+
self::Afternoon,
21+
];
22+
}
1023
}

package-lock.json

Lines changed: 44 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"class-variance-authority": "^0.7.1",
3232
"clsx": "^2.1.1",
3333
"concurrently": "^9.0.1",
34+
"laravel-precognition-vue-inertia": "^0.7.2",
3435
"laravel-vite-plugin": "^1.0",
3536
"lucide-vue-next": "^0.468.0",
3637
"reka-ui": "^2.2.0",

resources/js/pages/client/NewAppointment.vue

Lines changed: 124 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,74 @@
11
<script setup lang="ts">
22
import { Head, usePage } from '@inertiajs/vue3';
3-
import { ref } from 'vue';
3+
import type { Form } from '@nuxt/ui/runtime/types/form.d.ts';
4+
import { useForm } from 'laravel-precognition-vue-inertia';
5+
import { capitalize, computed, ref, useTemplateRef } from 'vue';
46
57
const page = usePage();
68
7-
const animalTypes = ref<string[]>(page.props.animalTypes);
8-
const state = ref({
9-
client: {
10-
name: '',
11-
email: '',
12-
},
13-
animal: {
14-
name: '',
15-
type: '',
16-
ageMonths: null,
9+
const animalTypes = ref(page.props.animalTypes as string[]);
10+
const timeOfDay = (page.props.timeOfDay as string[]).map((time) => ({
11+
value: time,
12+
label: capitalize(time),
13+
}));
14+
15+
const form = useForm(
16+
'post',
17+
route('public.schedule-appointment'),
18+
{
19+
client: {
20+
name: '',
21+
email: '',
22+
},
23+
animal: {
24+
name: '',
25+
type: '',
26+
ageMonths: null,
27+
},
28+
appointment: {
29+
preferredDate: '',
30+
preferredTime: [],
31+
symptoms: '',
32+
},
1733
},
18-
appointment: {
19-
preferredDate: '',
20-
preferredTime: [],
21-
symptoms: '',
34+
{
35+
onFinish() {
36+
// Show errors after validating (using precognition) on the server
37+
showFormErrors();
38+
},
2239
},
23-
});
40+
);
41+
const formRef = useTemplateRef<Form<any>>('formRef');
42+
const formErrors = computed(() => Object.entries(form.errors).map(([name, message]) => ({ name, message })));
43+
44+
function showFormErrors() {
45+
formRef.value?.setErrors(formErrors.value);
46+
}
47+
48+
function validateForm() {
49+
// Get touched fields from the Nuxt UI form
50+
const fields = Array.from(formRef.value?.touchedFields as Set<string>);
51+
// Add the touched fields to the Laravel Precognition form
52+
form.touch(fields);
53+
// Validate on the server, using precognition
54+
form.validate();
55+
56+
return formRef.value?.getErrors() ?? []; // Don't overwrite the errors
57+
}
2458
25-
function onCreateAnimalType(item: string) {
26-
console.log('New animal type: ' + item);
59+
async function submitForm() {
60+
form.submit({
61+
preserveScroll: true,
62+
onError: () => {
63+
// Show errors after failed submission
64+
showFormErrors();
65+
},
66+
});
67+
}
68+
69+
function createAnimalType(item: string) {
2770
animalTypes.value.push(item);
28-
state.value.animal.type = item;
71+
form.animal.type = item;
2972
}
3073
</script>
3174

@@ -34,72 +77,106 @@ function onCreateAnimalType(item: string) {
3477

3578
<UApp>
3679
<UContainer class="flex h-full flex-col items-center justify-center p-4 sm:p-6">
37-
<!-- Header -->
80+
<!-- Page header -->
3881
<UIcon name="i-lucide-hospital" class="mt-8 size-32" />
39-
<h1 class="mt-6 text-5xl">{{ $page.props.name }}</h1>
82+
<h1 class="mt-6 text-5xl">{{ page.props.name }}</h1>
4083

4184
<!-- Appointment card -->
4285
<UCard class="mt-16 sm:p-4">
86+
<!-- Appointment card: header-->
4387
<div class="mt-6 mb-12 px-4 text-center sm:mt-0">
4488
<h2 class="text-3xl">Schedule an appointment with us!</h2>
4589
<p class="mt-2">We'll get back to you as soon as possible.</p>
4690
</div>
4791

48-
<UForm :state="state" class="space-y-4">
92+
<!-- Appointment card: form-->
93+
<UForm
94+
ref="formRef"
95+
:state="form"
96+
:validate="validateForm"
97+
:validate-on="['change']"
98+
:validate-on-input-delay="0"
99+
:disabled="form.processing"
100+
@submit="submitForm"
101+
class="space-y-4"
102+
>
103+
<!-- Client -->
49104
<p class="mb-2 text-2xl/12">About you</p>
50-
<UFormField label="Name" name="name" required>
51-
<UInput v-model="state.client.name" placeholder="Your name" class="w-full" />
52-
</UFormField>
53105

54-
<UFormField label="Email" name="email" description="Used to contact you about your appointment" required>
55-
<UInput v-model="state.client.email" type="email" placeholder="Your email" class="w-full" />
106+
<!--Client: name -->
107+
<UFormField label="Name" name="client.name" required>
108+
<UInput v-model="form.client.name" placeholder="Your name" class="w-full" />
109+
</UFormField>
110+
<!--Client: email-->
111+
<UFormField label="Email" name="client.email" description="Used to contact you about your appointment" required>
112+
<UInput v-model="form.client.email" type="email" placeholder="Your email" class="w-full" />
56113
</UFormField>
57114

58115
<USeparator class="mb-0 py-6" />
59116

117+
<!-- Pet -->
60118
<p class="mb-2 pt-0 text-2xl/12">About your pet</p>
61119
<div class="flex flex-col gap-4 md:flex-row">
62-
<UFormField label="Name" name="name" required>
63-
<UInput v-model="state.animal.name" class="w-full" placeholder="Your pet's name" />
120+
<!-- Pet: name-->
121+
<UFormField label="Name" name="animal.name" required>
122+
<UInput v-model="form.animal.name" class="w-full" placeholder="Your pet's name" />
64123
</UFormField>
65-
<UFormField label="Type" name="type" required>
124+
<!-- Pet: type -->
125+
<UFormField label="Type" name="animal.type" required>
66126
<UInputMenu
67-
v-model="state.animal.type"
127+
v-model="form.animal.type"
68128
placeholder="Your pet type"
69129
create-item="always"
70130
:items="animalTypes"
71-
@create="onCreateAnimalType"
131+
@create="createAnimalType"
72132
class="w-full"
73133
/>
74134
</UFormField>
75-
<UFormField label="Age" name="age" hint="in months" required>
76-
<UInput v-model="state.animal.ageMonths" class="w-full" placeholder="Your pet's age (in months)" />
135+
<!-- Pet: age -->
136+
<UFormField label="Age" name="animal.ageMonths" hint="in months" required>
137+
<UInput v-model="form.animal.ageMonths" class="w-full" placeholder="Your pet's age (in months)" />
77138
</UFormField>
78139
</div>
79140

80141
<USeparator class="mb-0 py-6" />
81142

143+
<!-- Appointment -->
82144
<p class="mb-2 pt-0 text-2xl/12">About your visit</p>
83145
<div class="flex flex-col gap-4 md:flex-row">
84146
<div class="space-y-2 md:w-1/3">
85-
<UFormField label="Preferred Time" name="preferred_date" required>
86-
<UInput v-model="state.appointment.preferredDate" type="date" class="w-full" />
147+
<!-- Appointment: date + time -->
148+
<UFormField label="Preferred Time" name="appointment.preferredDate" required>
149+
<UInput v-model="form.appointment.preferredDate" type="date" class="w-full" />
150+
</UFormField>
151+
<UFormField name="appointment.preferredTime">
152+
<UCheckboxGroup
153+
v-model="form.appointment.preferredTime"
154+
orientation="horizontal"
155+
name="appointment.preferredTime"
156+
:items="timeOfDay"
157+
/>
87158
</UFormField>
88-
<UCheckboxGroup
89-
v-model="state.appointment.preferredTime"
90-
orientation="horizontal"
91-
name="preferred_time"
92-
:items="['Morning', 'Afternoon']"
93-
required
94-
/>
95159
</div>
96-
<UFormField label="Symptoms" name="date" class="w-full md:w-2/3" required>
97-
<UTextarea v-model="state.appointment.symptoms" placeholder="Why are you visiting us?" rows="4" class="w-full" />
160+
<!-- Appointment: symptoms-->
161+
<UFormField label="Symptoms" name="appointment.symptoms" class="w-full md:w-2/3" required>
162+
<UTextarea v-model="form.appointment.symptoms" placeholder="Why are you visiting us?" :rows="4" class="w-full" />
98163
</UFormField>
99164
</div>
100165

101-
<div class="mt-12 text-right">
102-
<UButton type="submit" label="Schedule appointment" color="primary" size="lg" trailing-icon="i-lucide-paw-print" />
166+
<!-- Form: controls -->
167+
<div class="mt-12 flex flex-col-reverse items-center justify-end gap-3 sm:flex-row">
168+
<div v-if="form.hasErrors" class="text-sm text-red-400">Please review and fix issues</div>
169+
<UButton
170+
type="submit"
171+
:disabled="form.hasErrors"
172+
:loading="form.processing"
173+
color="primary"
174+
size="lg"
175+
icon="i-lucide-paw-print"
176+
trailing
177+
>
178+
Schedule appointment
179+
</UButton>
103180
</div>
104181
</UForm>
105182
</UCard>

0 commit comments

Comments
 (0)