feat: UsersTable and UserCardList get Email, Display name (dash when empty), Role (new RoleBadge), Invite status and Created

This commit is contained in:
fsylvain committed 2026-09-30 02:00:46 +02:00
1 parent 153fd8719a
commit 1ece778caf
19 files changed
+485 -204

No files matched your search

+146 -30
View File
@@ -45,6 +45,7 @@ function defaultFixture(): UserListItem[] {
kind: 'invite',
id: 1,
email: 'p.huber@gmail.com',
display_name: 'Paul Huber',
status: 'expired',
created_at: '2026-08-03T09:00:00Z',
},
@@ -52,6 +53,8 @@ function defaultFixture(): UserListItem[] {
kind: 'user',
id: 3,
username: 'ines',
email: 'ines.duarte@renew.example',
role: 'admin',
display_name: 'Inès Duarte',
status: 'accepted',
created_at: '2026-06-18T09:00:00Z',
@@ -60,6 +63,8 @@ function defaultFixture(): UserListItem[] {
kind: 'user',
id: 2,
username: 'jonas',
email: 'jonas@renew.example',
role: 'admin',
display_name: '',
status: 'accepted',
created_at: '2026-04-02T09:00:00Z',
@@ -68,6 +73,8 @@ function defaultFixture(): UserListItem[] {
kind: 'user',
id: 1,
username: 'marta',
email: 'marta@renew.example',
role: 'owner',
display_name: '',
status: 'accepted',
created_at: '2026-03-12T09:00:00Z',
@@ -119,11 +126,21 @@ interface Handlers {
invite?: () => Promise<unknown>
}
async function mountPage({ list, invite }: Handlers = {}, desktop = true) {
async function mountPage(
{ list, invite }: Handlers = {},
desktop = true,
role: string | undefined = 'owner',
) {
setViewport(desktop)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', id: 1 }
session.session = {
verified: true,
username: 'marta',
id: 1,
displayName: '',
role: role ?? undefined,
}
session.status = 'ready'
requestMock = vi.fn((path: string) => {
if (path === LIST_PATH) {
@@ -135,7 +152,12 @@ async function mountPage({ list, invite }: Handlers = {}, desktop = true) {
throw new Error(`Unexpected request: ${path}`)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
getMe: vi.fn().mockResolvedValue({
verified: true,
username: 'marta',
id: 1,
role: role ?? undefined,
}),
request: requestMock,
})
await router.push('/users')
@@ -257,25 +279,70 @@ describe('Default — the list loads newest first, with display name, "you" tag
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',
'sam.oliveira@renew.example — admin Pending 27 Sep 2026',
'p.huber@gmail.com Paul Huber admin Expired 3 Aug 2026',
'ines.duarte@renew.example Inès Duarte admin Accepted 18 Jun 2026',
'jonas@renew.example — admin Accepted 2 Apr 2026',
'marta@renew.example you — owner Accepted 12 Mar 2026',
])
expect(rowTexts().join(' ')).not.toMatch(/(ines|jonas|marta)(?!@)/)
expect(rows().filter((row) => /\byou\b/.test(textOf(row)))).toHaveLength(1)
expect(document.body.textContent).toContain('Invite status')
const header = document.body.querySelector(
'[data-testid="user-row"]',
)!.previousElementSibling!
expect(textOf(header)).toBe('Email Display name Role Invite status Created')
expect(header.children).toHaveLength(6)
expect(document.body.querySelectorAll('[data-testid="user-row"]')).toHaveLength(5)
})
it('styles the owner badge filled, the admin badge outlined, dashes muted and the you tag on marta only', async () => {
await mountPage()
const badge = (row: Element, role: string) =>
Array.from(row.querySelectorAll('span')).findLast(
(el) => el.textContent?.trim() === role,
)!
const [sam, huber, , jonas, marta] = rows()
expect(badge(marta, 'owner').className).toContain('bg-primary')
expect(badge(marta, 'owner').className).not.toContain('bg-transparent')
expect(badge(jonas, 'admin').className).toContain('bg-transparent')
expect(badge(jonas, 'admin').className).not.toContain('bg-primary')
expect(badge(jonas, '—').className).toContain('text-muted')
expect(badge(sam, '—').className).toContain('text-muted')
expect(badge(huber, 'Paul Huber').className).toContain('text-highlighted')
expect(badge(huber, 'Paul Huber').className).not.toContain('text-muted')
expect(badge(marta, 'you')).toBeTruthy()
expect(rows().filter((row) => badge(row, 'you'))).toEqual([marta])
})
it('styles the same badges and hides the display-name line when empty on cards below md', async () => {
await mountPage({}, false)
const spans = (row: Element) => Array.from(row.querySelectorAll('span'))
const [, huber, , jonas, marta] = rows()
const find = (row: Element, text: string) =>
spans(row).findLast((el) => el.textContent?.trim() === text)
expect(find(marta, 'owner')!.className).toContain('bg-primary')
expect(find(jonas, 'admin')!.className).toContain('bg-transparent')
expect(find(jonas, '—')).toBeUndefined()
expect(find(huber, 'Paul Huber')).toBeTruthy()
expect(find(marta, 'you')).toBeTruthy()
expect(find(jonas, 'you')).toBeUndefined()
})
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',
'sam.oliveira@renew.example admin Pending Created 27 Sep 2026',
'p.huber@gmail.com Paul Huber admin Expired Created 3 Aug 2026',
'ines.duarte@renew.example Inès Duarte admin Accepted Created 18 Jun 2026',
'jonas@renew.example admin Accepted Created 2 Apr 2026',
'marta@renew.example you owner Accepted Created 12 Mar 2026',
])
expect(document.body.querySelector('[data-testid="user-row"]')).toBeNull()
})
@@ -289,7 +356,7 @@ describe('Default — the list loads newest first, with display name, "you" tag
})
describe('Display names in the Users list', () => {
it('shows each display name with its username beside it and the chip with the session name', async () => {
it('shows each saved name, a dash for empty ones, the role and the chip with the session name', async () => {
const fixture = defaultFixture().map((item) =>
item.kind === 'user' && item.username === 'marta'
? { ...item, display_name: 'Marta Alves' }
@@ -301,13 +368,14 @@ describe('Display names in the Users list', () => {
username: 'marta',
id: 1,
displayName: 'Marta Alves',
role: 'owner',
}
await flushPromises()
expect(rowTexts().slice(2)).toEqual([
'Inès Duarte ines Accepted 18 Jun 2026',
'jonas Accepted 2 Apr 2026',
'Marta Alves marta you Accepted 12 Mar 2026',
'ines.duarte@renew.example Inès Duarte admin Accepted 18 Jun 2026',
'jonas@renew.example — admin Accepted 2 Apr 2026',
'marta@renew.example you Marta Alves owner Accepted 12 Mar 2026',
])
const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
expect(footer.textContent).toContain('Marta Alves')
@@ -336,13 +404,31 @@ describe('Loading — skeleton rows after 150 ms', () => {
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
expect(rows()).toHaveLength(5)
})
it('draws role and status placeholders on the desktop table skeleton only', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountPage({ list: () => deferred<UserListItem[]>().promise })
vi.advanceTimersByTime(150)
await flushPromises()
const tableBars = document.body.querySelectorAll('[aria-busy="true"] .animate-pulse')
expect(tableBars).toHaveLength(25)
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
await mountPage({ list: () => deferred<UserListItem[]>().promise }, false)
vi.advanceTimersByTime(150)
await flushPromises()
const cardBars = document.body.querySelectorAll('[aria-busy="true"] .animate-pulse')
expect(cardBars).toHaveLength(10)
})
})
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(rowTexts()).toEqual(['marta@renew.example you — owner Accepted 12 Mar 2026'])
expect(document.body.textContent).toContain(
'Invite someone so they can sign in with their own account instead of sharing yours.',
)
@@ -350,6 +436,36 @@ describe('Only you — nobody has been invited yet', () => {
})
})
describe('Non-owners cannot invite', () => {
it.each([
{ name: 'admin on desktop', role: 'admin', desktop: true },
{ name: 'admin below md', role: 'admin', desktop: false },
{ name: 'a missing role on desktop', role: null, desktop: true },
{ name: 'an unknown role below md', role: 'guest', desktop: false },
])('renders the list but no Invite user for $name', async ({ role, desktop }) => {
await mountPage({}, desktop, role)
expect(findButton('Invite user')).toBeUndefined()
expect(rows()).toHaveLength(5)
})
it('hides the Only-you hint for an admin', async () => {
await mountPage({ list: () => Promise.resolve(onlyYouFixture()) }, true, 'admin')
expect(rows()).toHaveLength(1)
expect(document.body.textContent).not.toContain('Invite someone so they can sign in')
expect(findButton('Invite user')).toBeUndefined()
})
it.each([true, false])('shows no hint for an admin on marta and jonas alone (desktop %s)', async (desktop) => {
await mountPage({ list: () => Promise.resolve(defaultFixture().slice(3)) }, desktop, 'admin')
expect(rows()).toHaveLength(2)
expect(document.body.textContent).not.toContain('Invite someone so they can sign in')
expect(findButton('Invite user')).toBeUndefined()
})
})
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)) },
@@ -480,13 +596,13 @@ describe('Invite sent — a 201 closes the modal, adds a Pending row on top and
expect(listCalls()).toHaveLength(1)
const texts = rowTexts()
expect(texts).toHaveLength(6)
expect(texts[0]).toContain('lea.martin@renew.example Pending')
expect(texts[0]).toContain('lea.martin@renew.example — admin Pending Today')
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',
'sam.oliveira@renew.example — admin Pending 27 Sep 2026',
'p.huber@gmail.com Paul Huber admin Expired 3 Aug 2026',
'ines.duarte@renew.example Inès Duarte admin Accepted 18 Jun 2026',
'jonas@renew.example — admin Accepted 2 Apr 2026',
'marta@renew.example you — owner Accepted 12 Mar 2026',
])
expect(rows()[0].className).toContain('bg-elevated')
expect(rows()[1].className).not.toContain('bg-elevated')
@@ -516,7 +632,7 @@ describe('Invite sent to an address whose invite had expired replaces the old ro
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(rowTexts()[0]).toContain('p.huber@gmail.com — admin Pending')
expect(rows()).toHaveLength(5)
})
})
@@ -529,8 +645,8 @@ describe('Invite sent into the Only-you state keeps a coherent list', () => {
const texts = rowTexts()
expect(texts).toHaveLength(2)
expect(texts[0]).toContain('lea.martin@renew.example Pending')
expect(texts[1]).toContain('marta you Accepted')
expect(texts[0]).toContain('lea.martin@renew.example — admin Pending')
expect(texts[1]).toContain('marta@renew.example you')
expect(document.body.textContent).not.toContain('Invite someone so they can sign in')
})
})
@@ -603,7 +719,7 @@ describe('Invite: rate limited — a 429 shows a dialog alert and keeps the addr
await clickButton('Send invite')
expect(dialog()).toBeNull()
expect(rowTexts()[0]).toContain('lea.martin@renew.example Pending')
expect(rowTexts()[0]).toContain('lea.martin@renew.example — admin Pending')
expect(toastAdd).toHaveBeenCalledTimes(1)
})
})
@@ -644,7 +760,7 @@ describe('Retrying after a failure succeeds', () => {
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(rowTexts()[0]).toContain('lea.martin@renew.example — admin Pending')
expect(toastAdd).toHaveBeenCalledTimes(1)
})
})