---
name: Form Development with VeeValidate
description: Hướng dẫn tạo forms với validation sử dụng VeeValidate và Yup trong dự án LADA Express
---

# Form Development with VeeValidate Skill

## Mục đích

Skill này hướng dẫn cách tạo forms với validation sử dụng VeeValidate và Yup schema trong dự án LADA Express.

## Công nghệ sử dụng

- **VeeValidate 4.x** - Form validation
- **Yup** - Schema validation
- **@vee-validate/rules** - Validation rules

## 1. Basic Form Pattern

### Simple Form với VeeValidate

```vue
<template>
  <div class="card">
    <div class="card-header">
      <h5 class="card-title mb-0">
        <i class="fas fa-edit me-2"></i>
        {{ isEditMode ? 'Chỉnh sửa' : 'Thêm mới' }}
      </h5>
    </div>
    <div class="card-body">
      <Form @submit="onSubmit" :validation-schema="schema" v-slot="{ errors }">
        <div class="row">
          <!-- Text Input -->
          <div class="col-md-6 mb-3">
            <label>Tên <span class="text-danger">*</span></label>
            <Field 
              name="name" 
              type="text" 
              class="form-control" 
              :class="{ 'is-invalid': errors.name }"
              placeholder="Nhập tên"
            />
            <ErrorMessage name="name" class="invalid-feedback" />
          </div>

          <!-- Email Input -->
          <div class="col-md-6 mb-3">
            <label>Email <span class="text-danger">*</span></label>
            <Field 
              name="email" 
              type="email" 
              class="form-control"
              :class="{ 'is-invalid': errors.email }"
              placeholder="Nhập email"
            />
            <ErrorMessage name="email" class="invalid-feedback" />
          </div>

          <!-- Phone Input -->
          <div class="col-md-6 mb-3">
            <label>Số điện thoại <span class="text-danger">*</span></label>
            <Field 
              name="phone" 
              type="text" 
              class="form-control"
              :class="{ 'is-invalid': errors.phone }"
              placeholder="Nhập số điện thoại"
            />
            <ErrorMessage name="phone" class="invalid-feedback" />
          </div>

          <!-- Select Dropdown -->
          <div class="col-md-6 mb-3">
            <label>Trạng thái <span class="text-danger">*</span></label>
            <Field 
              name="status" 
              as="select" 
              class="form-select"
              :class="{ 'is-invalid': errors.status }"
            >
              <option value="">-- Chọn trạng thái --</option>
              <option value="active">Hoạt động</option>
              <option value="inactive">Không hoạt động</option>
            </Field>
            <ErrorMessage name="status" class="invalid-feedback" />
          </div>

          <!-- Textarea -->
          <div class="col-md-12 mb-3">
            <label>Mô tả</label>
            <Field 
              name="description" 
              as="textarea" 
              class="form-control"
              :class="{ 'is-invalid': errors.description }"
              rows="3"
              placeholder="Nhập mô tả"
            />
            <ErrorMessage name="description" class="invalid-feedback" />
          </div>

          <!-- Checkbox -->
          <div class="col-md-12 mb-3">
            <div class="form-check">
              <Field 
                name="isActive" 
                type="checkbox" 
                class="form-check-input"
                :value="true"
              />
              <label class="form-check-label">
                Kích hoạt
              </label>
            </div>
          </div>
        </div>

        <!-- Form Actions -->
        <div class="d-flex justify-content-end gap-2">
          <button 
            type="button" 
            class="btn btn-secondary"
            @click="onCancel"
          >
            <i class="fe fe-x me-1"></i>
            Hủy
          </button>
          <button 
            type="submit" 
            class="btn btn-primary"
            :disabled="loading"
          >
            <span v-if="loading" class="spinner-border spinner-border-sm me-1"></span>
            <i v-else class="fe fe-save me-1"></i>
            {{ isEditMode ? 'Cập nhật' : 'Lưu' }}
          </button>
        </div>
      </Form>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import { Form, Field, ErrorMessage } from 'vee-validate';
import * as yup from 'yup';
import { useToast } from 'vue-toastification';
import moduleService from '@/services/moduleService';

const props = defineProps({
  id: {
    type: [Number, String],
    default: null
  }
});

const emit = defineEmits(['saved', 'cancelled']);

const toast = useToast();
const loading = ref(false);
const isEditMode = ref(false);

// Validation Schema
const schema = yup.object({
  name: yup.string()
    .required('Vui lòng nhập tên')
    .min(3, 'Tên phải có ít nhất 3 ký tự')
    .max(100, 'Tên không được vượt quá 100 ký tự'),
  
  email: yup.string()
    .required('Vui lòng nhập email')
    .email('Email không hợp lệ'),
  
  phone: yup.string()
    .required('Vui lòng nhập số điện thoại')
    .matches(/^[0-9]{10}$/, 'Số điện thoại phải có 10 chữ số'),
  
  status: yup.string()
    .required('Vui lòng chọn trạng thái'),
  
  description: yup.string()
    .max(500, 'Mô tả không được vượt quá 500 ký tự'),
  
  isActive: yup.boolean()
});

async function onSubmit(values) {
  loading.value = true;
  
  try {
    const model = {
      ...values,
      Id: props.id || 0
    };
    
    const result = await moduleService.createOrUpdate(model);
    
    if (result && result.Result === 1) {
      toast.success(isEditMode.value ? 'Cập nhật thành công' : 'Thêm mới thành công');
      emit('saved', result.Item);
    } else {
      toast.error('Lỗi: ' + (result.Message || 'Không thể lưu dữ liệu'));
    }
  } catch (error) {
    console.error('Error saving:', error);
    toast.error('Có lỗi xảy ra khi lưu dữ liệu');
  } finally {
    loading.value = false;
  }
}

function onCancel() {
  emit('cancelled');
}

async function loadData() {
  if (!props.id) return;
  
  loading.value = true;
  isEditMode.value = true;
  
  try {
    const result = await moduleService.getById(props.id);
    
    if (result && result.Result === 1) {
      // Set form values
      // Note: VeeValidate sẽ tự động bind values nếu field names match
    } else {
      toast.error('Không thể tải dữ liệu');
    }
  } catch (error) {
    console.error('Error loading:', error);
    toast.error('Có lỗi xảy ra khi tải dữ liệu');
  } finally {
    loading.value = false;
  }
}

onMounted(() => {
  if (props.id) {
    loadData();
  }
});
</script>
```

## 2. Advanced Validation Patterns

### Custom Validation Rules

```javascript
import * as yup from 'yup';

// Vietnamese phone number
const phoneRegex = /^(0|\+84)(3|5|7|8|9)[0-9]{8}$/;

// Custom validation methods
yup.addMethod(yup.string, 'vietnamesePhone', function(message) {
  return this.matches(phoneRegex, message || 'Số điện thoại không hợp lệ');
});

// Usage
const schema = yup.object({
  phone: yup.string()
    .required('Vui lòng nhập số điện thoại')
    .vietnamesePhone()
});
```

### Conditional Validation

```javascript
const schema = yup.object({
  type: yup.string().required('Vui lòng chọn loại'),
  
  // Required only if type is 'company'
  companyName: yup.string()
    .when('type', {
      is: 'company',
      then: (schema) => schema.required('Vui lòng nhập tên công ty'),
      otherwise: (schema) => schema.notRequired()
    }),
  
  // Required only if type is 'individual'
  personalId: yup.string()
    .when('type', {
      is: 'individual',
      then: (schema) => schema.required('Vui lòng nhập CMND/CCCD'),
      otherwise: (schema) => schema.notRequired()
    })
});
```

### Cross-field Validation

```javascript
const schema = yup.object({
  password: yup.string()
    .required('Vui lòng nhập mật khẩu')
    .min(8, 'Mật khẩu phải có ít nhất 8 ký tự'),
  
  confirmPassword: yup.string()
    .required('Vui lòng xác nhận mật khẩu')
    .oneOf([yup.ref('password')], 'Mật khẩu xác nhận không khớp')
});
```

### Date Validation

```javascript
const schema = yup.object({
  startDate: yup.date()
    .required('Vui lòng chọn ngày bắt đầu')
    .min(new Date(), 'Ngày bắt đầu phải sau ngày hiện tại'),
  
  endDate: yup.date()
    .required('Vui lòng chọn ngày kết thúc')
    .min(yup.ref('startDate'), 'Ngày kết thúc phải sau ngày bắt đầu')
});
```

## 3. Form với v-select

```vue
<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    <div class="mb-3">
      <label>Khách hàng <span class="text-danger">*</span></label>
      <Field name="customerId" v-slot="{ field, errorMessage }">
        <v-select
          v-model="field.value"
          :options="customers"
          :reduce="customer => customer.Id"
          label="Name"
          placeholder="Chọn khách hàng"
          :class="{ 'is-invalid': errorMessage }"
        >
          <template #no-options>
            Không tìm thấy khách hàng
          </template>
        </v-select>
        <div v-if="errorMessage" class="invalid-feedback d-block">
          {{ errorMessage }}
        </div>
      </Field>
    </div>
  </Form>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import { Form, Field } from 'vee-validate';
import * as yup from 'yup';

const customers = ref([]);

const schema = yup.object({
  customerId: yup.number()
    .required('Vui lòng chọn khách hàng')
    .positive('Vui lòng chọn khách hàng hợp lệ')
});

onMounted(async () => {
  // Load customers
  customers.value = await loadCustomers();
});
</script>
```

## 4. Form với VueDatePicker

```vue
<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    <div class="mb-3">
      <label>Ngày sinh <span class="text-danger">*</span></label>
      <Field name="birthDate" v-slot="{ field, errorMessage }">
        <VueDatePicker
          v-model="field.value"
          :enable-time-picker="false"
          format="dd/MM/yyyy"
          placeholder="Chọn ngày sinh"
          :class="{ 'is-invalid': errorMessage }"
        />
        <div v-if="errorMessage" class="invalid-feedback d-block">
          {{ errorMessage }}
        </div>
      </Field>
    </div>
  </Form>
</template>

<script setup>
import { Form, Field } from 'vee-validate';
import VueDatePicker from '@vuepic/vue-datepicker';
import * as yup from 'yup';

const schema = yup.object({
  birthDate: yup.date()
    .required('Vui lòng chọn ngày sinh')
    .max(new Date(), 'Ngày sinh không được sau ngày hiện tại')
});
</script>
```

## 5. Form với File Upload

```vue
<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    <div class="mb-3">
      <label>Ảnh đại diện</label>
      <Field name="avatar" v-slot="{ field, errorMessage }">
        <input
          type="file"
          class="form-control"
          :class="{ 'is-invalid': errorMessage }"
          @change="handleFileChange($event, field)"
          accept="image/*"
        />
        <div v-if="errorMessage" class="invalid-feedback">
          {{ errorMessage }}
        </div>
      </Field>
      
      <!-- Preview -->
      <div v-if="previewUrl" class="mt-2">
        <img :src="previewUrl" alt="Preview" style="max-width: 200px;" />
      </div>
    </div>
  </Form>
</template>

<script setup>
import { ref } from 'vue';
import { Form, Field } from 'vee-validate';
import * as yup from 'yup';

const previewUrl = ref(null);

const schema = yup.object({
  avatar: yup.mixed()
    .test('fileSize', 'File quá lớn (tối đa 5MB)', (value) => {
      if (!value) return true;
      return value.size <= 5 * 1024 * 1024; // 5MB
    })
    .test('fileType', 'Chỉ chấp nhận file ảnh', (value) => {
      if (!value) return true;
      return ['image/jpeg', 'image/png', 'image/gif'].includes(value.type);
    })
});

function handleFileChange(event, field) {
  const file = event.target.files[0];
  field.value = file;
  
  if (file) {
    previewUrl.value = URL.createObjectURL(file);
  } else {
    previewUrl.value = null;
  }
}
</script>
```

## 6. Dynamic Form Fields

```vue
<template>
  <Form @submit="onSubmit" :validation-schema="schema">
    <div v-for="(item, index) in items" :key="index" class="mb-3">
      <div class="row">
        <div class="col-md-5">
          <Field 
            :name="`items[${index}].name`" 
            type="text" 
            class="form-control"
            placeholder="Tên sản phẩm"
          />
          <ErrorMessage :name="`items[${index}].name`" class="text-danger" />
        </div>
        
        <div class="col-md-3">
          <Field 
            :name="`items[${index}].quantity`" 
            type="number" 
            class="form-control"
            placeholder="Số lượng"
          />
          <ErrorMessage :name="`items[${index}].quantity`" class="text-danger" />
        </div>
        
        <div class="col-md-3">
          <Field 
            :name="`items[${index}].price`" 
            type="number" 
            class="form-control"
            placeholder="Đơn giá"
          />
          <ErrorMessage :name="`items[${index}].price`" class="text-danger" />
        </div>
        
        <div class="col-md-1">
          <button 
            type="button" 
            class="btn btn-danger"
            @click="removeItem(index)"
          >
            <i class="fe fe-trash"></i>
          </button>
        </div>
      </div>
    </div>
    
    <button 
      type="button" 
      class="btn btn-secondary mb-3"
      @click="addItem"
    >
      <i class="fe fe-plus me-1"></i>
      Thêm dòng
    </button>
  </Form>
</template>

<script setup>
import { ref } from 'vue';
import { Form, Field, ErrorMessage } from 'vee-validate';
import * as yup from 'yup';

const items = ref([
  { name: '', quantity: 0, price: 0 }
]);

const schema = yup.object({
  items: yup.array().of(
    yup.object({
      name: yup.string().required('Vui lòng nhập tên'),
      quantity: yup.number()
        .required('Vui lòng nhập số lượng')
        .positive('Số lượng phải lớn hơn 0'),
      price: yup.number()
        .required('Vui lòng nhập đơn giá')
        .positive('Đơn giá phải lớn hơn 0')
    })
  ).min(1, 'Phải có ít nhất 1 sản phẩm')
});

function addItem() {
  items.value.push({ name: '', quantity: 0, price: 0 });
}

function removeItem(index) {
  items.value.splice(index, 1);
}
</script>
```

## 7. Common Validation Rules

```javascript
import * as yup from 'yup';

// Required field
yup.string().required('Trường này là bắt buộc')

// Email
yup.string().email('Email không hợp lệ')

// Min/Max length
yup.string().min(3, 'Tối thiểu 3 ký tự').max(100, 'Tối đa 100 ký tự')

// Number range
yup.number().min(0, 'Giá trị tối thiểu là 0').max(100, 'Giá trị tối đa là 100')

// Positive number
yup.number().positive('Giá trị phải lớn hơn 0')

// URL
yup.string().url('URL không hợp lệ')

// Regex pattern
yup.string().matches(/^[a-zA-Z0-9]+$/, 'Chỉ chấp nhận chữ và số')

// One of values
yup.string().oneOf(['active', 'inactive'], 'Giá trị không hợp lệ')

// Array min/max
yup.array().min(1, 'Phải chọn ít nhất 1 mục').max(5, 'Chọn tối đa 5 mục')
```

## Best Practices

### 1. Form Organization

- Nhóm các fields liên quan vào cùng một row
- Sử dụng Bootstrap grid system (col-md-6, col-md-12)
- Đặt required indicator (*) cho các trường bắt buộc
- Hiển thị error messages ngay dưới field

### 2. Validation Messages

- Sử dụng tiếng Việt cho messages
- Messages phải rõ ràng và hữu ích
- Tránh technical jargon

### 3. Loading States

- Disable submit button khi đang loading
- Show spinner trong button
- Prevent multiple submissions

### 4. Error Handling

- Validate trước khi submit
- Show API errors với toast
- Log errors vào console

## Checklist khi tạo Form mới

- [ ] Import Form, Field, ErrorMessage từ vee-validate
- [ ] Tạo validation schema với yup
- [ ] Bind schema vào Form component
- [ ] Add error classes (:class="{ 'is-invalid': errors.fieldName }")
- [ ] Show ErrorMessage cho mỗi field
- [ ] Implement onSubmit handler
- [ ] Add loading state
- [ ] Handle API errors
- [ ] Test validation rules
- [ ] Test form submission
