Forms
CPR follows a consistent pattern for building forms: reactive state with useFormValidation for validation, useSnackBar for feedback, and useDialog for opening forms in modals. This page documents each layer and shows a complete example.
Form Structure
Every form component follows the same skeleton:
<template>
<form novalidate @submit.prevent="handleSubmit">
<!-- Fields -->
<!-- Actions -->
</form>
</template>
<script setup lang="ts">
// 1. Emits: close event to dismiss modal
// 2. Composables: API composable, snackbar, validation
// 3. Reactive form state
// 4. Validation rules
// 5. Submit handler
</script>Key conventions:
- Forms use
novalidateto bypass browser validation (CPR uses its own validation system). - The submit handler is bound via
@submit.prevent. - Forms emit a
closeevent that theUiDialogProviderlistens for to dismiss the modal.
Input Components
UiInput
The primary text input. Supports v-model, labels, error display, icons, and multiple input types.
<UiInput
v-model="form.provider_name"
label="Provider Name"
placeholder="e.g., Maxicare"
:error="errors.provider_name"
required
/>
<UiInput
v-model="form.coverage"
type="number"
label="Coverage (%)"
placeholder="0-100"
:error="errors.coverage"
/>The :error prop connects to the errors object from useFormValidation. When a validation error exists for a field, it renders as red text below the input and applies a red border style.
UiTextArea
Multi-line input for longer text. Same v-model pattern.
<UiTextArea
v-model="form.contact_address"
label="Contact Address"
placeholder="Enter complete address"
rows="2"
/>UiDropdown
Select dropdowns for enumerated values. Used with enum stores for options.
<UiDropdown
v-model="form.plan_type"
:options="planTypeOptions"
label="Plan Type"
placeholder="Select Plan Type"
full-width
required
option-value="value"
option-label="label"
:error="errors.plan_type"
/>UiSearchSelect
Async searchable select for large datasets (doctors, medicines, patients). Accepts a fetch function that returns options.
<UiSearchSelect
v-model="form.doctor_id"
:fetch="searchDoctors"
placeholder="Select a doctor"
:display-label="selectedDoctorName"
/>Form Validation
useFormValidation
The core validation composable. You define rules per field and call validate(form) before submitting.
import { useFormValidation } from '~/composables/useFormValidation';
import { required, minValue, maxValue } from '~/composables/useValidationRules';
const { errors, validate, clearErrors, setError, clearError } =
useFormValidation<InsuranceFormData>({
provider_name: [required('Provider Name')],
plan_type: [required('Plan Type')],
coverage: [
required('Coverage'),
minValue('Coverage', 0),
maxValue('Coverage', 100),
],
});API:
| Method | Signature | Description |
|---|---|---|
errors | Record<string, string> | Reactive error map. Bind to UiInput's :error prop. |
validate | (form: T) => boolean | Runs all rules. Returns true if valid. Populates errors. |
clearErrors | () => void | Clears all errors. |
setError | (field: string, message: string) => void | Manually set a field error (e.g., from API response). |
clearError | (field: string) => void | Clear a specific field error. |
How validation works:
- For each field in the rules, each rule function is called in order.
- Rules can be field-level (receive the field value) or form-level (receive the entire form object).
- The first rule that returns a string stops validation for that field. That string becomes the error message.
- If all rules return
null, the field passes.
useValidationRules
Pre-built rule factories exported from ~/composables/useValidationRules. Each factory takes a label (used in error messages) and returns a validation function.
| Rule | Factory Signature | Error Message |
|---|---|---|
required | (label: string) | "{label} is required" |
numeric | (label: string) | "{label} must be a number" |
greaterThanZero | (label: string) | "{label} must be greater than 0" |
minValue | (label: string, min: number) | "{label} must be at least {min}" |
maxValue | (label: string, max: number) | "{label} must not be greater than {max}" |
minLength | (label: string, min: number) | "{label} must be at least {min} characters" |
maxLength | (label: string, max: number) | "{label} must not be more than {max} characters" |
email | (label?: string) | "{label} must be a valid email address" |
requiredIf | (label: string, condition: boolean) | "{label} is required" (only when condition is true) |
oneOf | (label: string, allowedValues: Array) | "{label} is invalid" |
Custom Form-Level Rules
For validation that depends on multiple fields, pass a function that receives the full form:
const { errors, validate } = useFormValidation<PasswordForm>({
password: [required('Password'), minLength('Password', 8)],
confirm_password: [
required('Confirm Password'),
(form) =>
form.password !== form.confirm_password
? 'Passwords do not match'
: null,
],
});Create vs. Update Forms
CPR separates create and update forms into distinct components following a consistent pattern.
Create Form ([Entity]Form.vue)
- Initializes
formwith empty/default values. - Uses a
useCreate[Entity]composable for the API call. - On success, shows a snackbar and emits
close.
Update Form (Update[Entity]Form.vue)
- Receives existing data via a
dataprop. - Initializes
formfromprops.data. - Uses a
useUpdate[Entity]composable for the API call. - On success, shows a snackbar and emits
close.
<!-- UpdateInsuranceForm.vue -->
<script setup lang="ts">
const props = defineProps<{
data: InsuranceResource;
}>();
const form = reactive<InsuranceFormData>({
provider_name: props.data.provider_name,
plan_type: props.data.plan_type,
coverage: props.data.coverage,
contact_email: props.data.contact_email || '',
contact_phone: props.data.contact_phone || '',
contact_address: props.data.contact_address || '',
status: props.data.status,
});The data prop is passed through useDialog's props option (see below).
Opening Forms with useDialog
The useDialog composable and UiDialogProvider let you open any component inside a modal without managing v-model state manually.
How It Works
- The default layout renders
<UiDialogProvider />. UiDialogProviderreads fromuseDialog()'s shared state (visible,component,options).- When you call
dialog.open(SomeComponent, options), the provider renders that component inside aUiModal. - The inner component can emit
close(to dismiss) andsuccess(to triggeronSuccess).
Opening a Create Form
import InsuranceForm from '~/components/configuration/forms/InsuranceForms/InsuranceForm.vue';
const formDialog = useDialog();
const handleAddInsuranceClick = () => {
formDialog.open(InsuranceForm, {
title: 'Add New Insurance',
size: 'xl',
onClose: () => refetchCurrentPage(),
onSuccess: () => refetchCurrentPage(),
});
};Opening an Update Form with Props
Pass existing data to the update form via the props option:
import UpdateInsuranceForm from '~/components/configuration/forms/InsuranceForms/UpdateInsuranceForm.vue';
const handleUpdateInsuranceClick = (data: InsuranceResource) => {
formDialog.open(UpdateInsuranceForm, {
title: `Update ${data.provider_name}`,
subtitle: 'Use this form to update the existing insurance provider data',
size: 'xl',
props: { data },
onClose: () => refetchCurrentPage(),
onSuccess: () => refetchCurrentPage(),
});
};Dialog Options
| Option | Type | Default | Description |
|---|---|---|---|
title | string | undefined | Modal header title |
subtitle | string | undefined | Modal header subtitle |
plain | boolean | false | Remove modal chrome |
size | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | ... | 'full' | 'lg' | Modal width |
position | 'center' | 'top' | 'center' | Modal vertical position |
props | Record<string, unknown> | {} | Props passed to the rendered component |
onClose | () => void | undefined | Called when dialog closes |
onSuccess | () => void | undefined | Called when inner component emits success |
Submit Handler Pattern
Every form follows the same submit flow:
const handleSubmit = async () => {
// 1. Validate -- stop if invalid
if (!validate(form)) return;
// 2. Call API composable
await createInsurance(form);
// 3. Check success and provide feedback
if (success.value) {
snackbar.show('Insurance provider created successfully');
emit('close');
}
// Error state is handled by the API composable's `error` ref
};The API composable (e.g., useCreateInsurance) typically exposes:
loading--Ref<boolean>bound to the submit button's:loadingproperror--Ref<string>displayed at the top of the formsuccess--Ref<boolean>checked after the API call
Complete Example: Insurance Form
Here is the full InsuranceForm.vue as a reference implementation.
<template>
<form
class="max-h-[70vh] overflow-y-auto custom-scrollbar"
novalidate
@submit.prevent="handleSubmit"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- API error banner -->
<div
v-if="error"
class="col-span-full p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm"
>
{{ error }}
</div>
<!-- Provider Name (full width) -->
<div class="md:col-span-2">
<UiInput
v-model="form.provider_name"
label="Provider Name"
placeholder="e.g., Maxicare"
:error="errors.provider_name"
required
/>
</div>
<!-- Plan Type -->
<div>
<UiDropdown
v-model="form.plan_type"
:options="planTypeOptions"
label="Plan Type"
placeholder="Select Plan Type"
full-width
required
option-value="value"
option-label="label"
:error="errors.plan_type"
/>
</div>
<!-- Coverage -->
<div>
<UiInput
v-model="form.coverage"
type="number"
label="Coverage (%)"
placeholder="0-100"
:error="errors.coverage"
required
/>
</div>
<!-- Contact Email -->
<div>
<UiInput
v-model="form.contact_email"
type="email"
label="Contact Email"
placeholder="Enter contact email"
/>
</div>
<!-- Contact Phone -->
<div>
<UiInput
v-model="form.contact_phone"
label="Contact Phone"
placeholder="Enter contact phone number"
/>
</div>
<!-- Address (full width) -->
<div class="md:col-span-2">
<UiTextArea
v-model="form.contact_address"
label="Contact Address"
placeholder="Enter complete address"
rows="2"
/>
</div>
<!-- Status -->
<div>
<UiDropdown
v-model="form.status"
:options="statusOptions"
label="Status"
placeholder="Select Status"
full-width
capitalize
option-value="value"
option-label="label"
/>
</div>
</div>
<!-- Form Actions -->
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200 mt-6">
<UiButton variant="secondary" type="button" @click="emit('close')">
Cancel
</UiButton>
<UiButton variant="primary" type="submit" :loading="loading">
Add Insurance
</UiButton>
</div>
</form>
</template>
<script setup lang="ts">
import { reactive, computed, onMounted } from 'vue';
import { useCreateInsurance } from '~/composables/insurance/useCreateInsurance';
import { useSnackBar } from '~/composables/useSnackBar';
import { useEnumsStore } from '~/stores/enums.store';
import type { InsuranceFormData } from '~/types/insurance.type';
import { useFormValidation } from '~/composables/useFormValidation';
import { required, minValue, maxValue } from '~/composables/useValidationRules';
// Emit close to dismiss the dialog
const emit = defineEmits<{ close: [] }>();
// API composable
const { loading, error, success, createInsurance } = useCreateInsurance();
// Snackbar for success/error feedback
const snackbar = useSnackBar();
// Enum store for dropdown options
const enums = useEnumsStore();
onMounted(() => {
enums.fetchEnums();
});
const planTypeOptions = computed(() => enums.insurance_plan_types);
const statusOptions = computed(() => enums.status);
// Reactive form state
const form = reactive<InsuranceFormData>({
provider_name: '',
plan_type: '',
coverage: 0,
contact_email: '',
contact_phone: '',
contact_address: '',
status: 'active',
});
// Validation rules
const { errors, validate } = useFormValidation<InsuranceFormData>({
provider_name: [required('Provider Name')],
plan_type: [required('Plan Type')],
coverage: [
required('Coverage'),
minValue('Coverage', 0),
maxValue('Coverage', 100),
],
});
// Submit handler
const handleSubmit = async () => {
if (!validate(form)) return;
await createInsurance(form);
if (success.value) {
snackbar.show('Insurance provider created successfully');
emit('close');
}
};
</script>Opening This Form from a Page
// In the page component's <script setup>
const formDialog = useDialog();
const handleAddInsuranceClick = () => {
formDialog.open(InsuranceForm, {
title: 'Add New Insurance',
size: 'xl',
onClose: () => refetchCurrentPage(),
onSuccess: () => refetchCurrentPage(),
});
};Checklist for Building a New Form
- Create a TypeScript interface for the form data in
~/types/. - Create the API composable (e.g.,
useCreateEntity) in~/composables/[entity]/. - Create the form component in the appropriate
forms/directory. - Use
reactive<FormDataType>({...})for form state. - Set up
useFormValidationwith rules fromuseValidationRules. - Wire the submit handler: validate, call API, check success, show snackbar, emit close.
- Bind
:error="errors.field_name"on every validated input. - Bind
:loading="loading"on the submit button. - Display API errors with
v-if="error"at the top of the form. - Open the form from a page using
useDialog().open(FormComponent, { ... }).
