refac: hardening pass with multiple overhauls
This commit is contained in:
1 parent
b0f7b7892b
commit
da36c78bc0
28 files changed
+1375
-39
No files matched your search
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
@@ -68,12 +68,33 @@ const navUi = computed(() => ({
|
||||
const loggingOut = ref(false)
|
||||
const sidebarOpen = ref(false)
|
||||
const accountSheetOpen = ref(false)
|
||||
const NAVBAR_TOGGLE_SELECTOR = '[data-slot="toggle"]'
|
||||
let sheetOpener: HTMLElement | undefined
|
||||
|
||||
function openAccountSheet() {
|
||||
function openAccountSheet(opener: HTMLElement | undefined) {
|
||||
sheetOpener = opener
|
||||
sidebarOpen.value = false
|
||||
accountSheetOpen.value = true
|
||||
}
|
||||
|
||||
async function restoreFocusAfterSheet() {
|
||||
await nextTick()
|
||||
if (loggingOut.value) {
|
||||
return
|
||||
}
|
||||
const target = sheetOpener?.isConnected
|
||||
? sheetOpener
|
||||
: document.querySelector<HTMLElement>(NAVBAR_TOGGLE_SELECTOR)
|
||||
sheetOpener = undefined
|
||||
target?.focus()
|
||||
}
|
||||
|
||||
watch(accountSheetOpen, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
restoreFocusAfterSheet()
|
||||
}
|
||||
})
|
||||
|
||||
const showBoot = computed(() => session.status === 'checking' && !props.renderWhileChecking)
|
||||
|
||||
function onRetry() {
|
||||
@@ -129,6 +150,7 @@ async function onLogout() {
|
||||
:display-name="session.session?.displayName"
|
||||
:email="session.session?.email"
|
||||
:collapsed="sidebarCollapsed"
|
||||
:sheet-open="accountSheetOpen"
|
||||
@logout="onLogout"
|
||||
@open-sheet="openAccountSheet"
|
||||
/>
|
||||
|
||||
@@ -10,16 +10,18 @@ const props = defineProps<{
|
||||
displayName?: string
|
||||
email?: string
|
||||
collapsed: boolean
|
||||
sheetOpen?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
logout: []
|
||||
'open-sheet': []
|
||||
'open-sheet': [chip: HTMLElement | undefined]
|
||||
}>()
|
||||
|
||||
const { colorMode, name, initials, onProfile, goToProfile } = useAccountMenu(() => props)
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const open = ref(false)
|
||||
const chip = ref<{ $el: HTMLElement } | null>(null)
|
||||
|
||||
const chipActive = computed(() => open.value || onProfile.value)
|
||||
|
||||
@@ -41,10 +43,12 @@ function onUpdateOpen(value: boolean) {
|
||||
return
|
||||
}
|
||||
if (value) {
|
||||
emit('open-sheet')
|
||||
emit('open-sheet', chip.value?.$el)
|
||||
}
|
||||
}
|
||||
|
||||
const chipExpanded = computed(() => (isDesktop.value ? open.value : props.sheetOpen === true))
|
||||
|
||||
const items = computed(() => [
|
||||
[
|
||||
{
|
||||
@@ -89,10 +93,12 @@ const items = computed(() => [
|
||||
@update:open="onUpdateOpen"
|
||||
>
|
||||
<UButton
|
||||
ref="chip"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
block
|
||||
title="Account"
|
||||
:aria-expanded="chipExpanded"
|
||||
:class="chipClass"
|
||||
:trailing-icon="collapsed ? undefined : 'i-lucide-chevrons-up-down'"
|
||||
:ui="{ trailingIcon: 'ms-auto size-3.5 text-current' }"
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import DashboardShell from '../DashboardShell.vue'
|
||||
import { MOBILE_WIDTH, setViewportWidth } from './viewport'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return {
|
||||
...actual,
|
||||
useApi: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
let mountedShell: VueWrapper | null = null
|
||||
|
||||
async function mountMobileShell() {
|
||||
localStorage.clear()
|
||||
setViewportWidth(MOBILE_WIDTH)
|
||||
setActivePinia(createPinia())
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
email: 'marta@renew.example',
|
||||
displayName: '',
|
||||
}),
|
||||
request: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
await router.push('/home')
|
||||
mountedShell = mount(DashboardShell, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function drawerChip() {
|
||||
return document.body.querySelector<HTMLButtonElement>('[role="dialog"] button[aria-haspopup]')
|
||||
}
|
||||
|
||||
function sheet() {
|
||||
return document.body.querySelector('[role="menu"]')
|
||||
}
|
||||
|
||||
function menuItem(label: string) {
|
||||
return Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]'),
|
||||
).find((item) => item.textContent?.trim() === label)!
|
||||
}
|
||||
|
||||
async function openDrawer() {
|
||||
document.body.querySelector<HTMLButtonElement>('[data-slot="toggle"]')!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function openSheetFromChip() {
|
||||
await openDrawer()
|
||||
drawerChip()!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
const closingActions = [
|
||||
{
|
||||
name: 'pressing Escape',
|
||||
close: async () => {
|
||||
document.activeElement?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tapping the backdrop',
|
||||
close: async () => {
|
||||
const overlay = document.body.querySelector<HTMLElement>('[data-slot="overlay"]')!
|
||||
overlay.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
|
||||
overlay.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
|
||||
overlay.click()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'activating "Profile"',
|
||||
close: async () => {
|
||||
menuItem('Profile').click()
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
afterEach(async () => {
|
||||
mountedShell?.unmount()
|
||||
mountedShell = null
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('Mobile account sheet focus', () => {
|
||||
it.each(closingActions)(
|
||||
'returns focus to the navbar toggle when the sheet closes by $name',
|
||||
async ({ close }) => {
|
||||
await mountMobileShell()
|
||||
await openSheetFromChip()
|
||||
expect(sheet()).not.toBeNull()
|
||||
|
||||
await close()
|
||||
await flushPromises()
|
||||
|
||||
expect(sheet()).toBeNull()
|
||||
expect(document.activeElement).toBe(document.body.querySelector('[data-slot="toggle"]'))
|
||||
},
|
||||
)
|
||||
|
||||
it('logs out from the sheet without throwing', async () => {
|
||||
const unhandled = vi.fn()
|
||||
process.on('unhandledRejection', unhandled)
|
||||
await mountMobileShell()
|
||||
await openSheetFromChip()
|
||||
|
||||
menuItem('Log out').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
expect(unhandled).not.toHaveBeenCalled()
|
||||
process.off('unhandledRejection', unhandled)
|
||||
})
|
||||
|
||||
it('does not put focus on the chip or the toggle when the sheet was never opened', async () => {
|
||||
await mountMobileShell()
|
||||
|
||||
expect(document.activeElement).toBe(document.body)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import inviteEmailCases from '../../../../test/testdata/invite_emails.json'
|
||||
import InviteUserModal from '../InviteUserModal.vue'
|
||||
|
||||
const INVALID_EMAIL = 'Enter a full email address, like name@example.com.'
|
||||
|
||||
const inviteUser = vi.fn()
|
||||
|
||||
vi.mock('@/composables/useUsers', () => ({
|
||||
useUsers: () => ({ inviteUser }),
|
||||
}))
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
function emailInput() {
|
||||
return document.body.querySelector<HTMLInputElement>('input[placeholder="name@example.com"]')!
|
||||
}
|
||||
|
||||
function dialogText() {
|
||||
return document.body.querySelector('[role="dialog"]')?.textContent ?? ''
|
||||
}
|
||||
|
||||
function dispatch(element: HTMLElement, type: string) {
|
||||
element.dispatchEvent(new Event(type, { bubbles: true }))
|
||||
}
|
||||
|
||||
async function mountOpenModal() {
|
||||
wrapper = mount(InviteUserModal, {
|
||||
attachTo: document.body,
|
||||
props: { open: true },
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function clickSend() {
|
||||
const send = [...document.body.querySelectorAll('button')].find(
|
||||
(button) => button.textContent?.trim() === 'Send invite',
|
||||
)!
|
||||
send.click()
|
||||
return flushPromises()
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
inviteUser.mockReset()
|
||||
})
|
||||
|
||||
describe('Invite modal email rule', () => {
|
||||
it.each(inviteEmailCases)('gives "$email" the shared verdict (valid: $valid)', async (row) => {
|
||||
inviteUser.mockResolvedValue({ id: 1 })
|
||||
await mountOpenModal()
|
||||
|
||||
const input = emailInput()
|
||||
input.value = row.email
|
||||
dispatch(input, 'input')
|
||||
dispatch(input, 'blur')
|
||||
await flushPromises()
|
||||
await clickSend()
|
||||
|
||||
if (row.valid) {
|
||||
expect(dialogText()).not.toContain(INVALID_EMAIL)
|
||||
expect(inviteUser).toHaveBeenCalledTimes(1)
|
||||
} else {
|
||||
expect(dialogText()).toContain(INVALID_EMAIL)
|
||||
expect(inviteUser).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ interface MenuProps {
|
||||
displayName?: string
|
||||
email?: string
|
||||
collapsed: boolean
|
||||
sheetOpen?: boolean
|
||||
}
|
||||
|
||||
const baseProps: MenuProps = {
|
||||
@@ -302,3 +303,31 @@ describe('Below lg the chip hands the menu to the bottom sheet', () => {
|
||||
expect(menu()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Account menu: the chip on mobile follows the sheet', () => {
|
||||
it('reports the sheet through aria-expanded and hands its own element to open-sheet', async () => {
|
||||
setViewportWidth(MOBILE_WIDTH)
|
||||
const wrapper = await mountMenu({ sheetOpen: false })
|
||||
|
||||
expect(chip().getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
await openMenu()
|
||||
|
||||
expect(wrapper.emitted('open-sheet')).toEqual([[chip()]])
|
||||
expect(chip().getAttribute('aria-expanded')).toBe('false')
|
||||
expect(menu()).toBeNull()
|
||||
|
||||
await wrapper.setProps({ sheetOpen: true })
|
||||
expect(chip().getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
await wrapper.setProps({ sheetOpen: false })
|
||||
expect(chip().getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('does not add the sand fill while the sheet is open', async () => {
|
||||
setViewportWidth(MOBILE_WIDTH)
|
||||
await mountMenu({ sheetOpen: true })
|
||||
|
||||
expect(chip().classList.contains('bg-secondary-200')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,7 @@ import { ApiError, useApi } from '@/composables/useApi'
|
||||
import { VERIFIED_REDIRECT_DELAY_MS, wait } from '@/lib/verifiedRedirect'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
type InviteState = 'checking' | 'form' | 'invalid' | 'expired' | 'success'
|
||||
type InviteState = 'checking' | 'form' | 'invalid' | 'expired' | 'failed' | 'success'
|
||||
|
||||
const EMAIL_HAS_ACCOUNT_MESSAGE = 'This address already has an account.'
|
||||
const USERNAME_TAKEN_MESSAGE = 'That username is taken. Try another.'
|
||||
@@ -100,6 +100,7 @@ function applyDeadLinkStatus(error: unknown): boolean {
|
||||
}
|
||||
|
||||
async function loadInvite() {
|
||||
state.value = 'checking'
|
||||
try {
|
||||
const invite = await api.request<{ email: string }>(
|
||||
`/v1/auth/invite?c=${encodeURIComponent(code)}`,
|
||||
@@ -108,7 +109,9 @@ async function loadInvite() {
|
||||
inviteEmail.value = invite.email
|
||||
state.value = 'form'
|
||||
} catch (error) {
|
||||
applyDeadLinkStatus(error)
|
||||
if (!applyDeadLinkStatus(error)) {
|
||||
state.value = 'failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +206,28 @@ onMounted(() => {
|
||||
<p class="text-base text-muted">This only takes a moment.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="state === 'failed'" class="flex flex-col gap-4.5">
|
||||
<UAlert
|
||||
color="error"
|
||||
icon="i-lucide-circle-x"
|
||||
title="Couldn't check your invite"
|
||||
description="Something went wrong on the server. Try again in a moment."
|
||||
variant="soft"
|
||||
:close="false"
|
||||
role="alert"
|
||||
/>
|
||||
<UButton
|
||||
block
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:size="controlSize"
|
||||
:ui="{ base: controlClass }"
|
||||
@click="loadInvite"
|
||||
>
|
||||
Try again
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<ResultPanel
|
||||
v-else-if="state === 'invalid'"
|
||||
tone="error"
|
||||
|
||||
@@ -368,3 +368,95 @@ describe('Success', () => {
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Failed lookup', () => {
|
||||
const lookupKey = 'GET /v1/auth/invite?c=abc123'
|
||||
const alertTitle = "Couldn't check your invite"
|
||||
const alertText = 'Something went wrong on the server. Try again in a moment.'
|
||||
|
||||
function tryAgainButton(wrapper: Wrapper) {
|
||||
return wrapper.findAll('button').find((button) => button.text() === 'Try again')
|
||||
}
|
||||
|
||||
function expectFailedLookup(wrapper: Wrapper) {
|
||||
expect(wrapper.text()).toContain(alertTitle)
|
||||
expect(wrapper.text()).toContain(alertText)
|
||||
expect(tryAgainButton(wrapper)).toBeTruthy()
|
||||
expect(wrapper.text()).not.toContain('Checking your invite…')
|
||||
expect(wrapper.find('form').exists()).toBe(false)
|
||||
expect(emailInput(wrapper).exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain("This invite link doesn't work")
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ name: 'answers 500', handler: () => mockResponse(500) },
|
||||
{ name: 'answers 502', handler: () => mockResponse(502) },
|
||||
{
|
||||
name: 'fails with a network error',
|
||||
handler: () => Promise.reject(new TypeError('Failed to fetch')),
|
||||
},
|
||||
])('shows the alert and a Try again button when the lookup $name', async ({ handler }) => {
|
||||
const { wrapper } = await mountAccept('/invite/accept?c=abc123', { [lookupKey]: handler })
|
||||
|
||||
expectFailedLookup(wrapper)
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('looks the invite up again when Try again is activated', async () => {
|
||||
let calls = 0
|
||||
const { wrapper, fetchMock } = await mountAccept('/invite/accept?c=abc123', {
|
||||
[lookupKey]: () => {
|
||||
calls += 1
|
||||
return calls === 1
|
||||
? mockResponse(500)
|
||||
: new Promise<Response>((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve(
|
||||
mockResponse(200, { email: 'lea.martin@renew.example' }),
|
||||
),
|
||||
0,
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await tryAgainButton(wrapper)!.trigger('click')
|
||||
|
||||
expect(wrapper.text()).toContain('Checking your invite…')
|
||||
expect(wrapper.text()).not.toContain(alertTitle)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
await flushPromises()
|
||||
expect((emailInput(wrapper).element as HTMLInputElement).value).toBe(
|
||||
'lea.martin@renew.example',
|
||||
)
|
||||
expect(wrapper.find('h1').text()).toBe('Accept your invite')
|
||||
})
|
||||
|
||||
it('shows the alert again with an enabled button when the lookup fails again', async () => {
|
||||
const { wrapper, fetchMock } = await mountAccept('/invite/accept?c=abc123', {
|
||||
[lookupKey]: () => mockResponse(500),
|
||||
})
|
||||
|
||||
await tryAgainButton(wrapper)!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expectFailedLookup(wrapper)
|
||||
expect(tryAgainButton(wrapper)!.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ status: 404, text: "This invite link doesn't work" },
|
||||
{ status: 410, text: 'This invite has expired' },
|
||||
])('keeps the design state when the lookup answers $status', async ({ status, text }) => {
|
||||
const { wrapper } = await mountAccept('/invite/accept?c=abc123', {
|
||||
[lookupKey]: () => mockResponse(status),
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain(text)
|
||||
expect(wrapper.text()).not.toContain(alertTitle)
|
||||
expect(tryAgainButton(wrapper)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user