Skip to main content

Development Guide

Panduan best practices untuk development di MStore Dashboard.

Development Workflow

Starting Development

# Install dependencies
pnpm install

# Start development server
pnpm dev

# Dashboard runs at http://localhost:4001

Code Generation

Saat membuat perubahan pada types atau schema:
# Generate types (if using code-gen)
pnpm generate:types

Project Structure Best Practices

Feature Module Structure

Setiap feature module harus mengikuti struktur ini:
features/{domain}/
├── api/              # API calls
│   ├── index.ts      # Barrel export
│   ├── getList.ts
│   ├── create.ts
│   └── ...
├── components/       # Domain-specific components
│   ├── index.ts
│   └── {Domain}Table.vue
├── composables/      # Business logic
│   ├── index.ts
│   └── use{Domain}List.ts
├── store/            # Pinia store
│   ├── index.ts
│   └── {domain}.store.ts
├── types/            # TypeScript types
│   ├── index.ts
│   └── {domain}.types.ts
└── index.ts          # Main barrel export

Barrel Exports

Selalu gunakan barrel exports untuk clean imports:
// features/03_inventory/index.ts
export * from './api'
export * from './store'
export * from './composables'
export * from './types'

// Export components dengan named export
export { default as InventoryTable } from './components/InventoryTable.vue'
Usage:
import {
  useInventoryStore,
  useInventoryList,
  InventoryTable,
  type Inventory,
} from '~/features/03_inventory'

Naming Conventions

Files

TypeConventionExample
Pagekebab-caseinventory-list.vue
ComponentPascalCaseInventoryTable.vue
ComposablecamelCaseuseInventoryList.ts
Storekebab-caseinventory.store.ts
Typekebab-caseinventory.types.ts
APIcamelCasegetList.ts

Code

TypeConventionExample
ComponentPascalCaseInventoryTable
ComposableusePascalCaseuseInventoryList
StoreusePascalCaseStoreuseInventoryStore
FunctioncamelCasegetInventoryList
InterfacePascalCaseInventory
TypePascalCaseInventoryStatus
ConstantSCREAMING_SNAKE_CASEMAX_ITEMS

TypeScript Best Practices

Always Type Everything

// Good
interface User {
  id: string
  name: string
  email: string
}

const fetchUser = async (id: string): Promise<User> => {
  return await $api<User>(`/users/${id}`)
}

// Avoid
const fetchUser = async (id) => {
  return await $api(`/users/${id}`)
}

Use Strict Mode

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true
  }
}

Prefer Interfaces Over Types

// Prefer for objects
interface User {
  id: string
  name: string
}

// Use type for unions, intersections
type Status = 'active' | 'inactive'
type UserWithRole = User & { role: string }

Vue 3 Best Practices

Composition API

Always use Composition API with <script setup>:
<script setup lang="ts">
import { useInventoryStore } from '~/features/03_inventory'

const store = useInventoryStore()

// Reactive state
const searchQuery = ref('')

// Computed
const filteredItems = computed(() =>
  store.items.filter(item =>
    item.name.includes(searchQuery.value)
  )
)

// Methods
const handleSearch = (query: string) => {
  searchQuery.value = query
}
</script>

Props & Emits Typing

<script setup lang="ts">
interface Props {
  items: Product[]
  loading?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  loading: false,
})

const emit = defineEmits<{
  select: [item: Product]
  delete: [id: string]
}>()
</script>

Use v-model Properly

<!-- Parent -->
<template>
  <CustomInput v-model="value" />
</template>

<!-- CustomInput.vue -->
<script setup lang="ts">
const model = defineModel<string>()
</script>

<template>
  <input :value="model" @input="model = $event.target.value" />
</template>

State Management Best Practices

Store Organization

// features/03_inventory/store/inventory.store.ts
export const useInventoryStore = defineStore('inventory', () => {
  // 1. State
  const items = ref<Inventory[]>([])
  const loading = ref(false)
  const error = ref<string | null>(null)

  // 2. Getters (computed)
  const hasItems = computed(() => items.value.length > 0)

  // 3. Actions
  const fetchItems = async () => {
    loading.value = true
    try {
      items.value = await getInventoryList()
    } catch (e) {
      error.value = e.message
    } finally {
      loading.value = false
    }
  }

  // 4. Reset
  const $reset = () => {
    items.value = []
    loading.value = false
    error.value = null
  }

  return {
    items,
    loading,
    error,
    hasItems,
    fetchItems,
    $reset,
  }
})

Composable vs Store

Use CaseUse StoreUse Composable
Shared across pages
Single page only
Need persistence
Form state
UI state✅ (or ref)

API Best Practices

Error Handling

// API layer - transform errors
export const getInventoryList = async () => {
  try {
    return await $api<Inventory[]>('/api/v1/inventory')
  } catch (error) {
    if (error.statusCode === 404) {
      throw new Error('Inventory not found')
    }
    throw error
  }
}

// Store - store error state
const fetchItems = async () => {
  try {
    items.value = await getInventoryList()
  } catch (e) {
    error.value = e.message
  }
}

// Composable - show notification
const { fetchItems } = useInventoryList()

const handleFetch = async () => {
  try {
    await fetchItems()
  } catch {
    toast.add({
      title: 'Error',
      description: 'Failed to load inventory',
      color: 'error',
    })
  }
}

Component Best Practices

Single Responsibility

<!-- Good: Single responsibility -->
<ProductTable :items="items" @select="handleSelect" />
<ProductForm :product="selected" @save="handleSave" />

<!-- Avoid: Too many responsibilities -->
<ProductManager /> <!-- Does everything -->

Props Down, Events Up

<!-- Parent -->
<template>
  <ProductList
    :items="items"
    :loading="loading"
    @refresh="fetchItems"
    @delete="handleDelete"
  />
</template>

<!-- ProductList.vue -->
<script setup lang="ts">
defineProps<{
  items: Product[]
  loading: boolean
}>()

const emit = defineEmits<{
  refresh: []
  delete: [id: string]
}>()
</script>

Performance Tips

Lazy Loading

// Lazy load routes
// Nuxt does this automatically with file-based routing

// Lazy load components
const HeavyComponent = defineAsyncComponent(() =>
  import('./HeavyComponent.vue')
)

Virtual Scrolling

Untuk list besar, gunakan virtual scrolling:
<template>
  <VirtualScroller
    :items="items"
    :item-height="50"
    class="h-[500px]"
  >
    <template #default="{ item }">
      <ProductRow :product="item" />
    </template>
  </VirtualScroller>
</template>

Computed Caching

// Good: Computed caches results
const filteredItems = computed(() =>
  items.value.filter(item => item.active)
)

// Avoid: Re-computes every render
const getFilteredItems = () =>
  items.value.filter(item => item.active)

Debugging

Vue DevTools

  • Install Vue DevTools browser extension
  • Inspect component hierarchy
  • Debug Pinia stores
  • Track events

Vue Inspector

// nuxt.config.ts
import VueInspector from 'vite-plugin-vue-inspector'

export default defineNuxtConfig({
  vite: {
    plugins: [
      VueInspector({
        enabled: true,
        toggleButtonVisibility: 'always',
      }),
    ],
  },
})
Click the inspector button to jump to component source code.

Console Logging

// Use structured logging
console.log('[Inventory]', 'Fetching items...', { page, limit })

// Or use a logger utility
import { logger } from '~/utils/logger'
logger.info('Fetching items', { page, limit })

Common Patterns

Loading State Pattern

<script setup lang="ts">
const { data, loading, error, refresh } = useAsyncData(
  'inventory',
  () => getInventoryList()
)
</script>

<template>
  <USkeleton v-if="loading" />
  <UAlert v-else-if="error" color="error" :title="error.message" />
  <InventoryTable v-else :items="data" />
</template>

Form Pattern

<script setup lang="ts">
const form = reactive({
  name: '',
  email: '',
})

const errors = reactive({
  name: '',
  email: '',
})

const isSubmitting = ref(false)

const validate = () => {
  errors.name = form.name ? '' : 'Required'
  errors.email = form.email ? '' : 'Required'
  return !errors.name && !errors.email
}

const handleSubmit = async () => {
  if (!validate()) return

  isSubmitting.value = true
  try {
    await submitForm(form)
    toast.add({ title: 'Saved!' })
  } finally {
    isSubmitting.value = false
  }
}
</script>

Checklist for New Features

  • Create feature folder structure
  • Define types in types/
  • Create API functions in api/
  • Create Pinia store in store/
  • Create composables in composables/
  • Create components in components/
  • Create barrel exports in index.ts
  • Create page in app/pages/
  • Add translations in locales/
  • Write tests (if applicable)
  • Update documentation

Next Steps