feat(dashboard): verbatim modal + defect feed on /defects (T8)
This commit is contained in:
47
app/components/DefectFeed.vue
Normal file
47
app/components/DefectFeed.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// The defect feed (C6, F5): every filed defect beside the board, newest-first.
|
||||||
|
// Pure presentation — the parent passes the already-ordered, bounded slice from
|
||||||
|
// `/api/defects` (T6); we don't refetch or re-sort. Each entry carries the date,
|
||||||
|
// section, project, reporter and the verbatim itself (the point of the feed).
|
||||||
|
import { sections } from '~/board/definition'
|
||||||
|
|
||||||
|
interface FeedDefect {
|
||||||
|
id: string
|
||||||
|
sectionId: string
|
||||||
|
projectName: string
|
||||||
|
reporterEmail: string
|
||||||
|
verbatim: string
|
||||||
|
createdAt: string | Date
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{ defects: FeedDefect[] }>()
|
||||||
|
|
||||||
|
const labelFor = (id: string) => sections.find((s) => s.id === id)?.label ?? id
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="flex w-full flex-col gap-3" data-test="defect-feed">
|
||||||
|
<h2 class="text-lg font-bold">Feed</h2>
|
||||||
|
|
||||||
|
<p v-if="defects.length === 0" class="opacity-60" data-test="feed-empty">
|
||||||
|
No defects filed yet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul v-else class="flex flex-col gap-2">
|
||||||
|
<li
|
||||||
|
v-for="defect in defects"
|
||||||
|
:key="defect.id"
|
||||||
|
class="border-base-300 flex flex-col gap-1 border-l-2 pl-3"
|
||||||
|
data-test="feed-item"
|
||||||
|
>
|
||||||
|
<p class="text-sm">{{ defect.verbatim }}</p>
|
||||||
|
<p class="flex flex-wrap gap-x-2 text-xs opacity-60">
|
||||||
|
<span>{{ datestamp(defect.createdAt) }}</span>
|
||||||
|
<span>· {{ labelFor(defect.sectionId) }}</span>
|
||||||
|
<span>· {{ defect.projectName }}</span>
|
||||||
|
<span>· {{ defect.reporterEmail }}</span>
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -19,6 +19,10 @@ const props = defineProps<{
|
|||||||
cap?: number
|
cap?: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// Forward a Section click up so the page can open that section's verbatim modal
|
||||||
|
// (T8); BoardView owns the click, DotMap just relays the id.
|
||||||
|
defineEmits<{ select: [sectionId: string] }>()
|
||||||
|
|
||||||
const cap = computed(() => props.cap ?? 10)
|
const cap = computed(() => props.cap ?? 10)
|
||||||
|
|
||||||
// Group the feed by section, preserving its newest-first order.
|
// Group the feed by section, preserving its newest-first order.
|
||||||
@@ -56,7 +60,7 @@ function dotStyle(index: number) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BoardView>
|
<BoardView @select="$emit('select', $event)">
|
||||||
<template #overlay="{ sectionId }">
|
<template #overlay="{ sectionId }">
|
||||||
<span
|
<span
|
||||||
v-for="(defect, index) in dotsFor(sectionId)"
|
v-for="(defect, index) in dotsFor(sectionId)"
|
||||||
|
|||||||
103
app/components/VerbatimModal.vue
Normal file
103
app/components/VerbatimModal.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Per-section verbatim list (C6, F5): clicking a Section/dot on the board opens
|
||||||
|
// this modal over that section's defects without leaving the board. The list is
|
||||||
|
// loaded lazily — `/api/defects?section=ID` (T6) — only when a section opens, so
|
||||||
|
// a hot section's full history isn't paid for up front.
|
||||||
|
//
|
||||||
|
// Same native <dialog> (DaisyUI `modal`) as DefectForm: Escape, focus-trap and
|
||||||
|
// backdrop come for free, and the parent-controlled `sectionId` drives open/close.
|
||||||
|
import { sections } from '~/board/definition'
|
||||||
|
|
||||||
|
interface SectionDefect {
|
||||||
|
id: string
|
||||||
|
sectionId: string
|
||||||
|
projectName: string
|
||||||
|
reporterEmail: string
|
||||||
|
verbatim: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{ sectionId: string | null }>()
|
||||||
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
|
const dialog = ref<HTMLDialogElement>()
|
||||||
|
const defects = ref<SectionDefect[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const sectionLabel = computed(
|
||||||
|
() => sections.find((s) => s.id === props.sectionId)?.label ?? '',
|
||||||
|
)
|
||||||
|
|
||||||
|
// Lazily load the clicked section's history, bounded generously (well past the
|
||||||
|
// dot cap so the modal shows more than the map does).
|
||||||
|
async function load(sectionId: string) {
|
||||||
|
loading.value = true
|
||||||
|
defects.value = []
|
||||||
|
try {
|
||||||
|
defects.value = await $fetch<SectionDefect[]>('/api/defects', {
|
||||||
|
query: { section: sectionId, limit: 200 },
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror the parent-controlled section into the dialog's open state.
|
||||||
|
function sync() {
|
||||||
|
const el = dialog.value
|
||||||
|
if (!el) return
|
||||||
|
if (props.sectionId !== null) {
|
||||||
|
if (!el.open) el.showModal()
|
||||||
|
load(props.sectionId)
|
||||||
|
} else if (el.open) {
|
||||||
|
el.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(sync)
|
||||||
|
watch(() => props.sectionId, sync)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<dialog ref="dialog" class="modal" @close="emit('close')">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="text-lg font-bold">{{ sectionLabel }} — defects</h3>
|
||||||
|
|
||||||
|
<p v-if="loading" class="mt-4 opacity-60">Loading…</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-else-if="defects.length === 0"
|
||||||
|
class="mt-4 opacity-60"
|
||||||
|
data-test="modal-empty"
|
||||||
|
>
|
||||||
|
No defects filed against this section yet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul v-else class="mt-4 flex flex-col gap-3">
|
||||||
|
<li
|
||||||
|
v-for="defect in defects"
|
||||||
|
:key="defect.id"
|
||||||
|
class="border-base-300 flex flex-col gap-1 border-l-2 pl-3"
|
||||||
|
data-test="modal-item"
|
||||||
|
>
|
||||||
|
<p class="text-sm">{{ defect.verbatim }}</p>
|
||||||
|
<p class="flex flex-wrap gap-x-2 text-xs opacity-60">
|
||||||
|
<span>{{ datestamp(defect.createdAt) }}</span>
|
||||||
|
<span>· {{ defect.projectName }}</span>
|
||||||
|
<span>· {{ defect.reporterEmail }}</span>
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn btn-ghost" @click="dialog?.close()">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Native backdrop: clicking it closes the dialog. -->
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button aria-label="Close">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
@@ -6,6 +6,9 @@ import { sections } from '~/board/definition'
|
|||||||
interface DefectView {
|
interface DefectView {
|
||||||
id: string
|
id: string
|
||||||
sectionId: string
|
sectionId: string
|
||||||
|
projectName: string
|
||||||
|
reporterEmail: string
|
||||||
|
verbatim: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +21,10 @@ const { data: defects } = await useFetch<DefectView[]>('/api/defects', {
|
|||||||
const { data: counts } = await useFetch<Record<string, number>>('/api/defects/counts', {
|
const { data: counts } = await useFetch<Record<string, number>>('/api/defects/counts', {
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Which section's verbatim modal is open (null = closed). A click on any
|
||||||
|
// Section/dot in the DotMap sets it; the modal lazily loads that section (T8).
|
||||||
|
const openSection = ref<string | null>(null)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -27,8 +34,10 @@ const { data: counts } = await useFetch<Record<string, number>>('/api/defects/co
|
|||||||
<p class="opacity-70">Every defect filed on the board.</p>
|
<p class="opacity-70">Every defect filed on the board.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<DotMap :defects="defects" :counts="counts" />
|
<DotMap :defects="defects" :counts="counts" @select="openSection = $event" />
|
||||||
|
|
||||||
<p class="opacity-60">The defect feed and verbatim modal (T8) land next.</p>
|
<DefectFeed :defects="defects" />
|
||||||
|
|
||||||
|
<VerbatimModal :section-id="openSection" @close="openSection = null" />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
11
app/utils/datestamp.ts
Normal file
11
app/utils/datestamp.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Compact, locale-free date stamp (DD/MM/YYYY, UTC) for defect listings.
|
||||||
|
* UTC keeps it deterministic across machines and test runs. DotMap keeps its
|
||||||
|
* own shorter DD/MM stamp for the cramped diagonal dot labels.
|
||||||
|
*/
|
||||||
|
export function datestamp(value: string | Date): string {
|
||||||
|
const d = new Date(value)
|
||||||
|
const dd = String(d.getUTCDate()).padStart(2, '0')
|
||||||
|
const mm = String(d.getUTCMonth() + 1).padStart(2, '0')
|
||||||
|
return `${dd}/${mm}/${d.getUTCFullYear()}`
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ review.
|
|||||||
## Phase 3 — See weak points (G2)
|
## Phase 3 — See weak points (G2)
|
||||||
- [x] **T6** Defects read API: feed + per-section counts (C7) — F6 (M) — _deps: T3_
|
- [x] **T6** Defects read API: feed + per-section counts (C7) — F6 (M) — _deps: T3_
|
||||||
- [x] **T7** Dashboard DotMap (C5) — F4 (M) — _deps: T2, T6_
|
- [x] **T7** Dashboard DotMap (C5) — F4 (M) — _deps: T2, T6_
|
||||||
- [ ] **T8** VerbatimModal + Feed (C6) — F5 (M) — _deps: T6, T7_
|
- [x] **T8** VerbatimModal + Feed (C6) — F5 (M) — _deps: T6, T7_
|
||||||
- [ ] ⛳ **Checkpoint:** dashboard dot map + feed + modal work, ≤350ms @2k, human review
|
- [ ] ⛳ **Checkpoint:** dashboard dot map + feed + modal work, ≤350ms @2k, human review
|
||||||
|
|
||||||
## Phase 4 — Authentication (G4)
|
## Phase 4 — Authentication (G4)
|
||||||
|
|||||||
51
tests/defect-feed.nuxt.spec.ts
Normal file
51
tests/defect-feed.nuxt.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// @vitest-environment nuxt
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mountSuspended } from '@nuxt/test-utils/runtime'
|
||||||
|
import DefectFeed from '../app/components/DefectFeed.vue'
|
||||||
|
|
||||||
|
const defect = (over: Partial<Record<string, unknown>> = {}) => ({
|
||||||
|
id: 'd1',
|
||||||
|
sectionId: 'macroplan',
|
||||||
|
projectName: 'Acme',
|
||||||
|
reporterEmail: 'dev@theodo.com',
|
||||||
|
verbatim: 'builds are flaky',
|
||||||
|
createdAt: '2026-05-28T10:00:00Z',
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DefectFeed', () => {
|
||||||
|
it('renders each defect with its date, project, reporter and verbatim', async () => {
|
||||||
|
const wrapper = await mountSuspended(DefectFeed, {
|
||||||
|
props: { defects: [defect()] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = wrapper.findAll('[data-test="feed-item"]')
|
||||||
|
expect(items).toHaveLength(1)
|
||||||
|
const text = items[0]!.text()
|
||||||
|
expect(text).toContain('28/05')
|
||||||
|
expect(text).toContain('Acme')
|
||||||
|
expect(text).toContain('dev@theodo.com')
|
||||||
|
expect(text).toContain('builds are flaky')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves the feed order it is given (newest-first from the API)', async () => {
|
||||||
|
const wrapper = await mountSuspended(DefectFeed, {
|
||||||
|
props: {
|
||||||
|
defects: [
|
||||||
|
defect({ id: 'newer', verbatim: 'newer one' }),
|
||||||
|
defect({ id: 'older', verbatim: 'older one' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = wrapper.findAll('[data-test="feed-item"]')
|
||||||
|
expect(items[0]!.text()).toContain('newer one')
|
||||||
|
expect(items[1]!.text()).toContain('older one')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an empty state when there are no defects', async () => {
|
||||||
|
const wrapper = await mountSuspended(DefectFeed, { props: { defects: [] } })
|
||||||
|
expect(wrapper.findAll('[data-test="feed-item"]')).toHaveLength(0)
|
||||||
|
expect(wrapper.find('[data-test="feed-empty"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -56,4 +56,16 @@ describe('DotMap', () => {
|
|||||||
|
|
||||||
expect(section(wrapper, 'macroplan').find('[data-test="more"]').exists()).toBe(false)
|
expect(section(wrapper, 'macroplan').find('[data-test="more"]').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('emits the clicked section id (so the page can open its verbatim modal)', async () => {
|
||||||
|
const wrapper = await mountSuspended(DotMap, {
|
||||||
|
props: {
|
||||||
|
defects: [{ id: 'd1', sectionId: 'macroplan', createdAt: '2026-05-28T10:00:00Z' }],
|
||||||
|
counts: { macroplan: 1 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await section(wrapper, 'macroplan').trigger('click')
|
||||||
|
expect(wrapper.emitted('select')?.[0]).toEqual(['macroplan'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
75
tests/verbatim-modal.nuxt.spec.ts
Normal file
75
tests/verbatim-modal.nuxt.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// @vitest-environment nuxt
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import { defineEventHandler, getQuery } from 'h3'
|
||||||
|
import VerbatimModal from '../app/components/VerbatimModal.vue'
|
||||||
|
|
||||||
|
// Capture the section the modal lazily queried, and answer with that section's
|
||||||
|
// history (T6's `/api/defects?section=ID`).
|
||||||
|
let queried: string | undefined
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
queried = undefined
|
||||||
|
registerEndpoint(
|
||||||
|
'/api/defects',
|
||||||
|
defineEventHandler((event) => {
|
||||||
|
queried = getQuery(event).section as string | undefined
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'd1',
|
||||||
|
sectionId: 'macroplan',
|
||||||
|
projectName: 'Acme',
|
||||||
|
reporterEmail: 'dev@theodo.com',
|
||||||
|
verbatim: 'builds are flaky',
|
||||||
|
createdAt: '2026-05-28T10:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
type Wrapper = Awaited<ReturnType<typeof mountSuspended>>
|
||||||
|
const dialogEl = (w: Wrapper) => w.find('dialog').element as HTMLDialogElement
|
||||||
|
|
||||||
|
describe('VerbatimModal', () => {
|
||||||
|
it('stays closed when no section is selected', async () => {
|
||||||
|
const wrapper = await mountSuspended(VerbatimModal, { props: { sectionId: null } })
|
||||||
|
await flushPromises()
|
||||||
|
expect(dialogEl(wrapper).open).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens for the clicked section and lists its defects with verbatim, project, reporter and date', async () => {
|
||||||
|
const wrapper = await mountSuspended(VerbatimModal, { props: { sectionId: 'macroplan' } })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(dialogEl(wrapper).open).toBe(true)
|
||||||
|
expect(queried).toBe('macroplan')
|
||||||
|
// The section's human label heads the modal.
|
||||||
|
expect(wrapper.text()).toContain('Macroplan')
|
||||||
|
|
||||||
|
const items = wrapper.findAll('[data-test="modal-item"]')
|
||||||
|
expect(items).toHaveLength(1)
|
||||||
|
const text = items[0]!.text()
|
||||||
|
expect(text).toContain('builds are flaky')
|
||||||
|
expect(text).toContain('Acme')
|
||||||
|
expect(text).toContain('dev@theodo.com')
|
||||||
|
expect(text).toContain('28/05')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes when the section is cleared', async () => {
|
||||||
|
const wrapper = await mountSuspended(VerbatimModal, { props: { sectionId: 'macroplan' } })
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.setProps({ sectionId: null })
|
||||||
|
await flushPromises()
|
||||||
|
expect(dialogEl(wrapper).open).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits close when the dialog is dismissed (Escape/backdrop fire native close)', async () => {
|
||||||
|
const wrapper = await mountSuspended(VerbatimModal, { props: { sectionId: 'macroplan' } })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
dialogEl(wrapper).dispatchEvent(new Event('close'))
|
||||||
|
expect(wrapper.emitted('close')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user