Skip to content
Directus UI Extensions Mastery logo

Directus UI Extensions Mastery

Build Vue 3 UI extensions for Directus with modern patterns, real-time data, and responsive design

SKILL.md

Full skill instructions

Directus UI Extensions Mastery

Overview

This skill provides expert guidance for building production-ready Vue 3 UI extensions in Directus. Master the creation of custom panels, interfaces, displays, and layouts using the @directus/​extensions-sdk with modern Vue 3 Composition API patterns. Implement real-time data visualization, responsive design, and seamless integration with Directus' component library.

When to Use This Skill

  • Building custom dashboard panels for data visualization
  • Creating specialized input interfaces for complex data types
  • Developing custom collection displays and layouts
  • Implementing real-time components with WebSocket integration
  • Adding glass morphism or modern UI design patterns
  • Ensuring mobile parity and responsive design
  • Integrating with Directus theme system
  • Creating reusable UI components for teams

Core Concepts

Extension Types

  1. Panels - Dashboard widgets for Insights module
  2. Interfaces - Custom input components for data entry
  3. Displays - Custom rendering of field values
  4. Layouts - Alternative collection views
  5. Modules - Complete custom sections in Directus

Technology Stack

  • Vue 3 with Composition API
  • TypeScript for type safety
  • @directus/​extensions-sdk for Directus integration
  • Vite for building and development
  • Pinia for state management (via useStores)
  • Vue Router for navigation (in modules)

Process: Building a Custom Panel

Step 1: Initialize Extension

# Create new panel extension
npx create-directus-extension@latest

# Select options:
# > panel
# > my-custom-panel
# > typescript

Step 2: Configure Panel Metadata

// src/​index.ts
import { definePanel } from '@directus/​extensions-sdk';
import PanelComponent from './​panel.vue';

export default definePanel({
  id: 'custom-analytics',
  name: 'Analytics Dashboard',
  icon: 'analytics',
  description: 'Real-time analytics and metrics',
  component: PanelComponent,
  minWidth: 12,
  minHeight: 8,
  options: [
    {
      field: 'collection',
      type: 'string',
      name: 'Collection',
      meta: {
        interface: 'system-collection',
        width: 'half',
      },
    },
    {
      field: 'dateField',
      type: 'string',
      name: 'Date Field',
      meta: {
        interface: 'system-field',
        options: {
          collectionField: 'collection',
          typeAllowList: ['datetime', 'date', 'timestamp'],
        },
        width: 'half',
      },
    },
    {
      field: 'refreshInterval',
      type: 'integer',
      name: 'Refresh Interval (seconds)',
      meta: {
        interface: 'input',
        width: 'half',
      },
      schema: {
        default_value: 30,
      },
    },
  ],
});

Step 3: Implement Vue Component

<!-- src/​panel.vue -->
<template>
  <div class="analytics-panel">
    <div v-if="loading" class="loading-state">
      <v-progress-circular indeterminate />
    </​div>

    <div v-else-if="error" class="error-state">
      <v-notice type="danger">
        {{ error }}
      </​v-notice>
    </​div>

    <div v-else class="panel-content">
      <div class="metrics-grid">
        <div class="metric-card" v-for="metric in metrics" :key="metric.id">
          <div class="metric-value">{{ formatNumber(metric.value) }}</​div>
          <div class="metric-label">{{ metric.label }}</​div>
          <div class="metric-change" :class="metric.trend">
            <v-icon :name="getTrendIcon(metric.trend)" small />
            {{ metric.change }}%
          </​div>
        </​div>
      </​div>

      <div class="chart-container">
        <canvas ref="chartCanvas"></​canvas>
      </​div>
    </​div>
  </​div>
</​template>

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useApi, useStores } from '@directus/​extensions-sdk';
import { Chart, registerables } from 'chart.js';

// Register Chart.js components
Chart.register(...registerables);

// Props from panel configuration
interface Props {
  collection?: string;
  dateField?: string;
  refreshInterval?: number;
  showHeader?: boolean;
  height: number;
  width: number;
}

const props = withDefaults(defineProps<Props>(), {
  refreshInterval: 30,
  showHeader: true,
});

// Composables
const api = useApi();
const { useItemsStore } = useStores();
const itemsStore = useItemsStore();

// Reactive state
const loading = ref(true);
const error = ref<string | null>(null);
const metrics = ref([]);
const chartData = ref([]);
const chart = ref<Chart | null>(null);
const chartCanvas = ref<HTMLCanvasElement>();
const refreshTimer = ref<NodeJS.Timeout>();

// Computed properties
const chartConfig = computed(() => ({
  type: 'line',
  data: {
    labels: chartData.value.map(d => formatDate(d.date)),
    datasets: [{
      label: 'Activity',
      data: chartData.value.map(d => d.value),
      borderColor: 'var(--theme--primary)',
      backgroundColor: 'var(--theme--primary-background)',
      tension: 0.4,
      fill: true,
    }],
  },
  options: {
    responsive: true,
    maintainAspectRatio: false,
    plugins: {
      legend: {
        display: false,
      },
      tooltip: {
        backgroundColor: 'var(--theme--background-accent)',
        titleColor: 'var(--theme--foreground)',
        bodyColor: 'var(--theme--foreground-subdued)',
      },
    },
    scales: {
      x: {
        grid: {
          color: 'var(--theme--border-color-subdued)',
        },
        ticks: {
          color: 'var(--theme--foreground-subdued)',
        },
      },
      y: {
        grid: {
          color: 'var(--theme--border-color-subdued)',
        },
        ticks: {
          color: 'var(--theme--foreground-subdued)',
        },
      },
    },
  },
}));

// Methods
async function fetchData() {
  if (!props.collection) {
    error.value = 'No collection selected';
    loading.value = false;
    return;
  }

  try {
    loading.value = true;
    error.value = null;

    // Fetch aggregate data for metrics
    const { data: aggregateData } = await api.get(`/​items/​${props.collection}`, {
      params: {
        aggregate: {
          count: '*',
          avg: 'amount',
          sum: 'amount',
        },
        groupBy: props.dateField ? [props.dateField] : undefined,
        filter: props.dateField ? {
          [props.dateField]: {
            _between: [getStartDate(), getEndDate()],
          },
        } : undefined,
        limit: -1,
      },
    });

    // Process metrics
    processMetrics(aggregateData);

    // Fetch time series data for chart
    if (props.dateField) {
      const { data: timeSeriesData } = await api.get(`/​items/​${props.collection}`, {
        params: {
          fields: [props.dateField, 'amount'],
          filter: {
            [props.dateField]: {
              _between: [getStartDate(), getEndDate()],
            },
          },
          sort: [props.dateField],
          limit: 100,
        },
      });

      processChartData(timeSeriesData);
    }

    loading.value = false;
  } catch (err) {
    console.error('Error fetching data:', err);
    error.value = 'Failed to load data';
    loading.value = false;
  }
}

function processMetrics(data: any) {
  // Calculate and format metrics
  const total = data?.count || 0;
  const average = data?.avg?.amount || 0;
  const sum = data?.sum?.amount || 0;

  metrics.value = [
    {
      id: 'total',
      label: 'Total Items',
      value: total,
      change: calculateChange(total, 'previous'),
      trend: total > 0 ? 'up' : 'neutral',
    },
    {
      id: 'average',
      label: 'Average Value',
      value: average,
      change: calculateChange(average, 'previous'),
      trend: average > 0 ? 'up' : 'down',
    },
    {
      id: 'sum',
      label: 'Total Value',
      value: sum,
      change: calculateChange(sum, 'previous'),
      trend: sum > 0 ? 'up' : 'neutral',
    },
  ];
}

function processChartData(data: any[]) {
  // Aggregate data by date
  const aggregated = data.reduce((acc, item) => {
    const date = new Date(item[props.dateField!]).toLocaleDateString();
    if (!acc[date]) {
      acc[date] = { date, value: 0, count: 0 };
    }
    acc[date].value += item.amount || 0;
    acc[date].count++;
    return acc;
  }, {});

  chartData.value = Object.values(aggregated);
  updateChart();
}

function updateChart() {
  if (!chartCanvas.value) return;

  if (chart.value) {
    chart.value.destroy();
  }

  chart.value = new Chart(chartCanvas.value, chartConfig.value as any);
}

function setupAutoRefresh() {
  if (props.refreshInterval && props.refreshInterval > 0) {
    refreshTimer.value = setInterval(() => {
      fetchData();
    }, props.refreshInterval * 1000);
  }
}

function cleanupAutoRefresh() {
  if (refreshTimer.value) {
    clearInterval(refreshTimer.value);
  }
}

// Utility functions
function formatNumber(value: number): string {
  return new Intl.NumberFormat('en-US', {
    notation: 'compact',
    maximumFractionDigits: 1,
  }).format(value);
}

function formatDate(date: string): string {
  return new Date(date).toLocaleDateString('en-US', {
    month: 'short',
    day: 'numeric',
  });
}

function getTrendIcon(trend: string): string {
  switch (trend) {
    case 'up': return 'trending_up';
    case 'down': return 'trending_down';
    default: return 'trending_flat';
  }
}

function calculateChange(current: number, previous: number | string): number {
  // Mock calculation - replace with actual logic
  return Math.round(Math.random() * 20 - 10);
}

function getStartDate(): string {
  const date = new Date();
  date.setDate(date.getDate() - 30);
  return date.toISOString();
}

function getEndDate(): string {
  return new Date().toISOString();
}

// Lifecycle hooks
onMounted(() => {
  fetchData();
  setupAutoRefresh();
});

onUnmounted(() => {
  cleanupAutoRefresh();
  if (chart.value) {
    chart.value.destroy();
  }
});

// Watchers
watch(() => props.collection, () => {
  fetchData();
});

watch(() => props.refreshInterval, () => {
  cleanupAutoRefresh();
  setupAutoRefresh();
});
</​script>

<style scoped>
.analytics-panel {
  height: 100%;
  display: flex;
  flex-direction: column;
  padding: var(--content-padding);
  background: var(--theme--background);
  border-radius: var(--theme--border-radius);
}

.loading-state,
.error-state {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
}

.panel-content {
  display: flex;
  flex-direction: column;
  gap: var(--spacing-l);
  height: 100%;
}

.metrics-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: var(--spacing-m);
}

.metric-card {
  padding: var(--spacing-m);
  background: var(--theme--background-accent);
  border-radius: var(--theme--border-radius);
  border: 1px solid var(--theme--border-color-subdued);
}

.metric-value {
  font-size: 1.75rem;
  font-weight: 600;
  color: var(--theme--primary);
  line-height: 1.2;
}

.metric-label {
  font-size: 0.875rem;
  color: var(--theme--foreground-subdued);
  margin-top: var(--spacing-xs);
}

.metric-change {
  display: flex;
  align-items: center;
  gap: var(--spacing-xs);
  margin-top: var(--spacing-s);
  font-size: 0.875rem;
}

.metric-change.up {
  color: var(--theme--success);
}

.metric-change.down {
  color: var(--theme--danger);
}

.metric-change.neutral {
  color: var(--theme--foreground-subdued);
}

.chart-container {
  flex: 1;
  position: relative;
  min-height: 200px;
}

/​* Responsive design */
@media (max-width: 768px) {
  .metrics-grid {
    grid-template-columns: 1fr;
  }

  .metric-card {
    padding: var(--spacing-s);
  }

  .metric-value {
    font-size: 1.5rem;
  }
}

/​* Glass morphism variant */
.analytics-panel.glass {
  background: rgba(255, 255, 255, 0.1);
  backdrop-filter: blur(10px);
  border: 1px solid rgba(255, 255, 255, 0.2);
}

.analytics-panel.glass .metric-card {
  background: rgba(255, 255, 255, 0.05);
  backdrop-filter: blur(5px);
  border: 1px solid rgba(255, 255, 255, 0.1);
}
</​style>

Process: Building a Custom Interface

Step 1: Interface Structure

// src/​index.ts
import { defineInterface } from '@directus/​extensions-sdk';
import InterfaceComponent from './​interface.vue';

export default defineInterface({
  id: 'color-palette',
  name: 'Color Palette',
  icon: 'palette',
  description: 'Select colors from a predefined palette',
  component: InterfaceComponent,
  types: ['string', 'json'],
  group: 'selection',
  options: [
    {
      field: 'palette',
      type: 'json',
      name: 'Color Palette',
      meta: {
        interface: 'code',
        options: {
          language: 'json',
        },
      },
      schema: {
        default_value: [
          '#FF6B6B', '#4ECDC4', '#45B7D1',
          '#96CEB4', '#FECA57', '#FF9FF3',
        ],
      },
    },
    {
      field: 'allowMultiple',
      type: 'boolean',
      name: 'Allow Multiple Selection',
      meta: {
        interface: 'boolean',
        width: 'half',
      },
      schema: {
        default_value: false,
      },
    },
  ],
});

Step 2: Interface Component

<!-- src/​interface.vue -->
<template>
  <div class="color-palette-interface">
    <div class="color-grid">
      <button
        v-for="color in palette"
        :key="color"
        class="color-swatch"
        :class="{ selected: isSelected(color) }"
        :style="{ backgroundColor: color }"
        @click="toggleColor(color)"
        :title="color"
      >
        <v-icon
          v-if="isSelected(color)"
          name="check"
          small
        />
      </​button>
    </​div>

    <div v-if="selectedColors.length > 0" class="selected-display">
      <span class="label">Selected:</​span>
      <div class="selected-colors">
        <span
          v-for="color in selectedColors"
          :key="color"
          class="color-tag"
          :style="{ backgroundColor: color }"
        >
          {{ color }}
          <v-icon
            name="close"
            x-small
            @click="removeColor(color)"
          />
        </​span>
      </​div>
    </​div>
  </​div>
</​template>

<script setup lang="ts">
import { ref, computed, watch } from 'vue';

interface Props {
  value: string | string[] | null;
  palette?: string[];
  allowMultiple?: boolean;
  disabled?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  palette: () => [
    '#FF6B6B', '#4ECDC4', '#45B7D1',
    '#96CEB4', '#FECA57', '#FF9FF3',
  ],
  allowMultiple: false,
  disabled: false,
});

const emit = defineEmits<{
  input: [value: string | string[] | null];
}>();

const selectedColors = ref<string[]>([]);

// Initialize selected colors from value
watch(() => props.value, (newValue) => {
  if (Array.isArray(newValue)) {
    selectedColors.value = newValue;
  } else if (newValue) {
    selectedColors.value = [newValue];
  } else {
    selectedColors.value = [];
  }
}, { immediate: true });

function isSelected(color: string): boolean {
  return selectedColors.value.includes(color);
}

function toggleColor(color: string) {
  if (props.disabled) return;

  if (props.allowMultiple) {
    if (isSelected(color)) {
      removeColor(color);
    } else {
      selectedColors.value.push(color);
      emitValue();
    }
  } else {
    selectedColors.value = [color];
    emitValue();
  }
}

function removeColor(color: string) {
  if (props.disabled) return;

  const index = selectedColors.value.indexOf(color);
  if (index > -1) {
    selectedColors.value.splice(index, 1);
    emitValue();
  }
}

function emitValue() {
  if (props.allowMultiple) {
    emit('input', selectedColors.value.length > 0 ? selectedColors.value : null);
  } else {
    emit('input', selectedColors.value[0] || null);
  }
}
</​script>

<style scoped>
.color-palette-interface {
  display: flex;
  flex-direction: column;
  gap: var(--spacing-m);
}

.color-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
  gap: var(--spacing-s);
}

.color-swatch {
  aspect-ratio: 1;
  border-radius: var(--theme--border-radius);
  border: 2px solid transparent;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: all 0.2s ease;
  position: relative;
  overflow: hidden;
}

.color-swatch:hover {
  transform: scale(1.05);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

.color-swatch.selected {
  border-color: var(--theme--primary);
  box-shadow: 0 0 0 3px var(--theme--primary-background);
}

.color-swatch:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

.selected-display {
  display: flex;
  align-items: center;
  gap: var(--spacing-m);
  padding: var(--spacing-s);
  background: var(--theme--background-accent);
  border-radius: var(--theme--border-radius);
}

.label {
  font-size: 0.875rem;
  color: var(--theme--foreground-subdued);
}

.selected-colors {
  display: flex;
  flex-wrap: wrap;
  gap: var(--spacing-xs);
}

.color-tag {
  display: inline-flex;
  align-items: center;
  gap: var(--spacing-xs);
  padding: var(--spacing-xs) var(--spacing-s);
  border-radius: var(--theme--border-radius);
  color: white;
  font-size: 0.75rem;
  font-weight: 500;
}

.color-tag .v-icon {
  cursor: pointer;
  opacity: 0.8;
  transition: opacity 0.2s;
}

.color-tag .v-icon:hover {
  opacity: 1;
}
</​style>

Using Directus Composables

Available Composables

import {
  useApi,        // API client for making requests
  useStores,     // Access to Directus stores
  useSync,       // Sync data between components
  useCollection, // Collection metadata
  useItems,      // Items management
  useLayout,     // Layout configuration
  usePermissions,// User permissions
  useFilterFields,// Field filtering
} from '@directus/​extensions-sdk';

API Usage Examples

// Fetch data from API
const api = useApi();

// GET request
const response = await api.get('/​items/​articles', {
  params: {
    filter: { status: { _eq: 'published' } },
    limit: 10,
    sort: ['-date_created'],
  },
});

// POST request
await api.post('/​items/​comments', {
  article: 1,
  content: 'Great article!',
  author: 'current-user-id',
});

// File upload
const formData = new FormData();
formData.append('file', fileBlob);
await api.post('/​files', formData);

Store Usage Examples

const { useItemsStore, useCollectionsStore, useFieldsStore } = useStores();

// Items store
const itemsStore = useItemsStore();
const items = await itemsStore.getItems('articles', {
  limit: 10,
  fields: ['id', 'title', 'content'],
});

// Collections store
const collectionsStore = useCollectionsStore();
const collections = collectionsStore.collections;

// Fields store
const fieldsStore = useFieldsStore();
const fields = fieldsStore.getFieldsForCollection('articles');

Real-time Features with WebSockets

Setup WebSocket Connection

import { useApi } from '@directus/​extensions-sdk';
import { io, Socket } from 'socket.io-client';

const api = useApi();
let socket: Socket | null = null;

function connectWebSocket() {
  // Get the API URL
  const baseURL = api.defaults.baseURL || window.location.origin;

  socket = io(baseURL, {
    transports: ['websocket'],
    auth: {
      access_token: api.defaults.headers.common['Authorization']?.replace('Bearer ', ''),
    },
  });

  socket.on('connect', () => {
    console.log('WebSocket connected');
    subscribeToCollections();
  });

  socket.on('subscription', handleRealtimeUpdate);
}

function subscribeToCollections() {
  if (!socket) return;

  socket.emit('subscribe', {
    collection: 'articles',
    query: {
      fields: ['*'],
      filter: { status: { _eq: 'published' } },
    },
  });
}

function handleRealtimeUpdate(data: any) {
  // Handle real-time updates
  if (data.action === 'create') {
    // New item created
  } else if (data.action === 'update') {
    // Item updated
  } else if (data.action === 'delete') {
    // Item deleted
  }
}

Theme Integration

Using Theme Variables

/​* Available theme variables */
.my-component {
  /​* Colors */
  color: var(--theme--foreground);
  background: var(--theme--background);
  border-color: var(--theme--border-color);

  /​* Primary colors */
  background: var(--theme--primary);
  background: var(--theme--primary-background);
  background: var(--theme--primary-subdued);

  /​* Semantic colors */
  color: var(--theme--success);
  color: var(--theme--warning);
  color: var(--theme--danger);

  /​* Spacing */
  padding: var(--spacing-s);
  margin: var(--spacing-m);
  gap: var(--spacing-l);

  /​* Border radius */
  border-radius: var(--theme--border-radius);

  /​* Typography */
  font-family: var(--theme--fonts--sans--font-family);
  font-size: var(--theme--fonts--sans--font-size);

  /​* Shadows */
  box-shadow: var(--theme--shadow);
}

/​* Dark mode support */
@media (prefers-color-scheme: dark) {
  .my-component {
    background: var(--theme--background-accent);
  }
}

Using Directus Component Library

Import Components

<template>
  <div class="my-extension">
    <!-- Directus components -->
    <v-button @click="handleClick" :loading="isLoading">
      Click Me
    </​v-button>

    <v-input
      v-model="inputValue"
      placeholder="Enter text..."
      :disabled="isDisabled"
    />

    <v-notice type="info">
      This is an informational notice
    </​v-notice>

    <v-dialog
      v-model="dialogOpen"
      title="Confirm Action"
      @confirm="handleConfirm"
    >
      Are you sure you want to proceed?
    </​v-dialog>

    <v-progress-circular
      v-if="loading"
      indeterminate
    />
  </​div>
</​template>

Available Components

  • Forms: v-input, v-textarea, v-select, v-checkbox, v-radio
  • Buttons: v-button, v-button-group, v-icon-button
  • Feedback: v-notice, v-dialog, v-tooltip, v-progress-circular
  • Layout: v-card, v-divider, v-tabs, v-drawer
  • Data: v-table, v-pagination, v-chip
  • Navigation: v-breadcrumb, v-menu, v-list

Mobile Responsive Design

Responsive Grid System

<template>
  <div class="responsive-layout">
    <div class="grid-container">
      <div class="grid-item" v-for="item in items" :key="item.id">
        <!-- Content -->
      </​div>
    </​div>
  </​div>
</​template>

<style scoped>
.grid-container {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: var(--spacing-m);
}

/​* Tablet */
@media (max-width: 768px) {
  .grid-container {
    grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
    gap: var(--spacing-s);
  }
}

/​* Mobile */
@media (max-width: 480px) {
  .grid-container {
    grid-template-columns: 1fr;
    gap: var(--spacing-xs);
  }
}

/​* Touch-friendly interactions */
@media (hover: none) {
  .clickable-element {
    min-height: 44px; /​* iOS touch target */
    min-width: 44px;
  }
}
</​style>

Testing Extensions

Unit Testing with Vitest

// test/​panel.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/​test-utils';
import PanelComponent from '../​src/​panel.vue';

// Mock Directus SDK
vi.mock('@directus/​extensions-sdk', () => ({
  useApi: () => ({
    get: vi.fn().mockResolvedValue({ data: [] }),
    post: vi.fn(),
  }),
  useStores: () => ({
    useItemsStore: () => ({
      getItems: vi.fn().mockResolvedValue([]),
    }),
  }),
}));

describe('Analytics Panel', () => {
  let wrapper;

  beforeEach(() => {
    wrapper = mount(PanelComponent, {
      props: {
        collection: 'test_collection',
        dateField: 'created_at',
        height: 400,
        width: 600,
      },
    });
  });

  it('renders loading state initially', () => {
    expect(wrapper.find('.loading-state').exists()).toBe(true);
  });

  it('displays metrics after data loads', async () => {
    // Wait for async operations
    await wrapper.vm.$nextTick();
    await new Promise(resolve => setTimeout(resolve, 100));

    expect(wrapper.findAll('.metric-card').length).toBeGreaterThan(0);
  });

  it('handles error state gracefully', async () => {
    wrapper.vm.error = 'Test error';
    await wrapper.vm.$nextTick();

    expect(wrapper.find('.error-state').exists()).toBe(true);
    expect(wrapper.text()).toContain('Test error');
  });
});

Deployment

Build Process

# Development
npm run dev

# Production build
npm run build

# Output structure
dist/
├── index.js        # Compiled extension
├── index.css       # Styles (if any)
└── package.json    # Extension metadata

Installation Methods

  1. Via NPM:
npm install directus-extension-my-panel
  1. Via Extensions Folder:
# Copy to extensions directory
cp -r dist/ /​directus/​extensions/​panels/​my-panel/
  1. Via Admin Panel:
  • Navigate to Settings → Extensions
  • Upload the .tar.gz package

Best Practices

Performance Optimization

  1. Use computed properties for derived state
  2. Implement virtual scrolling for large lists
  3. Debounce API calls for search/​filter inputs
  4. Lazy load heavy components
  5. Cache API responses when appropriate
  6. Use Web Workers for heavy computations

Code Organization

extension/
├── src/
│   ├── components/     # Reusable components
│   ├── composables/    # Shared logic
│   ├── utils/          # Helper functions
│   ├── types/          # TypeScript types
│   ├── index.ts        # Extension entry
│   └── panel.vue       # Main component
├── test/
│   └── *.test.ts       # Test files
├── package.json
└── tsconfig.json

Error Handling

// Comprehensive error handling
try {
  const response = await api.get('/​items/​collection');
  // Process response
} catch (error) {
  // User-friendly error messages
  if (error.response?.status === 403) {
    showNotification({
      type: 'error',
      title: 'Permission Denied',
      description: 'You don\'t have access to this resource',
    });
  } else if (error.response?.status === 404) {
    showNotification({
      type: 'warning',
      title: 'Not Found',
      description: 'The requested resource was not found',
    });
  } else {
    showNotification({
      type: 'error',
      title: 'Error',
      description: error.message || 'An unexpected error occurred',
    });
  }

  // Log for debugging
  console.error('[Extension Error]:', error);
}

Troubleshooting Guide

Common Issues and Solutions

  1. Extension not loading

    • Check id uniqueness in index.ts
    • Verify build output in dist/
    • Check browser console for errors
    • Ensure Directus version compatibility
  2. API calls failing

    • Verify user permissions
    • Check API endpoint paths
    • Validate authentication tokens
    • Review CORS settings
  3. Styling issues

    • Use Directus theme variables
    • Check CSS scoping
    • Test in light/​dark modes
    • Verify responsive breakpoints
  4. Performance problems

    • Profile with Vue DevTools
    • Check API query efficiency
    • Implement pagination
    • Optimize reactive dependencies

Success Metrics

  • ✅ Extension loads without errors
  • ✅ Data fetches and displays correctly
  • ✅ Real-time updates work (if implemented)
  • ✅ Mobile responsive design functions
  • ✅ Theme integration is seamless
  • ✅ Performance is smooth (< 100ms interactions)
  • ✅ Error states are handled gracefully
  • ✅ Accessibility standards are met
  • ✅ TypeScript types are properly defined
  • ✅ Unit tests pass with > 80% coverage

Resources

Version History

  • 1.0.0 - Initial release with comprehensive Vue 3 extension patterns

More skills from majiayu000

xiaohongshu logo
majiayu000/claude-arsenal

xiaohongshu

xiaohongshu

286 148
View
agent-task-conductor logo
majiayu000/claude-skill-registry

agent-task-conductor

Conduct multi-agent task orchestration and workflow coordination.

663 1
View
conductor-setup logo
majiayu000/claude-skill-registry

conductor-setup

Initialize project with Conductor artifacts (product definition,

663 1
View
animation-designer logo
majiayu000/claude-skill-registry

animation-designer

Expert in web animations, transitions, and motion design using Framer Motion and CSS

663 1
View
diagramming logo
majiayu000/claude-skill-registry

diagramming

Creates Mermaid and ASCII diagrams for flowcharts, architecture, ERDs, state machines, mindmaps, and more. Use when user mentions diagram, flowchart, mermaid, ASCII diagram, text diagram, terminal diagram, visualize, C4, mindmap, architecture diagram, sequence diagram, ERD, or needs visual docume...

663 1
View
h3-pg logo
majiayu000/claude-skill-registry-data

h3-pg

PostgreSQL bindings for H3 hexagonal grid system. Use when working with H3 cells in Postgres, including spatial indexing, geometry/geography integration, and raster analysis.

23 1
View
conductor-development logo
majiayu000/claude-skill-registry

conductor-development

Context-Driven Development skill for projects using Conductor. Use this skill when you detect a `conductor/` directory in the project, when working on tasks defined in a `plan.md` file, or when the user asks about tracks, specs, or plans. Automatically applies TDD workflow, tracks task completion...

663 1
View
conductor-status logo
majiayu000/claude-skill-registry

conductor-status

Display project status, active tracks, and next actions

663 1
View
dockerization logo
majiayu000/claude-skill-registry

dockerization

Official Stakpak application containerization standard operating procedure, a step-by-step guidline to properly dockerize applications. This is a rule book curated by the Stakpak Team.

663 1
View

Popular AI tools

Kaiber logo
Video

Kaiber

Generate, edit, and beat-sync AI video with leading models in one workspace.

Paid
View
Vimcal logo
Productivity

Vimcal

The world's fastest calendar for remote work

Free
View

Transform Your Design with AI Designer by ImgCreator.ai

Freemium
View
Akool AI logo
Content & writing

Akool AI

Revolutionizing Video Production with AI-Powered Creativity

Paid
View

Extend an image past the frame and let AI fill the new aspect ratio.

Freemium
View
StarByFace logo
Security

StarByFace

Discover your celebrity doppelgänger with StarByFace!

Free
View
C

ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.

Freemium
View
Opus Clip logo
Coding & apps

Opus Clip

Opus.ai: Revolutionize Your Web Experience

Free
View