Skip to the content.

Vue.js Integration

This guide shows how to integrate Firebase ORM with Vue.js applications, covering Vue 3 Composition API, Options API, and integration with Pinia for state management.

Quick Setup

1. Installation

npm install @arbel/firebase-orm firebase moment --save

For Vue 3 with TypeScript:

npm install --save-dev @types/node

2. TypeScript Configuration

Update your tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "preserve",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "strictPropertyInitialization": false
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

3. Vite Configuration

For Vite projects, update vite.config.ts:

import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  define: {
    global: 'globalThis',
  },
  optimizeDeps: {
    include: ['firebase/firestore', 'firebase/app'],
  },
});

4. Environment Configuration

Create a .env file:

VITE_FIREBASE_API_KEY=your-api-key
VITE_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=your-project-id
VITE_FIREBASE_STORAGE_BUCKET=your-project.appspot.com
VITE_FIREBASE_MESSAGING_SENDER_ID=123456789
VITE_FIREBASE_APP_ID=your-app-id

Firebase Setup

Firebase Configuration

// src/config/firebase.ts
import { initializeApp } from 'firebase/app';
import { getFirestore } from 'firebase/firestore';
import { FirestoreOrmRepository } from '@arbel/firebase-orm';

const firebaseConfig = {
  apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
  authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
  projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
  storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
  appId: import.meta.env.VITE_FIREBASE_APP_ID,
};

// Initialize Firebase
const app = initializeApp(firebaseConfig);
const firestore = getFirestore(app);

// Initialize Firebase ORM
FirestoreOrmRepository.initGlobalConnection(firestore);

export { firestore, app };

Vue Plugin

// src/plugins/firebase.ts
import { App } from 'vue';
import { firestore } from '../config/firebase';

export default {
  install(app: App) {
    app.config.globalProperties.$firestore = firestore;
    app.provide('firestore', firestore);
  },
};

Initialize in Main

// src/main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import { createRouter, createWebHistory } from 'vue-router';
import App from './App.vue';
import firebasePlugin from './plugins/firebase';
import './config/firebase'; // Initialize Firebase ORM

import './style.css';

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', redirect: '/users' },
    { path: '/users', component: () => import('./views/UserList.vue') },
    { path: '/users/:id', component: () => import('./views/UserDetail.vue') },
    { path: '/create', component: () => import('./views/CreateUser.vue') },
  ],
});

const app = createApp(App);
const pinia = createPinia();

app.use(pinia);
app.use(router);
app.use(firebasePlugin);

app.mount('#app');

Model Definition

// src/models/User.ts
import { BaseModel, Model, Field, HasMany } from '@arbel/firebase-orm';
import { Post } from './Post';

@Model({
  reference_path: 'users',
  path_id: 'user_id'
})
export class User extends BaseModel {
  @Field({ is_required: true })
  public name!: string;

  @Field({ is_required: true })
  public email!: string;

  @Field({ field_name: 'created_at' })
  public createdAt?: string;

  @Field({ is_required: false })
  public bio?: string;

  @Field({ is_required: false })
  public avatar?: string;

  @HasMany({ model: Post, foreignKey: 'author_id' })
  public posts?: Post[];

  // Vue-specific helper methods
  toJSON() {
    return {
      id: this.getId(),
      name: this.name,
      email: this.email,
      createdAt: this.createdAt,
      bio: this.bio,
      avatar: this.avatar,
    };
  }

  static async findByEmail(email: string): Promise<User | null> {
    const users = await User.query().where('email', '==', email).get();
    return users.length > 0 ? users[0] : null;
  }

  getDisplayName(): string {
    return this.name || this.email;
  }

  getInitials(): string {
    return this.name
      ? this.name.split(' ').map(n => n[0]).join('').toUpperCase()
      : this.email[0].toUpperCase();
  }
}

Composables

useFirebaseORM Composable

// src/composables/useFirebaseORM.ts
import { ref, onMounted, onUnmounted, type Ref } from 'vue';

export function useFirebaseORM<T extends any>(
  ModelClass: new () => T,
  options: {
    realtime?: boolean;
    autoLoad?: boolean;
  } = {}
) {
  const data: Ref<T[]> = ref([]);
  const loading = ref(false);
  const error = ref<string | null>(null);
  let unsubscribe: (() => void) | null = null;

  // Load data
  const loadData = async () => {
    loading.value = true;
    error.value = null;

    try {
      const items = await (ModelClass as any).getAll();
      data.value = items;
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'An error occurred';
    } finally {
      loading.value = false;
    }
  };

  // Create item
  const createItem = async (itemData: Partial<T>) => {
    try {
      const item = new ModelClass();
      item.initFromData(itemData);
      await (item as any).save();

      if (!options.realtime) {
        data.value.push(item);
      }

      return item;
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Failed to create item';
      throw err;
    }
  };

  // Update item
  const updateItem = async (id: string, updates: Partial<T>) => {
    try {
      const item = new ModelClass();
      await (item as any).load(id);
      item.initFromData(updates);
      await (item as any).save();

      if (!options.realtime) {
        const index = data.value.findIndex(d => (d as any).getId() === id);
        if (index >= 0) {
          data.value[index] = item;
        }
      }

      return item;
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Failed to update item';
      throw err;
    }
  };

  // Delete item
  const deleteItem = async (id: string) => {
    try {
      const item = new ModelClass();
      await (item as any).load(id);
      await (item as any).destroy();

      if (!options.realtime) {
        data.value = data.value.filter(d => (d as any).getId() !== id);
      }
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Failed to delete item';
      throw err;
    }
  };

  // Set up real-time subscription
  const setupRealtime = () => {
    if (options.realtime) {
      unsubscribe = (ModelClass as any).onList((item: T) => {
        const index = data.value.findIndex(d => (d as any).getId() === (item as any).getId());
        if (index >= 0) {
          data.value[index] = item;
        } else {
          data.value.push(item);
        }
      });
    }
  };

  onMounted(() => {
    if (options.autoLoad !== false) {
      loadData();
    }
    setupRealtime();
  });

  onUnmounted(() => {
    if (unsubscribe) {
      unsubscribe();
    }
  });

  return {
    data: readonly(data),
    loading: readonly(loading),
    error: readonly(error),
    loadData,
    createItem,
    updateItem,
    deleteItem,
  };
}

useUser Composable

// src/composables/useUser.ts
import { ref, watch, type Ref } from 'vue';
import { User } from '../models/User';

export function useUser(userId: Ref<string | undefined>) {
  const user = ref<User | null>(null);
  const loading = ref(false);
  const error = ref<string | null>(null);

  const loadUser = async (id: string) => {
    loading.value = true;
    error.value = null;

    try {
      const userInstance = new User();
      await userInstance.load(id);
      user.value = userInstance;
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Failed to load user';
      user.value = null;
    } finally {
      loading.value = false;
    }
  };

  const loadUserPosts = async () => {
    if (!user.value) return [];

    try {
      const posts = await user.value.loadHasMany('posts');
      // Update user with loaded posts
      if (user.value) {
        user.value.posts = posts;
      }
      return posts;
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Failed to load user posts';
      return [];
    }
  };

  // Watch for userId changes
  watch(userId, (newId) => {
    if (newId) {
      loadUser(newId);
    } else {
      user.value = null;
    }
  }, { immediate: true });

  return {
    user: readonly(user),
    loading: readonly(loading),
    error: readonly(error),
    loadUser,
    loadUserPosts,
  };
}

Pinia Store

// src/stores/userStore.ts
import { defineStore } from 'pinia';
import { User } from '../models/User';

interface UserState {
  users: User[];
  selectedUser: User | null;
  loading: boolean;
  error: string | null;
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    users: [],
    selectedUser: null,
    loading: false,
    error: null,
  }),

  getters: {
    getUserById: (state) => {
      return (id: string) => state.users.find(user => user.getId() === id);
    },
    userCount: (state) => state.users.length,
    hasError: (state) => !!state.error,
  },

  actions: {
    // Load all users
    async loadUsers() {
      this.loading = true;
      this.error = null;

      try {
        const users = await User.getAll();
        this.users = users;
      } catch (error) {
        this.error = error instanceof Error ? error.message : 'Failed to load users';
      } finally {
        this.loading = false;
      }
    },

    // Create user
    async createUser(userData: Partial<User>) {
      try {
        const user = new User();
        user.initFromData(userData);
        user.createdAt = new Date().toISOString();
        await user.save();

        this.users.push(user);
        return user;
      } catch (error) {
        this.error = error instanceof Error ? error.message : 'Failed to create user';
        throw error;
      }
    },

    // Update user
    async updateUser(id: string, updates: Partial<User>) {
      try {
        const user = new User();
        await user.load(id);
        user.initFromData(updates);
        await user.save();

        const index = this.users.findIndex(u => u.getId() === id);
        if (index >= 0) {
          this.users[index] = user;
        }

        if (this.selectedUser?.getId() === id) {
          this.selectedUser = user;
        }

        return user;
      } catch (error) {
        this.error = error instanceof Error ? error.message : 'Failed to update user';
        throw error;
      }
    },

    // Delete user
    async deleteUser(id: string) {
      try {
        const user = new User();
        await user.load(id);
        await user.destroy();

        this.users = this.users.filter(u => u.getId() !== id);

        if (this.selectedUser?.getId() === id) {
          this.selectedUser = null;
        }
      } catch (error) {
        this.error = error instanceof Error ? error.message : 'Failed to delete user';
        throw error;
      }
    },

    // Load single user
    async loadUser(id: string) {
      try {
        const user = new User();
        await user.load(id);
        this.selectedUser = user;
        return user;
      } catch (error) {
        this.error = error instanceof Error ? error.message : 'Failed to load user';
        throw error;
      }
    },

    // Clear error
    clearError() {
      this.error = null;
    },

    // Set selected user
    setSelectedUser(user: User | null) {
      this.selectedUser = user;
    },

    // Set up real-time subscription
    setupRealtime() {
      return User.onList((user: User) => {
        const index = this.users.findIndex(u => u.getId() === user.getId());
        if (index >= 0) {
          this.users[index] = user;
        } else {
          this.users.push(user);
        }

        if (this.selectedUser?.getId() === user.getId()) {
          this.selectedUser = user;
        }
      });
    },
  },
});

Vue Components

User List View (Composition API)

<!-- src/views/UserList.vue -->
<template>
  <div class="space-y-6">
    <!-- Header -->
    <div class="flex justify-between items-center">
      <h1 class="text-3xl font-bold text-gray-900">Users</h1>
      <router-link
        to="/create"
        class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 transition-colors"
      >
        Add User
      </router-link>
    </div>

    <!-- Error Message -->
    <div v-if="error" class="bg-red-50 border border-red-200 rounded-md p-4">
      <div class="flex">
        <div class="flex-shrink-0">
          <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
          </svg>
        </div>
        <div class="ml-3">
          <p class="text-sm text-red-800"></p>
        </div>
        <div class="ml-auto pl-3">
          <button
            @click="clearError"
            class="inline-flex rounded-md p-1.5 text-red-500 hover:bg-red-100"
          >
            <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
            </svg>
          </button>
        </div>
      </div>
    </div>

    <!-- Loading State -->
    <div v-if="loading && users.length === 0" class="flex justify-center py-12">
      <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
    </div>

    <!-- Empty State -->
    <div v-else-if="users.length === 0" class="text-center py-12">
      <p class="text-gray-500 text-lg">No users found</p>
      <router-link
        to="/create"
        class="text-blue-500 hover:text-blue-600 mt-2 inline-block"
      >
        Create your first user
      </router-link>
    </div>

    <!-- Users Grid -->
    <div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
      <UserCard
        v-for="user in users"
        :key="user.getId()"
        :user="user"
        @delete="handleDeleteUser"
      />
    </div>

    <!-- Loading Indicator for Additional Data -->
    <div v-if="loading && users.length > 0" class="text-center py-4">
      <div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto"></div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue';
import { useUserStore } from '../stores/userStore';
import { storeToRefs } from 'pinia';
import UserCard from '../components/UserCard.vue';

const userStore = useUserStore();
const { users, loading, error } = storeToRefs(userStore);

let unsubscribe: (() => void) | null = null;

onMounted(async () => {
  await userStore.loadUsers();
  unsubscribe = userStore.setupRealtime();
});

onUnmounted(() => {
  if (unsubscribe) {
    unsubscribe();
  }
});

const handleDeleteUser = async (userId: string) => {
  if (confirm('Are you sure you want to delete this user?')) {
    try {
      await userStore.deleteUser(userId);
    } catch (error) {
      console.error('Failed to delete user:', error);
    }
  }
};

const clearError = () => {
  userStore.clearError();
};
</script>

User Card Component

<!-- src/components/UserCard.vue -->
<template>
  <div class="bg-white rounded-lg shadow-md border border-gray-200 overflow-hidden">
    <div class="p-6">
      <div class="flex items-start justify-between">
        <div class="flex items-center space-x-3">
          <!-- Avatar -->
          <div class="w-12 h-12 bg-gray-300 rounded-full flex items-center justify-center">
            <img
              v-if="user.avatar"
              :src="user.avatar"
              :alt="user.name"
              class="w-12 h-12 rounded-full object-cover"
            />
            <span v-else class="text-gray-600 font-medium">
              
            </span>
          </div>
          
          <!-- User Info -->
          <div>
            <h3 class="text-lg font-semibold text-gray-900">
              
            </h3>
            <p class="text-sm text-gray-600"></p>
          </div>
        </div>

        <!-- Menu -->
        <div class="relative">
          <button
            @click="isMenuOpen = !isMenuOpen"
            class="text-gray-400 hover:text-gray-600"
          >
            <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
              <path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
            </svg>
          </button>

          <!-- Dropdown Menu -->
          <div
            v-if="isMenuOpen"
            class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg z-10 border border-gray-200"
          >
            <div class="py-1">
              <router-link
                :to="`/users/${user.getId()}`"
                class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
                @click="isMenuOpen = false"
              >
                View Details
              </router-link>
              <button
                @click="loadPosts"
                :disabled="loadingPosts"
                class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 disabled:opacity-50"
              >
                
              </button>
              <button
                @click="deleteUser"
                class="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-gray-100"
              >
                Delete
              </button>
            </div>
          </div>
        </div>
      </div>

      <!-- Bio -->
      <p v-if="user.bio" class="mt-3 text-sm text-gray-600"></p>

      <!-- Created Date -->
      <div class="mt-4 text-xs text-gray-500">
        Created: 
      </div>

      <!-- Posts -->
      <div v-if="posts.length > 0" class="mt-4 pt-4 border-t border-gray-200">
        <h4 class="text-sm font-medium text-gray-900 mb-2">
          Posts ()
        </h4>
        <div class="space-y-1">
          <div
            v-for="(post, index) in posts.slice(0, 3)"
            :key="index"
            class="text-xs text-gray-600 p-2 bg-gray-50 rounded"
          >
            
          </div>
          <div v-if="posts.length > 3" class="text-xs text-gray-500">
            + more posts
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { User } from '../models/User';

interface Props {
  user: User;
}

const props = defineProps<Props>();
const emit = defineEmits<{
  delete: [userId: string];
}>();

const isMenuOpen = ref(false);
const posts = ref<any[]>([]);
const loadingPosts = ref(false);

const loadPosts = async () => {
  if (loadingPosts.value) return;

  loadingPosts.value = true;
  try {
    const userPosts = await props.user.loadHasMany('posts');
    posts.value = userPosts;
  } catch (error) {
    console.error('Error loading posts:', error);
  } finally {
    loadingPosts.value = false;
    isMenuOpen.value = false;
  }
};

const deleteUser = () => {
  emit('delete', props.user.getId());
  isMenuOpen.value = false;
};

const formatDate = (dateString?: string) => {
  if (!dateString) return 'N/A';
  return new Date(dateString).toLocaleDateString();
};
</script>

User Detail View

<!-- src/views/UserDetail.vue -->
<template>
  <div class="max-w-4xl mx-auto">
    <!-- Back Button -->
    <div class="mb-6">
      <router-link
        to="/users"
        class="text-blue-500 hover:text-blue-600 flex items-center"
      >
        <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
        </svg>
        Back to Users
      </router-link>
    </div>

    <!-- Loading State -->
    <div v-if="loading" class="flex justify-center py-12">
      <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
    </div>

    <!-- Error State -->
    <div v-else-if="error" class="bg-red-50 border border-red-200 rounded-md p-4">
      <p class="text-red-800"></p>
    </div>

    <!-- User Not Found -->
    <div v-else-if="!user" class="text-center py-12">
      <p class="text-gray-500 text-lg">User not found</p>
      <router-link
        to="/users"
        class="text-blue-500 hover:text-blue-600 mt-2 inline-block"
      >
        Back to Users
      </router-link>
    </div>

    <!-- User Details -->
    <div v-else class="bg-white rounded-lg shadow-lg overflow-hidden">
      <div class="p-8">
        <!-- User Header -->
        <div class="flex items-center space-x-6 mb-6">
          <div class="w-24 h-24 bg-gray-300 rounded-full flex items-center justify-center">
            <img
              v-if="user.avatar"
              :src="user.avatar"
              :alt="user.name"
              class="w-24 h-24 rounded-full object-cover"
            />
            <span v-else class="text-gray-600 font-medium text-2xl">
              
            </span>
          </div>
          <div>
            <h1 class="text-3xl font-bold text-gray-900">
              
            </h1>
            <p class="text-lg text-gray-600"></p>
            <p class="text-sm text-gray-500 mt-1">
              Member since 
            </p>
          </div>
        </div>

        <!-- Bio Section -->
        <div v-if="user.bio" class="mb-6">
          <h2 class="text-xl font-semibold text-gray-900 mb-2">Bio</h2>
          <p class="text-gray-700"></p>
        </div>

        <!-- Posts Section -->
        <div class="border-t pt-6">
          <div class="flex justify-between items-center mb-4">
            <h2 class="text-xl font-semibold text-gray-900">Posts</h2>
            <button
              @click="loadUserPosts"
              :disabled="loadingPosts"
              class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 disabled:opacity-50 transition-colors"
            >
              
            </button>
          </div>

          <!-- Posts Loading -->
          <div v-if="loadingPosts" class="flex justify-center py-8">
            <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
          </div>

          <!-- Posts List -->
          <div v-else-if="user.posts && user.posts.length > 0" class="grid gap-4">
            <div
              v-for="(post, index) in user.posts"
              :key="index"
              class="p-4 border border-gray-200 rounded-md"
            >
              <h3 class="font-medium text-gray-900"></h3>
              <p class="text-gray-600 text-sm mt-1"></p>
              <p class="text-gray-400 text-xs mt-2">
                
              </p>
            </div>
          </div>

          <!-- No Posts -->
          <div v-else-if="user.posts" class="text-center py-8">
            <p class="text-gray-500">No posts found</p>
          </div>

          <!-- Posts Not Loaded -->
          <div v-else class="text-center py-8">
            <p class="text-gray-500">Click "Load Posts" to view user posts</p>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useUser } from '../composables/useUser';

const route = useRoute();
const userId = computed(() => route.params.id as string);

const { user, loading, error, loadUserPosts } = useUser(userId);
const loadingPosts = ref(false);

const handleLoadPosts = async () => {
  loadingPosts.value = true;
  try {
    await loadUserPosts();
  } catch (error) {
    console.error('Failed to load posts:', error);
  } finally {
    loadingPosts.value = false;
  }
};

const formatDate = (dateString?: string) => {
  if (!dateString) return 'N/A';
  return new Date(dateString).toLocaleDateString();
};
</script>

Create User Form

<!-- src/views/CreateUser.vue -->
<template>
  <div class="max-w-2xl mx-auto">
    <h1 class="text-3xl font-bold text-gray-900 mb-8">Create New User</h1>

    <!-- Error Message -->
    <div v-if="error" class="bg-red-50 border border-red-200 rounded-md p-4 mb-6">
      <div class="flex">
        <div class="flex-shrink-0">
          <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
          </svg>
        </div>
        <div class="ml-3">
          <p class="text-sm text-red-800"></p>
        </div>
        <div class="ml-auto pl-3">
          <button
            @click="error = null"
            class="inline-flex rounded-md p-1.5 text-red-500 hover:bg-red-100"
          >
            <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
            </svg>
          </button>
        </div>
      </div>
    </div>

    <!-- Form -->
    <form @submit.prevent="submitForm" class="bg-white shadow-lg rounded-lg p-8">
      <div class="space-y-6">
        <!-- Name Field -->
        <div>
          <label for="name" class="block text-sm font-medium text-gray-700 mb-1">
            Name *
          </label>
          <input
            id="name"
            v-model="formData.name"
            type="text"
            required
            class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
            placeholder="Enter full name"
          />
        </div>

        <!-- Email Field -->
        <div>
          <label for="email" class="block text-sm font-medium text-gray-700 mb-1">
            Email *
          </label>
          <input
            id="email"
            v-model="formData.email"
            type="email"
            required
            class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
            placeholder="Enter email address"
          />
        </div>

        <!-- Avatar Field -->
        <div>
          <label for="avatar" class="block text-sm font-medium text-gray-700 mb-1">
            Avatar URL
          </label>
          <input
            id="avatar"
            v-model="formData.avatar"
            type="url"
            class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
            placeholder="https://example.com/avatar.jpg"
          />
        </div>

        <!-- Bio Field -->
        <div>
          <label for="bio" class="block text-sm font-medium text-gray-700 mb-1">
            Bio
          </label>
          <textarea
            id="bio"
            v-model="formData.bio"
            rows="4"
            class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
            placeholder="Tell us about yourself..."
          />
        </div>
      </div>

      <!-- Form Actions -->
      <div class="flex justify-end space-x-4 mt-8">
        <router-link
          to="/users"
          class="px-4 py-2 text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300 transition-colors"
        >
          Cancel
        </router-link>
        <button
          type="submit"
          :disabled="loading"
          class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
        >
          
        </button>
      </div>
    </form>
  </div>
</template>

<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useUserStore } from '../stores/userStore';

const router = useRouter();
const userStore = useUserStore();

const formData = reactive({
  name: '',
  email: '',
  bio: '',
  avatar: ''
});

const loading = ref(false);
const error = ref<string | null>(null);

const submitForm = async () => {
  error.value = null;

  if (!formData.name.trim() || !formData.email.trim()) {
    error.value = 'Name and email are required';
    return;
  }

  loading.value = true;

  try {
    await userStore.createUser(formData);
    router.push('/users');
  } catch (err) {
    error.value = err instanceof Error ? err.message : 'Failed to create user';
  } finally {
    loading.value = false;
  }
};
</script>

Options API Alternative

For developers who prefer the Options API:

<!-- src/components/UserListOptions.vue -->
<template>
  <div class="space-y-6">
    <div class="flex justify-between items-center">
      <h1 class="text-3xl font-bold text-gray-900">Users (Options API)</h1>
      <router-link
        to="/create"
        class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 transition-colors"
      >
        Add User
      </router-link>
    </div>

    <div v-if="loading" class="flex justify-center py-12">
      <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
    </div>

    <div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
      <UserCard
        v-for="user in users"
        :key="user.getId()"
        :user="user"
        @delete="handleDeleteUser"
      />
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import { User } from '../models/User';
import UserCard from './UserCard.vue';

export default defineComponent({
  name: 'UserListOptions',
  components: {
    UserCard,
  },
  data() {
    return {
      users: [] as User[],
      loading: false,
      error: null as string | null,
      unsubscribe: null as (() => void) | null,
    };
  },
  async mounted() {
    await this.loadUsers();
    this.setupRealtime();
  },
  unmounted() {
    if (this.unsubscribe) {
      this.unsubscribe();
    }
  },
  methods: {
    async loadUsers() {
      this.loading = true;
      this.error = null;

      try {
        this.users = await User.getAll();
      } catch (error) {
        this.error = error instanceof Error ? error.message : 'Failed to load users';
      } finally {
        this.loading = false;
      }
    },
    async handleDeleteUser(userId: string) {
      if (confirm('Are you sure you want to delete this user?')) {
        try {
          const user = new User();
          await user.load(userId);
          await user.destroy();
          // Real-time listener will update the UI
        } catch (error) {
          console.error('Failed to delete user:', error);
        }
      }
    },
    setupRealtime() {
      this.unsubscribe = User.onList((user: User) => {
        const index = this.users.findIndex(u => u.getId() === user.getId());
        if (index >= 0) {
          this.users[index] = user;
        } else {
          this.users.push(user);
        }
      });
    },
  },
});
</script>

Testing

Vitest Setup

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
  },
});
// src/test/setup.ts
import { vi } from 'vitest';

// Mock Firebase
vi.mock('../config/firebase', () => ({
  firestore: {},
  app: {},
}));

// Mock Firebase ORM
vi.mock('@arbel/firebase-orm', () => ({
  FirestoreOrmRepository: {
    initGlobalConnection: vi.fn(),
  },
  BaseModel: class MockBaseModel {
    save = vi.fn();
    load = vi.fn();
    destroy = vi.fn();
    getId = vi.fn(() => 'mock-id');
    static getAll = vi.fn(() => Promise.resolve([]));
    static query = vi.fn(() => ({
      where: vi.fn().mockReturnThis(),
      get: vi.fn(() => Promise.resolve([])),
    }));
    static onList = vi.fn(() => vi.fn());
  },
  Model: () => (target: any) => target,
  Field: () => (target: any, propertyKey: string) => {},
  HasMany: () => (target: any, propertyKey: string) => {},
}));

Component Tests

// src/components/__tests__/UserCard.test.ts
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import UserCard from '../UserCard.vue';
import { User } from '../../models/User';

// Mock user
const mockUser = {
  getId: () => 'test-id',
  name: 'John Doe',
  email: 'john@example.com',
  getDisplayName: () => 'John Doe',
  getInitials: () => 'JD',
  loadHasMany: vi.fn(() => Promise.resolve([])),
} as unknown as User;

describe('UserCard', () => {
  it('renders user information correctly', () => {
    const wrapper = mount(UserCard, {
      props: {
        user: mockUser,
      },
    });

    expect(wrapper.text()).toContain('John Doe');
    expect(wrapper.text()).toContain('john@example.com');
    expect(wrapper.text()).toContain('JD');
  });

  it('emits delete event when delete button is clicked', async () => {
    const wrapper = mount(UserCard, {
      props: {
        user: mockUser,
      },
    });

    // Open menu
    await wrapper.find('button').trigger('click');
    
    // Click delete
    await wrapper.find('button:last-child').trigger('click');

    expect(wrapper.emitted().delete).toBeTruthy();
    expect(wrapper.emitted().delete[0]).toEqual(['test-id']);
  });
});

Best Practices

1. Performance Optimization

<!-- Use v-memo for expensive rendering -->
<div
  v-for="user in users"
  :key="user.getId()"
  v-memo="[user.getId(), user.name, user.email]"
>
  <!-- User content -->
</div>

<!-- Use computed properties for expensive calculations -->
<script setup>
const expensiveValue = computed(() => {
  return users.value.filter(user => user.isActive).length;
});
</script>

2. Error Handling

// Global error handler
app.config.errorHandler = (err, vm, info) => {
  console.error('Global error:', err);
  console.error('Component info:', info);
};

3. TypeScript Integration

// Define proper types
interface UserFormData {
  name: string;
  email: string;
  bio: string;
  avatar: string;
}

const formData = reactive<UserFormData>({
  name: '',
  email: '',
  bio: '',
  avatar: ''
});

Common Issues

1. Reactivity with Firebase ORM Models

Ensure proper reactivity when working with Firebase ORM models:

// Use reactive/ref for model instances
const user = ref<User | null>(null);

// Update reactively
const updateUser = (newUser: User) => {
  user.value = newUser;
};

2. Real-time Subscription Cleanup

Always clean up subscriptions in onUnmounted:

onUnmounted(() => {
  if (unsubscribe) {
    unsubscribe();
  }
});

3. TypeScript Decorators

Ensure proper TypeScript configuration for decorator support.

Next Steps