feat: register T1 (pending, verify)

This commit is contained in:
fsylvain committed 2026-09-26 04:56:48 +02:00
1 parent 3c3f7c6194
commit 4e41d60142
29 files changed
+2162 -42

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
const props = defineProps<{
title: string
description: string
confirmLabel: string
cancelLabel: string
pendingLabel: string
tone: 'danger' | 'neutral'
action: () => Promise<void>
}>()
const emit = defineEmits<{ close: [confirmed: boolean] }>()
const open = defineModel<boolean>('open', { default: true })
const pending = ref(false)
const error = ref('')
const cancelButton = ref<{ $el: HTMLElement } | null>(null)
let settled = false
const confirmColor = computed(() => (props.tone === 'danger' ? 'error' : 'primary'))
const confirmText = computed(() => (pending.value ? props.pendingLabel : props.confirmLabel))
onMounted(async () => {
await nextTick()
cancelButton.value?.$el?.focus()
})
function settle(confirmed: boolean) {
if (settled) {
return
}
settled = true
open.value = false
emit('close', confirmed)
}
watch(open, (value) => {
if (!value) {
settle(false)
}
})
async function onConfirm() {
if (pending.value) {
return
}
pending.value = true
error.value = ''
try {
await props.action()
settle(true)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Something went wrong. Try again.'
} finally {
pending.value = false
}
}
function onCancel() {
if (pending.value) {
return
}
settle(false)
}
</script>
<template>
<UModal
v-model:open="open"
:title="title"
:description="description"
:dismissible="!pending"
:close="false"
:content="{ role: 'alertdialog', 'aria-busy': pending ? 'true' : undefined }"
:ui="{
content:
'max-sm:!inset-x-0 max-sm:!bottom-0 max-sm:!top-auto max-sm:!left-0 max-sm:!right-0 max-sm:!translate-x-0 max-sm:!translate-y-0 max-sm:!w-full max-sm:!max-w-none',
}"
>
<template v-if="error" #body>
<UAlert
color="error"
icon="i-lucide-circle-x"
:description="error"
variant="soft"
:close="false"
role="alert"
/>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
ref="cancelButton"
color="neutral"
variant="outline"
block
class="sm:w-auto"
:disabled="pending"
@click="onCancel"
>
{{ cancelLabel }}
</UButton>
<UButton
:color="confirmColor"
:loading="pending"
:disabled="pending"
block
class="sm:w-auto"
@click="onConfirm"
>
{{ confirmText }}
</UButton>
</div>
</template>
</UModal>
</template>
+3 -1
View File
@@ -6,6 +6,7 @@ interface ResultPanelAction {
color?: 'primary' | 'neutral'
variant?: 'solid' | 'soft' | 'outline' | 'ghost'
to?: string
disabled?: boolean
onClick?: () => void
}
@@ -46,6 +47,7 @@ onMounted(async () => {
</h1>
<p class="text-toned text-sm">{{ description }}</p>
</div>
<slot />
<div class="flex w-full flex-col gap-2">
<UButton
v-for="action in actions"
@@ -55,11 +57,11 @@ onMounted(async () => {
:color="action.color ?? 'primary'"
:variant="action.variant ?? 'solid'"
:to="action.to"
:disabled="action.disabled"
@click="action.onClick"
>
{{ action.label }}
</UButton>
</div>
<slot />
</div>
</template>
@@ -0,0 +1,129 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import ConfirmDialog from '../ConfirmDialog.vue'
afterEach(() => {
document.body.innerHTML = ''
})
function mountDialog(action: () => Promise<void>) {
return mount(ConfirmDialog, {
attachTo: document.body,
props: {
title: 'Cancel your registration?',
description:
'Your account will be deleted. You can register again with the same or a different email.',
confirmLabel: 'Cancel registration',
cancelLabel: 'Keep my account',
pendingLabel: 'Cancelling…',
tone: 'danger',
action,
},
global: {
plugins: [uiPlugin],
},
})
}
function findButtonElement(text: string) {
return Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === text,
)
}
async function clickButton(text: string) {
findButtonElement(text)?.click()
await flushPromises()
}
describe('Confirm cancel — the destructive dialog opens', () => {
it('renders title, description, both buttons, role and starts focus on the cancel button', async () => {
const wrapper = mountDialog(() => new Promise(() => {}))
await flushPromises()
expect(document.body.textContent).toContain('Cancel your registration?')
expect(document.body.textContent).toContain(
'Your account will be deleted. You can register again with the same or a different email.',
)
const cancelButton = findButtonElement('Keep my account')
const confirmButton = findButtonElement('Cancel registration')
expect(cancelButton).toBeTruthy()
expect(confirmButton).toBeTruthy()
expect(document.querySelector('[role="alertdialog"]')).toBeTruthy()
expect(cancelButton).toBe(document.activeElement)
wrapper.unmount()
})
})
describe('Cancelling — the action is in flight and the dialog cannot be dismissed', () => {
it('disables both buttons, shows the pending label and sets aria-busy', async () => {
let resolveAction: (() => void) | null = null
const action = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveAction = resolve
}),
)
const wrapper = mountDialog(action)
await flushPromises()
await clickButton('Cancel registration')
expect(action).toHaveBeenCalledTimes(1)
expect((findButtonElement('Keep my account') as HTMLButtonElement).disabled).toBe(true)
expect(findButtonElement('Cancelling…')).toBeTruthy()
expect((findButtonElement('Cancelling…') as HTMLButtonElement).disabled).toBe(true)
expect(document.querySelector('[aria-busy="true"]')).toBeTruthy()
resolveAction?.()
await flushPromises()
wrapper.unmount()
})
})
describe('Keep my account — the dialog is dismissed without deleting anything', () => {
it('emits close(false) without ever calling the action', async () => {
const action = vi.fn(() => Promise.resolve())
const wrapper = mountDialog(action)
await flushPromises()
await clickButton('Keep my account')
expect(action).not.toHaveBeenCalled()
expect(wrapper.emitted('close')).toEqual([[false]])
wrapper.unmount()
})
})
describe('Cancelled — resolving the action closes the dialog', () => {
it('emits close(true) once the action resolves', async () => {
const action = vi.fn(() => Promise.resolve())
const wrapper = mountDialog(action)
await flushPromises()
await clickButton('Cancel registration')
expect(wrapper.emitted('close')).toEqual([[true]])
wrapper.unmount()
})
})
describe('Cancel failed — the dialog stays open and shows the failure inline', () => {
it('re-enables both buttons and shows the rejection message inline', async () => {
const action = vi.fn(() => Promise.reject(new Error("Couldn't cancel registration.")))
const wrapper = mountDialog(action)
await flushPromises()
await clickButton('Cancel registration')
expect(wrapper.emitted('close')).toBeFalsy()
expect(document.body.textContent).toContain("Couldn't cancel registration.")
expect((findButtonElement('Keep my account') as HTMLButtonElement).disabled).toBe(false)
expect((findButtonElement('Cancel registration') as HTMLButtonElement).disabled).toBe(false)
wrapper.unmount()
})
})