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
@@ -36,6 +36,7 @@ const navItems = computed(() =>
|
||||
[
|
||||
{ label: 'Home', to: '/home', icon: 'i-lucide-home' },
|
||||
{ label: 'Articles', to: '/article', icon: 'i-lucide-file-text' },
|
||||
{ label: 'Users', to: '/users', icon: 'i-lucide-users' },
|
||||
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
|
||||
].map((item) => ({
|
||||
...item,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
status: 'accepted' | 'pending' | 'expired'
|
||||
size?: 'sm' | 'md'
|
||||
}>(),
|
||||
{
|
||||
size: 'md',
|
||||
},
|
||||
)
|
||||
|
||||
const badges = {
|
||||
accepted: {
|
||||
label: 'Accepted',
|
||||
icon: 'i-lucide-circle-check',
|
||||
color: 'success',
|
||||
class: 'border border-(--rc-success-soft) bg-(--rc-success-soft) text-success',
|
||||
},
|
||||
pending: {
|
||||
label: 'Pending',
|
||||
icon: 'i-lucide-mail',
|
||||
color: 'warning',
|
||||
class: 'border border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning',
|
||||
},
|
||||
expired: {
|
||||
label: 'Expired',
|
||||
icon: 'i-lucide-clock-alert',
|
||||
color: 'neutral',
|
||||
class: 'border border-accented bg-transparent text-muted',
|
||||
},
|
||||
} as const
|
||||
|
||||
const badge = computed(() => badges[props.status])
|
||||
const sizeClass = computed(() => (props.size === 'sm' ? 'h-5 px-1.5' : 'h-5.5 px-1.75'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UBadge
|
||||
:color="badge.color"
|
||||
variant="soft"
|
||||
:icon="badge.icon"
|
||||
:class="[badge.class, sizeClass]"
|
||||
:ui="{ base: 'gap-1 py-0 text-xs', leadingIcon: 'size-3' }"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</UBadge>
|
||||
</template>
|
||||
@@ -0,0 +1,233 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useUsers, type CreatedInvite } from '@/composables/useUsers'
|
||||
|
||||
const EMAIL_PLACEHOLDER = 'name@example.com'
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/
|
||||
const INVALID_EMAIL_MESSAGE = 'Enter a full email address, like name@example.com.'
|
||||
const HAS_ACCOUNT_MESSAGE = 'This address already has an account.'
|
||||
const PENDING_INVITE_MESSAGE = "There's already a pending invite for this address."
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const emit = defineEmits<{ invited: [invite: CreatedInvite] }>()
|
||||
|
||||
const { inviteUser } = useUsers()
|
||||
|
||||
const email = ref('')
|
||||
const touched = ref(false)
|
||||
const focused = ref(false)
|
||||
const submitting = ref(false)
|
||||
const serverError = ref('')
|
||||
const formAlert = ref<'' | 'invalid' | 'rateLimited' | 'failed'>('')
|
||||
|
||||
const emailValid = computed(() => EMAIL_PATTERN.test(email.value))
|
||||
const emailError = computed(
|
||||
() => serverError.value || (touched.value && !emailValid.value ? INVALID_EMAIL_MESSAGE : ''),
|
||||
)
|
||||
|
||||
const alertMessage = computed(() => {
|
||||
if (formAlert.value === 'invalid') {
|
||||
return "Something in the form isn't valid. Check the highlighted fields."
|
||||
}
|
||||
if (formAlert.value === 'rateLimited') {
|
||||
return 'Too many invites. Wait a moment, then try again.'
|
||||
}
|
||||
if (formAlert.value === 'failed') {
|
||||
return "Couldn't send the invite. Try again in a moment."
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
email.value = ''
|
||||
touched.value = false
|
||||
focused.value = false
|
||||
submitting.value = false
|
||||
serverError.value = ''
|
||||
formAlert.value = ''
|
||||
}
|
||||
|
||||
function emailInput() {
|
||||
return document.querySelector<HTMLInputElement>(`input[placeholder="${EMAIL_PLACEHOLDER}"]`)
|
||||
}
|
||||
|
||||
async function focusEmailField() {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
emailInput()?.focus()
|
||||
focused.value = true
|
||||
}
|
||||
|
||||
watch(
|
||||
open,
|
||||
(isOpen) => {
|
||||
if (!isOpen) {
|
||||
return
|
||||
}
|
||||
resetForm()
|
||||
focusEmailField()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(email, () => {
|
||||
serverError.value = ''
|
||||
})
|
||||
|
||||
function onBlur() {
|
||||
touched.value = true
|
||||
focused.value = false
|
||||
}
|
||||
|
||||
function messageForConflict(error: ApiError) {
|
||||
return error.body === PENDING_INVITE_MESSAGE ? PENDING_INVITE_MESSAGE : HAS_ACCOUNT_MESSAGE
|
||||
}
|
||||
|
||||
function showFailure(error: unknown) {
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
serverError.value = messageForConflict(error)
|
||||
return
|
||||
}
|
||||
if (error instanceof ApiError && error.status === 400) {
|
||||
serverError.value = INVALID_EMAIL_MESSAGE
|
||||
formAlert.value = 'invalid'
|
||||
return
|
||||
}
|
||||
formAlert.value = error instanceof ApiError && error.status === 429 ? 'rateLimited' : 'failed'
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (submitting.value) {
|
||||
return
|
||||
}
|
||||
touched.value = true
|
||||
focused.value = false
|
||||
|
||||
if (!emailValid.value) {
|
||||
emailInput()?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
serverError.value = ''
|
||||
formAlert.value = ''
|
||||
submitting.value = true
|
||||
try {
|
||||
const invite = await inviteUser(email.value)
|
||||
open.value = false
|
||||
emit('invited', invite)
|
||||
} catch (error) {
|
||||
showFailure(error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen && submitting.value) {
|
||||
return
|
||||
}
|
||||
open.value = isOpen
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
if (submitting.value) {
|
||||
return
|
||||
}
|
||||
resetForm()
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
:open="open"
|
||||
title="Invite a user"
|
||||
description="We'll email them a link to choose a username and password. The link expires after 3 days."
|
||||
:dismissible="!submitting"
|
||||
:close="{ color: 'neutral', variant: 'ghost', class: 'text-muted' }"
|
||||
@update:open="onOpenChange"
|
||||
:ui="{
|
||||
content:
|
||||
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-115 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
|
||||
header: 'items-start gap-3 px-6 pt-5 pb-0 sm:px-6',
|
||||
wrapper: 'pe-10',
|
||||
close: 'top-5 end-6 size-7 justify-center p-0',
|
||||
title: 'text-lg/6 font-semibold',
|
||||
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
||||
footer: 'px-6 py-5 sm:px-6',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4">
|
||||
<UAlert
|
||||
v-if="alertMessage"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
:ui="{ root: 'gap-2 px-3 py-2.5' }"
|
||||
:description="alertMessage"
|
||||
/>
|
||||
<UForm :state="{ email }" class="flex flex-col gap-4" @submit="onSubmit">
|
||||
<UFormField
|
||||
label="Email"
|
||||
name="email"
|
||||
:error="emailError || undefined"
|
||||
:ui="{ error: 'mt-1.5 text-xs/5' }"
|
||||
>
|
||||
<template v-if="emailError" #error="{ error }">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<UIcon
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
</span>
|
||||
</template>
|
||||
<UInput
|
||||
v-model="email"
|
||||
type="email"
|
||||
:placeholder="EMAIL_PLACEHOLDER"
|
||||
autocomplete="off"
|
||||
:disabled="submitting"
|
||||
color="primary"
|
||||
:highlight="focused || !!emailError"
|
||||
:ui="{ base: 'h-11 text-lg md:h-10 md:text-base' }"
|
||||
class="w-full"
|
||||
@focus="focused = true"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
</UFormField>
|
||||
</UForm>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
block
|
||||
:ui="{ base: 'h-11 md:h-8' }"
|
||||
class="sm:w-auto"
|
||||
:disabled="submitting"
|
||||
@click="onCancel"
|
||||
>
|
||||
Cancel
|
||||
</UButton>
|
||||
<UButton
|
||||
block
|
||||
:ui="{ base: 'h-11 md:h-8' }"
|
||||
class="sm:w-auto"
|
||||
:loading="submitting"
|
||||
:disabled="submitting"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{ submitting ? 'Sending…' : 'Send invite' }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
|
||||
import UserNameCell from '@/components/UserNameCell.vue'
|
||||
import { formatCreatedDate } from '@/lib/relativeDate'
|
||||
import { USER_SKELETON_ROWS, userRowKey, type UserListItem } from '@/composables/useUsers'
|
||||
|
||||
defineProps<{
|
||||
items: UserListItem[]
|
||||
loading: boolean
|
||||
sessionUserId?: number
|
||||
highlightedKey?: string
|
||||
}>()
|
||||
|
||||
function rowKey(item: UserListItem) {
|
||||
return userRowKey(item.kind, item.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="-mx-4 border-y border-default bg-default" :aria-busy="loading ? 'true' : undefined">
|
||||
<template v-if="loading">
|
||||
<li
|
||||
v-for="n in USER_SKELETON_ROWS"
|
||||
:key="n"
|
||||
class="flex min-h-17 flex-col justify-center gap-1.5 border-b border-default px-4 py-3"
|
||||
>
|
||||
<USkeleton class="h-3.5 w-[55%] rounded-none bg-accented" />
|
||||
<USkeleton class="h-3 w-[40%] rounded-none bg-accented" />
|
||||
</li>
|
||||
</template>
|
||||
<template v-else>
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="rowKey(item)"
|
||||
data-testid="user-card"
|
||||
class="flex min-h-17 flex-col justify-center gap-1.5 border-b border-default px-4 py-3"
|
||||
:class="{ 'bg-elevated': rowKey(item) === highlightedKey }"
|
||||
>
|
||||
<UserNameCell
|
||||
:item="item"
|
||||
:is-you="item.kind === 'user' && item.id === sessionUserId"
|
||||
large
|
||||
/>
|
||||
<div class="flex items-center gap-2.5 text-xs text-muted">
|
||||
<InviteStatusBadge :status="item.status" size="sm" />
|
||||
<span>Created {{ formatCreatedDate(item.created_at) }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { UserListItem } from '@/composables/useUsers'
|
||||
|
||||
const props = defineProps<{
|
||||
item: UserListItem
|
||||
isYou: boolean
|
||||
large?: boolean
|
||||
}>()
|
||||
|
||||
const primary = computed(() => {
|
||||
if (props.item.kind === 'invite') {
|
||||
return props.item.email
|
||||
}
|
||||
return props.item.display_name || props.item.username
|
||||
})
|
||||
|
||||
const secondary = computed(() =>
|
||||
props.item.kind === 'user' && props.item.display_name ? props.item.username : '',
|
||||
)
|
||||
|
||||
const primaryClass = computed(() => [
|
||||
props.item.kind === 'invite' ? 'text-toned' : 'font-medium text-highlighted',
|
||||
props.large ? 'text-[0.9375rem]/[21px]' : '',
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<span class="truncate" :class="primaryClass">{{ primary }}</span>
|
||||
<span v-if="secondary" class="shrink-0 text-muted">{{ secondary }}</span>
|
||||
<span v-if="isYou" class="shrink-0 font-mono text-[0.6875rem] font-medium text-muted"
|
||||
>you</span
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
|
||||
import UserNameCell from '@/components/UserNameCell.vue'
|
||||
import { formatCreatedDate } from '@/lib/relativeDate'
|
||||
import { USER_SKELETON_ROWS, userRowKey, type UserListItem } from '@/composables/useUsers'
|
||||
|
||||
const ROW_GRID = 'grid grid-cols-[minmax(0,1fr)_160px_160px] items-center px-3'
|
||||
|
||||
defineProps<{
|
||||
items: UserListItem[]
|
||||
loading: boolean
|
||||
sessionUserId?: number
|
||||
highlightedKey?: string
|
||||
}>()
|
||||
|
||||
function rowKey(item: UserListItem) {
|
||||
return userRowKey(item.kind, item.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border border-default bg-default" :aria-busy="loading ? 'true' : undefined">
|
||||
<div :class="ROW_GRID" class="h-9 bg-elevated font-medium text-muted">
|
||||
<span>Username</span>
|
||||
<span>Invite status</span>
|
||||
<span class="flex items-center gap-1 text-highlighted">
|
||||
Created
|
||||
<UIcon name="i-lucide-arrow-down" class="size-3 shrink-0" />
|
||||
</span>
|
||||
</div>
|
||||
<template v-if="loading">
|
||||
<span role="status" class="sr-only">Loading users</span>
|
||||
<div
|
||||
v-for="n in USER_SKELETON_ROWS"
|
||||
:key="n"
|
||||
:class="ROW_GRID"
|
||||
class="h-11 border-t border-default"
|
||||
>
|
||||
<USkeleton class="h-3 w-[30%] rounded-none bg-accented" />
|
||||
<USkeleton class="h-5 w-19 rounded-none bg-accented" />
|
||||
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="rowKey(item)"
|
||||
data-testid="user-row"
|
||||
:class="[ROW_GRID, { 'bg-elevated': rowKey(item) === highlightedKey }]"
|
||||
class="h-11 border-t border-default"
|
||||
>
|
||||
<UserNameCell
|
||||
:item="item"
|
||||
:is-you="item.kind === 'user' && item.id === sessionUserId"
|
||||
class="pr-3"
|
||||
/>
|
||||
<span><InviteStatusBadge :status="item.status" /></span>
|
||||
<span class="text-muted">{{ formatCreatedDate(item.created_at) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -36,29 +36,39 @@ afterEach(async () => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('DashboardShell renders the three navigation destinations', () => {
|
||||
it('lists Home, Articles and Integration, with only Articles carrying aria-current', async () => {
|
||||
describe('DashboardShell renders the four navigation destinations', () => {
|
||||
it('lists Home, Articles, Users and Integration in order, with only Articles carrying aria-current', async () => {
|
||||
const wrapper = await mountShell('/article')
|
||||
|
||||
const hrefs = wrapper
|
||||
.findAll('nav[aria-label="Main"] a')
|
||||
.map((link) => link.attributes('href'))
|
||||
expect(hrefs).toEqual(['/home', '/article', '/users', '/integration'])
|
||||
|
||||
const home = wrapper.find('a[href="/home"]')
|
||||
const articles = wrapper.find('a[href="/article"]')
|
||||
const users = wrapper.find('a[href="/users"]')
|
||||
const integration = wrapper.find('a[href="/integration"]')
|
||||
|
||||
expect(home.exists()).toBe(true)
|
||||
expect(articles.exists()).toBe(true)
|
||||
expect(integration.exists()).toBe(true)
|
||||
|
||||
expect(articles.attributes('aria-current')).toBe('page')
|
||||
expect(home.attributes('aria-current')).toBeUndefined()
|
||||
expect(users.attributes('aria-current')).toBeUndefined()
|
||||
expect(integration.attributes('aria-current')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('marks Users with aria-current on /users', async () => {
|
||||
const wrapper = await mountShell('/users')
|
||||
|
||||
const users = wrapper.find('a[href="/users"]')
|
||||
expect(users.text()).toContain('Users')
|
||||
expect(users.attributes('aria-current')).toBe('page')
|
||||
expect(wrapper.find('a[href="/article"]').attributes('aria-current')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DashboardShell shows the offline banner when the browser reports offline', () => {
|
||||
it('shows the banner while offline and hides it once back online', async () => {
|
||||
const onLineSpy = vi
|
||||
.spyOn(navigator, 'onLine', 'get')
|
||||
.mockReturnValue(false)
|
||||
const onLineSpy = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
|
||||
|
||||
const wrapper = await mountShell('/article')
|
||||
|
||||
@@ -79,16 +89,12 @@ describe('The sidebar header renders AppLogo and the "RenewCMS" wordmark', () =>
|
||||
const wrapper = await mountShell('/home')
|
||||
|
||||
const header = wrapper.find('[data-slot="header"]')
|
||||
expect(
|
||||
header.find('svg[role="img"][aria-label="RenewCMS"]').exists(),
|
||||
).toBe(true)
|
||||
expect(header.find('svg[role="img"][aria-label="RenewCMS"]').exists()).toBe(true)
|
||||
expect(header.text()).toContain('RenewCMS')
|
||||
expect(header.find('a').exists()).toBe(false)
|
||||
|
||||
const headerHtml = header.html()
|
||||
expect(headerHtml.indexOf('<svg')).toBeLessThan(
|
||||
headerHtml.indexOf('RenewCMS</span>'),
|
||||
)
|
||||
expect(headerHtml.indexOf('<svg')).toBeLessThan(headerHtml.indexOf('RenewCMS</span>'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -97,9 +103,7 @@ describe('The sidebar collapse trigger toggles the rail and exposes aria-expande
|
||||
await mountShell('/home')
|
||||
|
||||
const collapseButton = () =>
|
||||
document.body.querySelector(
|
||||
'[data-testid="sidebar-collapse"]',
|
||||
) as HTMLButtonElement
|
||||
document.body.querySelector('[data-testid="sidebar-collapse"]') as HTMLButtonElement
|
||||
|
||||
expect(collapseButton().getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
@@ -120,13 +124,11 @@ describe('A successful logout clears the session and navigates to /login', () =>
|
||||
await mountShell('/home')
|
||||
|
||||
const logOutItem = () =>
|
||||
Array.from(
|
||||
document.body.querySelectorAll('[role="menuitem"]'),
|
||||
).find((el) => el.textContent?.trim() === 'Log out') as HTMLElement
|
||||
Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
|
||||
(el) => el.textContent?.trim() === 'Log out',
|
||||
) as HTMLElement
|
||||
|
||||
const trigger = document.body.querySelector(
|
||||
'button[aria-haspopup]',
|
||||
) as HTMLButtonElement
|
||||
const trigger = document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement
|
||||
trigger.click()
|
||||
await flushPromises()
|
||||
|
||||
@@ -151,28 +153,16 @@ describe('Collapsed sidebar hides the wordmark and username text but keeps an ac
|
||||
collapseButton.click()
|
||||
await flushPromises()
|
||||
|
||||
const header = document.body.querySelector(
|
||||
'[data-slot="header"]',
|
||||
) as HTMLElement
|
||||
expect(
|
||||
header.querySelector('svg[role="img"][aria-label="RenewCMS"]'),
|
||||
).toBeTruthy()
|
||||
const header = document.body.querySelector('[data-slot="header"]') as HTMLElement
|
||||
expect(header.querySelector('svg[role="img"][aria-label="RenewCMS"]')).toBeTruthy()
|
||||
expect(header.textContent).not.toContain('RenewCMS')
|
||||
expect(header.querySelector('span[aria-hidden="true"]')).toBeNull()
|
||||
|
||||
const footer = document.body.querySelector(
|
||||
'[data-slot="footer"]',
|
||||
) as HTMLElement
|
||||
const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
|
||||
expect(footer.textContent).not.toContain('marta')
|
||||
|
||||
const userMenuTrigger = footer.querySelector(
|
||||
'button[aria-haspopup]',
|
||||
) as HTMLButtonElement
|
||||
expect(userMenuTrigger.getAttribute('aria-label')).toBe(
|
||||
'marta — account menu',
|
||||
)
|
||||
expect(
|
||||
footer.querySelector('[data-slot="fallback"]')?.textContent,
|
||||
).toBe('MA')
|
||||
const userMenuTrigger = footer.querySelector('button[aria-haspopup]') as HTMLButtonElement
|
||||
expect(userMenuTrigger.getAttribute('aria-label')).toBe('marta — account menu')
|
||||
expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user