feat(SPA): users page and invite modal
This commit is contained in:
1 parent
acdef18b37
commit
6afffc1984
20 files changed
+1880
-55
No files matched your search
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { useToast } from '@nuxt/ui/composables'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import InviteUserModal from '@/components/InviteUserModal.vue'
|
||||
import UserCardList from '@/components/UserCardList.vue'
|
||||
import UsersTable from '@/components/UsersTable.vue'
|
||||
import { useUsers, userRowKey, type CreatedInvite, type UserListItem } from '@/composables/useUsers'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
|
||||
type LoadState = 'loading' | 'ready' | 'error'
|
||||
|
||||
const toast = useToast()
|
||||
const session = useSessionStore()
|
||||
const { listUsers } = useUsers()
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
|
||||
const loadState = ref<LoadState>('loading')
|
||||
const items = ref<UserListItem[]>([])
|
||||
const showSkeleton = ref(false)
|
||||
const inviteModalOpen = ref(false)
|
||||
const highlightedKey = ref('')
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let opener: HTMLElement | null = null
|
||||
|
||||
const sessionUserId = computed(() => session.session?.id)
|
||||
const showList = computed(
|
||||
() => loadState.value === 'ready' || (loadState.value === 'loading' && showSkeleton.value),
|
||||
)
|
||||
const showOnlyYouHint = computed(() => loadState.value === 'ready' && items.value.length === 1)
|
||||
|
||||
function clearSkeletonTimer() {
|
||||
if (skeletonTimer !== null) {
|
||||
clearTimeout(skeletonTimer)
|
||||
skeletonTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
loadState.value = 'loading'
|
||||
showSkeleton.value = false
|
||||
clearSkeletonTimer()
|
||||
skeletonTimer = setTimeout(() => {
|
||||
showSkeleton.value = true
|
||||
}, SKELETON_DELAY_MS)
|
||||
|
||||
try {
|
||||
items.value = await listUsers()
|
||||
highlightedKey.value = ''
|
||||
loadState.value = 'ready'
|
||||
} catch {
|
||||
loadState.value = 'error'
|
||||
} finally {
|
||||
clearSkeletonTimer()
|
||||
showSkeleton.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openInviteModal() {
|
||||
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
inviteModalOpen.value = true
|
||||
}
|
||||
|
||||
async function restoreFocus() {
|
||||
await nextTick()
|
||||
opener?.focus()
|
||||
opener = null
|
||||
}
|
||||
|
||||
function onInviteModalOpenChange(isOpen: boolean) {
|
||||
inviteModalOpen.value = isOpen
|
||||
if (!isOpen) {
|
||||
restoreFocus()
|
||||
}
|
||||
}
|
||||
|
||||
function onInvited(invite: CreatedInvite) {
|
||||
const sameAddress = invite.email.toLowerCase()
|
||||
const newRow: UserListItem = {
|
||||
kind: 'invite',
|
||||
id: invite.id,
|
||||
email: invite.email,
|
||||
status: 'pending',
|
||||
created_at: invite.created_at,
|
||||
}
|
||||
items.value = [
|
||||
newRow,
|
||||
...items.value.filter(
|
||||
(item) => !(item.kind === 'invite' && item.email.toLowerCase() === sameAddress),
|
||||
),
|
||||
]
|
||||
highlightedKey.value = userRowKey('invite', invite.id)
|
||||
toast.add({
|
||||
id: `invite-sent-${invite.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Invite sent',
|
||||
description: `${invite.email} has 3 days to accept.`,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
onBeforeUnmount(clearSkeletonTimer)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
|
||||
<h1 class="text-xl font-semibold text-highlighted">Users</h1>
|
||||
<p class="text-muted">Everyone who can sign in and work on your articles.</p>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="isDesktop"
|
||||
icon="i-lucide-user-plus"
|
||||
class="h-8 px-3"
|
||||
@click="openInviteModal"
|
||||
>
|
||||
Invite user
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="loadState === 'error'"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
title="Couldn't load users"
|
||||
description="Check your connection and try again."
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="loadUsers"
|
||||
>
|
||||
Try again
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<template v-if="showList">
|
||||
<UsersTable
|
||||
v-if="isDesktop"
|
||||
:items="items"
|
||||
:loading="loadState === 'loading'"
|
||||
:session-user-id="sessionUserId"
|
||||
:highlighted-key="highlightedKey"
|
||||
/>
|
||||
<UserCardList
|
||||
v-else
|
||||
:items="items"
|
||||
:loading="loadState === 'loading'"
|
||||
:session-user-id="sessionUserId"
|
||||
:highlighted-key="highlightedKey"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<p v-if="showOnlyYouHint" class="text-pretty text-muted">
|
||||
Invite someone so they can sign in with their own account instead of sharing yours.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
v-if="!isDesktop"
|
||||
v-show="!inviteModalOpen"
|
||||
icon="i-lucide-user-plus"
|
||||
class="fixed inset-x-4 bottom-4 h-12 justify-center text-[0.9375rem] shadow-(--shadow-popover)"
|
||||
:ui="{ base: 'gap-2', leadingIcon: 'size-4.5' }"
|
||||
@click="openInviteModal"
|
||||
>
|
||||
Invite user
|
||||
</UButton>
|
||||
|
||||
<InviteUserModal
|
||||
:open="inviteModalOpen"
|
||||
@update:open="onInviteModalOpenChange"
|
||||
@invited="onInvited"
|
||||
/>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -0,0 +1,641 @@
|
||||
import { afterEach, beforeEach, 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 { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||||
import type { CreatedInvite, UserListItem } from '@/composables/useUsers'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import UsersView from '../UsersView.vue'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return {
|
||||
...actual,
|
||||
useApi: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
let toastAdd: ReturnType<typeof vi.fn>
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return {
|
||||
...actual,
|
||||
useToast: () => ({ add: toastAdd }),
|
||||
}
|
||||
})
|
||||
|
||||
const LIST_PATH = '/v1/user/'
|
||||
const INVITE_PATH = '/v1/user/invite'
|
||||
const INVALID_EMAIL = 'Enter a full email address, like name@example.com.'
|
||||
const FAILED_ALERT = "Couldn't send the invite. Try again in a moment."
|
||||
const VALID_EMAIL = 'lea.martin@renew.example'
|
||||
|
||||
function defaultFixture(): UserListItem[] {
|
||||
return [
|
||||
{
|
||||
kind: 'invite',
|
||||
id: 2,
|
||||
email: 'sam.oliveira@renew.example',
|
||||
status: 'pending',
|
||||
created_at: '2026-09-27T09:00:00Z',
|
||||
},
|
||||
{
|
||||
kind: 'invite',
|
||||
id: 1,
|
||||
email: 'p.huber@gmail.com',
|
||||
status: 'expired',
|
||||
created_at: '2026-08-03T09:00:00Z',
|
||||
},
|
||||
{
|
||||
kind: 'user',
|
||||
id: 3,
|
||||
username: 'ines',
|
||||
display_name: 'Inès Duarte',
|
||||
status: 'accepted',
|
||||
created_at: '2026-06-18T09:00:00Z',
|
||||
},
|
||||
{
|
||||
kind: 'user',
|
||||
id: 2,
|
||||
username: 'jonas',
|
||||
display_name: '',
|
||||
status: 'accepted',
|
||||
created_at: '2026-04-02T09:00:00Z',
|
||||
},
|
||||
{
|
||||
kind: 'user',
|
||||
id: 1,
|
||||
username: 'marta',
|
||||
display_name: '',
|
||||
status: 'accepted',
|
||||
created_at: '2026-03-12T09:00:00Z',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function onlyYouFixture(): UserListItem[] {
|
||||
return defaultFixture().slice(4)
|
||||
}
|
||||
|
||||
function inviteFixture(email = VALID_EMAIL): CreatedInvite {
|
||||
return {
|
||||
id: 3,
|
||||
kind: 'invite',
|
||||
email,
|
||||
status: 'pending',
|
||||
created_at: new Date().toISOString(),
|
||||
expires_at: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function setViewport(desktop: boolean) {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: desktop,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
})) as unknown as typeof window.matchMedia
|
||||
}
|
||||
|
||||
let requestMock: ReturnType<typeof vi.fn>
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
interface Handlers {
|
||||
list?: () => Promise<unknown>
|
||||
invite?: () => Promise<unknown>
|
||||
}
|
||||
|
||||
async function mountPage({ list, invite }: Handlers = {}, desktop = true) {
|
||||
setViewport(desktop)
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', id: 1 }
|
||||
session.status = 'ready'
|
||||
requestMock = vi.fn((path: string) => {
|
||||
if (path === LIST_PATH) {
|
||||
return (list ?? (() => Promise.resolve(defaultFixture())))()
|
||||
}
|
||||
if (path === INVITE_PATH) {
|
||||
return (invite ?? (() => Promise.resolve(inviteFixture())))()
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
|
||||
request: requestMock,
|
||||
})
|
||||
await router.push('/users')
|
||||
wrapper = mount(UsersView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function rows() {
|
||||
return Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>(
|
||||
'[data-testid="user-row"], [data-testid="user-card"]',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function textOf(element: Element) {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
|
||||
const parts: string[] = []
|
||||
while (walker.nextNode()) {
|
||||
const text = walker.currentNode.textContent?.trim()
|
||||
if (text) {
|
||||
parts.push(text)
|
||||
}
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
function rowTexts() {
|
||||
return rows().map(textOf)
|
||||
}
|
||||
|
||||
function findButton(text: string) {
|
||||
return Array.from(document.body.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === text,
|
||||
)
|
||||
}
|
||||
|
||||
async function clickButton(text: string) {
|
||||
findButton(text)?.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function emailInput() {
|
||||
return document.body.querySelector<HTMLInputElement>('input[placeholder="name@example.com"]')!
|
||||
}
|
||||
|
||||
function dialog() {
|
||||
return document.body.querySelector('[role="dialog"]')
|
||||
}
|
||||
|
||||
function dispatch(element: HTMLElement, type: string) {
|
||||
element.dispatchEvent(new Event(type, { bubbles: true }))
|
||||
}
|
||||
|
||||
async function typeEmail(value: string) {
|
||||
const input = emailInput()
|
||||
input.value = value
|
||||
dispatch(input, 'input')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function blurEmail() {
|
||||
dispatch(emailInput(), 'blur')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function openModal() {
|
||||
const opener = findButton('Invite user')!
|
||||
opener.focus()
|
||||
opener.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function submitWith(value: string) {
|
||||
await typeEmail(value)
|
||||
await clickButton('Send invite')
|
||||
}
|
||||
|
||||
function inviteCalls() {
|
||||
return requestMock.mock.calls.filter(([path]) => path === INVITE_PATH)
|
||||
}
|
||||
|
||||
function listCalls() {
|
||||
return requestMock.mock.calls.filter(([path]) => path === LIST_PATH)
|
||||
}
|
||||
|
||||
function alertText() {
|
||||
return document.body.querySelector('[role="alert"]')?.textContent?.trim() ?? ''
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toastAdd = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
vi.useRealTimers()
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('Default — the list loads newest first, with display name, "you" tag and status badges', () => {
|
||||
it('shows the header, the rows in fixture order and the badges on desktop', async () => {
|
||||
await mountPage()
|
||||
|
||||
const breadcrumb = document.body.querySelector(
|
||||
'[aria-label="breadcrumb"], nav:not([aria-label="Main"])',
|
||||
)
|
||||
expect(breadcrumb?.textContent).toContain('Users')
|
||||
expect(document.body.querySelector('h1')?.textContent).toBe('Users')
|
||||
expect(document.body.textContent).toContain(
|
||||
'Everyone who can sign in and work on your articles.',
|
||||
)
|
||||
expect(findButton('Invite user')).toBeTruthy()
|
||||
|
||||
expect(rowTexts()).toEqual([
|
||||
'sam.oliveira@renew.example Pending 27 Sep 2026',
|
||||
'p.huber@gmail.com Expired 3 Aug 2026',
|
||||
'Inès Duarte ines Accepted 18 Jun 2026',
|
||||
'jonas Accepted 2 Apr 2026',
|
||||
'marta you Accepted 12 Mar 2026',
|
||||
])
|
||||
expect(rows().filter((row) => /\byou\b/.test(textOf(row)))).toHaveLength(1)
|
||||
expect(document.body.textContent).toContain('Invite status')
|
||||
})
|
||||
|
||||
it('renders the same rows as cards below md', async () => {
|
||||
await mountPage({}, false)
|
||||
|
||||
expect(rowTexts()).toEqual([
|
||||
'sam.oliveira@renew.example Pending Created 27 Sep 2026',
|
||||
'p.huber@gmail.com Expired Created 3 Aug 2026',
|
||||
'Inès Duarte ines Accepted Created 18 Jun 2026',
|
||||
'jonas Accepted Created 2 Apr 2026',
|
||||
'marta you Accepted Created 12 Mar 2026',
|
||||
])
|
||||
expect(document.body.querySelector('[data-testid="user-row"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('gives the Users link aria-current on /users', async () => {
|
||||
await mountPage()
|
||||
|
||||
const link = document.body.querySelector('nav[aria-label="Main"] a[href="/users"]')
|
||||
expect(link?.getAttribute('aria-current')).toBe('page')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading — skeleton rows after 150 ms', () => {
|
||||
it('draws nothing until 150 ms, then five skeleton rows, then the rows', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const pending = deferred<UserListItem[]>()
|
||||
await mountPage({ list: () => pending.promise })
|
||||
|
||||
vi.advanceTimersByTime(149)
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
|
||||
expect(document.body.querySelector('[data-slot="base"].animate-pulse')).toBeNull()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
await flushPromises()
|
||||
const busy = document.body.querySelector('[aria-busy="true"]')!
|
||||
expect(busy.querySelectorAll('.animate-pulse').length).toBeGreaterThanOrEqual(5)
|
||||
expect(rows()).toHaveLength(0)
|
||||
|
||||
pending.resolve(defaultFixture())
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
|
||||
expect(rows()).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Only you — nobody has been invited yet', () => {
|
||||
it('shows the one row, the hint and Invite user', async () => {
|
||||
await mountPage({ list: () => Promise.resolve(onlyYouFixture()) })
|
||||
|
||||
expect(rowTexts()).toEqual(['marta you Accepted 12 Mar 2026'])
|
||||
expect(document.body.textContent).toContain(
|
||||
'Invite someone so they can sign in with their own account instead of sharing yours.',
|
||||
)
|
||||
expect(findButton('Invite user')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error — a 500 or a network failure replaces the list with an alert and a retry', () => {
|
||||
it.each([
|
||||
{ name: 'a 500 response', failure: () => Promise.reject(new ApiError(500)) },
|
||||
{ name: 'a network failure', failure: () => Promise.reject(new NetworkError()) },
|
||||
])('shows the alert and recovers after Try again on $name', async ({ failure }) => {
|
||||
await mountPage({ list: failure })
|
||||
|
||||
expect(alertText()).toContain("Couldn't load users")
|
||||
expect(alertText()).toContain('Check your connection and try again.')
|
||||
expect(findButton('Try again')).toBeTruthy()
|
||||
expect(document.body.querySelector('[role="alert"] button[aria-label="Close"]')).toBeNull()
|
||||
expect(rows()).toHaveLength(0)
|
||||
expect(findButton('Invite user')).toBeTruthy()
|
||||
|
||||
requestMock.mockImplementation((path: string) =>
|
||||
path === LIST_PATH
|
||||
? Promise.resolve(defaultFixture())
|
||||
: Promise.reject(new Error(path)),
|
||||
)
|
||||
await clickButton('Try again')
|
||||
|
||||
expect(listCalls()).toHaveLength(2)
|
||||
expect(rows()).toHaveLength(5)
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Invite: default — opening the modal', () => {
|
||||
it('shows the copy, an empty focused Email and both buttons, and sends nothing', async () => {
|
||||
await mountPage()
|
||||
await openModal()
|
||||
|
||||
expect(dialog()?.textContent).toContain('Invite a user')
|
||||
expect(dialog()?.textContent).toContain(
|
||||
"We'll email them a link to choose a username and password. The link expires after 3 days.",
|
||||
)
|
||||
expect(emailInput().value).toBe('')
|
||||
expect(document.activeElement).toBe(emailInput())
|
||||
expect(findButton('Cancel')).toBeTruthy()
|
||||
expect(findButton('Send invite')).toBeTruthy()
|
||||
expect(inviteCalls()).toHaveLength(0)
|
||||
expect(rows()).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('hides the pinned button below md while the modal is open', async () => {
|
||||
await mountPage({}, false)
|
||||
|
||||
const pinned = findButton('Invite user')!
|
||||
expect(pinned.style.display).not.toBe('none')
|
||||
|
||||
await openModal()
|
||||
expect(pinned.style.display).toBe('none')
|
||||
|
||||
await clickButton('Cancel')
|
||||
expect(pinned.style.display).not.toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Invite: validation — an invalid address is refused before any request', () => {
|
||||
it.each([
|
||||
{ value: 'sam.oliveira@renew', trigger: 'blur' },
|
||||
{ value: 'not-an-email', trigger: 'submit' },
|
||||
{ value: '', trigger: 'submit' },
|
||||
])('refuses "$value" on $trigger', async ({ value, trigger }) => {
|
||||
await mountPage()
|
||||
await openModal()
|
||||
|
||||
await typeEmail(value)
|
||||
if (trigger === 'blur') {
|
||||
await blurEmail()
|
||||
} else {
|
||||
await clickButton('Send invite')
|
||||
expect(document.activeElement).toBe(emailInput())
|
||||
}
|
||||
|
||||
expect(dialog()?.textContent).toContain(INVALID_EMAIL)
|
||||
expect(inviteCalls()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('clears the error as soon as the address is fixed', async () => {
|
||||
await mountPage()
|
||||
await openModal()
|
||||
await typeEmail('sam.oliveira@renew')
|
||||
await blurEmail()
|
||||
expect(dialog()?.textContent).toContain(INVALID_EMAIL)
|
||||
|
||||
await typeEmail('sam.oliveira@renew.example')
|
||||
|
||||
expect(dialog()?.textContent).not.toContain(INVALID_EMAIL)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Invite: sending — the form is locked and cannot be dismissed', () => {
|
||||
it('sends the JSON body, disables the form and ignores Esc and the backdrop', async () => {
|
||||
const pending = deferred<CreatedInvite>()
|
||||
await mountPage({ invite: () => pending.promise })
|
||||
await openModal()
|
||||
await submitWith(VALID_EMAIL)
|
||||
|
||||
expect(inviteCalls()).toHaveLength(1)
|
||||
const [, options] = inviteCalls()[0]
|
||||
expect(options.method).toBe('POST')
|
||||
expect(JSON.parse(options.body)).toEqual({ email: VALID_EMAIL })
|
||||
expect(emailInput().disabled).toBe(true)
|
||||
expect(findButton('Cancel')!.disabled).toBe(true)
|
||||
expect(findButton('Sending…')).toBeTruthy()
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(dialog()).toBeTruthy()
|
||||
|
||||
document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(dialog()).toBeTruthy()
|
||||
|
||||
pending.resolve(inviteFixture())
|
||||
await flushPromises()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Invite sent — a 201 closes the modal, adds a Pending row on top and raises a toast', () => {
|
||||
it('closes, prepends the highlighted row without a refetch and toasts', async () => {
|
||||
await mountPage()
|
||||
await openModal()
|
||||
await submitWith(VALID_EMAIL)
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(listCalls()).toHaveLength(1)
|
||||
const texts = rowTexts()
|
||||
expect(texts).toHaveLength(6)
|
||||
expect(texts[0]).toContain('lea.martin@renew.example Pending')
|
||||
expect(texts.slice(1)).toEqual([
|
||||
'sam.oliveira@renew.example Pending 27 Sep 2026',
|
||||
'p.huber@gmail.com Expired 3 Aug 2026',
|
||||
'Inès Duarte ines Accepted 18 Jun 2026',
|
||||
'jonas Accepted 2 Apr 2026',
|
||||
'marta you Accepted 12 Mar 2026',
|
||||
])
|
||||
expect(rows()[0].className).toContain('bg-elevated')
|
||||
expect(rows()[1].className).not.toContain('bg-elevated')
|
||||
expect(toastAdd).toHaveBeenCalledWith({
|
||||
id: 'invite-sent-3',
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Invite sent',
|
||||
description: 'lea.martin@renew.example has 3 days to accept.',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns focus to Invite user', async () => {
|
||||
await mountPage()
|
||||
await openModal()
|
||||
await submitWith(VALID_EMAIL)
|
||||
|
||||
expect(document.activeElement).toBe(findButton('Invite user'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Invite sent to an address whose invite had expired replaces the old row', () => {
|
||||
it('keeps exactly one Pending row for the address, first', async () => {
|
||||
await mountPage({ invite: () => Promise.resolve(inviteFixture('p.huber@gmail.com')) })
|
||||
await openModal()
|
||||
await submitWith('p.huber@gmail.com')
|
||||
|
||||
const matching = rowTexts().filter((text) => text.includes('p.huber@gmail.com'))
|
||||
expect(matching).toHaveLength(1)
|
||||
expect(rowTexts()[0]).toContain('p.huber@gmail.com Pending')
|
||||
expect(rows()).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Invite sent into the Only-you state keeps a coherent list', () => {
|
||||
it('puts the new row above marta and hides the hint', async () => {
|
||||
await mountPage({ list: () => Promise.resolve(onlyYouFixture()) })
|
||||
await openModal()
|
||||
await submitWith(VALID_EMAIL)
|
||||
|
||||
const texts = rowTexts()
|
||||
expect(texts).toHaveLength(2)
|
||||
expect(texts[0]).toContain('lea.martin@renew.example Pending')
|
||||
expect(texts[1]).toContain('marta you Accepted')
|
||||
expect(document.body.textContent).not.toContain('Invite someone so they can sign in')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Invite: already a user (409) — the field error depends on the response text', () => {
|
||||
it.each([
|
||||
{
|
||||
email: 'jonas@renew.example',
|
||||
body: 'This address already has an account.',
|
||||
error: 'This address already has an account.',
|
||||
},
|
||||
{
|
||||
email: 'sam.oliveira@renew.example',
|
||||
body: "There's already a pending invite for this address.",
|
||||
error: "There's already a pending invite for this address.",
|
||||
},
|
||||
{
|
||||
email: 'jonas@renew.example',
|
||||
body: 'Something else',
|
||||
error: 'This address already has an account.',
|
||||
},
|
||||
])('shows "$error" for the body "$body"', async ({ email, body, error }) => {
|
||||
await mountPage({ invite: () => Promise.reject(new ApiError(409, undefined, body)) })
|
||||
await openModal()
|
||||
await submitWith(email)
|
||||
|
||||
expect(dialog()).toBeTruthy()
|
||||
expect(emailInput().value).toBe(email)
|
||||
expect(emailInput().disabled).toBe(false)
|
||||
expect(dialog()?.textContent).toContain(error)
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("A 400 that got past this form's own check", () => {
|
||||
it('keeps the address and shows the alert and the field error', async () => {
|
||||
await mountPage({ invite: () => Promise.reject(new ApiError(400)) })
|
||||
await openModal()
|
||||
await submitWith(VALID_EMAIL)
|
||||
|
||||
expect(emailInput().value).toBe(VALID_EMAIL)
|
||||
expect(emailInput().disabled).toBe(false)
|
||||
expect(alertText()).toContain(
|
||||
"Something in the form isn't valid. Check the highlighted fields.",
|
||||
)
|
||||
expect(dialog()?.textContent).toContain(INVALID_EMAIL)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Invite: rate limited — a 429 shows a dialog alert and keeps the address', () => {
|
||||
it('shows the fixed alert, no field error, then succeeds on retry', async () => {
|
||||
const outcomes = [
|
||||
() =>
|
||||
Promise.reject(
|
||||
new ApiError(429, undefined, 'Too many requests. Please try again later.'),
|
||||
),
|
||||
() => Promise.resolve(inviteFixture()),
|
||||
]
|
||||
await mountPage({ invite: () => outcomes.shift()!() })
|
||||
await openModal()
|
||||
await submitWith(VALID_EMAIL)
|
||||
|
||||
expect(alertText()).toBe('Too many invites. Wait a moment, then try again.')
|
||||
expect(dialog()?.textContent).not.toContain('Too many requests')
|
||||
expect(dialog()?.textContent).not.toContain(INVALID_EMAIL)
|
||||
expect(emailInput().value).toBe(VALID_EMAIL)
|
||||
expect(emailInput().disabled).toBe(false)
|
||||
expect(rows()).toHaveLength(5)
|
||||
|
||||
await clickButton('Send invite')
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(rowTexts()[0]).toContain('lea.martin@renew.example Pending')
|
||||
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Invite: failed — a 500 or a network failure keeps the address and offers a retry', () => {
|
||||
it.each([
|
||||
{ name: 'a 500 response', failure: () => Promise.reject(new ApiError(500)) },
|
||||
{
|
||||
name: 'a 500 with the API text',
|
||||
failure: () => Promise.reject(new ApiError(500, undefined, FAILED_ALERT)),
|
||||
},
|
||||
{ name: 'a network failure', failure: () => Promise.reject(new NetworkError()) },
|
||||
])('shows the alert once on $name', async ({ failure }) => {
|
||||
await mountPage({ invite: failure })
|
||||
await openModal()
|
||||
await submitWith(VALID_EMAIL)
|
||||
|
||||
expect(alertText()).toBe(FAILED_ALERT)
|
||||
expect(document.body.querySelectorAll('[role="alert"]')).toHaveLength(1)
|
||||
expect(emailInput().value).toBe(VALID_EMAIL)
|
||||
expect(emailInput().disabled).toBe(false)
|
||||
expect(dialog()?.textContent).not.toContain(INVALID_EMAIL)
|
||||
expect(rows()).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Retrying after a failure succeeds', () => {
|
||||
it('sends the same body again and completes', async () => {
|
||||
const outcomes = [
|
||||
() => Promise.reject(new ApiError(500)),
|
||||
() => Promise.resolve(inviteFixture()),
|
||||
]
|
||||
await mountPage({ invite: () => outcomes.shift()!() })
|
||||
await openModal()
|
||||
await submitWith(VALID_EMAIL)
|
||||
await clickButton('Send invite')
|
||||
|
||||
expect(inviteCalls()).toHaveLength(2)
|
||||
expect(inviteCalls()[1][1].body).toBe(inviteCalls()[0][1].body)
|
||||
expect(dialog()).toBeNull()
|
||||
expect(rowTexts()[0]).toContain('lea.martin@renew.example Pending')
|
||||
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cancel discards the address with no confirmation', () => {
|
||||
it('closes at once, sends nothing, restores focus and reopens empty', async () => {
|
||||
await mountPage()
|
||||
await openModal()
|
||||
await typeEmail(VALID_EMAIL)
|
||||
await clickButton('Cancel')
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(inviteCalls()).toHaveLength(0)
|
||||
expect(document.activeElement).toBe(findButton('Invite user'))
|
||||
|
||||
await openModal()
|
||||
expect(emailInput().value).toBe('')
|
||||
expect(dialog()?.textContent).not.toContain(INVALID_EMAIL)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user