turnstone/web/src/components/WizardOverlay.vue
pyr0ball 251109ae96 fix: final review fixes — port guard, network error handling, wizard back nav, tablist arrow keys, dialog focus trap
- wizard.py: wrap syslog_port int() in try/except to default 514 on non-numeric input
- ContextView: add try/catch to doDelete, doDeleteFact, addFact for network errors
- ContextView: arrow-key navigation for tablist (ArrowLeft/ArrowRight)
- DiagnoseView: arrow-key navigation for tablist (ArrowLeft/ArrowRight)
- WizardOverlay: reset current_step to last schema step when clicking 'Go back and edit'
- WizardOverlay: focus trap on Tab/Shift+Tab within dialog element
2026-05-13 17:40:40 -07:00

235 lines
8.8 KiB
Vue

<template>
<div
role="dialog"
aria-modal="true"
:aria-labelledby="`wiz-heading-${currentStep?.step ?? 1}`"
class="fixed inset-0 z-50 bg-surface overflow-y-auto"
:ref="(el) => { dialogRef = el as HTMLElement | null }"
>
<div class="max-w-lg mx-auto py-12 px-6">
<!-- Progress heading -->
<h2
:id="`wiz-heading-${currentStep?.step ?? 1}`"
ref="headingRef"
tabindex="-1"
class="text-lg font-semibold text-text-primary mb-1 focus:outline-none"
>
Step {{ currentStep?.step ?? 1 }} of {{ totalSteps }}: {{ currentStep?.title ?? '…' }}
</h2>
<p v-if="currentStep?.help" class="text-sm text-text-dim mb-6">{{ currentStep.help }}</p>
<!-- Step input -->
<div class="mb-8">
<!-- Select -->
<div v-if="currentStep?.type === 'select'" class="space-y-2">
<label :for="`wiz-input-${currentStep.id}`" class="sr-only">{{ currentStep.title }}</label>
<select
:id="`wiz-input-${currentStep.id}`"
v-model="answer"
class="w-full bg-surface border border-surface-border rounded px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:border-accent"
>
<option value="" disabled>Choose one…</option>
<option v-for="opt in currentStep.options" :key="opt" :value="opt">{{ opt }}</option>
</select>
</div>
<!-- Text -->
<div v-else-if="currentStep?.type === 'text'">
<label :for="`wiz-input-${currentStep.id}`" class="sr-only">{{ currentStep.title }}</label>
<input
:id="`wiz-input-${currentStep.id}`"
v-model="answer"
type="text"
:placeholder="currentStep.placeholder || ''"
class="w-full bg-surface border border-surface-border rounded px-4 py-2.5 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent"
/>
</div>
</div>
<!-- Button row: Back | Next/Skip -->
<div class="flex items-center gap-3">
<button
v-if="session.current_step > 1"
@click="goBack"
class="px-4 py-2 border border-surface-border rounded text-sm text-text-dim hover:text-text-primary transition-colors"
>Back</button>
<button
@click="goNext()"
:disabled="!canAdvance"
class="px-4 py-2 bg-accent text-surface text-sm rounded font-medium hover:opacity-90 disabled:opacity-40 transition-opacity"
>
{{ isLastStep ? 'Review' : 'Next' }}
</button>
<button
v-if="currentStep?.optional"
@click="goNext(true)"
class="px-4 py-2 text-sm text-text-dim hover:text-text-primary transition-colors"
>Skip this step (add later)</button>
</div>
<!-- Escape hatch -->
<p class="mt-8 pt-4 border-t border-surface-border">
<RouterLink to="/settings" @click="$emit('close')" class="text-xs text-text-dim hover:text-text-primary underline underline-offset-2">
Skip wizard edit sources directly in config
</RouterLink>
</p>
</div>
<!-- Summary / Apply screen -->
<div v-if="showSummary" class="max-w-lg mx-auto pb-12 px-6">
<h2 ref="summaryRef" tabindex="-1" class="text-lg font-semibold text-text-primary mb-4 focus:outline-none">
Review and apply
</h2>
<div class="rounded border border-surface-border bg-surface-raised p-4 mb-4 space-y-1">
<p class="text-xs text-text-dim font-medium uppercase tracking-wide mb-2">Facts that will be saved</p>
<div v-for="(val, key) in session.answers" :key="String(key)" class="flex gap-2 text-sm">
<span class="font-mono text-text-dim w-28 shrink-0">{{ key }}</span>
<span class="text-text-primary">{{ val || '(skipped)' }}</span>
</div>
</div>
<div class="flex gap-3">
<button
@click="applyWizard"
:disabled="applying"
class="px-4 py-2 bg-accent text-surface text-sm rounded font-medium hover:opacity-90 disabled:opacity-40"
>
{{ applying ? 'Saving…' : `Save ${factCount} facts and apply source config` }}
</button>
<button @click="showSummary = false; session = { ...session, current_step: schema.length }" class="text-sm text-text-dim hover:text-text-primary">
Go back and edit
</button>
</div>
<div v-if="applyError" role="alert" class="mt-3 text-sm text-sev-error">{{ applyError }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { RouterLink } from 'vue-router'
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
const emit = defineEmits<{ (e: 'close'): void; (e: 'complete'): void }>()
interface WizardStep {
step: number
id: string
title: string
type: string
options?: string[]
placeholder?: string
optional?: boolean
help?: string
condition?: { step_id: string; value: string }
}
const schema = ref<WizardStep[]>([])
const session = ref<{ current_step: number; answers: Record<string, string> }>({
current_step: 1,
answers: {},
})
const answer = ref('')
const showSummary = ref(false)
const applying = ref(false)
const applyError = ref<string | null>(null)
const headingRef = ref<HTMLElement | null>(null)
const summaryRef = ref<HTMLElement | null>(null)
const dialogRef = ref<HTMLElement | null>(null)
const totalSteps = computed(() => schema.value.length)
const currentStep = computed(() =>
schema.value.find(s => s.step === session.value.current_step) ?? schema.value[0]
)
const isLastStep = computed(() => session.value.current_step === totalSteps.value)
const canAdvance = computed(() => currentStep.value?.optional || !!answer.value.trim())
const factCount = computed(() => Object.values(session.value.answers).filter(v => v).length)
async function loadSchema() {
try {
const r = await fetch(`${BASE}/api/context/wizard/schema`)
if (r.ok) schema.value = await r.json()
} catch { /* non-critical — wizard shows loading state */ }
const saved = sessionStorage.getItem('turnstone-wizard')
if (saved) {
try { session.value = JSON.parse(saved) } catch {}
}
// Pre-populate answer for the current step (handles sessionStorage restore)
answer.value = session.value.answers[currentStep.value?.id ?? ''] ?? ''
}
async function goNext(skip = false) {
applyError.value = null
const stepId = currentStep.value?.id
if (!stepId) return
try {
const r = await fetch(`${BASE}/api/context/wizard/step`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: session.value, step_id: stepId, answer: skip ? '' : answer.value }),
})
if (!r.ok) { applyError.value = `Step failed (${r.status})`; return }
const data = await r.json()
session.value = data.session
sessionStorage.setItem('turnstone-wizard', JSON.stringify(session.value))
answer.value = session.value.answers[currentStep.value?.id ?? ''] ?? ''
if (data.complete) {
showSummary.value = true
nextTick(() => summaryRef.value?.focus())
} else {
nextTick(() => headingRef.value?.focus())
}
} catch {
applyError.value = 'Could not reach server'
}
}
function goBack() {
session.value = { ...session.value, current_step: session.value.current_step - 1 }
answer.value = session.value.answers[currentStep.value?.id ?? ''] ?? ''
nextTick(() => headingRef.value?.focus())
}
async function applyWizard() {
applying.value = true
applyError.value = null
try {
const r = await fetch(`${BASE}/api/context/wizard/apply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: session.value }),
})
if (!r.ok) throw new Error(await r.text())
sessionStorage.removeItem('turnstone-wizard')
emit('complete')
} catch (e) {
applyError.value = e instanceof Error ? e.message : 'Apply failed'
} finally {
applying.value = false
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Tab') {
const focusable = dialogRef.value?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (!focusable || focusable.length === 0) return
const first = focusable[0]!
const last = focusable[focusable.length - 1]!
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus() }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus() }
}
}
if (e.key === 'Escape') emit('close')
}
onMounted(() => {
loadSchema()
document.addEventListener('keydown', onKeydown)
nextTick(() => headingRef.value?.focus())
})
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
</script>