feat(SPA): screens for Sites and Authors

This commit is contained in:
Florian Sylvain committed 2026-10-14 23:00:50 +02:00
1 parent b34a173d83
commit 3d81d2c717
82 files changed
+10676 -37

No files matched your search

@@ -0,0 +1,219 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { ApiError } from '@/composables/useApi'
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
import { useDirectoryCollection } from '@/composables/useDirectoryCollection'
import { useDirectoryDelete } from '@/composables/useDirectoryDelete'
import { useDirectoryView } from '@/composables/useDirectoryView'
import { useSlugField } from '@/composables/useSlugField'
import router from '@/router'
let wrapper: VueWrapper | null = null
function inComponent<T>(setup: () => T) {
let result!: T
wrapper = mount(
defineComponent({
setup() {
result = setup()
return () => h('div')
},
}),
{ global: { plugins: [router] } },
)
return result
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((done, fail) => {
resolve = done
reject = fail
})
return { promise, resolve, reject }
}
afterEach(() => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
})
describe('useDelayedSkeleton', () => {
it('shows only after the delay and hides on stop', async () => {
vi.useFakeTimers()
const skeleton = inComponent(() => useDelayedSkeleton(150))
skeleton.start()
await vi.advanceTimersByTimeAsync(149)
expect(skeleton.visible.value).toBe(false)
await vi.advanceTimersByTimeAsync(1)
expect(skeleton.visible.value).toBe(true)
skeleton.stop()
expect(skeleton.visible.value).toBe(false)
})
})
describe('useDirectoryCollection', () => {
it('loads, keeps the data on a later load and ignores a stale answer', async () => {
const first = deferred<string>()
const second = deferred<string>()
const answers = [first, second]
const collection = inComponent(() => useDirectoryCollection(() => answers.shift()!.promise))
const early = collection.load([])
const late = collection.load([])
second.resolve('new')
first.resolve('old')
expect(await late).toBe('ready')
expect(await early).toBe('stale')
expect(collection.data.value).toBe('new')
expect(collection.state.value).toBe('ready')
})
it('can refuse a result without committing it', async () => {
const collection = inComponent(() => useDirectoryCollection(async () => 'page'))
expect(await collection.load([], { accept: () => false })).toBe('rejected')
expect(collection.data.value).toBeNull()
})
it('maps a 403 to forbidden, other failures to error and ignores a 401', async () => {
const failures = [new ApiError(403), new ApiError(500), new ApiError(401)]
const collection = inComponent(() =>
useDirectoryCollection(async () => {
throw failures.shift()
}),
)
await collection.load([])
expect(collection.state.value).toBe('forbidden')
await collection.load([])
expect(collection.state.value).toBe('error')
expect(await collection.load([])).toBe('stale')
expect(collection.state.value).toBe('loading')
})
it('draws the skeleton only after the delay on the first load', async () => {
vi.useFakeTimers()
const hold = deferred<string>()
const collection = inComponent(() => useDirectoryCollection(() => hold.promise))
const loading = collection.load([])
expect(collection.showSkeleton.value).toBe(false)
await vi.advanceTimersByTimeAsync(150)
expect(collection.showSkeleton.value).toBe(true)
hold.resolve('done')
await loading
expect(collection.showSkeleton.value).toBe(false)
})
it('drops an answer that arrives after the component is gone', async () => {
const hold = deferred<string>()
const collection = inComponent(() => useDirectoryCollection(() => hold.promise))
const loading = collection.load([])
wrapper!.unmount()
wrapper = null
hold.resolve('late')
expect(await loading).toBe('stale')
expect(collection.data.value).toBeNull()
})
})
describe('useDirectoryDelete', () => {
it('runs once, clears the notice and reports failures to the handler', async () => {
const removal = useDirectoryDelete<{ id: number }>()
removal.open({ id: 1 })
removal.notice.value = { description: 'old' }
const run = vi.fn().mockRejectedValue(new Error('no'))
const onFailure = vi.fn()
const first = removal.confirm(run, onFailure)
await removal.confirm(run, onFailure)
await first
expect(run).toHaveBeenCalledTimes(1)
expect(onFailure).toHaveBeenCalledWith(expect.any(Error), { id: 1 })
expect(removal.pending.value).toBe(false)
expect(removal.notice.value).toBeNull()
})
it('does nothing without a target and closes cleanly', async () => {
const removal = useDirectoryDelete<{ id: number }>()
const run = vi.fn()
await removal.confirm(run, vi.fn())
expect(run).not.toHaveBeenCalled()
removal.open({ id: 2 })
removal.close()
expect(removal.target.value).toBeNull()
})
})
describe('useSlugField', () => {
it('follows the name until unlocked and goes back with reset', () => {
const slug = useSlugField(32)
expect(slug.mode.value).toBe('empty')
slug.setSource('Camille Roche')
expect(slug.mode.value).toBe('auto')
expect(slug.value.value).toBe('camille-roche')
slug.unlock()
slug.setSource('Someone Else')
expect(slug.mode.value).toBe('manual')
expect(slug.value.value).toBe('camille-roche')
slug.reset()
expect(slug.value.value).toBe('someone-else')
expect(slug.mode.value).toBe('auto')
})
it('leaves the saved mode as soon as the value is edited and reset follows the name', () => {
const slug = useSlugField(100, 'ines-duarte')
slug.setSource('Inès Duarte')
expect(slug.mode.value).toBe('saved')
slug.setValue('ines-d')
expect(slug.mode.value).toBe('manual')
slug.setValue('ines-duarte')
expect(slug.mode.value).toBe('saved')
slug.setValue('other')
slug.reset()
expect(slug.value.value).toBe('ines-duarte')
})
})
describe('useDirectoryView', () => {
beforeEach(() => setActivePinia(createPinia()))
it('opens a deep link once and ignores a lookup that finishes after unmount', async () => {
await router.push('/authors?edit=zoe')
const hold = deferred<string | null>()
const view = inComponent(() => useDirectoryView())
const open = vi.fn()
const missing = vi.fn()
const following = view.followEditLink(() => hold.promise, open, missing)
await view.followEditLink(() => hold.promise, open, missing)
wrapper!.unmount()
wrapper = null
hold.resolve('found')
await following
expect(open).not.toHaveBeenCalled()
expect(missing).not.toHaveBeenCalled()
await router.push('/login')
})
it('reports an unknown value once and drops it from the URL', async () => {
await router.push('/authors?edit=nobody&q=x')
const view = inComponent(() => useDirectoryView())
const missing = vi.fn()
await view.followEditLink(() => null, vi.fn(), missing)
await flushPromises()
expect(missing).toHaveBeenCalledTimes(1)
expect(router.currentRoute.value.query).toEqual({ q: 'x' })
await router.push('/login')
})
})
+10 -1
View File
@@ -23,6 +23,12 @@ export class NetworkError extends Error {
export type UserRole = 'owner' | 'admin'
export interface MeAuthor {
id: number
name: string
slug: string
}
export interface MeResult {
id?: number
verified: boolean
@@ -30,6 +36,7 @@ export interface MeResult {
email?: string
displayName: string
role?: UserRole
author?: MeAuthor
}
export type OnUnauthorized = 'redirect' | 'passthrough'
@@ -129,7 +136,7 @@ export function useApi() {
if (response.status === 403) {
redirectUnverified(onForbidden)
throw new ApiError(403)
throw new ApiError(403, undefined, await readErrorBody(response))
}
if (response.status === 429) {
@@ -165,6 +172,7 @@ export function useApi() {
email?: string
display_name?: string | null
role?: string
author?: MeAuthor | null
}>('/v1/auth/me', { onUnauthorized: 'passthrough' })
return {
id: body.id,
@@ -173,6 +181,7 @@ export function useApi() {
email: body.email,
displayName: body.display_name ?? '',
role: body.role as UserRole | undefined,
...(body.author ? { author: body.author } : {}),
}
}
+114
View File
@@ -0,0 +1,114 @@
import { useApi } from '@/composables/useApi'
import { useUsers } from '@/composables/useUsers'
import { reassignQuery } from '@/lib/directory'
import { jsonInit } from '@/lib/directoryRequest'
import type { LinkableUser } from '@/lib/directoryTypes'
import type { SocialType } from '@/lib/socialLinks'
export interface SocialLink {
type: SocialType
url: string
}
export interface Author {
id: number
name: string
slug: string
bio: string
avatar_id: number | null
website: string | null
social_links: SocialLink[]
user_id: number | null
is_linked: boolean
article_count: number
user_email?: string
}
export interface AuthorPage {
items: Author[]
total: number
page: number
size: number
}
export interface AuthorListQuery {
q: string
page: number
size: number
}
export interface NewAuthor {
name: string
slug: string
bio: string
avatar_id: number | null
website: string | null
social_links: SocialLink[]
user_id: number | null
}
export type AuthorPatch = Partial<NewAuthor>
export type ReassignTarget = number | 'none' | undefined
const OPTION_PAGE_SIZE = 100
export function authorListPath(query: AuthorListQuery) {
const parts = [
...(query.q ? [`q=${encodeURIComponent(query.q)}`] : []),
`page=${query.page}`,
`size=${query.size}`,
]
return `/v1/author/?${parts.join('&')}`
}
export function useAuthors() {
const { request } = useApi()
const { listLinkableUsers: listUsersWithoutAuthor } = useUsers()
function listAuthors(query: AuthorListQuery): Promise<AuthorPage> {
return request<AuthorPage>(authorListPath(query))
}
async function listAllAuthors(): Promise<Author[]> {
const all: Author[] = []
let page = 1
let total = 0
do {
const response = await listAuthors({ q: '', page, size: OPTION_PAGE_SIZE })
all.push(...response.items)
total = response.total
page += 1
if (response.items.length === 0) {
break
}
} while (all.length < total)
return all
}
async function listLinkableUsers(): Promise<LinkableUser[]> {
const authors = await listAllAuthors()
return listUsersWithoutAuthor(new Set(authors.map((author) => author.user_id)))
}
function createAuthor(author: NewAuthor): Promise<Author> {
return request<Author>('/v1/author/', jsonInit('POST', author))
}
function updateAuthor(id: number, patch: AuthorPatch): Promise<Author> {
return request<Author>(`/v1/author/${id}`, jsonInit('PATCH', patch))
}
function deleteAuthor(id: number, reassignTo: ReassignTarget): Promise<void> {
return request<void>(`/v1/author/${id}${reassignQuery(reassignTo)}`, { method: 'DELETE' })
}
return {
listAuthors,
listAllAuthors,
listLinkableUsers,
createAuthor,
updateAuthor,
deleteAuthor,
}
}
+27
View File
@@ -0,0 +1,27 @@
import { onBeforeUnmount, ref } from 'vue'
export const SKELETON_DELAY_MS = 150
export function useDelayedSkeleton(delay = SKELETON_DELAY_MS) {
const visible = ref(false)
let timer: ReturnType<typeof setTimeout> | null = null
function stop() {
if (timer !== null) {
clearTimeout(timer)
timer = null
}
visible.value = false
}
function start() {
stop()
timer = setTimeout(() => {
visible.value = true
}, delay)
}
onBeforeUnmount(stop)
return { visible, start, stop }
}
@@ -0,0 +1,58 @@
import { onBeforeUnmount, ref, type Ref } from 'vue'
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
import { isSessionError, statusOf } from '@/lib/directoryErrors'
export type CollectionState = 'loading' | 'ready' | 'error' | 'forbidden'
export interface LoadOptions<T> {
accept?: (result: T) => boolean
}
export function useDirectoryCollection<T, A extends unknown[]>(
fetcher: (...args: A) => Promise<T>,
) {
const state = ref<CollectionState>('loading')
const data = ref<T | null>(null) as Ref<T | null>
const skeleton = useDelayedSkeleton()
let ticket = 0
let alive = true
onBeforeUnmount(() => {
alive = false
})
async function load(args: A, options: LoadOptions<T> = {}) {
ticket += 1
const current = ticket
const first = data.value === null
if (first) {
state.value = 'loading'
skeleton.start()
}
try {
const result = await fetcher(...args)
if (!alive || current !== ticket) {
return 'stale' as const
}
if (options.accept && !options.accept(result)) {
return 'rejected' as const
}
data.value = result
state.value = 'ready'
return 'ready' as const
} catch (error) {
if (!alive || current !== ticket || isSessionError(error)) {
return 'stale' as const
}
data.value = null
state.value = statusOf(error) === 403 ? 'forbidden' : 'error'
return 'failed' as const
} finally {
if (alive && current === ticket) {
skeleton.stop()
}
}
}
return { state, data, showSkeleton: skeleton.visible, load }
}
+39
View File
@@ -0,0 +1,39 @@
import { ref, type Ref } from 'vue'
import type { DirectoryNotice } from '@/lib/directoryTypes'
export function useDirectoryDelete<T>() {
const target = ref<T | null>(null) as Ref<T | null>
const pending = ref(false)
const notice = ref<DirectoryNotice | null>(null)
function open(item: T) {
notice.value = null
target.value = item
}
function close() {
target.value = null
notice.value = null
}
async function confirm(
run: (item: T) => Promise<void>,
onFailure: (error: unknown, item: T) => Promise<void> | void,
) {
const item = target.value
if (!item || pending.value) {
return
}
pending.value = true
notice.value = null
try {
await run(item)
} catch (error) {
await onFailure(error, item)
} finally {
pending.value = false
}
}
return { target, pending, notice, open, close, confirm }
}
+147
View File
@@ -0,0 +1,147 @@
import { t } from '@/i18n'
import { copy } from '@/lib/directoryCopy'
import type { FailureContext, FailureKind } from '@/lib/directoryErrors'
import { useLocalizedToast } from '@/i18n/presentation'
type ToastColor = 'success' | 'info' | 'error'
const ICONS: Record<ToastColor, string> = {
success: 'i-lucide-circle-check',
info: 'i-lucide-info',
error: 'i-lucide-circle-x',
}
export function useDirectoryToasts() {
const toast = useLocalizedToast()
function show(id: string, color: ToastColor, title: () => string, description?: () => string) {
toast.add(() => ({
id,
color,
icon: ICONS[color],
get title() {
return title()
},
get description() {
return description?.()
},
}))
}
function siteGone() {
show(
'site-gone',
'info',
() => t('That site no longer exists'),
() => t('It was deleted somewhere else, so we removed it from the list.'),
)
}
function authorGone() {
show(
'author-gone',
'info',
() => t('That author no longer exists'),
() => t('It was deleted somewhere else, so we removed it from the list.'),
)
}
function ownerOnly() {
show(
'site-owner-only',
'error',
() => t('Only the owner can manage sites'),
() => t('Ask the owner of this site.'),
)
}
function defaultSiteBlocked() {
show('site-default-blocked', 'error', () => copy('defaultSiteBlocked'))
}
function authorRights() {
show(
'author-rights',
'error',
() => t('Only an owner or admin can change authors'),
() => t('You can still edit your own author profile.'),
)
}
function siteDefaultChanged(name: string) {
show(
'site-default-changed',
'success',
() => t('Default site changed'),
() => t('"{0}" is now the default site.', [name]),
)
}
function siteDeleted(count: number, destination: string) {
show(
'site-deleted',
'success',
() => t('Site deleted'),
count > 0
? () =>
count === 1
? t('1 article moved to {0}.', [destination])
: t('{0} articles moved to {1}.', [count, destination])
: undefined,
)
}
function authorDeleted(count: number, destination: string | null) {
show(
'author-deleted',
'success',
() => t('Author deleted'),
count > 0
? () => {
if (destination === null) {
return count === 1
? t('1 article no longer has a byline.')
: t('{0} articles no longer have a byline.', [count])
}
return count === 1
? t('1 article now credits {0}.', [destination])
: t('{0} articles now credit {1}.', [count, destination])
}
: undefined,
)
}
function userUnlinked(name: string) {
show(
'author-unlinked',
'success',
() => t('User unlinked'),
() => t('{0} is now a guest author.', [name]),
)
}
function report(kind: FailureKind, context: FailureContext) {
const handlers: Partial<Record<FailureKind, () => void>> = {
forbidden: ownerOnly,
defaultSite: defaultSiteBlocked,
rights: authorRights,
gone: context === 'site' ? siteGone : authorGone,
}
const handler = handlers[kind]
handler?.()
return handler !== undefined
}
return {
report,
siteGone,
authorGone,
ownerOnly,
defaultSiteBlocked,
authorRights,
siteDefaultChanged,
siteDeleted,
authorDeleted,
userUnlinked,
}
}
+76
View File
@@ -0,0 +1,76 @@
import { nextTick, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
export function useDirectoryView() {
const route = useRoute()
const router = useRouter()
let opener: HTMLElement | null = null
let followed = ''
let ticket = 0
let alive = true
onBeforeUnmount(() => {
alive = false
})
function rememberOpener(element?: HTMLElement | null) {
opener =
element ??
(document.activeElement instanceof HTMLElement ? document.activeElement : null)
}
async function restoreFocus() {
await nextTick()
if (opener?.isConnected) {
opener.focus()
}
opener = null
}
function editParam() {
const value = route.query.edit
return typeof value === 'string' ? value : Array.isArray(value) ? (value[0] ?? '') : ''
}
function dropEditParam() {
followed = ''
if (route.query.edit !== undefined) {
router.replace({
query: Object.fromEntries(
Object.entries(route.query).filter(([key]) => key !== 'edit'),
),
})
}
}
function leavePanel() {
dropEditParam()
return restoreFocus()
}
async function followEditLink<T>(
lookup: (value: string) => T | null | Promise<T | null>,
open: (item: T) => void,
missing: () => void,
) {
const value = editParam()
if (!value || followed === value) {
return
}
followed = value
ticket += 1
const current = ticket
const item = await lookup(value)
if (!alive || current !== ticket) {
return
}
if (item === null) {
missing()
dropEditParam()
return
}
open(item)
}
return { rememberOpener, restoreFocus, editParam, dropEditParam, leavePanel, followEditLink }
}
+61
View File
@@ -0,0 +1,61 @@
import { useApi } from '@/composables/useApi'
import { jsonInit } from '@/lib/directoryRequest'
import { reassignQuery } from '@/lib/directory'
export type SiteRobots = 'index' | 'noindex'
export interface Site {
id: number
name: string
key: string
base_url: string
default_social_image_id: number | null
default_robots: SiteRobots
locale: string | null
is_default: boolean
article_count: number
}
export interface NewSite {
name: string
key: string
base_url: string
default_robots: SiteRobots
locale?: string
default_social_image_id?: number
is_default: boolean
}
export interface SitePatch {
name?: string
base_url?: string
default_robots?: SiteRobots
locale?: string | null
default_social_image_id?: number | null
}
export function useSites() {
const { request } = useApi()
function listSites(): Promise<Site[]> {
return request<Site[]>('/v1/site/')
}
function createSite(site: NewSite): Promise<Site> {
return request<Site>('/v1/site/', jsonInit('POST', site))
}
function updateSite(id: number, patch: SitePatch): Promise<Site> {
return request<Site>(`/v1/site/${id}`, jsonInit('PATCH', patch))
}
function setDefaultSite(id: number): Promise<Site> {
return request<Site>(`/v1/site/${id}/default`, { method: 'PATCH' })
}
function deleteSite(id: number, reassignTo?: number): Promise<void> {
return request<void>(`/v1/site/${id}${reassignQuery(reassignTo)}`, { method: 'DELETE' })
}
return { listSites, createSite, updateSite, setDefaultSite, deleteSite }
}
+36
View File
@@ -0,0 +1,36 @@
import { computed, ref } from 'vue'
import { slugify } from '@/lib/directory'
export type SlugMode = 'empty' | 'auto' | 'manual' | 'saved'
export function useSlugField(max: number, saved?: string) {
const source = ref('')
const override = ref<string | null>(saved ?? null)
const isSaved = saved !== undefined
const value = computed(() => override.value ?? slugify(source.value, max))
const mode = computed<SlugMode>(() => {
if (override.value !== null) {
return isSaved && override.value === saved ? 'saved' : 'manual'
}
return source.value.trim() ? 'auto' : 'empty'
})
function setSource(text: string) {
source.value = text
}
function unlock() {
override.value = value.value
}
function setValue(next: string) {
override.value = next
}
function reset() {
override.value = isSaved ? slugify(source.value, max) : null
}
return { source, value, mode, setSource, unlock, setValue, reset }
}
+13 -1
View File
@@ -1,4 +1,5 @@
import { useApi } from '@/composables/useApi'
import type { LinkableUser } from '@/lib/directoryTypes'
export type UserListItem =
| {
@@ -50,6 +51,17 @@ export function useUsers() {
return request<UserListItem[]>('/v1/user/')
}
async function listLinkableUsers(
takenUserIds: ReadonlySet<number | null>,
): Promise<LinkableUser[]> {
const users = await listUsers()
return users.flatMap((user) =>
user.kind === 'user' && !takenUserIds.has(user.id)
? [{ id: user.id, label: user.display_name || user.username, email: user.email }]
: [],
)
}
function inviteUser(email: string): Promise<CreatedInvite> {
return request<CreatedInvite>('/v1/user/invite', {
method: 'POST',
@@ -66,5 +78,5 @@ export function useUsers() {
return request<CreatedInvite>(`/v1/user/invite/${id}/renew`, { method: 'POST' })
}
return { listUsers, inviteUser, cancelInvite, renewInvite }
return { listUsers, listLinkableUsers, inviteUser, cancelInvite, renewInvite }
}