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,388 @@
|
||||
import { vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import App from '@/App.vue'
|
||||
import router from '@/router'
|
||||
import { ApiError, useApi, type MeAuthor, type MeResult } from '@/composables/useApi'
|
||||
import type { Article, Byline } from '@/composables/useArticles'
|
||||
import type { Author } from '@/composables/useAuthors'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import { slugify } from '@/lib/directory'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import {
|
||||
authorsFixture,
|
||||
defaultRoute,
|
||||
sitesFixture,
|
||||
usersFixture,
|
||||
type Backend,
|
||||
type Call,
|
||||
} from './directoryTestKit'
|
||||
|
||||
export {
|
||||
callsTo,
|
||||
textOf,
|
||||
button,
|
||||
click,
|
||||
typeInto,
|
||||
dialog,
|
||||
toasts,
|
||||
pending,
|
||||
} from './directoryTestKit'
|
||||
export { failWith, networkFailure } from './directoryTestKit'
|
||||
|
||||
export type MetaRole = 'owner' | 'admin' | 'verified'
|
||||
|
||||
export interface MetaBackend extends Backend {
|
||||
article: Article
|
||||
articles: Article[]
|
||||
me: MeResult
|
||||
meAuthorSlug?: string
|
||||
meSlugAdjusted?: boolean
|
||||
}
|
||||
|
||||
export const MARTA_EMAIL = 'marta@renew.example'
|
||||
const ARTICLE_ID = 12
|
||||
|
||||
export function renewSites(): Site[] {
|
||||
return sitesFixture().map((site) => ({
|
||||
...site,
|
||||
base_url: `https://${site.key === 'renew' ? 'renew' : `${site.key}.renew`}.example`,
|
||||
}))
|
||||
}
|
||||
|
||||
export function boardAuthors(): Author[] {
|
||||
const extra = (id: number, name: string): Author => ({
|
||||
id,
|
||||
name,
|
||||
slug: slugify(name, 100),
|
||||
bio: '',
|
||||
avatar_id: null,
|
||||
website: null,
|
||||
social_links: [],
|
||||
user_id: null,
|
||||
is_linked: false,
|
||||
article_count: 0,
|
||||
})
|
||||
return [...authorsFixture(), extra(3, 'Jonas Pereira'), extra(8, 'Paul Huber')]
|
||||
}
|
||||
|
||||
export function bylines(...names: string[]): Byline[] {
|
||||
const all = boardAuthors()
|
||||
return names.map((name) => {
|
||||
const found = all.find((author) => author.name === name)
|
||||
if (found) {
|
||||
return { id: found.id, name: found.name, slug: found.slug }
|
||||
}
|
||||
return { id: 100 + name.length, name, slug: slugify(name, 100) }
|
||||
})
|
||||
}
|
||||
|
||||
export function articleOf(id: number, title: string, overrides: Partial<Article> = {}): Article {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
body: '<p>Intro</p>',
|
||||
is_online: false,
|
||||
site_id: 1,
|
||||
authors: bylines('Inès Duarte'),
|
||||
created_at: '2026-03-12T09:14:00Z',
|
||||
updated_at: '2026-09-26T11:02:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function createMetaBackend(
|
||||
options: {
|
||||
sites?: Site[]
|
||||
authors?: Author[]
|
||||
role?: MetaRole
|
||||
author?: MeAuthor
|
||||
} = {},
|
||||
): MetaBackend {
|
||||
return {
|
||||
sites: options.sites ?? sitesFixture().slice(0, 1),
|
||||
authors: options.authors ?? boardAuthors(),
|
||||
users: usersFixture(),
|
||||
calls: [],
|
||||
responders: {},
|
||||
article: articleOf(ARTICLE_ID, 'How we price a bathroom renovation'),
|
||||
articles: [],
|
||||
me: {
|
||||
id: 1,
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
email: MARTA_EMAIL,
|
||||
displayName: 'Marta Alves',
|
||||
role: options.role === 'verified' ? undefined : (options.role ?? 'owner'),
|
||||
...(options.author ? { author: options.author } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function fail(status: number, code?: string): never {
|
||||
throw new ApiError(status, undefined, code)
|
||||
}
|
||||
|
||||
function toByline(backend: MetaBackend, id: number): Byline {
|
||||
const author = backend.authors.find((candidate) => candidate.id === id)
|
||||
if (!author) {
|
||||
fail(404, 'author_not_found')
|
||||
}
|
||||
return { id: author.id, name: author.name, slug: author.slug }
|
||||
}
|
||||
|
||||
function patchArticle(backend: MetaBackend, body: Record<string, unknown>) {
|
||||
if (typeof body.site_id === 'number') {
|
||||
if (!backend.sites.some((site) => site.id === body.site_id)) {
|
||||
fail(404, 'site_not_found')
|
||||
}
|
||||
backend.article.site_id = body.site_id
|
||||
}
|
||||
if (Array.isArray(body.author_ids)) {
|
||||
backend.article.authors = (body.author_ids as number[]).map((id) => toByline(backend, id))
|
||||
}
|
||||
if (typeof body.body === 'string') {
|
||||
backend.article.body = body.body
|
||||
}
|
||||
return structuredClone(backend.article)
|
||||
}
|
||||
|
||||
function listArticles(backend: MetaBackend, query: URLSearchParams) {
|
||||
const size = Number(query.get('size') ?? 25)
|
||||
const page = Number(query.get('page') ?? 1)
|
||||
const siteKey = query.get('site')
|
||||
const status = query.get('status')
|
||||
const q = (query.get('q') ?? '').toLowerCase()
|
||||
const siteId = backend.sites.find((site) => site.key === siteKey)?.id
|
||||
const matching = backend.articles
|
||||
.filter((article) => !siteKey || article.site_id === siteId)
|
||||
.filter((article) => !status || article.is_online === (status === 'online'))
|
||||
.filter((article) => !q || article.title.toLowerCase().includes(q))
|
||||
const online = backend.articles.filter((article) => article.is_online).length
|
||||
return {
|
||||
items: structuredClone(matching.slice((page - 1) * size, page * size)),
|
||||
total: matching.length,
|
||||
page,
|
||||
size,
|
||||
counts: {
|
||||
all: backend.articles.length,
|
||||
online,
|
||||
draft: backend.articles.length - online,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createMyAuthor(backend: MetaBackend) {
|
||||
if (backend.me.author) {
|
||||
fail(409, 'user_already_linked')
|
||||
}
|
||||
const name = backend.me.displayName || backend.me.username || ''
|
||||
const created = {
|
||||
id: 300,
|
||||
name,
|
||||
slug: backend.meAuthorSlug ?? slugify(name, 100),
|
||||
bio: '',
|
||||
avatar_id: null,
|
||||
website: null,
|
||||
social_links: [],
|
||||
user_id: backend.me.id ?? null,
|
||||
is_linked: true,
|
||||
article_count: 0,
|
||||
} as Author
|
||||
backend.authors.push(created)
|
||||
backend.me.author = {
|
||||
id: created.id,
|
||||
name: created.name,
|
||||
slug: created.slug,
|
||||
}
|
||||
return {
|
||||
...created,
|
||||
slug_adjusted: backend.meSlugAdjusted ?? false,
|
||||
} as Author
|
||||
}
|
||||
|
||||
function route(backend: MetaBackend, call: Call): unknown {
|
||||
const { method, path, query, body } = call
|
||||
const articleMatch = /^\/v1\/article\/(\d+)$/.exec(path)
|
||||
if (articleMatch && method === 'GET') {
|
||||
return structuredClone(backend.article)
|
||||
}
|
||||
if (articleMatch && method === 'PATCH') {
|
||||
return patchArticle(backend, body ?? {})
|
||||
}
|
||||
if (path === '/v1/article/' && method === 'GET') {
|
||||
return listArticles(backend, query)
|
||||
}
|
||||
if (path === '/v1/author/me' && method === 'POST') {
|
||||
return createMyAuthor(backend)
|
||||
}
|
||||
if (path === '/v1/author/' && method === 'POST' && body?.user_id != null) {
|
||||
const created = defaultRoute(backend, call) as Author
|
||||
backend.me.author = {
|
||||
id: created.id,
|
||||
name: created.name,
|
||||
slug: created.slug,
|
||||
}
|
||||
return created
|
||||
}
|
||||
return defaultRoute(backend, call)
|
||||
}
|
||||
|
||||
export interface MetaMountOptions {
|
||||
role?: MetaRole
|
||||
width?: number
|
||||
backend?: MetaBackend
|
||||
author?: MeAuthor
|
||||
}
|
||||
|
||||
export const metaKit: {
|
||||
wrapper: ReturnType<typeof mount> | null
|
||||
backend: MetaBackend
|
||||
} = {
|
||||
wrapper: null,
|
||||
backend: createMetaBackend(),
|
||||
}
|
||||
|
||||
export async function mountMeta(
|
||||
_view: 'editor' | 'articles' | 'profile',
|
||||
url: string,
|
||||
options: MetaMountOptions = {},
|
||||
) {
|
||||
setViewportWidth(options.width ?? DESKTOP_WIDTH)
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
setActivePinia(createPinia())
|
||||
const backend =
|
||||
options.backend ?? createMetaBackend({ role: options.role, author: options.author })
|
||||
metaKit.backend = backend
|
||||
const session = useSessionStore()
|
||||
session.session = {
|
||||
verified: true,
|
||||
username: backend.me.username,
|
||||
id: backend.me.id,
|
||||
email: backend.me.email,
|
||||
displayName: backend.me.displayName,
|
||||
role: backend.me.role,
|
||||
...(backend.me.author ? { author: backend.me.author } : {}),
|
||||
}
|
||||
session.status = 'ready'
|
||||
const request = vi.fn(async (path: string, init?: RequestInit) => {
|
||||
const [pathname = '', search = ''] = path.split('?')
|
||||
const call: Call = {
|
||||
method: init?.method ?? 'GET',
|
||||
path: pathname,
|
||||
raw: path,
|
||||
query: new URLSearchParams(search),
|
||||
body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined,
|
||||
}
|
||||
backend.calls.push(call)
|
||||
const responder =
|
||||
backend.responders[`${call.method} ${path}`] ??
|
||||
backend.responders[`${call.method} ${pathname}`]
|
||||
return responder ? responder(call) : route(backend, call)
|
||||
})
|
||||
const getMe = vi.fn(async () => {
|
||||
backend.calls.push({
|
||||
method: 'GET',
|
||||
path: '/v1/auth/me',
|
||||
raw: '/v1/auth/me',
|
||||
query: new URLSearchParams(),
|
||||
body: undefined,
|
||||
})
|
||||
return structuredClone(backend.me)
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe,
|
||||
request,
|
||||
} as unknown as ReturnType<typeof useApi>)
|
||||
await router.push(url)
|
||||
metaKit.wrapper = mount(App, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
return backend
|
||||
}
|
||||
|
||||
export function metaSection() {
|
||||
return document.body.querySelector<HTMLElement>('[data-testid="meta-section"]')
|
||||
}
|
||||
|
||||
export function allMetaSections() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-testid="meta-section"]'))
|
||||
}
|
||||
|
||||
export function authorRows(root: ParentNode = document.body) {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>('[data-testid="author-row"]'))
|
||||
}
|
||||
|
||||
export function rowNames(root: ParentNode = document.body) {
|
||||
return authorRows(root).map((row) => row.querySelector('[title]')?.getAttribute('title') ?? '')
|
||||
}
|
||||
|
||||
export function handleOf(name: string) {
|
||||
return document.body.querySelector<HTMLElement>(`button[aria-label="Reorder ${name}"]`)!
|
||||
}
|
||||
|
||||
export async function pressKey(element: HTMLElement, key: string) {
|
||||
element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export function mockRowRects(rows: HTMLElement[], rowHeight = 32) {
|
||||
rows.forEach((row, index) => {
|
||||
row.getBoundingClientRect = () =>
|
||||
({
|
||||
top: index * rowHeight,
|
||||
bottom: (index + 1) * rowHeight,
|
||||
height: rowHeight,
|
||||
left: 0,
|
||||
right: 200,
|
||||
width: 200,
|
||||
x: 0,
|
||||
y: index * rowHeight,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect
|
||||
})
|
||||
}
|
||||
|
||||
export async function pointer(element: HTMLElement, type: string, clientY: number) {
|
||||
const event = new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientY,
|
||||
button: 0,
|
||||
})
|
||||
element.dispatchEvent(event)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export async function dragTo(name: string, clientY: number) {
|
||||
const handle = handleOf(name)
|
||||
mockRowRects(authorRows())
|
||||
await pointer(handle, 'pointerdown', 0)
|
||||
await pointer(handle, 'pointermove', clientY)
|
||||
return {
|
||||
release: () => pointer(handle, 'pointerup', clientY),
|
||||
cancel: () => pointer(handle, 'pointercancel', clientY),
|
||||
}
|
||||
}
|
||||
|
||||
export function saveButtonEl() {
|
||||
return document.body.querySelector<HTMLButtonElement>('[data-testid="save-button"]')!
|
||||
}
|
||||
|
||||
export function patchCalls(backend: MetaBackend) {
|
||||
return backend.calls.filter(
|
||||
(call) => call.method === 'PATCH' && /^\/v1\/article\/\d+$/.test(call.path),
|
||||
)
|
||||
}
|
||||
|
||||
export async function unmountMeta() {
|
||||
metaKit.wrapper?.unmount()
|
||||
metaKit.wrapper = null
|
||||
vi.useRealTimers()
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
}
|
||||
Reference in new issue
Block a user