feat: sites and authors integration into existing screens (& formated all frontend files kinda accidentally)
This commit is contained in:
1 parent
3d81d2c717
commit
6b8990c55e
239 files changed
+9194
-1354
No files matched your search
@@ -0,0 +1,493 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { META_SECTION_OPEN_KEY, hintDismissedKey } from '@/lib/metaSectionMemory'
|
||||
import {
|
||||
MARTA_EMAIL,
|
||||
authorRows,
|
||||
bylines,
|
||||
callsTo,
|
||||
click,
|
||||
createMetaBackend,
|
||||
dialog,
|
||||
failWith,
|
||||
mountMeta,
|
||||
networkFailure,
|
||||
patchCalls,
|
||||
renewSites,
|
||||
rowNames,
|
||||
textOf,
|
||||
toasts,
|
||||
typeInto,
|
||||
unmountMeta,
|
||||
type MetaBackend,
|
||||
type MetaRole,
|
||||
} from './metaTestKit'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const EDITOR = '/article/12/edit'
|
||||
const WAIT_FOR_SEARCH_MS = 320
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
localStorage.setItem(META_SECTION_OPEN_KEY, 'true')
|
||||
})
|
||||
|
||||
afterEach(unmountMeta)
|
||||
|
||||
function backendWith(role: MetaRole, ...names: string[]): MetaBackend {
|
||||
const backend = createMetaBackend({ sites: renewSites().slice(0, 1), role })
|
||||
backend.article.authors = bylines(...names)
|
||||
return backend
|
||||
}
|
||||
|
||||
function trigger() {
|
||||
return Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('button[aria-haspopup="listbox"]'),
|
||||
).find((element) => textOf(element) === 'Add an author')!
|
||||
}
|
||||
|
||||
async function openCombobox() {
|
||||
trigger().click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function options() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
|
||||
}
|
||||
|
||||
function option(name: string) {
|
||||
return options().find((item) => textOf(item).includes(name))!
|
||||
}
|
||||
|
||||
function searchInput() {
|
||||
return document.body.querySelector<HTMLInputElement>('input[placeholder="Search authors"]')!
|
||||
}
|
||||
|
||||
function createRow() {
|
||||
return document.body.querySelector<HTMLElement>('[data-testid="create-author-row"]')
|
||||
}
|
||||
|
||||
async function search(term: string) {
|
||||
await typeInto(searchInput(), term)
|
||||
await new Promise((resolve) => setTimeout(resolve, WAIT_FOR_SEARCH_MS))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function authorListCalls(backend: MetaBackend) {
|
||||
return callsTo(backend, 'GET', '/v1/author/')
|
||||
}
|
||||
|
||||
describe('Add author: the combobox', () => {
|
||||
it('opens with a Search authors field, requests the first 100 authors and dims the ones already on the article', async () => {
|
||||
const backend = backendWith('owner', 'Inès Duarte')
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
await openCombobox()
|
||||
|
||||
expect(searchInput()).not.toBeNull()
|
||||
expect(authorListCalls(backend)).toHaveLength(1)
|
||||
expect(authorListCalls(backend)[0]!.query.get('size')).toBe('100')
|
||||
expect(option('Inès Duarte').className).toContain('opacity-60')
|
||||
expect(option('Camille Roche').className).not.toContain('opacity-60')
|
||||
})
|
||||
|
||||
it('does nothing when a checked author is activated, and appends a new author on pick', async () => {
|
||||
const backend = backendWith('owner', 'Inès Duarte')
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
await openCombobox()
|
||||
|
||||
option('Inès Duarte').click()
|
||||
await flushPromises()
|
||||
expect(rowNames()).toEqual(['Inès Duarte'])
|
||||
expect(textOf(document.body)).not.toContain('Unsaved changes')
|
||||
|
||||
option('Camille Roche').click()
|
||||
await flushPromises()
|
||||
expect(rowNames()).toEqual(['Inès Duarte', 'Camille Roche'])
|
||||
expect(textOf(document.body)).toContain('Unsaved changes')
|
||||
expect(patchCalls(backend)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps the section open when the first author is added to an article that had none', async () => {
|
||||
localStorage.clear()
|
||||
const backend = backendWith('owner')
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
expect(document.body.querySelector('[data-testid="meta-summary"]')).toBeNull()
|
||||
await openCombobox()
|
||||
|
||||
option('Camille Roche').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(rowNames()).toEqual(['Camille Roche'])
|
||||
expect(document.body.querySelector('[data-testid="meta-summary"]')).toBeNull()
|
||||
expect(trigger()).toBeDefined()
|
||||
expect(localStorage.getItem(META_SECTION_OPEN_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('searches the server with q when the install has more than 100 authors', async () => {
|
||||
const backend = backendWith('owner', 'Inès Duarte')
|
||||
const crowd = Array.from({ length: 120 }, (_, index) => ({
|
||||
...backend.authors[0]!,
|
||||
id: 1000 + index,
|
||||
name: `Writer ${String(index).padStart(3, '0')}`,
|
||||
slug: `writer-${index}`,
|
||||
}))
|
||||
backend.authors = [
|
||||
...crowd,
|
||||
{
|
||||
...backend.authors[0]!,
|
||||
id: 2000,
|
||||
name: 'Zoe Adams',
|
||||
slug: 'zoe-adams',
|
||||
},
|
||||
]
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
await openCombobox()
|
||||
expect(options()).toHaveLength(100)
|
||||
expect(textOf(document.body)).not.toContain('Zoe Adams')
|
||||
|
||||
await search('Zoe')
|
||||
|
||||
const last = authorListCalls(backend).at(-1)!
|
||||
expect(last.query.get('q')).toBe('Zoe')
|
||||
expect(last.query.get('size')).toBe('100')
|
||||
expect(options().map(textOf)).toEqual(['ZA Zoe Adams zoe-adams'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Add author: "Create author…" only for roles with author rights', () => {
|
||||
it.each([
|
||||
{ role: 'owner', shown: true },
|
||||
{ role: 'admin', shown: true },
|
||||
{ role: 'verified', shown: false },
|
||||
] as const)('the $role sees the row: $shown', async ({ role, shown }) => {
|
||||
await mountMeta('editor', EDITOR, {
|
||||
backend: backendWith(role, 'Inès Duarte'),
|
||||
})
|
||||
await openCombobox()
|
||||
|
||||
expect(createRow() !== null).toBe(shown)
|
||||
if (shown) {
|
||||
expect(textOf(createRow())).toBe('Create author…')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Add author: no match', () => {
|
||||
it.each([
|
||||
{ role: 'owner', create: true },
|
||||
{ role: 'verified', create: false },
|
||||
] as const)(
|
||||
'shows the message for a $role, with the create row: $create',
|
||||
async ({ role, create }) => {
|
||||
await mountMeta('editor', EDITOR, {
|
||||
backend: backendWith(role, 'Inès Duarte'),
|
||||
})
|
||||
await openCombobox()
|
||||
await search('Zoe Lambert')
|
||||
|
||||
expect(textOf(document.body)).toContain('No author matches "Zoe Lambert".')
|
||||
expect(createRow() !== null).toBe(create)
|
||||
if (create) {
|
||||
expect(textOf(createRow())).toBe('Create author "Zoe Lambert"…')
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Add author: authors could not be loaded', () => {
|
||||
it.each([
|
||||
{ failure: 'status 500', responder: failWith(500) },
|
||||
{ failure: 'a network error', responder: networkFailure },
|
||||
])('shows the error and retries when the load fails with $failure', async ({ responder }) => {
|
||||
const backend = backendWith('owner', 'Inès Duarte')
|
||||
backend.responders['GET /v1/author/'] = responder
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
await openCombobox()
|
||||
|
||||
const text = textOf(document.body)
|
||||
expect(text).toContain("Couldn't load authors")
|
||||
expect(text).toContain('Check your connection and try again.')
|
||||
expect(createRow()).toBeNull()
|
||||
expect(rowNames()).toEqual(['Inès Duarte'])
|
||||
|
||||
delete backend.responders['GET /v1/author/']
|
||||
await click('Try again')
|
||||
|
||||
expect(authorListCalls(backend)).toHaveLength(2)
|
||||
expect(options().length).toBeGreaterThan(0)
|
||||
expect(rowNames()).toEqual(['Inès Duarte'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Create author dialog', () => {
|
||||
async function openDialog(backend: MetaBackend) {
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
await openCombobox()
|
||||
await search('Zoe Lambert')
|
||||
createRow()!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function nameField() {
|
||||
return dialog()!.querySelector<HTMLInputElement>('input[placeholder="e.g. Camille Roche"]')!
|
||||
}
|
||||
|
||||
function slugField() {
|
||||
return dialog()!.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="Generated from the name"]',
|
||||
)!
|
||||
}
|
||||
|
||||
it('opens over the editor with the name, the auto slug and both actions', async () => {
|
||||
const backend = backendWith('owner', 'Inès Duarte')
|
||||
await openDialog(backend)
|
||||
|
||||
const text = textOf(dialog())
|
||||
expect(text).toContain('Create author')
|
||||
expect(text).toContain(
|
||||
'Adds a byline for this article. You can add a bio and avatar later in Authors.',
|
||||
)
|
||||
expect(nameField().value).toBe('Zoe Lambert')
|
||||
expect(slugField().value).toBe('zoe-lambert')
|
||||
expect(text).toContain('Auto')
|
||||
expect(text).toContain('Cancel')
|
||||
expect(patchCalls(backend)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('creates with name and slug, appends the author and waits for Save to send the authors', async () => {
|
||||
const backend = backendWith('owner', 'Inès Duarte')
|
||||
await openDialog(backend)
|
||||
|
||||
await click('Create author', dialog()!)
|
||||
|
||||
const posts = callsTo(backend, 'POST', '/v1/author/')
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(posts[0]!.body).toEqual({
|
||||
name: 'Zoe Lambert',
|
||||
slug: 'zoe-lambert',
|
||||
})
|
||||
expect(dialog()).toBeNull()
|
||||
expect(rowNames()).toEqual(['Inès Duarte', 'Zoe Lambert'])
|
||||
expect(textOf(document.body)).toContain('Unsaved changes')
|
||||
expect(patchCalls(backend)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('offers "This is me" only without an author profile, unchecked, and sends user_id when checked', async () => {
|
||||
const backend = backendWith('owner', 'Inès Duarte')
|
||||
await openDialog(backend)
|
||||
|
||||
const checkbox = dialog()!.querySelector<HTMLElement>('button[role="checkbox"]')!
|
||||
expect(textOf(dialog())).toContain('This is me')
|
||||
expect(textOf(dialog())).toContain(`Links the author to ${MARTA_EMAIL}.`)
|
||||
expect(checkbox.getAttribute('aria-checked')).toBe('false')
|
||||
|
||||
checkbox.click()
|
||||
await flushPromises()
|
||||
await click('Create author', dialog()!)
|
||||
|
||||
expect(callsTo(backend, 'POST', '/v1/author/')[0]!.body).toEqual({
|
||||
name: 'Zoe Lambert',
|
||||
slug: 'zoe-lambert',
|
||||
user_id: 1,
|
||||
})
|
||||
expect(useSessionStore().session?.author).toMatchObject({
|
||||
name: 'Zoe Lambert',
|
||||
})
|
||||
})
|
||||
|
||||
it('has no "This is me" and never sends a user_id when the user already has an author', async () => {
|
||||
const backend = createMetaBackend({
|
||||
sites: renewSites().slice(0, 1),
|
||||
author: { id: 2, name: 'Inès Duarte', slug: 'ines-duarte' },
|
||||
})
|
||||
await openDialog(backend)
|
||||
|
||||
expect(textOf(dialog())).not.toContain('This is me')
|
||||
await click('Create author', dialog()!)
|
||||
expect(callsTo(backend, 'POST', '/v1/author/')[0]!.body).not.toHaveProperty('user_id')
|
||||
})
|
||||
|
||||
it('shows the taken slug with a suggestion that keeps the name', async () => {
|
||||
const backend = backendWith('owner', 'Inès Duarte')
|
||||
backend.responders['POST /v1/author/'] = failWith(409, 'author_slug_taken')
|
||||
await openDialog(backend)
|
||||
await click('Create author', dialog()!)
|
||||
|
||||
expect(textOf(dialog())).toContain(
|
||||
'The slug "zoe-lambert" is already used by another author.',
|
||||
)
|
||||
await click('Use zoe-lambert-2', dialog()!)
|
||||
|
||||
expect(slugField().value).toBe('zoe-lambert-2')
|
||||
expect(nameField().value).toBe('Zoe Lambert')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ failure: 'status 500', responder: failWith(500) },
|
||||
{ failure: 'a network error', responder: networkFailure },
|
||||
])(
|
||||
'keeps the dialog and the values open when creating fails with $failure',
|
||||
async ({ responder }) => {
|
||||
const backend = backendWith('owner', 'Inès Duarte')
|
||||
backend.responders['POST /v1/author/'] = responder
|
||||
await openDialog(backend)
|
||||
await click('Create author', dialog()!)
|
||||
|
||||
expect(textOf(dialog())).toContain("Couldn't create the author")
|
||||
expect(textOf(dialog())).toContain('The article is unchanged. Try again in a moment.')
|
||||
expect(nameField().value).toBe('Zoe Lambert')
|
||||
expect(rowNames()).toEqual(['Inès Duarte'])
|
||||
expect(patchCalls(backend)).toHaveLength(0)
|
||||
},
|
||||
)
|
||||
|
||||
it('shows the rights toast and closes when the role changed while the dialog was open', async () => {
|
||||
const backend = backendWith('owner', 'Inès Duarte')
|
||||
backend.responders['POST /v1/author/'] = failWith(403, 'author_rights')
|
||||
await openDialog(backend)
|
||||
await click('Create author', dialog()!)
|
||||
|
||||
expect(toasts()).toContain('Only an owner or admin can change authors')
|
||||
expect(dialog()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Create my author profile hint', () => {
|
||||
function hint() {
|
||||
return document.body.querySelector<HTMLElement>('[data-testid="author-hint"]') ?? undefined
|
||||
}
|
||||
|
||||
it.each(['owner', 'admin', 'verified'] as const)(
|
||||
'appears for a %s with no profile, no authors on the article and no dismissal',
|
||||
async (role) => {
|
||||
await mountMeta('editor', EDITOR, { backend: backendWith(role) })
|
||||
|
||||
expect(textOf(hint())).toContain('Create one to be credited on your articles.')
|
||||
expect(textOf(hint())).toContain('Create my author profile')
|
||||
expect(textOf(document.body)).toContain('No authors. This article will have no byline.')
|
||||
},
|
||||
)
|
||||
|
||||
it('stays away when the user already has an author profile', async () => {
|
||||
const backend = createMetaBackend({
|
||||
sites: renewSites().slice(0, 1),
|
||||
author: { id: 2, name: 'Inès Duarte', slug: 'ines-duarte' },
|
||||
})
|
||||
backend.article.authors = []
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
|
||||
expect(hint()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stays away when the article has an author', async () => {
|
||||
await mountMeta('editor', EDITOR, {
|
||||
backend: backendWith('owner', 'Inès Duarte'),
|
||||
})
|
||||
|
||||
expect(hint()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stays away once this user dismissed it', async () => {
|
||||
localStorage.setItem(hintDismissedKey(1), 'true')
|
||||
await mountMeta('editor', EDITOR, { backend: backendWith('owner') })
|
||||
|
||||
expect(hint()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('creates the profile directly: one POST with no body, no dialog, the author first and a toast', async () => {
|
||||
const backend = backendWith('verified', 'Inès Duarte')
|
||||
backend.article.authors = []
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
|
||||
await click('Create my author profile')
|
||||
|
||||
const posts = callsTo(backend, 'POST', '/v1/author/me')
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(posts[0]!.body).toBeUndefined()
|
||||
expect(dialog()).toBeNull()
|
||||
expect(rowNames()).toEqual(['Marta Alves'])
|
||||
expect(textOf(document.body)).toContain('Unsaved changes')
|
||||
expect(hint()).toBeUndefined()
|
||||
expect(useSessionStore().session?.author).toMatchObject({
|
||||
name: 'Marta Alves',
|
||||
})
|
||||
expect(toasts()).toContain('Author profile created')
|
||||
expect(toasts()).toContain('Marta Alves is now your byline.')
|
||||
expect(patchCalls(backend)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('puts the new author before the others as the primary byline', async () => {
|
||||
const backend = backendWith('owner')
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
await click('Create my author profile')
|
||||
await openCombobox()
|
||||
option('Camille Roche').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(rowNames()).toEqual(['Marta Alves', 'Camille Roche'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ failure: 'status 500', responder: failWith(500) },
|
||||
{ failure: 'a network error', responder: networkFailure },
|
||||
])(
|
||||
'keeps the hint and shows an error toast when it fails with $failure',
|
||||
async ({ responder }) => {
|
||||
const backend = backendWith('owner')
|
||||
backend.responders['POST /v1/author/me'] = responder
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
await click('Create my author profile')
|
||||
|
||||
expect(textOf(hint())).toContain('Create my author profile')
|
||||
expect(toasts()).toContain("Couldn't create your author profile")
|
||||
expect(toasts()).toContain('Try again in a moment.')
|
||||
expect(rowNames()).toEqual([])
|
||||
expect(textOf(document.body)).not.toContain('Unsaved changes')
|
||||
},
|
||||
)
|
||||
|
||||
it('reloads the session and drops the hint when the profile already exists (409)', async () => {
|
||||
const backend = backendWith('owner')
|
||||
const conflict = failWith(409, 'user_already_linked')
|
||||
backend.responders['POST /v1/author/me'] = (call) => {
|
||||
backend.me.author = {
|
||||
id: 300,
|
||||
name: 'Marta Alves',
|
||||
slug: 'marta-alves',
|
||||
}
|
||||
return conflict(call)
|
||||
}
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
await click('Create my author profile')
|
||||
|
||||
expect(callsTo(backend, 'GET', '/v1/auth/me')).toHaveLength(1)
|
||||
expect(hint()).toBeUndefined()
|
||||
expect(useSessionStore().session?.author).toMatchObject({ id: 300 })
|
||||
expect(rowNames()).toEqual([])
|
||||
})
|
||||
|
||||
it('is dismissed without a request, kept per user in localStorage, and does not return', async () => {
|
||||
const backend = backendWith('owner')
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
const before = backend.calls.length
|
||||
|
||||
document.body.querySelector<HTMLElement>('button[aria-label="Dismiss"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(hint()).toBeUndefined()
|
||||
expect(localStorage.getItem(hintDismissedKey(1))).toBe('true')
|
||||
expect(backend.calls).toHaveLength(before)
|
||||
await unmountMeta()
|
||||
|
||||
const other = backendWith('owner')
|
||||
await mountMeta('editor', '/article/13/edit', { backend: other })
|
||||
expect(hint()).toBeUndefined()
|
||||
expect(authorRows()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user