Skip to content

Last updated:

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:

vue
<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 novalidate to bypass browser validation (CPR uses its own validation system).
  • The submit handler is bound via @submit.prevent.
  • Forms emit a close event that the UiDialogProvider listens for to dismiss the modal.

Input Components

UiInput

The primary text input. Supports v-model, labels, error display, icons, and multiple input types.

vue
<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.

vue
<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.

vue
<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.

vue
<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.

ts
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:

MethodSignatureDescription
errorsRecord<string, string>Reactive error map. Bind to UiInput's :error prop.
validate(form: T) => booleanRuns all rules. Returns true if valid. Populates errors.
clearErrors() => voidClears all errors.
setError(field: string, message: string) => voidManually set a field error (e.g., from API response).
clearError(field: string) => voidClear a specific field error.

How validation works:

  1. For each field in the rules, each rule function is called in order.
  2. Rules can be field-level (receive the field value) or form-level (receive the entire form object).
  3. The first rule that returns a string stops validation for that field. That string becomes the error message.
  4. 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.

RuleFactory SignatureError 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:

ts
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 form with 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 data prop.
  • Initializes form from props.data.
  • Uses a useUpdate[Entity] composable for the API call.
  • On success, shows a snackbar and emits close.
vue
<!-- 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

  1. The default layout renders <UiDialogProvider />.
  2. UiDialogProvider reads from useDialog()'s shared state (visible, component, options).
  3. When you call dialog.open(SomeComponent, options), the provider renders that component inside a UiModal.
  4. The inner component can emit close (to dismiss) and success (to trigger onSuccess).

Opening a Create Form

ts
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:

ts
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

OptionTypeDefaultDescription
titlestringundefinedModal header title
subtitlestringundefinedModal header subtitle
plainbooleanfalseRemove modal chrome
size'sm' | 'md' | 'lg' | 'xl' | '2xl' | ... | 'full''lg'Modal width
position'center' | 'top''center'Modal vertical position
propsRecord<string, unknown>{}Props passed to the rendered component
onClose() => voidundefinedCalled when dialog closes
onSuccess() => voidundefinedCalled when inner component emits success

Submit Handler Pattern

Every form follows the same submit flow:

ts
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 :loading prop
  • error -- Ref<string> displayed at the top of the form
  • success -- Ref<boolean> checked after the API call

Complete Example: Insurance Form

Here is the full InsuranceForm.vue as a reference implementation.

vue
<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

ts
// 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

  1. Create a TypeScript interface for the form data in ~/types/.
  2. Create the API composable (e.g., useCreateEntity) in ~/composables/[entity]/.
  3. Create the form component in the appropriate forms/ directory.
  4. Use reactive<FormDataType>({...}) for form state.
  5. Set up useFormValidation with rules from useValidationRules.
  6. Wire the submit handler: validate, call API, check success, show snackbar, emit close.
  7. Bind :error="errors.field_name" on every validated input.
  8. Bind :loading="loading" on the submit button.
  9. Display API errors with v-if="error" at the top of the form.
  10. Open the form from a page using useDialog().open(FormComponent, { ... }).

CPR - Clinical Patient Records