feat: pagination in the SPA for the Sites and Authors lists

This commit is contained in:
Florian Sylvain committed 2026-10-19 04:00:47 +02:00
1 parent 500ad59458
commit 340a0ad461
34 files changed
+2833 -896

No files matched your search

+30 -48
View File
@@ -4,7 +4,7 @@ import { t, formatNumber } from '@/i18n'
import { computed } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { pageItems, rangeFor } from '@/lib/pagination'
import { ARTICLE_PAGE_SIZES } from '@/lib/articleListQuery'
import PageSizeSelect from '@/components/PageSizeSelect.vue'
const DESKTOP_QUERY = '(min-width: 48rem)'
@@ -14,6 +14,8 @@ const props = defineProps<{
total: number
size: number
loadingPage: number | null
singlePageLabel?: string
flush?: boolean
}>()
const emit = defineEmits<{
@@ -27,6 +29,7 @@ const busy = computed(() => props.loadingPage !== null)
const atStart = computed(() => busy.value || props.page <= 1)
const atEnd = computed(() => busy.value || props.page >= props.pageCount)
const showControls = computed(() => props.pageCount > 1)
const countOnly = computed(() => props.singlePageLabel !== undefined && props.pageCount <= 1)
const items = computed(() => pageItems(props.page, props.pageCount))
const summary = computed(() => {
@@ -41,13 +44,6 @@ const summary = computed(() => {
])
})
const sizeItems = computed(() =>
ARTICLE_PAGE_SIZES.map((value) => ({
label: t('{0} per page', [value]),
value,
})),
)
const FOCUS_RING =
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary'
const CONTROL = `justify-center rounded-none border border-accented bg-default p-0 text-highlighted ring-0 disabled:opacity-40 aria-disabled:opacity-40 ${FOCUS_RING}`
@@ -61,30 +57,25 @@ function numberClass(number: number) {
</script>
<template>
<div v-if="isDesktop" data-testid="paginator" class="flex items-center gap-4 pt-3">
<p
v-if="countOnly"
data-testid="paginator-count"
class="text-muted max-md:pb-4"
:class="{ 'pt-3': !flush }"
>
{{ singlePageLabel }}
</p>
<div
v-else-if="isDesktop"
data-testid="paginator"
class="flex items-center gap-4"
:class="{ 'pt-3': !flush }"
>
<span aria-live="polite" data-testid="paginator-summary" class="flex-1 text-muted">{{
summary
}}</span>
<USelectMenu
:model-value="size"
:items="sizeItems"
value-key="value"
:search-input="false"
:disabled="busy"
:aria-label="t('Rows per page')"
:content="{ side: 'top', align: 'start' }"
color="neutral"
variant="outline"
class="w-30"
:ui="{
base: `h-8 rounded-none border border-accented bg-default pl-3 pr-2 text-sm ring-0 focus-visible:ring-0 ${FOCUS_RING}`,
trailingIcon: 'size-3.5 text-muted',
content: 'w-40 rounded-none',
itemTrailingIcon: 'hidden',
item: 'h-8 rounded-none data-[state=checked]:bg-(--rc-sand) data-[state=checked]:font-semibold data-[state=checked]:text-(--rc-on-sand)',
}"
@update:model-value="emit('update:size', $event as number)"
/>
<PageSizeSelect :size="size" :disabled="busy" @update:size="emit('update:size', $event)" />
<nav v-if="showControls" :aria-label="t('Pagination')" class="flex items-center gap-1">
<UButton
color="neutral"
@@ -144,30 +135,21 @@ function numberClass(number: number) {
</nav>
</div>
<div v-else data-testid="paginator" class="flex flex-col gap-3 pt-3 pb-4">
<div
v-else
data-testid="paginator"
class="flex flex-col gap-3 pb-4"
:class="{ 'pt-3': !flush }"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<span aria-live="polite" data-testid="paginator-summary" class="text-muted">{{
summary
}}</span>
<USelectMenu
:model-value="size"
:items="sizeItems"
value-key="value"
:search-input="false"
<PageSizeSelect
:size="size"
:disabled="busy"
:aria-label="t('Rows per page')"
:content="{ side: 'top', align: 'end' }"
color="neutral"
variant="outline"
class="w-30"
:ui="{
base: `h-11 rounded-none border border-accented bg-default pl-3 pr-2 text-sm ring-0 focus-visible:ring-0 ${FOCUS_RING}`,
trailingIcon: 'size-3.5 text-muted',
content: 'w-40 rounded-none',
itemTrailingIcon: 'hidden',
item: 'h-11 rounded-none data-[state=checked]:bg-(--rc-sand) data-[state=checked]:font-semibold data-[state=checked]:text-(--rc-on-sand)',
}"
@update:model-value="emit('update:size', $event as number)"
touch
@update:size="emit('update:size', $event)"
/>
</div>
<nav v-if="showControls" :aria-label="t('Pagination')" class="flex items-center gap-2">
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { t } from '@/i18n'
defineProps<{ title: string }>()
defineEmits<{ retry: [] }>()
</script>
<template>
<UAlert
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="title"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="$emit('retry')"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
</template>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { t } from '@/i18n'
defineProps<{ title: string }>()
defineEmits<{ clear: [] }>()
</script>
<template>
<UEmpty
variant="naked"
icon="i-lucide-search"
:title="title"
:description="t('Check the spelling, or search for a shorter part of the name.')"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-14',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md font-semibold',
description: 'max-w-85 text-pretty text-muted',
}"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-4 text-highlighted ring-0 md:h-8"
@click="$emit('clear')"
>
{{ t('Clear search') }}
</UButton>
</template>
</UEmpty>
</template>
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { t } from '@/i18n'
import type { DirectoryNotice } from '@/composables/useDirectoryPages'
defineProps<{
notice: DirectoryNotice
shownPage: number
}>()
defineEmits<{
retry: []
}>()
</script>
<template>
<UAlert
v-if="notice.kind === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load page {0}. Showing page {1}.', [notice.query.page, shownPage])"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2 flex-col items-stretch' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 w-full justify-center gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
@click="$emit('retry')"
>
{{ t('Retry') }}
</UButton>
</template>
</UAlert>
<UAlert
v-else
role="status"
color="info"
variant="soft"
orientation="horizontal"
:close="false"
icon="i-lucide-info"
:description="t('That page doesn\'t exist. Showing the last page.')"
:ui="{
root: 'items-center gap-2.5 px-3.5 py-2.5',
icon: 'size-4',
description: 'text-toned',
}"
/>
</template>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import AppPaginator from '@/components/AppPaginator.vue'
import DirectoryPageNotice from '@/components/DirectoryPageNotice.vue'
import type { DirectoryNotice } from '@/composables/useDirectoryPages'
defineProps<{
notice: DirectoryNotice | null
showPaginator: boolean
shownPage: number
pageCount: number
total: number
size: number
loadingPage: number | null
singlePageLabel: string
}>()
defineEmits<{
retry: []
'update:page': [page: number]
'update:size': [size: number]
}>()
</script>
<template>
<div v-if="notice?.kind === 'error' || showPaginator" class="flex flex-col gap-3">
<DirectoryPageNotice
v-if="notice?.kind === 'error'"
:notice="notice"
:shown-page="shownPage"
@retry="$emit('retry')"
/>
<AppPaginator
v-if="showPaginator"
:page="shownPage"
:page-count="pageCount"
:total="total"
:size="size"
:loading-page="loadingPage"
:single-page-label="singlePageLabel"
flush
@update:page="$emit('update:page', $event)"
@update:size="$emit('update:size', $event)"
/>
</div>
</template>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { PAGE_SIZES } from '@/lib/pageQuery'
const FOCUS_RING =
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary'
const props = defineProps<{
size: number
disabled: boolean
touch?: boolean
}>()
const emit = defineEmits<{ 'update:size': [size: number] }>()
const sizeItems = computed(() =>
PAGE_SIZES.map((value) => ({
label: t('{0} per page', [value]),
value,
})),
)
const height = computed(() => (props.touch ? 'h-11' : 'h-8'))
</script>
<template>
<USelectMenu
:model-value="size"
:items="sizeItems"
value-key="value"
:search-input="false"
:disabled="disabled"
:aria-label="t('Rows per page')"
:content="{ side: 'top', align: touch ? 'end' : 'start' }"
color="neutral"
variant="outline"
class="w-30"
:ui="{
base: `${height} rounded-none border border-accented bg-default pl-3 pr-2 text-sm ring-0 focus-visible:ring-0 disabled:opacity-40 ${FOCUS_RING}`,
trailingIcon: 'size-3.5 text-muted',
content: 'w-40 rounded-none',
itemTrailingIcon: 'hidden',
item: `${height} rounded-none data-[state=checked]:bg-(--rc-sand) data-[state=checked]:font-semibold data-[state=checked]:text-(--rc-on-sand)`,
}"
@update:model-value="emit('update:size', $event as number)"
/>
</template>
@@ -2,9 +2,7 @@ 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'
@@ -56,72 +54,6 @@ describe('useDelayedSkeleton', () => {
})
})
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 }>()
+49 -1
View File
@@ -3,7 +3,7 @@ import { flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import { ApiError, NetworkError, isOffline, useApi } from '@/composables/useApi'
import { ApiError, MissingTotalError, NetworkError, isOffline, useApi } from '@/composables/useApi'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
@@ -273,3 +273,51 @@ describe('useApi abort and blob handling', () => {
expect(router.currentRoute.value.query.reason).toBe('expired')
})
})
describe('useApi requestPaged', () => {
it('returns the body and the total read from X-Total-Count', async () => {
fetchMock.mockResolvedValue(mockResponse(200, [{ id: 1 }], { 'X-Total-Count': '61' }))
const result = await useApi().requestPaged('/v1/site/?page=1&size=25')
expect(result).toEqual({ body: [{ id: 1 }], total: 61 })
expect(fetchMock).toHaveBeenCalledWith(
'/v1/site/?page=1&size=25',
expect.objectContaining({ credentials: 'include' }),
)
})
it.each([[null], ['abc'], ['-1']])(
'treats the X-Total-Count header %s as a failed load',
async (header) => {
const headers = header === null ? {} : { 'X-Total-Count': header }
fetchMock.mockResolvedValue(mockResponse(200, [], headers))
await expect(useApi().requestPaged('/v1/site/')).rejects.toBeInstanceOf(
MissingTotalError,
)
},
)
it('keeps the 401, 403 and 429 behaviour of request', async () => {
fetchMock.mockResolvedValueOnce(mockResponse(403))
await expectApiError(useApi().requestPaged('/v1/site/', { onForbidden: 'passthrough' }), {
status: 403,
})
fetchMock.mockResolvedValueOnce(mockResponse(429, null, { 'Retry-After': '7' }))
await expectApiError(useApi().requestPaged('/v1/site/'), {
status: 429,
retryAfter: 7,
})
fetchMock.mockResolvedValueOnce(mockResponse(401))
await expectApiError(useApi().requestPaged('/v1/site/'), { status: 401 })
await settle()
expect(router.currentRoute.value.path).toBe('/login')
})
it('leaves request returning the body only', async () => {
fetchMock.mockResolvedValue(mockResponse(200, [{ id: 1 }], { 'X-Total-Count': '61' }))
await expect(useApi().request('/v1/site/')).resolves.toEqual([{ id: 1 }])
})
})
+29 -3
View File
@@ -21,6 +21,17 @@ export class NetworkError extends Error {
}
}
export class MissingTotalError extends Error {
constructor() {
super('Response carries no X-Total-Count')
}
}
export interface PagedResponse<T> {
body: T
total: number
}
export type UserRole = 'owner' | 'admin'
export interface MeAuthor {
@@ -62,14 +73,17 @@ function syncOfflineFlagWithNavigator() {
isOffline.value = !isNavigatorOnLine()
}
function parseRetryAfterSeconds(response: Response): number | undefined {
const header = response.headers.get('Retry-After')
function parseCount(header: string | null): number | undefined {
if (header === null || !/^\d+$/.test(header)) {
return undefined
}
return Number.parseInt(header, 10)
}
function parseRetryAfterSeconds(response: Response): number | undefined {
return parseCount(response.headers.get('Retry-After'))
}
async function readErrorBody(response: Response): Promise<string | undefined> {
if (typeof response.text !== 'function') {
return undefined
@@ -159,6 +173,18 @@ export function useApi() {
return response.json()
}
async function requestPaged<T>(
path: string,
options: RequestOptions = {},
): Promise<PagedResponse<T>> {
const response = await send(path, options)
const total = parseCount(response.headers.get('X-Total-Count'))
if (total === undefined) {
throw new MissingTotalError()
}
return { body: (await response.json()) as T, total }
}
async function requestBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
const response = await send(path, options)
return response.blob()
@@ -185,5 +211,5 @@ export function useApi() {
}
}
return { getMe, request, requestBlob }
return { getMe, request, requestPaged, requestBlob }
}
+65 -192
View File
@@ -1,5 +1,5 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import {
useArticles,
type Article,
@@ -7,27 +7,28 @@ import {
type ArticleStatus,
} from '@/composables/useArticles'
import {
ARTICLE_PAGE_SIZES,
usePagedQuery,
useSearchInput,
type PagedNotice,
type PagedOutcome,
type UrlMode,
} from '@/composables/usePagedQuery'
import {
SEO_FILTER,
nextSort,
parseArticleQuery,
sameArticleQuery,
sameRouteQuery,
toRouteQuery,
type ArticleListQuery,
type ArticleSortField,
} from '@/lib/articleListQuery'
import { pageCountFor } from '@/lib/pagination'
import { PAGE_SIZES } from '@/lib/pageQuery'
import { ARTICLES_PAGE_SIZE_KEY, readStoredPageSize, storePageSize } from '@/lib/pageSizeMemory'
const SKELETON_DELAY_MS = 150
const SEARCH_DEBOUNCE_MS = 120
const ARTICLES_PATH = '/article'
export type UrlMode = 'push' | 'replace'
export type ArticleListNotice =
| { kind: 'range' }
| { kind: 'error'; query: ArticleListQuery; mode: UrlMode }
export type { UrlMode }
export type ArticleListNotice = PagedNotice<ArticleListQuery>
interface ArticleListOptions {
sitesSettled: () => boolean
@@ -36,7 +37,6 @@ interface ArticleListOptions {
export function useArticleList({ sitesSettled, usableSite }: ArticleListOptions) {
const route = useRoute()
const router = useRouter()
const { listArticles } = useArticles()
function parseWithStoredSize(query: typeof route.query): ArticleListQuery {
@@ -44,136 +44,66 @@ export function useArticleList({ sitesSettled, usableSite }: ArticleListOptions)
if (query.size !== undefined) {
return parsed
}
const stored = readStoredPageSize(ARTICLES_PAGE_SIZE_KEY, ARTICLE_PAGE_SIZES)
const stored = readStoredPageSize(ARTICLES_PAGE_SIZE_KEY, PAGE_SIZES)
return stored === null ? parsed : { ...parsed, size: stored }
}
const items = ref<Article[]>([])
const total = ref(0)
const counts = ref<ArticleCounts>({ all: 0, online: 0, draft: 0 })
const shown = ref<ArticleListQuery>(parseWithStoredSize(route.query))
const desired = ref<ArticleListQuery>(shown.value)
const hasLoaded = ref(false)
const loading = ref(true)
const loadFailed = ref(false)
const pendingPage = ref<number | null>(null)
const notice = ref<ArticleListNotice | null>(null)
const showSkeleton = ref(false)
const searchInput = ref(shown.value.q)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
let requestToken = 0
let syncedTarget: Record<string, string> | null = null
const needsAttentionCount = computed(() => counts.value.suggestions ?? null)
const settled = computed(() => hasLoaded.value && !loadFailed.value)
const skeletonActive = computed(() => loading.value && showSkeleton.value)
const showPaginator = computed(() => settled.value && total.value > 0)
const pageCount = computed(() => pageCountFor(total.value, shown.value.size))
function clearSearchTimer() {
if (searchDebounceTimer !== null) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
}
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
watch(searchInput, (value) => {
clearSearchTimer()
searchDebounceTimer = setTimeout(() => changeSearch(value), SEARCH_DEBOUNCE_MS)
})
async function syncUrl(query: ArticleListQuery, mode: UrlMode) {
if (route.path !== ARTICLES_PATH) {
return
}
const target = toRouteQuery(query)
if (sameRouteQuery(route.query, target)) {
return
}
syncedTarget = target
await router[mode]({ query: target })
}
async function load(
query: ArticleListQuery,
mode: UrlMode,
successNotice: ArticleListNotice | null = null,
) {
const token = ++requestToken
desired.value = query
loading.value = true
showSkeleton.value = false
pendingPage.value = hasLoaded.value ? query.page : null
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
async function request(query: ArticleListQuery, context: { loaded: boolean }) {
try {
const response = await listArticles(query)
if (token !== requestToken) {
return
}
const lastPage = pageCountFor(response.total, query.size)
if (
successNotice === null &&
response.items.length === 0 &&
response.total > 0 &&
query.page > lastPage
) {
await load({ ...query, page: lastPage }, 'replace', {
kind: 'range',
})
return
}
const effective = {
...query,
page: response.total === 0 ? 1 : query.page,
}
items.value = response.items
total.value = response.total
counts.value = response.counts
shown.value = effective
desired.value = effective
hasLoaded.value = true
loadFailed.value = false
notice.value = successNotice
await syncUrl(effective, mode)
return {
kind: 'loaded',
total: response.total,
count: response.items.length,
apply: () => {
items.value = response.items
counts.value = response.counts
loadFailed.value = false
},
} satisfies PagedOutcome
} catch {
if (token !== requestToken) {
return
}
if (hasLoaded.value) {
notice.value = { kind: 'error', query, mode }
} else {
if (!context.loaded) {
items.value = []
loadFailed.value = true
}
} finally {
if (token === requestToken) {
loading.value = false
pendingPage.value = null
clearSkeletonTimer()
showSkeleton.value = false
}
return { kind: 'failed', notice: context.loaded } satisfies PagedOutcome
}
}
function changePage(page: number) {
load({ ...desired.value, page }, 'push')
}
const paged = usePagedQuery<ArticleListQuery>({
path: ARTICLES_PATH,
initial: parseWithStoredSize(route.query),
initialLoading: true,
request,
parseRoute: (query, ownReplace) => {
const parsed = ownReplace ? parseArticleQuery(query) : parseWithStoredSize(query)
return sitesSettled() ? usableSite(parsed) : parsed
},
toRoute: toRouteQuery,
same: sameArticleQuery,
onRouteQuery: (query) => {
search.searchInput.value = query.q
},
})
const { shown, desired, hasLoaded, total, load } = paged
const search = useSearchInput(
shown.value.q,
() => desired.value.q,
(q) => load({ ...desired.value, q, page: 1 }, 'replace'),
)
const needsAttentionCount = computed(() => counts.value.suggestions ?? null)
const settled = computed(() => hasLoaded.value && !loadFailed.value)
const showPaginator = computed(() => settled.value && total.value > 0)
function changeSize(size: number) {
storePageSize(ARTICLES_PAGE_SIZE_KEY, size)
load({ ...desired.value, size, page: 1 }, 'push')
return paged.changeSize(size)
}
function changeStatus(status: '' | ArticleStatus) {
@@ -195,60 +125,9 @@ export function useArticleList({ sitesSettled, usableSite }: ArticleListOptions)
}
function changeSort(field: ArticleSortField) {
load(
{
...desired.value,
sort: nextSort(desired.value.sort, field),
page: 1,
},
'push',
)
load({ ...desired.value, sort: nextSort(desired.value.sort, field), page: 1 }, 'push')
}
function changeSearch(value: string) {
const q = value.trim()
if (q === desired.value.q) {
return
}
load({ ...desired.value, q, page: 1 }, 'replace')
}
function clearSearch() {
clearSearchTimer()
searchInput.value = ''
changeSearch('')
}
function retryNotice() {
if (notice.value?.kind === 'error') {
load(notice.value.query, notice.value.mode)
}
}
watch(
() => route.query,
() => {
if (route.path !== ARTICLES_PATH) {
return
}
const ownReplace = syncedTarget !== null && sameRouteQuery(route.query, syncedTarget)
syncedTarget = null
const parsed = ownReplace
? parseArticleQuery(route.query)
: parseWithStoredSize(route.query)
const next = sitesSettled() ? usableSite(parsed) : parsed
if (hasLoaded.value && sameArticleQuery(next, shown.value)) {
syncUrl(shown.value, 'replace')
return
}
if (sameArticleQuery(next, desired.value) && loading.value) {
return
}
searchInput.value = next.q
load(next, 'replace')
},
)
async function start(sitesRequest: Promise<unknown>) {
if (shown.value.site) {
await sitesRequest
@@ -259,12 +138,6 @@ export function useArticleList({ sitesSettled, usableSite }: ArticleListOptions)
load(shown.value, 'replace')
}
onBeforeUnmount(() => {
requestToken++
clearSearchTimer()
clearSkeletonTimer()
})
return {
items,
total,
@@ -272,27 +145,27 @@ export function useArticleList({ sitesSettled, usableSite }: ArticleListOptions)
shown,
desired,
hasLoaded,
loading,
loading: paged.loading,
loadFailed,
pendingPage,
notice,
showSkeleton,
searchInput,
pendingPage: paged.pendingPage,
notice: paged.notice,
showSkeleton: paged.showSkeleton,
searchInput: search.searchInput,
needsAttentionCount,
settled,
skeletonActive,
skeletonActive: paged.skeletonActive,
showPaginator,
pageCount,
pageCount: paged.pageCount,
load,
start,
changePage,
changePage: paged.changePage,
changeSize,
changeStatus,
changeSite,
changeSeo,
changeSort,
changeSearch,
clearSearch,
retryNotice,
changeSearch: search.changeSearch,
clearSearch: search.clearSearch,
retryNotice: paged.retryNotice,
}
}
+2 -2
View File
@@ -3,7 +3,7 @@ import { useSites, type Site } from '@/composables/useSites'
import type { ArticleListQuery } from '@/lib/articleListQuery'
export function useArticleSites(shownSite: () => string) {
const { listSites } = useSites()
const { listAllSites } = useSites()
const sites = ref<Site[]>([])
const sitesSettled = ref(false)
@@ -14,7 +14,7 @@ export function useArticleSites(shownSite: () => string) {
async function fetchSites() {
try {
sites.value = await listSites()
sites.value = await listAllSites()
} catch {
sites.value = []
}
+9 -8
View File
@@ -1,7 +1,7 @@
import { useApi } from '@/composables/useApi'
import { useUsers } from '@/composables/useUsers'
import { reassignQuery } from '@/lib/directory'
import { jsonInit } from '@/lib/directoryRequest'
import { directoryListPath, jsonInit } from '@/lib/directoryRequest'
import type { LinkableUser } from '@/lib/directoryTypes'
import type { SocialType } from '@/lib/socialLinks'
@@ -64,22 +64,22 @@ 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('&')}`
return directoryListPath('/v1/author/', query)
}
export function useAuthors() {
const { request } = useApi()
const { request, requestPaged } = useApi()
const { listLinkableUsers: listUsersWithoutAuthor } = useUsers()
function listAuthors(query: AuthorListQuery): Promise<AuthorPage> {
return request<AuthorPage>(authorListPath(query))
}
async function listAuthorPage(query: AuthorListQuery) {
const { body, total } = await requestPaged<AuthorPage>(authorListPath(query))
return { items: body.items, total }
}
async function listAllAuthors(): Promise<Author[]> {
const all: Author[] = []
let page = 1
@@ -125,6 +125,7 @@ export function useAuthors() {
return {
listAuthors,
listAuthorPage,
listAllAuthors,
listLinkableUsers,
createAuthor,
@@ -1,58 +0,0 @@
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 }
}
@@ -0,0 +1,46 @@
import { computed, type ComputedRef, type Ref } from 'vue'
const PAGE_SKELETON_ROWS_DESKTOP = 8
const PAGE_SKELETON_ROWS_MOBILE = 6
interface DirectoryListStateSource {
total: Ref<number>
shown: Ref<{ q: string }>
hasLoaded: Ref<boolean>
showSkeleton: Ref<boolean>
}
interface DirectoryListStateOptions {
list: DirectoryListStateSource
loadState: ComputedRef<'loading' | 'ready' | 'error' | 'forbidden'>
isDesktop: Ref<boolean>
initialRows: number
singleLabel: (total: number) => string
}
export function useDirectoryListState({
list,
loadState,
isDesktop,
initialRows,
singleLabel,
}: DirectoryListStateOptions) {
const noResults = computed(
() => loadState.value === 'ready' && list.total.value === 0 && list.shown.value.q !== '',
)
const showList = computed(
() =>
(loadState.value === 'ready' && list.total.value > 0) ||
(loadState.value === 'loading' && list.showSkeleton.value),
)
const skeletonRows = computed(() => {
if (!list.hasLoaded.value) {
return initialRows
}
return isDesktop.value ? PAGE_SKELETON_ROWS_DESKTOP : PAGE_SKELETON_ROWS_MOBILE
})
const showPaginator = computed(() => loadState.value === 'ready' && list.total.value > 0)
const singlePageLabel = computed(() => singleLabel(list.total.value))
return { noResults, showList, skeletonRows, showPaginator, singlePageLabel }
}
+153
View File
@@ -0,0 +1,153 @@
import { computed, ref, watch, type Ref } from 'vue'
import { useRoute } from 'vue-router'
import {
usePagedQuery,
useSearchInput,
type PagedNotice,
type PagedOutcome,
type UrlMode,
} from '@/composables/usePagedQuery'
import { isSessionError, statusOf } from '@/lib/directoryErrors'
import {
parseDirectoryQuery,
toDirectoryRouteQuery,
type DirectoryQuery,
} from '@/lib/directoryQuery'
import { pageCountFor } from '@/lib/pagination'
export type { UrlMode }
export type DirectoryNotice = PagedNotice<DirectoryQuery>
export interface DirectoryPage<T> {
items: T[]
total: number
}
interface DirectoryPagesOptions<T> {
loadPage: (query: DirectoryQuery) => Promise<DirectoryPage<T>>
ready: () => boolean
onLoaded?: () => void
}
export function useDirectoryPages<T>(options: DirectoryPagesOptions<T>) {
const route = useRoute()
const items = ref<T[]>([]) as Ref<T[]>
const failure = ref<'none' | 'error' | 'forbidden'>('none')
let started = false
async function request(query: DirectoryQuery, context: { loaded: boolean }) {
if (!context.loaded) {
failure.value = 'none'
}
try {
const response = await options.loadPage(query)
return {
kind: 'loaded',
total: response.total,
count: response.items.length,
apply: () => {
items.value = response.items
failure.value = 'none'
},
} satisfies PagedOutcome
} catch (error) {
if (isSessionError(error)) {
return { kind: 'failed', notice: false } satisfies PagedOutcome
}
if (!context.loaded) {
items.value = []
failure.value = statusOf(error) === 403 ? 'forbidden' : 'error'
}
return { kind: 'failed', notice: context.loaded } satisfies PagedOutcome
}
}
const paged = usePagedQuery<DirectoryQuery>({
path: route.path,
initial: parseDirectoryQuery(route.query),
request,
parseRoute: (query) => parseDirectoryQuery(query),
toRoute: toDirectoryRouteQuery,
same: sameQuery,
enabled: () => started,
onRouteQuery: (query) => {
search.searchInput.value = query.q
},
onLoaded: options.onLoaded,
})
const { shown, desired, hasLoaded, total, load } = paged
const search = useSearchInput(
shown.value.q,
() => desired.value.q,
(q) => load({ ...desired.value, q, page: 1 }, 'replace'),
)
const state = computed(() => {
if (failure.value !== 'none') {
return failure.value
}
return hasLoaded.value ? ('ready' as const) : ('loading' as const)
})
function reload() {
return load(shown.value, 'replace')
}
function retry() {
if (paged.notice.value?.kind === 'error') {
return paged.retryNotice()
}
return load(desired.value, 'replace')
}
function removeById(id: number, identify: (item: T) => number) {
const remaining = items.value.filter((item) => identify(item) !== id)
if (remaining.length === items.value.length) {
return
}
items.value = remaining
total.value = Math.max(0, total.value - 1)
if (remaining.length === 0 && total.value > 0) {
const page = Math.min(shown.value.page, pageCountFor(total.value, shown.value.size))
load({ ...shown.value, page }, 'replace')
}
}
watch(
options.ready,
(ready) => {
if (ready && !started) {
started = true
load(shown.value, 'replace')
}
},
{ immediate: true },
)
return {
items,
total,
shown,
desired,
hasLoaded,
loading: paged.loading,
state,
pendingPage: paged.pendingPage,
notice: paged.notice,
searchInput: search.searchInput,
pageCount: paged.pageCount,
showSkeleton: paged.showSkeleton,
skeletonActive: paged.skeletonActive,
changePage: paged.changePage,
changeSize: paged.changeSize,
clearSearch: search.clearSearch,
reload,
retry,
removeById,
}
}
function sameQuery(left: DirectoryQuery, right: DirectoryQuery) {
return left.q === right.q && left.page === right.page && left.size === right.size
}
+247
View File
@@ -0,0 +1,247 @@
import { computed, onBeforeUnmount, ref, watch, type Ref } from 'vue'
import { useRoute, useRouter, type LocationQuery } from 'vue-router'
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
import { pageCountFor } from '@/lib/pagination'
import { sameRouteQuery } from '@/lib/pageQuery'
const SEARCH_DEBOUNCE_MS = 120
export type UrlMode = 'push' | 'replace'
export interface PagedQuery {
q: string
page: number
size: number
}
export type PagedNotice<Q> = { kind: 'range' } | { kind: 'error'; query: Q; mode: UrlMode }
export type PagedOutcome =
| {
kind: 'loaded'
total: number
count: number
page?: number
redirected?: boolean
apply?: () => void
}
| { kind: 'failed'; notice: boolean; settled?: boolean; resetDesired?: boolean }
| { kind: 'stale' }
interface PagedContext<Q> {
loaded: boolean
shown: Q
}
interface PagedQueryOptions<Q extends PagedQuery> {
path: string
initial: Q
request: (query: Q, context: PagedContext<Q>) => Promise<PagedOutcome>
parseRoute: (query: LocationQuery, ownReplace: boolean) => Q
toRoute: (query: Q, current: LocationQuery) => Record<string, string>
same: (left: Q, right: Q) => boolean
sameRoute?: (current: LocationQuery, target: Record<string, string>) => boolean
enabled?: () => boolean
isLoading?: () => boolean
pendingPageFor?: (query: Q, loaded: boolean) => number | null
initialLoading?: boolean
onRouteQuery?: (query: Q) => void
onLoaded?: () => void
}
export function usePagedQuery<Q extends PagedQuery>(options: PagedQueryOptions<Q>) {
const route = useRoute()
const router = useRouter()
const skeleton = useDelayedSkeleton()
const sameRoute = options.sameRoute ?? sameRouteQuery
const total = ref(0)
const shown = ref(options.initial) as Ref<Q>
const desired = ref(options.initial) as Ref<Q>
const hasLoaded = ref(false)
const loadedOnce = ref(false)
const loading = ref(options.initialLoading ?? false)
const pendingPage = ref<number | null>(null)
const notice = ref<PagedNotice<Q> | null>(null)
let token = 0
let syncedTarget: Record<string, string> | null = null
const pageCount = computed(() => pageCountFor(total.value, shown.value.size))
const skeletonActive = computed(() => loading.value && skeleton.visible.value)
async function syncUrl(query: Q, mode: UrlMode) {
if (route.path !== options.path) {
return
}
const target = options.toRoute(query, route.query)
if (sameRoute(route.query, target)) {
return
}
syncedTarget = target
await router[mode]({ query: target })
}
async function load(query: Q, mode: UrlMode, successNotice: PagedNotice<Q> | null = null) {
const current = ++token
desired.value = query
loading.value = true
pendingPage.value = options.pendingPageFor
? options.pendingPageFor(query, loadedOnce.value)
: loadedOnce.value
? query.page
: null
skeleton.start()
const outcome = await options.request(query, {
loaded: loadedOnce.value,
shown: shown.value,
})
if (current !== token || outcome.kind === 'stale') {
return
}
if (outcome.kind === 'failed') {
finish()
if (outcome.settled) {
hasLoaded.value = true
}
if (outcome.resetDesired) {
desired.value = shown.value
}
if (outcome.notice) {
notice.value = { kind: 'error', query, mode }
}
return
}
const lastPage = pageCountFor(outcome.total, query.size)
if (
successNotice === null &&
!outcome.redirected &&
outcome.count === 0 &&
outcome.total > 0 &&
query.page > lastPage
) {
await load({ ...query, page: lastPage }, 'replace', { kind: 'range' })
return
}
const effective = {
...query,
page: outcome.page ?? (outcome.total === 0 ? 1 : query.page),
}
outcome.apply?.()
total.value = outcome.total
shown.value = effective
desired.value = effective
hasLoaded.value = true
loadedOnce.value = true
notice.value = successNotice ?? (outcome.redirected ? { kind: 'range' } : null)
finish()
await syncUrl(effective, outcome.redirected ? 'replace' : mode)
options.onLoaded?.()
}
function finish() {
loading.value = false
pendingPage.value = null
skeleton.stop()
}
function changePage(page: number) {
return load({ ...desired.value, page }, 'push')
}
function changeSize(size: number) {
return load({ ...desired.value, size, page: 1 }, 'push')
}
function retryNotice() {
const current = notice.value as PagedNotice<Q> | null
if (current?.kind === 'error') {
return load(current.query, current.mode)
}
}
watch(
() => route.query,
() => {
if (route.path !== options.path || options.enabled?.() === false) {
return
}
const ownReplace = syncedTarget !== null && sameRoute(route.query, syncedTarget)
syncedTarget = null
const next = options.parseRoute(route.query, ownReplace)
if (hasLoaded.value && options.same(next, shown.value)) {
syncUrl(shown.value, 'replace')
return
}
const busy = options.isLoading ? options.isLoading() : loading.value
if (options.same(next, desired.value) && busy) {
return
}
options.onRouteQuery?.(next)
load(next, 'replace')
},
)
onBeforeUnmount(() => {
token++
})
return {
total,
shown,
desired,
hasLoaded,
loadedOnce,
loading,
pendingPage,
notice,
pageCount,
showSkeleton: skeleton.visible,
skeletonActive,
load,
syncUrl,
changePage,
changeSize,
retryNotice,
}
}
export function useSearchInput(
initial: string,
current: () => string,
commit: (query: string) => unknown,
) {
const searchInput = ref(initial)
let timer: ReturnType<typeof setTimeout> | null = null
function cancel() {
if (timer !== null) {
clearTimeout(timer)
timer = null
}
}
function changeSearch(value: string) {
const q = value.trim()
if (q !== current()) {
commit(q)
}
}
function clearSearch() {
cancel()
searchInput.value = ''
changeSearch('')
}
watch(searchInput, (value) => {
cancel()
timer = setTimeout(() => {
timer = null
changeSearch(value)
}, SEARCH_DEBOUNCE_MS)
})
onBeforeUnmount(cancel)
return { searchInput, changeSearch, clearSearch }
}
+10 -4
View File
@@ -1,5 +1,6 @@
import { useApi } from '@/composables/useApi'
import { jsonInit } from '@/lib/directoryRequest'
import { directoryListPath, jsonInit } from '@/lib/directoryRequest'
import type { DirectoryQuery } from '@/lib/directoryQuery'
import { reassignQuery } from '@/lib/directory'
export type SiteRobots = 'index' | 'noindex'
@@ -44,12 +45,17 @@ export interface SitePatch {
}
export function useSites() {
const { request } = useApi()
const { request, requestPaged } = useApi()
function listSites(): Promise<Site[]> {
function listAllSites(): Promise<Site[]> {
return request<Site[]>('/v1/site/')
}
async function listSitePage(query: DirectoryQuery) {
const { body, total } = await requestPaged<Site[]>(directoryListPath('/v1/site/', query))
return { items: body, total }
}
function createSite(site: NewSite): Promise<Site> {
return request<Site>('/v1/site/', jsonInit('POST', site))
}
@@ -68,5 +74,5 @@ export function useSites() {
})
}
return { listSites, createSite, updateSite, setDefaultSite, deleteSite }
return { listAllSites, listSitePage, createSite, updateSite, setDefaultSite, deleteSite }
}
+3
View File
@@ -13,6 +13,7 @@ const SOURCES = [
'components/DirectoryDialog.vue',
'components/DirectoryField.vue',
'components/DirectoryList.vue',
'components/DirectoryPageNotice.vue',
'components/DirectoryPanel.vue',
'components/DirectoryRowMenu.vue',
'components/LinkUserDialog.vue',
@@ -43,6 +44,8 @@ const SAME_IN_FRENCH = new Set([
'Noindex',
'Site',
'Sites',
'1 site',
'{0} sites',
'Guest',
'Linked',
'Description',
+6
View File
@@ -1792,6 +1792,12 @@ export const messages = {
'Retirez le chemin. Utilisez seulement l’hôte, comme https://blog.example.com.',
'Reset to match the name': 'Rétablir selon le nom',
'Search by name or slug': 'Rechercher par nom ou identifiant',
'Search by name or key': 'Rechercher par nom ou clé',
'No sites match "{0}"': 'Aucun site ne correspond à « {0} »',
'{0} sites': '{0} sites',
'1 site': '1 site',
'{0} authors': '{0} auteurs',
'1 author': '1 auteur',
"Search engines may list this site's pages.":
'Les moteurs de recherche peuvent référencer les pages de ce site.',
'Set as default': 'Définir par défaut',
+18 -29
View File
@@ -1,19 +1,21 @@
import type { LocationQuery } from 'vue-router'
import { SITE_KEY_MAX } from '@/lib/directory'
import {
DEFAULT_PAGE_SIZE,
PAGE_SIZES,
SEARCH_MAX_LENGTH,
firstQueryValue,
parsePositiveInteger,
} from '@/lib/pageQuery'
export type ArticleStatus = 'online' | 'draft'
export const ARTICLE_PAGE_SIZES = [5, 25, 50, 100]
export const DEFAULT_ARTICLE_PAGE_SIZE = 25
export const ARTICLE_SEARCH_MAX_LENGTH = 200
export const DEFAULT_ARTICLE_SORT = 'updated_at:desc'
export const ARTICLE_SORT_FIELDS = ['title', 'status', 'created_at', 'updated_at'] as const
export type ArticleSortField = (typeof ARTICLE_SORT_FIELDS)[number]
export const SEO_FILTER = 'suggestions'
const SITE_KEY_MAX = 32
const POSITIVE_INTEGER = /^[1-9]\d*$/
export interface ArticleListQuery {
page: number
size: number
@@ -24,11 +26,6 @@ export interface ArticleListQuery {
seo: '' | typeof SEO_FILTER
}
function first(value: LocationQuery[string] | undefined) {
const single = Array.isArray(value) ? value[0] : value
return single ?? ''
}
const SORT_PATTERN = new RegExp(`^(${ARTICLE_SORT_FIELDS.join('|')}):(asc|desc)$`)
function validSort(value: string) {
@@ -45,17 +42,17 @@ export function nextSort(current: string, field: ArticleSortField) {
}
export function parseArticleQuery(query: LocationQuery): ArticleListQuery {
const page = first(query.page)
const size = Number(first(query.size))
const status = first(query.status)
const page = firstQueryValue(query.page)
const size = Number(firstQueryValue(query.size))
const status = firstQueryValue(query.status)
return {
page: POSITIVE_INTEGER.test(page) ? Number(page) : 1,
size: ARTICLE_PAGE_SIZES.includes(size) ? size : DEFAULT_ARTICLE_PAGE_SIZE,
q: first(query.q).slice(0, ARTICLE_SEARCH_MAX_LENGTH),
page: parsePositiveInteger(page) ?? 1,
size: PAGE_SIZES.includes(size) ? size : DEFAULT_PAGE_SIZE,
q: firstQueryValue(query.q).slice(0, SEARCH_MAX_LENGTH),
status: status === 'online' || status === 'draft' ? status : '',
sort: validSort(first(query.sort)),
site: first(query.site).slice(0, SITE_KEY_MAX),
seo: first(query.seo) === SEO_FILTER ? SEO_FILTER : '',
sort: validSort(firstQueryValue(query.sort)),
site: firstQueryValue(query.site).slice(0, SITE_KEY_MAX),
seo: firstQueryValue(query.seo) === SEO_FILTER ? SEO_FILTER : '',
}
}
@@ -64,7 +61,7 @@ export function toRouteQuery(query: ArticleListQuery) {
if (query.page !== 1) {
route.page = String(query.page)
}
if (query.size !== DEFAULT_ARTICLE_PAGE_SIZE) {
if (query.size !== DEFAULT_PAGE_SIZE) {
route.size = String(query.size)
}
if (query.q) {
@@ -96,11 +93,3 @@ export function sameArticleQuery(left: ArticleListQuery, right: ArticleListQuery
effectiveSort(left.sort) === effectiveSort(right.sort)
)
}
export function sameRouteQuery(left: LocationQuery, right: Record<string, string>) {
const leftKeys = Object.keys(left)
return (
leftKeys.length === Object.keys(right).length &&
leftKeys.every((key) => first(left[key]) === right[key])
)
}
+13 -18
View File
@@ -1,9 +1,11 @@
import type { LocationQuery } from 'vue-router'
import {
ARTICLE_PAGE_SIZES,
ARTICLE_SEARCH_MAX_LENGTH,
DEFAULT_ARTICLE_PAGE_SIZE,
} from '@/lib/articleListQuery'
DEFAULT_PAGE_SIZE,
PAGE_SIZES,
SEARCH_MAX_LENGTH,
firstQueryValue,
parsePositiveInteger,
} from '@/lib/pageQuery'
export interface DirectoryQuery {
q: string
@@ -11,20 +13,13 @@ export interface DirectoryQuery {
size: number
}
const POSITIVE_INTEGER = /^[1-9]\d*$/
function first(value: LocationQuery[string] | undefined) {
const single = Array.isArray(value) ? value[0] : value
return single ?? ''
}
export function parseDirectoryQuery(query: LocationQuery): DirectoryQuery {
const page = first(query.page)
const size = Number(first(query.size))
const page = firstQueryValue(query.page)
const size = Number(firstQueryValue(query.size))
return {
q: first(query.q).slice(0, ARTICLE_SEARCH_MAX_LENGTH),
page: POSITIVE_INTEGER.test(page) ? Number(page) : 1,
size: ARTICLE_PAGE_SIZES.includes(size) ? size : DEFAULT_ARTICLE_PAGE_SIZE,
q: firstQueryValue(query.q).slice(0, SEARCH_MAX_LENGTH),
page: parsePositiveInteger(page) ?? 1,
size: PAGE_SIZES.includes(size) ? size : DEFAULT_PAGE_SIZE,
}
}
@@ -32,7 +27,7 @@ export function toDirectoryRouteQuery(query: DirectoryQuery, rest: LocationQuery
const route: Record<string, string> = {}
for (const [key, value] of Object.entries(rest)) {
if (key !== 'q' && key !== 'page' && key !== 'size') {
route[key] = first(value)
route[key] = firstQueryValue(value)
}
}
if (query.q) {
@@ -41,7 +36,7 @@ export function toDirectoryRouteQuery(query: DirectoryQuery, rest: LocationQuery
if (query.page !== 1) {
route.page = String(query.page)
}
if (query.size !== DEFAULT_ARTICLE_PAGE_SIZE) {
if (query.size !== DEFAULT_PAGE_SIZE) {
route.size = String(query.size)
}
return route
+9
View File
@@ -5,3 +5,12 @@ export function jsonInit(method: string, body: unknown): RequestInit {
body: JSON.stringify(body),
}
}
export function directoryListPath(base: string, query: { q: string; page: number; size: number }) {
const parts = [
...(query.q ? [`q=${encodeURIComponent(query.q)}`] : []),
`page=${query.page}`,
`size=${query.size}`,
]
return `${base}?${parts.join('&')}`
}
+24
View File
@@ -0,0 +1,24 @@
import type { LocationQuery } from 'vue-router'
export const PAGE_SIZES = [5, 25, 50, 100]
export const DEFAULT_PAGE_SIZE = 25
export const SEARCH_MAX_LENGTH = 200
const POSITIVE_INTEGER = /^[1-9]\d*$/
export function firstQueryValue(value: LocationQuery[string] | undefined) {
const single = Array.isArray(value) ? value[0] : value
return single ?? ''
}
export function parsePositiveInteger(value: string) {
return POSITIVE_INTEGER.test(value) ? Number(value) : null
}
export function sameRouteQuery(left: LocationQuery, right: Record<string, string>) {
const leftKeys = Object.keys(left)
return (
leftKeys.length === Object.keys(right).length &&
leftKeys.every((key) => firstQueryValue(left[key]) === right[key])
)
}
+66 -218
View File
@@ -1,11 +1,10 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { t, formatNumber } from '@/i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedOverlay } from '@/i18n/presentation'
import AppPaginator from '@/components/AppPaginator.vue'
import AuthorAvatar from '@/components/AuthorAvatar.vue'
import AuthorFormPanel from '@/components/AuthorFormPanel.vue'
import AuthorViewPanel from '@/components/AuthorViewPanel.vue'
@@ -14,31 +13,27 @@ import DashboardShell from '@/components/DashboardShell.vue'
import DirectoryAddButton from '@/components/DirectoryAddButton.vue'
import DirectoryBadge from '@/components/DirectoryBadge.vue'
import DirectoryList from '@/components/DirectoryList.vue'
import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
import DirectoryNoResults from '@/components/DirectoryNoResults.vue'
import DirectoryPageNotice from '@/components/DirectoryPageNotice.vue'
import DirectoryPagination from '@/components/DirectoryPagination.vue'
import LinkUserDialog from '@/components/LinkUserDialog.vue'
import ReassignDialog, { type ReassignOption } from '@/components/ReassignDialog.vue'
import { useAuthors, type Author } from '@/composables/useAuthors'
import { useDirectoryCollection } from '@/composables/useDirectoryCollection'
import { useDirectoryDelete } from '@/composables/useDirectoryDelete'
import { useDirectoryListState } from '@/composables/useDirectoryListState'
import { useDirectoryPages } from '@/composables/useDirectoryPages'
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
import { useDirectoryView } from '@/composables/useDirectoryView'
import { sameRouteQuery } from '@/lib/articleListQuery'
import { pluralArticles } from '@/lib/directory'
import { copy } from '@/lib/directoryCopy'
import { classifyFailure } from '@/lib/directoryErrors'
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
import {
parseDirectoryQuery,
toDirectoryRouteQuery,
type DirectoryQuery,
} from '@/lib/directoryQuery'
import { pageCountFor } from '@/lib/pagination'
import { useSessionStore } from '@/stores/session'
const SEARCH_DEBOUNCE_MS = 120
const DESKTOP_QUERY = '(min-width: 48rem)'
const LIST_COLUMNS = 'minmax(0,1.4fr) minmax(0,1fr) 110px 100px 44px'
const SKELETON_ROWS = 6
const PAGINATOR_THRESHOLD = 25
const INITIAL_SKELETON_ROWS = 6
const NOBODY = 'none'
const LOOKUP_SIZE = 100
@@ -48,51 +43,43 @@ type PanelTarget =
| { kind: 'view'; author: Author }
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const overlay = useLocalizedOverlay()
const toasts = useDirectoryToasts()
const { rememberOpener, restoreFocus, leavePanel, followEditLink } = useDirectoryView()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const { listAuthors, listAllAuthors, deleteAuthor } = useAuthors()
const collection = useDirectoryCollection(listAuthors)
const { listAuthors, listAuthorPage, listAllAuthors, deleteAuthor } = useAuthors()
const removal = useDirectoryDelete<Author>()
const { data: result, showSkeleton } = collection
const sessionReady = computed(() => session.status === 'ready')
const list = useDirectoryPages<Author>({
loadPage: listAuthorPage,
ready: () => sessionReady.value,
onLoaded: () => followLink(),
})
const { items, total, shown, notice, searchInput, pageCount } = list
const { target: deleteTarget, pending: deletePending, notice: deleteNotice } = removal
const loadState = computed(() =>
collection.state.value === 'forbidden' ? 'error' : collection.state.value,
)
const pendingPage = ref<number | null>(null)
const searchText = ref(parseDirectoryQuery(route.query).q)
const loadState = computed(() => (list.state.value === 'forbidden' ? 'error' : list.state.value))
const panel = ref<PanelTarget | null>(null)
const linkTarget = ref<Author | null>(null)
const deleteOptions = ref<ReassignOption[]>([])
const destination = ref<string | number | undefined>(undefined)
let searchTimer: ReturnType<typeof setTimeout> | null = null
const sessionReady = computed(() => session.status === 'ready')
const canManage = computed(() => session.canManageContent)
const query = computed(() => parseDirectoryQuery(route.query))
const queryKey = computed(() => JSON.stringify(query.value))
const items = computed(() => result.value?.items ?? [])
const total = computed(() => result.value?.total ?? 0)
const pageCount = computed(() => pageCountFor(total.value, query.value.size))
const firstRun = computed(
() => loadState.value === 'ready' && total.value === 0 && query.value.q === '',
() => loadState.value === 'ready' && total.value === 0 && shown.value.q === '',
)
const noResults = computed(
() => loadState.value === 'ready' && total.value === 0 && query.value.q !== '',
const { noResults, showList, skeletonRows, showPaginator, singlePageLabel } = useDirectoryListState(
{
list,
loadState,
isDesktop,
initialRows: INITIAL_SKELETON_ROWS,
singleLabel: (count) =>
count === 1 ? t('1 author') : t('{0} authors', [formatNumber(count)]),
},
)
const showToolbar = computed(() => loadState.value !== 'error' && !firstRun.value)
const showList = computed(
() =>
(loadState.value === 'ready' && total.value > 0) ||
(loadState.value === 'loading' && showSkeleton.value),
)
const showPaginator = computed(
() => loadState.value === 'ready' && (total.value > PAGINATOR_THRESHOLD || pageCount.value > 1),
)
const overlayOpen = computed(
() => panel.value !== null || linkTarget.value !== null || deleteTarget.value !== null,
)
@@ -113,82 +100,6 @@ const headings = computed(() => [t('Author'), t('Slug'), t('Account'), t('Articl
const ownSlug = computed(() => session.session?.author?.slug ?? '')
const knownSlugs = computed(() => items.value.map((author) => author.slug))
function clearSearchTimer() {
if (searchTimer !== null) {
clearTimeout(searchTimer)
searchTimer = null
}
}
async function load(requested: DirectoryQuery) {
if (result.value !== null) {
pendingPage.value = requested.page
}
const outcome = await collection.load([requested], {
accept: (response) => {
const lastPage = pageCountFor(response.total, requested.size)
if (response.total > 0 && requested.page > lastPage) {
replaceQuery({ ...requested, page: lastPage })
return false
}
return true
},
})
if (outcome !== 'stale') {
pendingPage.value = null
}
if (outcome === 'ready') {
followLink()
}
}
async function replaceQuery(next: DirectoryQuery, mode: 'push' | 'replace' = 'replace') {
const target = toDirectoryRouteQuery(next, route.query)
if (sameRouteQuery(route.query, target)) {
return
}
await router[mode]({ query: target })
}
function normalizeUrl() {
const target = toDirectoryRouteQuery(query.value, route.query)
if (!sameRouteQuery(route.query, target)) {
router.replace({ query: target })
}
}
watch(
[sessionReady, queryKey],
([ready]) => {
if (!ready) {
return
}
normalizeUrl()
load(query.value)
},
{ immediate: true },
)
watch(
() => query.value.q,
(value) => {
if (value !== searchText.value.trim()) {
searchText.value = value
}
},
)
watch(searchText, (value) => {
clearSearchTimer()
searchTimer = setTimeout(() => {
searchTimer = null
const text = value.trim()
if (text !== query.value.q) {
replaceQuery({ q: text, page: 1, size: query.value.size })
}
}, SEARCH_DEBOUNCE_MS)
})
watch(
() => route.query.edit,
() => {
@@ -198,22 +109,6 @@ watch(
},
)
onBeforeUnmount(clearSearchTimer)
function clearSearch() {
clearSearchTimer()
searchText.value = ''
replaceQuery({ q: '', page: 1, size: query.value.size })
}
function changePage(page: number) {
replaceQuery({ ...query.value, page }, 'push')
}
function changeSize(size: number) {
replaceQuery({ ...query.value, size, page: 1 }, 'push')
}
async function findBySlug(slug: string) {
const loaded = items.value.find((author) => author.slug === slug)
if (loaded) {
@@ -267,42 +162,25 @@ function closePanel() {
}
function replaceItem(author: Author) {
if (!result.value) {
return
}
result.value = {
...result.value,
items: result.value.items.map((item) =>
item.id === author.id
? {
...author,
user_email: author.user_email ?? item.user_email,
}
: item,
),
}
items.value = items.value.map((item) =>
item.id === author.id
? {
...author,
user_email: author.user_email ?? item.user_email,
}
: item,
)
}
function removeItem(id: number) {
if (!result.value) {
return
}
const remaining = result.value.items.filter((item) => item.id !== id)
result.value = {
...result.value,
items: remaining,
total: Math.max(0, result.value.total - 1),
}
if (remaining.length === 0 && result.value.total > 0) {
load(query.value)
}
list.removeById(id, (item) => item.id)
}
function onSaved(author: Author) {
if (items.value.some((item) => item.id === author.id)) {
replaceItem(author)
} else {
load(query.value)
list.reload()
}
closePanel()
}
@@ -552,7 +430,7 @@ function menuItems(author: Author): DirectoryMenuItem[] {
return items
}
const noResultsTitle = computed(() => t('No authors match "{0}"', [query.value.q]))
const noResultsTitle = computed(() => t('No authors match "{0}"', [shown.value.q]))
</script>
<template>
@@ -572,40 +450,26 @@ const noResultsTitle = computed(() => t('No authors match "{0}"', [query.value.q
<UInput
v-if="showToolbar"
v-model="searchText"
v-model="searchInput"
type="search"
icon="i-lucide-search"
:placeholder="t('Search by name or slug')"
:aria-label="t('Search by name or slug')"
class="w-full sm:w-70"
:ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }"
:ui="{ base: 'h-11 text-lg ps-8.25 sm:h-8 sm:text-sm', leadingIcon: 'size-3.5' }"
/>
<UAlert
<DirectoryPageNotice
v-if="notice?.kind === 'range'"
:notice="notice"
:shown-page="shown.page"
/>
<DirectoryLoadError
v-if="loadState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load authors')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="load(query)"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
@retry="list.retry"
/>
<UEmpty
v-else-if="firstRun"
@@ -629,38 +493,19 @@ const noResultsTitle = computed(() => t('No authors match "{0}"', [query.value.q
</template>
</UEmpty>
<UEmpty
<DirectoryNoResults
v-else-if="noResults"
variant="naked"
icon="i-lucide-search"
:title="noResultsTitle"
:description="t('Check the spelling, or search for a shorter part of the name.')"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-14',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md font-semibold',
description: 'max-w-85 text-pretty text-muted',
}"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-4 text-highlighted ring-0 md:h-8"
@click="clearSearch"
>
{{ t('Clear search') }}
</UButton>
</template>
</UEmpty>
@clear="list.clearSearch"
/>
<DirectoryList
v-else-if="showList"
:items="items"
:columns="LIST_COLUMNS"
:headings="headings"
:loading="loadState === 'loading'"
:skeleton-rows="SKELETON_ROWS"
:loading="list.skeletonActive.value"
:skeleton-rows="skeletonRows"
:read-only="!canManage"
:label="t('Authors')"
:row-title="(author: Author) => author.name"
@@ -697,15 +542,18 @@ const noResultsTitle = computed(() => t('No authors match "{0}"', [query.value.q
</template>
</DirectoryList>
<AppPaginator
v-if="showPaginator"
:page="query.page"
<DirectoryPagination
:notice="notice"
:show-paginator="showPaginator"
:shown-page="shown.page"
:page-count="pageCount"
:total="total"
:size="query.size"
:loading-page="pendingPage"
@update:page="changePage"
@update:size="changeSize"
:size="shown.size"
:loading-page="list.pendingPage.value"
:single-page-label="singlePageLabel"
@retry="list.retry"
@update:page="list.changePage"
@update:size="list.changeSize"
/>
</div>
+2 -2
View File
@@ -50,7 +50,7 @@ const router = useRouter()
const { request } = useApi()
const toast = useLocalizedToast()
const directoryToasts = useDirectoryToasts()
const { listSites } = useSites()
const { listAllSites } = useSites()
const lifecycle = useArticleLifecycle()
const {
editorRef,
@@ -137,7 +137,7 @@ function clearSkeletonTimer() {
async function fetchSites() {
try {
const list = await listSites()
const list = await listAllSites()
return Array.isArray(list) ? list : []
} catch {
return []
+51 -93
View File
@@ -2,7 +2,7 @@
import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter, type LocationQuery } from 'vue-router'
import { useRoute, type LocationQuery } from 'vue-router'
import { useEventListener, useMediaQuery, useOnline } from '@vueuse/core'
import { useLocalizedToast } from '@/i18n/presentation'
import AppPaginator from '@/components/AppPaginator.vue'
@@ -34,6 +34,7 @@ import {
} from '@/composables/useMedia'
import { useMediaSelection } from '@/composables/useMediaSelection'
import { useMediaUpload } from '@/composables/useMediaUpload'
import { usePagedQuery, type PagedOutcome, type UrlMode } from '@/composables/usePagedQuery'
import type { PurgeResult } from '@/composables/useMediaPurge'
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
import { groupDuplicates, type DuplicateGroup } from '@/lib/mediaDuplicates'
@@ -45,7 +46,6 @@ import {
toRouteQuery,
type MediaControls,
} from '@/lib/mediaQuery'
import { sameRouteQuery } from '@/lib/articleListQuery'
import { MEDIA_PAGE_SIZE_KEY, storePageSize } from '@/lib/pageSizeMemory'
import { pageCountFor } from '@/lib/pagination'
import { purgedToast } from '@/lib/mediaPurge'
@@ -78,11 +78,7 @@ const MERGE_CONFLICT_COPY =
const OFFLINE_COPY =
"You're offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection."
type UrlMode = 'push' | 'replace'
type Notice = { kind: 'range' } | { kind: 'error'; query: MediaControls; mode: UrlMode }
const route = useRoute()
const router = useRouter()
const toast = useLocalizedToast()
const session = useSessionStore()
const media = useMedia()
@@ -91,15 +87,55 @@ const upload = useMediaUpload({ onAdded: refreshInBackground })
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const online = useOnline()
const shown = ref<MediaControls>(parseMediaControls(route.query))
const desired = ref<MediaControls>(shown.value)
function differsOnlyByPage(query: MediaControls, current: MediaControls) {
return sameMediaControls({ ...query, page: current.page }, current)
}
function sameOrderedQuery(current: LocationQuery, target: Record<string, string>) {
const flatten = (query: LocationQuery) =>
Object.entries(query).map(([key, value]) => [key, Array.isArray(value) ? value[0] : value])
return JSON.stringify(flatten(current)) === JSON.stringify(Object.entries(target))
}
async function requestPage(
query: MediaControls,
context: { loaded: boolean; shown: MediaControls },
): Promise<PagedOutcome> {
const keepOnError = context.loaded && differsOnlyByPage(query, context.shown)
const outcome = await media.load(query, { keepOnError })
if (outcome.kind === 'stale') {
return { kind: 'stale' }
}
if (outcome.kind === 'failed') {
const showNotice = keepOnError && !outcome.handled
return { kind: 'failed', settled: true, notice: showNotice, resetDesired: showNotice }
}
return {
kind: 'loaded',
total: media.total.value,
count: media.items.value.length,
page: outcome.page,
redirected: outcome.redirected,
apply: selection.clear,
}
}
const paged = usePagedQuery<MediaControls>({
path: MEDIA_PATH,
initial: parseMediaControls(route.query),
request: requestPage,
parseRoute: (query, ownReplace) => parseMediaControls(query, !ownReplace),
toRoute: (query) => toRouteQuery(query),
same: sameMediaControls,
sameRoute: sameOrderedQuery,
isLoading: () => media.status.value === 'loading',
pendingPageFor: (query, loaded) => (loaded && !isFullSet(query) ? query.page : null),
onLoaded: selectPending,
})
const { shown, desired, hasLoaded, loadedOnce, pendingPage, notice, load, syncUrl, changePage } =
paged
const controls = computed(() => shown.value)
const offline = computed(() => !online.value)
const hasLoaded = ref(false)
const loadedOnce = ref(false)
const pendingPage = ref<number | null>(null)
const notice = ref<Notice | null>(null)
const heading = ref<HTMLElement | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const activeId = ref<number | null>(null)
@@ -123,8 +159,6 @@ let bulkDeletedTotal = 0
let bulkFailureRevision = 0
let bulkFailureToastId = BULK_FAILED_TOAST_ID
let failureToastShown = false
let requestToken = 0
let syncedTarget: Record<string, string> | null = null
const activeItem = computed(
() => media.items.value.find((item) => item.id === activeId.value) ?? null,
@@ -224,56 +258,6 @@ watch(showBulkBar, (visible) => (toastsAboveBulkBar.value = visible), {
})
onBeforeUnmount(() => (toastsAboveBulkBar.value = false))
function sameOrderedQuery(current: LocationQuery, target: Record<string, string>) {
const flatten = (query: LocationQuery) =>
Object.entries(query).map(([key, value]) => [key, Array.isArray(value) ? value[0] : value])
return JSON.stringify(flatten(current)) === JSON.stringify(Object.entries(target))
}
async function syncUrl(query: MediaControls, mode: UrlMode) {
if (route.path !== MEDIA_PATH) {
return
}
const target = toRouteQuery(query)
if (sameOrderedQuery(route.query, target)) {
return
}
syncedTarget = target
await router[mode]({ query: target })
}
function differsOnlyByPage(query: MediaControls, current: MediaControls) {
return sameMediaControls({ ...query, page: current.page }, current)
}
async function load(query: MediaControls, mode: UrlMode) {
const token = ++requestToken
desired.value = query
pendingPage.value = loadedOnce.value && !isFullSet(query) ? query.page : null
const keepOnError = loadedOnce.value && differsOnlyByPage(query, shown.value)
const outcome = await media.load(query, { keepOnError })
if (token !== requestToken || outcome.kind === 'stale') {
return
}
pendingPage.value = null
hasLoaded.value = true
if (outcome.kind === 'failed') {
if (keepOnError && !outcome.handled) {
desired.value = shown.value
notice.value = { kind: 'error', query, mode }
}
return
}
loadedOnce.value = true
const effective = { ...query, page: outcome.page }
selection.clear()
shown.value = effective
desired.value = effective
notice.value = outcome.redirected ? { kind: 'range' } : null
await syncUrl(effective, outcome.redirected ? 'replace' : mode)
selectPending()
}
function updateControls(patch: Partial<MediaControls>, mode: UrlMode = 'replace') {
const changed = Object.entries(patch).some(
([key, value]) => desired.value[key as keyof MediaControls] !== value,
@@ -283,19 +267,13 @@ function updateControls(patch: Partial<MediaControls>, mode: UrlMode = 'replace'
}
}
function changePage(page: number) {
load({ ...desired.value, page }, 'push')
}
function changeSize(size: number) {
storePageSize(MEDIA_PAGE_SIZE_KEY, size)
load({ ...desired.value, size, page: 1 }, 'push')
paged.changeSize(size)
}
function retryNotice() {
if (notice.value?.kind === 'error') {
load(notice.value.query, notice.value.mode)
}
paged.retryNotice()
}
function clearFilters() {
@@ -312,26 +290,6 @@ watch(activeItem, (item) => {
}
})
watch(
() => route.query,
() => {
if (route.path !== MEDIA_PATH) {
return
}
const ownReplace = syncedTarget !== null && sameRouteQuery(route.query, syncedTarget)
syncedTarget = null
const next = parseMediaControls(route.query, !ownReplace)
if (hasLoaded.value && sameMediaControls(next, shown.value)) {
syncUrl(shown.value, 'replace')
return
}
if (sameMediaControls(next, desired.value) && media.status.value === 'loading') {
return
}
load(next, 'replace')
},
)
load(shown.value, 'replace')
watch(
+118 -79
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { t, formatNumber } from '@/i18n'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -10,11 +10,16 @@ import DashboardShell from '@/components/DashboardShell.vue'
import DirectoryAddButton from '@/components/DirectoryAddButton.vue'
import DirectoryBadge from '@/components/DirectoryBadge.vue'
import DirectoryList from '@/components/DirectoryList.vue'
import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
import DirectoryNoResults from '@/components/DirectoryNoResults.vue'
import DirectoryPageNotice from '@/components/DirectoryPageNotice.vue'
import DirectoryPagination from '@/components/DirectoryPagination.vue'
import ReassignDialog, { type ReassignOption } from '@/components/ReassignDialog.vue'
import SiteFormPanel from '@/components/SiteFormPanel.vue'
import SitesForbidden from '@/components/SitesForbidden.vue'
import { useDirectoryCollection } from '@/composables/useDirectoryCollection'
import { useDirectoryDelete } from '@/composables/useDirectoryDelete'
import { useDirectoryListState } from '@/composables/useDirectoryListState'
import { useDirectoryPages } from '@/composables/useDirectoryPages'
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
import { useDirectoryView } from '@/composables/useDirectoryView'
import { useSites, type Site } from '@/composables/useSites'
@@ -26,7 +31,7 @@ import { useSessionStore } from '@/stores/session'
const DESKTOP_QUERY = '(min-width: 48rem)'
const LIST_COLUMNS = 'minmax(0,1.1fr) minmax(0,1.5fr) 110px 44px'
const SKELETON_ROWS = 3
const INITIAL_SKELETON_ROWS = 3
type PanelTarget = { mode: 'add' } | { mode: 'edit'; site: Site }
@@ -37,41 +42,55 @@ const overlay = useLocalizedOverlay()
const toasts = useDirectoryToasts()
const { rememberOpener, restoreFocus, leavePanel, followEditLink } = useDirectoryView()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const { listSites, setDefaultSite, deleteSite } = useSites()
const collection = useDirectoryCollection(listSites)
const { listAllSites, listSitePage, setDefaultSite, deleteSite } = useSites()
const removal = useDirectoryDelete<Site>()
const { state: loadState, showSkeleton } = collection
const { target: deleteTarget, pending: deletePending, notice: deleteNotice } = removal
const sites = computed({
get: () => collection.data.value ?? [],
set: (value: Site[]) => {
collection.data.value = value
},
})
const panel = ref<PanelTarget | null>(null)
const destinationId = ref<number | undefined>(undefined)
let requested = false
const sessionReady = computed(() => session.status === 'ready')
const isOwner = computed(() => session.isOwner)
const list = useDirectoryPages<Site>({
loadPage: listSitePage,
ready: () => sessionReady.value && isOwner.value,
onLoaded: () => followLink(),
})
const { items: sites, total, shown, notice, searchInput, pageCount } = list
const loadState = list.state
const { target: deleteTarget, pending: deletePending, notice: deleteNotice } = removal
const allSites = ref<Site[]>([])
const panel = ref<PanelTarget | null>(null)
const destinationId = ref<number | undefined>(undefined)
const forbidden = computed(
() => (sessionReady.value && !isOwner.value) || loadState.value === 'forbidden',
)
const showList = computed(
() => loadState.value === 'ready' || (loadState.value === 'loading' && showSkeleton.value),
const { noResults, showList, skeletonRows, showPaginator, singlePageLabel } = useDirectoryListState(
{
list,
loadState,
isDesktop,
initialRows: INITIAL_SKELETON_ROWS,
singleLabel: (count) => (count === 1 ? t('1 site') : t('{0} sites', [formatNumber(count)])),
},
)
const onlyDefault = computed(
() => loadState.value === 'ready' && sites.value.length === 1 && sites.value[0]!.is_default,
() =>
loadState.value === 'ready' &&
shown.value.q === '' &&
total.value === 1 &&
sites.value.length === 1 &&
sites.value[0]!.is_default,
)
const overlayOpen = computed(() => panel.value !== null || deleteTarget.value !== null)
const defaultSite = computed(() => sites.value.find((site) => site.is_default))
const defaultSite = computed(() => allSites.value.find((site) => site.is_default))
const panelSite = computed(() => {
const target = panel.value
if (!target || target.mode === 'add') {
return undefined
}
return sites.value.find((site) => site.id === target.site.id) ?? target.site
return (
allSites.value.find((site) => site.id === target.site.id) ??
sites.value.find((site) => site.id === target.site.id) ??
target.site
)
})
const destinationOptions = computed<ReassignOption[]>(() => {
@@ -79,7 +98,7 @@ const destinationOptions = computed<ReassignOption[]>(() => {
if (!target || target.article_count === 0) {
return []
}
return sites.value
return allSites.value
.filter((site) => site.id !== target.id)
.map((site) => ({
value: site.id,
@@ -88,7 +107,7 @@ const destinationOptions = computed<ReassignOption[]>(() => {
display: `${site.name} · ${hostOf(site.base_url)}`,
}))
})
const destination = computed(() => sites.value.find((site) => site.id === destinationId.value))
const destination = computed(() => allSites.value.find((site) => site.id === destinationId.value))
const deleteDescription = computed(() => {
const target = deleteTarget.value
@@ -142,24 +161,15 @@ const deletePendingLabel = computed(() =>
: t('Deleting…'),
)
async function loadSites() {
const outcome = await collection.load([])
if (outcome === 'ready') {
followLink()
async function loadAllSites() {
try {
allSites.value = await listAllSites()
} catch {
return allSites.value
}
return allSites.value
}
watch(
[sessionReady, isOwner],
([ready, owner]) => {
if (ready && owner && !requested) {
requested = true
loadSites()
}
},
{ immediate: true },
)
watch(
() => route.query.edit,
() => {
@@ -174,7 +184,7 @@ function followLink() {
return
}
return followEditLink(
(key) => sites.value.find((candidate) => candidate.key === key) ?? null,
async (key) => (await loadAllSites()).find((candidate) => candidate.key === key) ?? null,
(site) => {
panel.value = { mode: 'edit', site }
},
@@ -184,11 +194,13 @@ function followLink() {
function openAdd() {
rememberOpener()
loadAllSites()
panel.value = { mode: 'add' }
}
function openEdit(site: Site, trigger: HTMLElement | null) {
rememberOpener(trigger)
loadAllSites()
panel.value = { mode: 'edit', site }
}
@@ -198,28 +210,33 @@ function closePanel() {
}
function upsert(site: Site) {
const exists = sites.value.some((candidate) => candidate.id === site.id)
sites.value = exists
? sites.value.map((candidate) => (candidate.id === site.id ? site : candidate))
: [...sites.value, site]
const replaceSite = (candidate: Site) => (candidate.id === site.id ? site : candidate)
sites.value = sites.value.map(replaceSite)
allSites.value = allSites.value.some((candidate) => candidate.id === site.id)
? allSites.value.map(replaceSite)
: [...allSites.value, site]
if (site.is_default) {
markDefault(site.id)
}
}
function markDefault(id: number) {
sites.value = sites.value.map((site) => ({
...site,
is_default: site.id === id,
}))
const mark = (site: Site) => ({ ...site, is_default: site.id === id })
sites.value = sites.value.map(mark)
allSites.value = allSites.value.map(mark)
}
function removeSite(id: number) {
sites.value = sites.value.filter((site) => site.id !== id)
allSites.value = allSites.value.filter((site) => site.id !== id)
list.removeById(id, (site) => site.id)
}
function onSaved(site: Site) {
const added = panel.value?.mode === 'add'
upsert(site)
if (added) {
list.reload()
}
closePanel()
}
@@ -276,8 +293,9 @@ function confirmDefault(site: Site, trigger: HTMLElement | null) {
.then(restoreFocus)
}
function openDelete(site: Site, trigger: HTMLElement | null) {
async function openDelete(site: Site, trigger: HTMLElement | null) {
rememberOpener(trigger)
await loadAllSites()
removal.open(site)
const options = destinationOptions.value
const preferred = options.find((option) => option.value === defaultSite.value?.id)
@@ -291,8 +309,8 @@ function closeDelete() {
async function refreshTarget(id: number) {
try {
sites.value = await listSites()
removal.target.value = sites.value.find((site) => site.id === id) ?? null
allSites.value = await listAllSites()
removal.target.value = allSites.value.find((site) => site.id === id) ?? null
} catch {
return
}
@@ -304,6 +322,9 @@ async function runDelete(target: Site) {
await deleteSite(target.id, to?.id)
removeSite(target.id)
if (to) {
sites.value = sites.value.map((site) =>
site.id === to.id ? { ...site, article_count: site.article_count + moved } : site,
)
to.article_count += moved
}
toasts.siteDeleted(moved, to?.name ?? '')
@@ -372,6 +393,7 @@ function menuItems(site: Site): DirectoryMenuItem[] {
return items
}
const noResultsTitle = computed(() => t('No sites match "{0}"', [shown.value.q]))
const headings = computed(() => [t('Site'), t('Base URL'), t('Articles'), ''])
function backToSettings() {
@@ -419,39 +441,42 @@ function backToSettings() {
</DirectoryAddButton>
</div>
<UAlert
<UInput
v-if="loadState === 'ready'"
v-model="searchInput"
type="search"
icon="i-lucide-search"
:placeholder="t('Search by name or key')"
:aria-label="t('Search by name or key')"
class="w-full sm:w-70"
:ui="{ base: 'h-11 text-lg ps-8.25 sm:h-8 sm:text-sm', leadingIcon: 'size-3.5' }"
/>
<DirectoryPageNotice
v-if="notice?.kind === 'range'"
:notice="notice"
:shown-page="shown.page"
/>
<DirectoryLoadError
v-if="loadState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load sites')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="loadSites"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
@retry="list.retry"
/>
<DirectoryNoResults
v-else-if="noResults"
:title="noResultsTitle"
@clear="list.clearSearch"
/>
<DirectoryList
v-if="showList"
v-else-if="showList"
:items="sites"
:columns="LIST_COLUMNS"
:headings="headings"
:loading="loadState === 'loading'"
:skeleton-rows="SKELETON_ROWS"
:loading="list.skeletonActive.value"
:skeleton-rows="skeletonRows"
:label="t('Sites')"
:row-title="(site: Site) => site.name"
:menu-items="menuItems"
@@ -506,6 +531,20 @@ function backToSettings() {
)
}}
</p>
<DirectoryPagination
:notice="notice"
:show-paginator="showPaginator"
:shown-page="shown.page"
:page-count="pageCount"
:total="total"
:size="shown.size"
:loading-page="list.pendingPage.value"
:single-page-label="singlePageLabel"
@retry="list.retry"
@update:page="list.changePage"
@update:size="list.changeSize"
/>
</div>
<DirectoryAddButton
@@ -521,7 +560,7 @@ function backToSettings() {
v-if="panel"
:key="panel.mode === 'edit' ? panel.site.id : 'add'"
:site="panelSite"
:sites="sites"
:sites="allSites"
@close="closePanel"
@saved="onSaved"
@updated="upsert"
@@ -0,0 +1,771 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { MissingTotalError } from '@/composables/useApi'
import { setLocale } from '@/i18n'
import type { Author } from '@/composables/useAuthors'
import type { Site } from '@/composables/useSites'
import { DESKTOP_WIDTH, MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
authorsFixture,
blur,
callsTo,
click,
createBackend,
dialog,
failWith,
field,
labelled,
mountDirectory,
networkFailure,
paged,
pending,
rows,
sitesFixture,
textOf,
typeInto,
unmountDirectory,
type Backend,
} from './directoryTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const SEARCH_DEBOUNCE_MS = 120
const SKELETON_DELAY_MS = 150
interface Screen {
name: string
view: 'sites' | 'authors'
route: string
path: string
noun: string
nounOne: string
total: number
pages: number
middle: number
middleSummary: string
lastRows: number
lastSummary: string
searchTerm: string
searchPlaceholder: string
emptyTitle: string
emptyTerm: string
searchFrom: number
searchTotal: number
longPage: number
longTotal: number
longSummary: string
longLabel: string
}
const SCREENS: Screen[] = [
{
name: 'Sites',
view: 'sites',
route: '/settings/sites',
path: '/v1/site/',
noun: 'sites',
nounOne: 'site',
total: 61,
pages: 3,
middle: 2,
middleSummary: 'Showing 26–50 of 61',
lastRows: 11,
lastSummary: 'Showing 51–61 of 61',
searchTerm: 'renew',
searchPlaceholder: 'Search by name or key',
emptyTitle: 'No sites match "blog"',
emptyTerm: 'blog',
searchFrom: 4,
searchTotal: 27,
longPage: 6,
longTotal: 312,
longSummary: 'Showing 126–150 of 312',
longLabel: 'Page 6 of 13',
},
{
name: 'Authors',
view: 'authors',
route: '/authors',
path: '/v1/author/',
noun: 'authors',
nounOne: 'author',
total: 128,
pages: 6,
middle: 3,
middleSummary: 'Showing 51–75 of 128',
lastRows: 3,
lastSummary: 'Showing 126–128 of 128',
searchTerm: 'mar',
searchPlaceholder: 'Search by name or slug',
emptyTitle: 'No authors match "zoe"',
emptyTerm: 'zoe',
searchFrom: 5,
searchTotal: 31,
longPage: 12,
longTotal: 1284,
longSummary: 'Showing 276–300 of 1,284',
longLabel: 'Page 12 of 52',
},
]
function generatedSites(count: number, prefix = 'Site'): Site[] {
const template = sitesFixture()[0]!
return Array.from({ length: count }, (_, index) => ({
...template,
id: index + 1,
name: `${prefix} ${index + 1}`,
key: `${prefix.toLowerCase()}-${index + 1}`,
base_url: `https://${prefix.toLowerCase()}-${index + 1}.example`,
is_default: index === 0,
article_count: index,
}))
}
function generatedAuthors(count: number, prefix = 'Author'): Author[] {
const template = authorsFixture()[1]!
return Array.from({ length: count }, (_, index) => ({
...template,
id: index + 1,
name: `${prefix} ${index + 1}`,
slug: `${prefix.toLowerCase()}-${index + 1}`,
user_id: null,
is_linked: false,
article_count: 0,
}))
}
function backendFor(screen: Screen, total = screen.total): Backend {
const backend = createBackend()
if (screen.view === 'sites') {
backend.sites = generatedSites(total)
} else {
backend.authors = generatedAuthors(total)
}
return backend
}
function listCalls(backend: Backend, screen: Screen) {
return callsTo(backend, 'GET', screen.path).filter((call) => call.query.has('page'))
}
function unpagedCalls(backend: Backend, screen: Screen) {
return callsTo(backend, 'GET', screen.path).filter((call) => !call.query.has('page'))
}
function summary() {
return document.body.querySelector('[data-testid="paginator-summary"]')?.textContent?.trim()
}
function countLine() {
return document.body.querySelector('[data-testid="paginator-count"]')?.textContent?.trim()
}
function control(label: string) {
return document.body.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
}
function pageNav() {
return document.body.querySelector('nav[aria-label="Pagination"]')
}
function currentPage() {
return pageNav()?.querySelector('[aria-current="page"]')?.textContent?.trim()
}
function sizeTrigger() {
return document.body.querySelector<HTMLElement>('[aria-label="Rows per page"]')
}
function alertText() {
return textOf(document.body.querySelector('[role="alert"]'))
}
async function chooseSize(label: string) {
sizeTrigger()?.click()
await flushPromises()
const option = Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]')).find(
(element) => element.textContent?.trim() === label,
)
option!.click()
await flushPromises()
}
async function searchFor(term: string) {
const input = document.body.querySelector<HTMLInputElement>('input[type="search"]')!
await typeInto(input, term)
await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS)
await flushPromises()
}
afterEach(async () => {
setLocale('en')
await unmountDirectory()
})
describe.each(SCREENS)('$name: pagination', (screen) => {
it('1 First page', async () => {
const backend = backendFor(screen)
await mountDirectory(screen.view, screen.route, { backend })
expect(listCalls(backend, screen).map((call) => call.raw)).toEqual([
`${screen.path}?page=1&size=25`,
])
expect(summary()).toBe(`Showing 1–25 of ${screen.total}`)
expect(rows()).toHaveLength(25)
expect(sizeTrigger()?.textContent).toContain('25 per page')
expect(control('First page')?.disabled).toBe(true)
expect(control('Previous page')?.disabled).toBe(true)
expect(control('Next page')?.disabled).toBe(false)
expect(control('Last page')?.disabled).toBe(false)
expect(currentPage()).toBe('1')
expect(router.currentRoute.value.fullPath).toBe(screen.route)
})
it('1 First page, below md: Page 1 of N with Previous disabled', async () => {
await mountDirectory(screen.view, screen.route, {
backend: backendFor(screen),
width: MOBILE_WIDTH,
})
expect(textOf(pageNav())).toContain(`Page 1 of ${screen.pages}`)
expect(control('Previous page')?.disabled).toBe(true)
expect(control('Next page')?.disabled).toBe(false)
})
it('2 Middle page', async () => {
const backend = backendFor(screen)
await mountDirectory(screen.view, `${screen.route}?page=${screen.middle}`, { backend })
expect(summary()).toBe(screen.middleSummary)
expect(currentPage()).toBe(String(screen.middle))
for (const label of ['First page', 'Previous page', 'Next page', 'Last page']) {
expect(control(label)?.disabled).toBe(false)
}
})
it('3 Last page', async () => {
const backend = backendFor(screen)
await mountDirectory(screen.view, `${screen.route}?page=${screen.pages}`, { backend })
expect(summary()).toBe(screen.lastSummary)
expect(rows()).toHaveLength(screen.lastRows)
expect(control('Next page')?.disabled).toBe(true)
expect(control('Last page')?.disabled).toBe(true)
expect(control('Previous page')?.disabled).toBe(false)
expect(control('First page')?.disabled).toBe(false)
})
it('4 Single page', async () => {
const total = screen.view === 'sites' ? 4 : 6
await mountDirectory(screen.view, screen.route, { backend: backendFor(screen, total) })
expect(countLine()).toBe(`${total} ${screen.noun}`)
expect(pageNav()).toBeNull()
expect(sizeTrigger()).toBeNull()
expect(summary()).toBeUndefined()
expect(document.body.textContent).not.toContain('Showing')
})
it('4b Singular count line', async () => {
await mountDirectory(screen.view, screen.route, { backend: backendFor(screen, 1) })
expect(countLine()).toBe(`1 ${screen.nounOne}`)
expect(sizeTrigger()).toBeNull()
})
it('4b Count line in French', async () => {
setLocale('fr')
const total = 6
await mountDirectory(screen.view, screen.route, { backend: backendFor(screen, total) })
expect(countLine()).toBe(screen.view === 'sites' ? '6 sites' : '6 auteurs')
})
it('4c Five per page with a few rows still pages', async () => {
const backend = backendFor(screen, 6)
await mountDirectory(screen.view, `${screen.route}?size=5`, { backend })
expect(listCalls(backend, screen)[0]!.raw).toBe(`${screen.path}?page=1&size=5`)
expect(summary()).toBe('Showing 1–5 of 6')
expect(sizeTrigger()?.textContent).toContain('5 per page')
expect(pageNav()?.querySelectorAll('button[aria-current]')).toHaveLength(1)
expect(control('Next page')?.disabled).toBe(false)
})
it.each([
['5 per page', 5, 'size=5'],
['25 per page', 25, ''],
['50 per page', 50, 'size=50'],
['100 per page', 100, 'size=100'],
])('5 Page size menu: %s', async (label, size, search) => {
const backend = backendFor(screen)
await mountDirectory(screen.view, `${screen.route}?page=${screen.middle}`, { backend })
await chooseSize(label)
expect(listCalls(backend, screen).at(-1)!.raw).toBe(`${screen.path}?page=1&size=${size}`)
expect(router.currentRoute.value.fullPath).toBe(
search ? `${screen.route}?${search}` : screen.route,
)
expect(router.currentRoute.value.query.page).toBeUndefined()
expect(pageNav() === null || currentPage() === '1').toBe(true)
})
it('5 Page size menu: ArrowDown opens, Escape closes', async () => {
await mountDirectory(screen.view, screen.route, { backend: backendFor(screen) })
const trigger = sizeTrigger()!
trigger.focus()
trigger.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
)
await flushPromises()
expect(document.body.querySelector('[role="listbox"]')).not.toBeNull()
;(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
)
await flushPromises()
expect(document.body.querySelector('[role="listbox"]')).toBeNull()
})
it.each([
['desktop', DESKTOP_WIDTH, 8],
['below md', MOBILE_WIDTH, 6],
])('6 Loading a page on %s', async (_name, width, skeletonRows) => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const backend = backendFor(screen)
await mountDirectory(screen.view, `${screen.route}?page=${screen.middle}`, {
backend,
width,
})
const next = screen.middle + 1
const hold = pending<unknown>()
backend.responders[`GET ${screen.path}?page=${next}&size=25`] = () => hold.promise
control('Next page')!.click()
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS)
await flushPromises()
expect(
document.body.querySelectorAll('[data-testid="directory-skeleton-row"]'),
).toHaveLength(skeletonRows)
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
expect(summary()).toBe(`Loading page ${next}…`)
expect(sizeTrigger()?.hasAttribute('disabled')).toBe(true)
expect(control('Next page')?.disabled).toBe(true)
expect(router.currentRoute.value.fullPath).toBe(`${screen.route}?page=${screen.middle}`)
if (width === DESKTOP_WIDTH) {
expect(currentPage()).toBe(String(screen.middle))
} else {
expect(textOf(pageNav())).toContain(`Page ${screen.middle} of`)
}
hold.resolve(
paged(
screen.view === 'sites'
? generatedSites(25, 'Next')
: { items: generatedAuthors(25, 'Next'), total: screen.total },
screen.total,
),
)
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(`${screen.route}?page=${next}`)
expect(rows()).toHaveLength(25)
expect(summary()).toContain(`of ${screen.total}`)
})
it.each([
['a 500 response', failWith(500)],
['a network failure', networkFailure],
])('7 Error loading a page with %s keeps the previous rows', async (_name, failure) => {
const backend = backendFor(screen)
await mountDirectory(screen.view, `${screen.route}?page=${screen.middle}`, { backend })
const next = screen.middle + 1
backend.responders[`GET ${screen.path}?page=${next}&size=25`] = failure
control('Next page')!.click()
await flushPromises()
expect(rows()).toHaveLength(25)
expect(currentPage()).toBe(String(screen.middle))
expect(control('Next page')?.disabled).toBe(false)
expect(router.currentRoute.value.fullPath).toBe(`${screen.route}?page=${screen.middle}`)
expect(alertText()).toContain(`Couldn't load page ${next}. Showing page ${screen.middle}.`)
expect(alertText()).toContain('Check your connection and try again.')
delete backend.responders[`GET ${screen.path}?page=${next}&size=25`]
await click('Retry')
expect(listCalls(backend, screen).at(-1)!.raw).toBe(`${screen.path}?page=${next}&size=25`)
expect(router.currentRoute.value.fullPath).toBe(`${screen.route}?page=${next}`)
expect(currentPage()).toBe(String(next))
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
it('7 A first-load failure keeps its own alert', async () => {
const backend = backendFor(screen)
backend.responders[`GET ${screen.path}`] = failWith(500)
await mountDirectory(screen.view, screen.route, { backend })
expect(alertText()).toContain(`Couldn't load ${screen.noun}`)
expect(alertText()).toContain('Try again')
expect(rows()).toHaveLength(0)
expect(summary()).toBeUndefined()
})
it('7 A response without X-Total-Count is a failed load', async () => {
const backend = backendFor(screen)
backend.responders[`GET ${screen.path}`] = () => {
throw new MissingTotalError()
}
await mountDirectory(screen.view, screen.route, { backend })
expect(alertText()).toContain(`Couldn't load ${screen.noun}`)
expect(rows()).toHaveLength(0)
})
it('8 Empty after search', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const backend = backendFor(screen)
await mountDirectory(screen.view, screen.route, { backend })
backend.responders[`GET ${screen.path}?q=${screen.emptyTerm}&page=1&size=25`] = () =>
screen.view === 'sites' ? paged([], 0) : { items: [], total: 0, page: 1, size: 25 }
await searchFor(screen.emptyTerm)
const text = textOf(document.body)
expect(text).toContain(screen.emptyTitle)
expect(text).toContain('Check the spelling, or search for a shorter part of the name.')
expect(text).toContain('Clear search')
expect(summary()).toBeUndefined()
expect(countLine()).toBeUndefined()
expect(pageNav()).toBeNull()
delete backend.responders[`GET ${screen.path}?q=${screen.emptyTerm}&page=1&size=25`]
await click('Clear search')
expect(router.currentRoute.value.fullPath).toBe(screen.route)
expect(listCalls(backend, screen).at(-1)!.raw).toBe(`${screen.path}?page=1&size=25`)
expect(rows().length).toBeGreaterThan(0)
})
it('9 Search resets to page 1', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const backend = backendFor(screen)
await mountDirectory(screen.view, `${screen.route}?page=${screen.searchFrom}`, {
backend,
})
const matches = screen.searchTotal
backend.responders[`GET ${screen.path}?q=${screen.searchTerm}&page=1&size=25`] = () =>
screen.view === 'sites'
? paged(generatedSites(25, 'Match'), matches)
: { items: generatedAuthors(25, 'Match'), total: matches, page: 1, size: 25 }
await searchFor(screen.searchTerm)
expect(listCalls(backend, screen).at(-1)!.raw).toBe(
`${screen.path}?q=${screen.searchTerm}&page=1&size=25`,
)
expect(router.currentRoute.value.fullPath).toBe(`${screen.route}?q=${screen.searchTerm}`)
expect(summary()).toBe(`Showing 1–25 of ${matches}`)
})
it('9 A size change resets the page', async () => {
const backend = backendFor(screen)
await mountDirectory(screen.view, `${screen.route}?page=${screen.middle}`, { backend })
await chooseSize('50 per page')
expect(listCalls(backend, screen).at(-1)!.raw).toBe(`${screen.path}?page=1&size=50`)
expect(router.currentRoute.value.query.page).toBeUndefined()
})
it('10 Long labels on mobile', async () => {
const backend = backendFor(screen)
backend.responders[`GET ${screen.path}?page=${screen.longPage}&size=25`] = () =>
screen.view === 'sites'
? paged(generatedSites(25, 'Long'), screen.longTotal)
: {
items: generatedAuthors(25, 'Long'),
total: screen.longTotal,
page: screen.longPage,
size: 25,
}
await mountDirectory(screen.view, `${screen.route}?page=${screen.longPage}`, {
backend,
width: MOBILE_WIDTH,
})
expect(summary()).toBe(screen.longSummary)
const label = Array.from(pageNav()!.querySelectorAll('span')).find(
(element) => element.textContent?.trim() === screen.longLabel,
)!
expect(label.className).toContain('whitespace-nowrap')
expect(control('Previous page')?.className).toContain('flex-1')
expect(control('Next page')?.className).toContain('flex-1')
})
})
describe.each(SCREENS)('$name: URL rules', (screen) => {
it('Invalid values fall back silently', async () => {
const backend = backendFor(screen)
const replace = vi.spyOn(router, 'replace')
const push = vi.spyOn(router, 'push')
await mountDirectory(screen.view, `${screen.route}?size=30&page=abc`, { backend })
const pushesAfterMount = push.mock.calls.length
expect(listCalls(backend, screen).map((call) => call.raw)).toEqual([
`${screen.path}?page=1&size=25`,
])
expect(replace).toHaveBeenCalledWith({ query: {} })
expect(push.mock.calls.length).toBe(pushesAfterMount)
expect(router.currentRoute.value.fullPath).toBe(screen.route)
expect(document.body.querySelector('[role="status"]')).toBeNull()
replace.mockRestore()
push.mockRestore()
})
it.each(['0', '-2'])('Page %s falls back to page 1', async (page) => {
const backend = backendFor(screen)
await mountDirectory(screen.view, `${screen.route}?page=${page}`, { backend })
expect(listCalls(backend, screen)[0]!.raw).toBe(`${screen.path}?page=1&size=25`)
expect(router.currentRoute.value.fullPath).toBe(screen.route)
})
it('A page out of range goes to the last page with a notice', async () => {
const backend = backendFor(screen)
const replace = vi.spyOn(router, 'replace')
await mountDirectory(screen.view, `${screen.route}?page=99`, { backend })
expect(listCalls(backend, screen).map((call) => call.raw)).toEqual([
`${screen.path}?page=99&size=25`,
`${screen.path}?page=${screen.pages}&size=25`,
])
expect(replace).toHaveBeenCalledWith({ query: { page: String(screen.pages) } })
expect(router.currentRoute.value.fullPath).toBe(`${screen.route}?page=${screen.pages}`)
expect(textOf(document.body.querySelector('[role="status"]'))).toContain(
"That page doesn't exist. Showing the last page.",
)
expect(summary()).toBe(screen.lastSummary)
replace.mockRestore()
})
it('A page out of range with a total of 0 shows the empty state', async () => {
const backend = backendFor(screen, 0)
await mountDirectory(screen.view, `${screen.route}?page=5`, { backend })
expect(listCalls(backend, screen)).toHaveLength(1)
expect(document.body.querySelector('[role="status"]')).toBeNull()
expect(router.currentRoute.value.fullPath).toBe(screen.route)
expect(rows()).toHaveLength(0)
expect(pageNav()).toBeNull()
expect(countLine()).toBeUndefined()
})
it('Combined q and page in the URL', async () => {
const backend = backendFor(screen)
await mountDirectory(screen.view, `${screen.route}?q=${screen.searchTerm}&page=2`, {
backend,
})
expect(listCalls(backend, screen)[0]!.raw).toBe(
`${screen.path}?q=${screen.searchTerm}&page=2&size=25`,
)
expect(document.body.querySelector<HTMLInputElement>('input[type="search"]')!.value).toBe(
screen.searchTerm,
)
})
it('Browser Back returns to the previous page of results', async () => {
const backend = backendFor(screen)
await mountDirectory(screen.view, screen.route, { backend })
control('Next page')!.click()
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(`${screen.route}?page=2`)
router.back()
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 20))
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(screen.route)
expect(listCalls(backend, screen).at(-1)!.raw).toBe(`${screen.path}?page=1&size=25`)
expect(currentPage()).toBe('1')
})
})
describe('Sites: search', () => {
it('shows the Sites search field and sends q', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const backend = backendFor(SCREENS[0]!)
await mountDirectory('sites', '/settings/sites', { backend })
expect(
document.body.querySelector('input[type="search"]')?.getAttribute('placeholder'),
).toBe('Search by name or key')
await searchFor('renew')
expect(listCalls(backend, SCREENS[0]!).at(-1)!.raw).toBe('/v1/site/?q=renew&page=1&size=25')
})
it('has a French placeholder', async () => {
setLocale('fr')
await mountDirectory('sites', '/settings/sites', { backend: backendFor(SCREENS[0]!) })
expect(
document.body.querySelector('input[type="search"]')?.getAttribute('placeholder'),
).toBe('Rechercher par nom ou clé')
})
it('a search that matches only one site lists what the API returns', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const backend = backendFor(SCREENS[0]!)
backend.responders['GET /v1/site/?q=journal&page=1&size=25'] = () =>
paged([{ ...generatedSites(1)[0]!, name: 'Journal', is_default: false }], 1)
await mountDirectory('sites', '/settings/sites', { backend })
await searchFor('journal')
expect(rows()).toHaveLength(1)
expect(textOf(rows()[0])).toContain('Journal')
expect(countLine()).toBe('1 site')
expect(document.body.textContent).not.toContain('Add another site')
})
})
describe('Sites: the unpaged list feeds the dependents', () => {
it('?edit=journal reads GET /v1/site/ without page and size', async () => {
const backend = backendFor(SCREENS[0]!)
backend.sites = [
...generatedSites(60),
{
...generatedSites(1)[0]!,
id: 900,
name: 'Journal',
key: 'journal',
is_default: false,
},
]
await mountDirectory('sites', '/settings/sites?page=2&edit=journal', { backend })
expect(unpagedCalls(backend, SCREENS[0]!)).toHaveLength(1)
expect(unpagedCalls(backend, SCREENS[0]!)[0]!.raw).toBe('/v1/site/')
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull()
expect(textOf(document.body.querySelector('[role="dialog"]'))).toContain('Edit Journal')
})
it('the add form checks duplicate keys against the unpaged list', async () => {
const backend = backendFor(SCREENS[0]!)
backend.sites = [
...generatedSites(60),
{
...generatedSites(1)[0]!,
id: 900,
name: 'Journal',
key: 'journal',
is_default: false,
},
]
await mountDirectory('sites', '/settings/sites?page=3', { backend })
await click('Add site')
await typeInto(field('e.g. Journal'), 'Journal')
await typeInto(field('https://blog.example.com'), 'https://journal2.example')
const key = labelled('Key') as HTMLInputElement
await blur(key)
expect(unpagedCalls(backend, SCREENS[0]!)).toHaveLength(1)
expect(textOf(dialog())).toContain(
'A site with the key "journal" already exists. Choose another key.',
)
})
it('the delete dialog offers destinations from the unpaged list', async () => {
const backend = backendFor(SCREENS[0]!)
await mountDirectory('sites', '/settings/sites?page=2', { backend })
const before = unpagedCalls(backend, SCREENS[0]!).length
const trigger = document.body.querySelector<HTMLElement>('[aria-label^="Actions for"]')!
trigger.click()
await flushPromises()
const remove = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
).find((item) => textOf(item) === 'Delete…')!
remove.click()
await flushPromises()
expect(unpagedCalls(backend, SCREENS[0]!).length).toBe(before + 1)
expect(unpagedCalls(backend, SCREENS[0]!).at(-1)!.raw).toBe('/v1/site/')
})
})
describe('Authors: roles', () => {
it('member sees the same footer with no Add author button and no row menu', async () => {
await mountDirectory('authors', '/authors?page=2', {
backend: backendFor(SCREENS[1]!),
role: 'member',
})
expect(summary()).toBe('Showing 26–50 of 128')
expect(currentPage()).toBe('2')
expect(document.body.textContent).not.toContain('Add author')
expect(document.body.querySelector('[aria-label^="Actions for"]')).toBeNull()
})
it('admin sees the same footer with the Add author button and the row menu', async () => {
await mountDirectory('authors', '/authors?page=2', {
backend: backendFor(SCREENS[1]!),
role: 'admin',
})
expect(summary()).toBe('Showing 26–50 of 128')
expect(currentPage()).toBe('2')
expect(document.body.textContent).toContain('Add author')
expect(document.body.querySelector('[aria-label^="Actions for"]')).not.toBeNull()
})
})
describe('Authors: list changes', () => {
it('deleting the last row of the last page requests the previous page', async () => {
const backend = backendFor(SCREENS[1]!, 26)
await mountDirectory('authors', '/authors?page=2', { backend })
expect(rows()).toHaveLength(1)
const trigger = document.body.querySelector<HTMLElement>('[aria-label^="Actions for"]')!
trigger.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find((item) => textOf(item) === 'Delete…')!
.click()
await flushPromises()
const confirm = Array.from(document.body.querySelectorAll<HTMLElement>('button')).find(
(item) => textOf(item) === 'Delete author',
)!
confirm.click()
await flushPromises()
expect(listCalls(backend, SCREENS[1]!).at(-1)!.raw).toBe('/v1/author/?page=1&size=25')
expect(router.currentRoute.value.fullPath).toBe('/authors')
})
it('a 26th row turns the count line into the pager', async () => {
const backend = backendFor(SCREENS[1]!, 25)
await mountDirectory('authors', '/authors', { backend })
expect(countLine()).toBe('25 authors')
await click('Add author')
const form = dialog()!
await typeInto(field('e.g. Camille Roche'), 'Zoe Lambert')
await click('Add author', form)
expect(summary()).toBe('Showing 1–25 of 26')
expect(countLine()).toBeUndefined()
expect(pageNav()).not.toBeNull()
})
})
+5 -3
View File
@@ -184,17 +184,19 @@ describe('Sites form: add succeeds', () => {
expect(button('Saving…', box())).toBeTruthy()
expect(field('e.g. Journal').disabled).toBe(true)
hold.resolve({
const created = {
id: 5,
name: 'Notes',
key: 'notes',
base_url: 'https://notes.example',
default_social_image_id: null,
default_robots: 'noindex',
default_robots: 'noindex' as const,
locale: null,
is_default: false,
article_count: 0,
})
}
backend.sites.push(created)
hold.resolve(created)
await flushPromises()
const posts = callsTo(backend, 'POST', '/v1/site/')
+39 -1
View File
@@ -21,6 +21,20 @@ export interface Call {
body: Record<string, unknown> | undefined
}
export class PagedReply {
body: unknown
total: number
constructor(body: unknown, total: number) {
this.body = body
this.total = total
}
}
export function paged(body: unknown, total: number) {
return new PagedReply(body, total)
}
export type Responder = (call: Call) => unknown | Promise<unknown>
export interface Backend {
@@ -177,12 +191,25 @@ function pageOf(backend: Backend, query: URLSearchParams) {
}
}
function sitePageOf(backend: Backend, query: URLSearchParams) {
const q = (query.get('q') ?? '').toLowerCase()
const size = Number(query.get('size') ?? 25)
const page = Number(query.get('page') ?? 1)
const matching = backend.sites.filter((item) =>
`${item.name} ${item.key} ${item.base_url}`.toLowerCase().includes(q),
)
return new PagedReply(
structuredClone(matching.slice((page - 1) * size, page * size)),
matching.length,
)
}
export function defaultRoute(backend: Backend, call: Call): unknown {
const { method, path, query, body } = call
const siteMatch = /^\/v1\/site\/(\d+)(\/default)?$/.exec(path)
const authorMatch = /^\/v1\/author\/(\d+)$/.exec(path)
if (method === 'GET' && path === '/v1/site/') {
return structuredClone(backend.sites)
return query.has('page') ? sitePageOf(backend, query) : structuredClone(backend.sites)
}
if (method === 'POST' && path === '/v1/site/') {
if (backend.sites.some((site) => site.key === body?.key)) {
@@ -349,9 +376,20 @@ export async function mountDirectory(
backend.responders[`${call.method} ${pathname}`]
return responder ? responder(call) : defaultRoute(backend, call)
})
const requestPaged = vi.fn(async (path: string, init?: RequestInit) => {
const result = await request(path, init)
if (result instanceof PagedReply) {
return { body: result.body, total: result.total }
}
if (Array.isArray(result)) {
return { body: result, total: result.length }
}
return { body: result, total: (result as { total: number }).total }
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
request,
requestPaged,
} as unknown as ReturnType<typeof useApi>)
await router.push(url)
const component = view === 'sites' ? SitesView : AuthorsView