DeployStack Docs

Global Settings Frontend Integration

The DeployStack frontend provides a flexible component system for global settings that allows developers to create custom interfaces for specific setting groups. This system enables rich functionality like connection testing, custom validation, and specialized UI components while maintaining consistency with the overall design system.

Architecture Overview

The global settings system uses a component registry pattern that allows custom Vue components to be registered for specific setting groups. When a user navigates to a settings group, the system checks if a custom component is registered and uses it instead of the default form renderer.

Global Settings Flow
├── User navigates to /admin/settings/{groupId}
├── System checks component registry
├── Custom component found?
│   ├── Yes → Render custom component
│   └── No → Render standard form
└── Component handles form state, validation, and API calls

Key Components

1. Component Registry (useSettingsComponentRegistry)

The registry manages the mapping between setting group IDs and their custom components.

// Register a component for a specific group
registerSettingsComponent('github-app', {
  component: GitHubAppSettings,
  description: 'GitHub App configuration with connection testing',
  author: 'DeployStack Team',
  version: '1.0.0'
})

// Check if a component is registered
const hasCustom = hasCustomComponent('github-app')

// Get the registered component
const componentDef = getSettingsComponent('github-app')

2. Settings Form Composable (useSettingsForm)

Provides common form functionality for settings components.

const {
  formValues,        // Reactive form values
  isSaving,         // Save state
  hasChanges,       // Dirty state tracking
  saveForm,         // Save function
  updateField,      // Update individual fields
  getFieldError     // Get validation errors
} = useSettingsForm(settings)

3. Connection Test Composable (useConnectionTest)

Handles connection testing functionality for external services.

const {
  isTestingConnection,    // Test state
  lastTestResult,        // Last test result
  testConnection,        // Generic test function
  testGitHubAppConnection, // Specific test functions
  getStatusMessage       // Helper for UI
} = useConnectionTest()

Creating Custom Setting Components

Step 1: Create the Component

Create a new Vue component in src/components/settings/:

<!-- src/components/settings/MyServiceSettings.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSettingsForm } from '@/composables/useSettingsForm'
import { useConnectionTest } from '@/composables/useConnectionTest'
import type { SettingsComponentProps, SettingsComponentEvents } from '@/composables/useSettingsComponentRegistry'
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { TestTube, CheckCircle, XCircle } from 'lucide-vue-next'

const props = defineProps<SettingsComponentProps>()
const emit = defineEmits<SettingsComponentEvents>()

const { t } = useI18n()

// Use form composable
const {
  formValues,
  isSaving,
  hasChanges,
  saveForm,
  updateField,
  getFieldError
} = useSettingsForm(props.settings)

// Use connection test composable
const {
  isTestingConnection,
  lastTestResult,
  testConnection,
  getStatusMessage,
  getAlertVariant
} = useConnectionTest()

// Custom validation logic
const canTestConnection = computed(() => {
  return !!(
    formValues.value['myservice.api_key'] &&
    formValues.value['myservice.endpoint']
  )
})

// Handle form submission
async function handleSave() {
  const success = await saveForm()
  if (success) {
    emit('settings-updated', props.settings)
  }
}

// Handle connection test
async function handleTestConnection() {
  if (!canTestConnection.value) return

  const credentials = {
    api_key: String(formValues.value['myservice.api_key']),
    endpoint: String(formValues.value['myservice.endpoint'])
  }

  const result = await testConnection('myservice', credentials)
  emit('connection-tested', result)
}

// Get setting by key helper
function getSetting(key: string) {
  return props.settings.find(s => s.key === key)
}
</script>

<template>
  <div class="space-y-6">
    <!-- Header Card -->
    <Card>
      <CardHeader>
        <CardTitle class="text-xl">{{ props.group.name }}</CardTitle>
        <CardDescription>{{ props.group.description }}</CardDescription>
      </CardHeader>
    </Card>

    <!-- Configuration Form -->
    <Card>
      <CardHeader>
        <CardTitle class="text-lg">Configuration</CardTitle>
        <CardDescription>
          Configure your service connection settings below.
        </CardDescription>
      </CardHeader>
      <CardContent>
        <form @submit.prevent="handleSave" class="space-y-6">
          <!-- API Key Field -->
          <div class="space-y-2">
            <Label for="api-key">
              {{ getSetting('myservice.api_key')?.description || 'API Key' }}
            </Label>
            <Input
              id="api-key"
              type="password"
              :model-value="String(formValues['myservice.api_key'] || '')"
              @update:model-value="(value) => updateField('myservice.api_key', value)"
              placeholder="Enter your API key"
              :class="{ 'border-destructive': getFieldError('myservice.api_key') }"
            />
            <p v-if="getFieldError('myservice.api_key')" class="text-sm text-destructive">
              {{ getFieldError('myservice.api_key') }}
            </p>
          </div>

          <!-- Endpoint Field -->
          <div class="space-y-2">
            <Label for="endpoint">
              {{ getSetting('myservice.endpoint')?.description || 'Service Endpoint' }}
            </Label>
            <Input
              id="endpoint"
              :model-value="String(formValues['myservice.endpoint'] || '')"
              @update:model-value="(value) => updateField('myservice.endpoint', value)"
              placeholder="https://api.myservice.com"
              :class="{ 'border-destructive': getFieldError('myservice.endpoint') }"
            />
            <p v-if="getFieldError('myservice.endpoint')" class="text-sm text-destructive">
              {{ getFieldError('myservice.endpoint') }}
            </p>
          </div>

          <!-- Connection Test Section -->
          <div class="border-t pt-4 space-y-4">
            <div class="flex items-center justify-between">
              <div>
                <h4 class="font-medium flex items-center space-x-2">
                  <TestTube class="h-4 w-4" />
                  <span>Test Connection</span>
                </h4>
                <p class="text-sm text-muted-foreground">
                  Verify your service configuration.
                </p>
              </div>
              <Button 
                type="button"
                @click="handleTestConnection" 
                :disabled="!canTestConnection || isTestingConnection"
                variant="outline"
                size="sm"
              >
                <TestTube class="h-4 w-4 mr-2" />
                {{ isTestingConnection ? 'Testing...' : 'Test Connection' }}
              </Button>
            </div>

            <!-- Connection Status -->
            <Alert v-if="lastTestResult" :variant="getAlertVariant(lastTestResult)">
              <component 
                :is="lastTestResult.success ? CheckCircle : XCircle" 
                class="h-4 w-4" 
              />
              <AlertTitle>
                {{ lastTestResult.success ? 'Connection Successful' : 'Connection Failed' }}
              </AlertTitle>
              <AlertDescription>
                {{ getStatusMessage(lastTestResult) }}
              </AlertDescription>
            </Alert>
          </div>

          <!-- Save Button -->
          <Button 
            type="submit" 
            :disabled="!hasChanges || isSaving"
            class="w-full"
          >
            {{ isSaving ? 'Saving...' : 'Save Changes' }}
          </Button>
        </form>
      </CardContent>
    </Card>
  </div>
</template>

Step 2: Register the Component

Add your component to the registration file:

// src/components/settings/index.ts
import { registerSettingsComponent } from '@/composables/useSettingsComponentRegistry'
import GitHubAppSettings from './GitHubAppSettings.vue'
import MyServiceSettings from './MyServiceSettings.vue' // Add your component

export function registerSettingsComponents() {
  // Existing registrations
  registerSettingsComponent('github-app', {
    component: GitHubAppSettings,
    description: 'GitHub App configuration with connection testing',
    author: 'DeployStack Team',
    version: '1.0.0'
  })

  // Register your new component
  registerSettingsComponent('myservice', {
    component: MyServiceSettings,
    description: 'My Service configuration with connection testing',
    author: 'Your Name',
    version: '1.0.0'
  })
}

Step 3: Component Props and Events

Your component must implement the required props and events:

// Required Props
interface SettingsComponentProps {
  group: GlobalSettingGroup    // The settings group metadata
  settings: Setting[]          // Array of settings for this group
}

// Required Events
interface SettingsComponentEvents {
  'settings-updated': [settings: Setting[]]  // Emitted when settings are saved
  'validation-error': [errors: Record<string, string>]  // Emitted on validation errors
  'connection-tested': [result: { success: boolean; message: string }]  // Emitted after connection tests
}

Advanced Patterns

Custom Validation

Add custom validation logic to your components:

// In your component
const {
  formValues,
  saveForm,
  // ... other form methods
} = useSettingsForm(props.settings, {
  onValidate: (values) => {
    const errors: ValidationError[] = []
    
    // Custom validation logic
    if (!values['myservice.api_key']) {
      errors.push({
        field: 'myservice.api_key',
        message: 'API key is required'
      })
    }
    
    if (values['myservice.endpoint'] && !isValidUrl(values['myservice.endpoint'])) {
      errors.push({
        field: 'myservice.endpoint',
        message: 'Please enter a valid URL'
      })
    }
    
    return errors
  }
})

function isValidUrl(url: string): boolean {
  try {
    new URL(url)
    return true
  } catch {
    return false
  }
}

Custom Connection Testing

Implement service-specific connection testing:

// In your component
async function testMyServiceConnection() {
  const credentials = {
    api_key: String(formValues.value['myservice.api_key']),
    endpoint: String(formValues.value['myservice.endpoint']),
    timeout: 10000
  }

  try {
    const result = await testConnection('myservice', credentials, {
      endpoint: '/api/settings/test-connection/myservice',
      timeout: 15000,
      retries: 2
    })
    
    // Handle successful test
    if (result.success) {
      // Show additional success information
      console.log('Service details:', result.details)
    }
    
    return result
  } catch (error) {
    console.error('Connection test failed:', error)
    throw error
  }
}

Multi-Step Configuration

Create complex multi-step configuration flows:

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

const currentStep = ref(1)
const totalSteps = 3

const isStepValid = computed(() => {
  switch (currentStep.value) {
    case 1:
      return !!(formValues.value['service.api_key'])
    case 2:
      return !!(formValues.value['service.endpoint'])
    case 3:
      return lastTestResult.value?.success
    default:
      return false
  }
})

function nextStep() {
  if (isStepValid.value && currentStep.value < totalSteps) {
    currentStep.value++
  }
}

function previousStep() {
  if (currentStep.value > 1) {
    currentStep.value--
  }
}
</script>

<template>
  <div class="space-y-6">
    <!-- Step Indicator -->
    <div class="flex items-center justify-between">
      <div class="flex space-x-2">
        <div 
          v-for="step in totalSteps" 
          :key="step"
          :class="[
            'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium',
            step <= currentStep ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
          ]"
        >
          {{ step }}
        </div>
      </div>
      <span class="text-sm text-muted-foreground">
        Step {{ currentStep }} of {{ totalSteps }}
      </span>
    </div>

    <!-- Step Content -->
    <Card>
      <CardContent class="pt-6">
        <!-- Step 1: API Configuration -->
        <div v-if="currentStep === 1" class="space-y-4">
          <h3 class="text-lg font-medium">API Configuration</h3>
          <!-- API fields -->
        </div>

        <!-- Step 2: Endpoint Configuration -->
        <div v-if="currentStep === 2" class="space-y-4">
          <h3 class="text-lg font-medium">Endpoint Configuration</h3>
          <!-- Endpoint fields -->
        </div>

        <!-- Step 3: Test & Verify -->
        <div v-if="currentStep === 3" class="space-y-4">
          <h3 class="text-lg font-medium">Test & Verify</h3>
          <!-- Connection test -->
        </div>

        <!-- Navigation -->
        <div class="flex justify-between mt-6">
          <Button 
            @click="previousStep" 
            :disabled="currentStep === 1"
            variant="outline"
          >
            Previous
          </Button>
          <Button 
            v-if="currentStep < totalSteps"
            @click="nextStep" 
            :disabled="!isStepValid"
          >
            Next
          </Button>
          <Button 
            v-else
            @click="handleSave" 
            :disabled="!isStepValid || isSaving"
          >
            {{ isSaving ? 'Saving...' : 'Complete Setup' }}
          </Button>
        </div>
      </CardContent>
    </Card>
  </div>
</template>

Integration with Existing Systems

Design System

Follow the established UI Design System patterns. Use shadcn-vue components and maintain consistency with the overall design.

Internationalization

Add i18n support following the Internationalization Guide. Create dedicated translation files for your settings components.

Event Bus

Use the Global Event Bus for cross-component communication when settings are updated.

Testing Custom Components

Unit Testing

// tests/components/settings/MyServiceSettings.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import MyServiceSettings from '@/components/settings/MyServiceSettings.vue'

describe('MyServiceSettings', () => {
  const mockSettings = [
    {
      key: 'myservice.api_key',
      value: '',
      type: 'string',
      description: 'API Key',
      is_encrypted: true
    }
  ]

  const mockGroup = {
    id: 'myservice',
    name: 'My Service',
    description: 'Service configuration'
  }

  it('renders form fields correctly', () => {
    const wrapper = mount(MyServiceSettings, {
      props: {
        group: mockGroup,
        settings: mockSettings
      }
    })

    expect(wrapper.find('input[type="password"]').exists()).toBe(true)
    expect(wrapper.text()).toContain('API Key')
  })

  it('emits settings-updated on save', async () => {
    const wrapper = mount(MyServiceSettings, {
      props: {
        group: mockGroup,
        settings: mockSettings
      }
    })

    // Simulate form submission
    await wrapper.find('form').trigger('submit')

    expect(wrapper.emitted('settings-updated')).toBeTruthy()
  })

  it('disables test button when fields are empty', () => {
    const wrapper = mount(MyServiceSettings, {
      props: {
        group: mockGroup,
        settings: mockSettings
      }
    })

    const testButton = wrapper.find('[data-testid="test-connection"]')
    expect(testButton.attributes('disabled')).toBeDefined()
  })
})

Integration Testing

// tests/integration/settings.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import GlobalSettings from '@/views/admin/GlobalSettings.vue'

describe('Global Settings Integration', () => {
  it('loads custom component for registered group', async () => {
    // Register test component
    registerSettingsComponent('test-service', {
      component: TestServiceSettings
    })

    const router = createRouter({
      history: createWebHistory(),
      routes: [
        { path: '/admin/settings/:groupId', component: GlobalSettings }
      ]
    })

    await router.push('/admin/settings/test-service')

    const wrapper = mount(GlobalSettings, {
      global: {
        plugins: [router]
      }
    })

    // Should render custom component
    expect(wrapper.findComponent(TestServiceSettings).exists()).toBe(true)
  })
})