mcpField
D
Directus UI Extensions Mastery

Directus UI Extensions Mastery

github.com/gumpen-app開発0 回インストール
リポジトリ

Directus UI Extensions Mastery について

Directus 向け Vue 3 UI 拡張の構築方法を案内するスキルです。パネル、インターフェース、表示、レイアウト、モジュールを対象に、Extensions SDK、実装パターン、テスト、デプロイ、性能面の実践方法を扱います。

主な機能

  1. 015種類の拡張タイプを解説
  2. 02Vue 3 Composition API のパターン
  3. 03Directus SDK との連携
  4. 04Vitest のテスト例
  5. 05ビルドとデプロイの案内

活用シーン

  1. 01Directus の独自ダッシュボードパネル作成
  2. 02専用のデータ入力インターフェース構築
  3. 03コレクション表示やレイアウトの開発
  4. 04Directus 拡張のテストとデプロイ

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/gumpen-app/directapp --skill directus-ui-extensions-mastery

インストール後、Claude Code で/directus-ui-extensions-masteryを入力して読み込まれたか確認します。

同梱ファイルexamples/analytics-panel.vue

詳細

Directus UI 拡張機能のマスター

概要

このスキルでは、Directus で本番環境に対応した Vue 3 UI 拡張機能を構築するための専門的なガイダンスを提供します。@directus/extensions-sdk と最新の Vue 3 Composition API のパターンを使用して、カスタムパネル、インターフェース、ディスプレイ、レイアウトを作成する方法を習得できます。リアルタイムのデータ可視化、レスポンシブデザイン、Directus のコンポーネントライブラリとのシームレスな統合を実現します。

このスキルを使用する場面

  • データ可視化用のカスタムダッシュボードパネルを構築する
  • 複雑なデータ型に対応した専用の入力インターフェースを作成する
  • カスタムコレクションディスプレイとレイアウトを開発する
  • WebSocket 統合によるリアルタイムコンポーネントを実装する
  • グラスモーフィズムや最新の UI デザインパターンを追加する
  • モバイル環境との互換性とレスポンシブデザインを確保する
  • Directus のテーマシステムと統合する
  • チームで再利用できる UI コンポーネントを作成する

主な概念

拡張機能の種類
  1. パネル - Insights モジュール用のダッシュボードウィジェット
  2. インターフェース - データ入力用のカスタム入力コンポーネント
  3. ディスプレイ - フィールド値のカスタム表示
  4. レイアウト - コレクションの代替ビュー
  5. モジュール - Directus 内の完全なカスタムセクション
技術スタック
  • Composition API を備えた Vue 3
  • 型安全性を実現する TypeScript
  • Directus との統合に使用する @directus/extensions-sdk
  • ビルドと開発に使用する Vite
  • 状態管理に使用する Pinia(useStores 経由)
  • ナビゲーションに使用する Vue Router(モジュール内)

手順:カスタムパネルの構築

ステップ 1:拡張機能を初期化する
# Create new panel extension
npx create-directus-extension@latest

# Select options:
# > panel
# > my-custom-panel
# > typescript
ステップ 2:パネルのメタデータを設定する
// 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,
      },
    },
  ],
});
ステップ 3: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>

手順:カスタムインターフェースの構築

ステップ 1:インターフェースの構造
// 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,
      },
    },
  ],
});
ステップ 2:インターフェースコンポーネント

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

Directus のコンポーザブルの使用

利用可能なコンポーザブル
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 の使用例
// 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);
ストアの使用例
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');

WebSocket によるリアルタイム機能

WebSocket 接続を設定する
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
  }
}

テーマとの統合

テーマ変数の使用
/* 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);
  }
}

Directus コンポーネントライブラリの使用

コンポーネントをインポートする
<template>
  <div class="my-extension">
    
    <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>
利用可能なコンポーネント
  • フォーム:v-input、v-textarea、v-select、v-checkbox、v-radio
  • ボタン:v-button、v-button-group、v-icon-button
  • フィードバック:v-notice、v-dialog、v-tooltip、v-progress-circular
  • レイアウト:v-card、v-divider、v-tabs、v-drawer
  • データ:v-table、v-pagination、v-chip
  • ナビゲーション:v-breadcrumb、v-menu、v-list

モバイル対応のレスポンシブデザイン

レスポンシブグリッドシステム
<template>
  <div class="responsive-layout">
    <div class="grid-container">
      <div class="grid-item" v-for="item in items" :key="item.id">
        
      </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>

拡張機能のテスト

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');
  });
});

デプロイ

ビルドプロセス
# 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
インストール方法
  1. NPM 経由:
npm install directus-extension-my-panel
  1. Extensions フォルダー経由:
# Copy to extensions directory
cp -r dist/ /directus/extensions/panels/my-panel/
  1. 管理パネル経由:
  • Settings → Extensions に移動します
  • .tar.gz パッケージをアップロードします

ベストプラクティス

パフォーマンスの最適化
  1. 派生状態には computed プロパティを使用します
  2. 大きなリストには 仮想スクロールを実装します
  3. 検索/フィルター入力の API 呼び出しを デバウンスします
  4. 重いコンポーネントを 遅延読み込みします
  5. 適切な場合は API レスポンスを キャッシュします
  6. 負荷の高い計算には Web Workers を使用します
コードの整理
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
エラー処理
// 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);
}

トラブルシューティングガイド

よくある問題と解決策
  1. 拡張機能が読み込まれない

    • index.ts の id が一意であることを確認します
    • dist/ 内のビルド出力を確認します
    • ブラウザーのコンソールでエラーを確認します
    • Directus のバージョンとの互換性を確認します
  2. API 呼び出しが失敗する

    • ユーザー権限を確認します
    • API エンドポイントのパスを確認します
    • 認証トークンを検証します
    • CORS の設定を確認します
  3. スタイルの問題

    • Directus のテーマ変数を使用します
    • CSS のスコープを確認します
    • ライトモード/ダークモードでテストします
    • レスポンシブブレークポイントを確認します
  4. パフォーマンスの問題

    • Vue DevTools でプロファイリングします
    • API クエリの効率を確認します
    • ページネーションを実装します
    • リアクティブな依存関係を最適化します

成功指標

  • ✅ 拡張機能がエラーなく読み込まれる
  • ✅ データが正しく取得され、表示される
  • ✅ リアルタイム更新が正常に動作する(実装されている場合)
  • ✅ モバイル対応デザインが機能する
  • ✅ テーマとの統合がシームレスである
  • ✅ パフォーマンスがスムーズである(操作が 100ms 未満)
  • ✅ エラー状態が適切に処理される
  • ✅ アクセシビリティ基準を満たしている
  • ✅ TypeScript の型が適切に定義されている
  • ✅ ユニットテストが 80% を超えるカバレッジで合格する

リソース

バージョン履歴

  • 1.0.0 - 包括的な Vue 3 拡張機能パターンを備えた初回リリース

コメント