refactor(defects): use the native <dialog> for the defect modal
DaisyUI's modal drives the native <dialog> via showModal/close, so Escape, focus-trapping and the backdrop work without custom handling. The parent's sectionId controls open/close and the dialog's native close event maps to the component's close emit.
This commit is contained in:
@@ -2,6 +2,9 @@
|
|||||||
// File a defect against a board section (C3, F2). Driven by `sectionId`: when a
|
// 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
|
// 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.
|
// what's wrong, submit. Reporter and timestamp are added server-side.
|
||||||
|
//
|
||||||
|
// Uses the native <dialog> (DaisyUI `modal`), so Escape, focus-trapping and the
|
||||||
|
// backdrop come for free; the parent-controlled `sectionId` drives open/close.
|
||||||
import { sections } from '~/board/definition'
|
import { sections } from '~/board/definition'
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
@@ -19,6 +22,7 @@ interface Defect {
|
|||||||
const props = defineProps<{ sectionId: string | null }>()
|
const props = defineProps<{ sectionId: string | null }>()
|
||||||
const emit = defineEmits<{ close: []; filed: [Defect] }>()
|
const emit = defineEmits<{ close: []; filed: [Defect] }>()
|
||||||
|
|
||||||
|
const dialog = ref<HTMLDialogElement>()
|
||||||
const project = ref<Project | null>(null)
|
const project = ref<Project | null>(null)
|
||||||
const verbatim = ref('')
|
const verbatim = ref('')
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
@@ -31,15 +35,22 @@ const canSubmit = computed(
|
|||||||
() => project.value !== null && verbatim.value.trim().length > 0,
|
() => project.value !== null && verbatim.value.trim().length > 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Each time a (new) section opens, start from a clean form.
|
// Mirror the parent-controlled section into the dialog's open state, resetting
|
||||||
watch(
|
// the form each time a (new) section opens.
|
||||||
() => props.sectionId,
|
function sync() {
|
||||||
() => {
|
const el = dialog.value
|
||||||
|
if (!el) return
|
||||||
|
if (props.sectionId !== null) {
|
||||||
project.value = null
|
project.value = null
|
||||||
verbatim.value = ''
|
verbatim.value = ''
|
||||||
error.value = ''
|
error.value = ''
|
||||||
},
|
if (!el.open) el.showModal()
|
||||||
)
|
} else if (el.open) {
|
||||||
|
el.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(sync)
|
||||||
|
watch(() => props.sectionId, sync)
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!canSubmit.value || busy.value || !props.sectionId) return
|
if (!canSubmit.value || busy.value || !props.sectionId) return
|
||||||
@@ -64,11 +75,15 @@ async function submit() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<dialog class="modal" :class="{ 'modal-open': sectionId !== null }">
|
<dialog ref="dialog" class="modal" @close="emit('close')">
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 class="text-lg font-bold">Report a problem — {{ sectionLabel }}</h3>
|
<h3 class="text-lg font-bold">Report a problem — {{ sectionLabel }}</h3>
|
||||||
|
|
||||||
<form class="mt-4 flex flex-col gap-4" @submit.prevent="submit">
|
<form
|
||||||
|
class="mt-4 flex flex-col gap-4"
|
||||||
|
data-test="defect-form"
|
||||||
|
@submit.prevent="submit"
|
||||||
|
>
|
||||||
<fieldset class="fieldset">
|
<fieldset class="fieldset">
|
||||||
<legend class="fieldset-legend">Project</legend>
|
<legend class="fieldset-legend">Project</legend>
|
||||||
<ProjectAutocomplete v-model="project" />
|
<ProjectAutocomplete v-model="project" />
|
||||||
@@ -88,7 +103,7 @@ async function submit() {
|
|||||||
<p v-if="error" class="text-error text-sm" role="alert">{{ error }}</p>
|
<p v-if="error" class="text-error text-sm" role="alert">{{ error }}</p>
|
||||||
|
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button type="button" class="btn btn-ghost" @click="emit('close')">
|
<button type="button" class="btn btn-ghost" @click="dialog?.close()">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -103,12 +118,9 @@ async function submit() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Click the backdrop to dismiss. -->
|
<!-- Native backdrop: clicking it closes the dialog. -->
|
||||||
<button
|
<form method="dialog" class="modal-backdrop">
|
||||||
type="button"
|
<button aria-label="Close">close</button>
|
||||||
class="modal-backdrop"
|
</form>
|
||||||
aria-label="Close"
|
|
||||||
@click="emit('close')"
|
|
||||||
/>
|
|
||||||
</dialog>
|
</dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -22,23 +22,43 @@ beforeEach(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function pickProject(wrapper: Awaited<ReturnType<typeof mountSuspended>>) {
|
type Wrapper = Awaited<ReturnType<typeof mountSuspended>>
|
||||||
|
const dialogEl = (w: Wrapper) => w.find('dialog').element as HTMLDialogElement
|
||||||
|
|
||||||
|
async function pickProject(wrapper: Wrapper) {
|
||||||
await wrapper.find('[data-test="project-input"]').trigger('focus')
|
await wrapper.find('[data-test="project-input"]').trigger('focus')
|
||||||
await wrapper.find('[data-test="project-option"]').trigger('click')
|
await wrapper.find('[data-test="project-option"]').trigger('click')
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('DefectForm', () => {
|
describe('DefectForm', () => {
|
||||||
it('opens for the clicked section and shows its name', async () => {
|
it('opens the native dialog for the clicked section and shows its name', async () => {
|
||||||
const wrapper = await mountSuspended(DefectForm, { props: { sectionId: 'macroplan' } })
|
const wrapper = await mountSuspended(DefectForm, { props: { sectionId: 'macroplan' } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('dialog').classes()).toContain('modal-open')
|
expect(dialogEl(wrapper).open).toBe(true)
|
||||||
expect(wrapper.text()).toContain('Macroplan')
|
expect(wrapper.text()).toContain('Macroplan')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('is closed when no section is selected', async () => {
|
it('stays closed when no section is selected', async () => {
|
||||||
const wrapper = await mountSuspended(DefectForm, { props: { sectionId: null } })
|
const wrapper = await mountSuspended(DefectForm, { props: { sectionId: null } })
|
||||||
expect(wrapper.find('dialog').classes()).not.toContain('modal-open')
|
await flushPromises()
|
||||||
|
expect(dialogEl(wrapper).open).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes the dialog when the section is cleared', async () => {
|
||||||
|
const wrapper = await mountSuspended(DefectForm, { 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(DefectForm, { props: { sectionId: 'macroplan' } })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
dialogEl(wrapper).dispatchEvent(new Event('close'))
|
||||||
|
expect(wrapper.emitted('close')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('cannot submit until both a project and verbatim are provided', async () => {
|
it('cannot submit until both a project and verbatim are provided', async () => {
|
||||||
@@ -48,7 +68,6 @@ describe('DefectForm', () => {
|
|||||||
expect(wrapper.find('[data-test="submit"]').attributes('disabled')).toBeDefined()
|
expect(wrapper.find('[data-test="submit"]').attributes('disabled')).toBeDefined()
|
||||||
|
|
||||||
await pickProject(wrapper)
|
await pickProject(wrapper)
|
||||||
// project but no verbatim → still blocked
|
|
||||||
expect(wrapper.find('[data-test="submit"]').attributes('disabled')).toBeDefined()
|
expect(wrapper.find('[data-test="submit"]').attributes('disabled')).toBeDefined()
|
||||||
|
|
||||||
await wrapper.find('[data-test="verbatim"]').setValue('builds are flaky')
|
await wrapper.find('[data-test="verbatim"]').setValue('builds are flaky')
|
||||||
@@ -61,7 +80,7 @@ describe('DefectForm', () => {
|
|||||||
|
|
||||||
await pickProject(wrapper)
|
await pickProject(wrapper)
|
||||||
await wrapper.find('[data-test="verbatim"]').setValue('builds are flaky')
|
await wrapper.find('[data-test="verbatim"]').setValue('builds are flaky')
|
||||||
await wrapper.find('form').trigger('submit')
|
await wrapper.find('[data-test="defect-form"]').trigger('submit')
|
||||||
|
|
||||||
await vi.waitFor(() => expect(wrapper.emitted('filed')).toBeTruthy())
|
await vi.waitFor(() => expect(wrapper.emitted('filed')).toBeTruthy())
|
||||||
expect(filed).toEqual({
|
expect(filed).toEqual({
|
||||||
|
|||||||
Reference in New Issue
Block a user