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
This commit is contained in:
parent
a047555031
commit
251109ae96
4 changed files with 78 additions and 24 deletions
|
|
@ -115,7 +115,10 @@ def apply_session(db_path: Path, session: dict[str, Any]) -> dict[str, Any]:
|
|||
|
||||
syslog_answer = str(answers.get("syslog") or "No")
|
||||
if syslog_answer.startswith("Yes"):
|
||||
port = int(answers.get("syslog_port") or 514)
|
||||
try:
|
||||
port = int(answers.get("syslog_port") or 514)
|
||||
except (ValueError, TypeError):
|
||||
port = 514
|
||||
sources.append({"type": "syslog", "id": f"syslog:{hostname}", "port": port})
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
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 -->
|
||||
|
|
@ -94,7 +95,7 @@
|
|||
>
|
||||
{{ applying ? 'Saving…' : `Save ${factCount} facts and apply source config` }}
|
||||
</button>
|
||||
<button @click="showSummary = false" class="text-sm text-text-dim hover:text-text-primary">
|
||||
<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>
|
||||
|
|
@ -133,6 +134,7 @@ 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(() =>
|
||||
|
|
@ -207,6 +209,19 @@ async function applyWizard() {
|
|||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
<!-- Tabs -->
|
||||
<div role="tablist" aria-label="Context sections" class="flex gap-1 mb-4 border-b border-surface-border">
|
||||
<button
|
||||
v-for="tab in ['Documents', 'Facts']"
|
||||
v-for="(tab, idx) in ctxTabs"
|
||||
:key="tab"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === tab"
|
||||
|
|
@ -29,6 +29,8 @@
|
|||
:aria-controls="`ctx-panel-${tab}`"
|
||||
:tabindex="activeTab === tab ? 0 : -1"
|
||||
@click="activeTab = tab"
|
||||
@keydown="handleTabKey($event, tab)"
|
||||
:ref="(el) => { if (el) tabRefs[idx] = el as HTMLButtonElement }"
|
||||
:class="[
|
||||
'px-4 py-2 text-sm transition-colors',
|
||||
activeTab === tab
|
||||
|
|
@ -189,7 +191,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import DocUploadZone from '@/components/DocUploadZone.vue'
|
||||
import WizardOverlay from '@/components/WizardOverlay.vue'
|
||||
|
||||
|
|
@ -199,6 +201,19 @@ interface Doc { id: string; filename: string; doc_type: string; file_size: numbe
|
|||
interface Fact { id: string; category: string; key: string; value: string; source: string | null; created_at: string }
|
||||
|
||||
const activeTab = ref('Documents')
|
||||
const tabRefs = ref<HTMLButtonElement[]>([])
|
||||
const ctxTabs = ['Documents', 'Facts']
|
||||
|
||||
function handleTabKey(e: KeyboardEvent, currentTab: string) {
|
||||
const idx = ctxTabs.indexOf(currentTab)
|
||||
let next = idx
|
||||
if (e.key === 'ArrowRight') next = (idx + 1) % ctxTabs.length
|
||||
else if (e.key === 'ArrowLeft') next = (idx - 1 + ctxTabs.length) % ctxTabs.length
|
||||
else return
|
||||
e.preventDefault()
|
||||
activeTab.value = ctxTabs[next]!
|
||||
nextTick(() => tabRefs.value[next]?.focus())
|
||||
}
|
||||
const showWizard = ref(false)
|
||||
const docs = ref<Doc[]>([])
|
||||
const facts = ref<Fact[]>([])
|
||||
|
|
@ -224,30 +239,36 @@ async function loadFacts() {
|
|||
}
|
||||
|
||||
async function doDelete(id: string) {
|
||||
const r = await fetch(`${BASE}/api/context/docs/${id}`, { method: 'DELETE' })
|
||||
if (r.ok) { confirmDelete.value = null; await loadDocs() }
|
||||
else error.value = 'Failed to delete document'
|
||||
try {
|
||||
const r = await fetch(`${BASE}/api/context/docs/${id}`, { method: 'DELETE' })
|
||||
if (r.ok) { confirmDelete.value = null; await loadDocs() }
|
||||
else error.value = 'Failed to delete document'
|
||||
} catch { error.value = 'Could not reach server' }
|
||||
}
|
||||
|
||||
async function doDeleteFact(id: string) {
|
||||
const r = await fetch(`${BASE}/api/context/facts/${id}`, { method: 'DELETE' })
|
||||
if (r.ok) { confirmDeleteFact.value = null; await loadFacts() }
|
||||
else error.value = 'Failed to delete fact'
|
||||
try {
|
||||
const r = await fetch(`${BASE}/api/context/facts/${id}`, { method: 'DELETE' })
|
||||
if (r.ok) { confirmDeleteFact.value = null; await loadFacts() }
|
||||
else error.value = 'Failed to delete fact'
|
||||
} catch { error.value = 'Could not reach server' }
|
||||
}
|
||||
|
||||
async function addFact() {
|
||||
if (!newFact.value.key.trim() || !newFact.value.value.trim()) return
|
||||
const r = await fetch(`${BASE}/api/context/facts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...newFact.value, source: 'manual' }),
|
||||
})
|
||||
if (r.ok) {
|
||||
newFact.value = { category: 'service', key: '', value: '' }
|
||||
await loadFacts()
|
||||
} else {
|
||||
error.value = 'Failed to add fact'
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`${BASE}/api/context/facts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...newFact.value, source: 'manual' }),
|
||||
})
|
||||
if (r.ok) {
|
||||
newFact.value = { category: 'service', key: '', value: '' }
|
||||
await loadFacts()
|
||||
} else {
|
||||
error.value = 'Failed to add fact'
|
||||
}
|
||||
} catch { error.value = 'Could not reach server' }
|
||||
}
|
||||
|
||||
function onWizardComplete() {
|
||||
|
|
|
|||
|
|
@ -11,14 +11,16 @@
|
|||
<!-- Tab toggle -->
|
||||
<div role="tablist" aria-label="Diagnose mode" class="flex gap-1 mb-6 border-b border-surface-border">
|
||||
<button
|
||||
v-for="t in tabs"
|
||||
v-for="(t, idx) in tabs"
|
||||
:key="t.key"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === t.key"
|
||||
:id="`tab-${t.key}`"
|
||||
:aria-controls="`tabpanel-${t.key}`"
|
||||
:tabindex="activeTab === t.key ? 0 : -1"
|
||||
@click="activeTab = t.key"
|
||||
@click="activeTab = t.key as 'quick' | 'structured'"
|
||||
@keydown="handleTabKey($event, t.key)"
|
||||
:ref="(el) => { if (el) tabRefs[idx] = el as HTMLButtonElement }"
|
||||
:class="[
|
||||
'px-4 py-2 text-sm transition-colors border-b-2 -mb-px',
|
||||
activeTab === t.key
|
||||
|
|
@ -60,7 +62,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||
import { useRoute, RouterLink } from 'vue-router'
|
||||
import QuickCapture from '@/components/QuickCapture.vue'
|
||||
import IncidentForm from '@/components/IncidentForm.vue'
|
||||
|
|
@ -72,6 +74,19 @@ const tabs: { key: 'quick' | 'structured'; label: string }[] = [
|
|||
]
|
||||
const activeTab = ref<'quick' | 'structured'>('quick')
|
||||
const createdLabel = ref('')
|
||||
const tabRefs = ref<HTMLButtonElement[]>([])
|
||||
|
||||
function handleTabKey(e: KeyboardEvent, currentKey: 'quick' | 'structured') {
|
||||
const keys = tabs.map(t => t.key)
|
||||
const idx = keys.indexOf(currentKey)
|
||||
let next = idx
|
||||
if (e.key === 'ArrowRight') next = (idx + 1) % keys.length
|
||||
else if (e.key === 'ArrowLeft') next = (idx - 1 + keys.length) % keys.length
|
||||
else return
|
||||
e.preventDefault()
|
||||
activeTab.value = keys[next] as 'quick' | 'structured'
|
||||
nextTick(() => tabRefs.value[next]?.focus())
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.tab === 'structured') activeTab.value = 'structured'
|
||||
|
|
|
|||
Loading…
Reference in a new issue