feat(defects): file a defect via a board-section modal (T5)
POST /api/defects validates the body with arktype (known section, uuid project, non-empty trimmed verbatim) and resolves the reporter through a dev seam (getReporter, ADR 0002) until OAuth lands. DefectForm is a DaisyUI modal opened by a section click — pick a project, describe the problem, submit.
This commit is contained in:
114
app/components/DefectForm.vue
Normal file
114
app/components/DefectForm.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
// File a defect against a board section (C3, F2). Driven by `sectionId`: when a
|
||||
// section is clicked the modal opens pre-targeted to it; pick a project, type
|
||||
// what's wrong, submit. Reporter and timestamp are added server-side.
|
||||
import { sections } from '~/board/definition'
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface Defect {
|
||||
id: string
|
||||
sectionId: string
|
||||
projectId: string
|
||||
verbatim: string
|
||||
}
|
||||
|
||||
const props = defineProps<{ sectionId: string | null }>()
|
||||
const emit = defineEmits<{ close: []; filed: [Defect] }>()
|
||||
|
||||
const project = ref<Project | null>(null)
|
||||
const verbatim = ref('')
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const sectionLabel = computed(
|
||||
() => sections.find((s) => s.id === props.sectionId)?.label ?? '',
|
||||
)
|
||||
const canSubmit = computed(
|
||||
() => project.value !== null && verbatim.value.trim().length > 0,
|
||||
)
|
||||
|
||||
// Each time a (new) section opens, start from a clean form.
|
||||
watch(
|
||||
() => props.sectionId,
|
||||
() => {
|
||||
project.value = null
|
||||
verbatim.value = ''
|
||||
error.value = ''
|
||||
},
|
||||
)
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit.value || busy.value || !props.sectionId) return
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const defect = await $fetch<Defect>('/api/defects', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
sectionId: props.sectionId,
|
||||
projectId: project.value!.id,
|
||||
verbatim: verbatim.value.trim(),
|
||||
},
|
||||
})
|
||||
emit('filed', defect)
|
||||
} catch {
|
||||
error.value = 'Could not file the defect. Please try again.'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog class="modal" :class="{ 'modal-open': sectionId !== null }">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">Report a problem — {{ sectionLabel }}</h3>
|
||||
|
||||
<form class="mt-4 flex flex-col gap-4" @submit.prevent="submit">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Project</legend>
|
||||
<ProjectAutocomplete v-model="project" />
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">What's wrong?</legend>
|
||||
<textarea
|
||||
v-model="verbatim"
|
||||
class="textarea textarea-bordered w-full"
|
||||
rows="3"
|
||||
placeholder="Describe the problem in your own words…"
|
||||
data-test="verbatim"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<p v-if="error" class="text-error text-sm" role="alert">{{ error }}</p>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-ghost" @click="emit('close')">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="!canSubmit || busy"
|
||||
data-test="submit"
|
||||
>
|
||||
{{ busy ? 'Filing…' : 'File defect' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Click the backdrop to dismiss. -->
|
||||
<button
|
||||
type="button"
|
||||
class="modal-backdrop"
|
||||
aria-label="Close"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
</dialog>
|
||||
</template>
|
||||
@@ -1,13 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
const selected = ref<string | null>(null)
|
||||
// Andon reporting view (F2): the board is the entry point — clicking a section
|
||||
// opens the defect form for it.
|
||||
const selectedSectionId = ref<string | null>(null)
|
||||
const confirmation = ref<string | null>(null)
|
||||
|
||||
function onFiled() {
|
||||
selectedSectionId.value = null
|
||||
confirmation.value = 'Thanks — your report was filed.'
|
||||
setTimeout(() => (confirmation.value = null), 4000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<h1>Andon — report a board problem</h1>
|
||||
<BoardView @select="(id) => (selected = id)" />
|
||||
<p v-if="selected">
|
||||
Selected section: <code>{{ selected }}</code> — the defect form arrives in Task 5.
|
||||
</p>
|
||||
<main class="mx-auto flex max-w-5xl flex-col items-center gap-6 p-6">
|
||||
<header class="text-center">
|
||||
<h1>Andon — report a board problem</h1>
|
||||
<p class="opacity-70">Click a section to report a problem.</p>
|
||||
</header>
|
||||
|
||||
<BoardView @select="(id) => (selectedSectionId = id)" />
|
||||
|
||||
<DefectForm
|
||||
:section-id="selectedSectionId"
|
||||
@close="selectedSectionId = null"
|
||||
@filed="onFiled"
|
||||
/>
|
||||
|
||||
<div v-if="confirmation" class="toast toast-end">
|
||||
<div class="alert alert-success">{{ confirmation }}</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
15
server/api/defects.post.ts
Normal file
15
server/api/defects.post.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { type } from 'arktype'
|
||||
import { getDb } from '../db/client'
|
||||
import { createDefect } from '../db/repositories/defects'
|
||||
import { DefectInput } from '../utils/defectInput'
|
||||
import { getReporter } from '../utils/getReporter'
|
||||
|
||||
// File a defect against a board section (F2). Reporter comes from the seam,
|
||||
// not the client; the timestamp defaults in the DB.
|
||||
export default defineEventHandler(async (event) => {
|
||||
const input = DefectInput(await readBody(event))
|
||||
if (input instanceof type.errors) {
|
||||
throw createError({ statusCode: 400, statusMessage: input.summary })
|
||||
}
|
||||
return createDefect(getDb(), { ...input, reporterEmail: getReporter(event) })
|
||||
})
|
||||
17
server/utils/defectInput.ts
Normal file
17
server/utils/defectInput.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { type } from 'arktype'
|
||||
import { sectionIds } from '../../app/board/definition'
|
||||
|
||||
// Validated request body for filing a defect (F2). The reporter is resolved
|
||||
// server-side via the session seam, never trusted from the client. Verbatim is
|
||||
// trimmed; the section must be one defined on the canonical board (C1).
|
||||
export const DefectInput = type({
|
||||
sectionId: type('string').narrow(
|
||||
(id, ctx) => sectionIds.has(id) || ctx.reject('a known board section id'),
|
||||
),
|
||||
projectId: 'string.uuid',
|
||||
verbatim: type('string')
|
||||
.pipe((s) => s.trim())
|
||||
.narrow((s, ctx) => s.length > 0 || ctx.reject('non-empty text')),
|
||||
})
|
||||
|
||||
export type DefectInputData = typeof DefectInput.infer
|
||||
10
server/utils/getReporter.ts
Normal file
10
server/utils/getReporter.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { H3Event } from 'h3'
|
||||
|
||||
/**
|
||||
* Resolves the reporter's email — the one seam between filing and auth (ADR
|
||||
* 0002). For now it returns a fixed dev identity so the filing slice works
|
||||
* before OAuth exists; Task 9 swaps this to read the verified session email.
|
||||
*/
|
||||
export function getReporter(_event: H3Event): string {
|
||||
return process.env.DEV_REPORTER_EMAIL ?? 'dev@theodo.com'
|
||||
}
|
||||
@@ -12,7 +12,7 @@ review.
|
||||
## Phase 2 — File a defect (G1)
|
||||
- [x] **T3** Postgres schema + migrations (C8) (S) — _deps: T1_
|
||||
- [x] **T4** Projects API + ProjectAutocomplete (C4) — F3 (M) — _deps: T3_
|
||||
- [ ] **T5** File a defect: POST /api/defects + DefectForm (C3) — F2 (M) — _deps: T2, T3, T4_
|
||||
- [x] **T5** File a defect: POST /api/defects + DefectForm (C3) — F2 (M) — _deps: T2, T3, T4_
|
||||
- [ ] ⛳ **Checkpoint:** defect files end-to-end (dev auth), tests pass, human review
|
||||
|
||||
## Phase 3 — See weak points (G2)
|
||||
|
||||
80
tests/defect-filing.spec.ts
Normal file
80
tests/defect-filing.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'
|
||||
import { type } from 'arktype'
|
||||
import { setupTestDb, truncateAll, type TestDb } from './helpers/db'
|
||||
import { createProject } from '../server/db/repositories/projects'
|
||||
import { createDefect, listRecentDefects } from '../server/db/repositories/defects'
|
||||
import { DefectInput } from '../server/utils/defectInput'
|
||||
|
||||
const UUID = '3f0a27a7-81c0-43fa-9b42-c2864ddc569b'
|
||||
|
||||
describe('defect input validation', () => {
|
||||
it('accepts a valid defect and trims the verbatim', () => {
|
||||
const result = DefectInput({
|
||||
sectionId: 'macroplan',
|
||||
projectId: UUID,
|
||||
verbatim: ' builds are flaky ',
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
sectionId: 'macroplan',
|
||||
projectId: UUID,
|
||||
verbatim: 'builds are flaky',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an unknown section id', () => {
|
||||
const result = DefectInput({ sectionId: 'not-a-section', projectId: UUID, verbatim: 'x' })
|
||||
expect(result instanceof type.errors).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty / whitespace-only verbatim', () => {
|
||||
const result = DefectInput({ sectionId: 'macroplan', projectId: UUID, verbatim: ' ' })
|
||||
expect(result instanceof type.errors).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a non-uuid project id', () => {
|
||||
const result = DefectInput({ sectionId: 'macroplan', projectId: 'nope', verbatim: 'x' })
|
||||
expect(result instanceof type.errors).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('defects repository', () => {
|
||||
let db: TestDb
|
||||
let pool: { end: () => Promise<void> }
|
||||
|
||||
beforeAll(async () => {
|
||||
const setup = await setupTestDb()
|
||||
db = setup.db
|
||||
pool = setup.pool
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAll(db)
|
||||
})
|
||||
|
||||
it('persists a filed defect with section, project, verbatim and reporter', async () => {
|
||||
const project = await createProject(db, 'Acme')
|
||||
const row = await createDefect(db, {
|
||||
sectionId: 'macroplan',
|
||||
projectId: project.id,
|
||||
verbatim: 'login is flaky',
|
||||
reporterEmail: 'dev@theodo.com',
|
||||
})
|
||||
|
||||
expect(row).toMatchObject({
|
||||
sectionId: 'macroplan',
|
||||
projectId: project.id,
|
||||
verbatim: 'login is flaky',
|
||||
reporterEmail: 'dev@theodo.com',
|
||||
})
|
||||
expect(row.id).toBeDefined()
|
||||
expect(row.createdAt).toBeInstanceOf(Date)
|
||||
|
||||
const recent = await listRecentDefects(db)
|
||||
expect(recent).toHaveLength(1)
|
||||
expect(recent[0]!.id).toBe(row.id)
|
||||
})
|
||||
})
|
||||
73
tests/defect-form.nuxt.spec.ts
Normal file
73
tests/defect-form.nuxt.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// @vitest-environment nuxt
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { defineEventHandler, readBody } from 'h3'
|
||||
import DefectForm from '../app/components/DefectForm.vue'
|
||||
|
||||
let filed: { sectionId: string; projectId: string; verbatim: string } | null
|
||||
|
||||
beforeEach(() => {
|
||||
filed = null
|
||||
registerEndpoint(
|
||||
'/api/projects',
|
||||
defineEventHandler(() => [{ id: 'p1', name: 'Acme' }]),
|
||||
)
|
||||
registerEndpoint(
|
||||
'/api/defects',
|
||||
defineEventHandler(async (event) => {
|
||||
filed = await readBody(event)
|
||||
return { id: 'd1', ...filed, reporterEmail: 'dev@theodo.com' }
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
async function pickProject(wrapper: Awaited<ReturnType<typeof mountSuspended>>) {
|
||||
await wrapper.find('[data-test="project-input"]').trigger('focus')
|
||||
await wrapper.find('[data-test="project-option"]').trigger('click')
|
||||
}
|
||||
|
||||
describe('DefectForm', () => {
|
||||
it('opens for the clicked section and shows its name', async () => {
|
||||
const wrapper = await mountSuspended(DefectForm, { props: { sectionId: 'macroplan' } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('dialog').classes()).toContain('modal-open')
|
||||
expect(wrapper.text()).toContain('Macroplan')
|
||||
})
|
||||
|
||||
it('is closed when no section is selected', async () => {
|
||||
const wrapper = await mountSuspended(DefectForm, { props: { sectionId: null } })
|
||||
expect(wrapper.find('dialog').classes()).not.toContain('modal-open')
|
||||
})
|
||||
|
||||
it('cannot submit until both a project and verbatim are provided', async () => {
|
||||
const wrapper = await mountSuspended(DefectForm, { props: { sectionId: 'macroplan' } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-test="submit"]').attributes('disabled')).toBeDefined()
|
||||
|
||||
await pickProject(wrapper)
|
||||
// project but no verbatim → still blocked
|
||||
expect(wrapper.find('[data-test="submit"]').attributes('disabled')).toBeDefined()
|
||||
|
||||
await wrapper.find('[data-test="verbatim"]').setValue('builds are flaky')
|
||||
expect(wrapper.find('[data-test="submit"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('files the defect with the section, chosen project and verbatim, then emits filed', async () => {
|
||||
const wrapper = await mountSuspended(DefectForm, { props: { sectionId: 'macroplan' } })
|
||||
await flushPromises()
|
||||
|
||||
await pickProject(wrapper)
|
||||
await wrapper.find('[data-test="verbatim"]').setValue('builds are flaky')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
await vi.waitFor(() => expect(wrapper.emitted('filed')).toBeTruthy())
|
||||
expect(filed).toEqual({
|
||||
sectionId: 'macroplan',
|
||||
projectId: 'p1',
|
||||
verbatim: 'builds are flaky',
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user