feat(SPA): screens for Sites and Authors
This commit is contained in:
1 parent
b34a173d83
commit
3d81d2c717
82 files changed
+10676
-37
No files matched your search
@@ -0,0 +1,752 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } 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'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
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 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 { 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 NOBODY = 'none'
|
||||
const LOOKUP_SIZE = 100
|
||||
|
||||
type PanelTarget =
|
||||
| { kind: 'add' }
|
||||
| { kind: 'edit'; author: Author; own: boolean }
|
||||
| { 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 removal = useDirectoryDelete<Author>()
|
||||
|
||||
const { data: result, showSkeleton } = collection
|
||||
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 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 === '',
|
||||
)
|
||||
const noResults = computed(
|
||||
() => loadState.value === 'ready' && total.value === 0 && query.value.q !== '',
|
||||
)
|
||||
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,
|
||||
)
|
||||
const showPinnedButton = computed(
|
||||
() =>
|
||||
!isDesktop.value &&
|
||||
canManage.value &&
|
||||
!overlayOpen.value &&
|
||||
loadState.value === 'ready' &&
|
||||
!firstRun.value,
|
||||
)
|
||||
const description = computed(() =>
|
||||
canManage.value
|
||||
? t("The names readers see on your articles. An author doesn't need an account.")
|
||||
: t('The names readers see on your articles. Only an owner or admin can change authors.'),
|
||||
)
|
||||
const headings = computed(() => [t('Author'), t('Slug'), t('Account'), t('Articles'), ''])
|
||||
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,
|
||||
() => {
|
||||
if (loadState.value === 'ready') {
|
||||
followLink()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
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) {
|
||||
return loaded
|
||||
}
|
||||
try {
|
||||
const found = await listAuthors({ q: slug, page: 1, size: LOOKUP_SIZE })
|
||||
return found.items.find((author) => author.slug === slug) ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function openFor(author: Author): PanelTarget {
|
||||
if (canManage.value) {
|
||||
return { kind: 'edit', author, own: false }
|
||||
}
|
||||
return author.slug === ownSlug.value
|
||||
? { kind: 'edit', author, own: true }
|
||||
: { kind: 'view', author }
|
||||
}
|
||||
|
||||
function followLink() {
|
||||
return followEditLink(
|
||||
findBySlug,
|
||||
(author) => {
|
||||
panel.value = openFor(author)
|
||||
},
|
||||
toasts.authorGone,
|
||||
)
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
rememberOpener()
|
||||
panel.value = { kind: 'add' }
|
||||
}
|
||||
|
||||
function openEdit(author: Author, trigger: HTMLElement | null) {
|
||||
rememberOpener(trigger)
|
||||
panel.value = { kind: 'edit', author, own: false }
|
||||
}
|
||||
|
||||
function openRow(author: Author) {
|
||||
rememberOpener()
|
||||
panel.value = { kind: 'view', author }
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
panel.value = null
|
||||
leavePanel()
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
function onSaved(author: Author) {
|
||||
if (items.value.some((item) => item.id === author.id)) {
|
||||
replaceItem(author)
|
||||
} else {
|
||||
load(query.value)
|
||||
}
|
||||
closePanel()
|
||||
}
|
||||
|
||||
function onGone(id: number) {
|
||||
removeItem(id)
|
||||
closePanel()
|
||||
}
|
||||
|
||||
function openLink(author: Author, trigger: HTMLElement | null) {
|
||||
rememberOpener(trigger)
|
||||
linkTarget.value = author
|
||||
}
|
||||
|
||||
function closeLink() {
|
||||
linkTarget.value = null
|
||||
restoreFocus()
|
||||
}
|
||||
|
||||
function onLinked(author: Author) {
|
||||
replaceItem(author)
|
||||
closeLink()
|
||||
}
|
||||
|
||||
function onLinkGone(id: number) {
|
||||
removeItem(id)
|
||||
closeLink()
|
||||
}
|
||||
|
||||
function nobodyOption(): ReassignOption {
|
||||
return {
|
||||
value: NOBODY,
|
||||
label: t('Nobody'),
|
||||
description: t('Remove the byline'),
|
||||
display: t('Nobody'),
|
||||
none: true,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDeleteOptions(author: Author) {
|
||||
let others: Author[] = []
|
||||
let failed = false
|
||||
try {
|
||||
others = (await listAllAuthors()).filter((candidate) => candidate.id !== author.id)
|
||||
} catch {
|
||||
failed = true
|
||||
}
|
||||
const options: ReassignOption[] = [
|
||||
...others.map((candidate) => ({
|
||||
value: candidate.id,
|
||||
label: candidate.name,
|
||||
description: candidate.slug,
|
||||
display: candidate.name,
|
||||
avatarName: candidate.name,
|
||||
})),
|
||||
nobodyOption(),
|
||||
]
|
||||
return { options, failed }
|
||||
}
|
||||
|
||||
async function openDelete(author: Author, trigger: HTMLElement | null) {
|
||||
rememberOpener(trigger)
|
||||
const loaded = author.article_count > 0 ? await loadDeleteOptions(author) : null
|
||||
deleteOptions.value = loaded?.options ?? []
|
||||
destination.value = deleteOptions.value[0]?.value
|
||||
removal.open(author)
|
||||
if (loaded?.failed) {
|
||||
removal.notice.value = {
|
||||
title: copy('loadAuthorsFailed'),
|
||||
description: copy('checkConnection'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeDelete() {
|
||||
removal.close()
|
||||
restoreFocus()
|
||||
}
|
||||
|
||||
const chosenAuthorName = computed(
|
||||
() => deleteOptions.value.find((option) => option.value === destination.value)?.label ?? '',
|
||||
)
|
||||
const deleteBody = computed(() => {
|
||||
const target = deleteTarget.value
|
||||
if (!target) {
|
||||
return { description: '', summary: '', confirm: '', pending: '' }
|
||||
}
|
||||
if (target.article_count === 0) {
|
||||
return {
|
||||
description: t(
|
||||
'"{0}" will be deleted. No articles credit this author. This can\'t be undone.',
|
||||
[target.name],
|
||||
),
|
||||
summary: '',
|
||||
confirm: t('Delete author'),
|
||||
pending: t('Deleting…'),
|
||||
}
|
||||
}
|
||||
const many = target.article_count !== 1
|
||||
return {
|
||||
description: many
|
||||
? t('"{0}" will be deleted and is credited on {1} articles. This can\'t be undone.', [
|
||||
target.name,
|
||||
target.article_count,
|
||||
])
|
||||
: t('"{0}" will be deleted and is credited on 1 article. This can\'t be undone.', [
|
||||
target.name,
|
||||
]),
|
||||
summary:
|
||||
destination.value === NOBODY
|
||||
? t(
|
||||
'The articles keep any other authors. Articles with only this author will have no byline.',
|
||||
)
|
||||
: t('Where an article has several authors, {0} is removed and the others stay.', [
|
||||
target.name,
|
||||
]),
|
||||
confirm: many
|
||||
? t('Delete and reassign {0} articles', [target.article_count])
|
||||
: t('Delete and reassign 1 article'),
|
||||
pending: t('Reassigning…'),
|
||||
}
|
||||
})
|
||||
|
||||
async function runDelete(target: Author) {
|
||||
const credited = target.article_count
|
||||
const reassignTo =
|
||||
credited === 0
|
||||
? undefined
|
||||
: destination.value === NOBODY
|
||||
? NOBODY
|
||||
: Number(destination.value)
|
||||
const receiver = reassignTo === NOBODY || credited === 0 ? null : chosenAuthorName.value
|
||||
await deleteAuthor(target.id, reassignTo)
|
||||
removeItem(target.id)
|
||||
toasts.authorDeleted(credited, receiver)
|
||||
closeDelete()
|
||||
}
|
||||
|
||||
async function handleDeleteFailure(error: unknown, target: Author) {
|
||||
const kind = classifyFailure(error, 'author')
|
||||
if (kind === 'session') {
|
||||
return
|
||||
}
|
||||
if (kind === 'hasArticles') {
|
||||
closeDelete()
|
||||
await showBlocked(target)
|
||||
return
|
||||
}
|
||||
if (toasts.report(kind, 'author')) {
|
||||
if (kind === 'gone') {
|
||||
removeItem(target.id)
|
||||
}
|
||||
closeDelete()
|
||||
return
|
||||
}
|
||||
removal.notice.value = {
|
||||
title: t("Couldn't delete the author"),
|
||||
description: copy('nothingChanged'),
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
return removal.confirm(runDelete, handleDeleteFailure)
|
||||
}
|
||||
|
||||
async function creditedCount(target: Author) {
|
||||
try {
|
||||
const found = await listAuthors({ q: target.slug, page: 1, size: LOOKUP_SIZE })
|
||||
return (
|
||||
found.items.find((item) => item.id === target.id)?.article_count || target.article_count
|
||||
)
|
||||
} catch {
|
||||
return target.article_count
|
||||
}
|
||||
}
|
||||
|
||||
async function showBlocked(target: Author) {
|
||||
const count = Math.max(1, await creditedCount(target))
|
||||
const reassign = await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
get title() {
|
||||
return t("Can't delete this author")
|
||||
},
|
||||
get description() {
|
||||
return count === 1
|
||||
? t(
|
||||
'"{0}" is credited on 1 article. Choose another author to credit instead, or remove {0} from those articles first.',
|
||||
[target.name],
|
||||
)
|
||||
: t(
|
||||
'"{0}" is credited on {1} articles. Choose another author to credit instead, or remove {0} from those articles first.',
|
||||
[target.name, count],
|
||||
)
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Reassign articles')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Close')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Reassign articles')
|
||||
},
|
||||
tone: 'neutral',
|
||||
action: async () => undefined,
|
||||
},
|
||||
})
|
||||
.open()
|
||||
if (reassign) {
|
||||
await openDelete({ ...target, article_count: count }, null)
|
||||
} else {
|
||||
restoreFocus()
|
||||
}
|
||||
}
|
||||
|
||||
function menuItems(author: Author): DirectoryMenuItem[] {
|
||||
const items: DirectoryMenuItem[] = [
|
||||
{
|
||||
key: 'edit',
|
||||
label: t('Edit'),
|
||||
icon: 'i-lucide-pencil',
|
||||
onSelect: (trigger) => openEdit(author, trigger),
|
||||
},
|
||||
]
|
||||
if (!author.is_linked) {
|
||||
items.push({
|
||||
key: 'link',
|
||||
label: t('Link user'),
|
||||
icon: 'i-lucide-link',
|
||||
onSelect: (trigger) => openLink(author, trigger),
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
key: 'delete',
|
||||
label: t('Delete…'),
|
||||
icon: 'i-lucide-trash-2',
|
||||
destructive: true,
|
||||
separated: true,
|
||||
onSelect: (trigger) => openDelete(author, trigger),
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
const noResultsTitle = computed(() => t('No authors match "{0}"', [query.value.q]))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell compact-padding :mobile-title="t('Authors')">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
|
||||
<h1 class="text-xl font-semibold text-highlighted">{{ t('Authors') }}</h1>
|
||||
<p class="text-muted">{{ description }}</p>
|
||||
</div>
|
||||
<DirectoryAddButton v-if="isDesktop && canManage" @click="openAdd">
|
||||
{{ t('Add author') }}
|
||||
</DirectoryAddButton>
|
||||
</div>
|
||||
|
||||
<UInput
|
||||
v-if="showToolbar"
|
||||
v-model="searchText"
|
||||
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' }"
|
||||
/>
|
||||
|
||||
<UAlert
|
||||
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>
|
||||
|
||||
<UEmpty
|
||||
v-else-if="firstRun"
|
||||
variant="naked"
|
||||
icon="i-lucide-feather"
|
||||
:title="t('No authors yet')"
|
||||
:description="
|
||||
t('Authors are the names readers see on articles. They don\'t need an account.')
|
||||
"
|
||||
: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 v-if="canManage" #actions>
|
||||
<UButton icon="i-lucide-plus" class="h-11 px-4 md:h-8" @click="openAdd">
|
||||
{{ t('Add author') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UEmpty>
|
||||
|
||||
<UEmpty
|
||||
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>
|
||||
|
||||
<DirectoryList
|
||||
v-else-if="showList"
|
||||
:items="items"
|
||||
:columns="LIST_COLUMNS"
|
||||
:headings="headings"
|
||||
:loading="loadState === 'loading'"
|
||||
:skeleton-rows="SKELETON_ROWS"
|
||||
:read-only="!canManage"
|
||||
:label="t('Authors')"
|
||||
:row-title="(author: Author) => author.name"
|
||||
:menu-items="menuItems"
|
||||
@open="openRow"
|
||||
>
|
||||
<template #cells="{ item }">
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<AuthorAvatar :name="item.name" :size="32" />
|
||||
<span class="truncate font-medium text-highlighted" :title="item.name">{{
|
||||
item.name
|
||||
}}</span>
|
||||
</div>
|
||||
<span class="truncate font-mono text-xs/4.5 text-muted">{{ item.slug }}</span>
|
||||
<span class="flex">
|
||||
<DirectoryBadge :kind="item.is_linked ? 'linked' : 'guest'" />
|
||||
</span>
|
||||
<span class="text-muted">{{ pluralArticles(item.article_count) }}</span>
|
||||
</template>
|
||||
<template #card="{ item }">
|
||||
<AuthorAvatar :name="item.name" :size="40" />
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.75">
|
||||
<span
|
||||
class="truncate text-md/5.25 font-medium text-highlighted"
|
||||
:title="item.name"
|
||||
>{{ item.name }}</span
|
||||
>
|
||||
<span class="truncate font-mono text-xs/4 text-muted">{{ item.slug }}</span>
|
||||
<div class="flex items-center gap-2 text-xs text-muted">
|
||||
<DirectoryBadge :kind="item.is_linked ? 'linked' : 'guest'" />
|
||||
<span>{{ pluralArticles(item.article_count) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DirectoryList>
|
||||
|
||||
<AppPaginator
|
||||
v-if="showPaginator"
|
||||
:page="query.page"
|
||||
:page-count="pageCount"
|
||||
:total="total"
|
||||
:size="query.size"
|
||||
:loading-page="pendingPage"
|
||||
@update:page="changePage"
|
||||
@update:size="changeSize"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DirectoryAddButton v-if="showPinnedButton" pinned @click="openAdd">
|
||||
{{ t('Add author') }}
|
||||
</DirectoryAddButton>
|
||||
|
||||
<AuthorFormPanel
|
||||
v-if="panel && (panel.kind === 'add' || panel.kind === 'edit')"
|
||||
:key="panel.kind === 'edit' ? panel.author.id : 'add'"
|
||||
:author="panel.kind === 'edit' ? panel.author : undefined"
|
||||
:own-profile="panel.kind === 'edit' && panel.own"
|
||||
:own-email="session.session?.email ?? ''"
|
||||
:known-slugs="knownSlugs"
|
||||
@close="closePanel"
|
||||
@saved="onSaved"
|
||||
@updated="replaceItem"
|
||||
@gone="onGone"
|
||||
/>
|
||||
|
||||
<AuthorViewPanel
|
||||
v-if="panel && panel.kind === 'view'"
|
||||
:author="panel.author"
|
||||
@close="closePanel"
|
||||
/>
|
||||
|
||||
<LinkUserDialog
|
||||
v-if="linkTarget"
|
||||
:author="linkTarget"
|
||||
@close="closeLink"
|
||||
@linked="onLinked"
|
||||
@gone="onLinkGone"
|
||||
/>
|
||||
|
||||
<ReassignDialog
|
||||
v-if="deleteTarget"
|
||||
v-model:selected="destination"
|
||||
closable
|
||||
:title="t('Delete this author?')"
|
||||
:description="deleteBody.description"
|
||||
:confirm-label="deleteBody.confirm"
|
||||
:pending-label="deleteBody.pending"
|
||||
:select-label="t('Credit these articles to')"
|
||||
:options="deleteOptions"
|
||||
:summary="deleteBody.summary"
|
||||
:pending="deletePending"
|
||||
:notice="deleteNotice"
|
||||
@confirm="confirmDelete"
|
||||
@close="closeDelete"
|
||||
>
|
||||
<p v-if="deleteTarget.is_linked && deleteTarget.user_email" class="text-xs text-muted">
|
||||
{{
|
||||
t("Linked to {0}. The user account isn't affected.", [deleteTarget.user_email])
|
||||
}}
|
||||
</p>
|
||||
</ReassignDialog>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -6,6 +6,7 @@ import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import MediaLinksCard from '@/components/MediaLinksCard.vue'
|
||||
import PublishingLinksCard from '@/components/PublishingLinksCard.vue'
|
||||
import MediaCleanupCard from '@/components/MediaCleanupCard.vue'
|
||||
import RestartOverlay from '@/components/RestartOverlay.vue'
|
||||
import RetentionCard from '@/components/RetentionCard.vue'
|
||||
@@ -56,7 +57,7 @@ const updatesCard = ref<InstanceType<typeof UpdatesCard> | null>(null)
|
||||
const loadState = ref<LoadState>('loading')
|
||||
const settings = ref<WorkspaceSettings | null>(null)
|
||||
const version = computed(() => settings.value?.version ?? '')
|
||||
const isOwner = computed(() => session.session?.role === 'owner')
|
||||
const isOwner = computed(() => session.isOwner)
|
||||
const showSkeleton = ref(false)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@@ -465,6 +466,8 @@ onBeforeUnmount(() => {
|
||||
|
||||
<MediaLinksCard />
|
||||
|
||||
<PublishingLinksCard v-if="isOwner" />
|
||||
|
||||
<MediaCleanupCard />
|
||||
|
||||
<RetentionCard
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { useLocalizedOverlay } from '@/i18n/presentation'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
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 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 { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
||||
import { useDirectoryView } from '@/composables/useDirectoryView'
|
||||
import { useSites, type Site } from '@/composables/useSites'
|
||||
import { hostOf, pluralArticles } from '@/lib/directory'
|
||||
import { COPY, copy } from '@/lib/directoryCopy'
|
||||
import { classifyFailure } from '@/lib/directoryErrors'
|
||||
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
|
||||
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
|
||||
|
||||
type PanelTarget = { mode: 'add' } | { mode: 'edit'; site: Site }
|
||||
|
||||
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 { listSites, setDefaultSite, deleteSite } = useSites()
|
||||
const collection = useDirectoryCollection(listSites)
|
||||
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 forbidden = computed(
|
||||
() => (sessionReady.value && !isOwner.value) || loadState.value === 'forbidden',
|
||||
)
|
||||
const showList = computed(
|
||||
() => loadState.value === 'ready' || (loadState.value === 'loading' && showSkeleton.value),
|
||||
)
|
||||
const onlyDefault = computed(
|
||||
() => loadState.value === 'ready' && 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 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
|
||||
})
|
||||
|
||||
const destinationOptions = computed<ReassignOption[]>(() => {
|
||||
const target = deleteTarget.value
|
||||
if (!target || target.article_count === 0) {
|
||||
return []
|
||||
}
|
||||
return sites.value
|
||||
.filter((site) => site.id !== target.id)
|
||||
.map((site) => ({
|
||||
value: site.id,
|
||||
label: site.name,
|
||||
description: hostOf(site.base_url),
|
||||
display: `${site.name} · ${hostOf(site.base_url)}`,
|
||||
}))
|
||||
})
|
||||
const destination = computed(() => sites.value.find((site) => site.id === destinationId.value))
|
||||
|
||||
const deleteDescription = computed(() => {
|
||||
const target = deleteTarget.value
|
||||
if (!target) {
|
||||
return ''
|
||||
}
|
||||
if (target.article_count === 0) {
|
||||
return t('"{0}" will be deleted. No articles belong to it. This can\'t be undone.', [
|
||||
target.name,
|
||||
])
|
||||
}
|
||||
return target.article_count === 1
|
||||
? t(
|
||||
'"{0}" will be deleted. 1 article belongs to it, so choose a site to move them to. This can\'t be undone.',
|
||||
[target.name],
|
||||
)
|
||||
: t(
|
||||
'"{0}" will be deleted. {1} articles belong to it, so choose a site to move them to. This can\'t be undone.',
|
||||
[target.name, target.article_count],
|
||||
)
|
||||
})
|
||||
const deleteSummary = computed(() => {
|
||||
const target = deleteTarget.value
|
||||
const to = destination.value
|
||||
if (!target || !to) {
|
||||
return ''
|
||||
}
|
||||
return target.article_count === 1
|
||||
? t('1 article will move to {0}. Their canonical host changes to {1}.', [
|
||||
to.name,
|
||||
hostOf(to.base_url),
|
||||
])
|
||||
: t('{0} articles will move to {1}. Their canonical host changes to {2}.', [
|
||||
target.article_count,
|
||||
to.name,
|
||||
hostOf(to.base_url),
|
||||
])
|
||||
})
|
||||
const deleteConfirmLabel = computed(() => {
|
||||
const target = deleteTarget.value
|
||||
if (!target || target.article_count === 0) {
|
||||
return t('Delete site')
|
||||
}
|
||||
return target.article_count === 1
|
||||
? t('Move 1 article and delete')
|
||||
: t('Move {0} articles and delete', [target.article_count])
|
||||
})
|
||||
const deletePendingLabel = computed(() =>
|
||||
deleteTarget.value && deleteTarget.value.article_count > 0
|
||||
? t('Moving articles…')
|
||||
: t('Deleting…'),
|
||||
)
|
||||
|
||||
async function loadSites() {
|
||||
const outcome = await collection.load([])
|
||||
if (outcome === 'ready') {
|
||||
followLink()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[sessionReady, isOwner],
|
||||
([ready, owner]) => {
|
||||
if (ready && owner && !requested) {
|
||||
requested = true
|
||||
loadSites()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.query.edit,
|
||||
() => {
|
||||
if (loadState.value === 'ready' && isOwner.value) {
|
||||
followLink()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function followLink() {
|
||||
if (!isOwner.value) {
|
||||
return
|
||||
}
|
||||
return followEditLink(
|
||||
(key) => sites.value.find((candidate) => candidate.key === key) ?? null,
|
||||
(site) => {
|
||||
panel.value = { mode: 'edit', site }
|
||||
},
|
||||
toasts.siteGone,
|
||||
)
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
rememberOpener()
|
||||
panel.value = { mode: 'add' }
|
||||
}
|
||||
|
||||
function openEdit(site: Site, trigger: HTMLElement | null) {
|
||||
rememberOpener(trigger)
|
||||
panel.value = { mode: 'edit', site }
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
panel.value = null
|
||||
leavePanel()
|
||||
}
|
||||
|
||||
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]
|
||||
if (site.is_default) {
|
||||
markDefault(site.id)
|
||||
}
|
||||
}
|
||||
|
||||
function markDefault(id: number) {
|
||||
sites.value = sites.value.map((site) => ({ ...site, is_default: site.id === id }))
|
||||
}
|
||||
|
||||
function removeSite(id: number) {
|
||||
sites.value = sites.value.filter((site) => site.id !== id)
|
||||
}
|
||||
|
||||
function onSaved(site: Site) {
|
||||
upsert(site)
|
||||
closePanel()
|
||||
}
|
||||
|
||||
function onGone(id: number) {
|
||||
removeSite(id)
|
||||
closePanel()
|
||||
}
|
||||
|
||||
function confirmDefault(site: Site, trigger: HTMLElement | null) {
|
||||
rememberOpener(trigger)
|
||||
overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
get title() {
|
||||
return t('Make {0} the default site?', [site.name])
|
||||
},
|
||||
get description() {
|
||||
return t(
|
||||
'New articles start on this site. Articles that are already on other sites stay where they are.',
|
||||
)
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Make default')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Cancel')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Saving…')
|
||||
},
|
||||
tone: 'neutral',
|
||||
action: async () => {
|
||||
try {
|
||||
await setDefaultSite(site.id)
|
||||
} catch (error) {
|
||||
const kind = classifyFailure(error, 'site')
|
||||
if (kind === 'session') {
|
||||
return
|
||||
}
|
||||
if (toasts.report(kind, 'site')) {
|
||||
if (kind === 'gone') {
|
||||
removeSite(site.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
throw new Error(COPY.siteSaveFailed)
|
||||
}
|
||||
markDefault(site.id)
|
||||
toasts.siteDefaultChanged(site.name)
|
||||
},
|
||||
},
|
||||
})
|
||||
.open()
|
||||
.then(restoreFocus)
|
||||
}
|
||||
|
||||
function openDelete(site: Site, trigger: HTMLElement | null) {
|
||||
rememberOpener(trigger)
|
||||
removal.open(site)
|
||||
const options = destinationOptions.value
|
||||
const preferred = options.find((option) => option.value === defaultSite.value?.id)
|
||||
destinationId.value = Number((preferred ?? options[0])?.value) || undefined
|
||||
}
|
||||
|
||||
function closeDelete() {
|
||||
removal.close()
|
||||
restoreFocus()
|
||||
}
|
||||
|
||||
async function refreshTarget(id: number) {
|
||||
try {
|
||||
sites.value = await listSites()
|
||||
removal.target.value = sites.value.find((site) => site.id === id) ?? null
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async function runDelete(target: Site) {
|
||||
const moved = target.article_count
|
||||
const to = moved > 0 ? destination.value : undefined
|
||||
await deleteSite(target.id, to?.id)
|
||||
removeSite(target.id)
|
||||
if (to) {
|
||||
to.article_count += moved
|
||||
}
|
||||
toasts.siteDeleted(moved, to?.name ?? '')
|
||||
closeDelete()
|
||||
}
|
||||
|
||||
async function handleDeleteFailure(error: unknown, target: Site) {
|
||||
const kind = classifyFailure(error, 'site')
|
||||
if (kind === 'session') {
|
||||
return
|
||||
}
|
||||
if (kind === 'hasArticles') {
|
||||
await refreshTarget(target.id)
|
||||
destinationId.value = defaultSite.value?.id
|
||||
removal.notice.value = {
|
||||
description: t('Choose where the articles go. This site has {0} articles.', [
|
||||
deleteTarget.value?.article_count ?? target.article_count,
|
||||
]),
|
||||
}
|
||||
return
|
||||
}
|
||||
if (toasts.report(kind, 'site')) {
|
||||
if (kind === 'gone') {
|
||||
removeSite(target.id)
|
||||
}
|
||||
closeDelete()
|
||||
return
|
||||
}
|
||||
removal.notice.value = {
|
||||
title: t("Couldn't delete the site"),
|
||||
description: copy('nothingChanged'),
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
return removal.confirm(runDelete, handleDeleteFailure)
|
||||
}
|
||||
|
||||
function menuItems(site: Site): DirectoryMenuItem[] {
|
||||
const items: DirectoryMenuItem[] = [
|
||||
{
|
||||
key: 'edit',
|
||||
label: t('Edit'),
|
||||
icon: 'i-lucide-pencil',
|
||||
onSelect: (trigger) => openEdit(site, trigger),
|
||||
},
|
||||
]
|
||||
if (!site.is_default) {
|
||||
items.push({
|
||||
key: 'default',
|
||||
label: t('Set as default'),
|
||||
icon: 'i-lucide-star',
|
||||
onSelect: (trigger) => confirmDefault(site, trigger),
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
key: 'delete',
|
||||
label: t('Delete…'),
|
||||
icon: 'i-lucide-trash-2',
|
||||
destructive: true,
|
||||
separated: !site.is_default,
|
||||
unavailable: site.is_default,
|
||||
hint: site.is_default ? copy('defaultSiteBlocked') : undefined,
|
||||
onSelect: (trigger) => openDelete(site, trigger),
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
const headings = computed(() => [t('Site'), t('Base URL'), t('Articles'), ''])
|
||||
|
||||
function backToSettings() {
|
||||
router.push('/settings')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell compact-padding :mobile-title="t('Sites')">
|
||||
<template #breadcrumb>
|
||||
<UBreadcrumb
|
||||
:items="[
|
||||
{
|
||||
get label() {
|
||||
return t('Settings')
|
||||
},
|
||||
to: '/settings',
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('Sites')
|
||||
},
|
||||
},
|
||||
]"
|
||||
color="neutral"
|
||||
class="hidden lg:block"
|
||||
:ui="{ link: 'text-sm font-medium' }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<SitesForbidden v-if="forbidden" @back="backToSettings" />
|
||||
|
||||
<div v-else-if="sessionReady" class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
|
||||
<h1 class="text-xl font-semibold text-highlighted">{{ t('Sites') }}</h1>
|
||||
<p class="text-muted">{{ t('The frontends that display your content.') }}</p>
|
||||
</div>
|
||||
<DirectoryAddButton v-if="isDesktop" @click="openAdd">
|
||||
{{ t('Add site') }}
|
||||
</DirectoryAddButton>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
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>
|
||||
|
||||
<DirectoryList
|
||||
v-if="showList"
|
||||
:items="sites"
|
||||
:columns="LIST_COLUMNS"
|
||||
:headings="headings"
|
||||
:loading="loadState === 'loading'"
|
||||
:skeleton-rows="SKELETON_ROWS"
|
||||
:label="t('Sites')"
|
||||
:row-title="(site: Site) => site.name"
|
||||
:menu-items="menuItems"
|
||||
>
|
||||
<template #cells="{ item }">
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class="truncate font-medium text-highlighted"
|
||||
:title="item.name"
|
||||
>{{ item.name }}</span
|
||||
>
|
||||
<DirectoryBadge v-if="item.is_default" kind="default" />
|
||||
<DirectoryBadge
|
||||
v-if="item.default_robots === 'noindex'"
|
||||
kind="noindex"
|
||||
/>
|
||||
</div>
|
||||
<span class="font-mono text-xs/4 text-muted">{{ item.key }}</span>
|
||||
</div>
|
||||
<span class="truncate font-mono text-xs/4.5 text-toned">{{
|
||||
item.base_url
|
||||
}}</span>
|
||||
<span class="text-muted">{{ pluralArticles(item.article_count) }}</span>
|
||||
</template>
|
||||
<template #card="{ item }">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="truncate font-medium text-highlighted">{{
|
||||
item.name
|
||||
}}</span>
|
||||
<DirectoryBadge v-if="item.is_default" kind="default" />
|
||||
<DirectoryBadge
|
||||
v-if="item.default_robots === 'noindex'"
|
||||
kind="noindex"
|
||||
/>
|
||||
</div>
|
||||
<span class="truncate font-mono text-xs/4.5 text-toned">{{
|
||||
hostOf(item.base_url)
|
||||
}}</span>
|
||||
<span class="text-xs/5 text-muted">{{
|
||||
pluralArticles(item.article_count)
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
</DirectoryList>
|
||||
|
||||
<p v-if="onlyDefault" class="text-pretty text-muted">
|
||||
{{
|
||||
t(
|
||||
'Add another site if your content is shown on more than one frontend. Each article belongs to one site.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DirectoryAddButton
|
||||
v-if="!isDesktop && !forbidden && loadState !== 'error'"
|
||||
v-show="!overlayOpen"
|
||||
pinned
|
||||
@click="openAdd"
|
||||
>
|
||||
{{ t('Add site') }}
|
||||
</DirectoryAddButton>
|
||||
|
||||
<SiteFormPanel
|
||||
v-if="panel"
|
||||
:key="panel.mode === 'edit' ? panel.site.id : 'add'"
|
||||
:site="panelSite"
|
||||
:sites="sites"
|
||||
@close="closePanel"
|
||||
@saved="onSaved"
|
||||
@updated="upsert"
|
||||
@defaulted="upsert"
|
||||
@gone="onGone"
|
||||
/>
|
||||
|
||||
<ReassignDialog
|
||||
v-if="deleteTarget"
|
||||
v-model:selected="destinationId"
|
||||
:title="t('Delete this site?')"
|
||||
:description="deleteDescription"
|
||||
:confirm-label="deleteConfirmLabel"
|
||||
:pending-label="deletePendingLabel"
|
||||
:select-label="t('Move articles to')"
|
||||
:options="destinationOptions"
|
||||
:summary="deleteSummary"
|
||||
:pending="deletePending"
|
||||
:notice="deleteNotice"
|
||||
@confirm="confirmDelete"
|
||||
@close="closeDelete"
|
||||
/>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -54,9 +54,7 @@ let formOpener: HTMLElement | null = null
|
||||
let requested = false
|
||||
|
||||
const sessionReady = computed(() => session.status === 'ready')
|
||||
const canManage = computed(
|
||||
() => session.session?.role === 'owner' || session.session?.role === 'admin',
|
||||
)
|
||||
const canManage = computed(() => session.canManageContent)
|
||||
const forbidden = computed(
|
||||
() => (sessionReady.value && !canManage.value) || store.loadState === 'forbidden',
|
||||
)
|
||||
|
||||
@@ -79,12 +79,13 @@ describe('Navigation', () => {
|
||||
'/home',
|
||||
'/article',
|
||||
'/media',
|
||||
'/authors',
|
||||
'/users',
|
||||
'/activity',
|
||||
'/integration',
|
||||
'/settings',
|
||||
])
|
||||
const activity = links[4]!
|
||||
const activity = links[5]!
|
||||
expect(activity.getAttribute('aria-current')).toBe('page')
|
||||
await expectIcon(activity, 'history')
|
||||
expect(textOf(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe('Activity')
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import {
|
||||
button,
|
||||
callsTo,
|
||||
chooseMenuItem,
|
||||
click,
|
||||
createBackend,
|
||||
dialog,
|
||||
failWith,
|
||||
mountDirectory,
|
||||
networkFailure,
|
||||
openRowMenu,
|
||||
pending,
|
||||
rowTexts,
|
||||
textOf,
|
||||
unmountDirectory,
|
||||
} from './directoryTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
afterEach(unmountDirectory)
|
||||
|
||||
async function openDelete(name: string, backend = createBackend()) {
|
||||
await mountDirectory('authors', '/authors', { backend })
|
||||
await openRowMenu(name)
|
||||
await chooseMenuItem('Delete…')
|
||||
return backend
|
||||
}
|
||||
|
||||
function box() {
|
||||
return dialog()!
|
||||
}
|
||||
|
||||
function trigger() {
|
||||
return box().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!
|
||||
}
|
||||
|
||||
async function openSelect() {
|
||||
trigger().click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function optionLabels() {
|
||||
return Array.from(box().querySelectorAll('[role="option"]')).map((option) => textOf(option))
|
||||
}
|
||||
|
||||
describe('Authors delete: reassign (recommended)', () => {
|
||||
it('explains, preselects the first other author and lists the buttons', async () => {
|
||||
const backend = await openDelete('Camille Roche')
|
||||
|
||||
const text = textOf(box())
|
||||
expect(box().getAttribute('role')).toBe('alertdialog')
|
||||
expect(text).toContain('Delete this author?')
|
||||
expect(text).toContain(
|
||||
'"Camille Roche" will be deleted and is credited on 3 articles. This can\'t be undone.',
|
||||
)
|
||||
expect(text).toContain('Credit these articles to')
|
||||
expect(textOf(trigger())).toBe('Inès Duarte')
|
||||
expect(text).toContain(
|
||||
'Where an article has several authors, Camille Roche is removed and the others stay.',
|
||||
)
|
||||
expect(button('Cancel', box())).toBeTruthy()
|
||||
expect(button('Delete and reassign 3 articles', box())).toBeTruthy()
|
||||
expect(
|
||||
backend.calls.filter((call) => call.raw === '/v1/author/?page=1&size=100'),
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('adds the linked-account line for a linked author', async () => {
|
||||
await openDelete('Inès Duarte')
|
||||
|
||||
expect(textOf(box())).toContain(
|
||||
"Linked to ines.duarte@renew.example. The user account isn't affected.",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors delete: reassign succeeds', () => {
|
||||
it('sends one DELETE with reassign_to, reads Reassigning… and toasts', async () => {
|
||||
const backend = createBackend()
|
||||
const hold = pending<unknown>()
|
||||
backend.responders['DELETE /v1/author/7'] = () => hold.promise
|
||||
await openDelete('Camille Roche', backend)
|
||||
await click('Delete and reassign 3 articles', box())
|
||||
|
||||
expect(button('Reassigning…', box())).toBeTruthy()
|
||||
hold.resolve(undefined)
|
||||
await flushPromises()
|
||||
|
||||
const deletes = callsTo(backend, 'DELETE', '/v1/author/7')
|
||||
expect(deletes).toHaveLength(1)
|
||||
expect(deletes[0]!.query.get('reassign_to')).toBe('2')
|
||||
expect(rowTexts().join(' ')).not.toContain('Camille Roche')
|
||||
expect(document.body.textContent).toContain('Author deleted')
|
||||
expect(document.body.textContent).toContain('3 articles now credit Inès Duarte.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors delete: choose who', () => {
|
||||
it('does not list the deleted author and ends with Nobody', async () => {
|
||||
await openDelete('Camille Roche')
|
||||
await openSelect()
|
||||
|
||||
const labels = optionLabels()
|
||||
expect(labels.some((label) => label.includes('Camille Roche'))).toBe(false)
|
||||
expect(labels.at(-1)).toContain('Nobody')
|
||||
expect(labels.at(-1)).toContain('Remove the byline')
|
||||
expect(labels[0]).toContain('Inès Duarte')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors delete: nobody', () => {
|
||||
it('explains the byline and sends reassign_to=none', async () => {
|
||||
const backend = await openDelete('Camille Roche')
|
||||
await openSelect()
|
||||
Array.from(box().querySelectorAll<HTMLElement>('[role="option"]'))
|
||||
.find((option) => textOf(option).includes('Nobody'))!
|
||||
.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf(box())).toContain(
|
||||
'The articles keep any other authors. Articles with only this author will have no byline.',
|
||||
)
|
||||
await click('Delete and reassign 3 articles', box())
|
||||
|
||||
expect(callsTo(backend, 'DELETE', '/v1/author/7')[0]!.query.get('reassign_to')).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['status 500', failWith(500)],
|
||||
['a network error', networkFailure],
|
||||
])('Authors delete: failed with %s', (_name, responder) => {
|
||||
it('keeps the dialog open with the notice and enables the buttons again', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['DELETE /v1/author/7'] = responder
|
||||
await openDelete('Camille Roche', backend)
|
||||
await click('Delete and reassign 3 articles', box())
|
||||
|
||||
const alert = textOf(box().querySelector('[role="alert"]'))
|
||||
expect(alert).toContain("Couldn't delete the author")
|
||||
expect(alert).toContain('Nothing was changed. Try again in a moment.')
|
||||
expect(button('Cancel', box())!.hasAttribute('disabled')).toBe(false)
|
||||
expect(button('Delete and reassign 3 articles', box())!.hasAttribute('disabled')).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors delete: server fallback (409)', () => {
|
||||
it('opens the fallback dialog and Reassign articles reopens the reassign dialog', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['DELETE /v1/author/7'] = failWith(409, 'author_has_articles')
|
||||
await openDelete('Camille Roche', backend)
|
||||
await click('Delete and reassign 3 articles', box())
|
||||
await flushPromises()
|
||||
|
||||
const text = textOf(box())
|
||||
expect(text).toContain("Can't delete this author")
|
||||
expect(text).toContain(
|
||||
'"Camille Roche" is credited on 3 articles. Choose another author to credit instead, or remove Camille Roche from those articles first.',
|
||||
)
|
||||
expect(button('Close', box())).toBeTruthy()
|
||||
expect(button('Reassign articles', box())).toBeTruthy()
|
||||
|
||||
await click('Reassign articles', box())
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf(box())).toContain('Delete this author?')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors delete: no articles', () => {
|
||||
it('asks a plain confirmation with no select and sends no reassign_to', async () => {
|
||||
const backend = await openDelete('Léa Fontaine')
|
||||
|
||||
const text = textOf(box())
|
||||
expect(text).toContain('Delete this author?')
|
||||
expect(text).toContain(
|
||||
'"Léa Fontaine" will be deleted. No articles credit this author. This can\'t be undone.',
|
||||
)
|
||||
expect(trigger()).toBeNull()
|
||||
expect(button('Delete author', box())).toBeTruthy()
|
||||
|
||||
await click('Delete author', box())
|
||||
|
||||
const deletes = callsTo(backend, 'DELETE', '/v1/author/10')
|
||||
expect(deletes).toHaveLength(1)
|
||||
expect(deletes[0]!.query.has('reassign_to')).toBe(false)
|
||||
expect(rowTexts().join(' ')).not.toContain('Léa Fontaine')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors delete: author already gone', () => {
|
||||
it('closes, removes the row and shows the info toast on 404', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['DELETE /v1/author/7'] = failWith(404, 'author_not_found')
|
||||
await openDelete('Camille Roche', backend)
|
||||
await click('Delete and reassign 3 articles', box())
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(rowTexts().join(' ')).not.toContain('Camille Roche')
|
||||
expect(document.body.textContent).toContain('That author no longer exists')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,543 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import {
|
||||
authorsFixture,
|
||||
blur,
|
||||
button,
|
||||
callsTo,
|
||||
chooseMenuItem,
|
||||
click,
|
||||
createBackend,
|
||||
dialog,
|
||||
failWith,
|
||||
field,
|
||||
labelled,
|
||||
mountDirectory,
|
||||
networkFailure,
|
||||
openRowMenu,
|
||||
pending,
|
||||
rowTexts,
|
||||
textOf,
|
||||
typeInto,
|
||||
unmountDirectory,
|
||||
} from './directoryTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const URL_MESSAGE = 'Enter a full URL starting with https://.'
|
||||
const SAVE_FAILED = "Couldn't save the author. Your changes are still here. Try again in a moment."
|
||||
|
||||
afterEach(unmountDirectory)
|
||||
|
||||
function box() {
|
||||
return dialog()!
|
||||
}
|
||||
|
||||
function nameField() {
|
||||
return field('e.g. Camille Roche')
|
||||
}
|
||||
|
||||
function bioField() {
|
||||
return field('A sentence or two about the author.')
|
||||
}
|
||||
|
||||
function websiteField() {
|
||||
return field('https://example.com')
|
||||
}
|
||||
|
||||
function slugField() {
|
||||
return labelled('Slug') as HTMLInputElement
|
||||
}
|
||||
|
||||
function submit(label = 'Add author') {
|
||||
return click(label, box())
|
||||
}
|
||||
|
||||
function userTrigger() {
|
||||
return box().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!
|
||||
}
|
||||
|
||||
async function openAdd(options: Parameters<typeof mountDirectory>[2] = {}) {
|
||||
const backend = await mountDirectory('authors', '/authors', options)
|
||||
await click('Add author')
|
||||
return backend
|
||||
}
|
||||
|
||||
async function openEdit(name: string, options: Parameters<typeof mountDirectory>[2] = {}) {
|
||||
const backend = await mountDirectory('authors', '/authors', options)
|
||||
await openRowMenu(name)
|
||||
await chooseMenuItem('Edit')
|
||||
return backend
|
||||
}
|
||||
|
||||
describe('Authors form: add, empty', () => {
|
||||
it('opens a slide-over with every field, placeholder, counter and the byline preview', async () => {
|
||||
await openAdd()
|
||||
|
||||
const text = textOf(box())
|
||||
for (const copy of [
|
||||
'Add author',
|
||||
'Avatar (optional)',
|
||||
'Choose image',
|
||||
'Name',
|
||||
'Slug',
|
||||
'Bio (optional)',
|
||||
'0 / 280',
|
||||
'Website (optional)',
|
||||
'Social links (optional)',
|
||||
'Add link',
|
||||
'0 of 5',
|
||||
'Linked user (optional)',
|
||||
'Link a CMS user if this author has an account. Guest writers need no link.',
|
||||
'Byline preview',
|
||||
'Author name',
|
||||
'The bio appears here.',
|
||||
'Cancel',
|
||||
]) {
|
||||
expect(text).toContain(copy)
|
||||
}
|
||||
expect(nameField()).toBeTruthy()
|
||||
expect(field('Generated from the name')).toBeTruthy()
|
||||
expect(bioField()).toBeTruthy()
|
||||
expect(websiteField()).toBeTruthy()
|
||||
expect(textOf(userTrigger())).toBe('No linked user')
|
||||
expect(button('Add author', box())).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: slug from the name', () => {
|
||||
it('fills the slug with an Auto badge, an Edit slug pencil and follows the byline preview', async () => {
|
||||
await openAdd()
|
||||
await typeInto(nameField(), 'Camille Roche')
|
||||
|
||||
expect(slugField().value).toBe('camille-roche')
|
||||
const text = textOf(box())
|
||||
expect(text).toContain('Auto')
|
||||
expect(button('Edit slug', box())).toBeTruthy()
|
||||
expect(text).toContain(
|
||||
'Generated from the name. Used in public author URLs and API paths. Unique across all authors.',
|
||||
)
|
||||
expect(text).toContain('Byline preview Camille Roche')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: add succeeds', () => {
|
||||
it('sends exactly one POST with the exact body, reads Saving… and lists the author', async () => {
|
||||
const backend = createBackend()
|
||||
const hold = pending<unknown>()
|
||||
backend.responders['POST /v1/author/'] = (call) => {
|
||||
backend.authors.push({
|
||||
id: 200,
|
||||
is_linked: false,
|
||||
article_count: 0,
|
||||
...call.body,
|
||||
} as never)
|
||||
return hold.promise
|
||||
}
|
||||
await openAdd({ backend })
|
||||
await typeInto(nameField(), 'Zoe Lambert')
|
||||
await typeInto(bioField(), 'Writes about plaster.')
|
||||
await typeInto(websiteField(), 'https://zoe.example')
|
||||
await click('Add link', box())
|
||||
await typeInto(
|
||||
box().querySelector<HTMLInputElement>('input[placeholder="https://x.com/username"]')!,
|
||||
'https://x.com/zoe',
|
||||
)
|
||||
await submit()
|
||||
|
||||
expect(button('Saving…', box())).toBeTruthy()
|
||||
hold.resolve({ id: 200 })
|
||||
await flushPromises()
|
||||
|
||||
const posts = callsTo(backend, 'POST', '/v1/author/')
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(posts[0]!.body).toEqual({
|
||||
name: 'Zoe Lambert',
|
||||
slug: 'zoe-lambert',
|
||||
bio: 'Writes about plaster.',
|
||||
avatar_id: null,
|
||||
website: 'https://zoe.example',
|
||||
social_links: [{ type: 'x', url: 'https://x.com/zoe' }],
|
||||
user_id: null,
|
||||
})
|
||||
expect(dialog()).toBeNull()
|
||||
expect(rowTexts().join(' ')).toContain('Zoe Lambert')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: slug collision', () => {
|
||||
it('shows the message, a suggestion and the reset link, and the suggestion keeps the other values', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['POST /v1/author/'] = failWith(409, 'author_slug_taken')
|
||||
await openAdd({ backend })
|
||||
await typeInto(nameField(), 'Camille Roche')
|
||||
await typeInto(bioField(), 'A bio')
|
||||
await submit()
|
||||
|
||||
const text = textOf(box())
|
||||
expect(text).toContain('The slug "camille-roche" is already used by another author.')
|
||||
expect(text).toContain('Use camille-roche-2')
|
||||
expect(text).toContain('Reset to match the name')
|
||||
|
||||
await click('Use camille-roche-2', box())
|
||||
|
||||
expect(slugField().value).toBe('camille-roche-2')
|
||||
expect(bioField().value).toBe('A bio')
|
||||
expect(nameField().value).toBe('Camille Roche')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: validation', () => {
|
||||
it('asks for a name and sends nothing', async () => {
|
||||
const backend = await openAdd()
|
||||
await submit()
|
||||
|
||||
expect(textOf(box())).toContain('Enter a name.')
|
||||
expect(callsTo(backend, 'POST', '/v1/author/')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('refuses a bio of 291 characters with the counter in the error colour', async () => {
|
||||
const backend = await openAdd()
|
||||
await typeInto(nameField(), 'Zoe')
|
||||
await typeInto(bioField(), 'x'.repeat(291))
|
||||
await submit()
|
||||
|
||||
expect(textOf(box())).toContain('Shorten the bio to 280 characters.')
|
||||
const counter = Array.from(box().querySelectorAll('span')).find(
|
||||
(span) => textOf(span) === '291 / 280' && !span.querySelector('span'),
|
||||
)!
|
||||
expect(counter.className).toContain('text-error')
|
||||
expect(callsTo(backend, 'POST', '/v1/author/')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('refuses a website without https', async () => {
|
||||
const backend = await openAdd()
|
||||
await typeInto(nameField(), 'Zoe')
|
||||
await typeInto(websiteField(), 'example.com')
|
||||
await submit()
|
||||
|
||||
expect(textOf(box())).toContain(URL_MESSAGE)
|
||||
expect(callsTo(backend, 'POST', '/v1/author/')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('refuses a social link with an http URL under its row', async () => {
|
||||
const backend = await openAdd()
|
||||
await typeInto(nameField(), 'Zoe')
|
||||
await click('Add link', box())
|
||||
await typeInto(
|
||||
box().querySelector<HTMLInputElement>('input[placeholder="https://x.com/username"]')!,
|
||||
'http://x',
|
||||
)
|
||||
await submit()
|
||||
|
||||
const row = box()
|
||||
.querySelector('[data-testid="social-link-row"]')!
|
||||
.closest('[data-slot="root"]')!
|
||||
expect(textOf(row)).toContain(URL_MESSAGE)
|
||||
expect(callsTo(backend, 'POST', '/v1/author/')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
[422, 'bio_too_long', 'Shorten the bio to 280 characters.'],
|
||||
[422, 'invalid_url', URL_MESSAGE],
|
||||
[409, 'user_already_linked', 'That user already has an author. Choose another user.'],
|
||||
])('Authors form: server validation %s %s', (status, code, message) => {
|
||||
it('shows the message under the field', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['POST /v1/author/'] = failWith(status, code)
|
||||
await openAdd({ backend })
|
||||
await typeInto(nameField(), 'Zoe')
|
||||
await typeInto(websiteField(), 'https://zoe.example')
|
||||
await submit()
|
||||
|
||||
expect(textOf(box())).toContain(message)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: social links', () => {
|
||||
it('adds rows with a type select, a type-specific placeholder, and a limit of five', async () => {
|
||||
await openAdd()
|
||||
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await click('Add link', box())
|
||||
}
|
||||
|
||||
expect(box().querySelectorAll('[data-testid="social-link-row"]')).toHaveLength(5)
|
||||
expect(button('Add link', box())!.hasAttribute('disabled')).toBe(true)
|
||||
expect(textOf(box())).toContain('You can add up to 5 links.')
|
||||
expect(textOf(box())).toContain('5 of 5')
|
||||
expect(box().querySelectorAll('input[placeholder="https://x.com/username"]')).toHaveLength(
|
||||
5,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: linked user, choose', () => {
|
||||
it('lists the users without an author under their group', async () => {
|
||||
const backend = await openAdd()
|
||||
userTrigger().click()
|
||||
await flushPromises()
|
||||
|
||||
const text = textOf(box())
|
||||
expect(text).toContain('Users without an author')
|
||||
expect(text).toContain('Marta Alves marta@renew.example')
|
||||
expect(text).toContain('Rui Santos rui@renew.example')
|
||||
expect(text).not.toContain('Inès Duarte ines.duarte')
|
||||
expect(callsTo(backend, 'GET', '/v1/user/')).toHaveLength(1)
|
||||
expect(
|
||||
backend.calls.filter((call) => call.raw === '/v1/author/?page=1&size=100'),
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('pages through the authors to find every linked user', async () => {
|
||||
const backend = createBackend()
|
||||
backend.authors = Array.from({ length: 120 }, (_, index) => ({
|
||||
...authorsFixture()[1]!,
|
||||
id: 600 + index,
|
||||
name: `Writer ${index}`,
|
||||
slug: `writer-${index}`,
|
||||
user_id: index === 119 ? 1 : null,
|
||||
}))
|
||||
await openAdd({ backend })
|
||||
userTrigger().click()
|
||||
await flushPromises()
|
||||
|
||||
const paths = backend.calls
|
||||
.filter((call) => call.path === '/v1/author/')
|
||||
.map((call) => call.raw)
|
||||
expect(paths).toContain('/v1/author/?page=1&size=100')
|
||||
expect(paths).toContain('/v1/author/?page=2&size=100')
|
||||
expect(textOf(box())).not.toContain('Marta Alves')
|
||||
expect(textOf(box())).toContain('Rui Santos')
|
||||
})
|
||||
|
||||
it('says so when every user already has an author', async () => {
|
||||
const backend = createBackend()
|
||||
backend.authors = authorsFixture().map((item, index) => ({ ...item, user_id: index + 1 }))
|
||||
backend.users = backend.users.slice(0, 1)
|
||||
await openAdd({ backend })
|
||||
userTrigger().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf(box())).toContain('Every user already has an author.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: edit a linked author', () => {
|
||||
it('fills the fields, shows the linked user with Unlink and sends only the changed fields', async () => {
|
||||
const backend = await openEdit('Inès Duarte')
|
||||
|
||||
const text = textOf(box())
|
||||
expect(text).toContain('Edit Inès Duarte')
|
||||
expect(nameField().value).toBe('Inès Duarte')
|
||||
expect(slugField().value).toBe('ines-duarte')
|
||||
expect(text).toContain('ines.duarte@renew.example')
|
||||
expect(button('Unlink', box())).toBeTruthy()
|
||||
expect(text).toContain('New articles by this user start with this author.')
|
||||
|
||||
await typeInto(bioField(), 'A new bio')
|
||||
await submit('Save changes')
|
||||
|
||||
const patches = callsTo(backend, 'PATCH', '/v1/author/2')
|
||||
expect(patches).toHaveLength(1)
|
||||
expect(patches[0]!.body).toEqual({ bio: 'A new bio' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: unlink', () => {
|
||||
it('asks, sends one PATCH with user_id null, returns to No linked user and toasts', async () => {
|
||||
const backend = await openEdit('Inès Duarte')
|
||||
await click('Unlink', box())
|
||||
|
||||
const ask = document.body.querySelector('[role="alertdialog"]')!
|
||||
const text = textOf(ask)
|
||||
expect(text).toContain('Unlink Inès Duarte?')
|
||||
expect(text).toContain(
|
||||
'Inès Duarte will stay on their articles. New articles by ines.duarte@renew.example will no longer start with this author.',
|
||||
)
|
||||
expect(button('Cancel', ask)).toBeTruthy()
|
||||
await click('Unlink user', ask)
|
||||
|
||||
const patches = callsTo(backend, 'PATCH', '/v1/author/2')
|
||||
expect(patches).toHaveLength(1)
|
||||
expect(patches[0]!.body).toEqual({ user_id: null })
|
||||
expect(textOf(userTrigger())).toBe('No linked user')
|
||||
expect(document.body.textContent).toContain('User unlinked')
|
||||
expect(document.body.textContent).toContain('Inès Duarte is now a guest author.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: link a guest', () => {
|
||||
it('links a user from the row menu and shows Linked on the row', async () => {
|
||||
const backend = await mountDirectory('authors', '/authors')
|
||||
await openRowMenu('Camille Roche')
|
||||
await chooseMenuItem('Link user')
|
||||
|
||||
const ask = document.body.querySelector('[role="alertdialog"]')!
|
||||
expect(textOf(ask)).toContain('Link Camille Roche to a user?')
|
||||
expect(textOf(ask)).toContain('Only users without an author are listed.')
|
||||
expect(button('Link user', ask)!.hasAttribute('disabled')).toBe(true)
|
||||
|
||||
ask.querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!.click()
|
||||
await flushPromises()
|
||||
expect(textOf(ask)).toContain('Marta Alves marta@renew.example')
|
||||
const option = Array.from(ask.querySelectorAll<HTMLElement>('[role="option"]')).find(
|
||||
(item) => textOf(item).includes('Marta Alves'),
|
||||
)!
|
||||
option.click()
|
||||
await flushPromises()
|
||||
await click('Link user', ask)
|
||||
|
||||
const patches = callsTo(backend, 'PATCH', '/v1/author/7')
|
||||
expect(patches).toHaveLength(1)
|
||||
expect(patches[0]!.body).toEqual({ user_id: 1 })
|
||||
expect(rowTexts()[1]).toContain('Linked')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: avatar picker', () => {
|
||||
it('opens the media picker for the avatar, returns the id and sends avatar_id on save', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['GET /v1/media/'] = () => ({
|
||||
items: [
|
||||
{
|
||||
id: 55,
|
||||
filename: 'face.jpg',
|
||||
url: '/media/face.jpg',
|
||||
size: 1000,
|
||||
content_type: 'image/jpeg',
|
||||
width: 400,
|
||||
height: 400,
|
||||
sha256: '0abc',
|
||||
uploaded_by: 'Marta Alves',
|
||||
created_at: '2026-09-12T12:00:00Z',
|
||||
used_in: 0,
|
||||
duplicate: false,
|
||||
variant_count: 0,
|
||||
variant_size: 0,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 1, unused: 1, duplicates: 0 },
|
||||
})
|
||||
await openEdit('Camille Roche', { backend })
|
||||
await click('Choose image', box())
|
||||
await flushPromises()
|
||||
|
||||
const picker = Array.from(document.body.querySelectorAll('[role="dialog"]')).at(-1)!
|
||||
const text = textOf(picker)
|
||||
expect(text).toContain('Choose an image')
|
||||
expect(text).toContain('The avatar for Camille Roche.')
|
||||
expect(text).toContain(
|
||||
'Shown as a square. Choose an image with the face or logo near the middle.',
|
||||
)
|
||||
picker.querySelector<HTMLElement>('[data-picker-tile]')!.click()
|
||||
await flushPromises()
|
||||
await click('Use image', picker)
|
||||
|
||||
expect(textOf(box())).toContain('face.jpg')
|
||||
await submit('Save changes')
|
||||
|
||||
expect(callsTo(backend, 'PATCH', '/v1/author/7')[0]!.body).toEqual({ avatar_id: 55 })
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['status 500', failWith(500)],
|
||||
['a network error', networkFailure],
|
||||
])('Authors form: save failed with %s', (_name, responder) => {
|
||||
it('shows the notice and keeps the values', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['POST /v1/author/'] = responder
|
||||
await openAdd({ backend })
|
||||
await typeInto(nameField(), 'Zoe')
|
||||
await typeInto(bioField(), 'Keep me')
|
||||
await submit()
|
||||
|
||||
expect(textOf(box().querySelector('[role="alert"]'))).toContain(SAVE_FAILED)
|
||||
expect(nameField().value).toBe('Zoe')
|
||||
expect(bioField().value).toBe('Keep me')
|
||||
expect(nameField().disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: role changed while open', () => {
|
||||
it('raises an error toast on 403 author_rights', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['POST /v1/author/'] = failWith(403, 'author_rights')
|
||||
await openAdd({ backend })
|
||||
await typeInto(nameField(), 'Zoe')
|
||||
await submit()
|
||||
|
||||
expect(document.body.textContent).toContain('Only an owner or admin can change authors')
|
||||
expect(document.body.textContent).toContain('You can still edit your own author profile.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: deep link', () => {
|
||||
it('opens the edit slide-over for an owner or admin', async () => {
|
||||
await mountDirectory('authors', '/authors?edit=camille-roche', { role: 'admin' })
|
||||
|
||||
expect(textOf(box())).toContain('Edit Camille Roche')
|
||||
})
|
||||
|
||||
it('shows an info toast and opens nothing for an unknown slug', async () => {
|
||||
await mountDirectory('authors', '/authors?edit=nobody-here')
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(document.body.textContent).toContain('That author no longer exists')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors form: own author, any role, by deep link only', () => {
|
||||
function memberBackend() {
|
||||
const backend = createBackend()
|
||||
backend.authors = authorsFixture(false)
|
||||
return backend
|
||||
}
|
||||
const own = { id: 2, name: 'Inès Duarte', slug: 'ines-duarte' }
|
||||
|
||||
it('opens the form with the linked user locked, no Unlink, no Link user and no delete', async () => {
|
||||
const backend = memberBackend()
|
||||
await mountDirectory('authors', '/authors?edit=ines-duarte', {
|
||||
backend,
|
||||
role: 'member',
|
||||
author: own,
|
||||
email: 'ines.duarte@renew.example',
|
||||
})
|
||||
|
||||
const text = textOf(box())
|
||||
expect(text).toContain('Edit Inès Duarte')
|
||||
expect(text).toContain('ines.duarte@renew.example')
|
||||
expect(text).toContain(
|
||||
'This is your author profile. Only an owner or admin can change the link.',
|
||||
)
|
||||
expect(button('Unlink', box())).toBeUndefined()
|
||||
expect(button('Link user', box())).toBeUndefined()
|
||||
expect(button(/Delete/, box())).toBeUndefined()
|
||||
|
||||
await typeInto(bioField(), 'Mine')
|
||||
await submit('Save changes')
|
||||
|
||||
expect(callsTo(backend, 'PATCH', '/v1/author/2')[0]!.body).toEqual({ bio: 'Mine' })
|
||||
})
|
||||
|
||||
it('opens the read-only detail for another author', async () => {
|
||||
await mountDirectory('authors', '/authors?edit=camille-roche', {
|
||||
backend: memberBackend(),
|
||||
role: 'member',
|
||||
author: own,
|
||||
})
|
||||
|
||||
expect(textOf(box())).toContain('Only an owner or admin can change this author.')
|
||||
expect(box().querySelectorAll('input, textarea')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
void blur
|
||||
@@ -0,0 +1,324 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import type { Author } from '@/composables/useAuthors'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import {
|
||||
authorsFixture,
|
||||
button,
|
||||
callsTo,
|
||||
click,
|
||||
createBackend,
|
||||
dialog,
|
||||
failWith,
|
||||
mountDirectory,
|
||||
networkFailure,
|
||||
openRowMenu,
|
||||
pending,
|
||||
rowTexts,
|
||||
rows,
|
||||
textOf,
|
||||
typeInto,
|
||||
unmountDirectory,
|
||||
} from './directoryTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const SEARCH_DEBOUNCE_MS = 120
|
||||
const OWNER_DESCRIPTION =
|
||||
"The names readers see on your articles. An author doesn't need an account."
|
||||
const READ_ONLY_DESCRIPTION =
|
||||
'The names readers see on your articles. Only an owner or admin can change authors.'
|
||||
|
||||
afterEach(unmountDirectory)
|
||||
|
||||
function listCalls(backend: ReturnType<typeof createBackend>) {
|
||||
return backend.calls.filter((call) => call.method === 'GET' && call.path === '/v1/author/')
|
||||
}
|
||||
|
||||
function manyAuthors(count: number): Author[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
...authorsFixture()[1]!,
|
||||
id: 500 + index,
|
||||
name: `Writer ${String(index).padStart(2, '0')}`,
|
||||
slug: `writer-${index}`,
|
||||
}))
|
||||
}
|
||||
|
||||
describe.each(['owner', 'admin'])('Authors: default for %s', (role) => {
|
||||
it('shows the header, the search field, the columns and the rows', async () => {
|
||||
const backend = await mountDirectory('authors', '/authors', { role })
|
||||
|
||||
expect(document.body.querySelector('h1')?.textContent).toBe('Authors')
|
||||
expect(document.body.textContent).toContain(OWNER_DESCRIPTION)
|
||||
expect(button('Add author')).toBeTruthy()
|
||||
expect(
|
||||
document.body.querySelector('input[placeholder="Search by name or slug"]'),
|
||||
).toBeTruthy()
|
||||
const header = document.body.querySelector(
|
||||
'[data-testid="directory-row"]',
|
||||
)!.previousElementSibling!
|
||||
expect(textOf(header)).toBe('Author Slug Account Articles')
|
||||
expect(rowTexts()).toEqual([
|
||||
'ID Inès Duarte ines-duarte Linked 9 articles',
|
||||
'CR Camille Roche camille-roche Guest 3 articles',
|
||||
'AK Dr. Alexandra Konstantinopoulou-Whitfield dr-alexandra-konstantinopoulou-whitfield Guest 1 article',
|
||||
'LF Léa Fontaine lea-fontaine Guest No articles',
|
||||
])
|
||||
expect(listCalls(backend)[0]!.raw).toBe('/v1/author/?page=1&size=25')
|
||||
})
|
||||
|
||||
it('puts Authors between Media and Users in the sidebar, current on /authors', async () => {
|
||||
await mountDirectory('authors', '/authors', { role })
|
||||
|
||||
const links = Array.from(
|
||||
document.body.querySelectorAll<HTMLAnchorElement>('nav[aria-label="Main"] a'),
|
||||
)
|
||||
const hrefs = links.map((link) => link.getAttribute('href'))
|
||||
expect(hrefs.slice(2, 5)).toEqual(['/media', '/authors', '/users'])
|
||||
expect(links[3]!.getAttribute('aria-current')).toBe('page')
|
||||
})
|
||||
|
||||
it('shows a long name in full on hover', async () => {
|
||||
await mountDirectory('authors', '/authors', { role })
|
||||
|
||||
const name = Array.from(document.body.querySelectorAll('span.truncate')).find((span) =>
|
||||
textOf(span).startsWith('Dr. Alexandra'),
|
||||
)!
|
||||
expect(name.getAttribute('title')).toBe('Dr. Alexandra Konstantinopoulou-Whitfield')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors: loading', () => {
|
||||
it('draws skeleton rows after the delay and marks the list busy', async () => {
|
||||
vi.useFakeTimers()
|
||||
const backend = createBackend()
|
||||
const hold = pending<unknown>()
|
||||
backend.responders['GET /v1/author/'] = () => hold.promise
|
||||
await mountDirectory('authors', '/authors', { backend })
|
||||
|
||||
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
|
||||
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS)
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
|
||||
expect(
|
||||
document.body.querySelectorAll('[data-testid="directory-skeleton-row"]'),
|
||||
).toHaveLength(6)
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['status 500', failWith(500)],
|
||||
['a network error', networkFailure],
|
||||
])('Authors: error on load with %s', (_name, responder) => {
|
||||
it('shows the alert and requests the list again on Try again', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['GET /v1/author/'] = responder
|
||||
await mountDirectory('authors', '/authors', { backend })
|
||||
|
||||
const alert = textOf(document.body.querySelector('[role="alert"]'))
|
||||
expect(alert).toContain("Couldn't load authors")
|
||||
expect(alert).toContain('Check your connection and try again.')
|
||||
|
||||
delete backend.responders['GET /v1/author/']
|
||||
await click('Try again')
|
||||
|
||||
expect(listCalls(backend)).toHaveLength(2)
|
||||
expect(rows()).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['owner', true],
|
||||
['admin', true],
|
||||
['member', false],
|
||||
])('Authors: empty, first run for %s', (role, canAdd) => {
|
||||
it('hides the toolbar and shows the empty state', async () => {
|
||||
const backend = createBackend()
|
||||
backend.authors = []
|
||||
await mountDirectory('authors', '/authors', { backend, role })
|
||||
|
||||
expect(
|
||||
document.body.querySelector('input[placeholder="Search by name or slug"]'),
|
||||
).toBeNull()
|
||||
expect(document.body.textContent).toContain('No authors yet')
|
||||
expect(document.body.textContent).toContain(
|
||||
"Authors are the names readers see on articles. They don't need an account.",
|
||||
)
|
||||
const emptyButton = Array.from(document.body.querySelectorAll('button')).filter(
|
||||
(element) => textOf(element) === 'Add author',
|
||||
)
|
||||
expect(emptyButton.length > 0).toBe(canAdd)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors: no results', () => {
|
||||
it('names the search and clears it from the URL', async () => {
|
||||
const backend = await mountDirectory('authors', '/authors?q=zoe')
|
||||
|
||||
expect(document.body.textContent).toContain('No authors match "zoe"')
|
||||
expect(document.body.textContent).toContain(
|
||||
'Check the spelling, or search for a shorter part of the name.',
|
||||
)
|
||||
await click('Clear search')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.query.q).toBeUndefined()
|
||||
expect(listCalls(backend).at(-1)!.raw).toBe('/v1/author/?page=1&size=25')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors: search and pagination in the URL', () => {
|
||||
it('debounces the search, requests it and keeps it in the URL', async () => {
|
||||
vi.useFakeTimers()
|
||||
const backend = await mountDirectory('authors', '/authors')
|
||||
const input = document.body.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="Search by name or slug"]',
|
||||
)!
|
||||
await typeInto(input, 'cam')
|
||||
|
||||
expect(listCalls(backend)).toHaveLength(1)
|
||||
await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS)
|
||||
await flushPromises()
|
||||
|
||||
expect(listCalls(backend).at(-1)!.raw).toBe('/v1/author/?q=cam&page=1&size=25')
|
||||
expect(router.currentRoute.value.query.q).toBe('cam')
|
||||
})
|
||||
|
||||
it('requests the page and size found in the URL', async () => {
|
||||
const backend = createBackend()
|
||||
backend.authors = manyAuthors(130)
|
||||
await mountDirectory('authors', '/authors?q=writer&page=2&size=50', { backend })
|
||||
|
||||
expect(listCalls(backend)[0]!.raw).toBe('/v1/author/?q=writer&page=2&size=50')
|
||||
expect(document.body.textContent).toContain('Showing 51–100 of 130')
|
||||
})
|
||||
|
||||
it('shows the paginator only when the total exceeds 25', async () => {
|
||||
await mountDirectory('authors', '/authors')
|
||||
expect(document.body.textContent).not.toContain('per page')
|
||||
await unmountDirectory()
|
||||
|
||||
const backend = createBackend()
|
||||
backend.authors = manyAuthors(40)
|
||||
await mountDirectory('authors', '/authors', { backend })
|
||||
|
||||
expect(document.body.textContent).toContain('Showing 1–25 of 40')
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([['size=30'], ['page=abc'], ['page=0']])(
|
||||
'Authors: invalid value in the URL (%s)',
|
||||
(query) => {
|
||||
it('replaces the value by its default with no notice and never reaches the API', async () => {
|
||||
const backend = await mountDirectory('authors', `/authors?${query}`)
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.query.size).toBeUndefined()
|
||||
expect(router.currentRoute.value.query.page).toBeUndefined()
|
||||
expect(listCalls(backend).map((call) => call.raw)).toEqual([
|
||||
'/v1/author/?page=1&size=25',
|
||||
])
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
describe('Authors: row menu for an owner or admin', () => {
|
||||
it('offers Edit, Link user and Delete… for a guest and Edit and Delete… for a linked author', async () => {
|
||||
await mountDirectory('authors', '/authors')
|
||||
|
||||
await openRowMenu('Camille Roche')
|
||||
expect(Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf)).toEqual(
|
||||
['Edit', 'Link user', 'Delete…'],
|
||||
)
|
||||
})
|
||||
|
||||
it('lists Edit and Delete… only for a linked author', async () => {
|
||||
await mountDirectory('authors', '/authors')
|
||||
await openRowMenu('Inès Duarte')
|
||||
|
||||
expect(Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf)).toEqual(
|
||||
['Edit', 'Delete…'],
|
||||
)
|
||||
})
|
||||
|
||||
it('opens a bottom sheet below md', async () => {
|
||||
await mountDirectory('authors', '/authors', { width: MOBILE_WIDTH })
|
||||
await openRowMenu('Camille Roche')
|
||||
|
||||
expect(textOf(dialog()!.querySelector('h2'))).toBe('Camille Roche')
|
||||
expect(dialog()!.querySelectorAll('[role="menuitem"]')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors: read-only list for other roles', () => {
|
||||
it('has no Add author button, no pinned button, no row menu and a chevron on each row', async () => {
|
||||
await mountDirectory('authors', '/authors', { role: 'member', width: MOBILE_WIDTH })
|
||||
|
||||
expect(document.body.textContent).toContain(READ_ONLY_DESCRIPTION)
|
||||
expect(button('Add author')).toBeUndefined()
|
||||
expect(
|
||||
Array.from(document.body.querySelectorAll('button[aria-label^="Actions for"]')),
|
||||
).toHaveLength(0)
|
||||
expect(document.body.querySelectorAll('button[aria-label^="View "]')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('keeps search working and shows the Authors sidebar entry on desktop', async () => {
|
||||
vi.useFakeTimers()
|
||||
const backend = await mountDirectory('authors', '/authors', { role: 'member' })
|
||||
|
||||
expect(
|
||||
document.body.querySelector('nav[aria-label="Main"] a[href="/authors"]'),
|
||||
).not.toBeNull()
|
||||
const input = document.body.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="Search by name or slug"]',
|
||||
)!
|
||||
await typeInto(input, 'lea')
|
||||
await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS)
|
||||
await flushPromises()
|
||||
|
||||
expect(listCalls(backend).at(-1)!.raw).toBe('/v1/author/?q=lea&page=1&size=25')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authors: read-only detail', () => {
|
||||
async function openDetail() {
|
||||
await mountDirectory('authors', '/authors', { role: 'member' })
|
||||
await click('View Inès Duarte')
|
||||
}
|
||||
|
||||
it('shows the author with Close as the only action and no email', async () => {
|
||||
await openDetail()
|
||||
|
||||
const text = textOf(dialog())
|
||||
for (const copy of [
|
||||
'Inès Duarte',
|
||||
'Linked',
|
||||
'9 articles',
|
||||
'Bio',
|
||||
'Website',
|
||||
'Only an owner or admin can change this author.',
|
||||
]) {
|
||||
expect(text).toContain(copy)
|
||||
}
|
||||
expect(text).not.toContain('ines.duarte@renew.example')
|
||||
expect(dialog()!.querySelectorAll('input, textarea')).toHaveLength(0)
|
||||
const actions = Array.from(dialog()!.querySelectorAll('button')).map(textOf)
|
||||
expect(actions.filter((label) => label !== '')).toEqual(['Close'])
|
||||
})
|
||||
|
||||
it('opens the same panel from /authors?edit=ines-duarte', async () => {
|
||||
await mountDirectory('authors', '/authors?edit=ines-duarte', { role: 'member' })
|
||||
|
||||
expect(textOf(dialog())).toContain('Only an owner or admin can change this author.')
|
||||
expect(dialog()!.querySelectorAll('input, textarea')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
void callsTo
|
||||
@@ -0,0 +1,170 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import type { Author } from '@/composables/useAuthors'
|
||||
import {
|
||||
authorsFixture,
|
||||
button,
|
||||
callsTo,
|
||||
chooseMenuItem,
|
||||
click,
|
||||
createBackend,
|
||||
dialog,
|
||||
failWith,
|
||||
field,
|
||||
labelled,
|
||||
mountDirectory,
|
||||
openRowMenu,
|
||||
textOf,
|
||||
typeInto,
|
||||
unmountDirectory,
|
||||
} from './directoryTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
afterEach(unmountDirectory)
|
||||
|
||||
function box() {
|
||||
return dialog()!
|
||||
}
|
||||
|
||||
function manyAuthors(count: number): Author[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
...authorsFixture()[1]!,
|
||||
id: 500 + index,
|
||||
name: `Writer ${String(index).padStart(3, '0')}`,
|
||||
slug: `writer-${index}`,
|
||||
article_count: index === 0 ? 3 : 1,
|
||||
}))
|
||||
}
|
||||
|
||||
describe('Author slug suggestions are never taken', () => {
|
||||
it('skips slugs that are already on the loaded page and ones the server just refused', async () => {
|
||||
const backend = createBackend()
|
||||
backend.authors.push({
|
||||
...authorsFixture()[1]!,
|
||||
id: 90,
|
||||
name: 'Other',
|
||||
slug: 'camille-roche-2',
|
||||
})
|
||||
backend.responders['POST /v1/author/'] = failWith(409, 'author_slug_taken')
|
||||
await mountDirectory('authors', '/authors', { backend })
|
||||
await click('Add author')
|
||||
await typeInto(field('e.g. Camille Roche'), 'Camille Roche')
|
||||
await click('Add author', box())
|
||||
|
||||
expect(textOf(box())).toContain('Use camille-roche-3')
|
||||
await click('Use camille-roche-3', box())
|
||||
await click('Add author', box())
|
||||
|
||||
expect(textOf(box())).toContain('Use camille-roche-4')
|
||||
expect((labelled('Slug') as HTMLInputElement).value).toBe('camille-roche-3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Author reassign options', () => {
|
||||
it('walks every page of authors so nobody past the first hundred is missing', async () => {
|
||||
const backend = createBackend()
|
||||
backend.authors = manyAuthors(130)
|
||||
await mountDirectory('authors', '/authors', { backend })
|
||||
await openRowMenu('Writer 000')
|
||||
await chooseMenuItem('Delete…')
|
||||
|
||||
const paths = backend.calls
|
||||
.filter((call) => call.path === '/v1/author/')
|
||||
.map((call) => call.raw)
|
||||
expect(paths).toContain('/v1/author/?page=1&size=100')
|
||||
expect(paths).toContain('/v1/author/?page=2&size=100')
|
||||
const trigger = box().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!
|
||||
trigger.click()
|
||||
await flushPromises()
|
||||
const labels = Array.from(box().querySelectorAll('[role="option"]')).map(textOf)
|
||||
expect(labels.some((label) => label.includes('Writer 129'))).toBe(true)
|
||||
expect(labels.some((label) => label.includes('Writer 000'))).toBe(false)
|
||||
})
|
||||
|
||||
it('says so when the options cannot be loaded and still offers Nobody', async () => {
|
||||
const backend = createBackend()
|
||||
await mountDirectory('authors', '/authors', { backend })
|
||||
backend.responders['GET /v1/author/?page=1&size=100'] = failWith(500)
|
||||
await openRowMenu('Camille Roche')
|
||||
await chooseMenuItem('Delete…')
|
||||
|
||||
const alert = textOf(box().querySelector('[role="alert"]'))
|
||||
expect(alert).toContain("Couldn't load authors")
|
||||
expect(alert).toContain('Check your connection and try again.')
|
||||
expect(textOf(box().querySelector('button[aria-haspopup="listbox"]'))).toBe('Nobody')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Making another site the default from the form', () => {
|
||||
async function openProjects(backend = createBackend()) {
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
await openRowMenu('Projects')
|
||||
await chooseMenuItem('Edit')
|
||||
return backend
|
||||
}
|
||||
|
||||
it('keeps the list truthful when only the default step fails and retries just that step', async () => {
|
||||
const backend = createBackend()
|
||||
let defaultCalls = 0
|
||||
backend.responders['PATCH /v1/site/3/default'] = () => {
|
||||
defaultCalls += 1
|
||||
if (defaultCalls === 1) {
|
||||
throw new ApiError(500)
|
||||
}
|
||||
backend.sites.forEach((site) => (site.is_default = site.id === 3))
|
||||
return backend.sites.find((site) => site.id === 3)
|
||||
}
|
||||
await openProjects(backend)
|
||||
await typeInto(field('e.g. Journal'), 'Projects 2')
|
||||
box().querySelector<HTMLElement>('[role="switch"]')!.click()
|
||||
await flushPromises()
|
||||
await click('Save changes', box())
|
||||
|
||||
expect(textOf(box().querySelector('[role="alert"]'))).toContain("Couldn't save the site")
|
||||
expect(
|
||||
textOf(document.body.querySelectorAll('[data-testid="directory-row"]')[2]),
|
||||
).toContain('Projects 2')
|
||||
|
||||
await click('Save changes', box())
|
||||
|
||||
expect(callsTo(backend, 'PATCH', '/v1/site/3')).toHaveLength(1)
|
||||
expect(callsTo(backend, 'PATCH', '/v1/site/3/default')).toHaveLength(2)
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Focus returns to the row menu button', () => {
|
||||
it('puts focus back on the ellipsis button after closing a panel opened from the menu', async () => {
|
||||
await mountDirectory('sites', '/settings/sites')
|
||||
await openRowMenu('Projects')
|
||||
await chooseMenuItem('Edit')
|
||||
await click('Cancel', box())
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(button('Actions for Projects'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Failure copy passes through the dialog in the active language', () => {
|
||||
beforeEach(() => setLocale('fr'))
|
||||
afterEach(() => initializeLocale())
|
||||
|
||||
it('shows the French notice when setting the default fails', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['PATCH /v1/site/2/default'] = failWith(500)
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
await click('Actions pour Journal')
|
||||
await chooseMenuItem('Définir par défaut')
|
||||
await click('Définir par défaut', box())
|
||||
|
||||
expect(textOf(box().querySelector('[role="alert"]'))).toContain(
|
||||
'Impossible d’enregistrer le site.',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import { messages } from '@/i18n/messages'
|
||||
import { chooseMenuItem, click, mountDirectory, textOf, unmountDirectory } from './directoryTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const MIN_LENGTH = 14
|
||||
|
||||
const translatable = Object.entries(messages).filter(
|
||||
([english, french]) =>
|
||||
english !== french && !english.includes('{') && english.length >= MIN_LENGTH,
|
||||
)
|
||||
|
||||
function leftovers() {
|
||||
const shown = textOf(document.body)
|
||||
return translatable.filter(([english]) => shown.includes(english)).map(([english]) => english)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setLocale('fr')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await unmountDirectory()
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
describe('Language: every string of the sites screen has a French entry', () => {
|
||||
it('translates the list, the row menu and the add form', async () => {
|
||||
await mountDirectory('sites', '/settings/sites')
|
||||
|
||||
expect(document.body.textContent).toContain('Les frontends qui affichent votre contenu.')
|
||||
expect(leftovers()).toEqual([])
|
||||
|
||||
await click('Actions pour Journal')
|
||||
expect(document.body.textContent).toContain('Définir par défaut')
|
||||
expect(leftovers()).toEqual([])
|
||||
await chooseMenuItem('Définir par défaut')
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
it('translates the add form and the delete dialog', async () => {
|
||||
await mountDirectory('sites', '/settings/sites')
|
||||
await click('Ajouter un site')
|
||||
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
it('translates the delete dialog', async () => {
|
||||
await mountDirectory('sites', '/settings/sites')
|
||||
await click('Actions pour Journal')
|
||||
await chooseMenuItem('Supprimer…')
|
||||
|
||||
expect(document.body.textContent).toContain('Supprimer ce site ?')
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
it('translates the 403 page', async () => {
|
||||
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
|
||||
|
||||
expect(document.body.textContent).toContain('Vous n’avez pas accès aux sites')
|
||||
expect(document.body.textContent).toContain('Retour aux paramètres')
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Language: every string of the authors screen has a French entry', () => {
|
||||
it('translates the list, the add form and the delete dialog', async () => {
|
||||
await mountDirectory('authors', '/authors')
|
||||
|
||||
expect(document.body.textContent).toContain('Auteurs')
|
||||
expect(
|
||||
document.body.querySelector('input[placeholder="Rechercher par nom ou identifiant"]'),
|
||||
).not.toBeNull()
|
||||
expect(leftovers()).toEqual([])
|
||||
|
||||
await click('Ajouter un auteur')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('Aperçu de la signature')
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
it('translates the delete dialog', async () => {
|
||||
await mountDirectory('authors', '/authors')
|
||||
await click('Actions pour Camille Roche')
|
||||
await chooseMenuItem('Supprimer…')
|
||||
|
||||
expect(document.body.textContent).toContain('Supprimer cet auteur ?')
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
it('translates the read-only list', async () => {
|
||||
await mountDirectory('authors', '/authors', { role: 'member' })
|
||||
|
||||
expect(document.body.textContent).toContain('Seul un propriétaire ou un administrateur')
|
||||
expect(leftovers()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import UApp from '@nuxt/ui/components/App.vue'
|
||||
import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import AuthorsView from '../AuthorsView.vue'
|
||||
import SitesView from '../SitesView.vue'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
function reply(status: number, body = '') {
|
||||
return new Response(body, { status, headers: { 'Content-Type': 'text/plain' } })
|
||||
}
|
||||
|
||||
async function mountWith(
|
||||
view: typeof AuthorsView | typeof SitesView,
|
||||
path: string,
|
||||
verified: boolean,
|
||||
) {
|
||||
setViewportWidth(DESKTOP_WIDTH)
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified, username: 'marta', id: 1, displayName: '', role: 'owner' }
|
||||
session.status = 'ready'
|
||||
await router.push(path)
|
||||
wrapper = mount(defineComponent({ render: () => h(UApp, null, { default: () => h(view) }) }), {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
vi.unstubAllGlobals()
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('Session problems use the app-wide handling', () => {
|
||||
it.each([
|
||||
['/authors', AuthorsView, '/v1/author/'],
|
||||
['/settings/sites', SitesView, '/v1/site/'],
|
||||
])('sends a 401 on %s to the login page with the expired reason', async (path, view, api) => {
|
||||
const calls: string[] = []
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: string) => {
|
||||
calls.push(input)
|
||||
return reply(401)
|
||||
}),
|
||||
)
|
||||
await mountWith(view, path, true)
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
|
||||
|
||||
expect(calls.some((call) => call.startsWith(api))).toBe(true)
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
expect(router.currentRoute.value.query).toEqual({ redirect: path, reason: 'expired' })
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('sends an unverified 403 on /authors to /register/pending', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => reply(403)),
|
||||
)
|
||||
await mountWith(AuthorsView, '/authors', false)
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/register/pending')
|
||||
})
|
||||
})
|
||||
@@ -1505,6 +1505,7 @@ describe('Sidebar', () => {
|
||||
'/home',
|
||||
'/article',
|
||||
'/media',
|
||||
'/authors',
|
||||
'/users',
|
||||
'/activity',
|
||||
'/integration',
|
||||
|
||||
@@ -279,11 +279,11 @@ describe('Default: the usual first visit with no display name', () => {
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks none of the seven navigation items as the current page', async () => {
|
||||
it('marks none of the eight navigation items as the current page', async () => {
|
||||
await mountApp()
|
||||
|
||||
const links = document.body.querySelectorAll('nav[aria-label="Main"] a')
|
||||
expect(links).toHaveLength(7)
|
||||
expect(links).toHaveLength(8)
|
||||
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
|
||||
})
|
||||
|
||||
|
||||
@@ -30,10 +30,10 @@ function deferred<T>() {
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
async function mountPage(settings: () => Promise<unknown>) {
|
||||
async function mountPage(settings: () => Promise<unknown>, role?: 'owner' | 'admin') {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', id: 1 }
|
||||
session.session = { verified: true, username: 'marta', id: 1, ...(role ? { role } : {}) }
|
||||
session.status = 'ready'
|
||||
requestMock = vi.fn((path: string) => {
|
||||
if (path === SETTINGS_PATH) {
|
||||
@@ -185,3 +185,35 @@ describe('A 401 or an unverified 403 is handled globally, not by the page', () =
|
||||
expect(tryAgainButton()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Publishing card: the owner manages sites from Settings', () => {
|
||||
it('shows a Publishing card with a Manage sites row linking to /settings/sites', async () => {
|
||||
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }), 'owner')
|
||||
|
||||
const card = document.body.querySelector('[data-testid="publishing-links-card"]')!
|
||||
expect(card.textContent).toContain('Publishing')
|
||||
expect(card.textContent).toContain('Where your content is shown.')
|
||||
const row = card.querySelector('a')!
|
||||
expect(row.getAttribute('href')).toBe('/settings/sites')
|
||||
expect(row.textContent).toContain('Manage sites')
|
||||
expect(row.textContent).toContain('Base URLs, default image and robots policy')
|
||||
})
|
||||
|
||||
it('shows the sidebar Publishing group with a Sites entry linking to /settings/sites', async () => {
|
||||
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }), 'owner')
|
||||
|
||||
const nav = document.body.querySelector('nav[aria-label="Main"]')!
|
||||
expect(nav.textContent).toContain('Publishing')
|
||||
expect(nav.querySelector('a[href="/settings/sites"]')?.textContent).toContain('Sites')
|
||||
})
|
||||
|
||||
it('hides the Publishing card, the Manage sites row and the Sites entry from an admin', async () => {
|
||||
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }), 'admin')
|
||||
|
||||
expect(document.body.querySelector('[data-testid="publishing-links-card"]')).toBeNull()
|
||||
expect(document.body.textContent).not.toContain('Manage sites')
|
||||
const nav = document.body.querySelector('nav[aria-label="Main"]')!
|
||||
expect(nav.textContent).not.toContain('Publishing')
|
||||
expect(nav.querySelector('a[href="/settings/sites"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import {
|
||||
button,
|
||||
callsTo,
|
||||
chooseMenuItem,
|
||||
click,
|
||||
createBackend,
|
||||
dialog,
|
||||
failWith,
|
||||
mountDirectory,
|
||||
networkFailure,
|
||||
openRowMenu,
|
||||
pending,
|
||||
rowTexts,
|
||||
textOf,
|
||||
unmountDirectory,
|
||||
} from './directoryTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
afterEach(unmountDirectory)
|
||||
|
||||
async function openDelete(name: string, backend = createBackend()) {
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
await openRowMenu(name)
|
||||
await chooseMenuItem('Delete…')
|
||||
return backend
|
||||
}
|
||||
|
||||
function box() {
|
||||
return dialog()!
|
||||
}
|
||||
|
||||
function trigger() {
|
||||
return box().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!
|
||||
}
|
||||
|
||||
describe('Sites delete: site with articles', () => {
|
||||
it('explains, defaults the destination to the default site and shows the move summary', async () => {
|
||||
await openDelete('Journal')
|
||||
|
||||
const text = textOf(box())
|
||||
expect(box().getAttribute('role')).toBe('alertdialog')
|
||||
expect(text).toContain('Delete this site?')
|
||||
expect(text).toContain(
|
||||
'"Journal" will be deleted. 38 articles belong to it, so choose a site to move them to. This can\'t be undone.',
|
||||
)
|
||||
expect(text).toContain('Move articles to')
|
||||
expect(textOf(trigger())).toBe('Renew · renew.example')
|
||||
expect(text).toContain(
|
||||
'38 articles will move to Renew. Their canonical host changes to renew.example.',
|
||||
)
|
||||
expect(button('Cancel', box())).toBeTruthy()
|
||||
expect(button('Move 38 articles and delete', box())).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites delete: choose destination', () => {
|
||||
it('does not offer the deleted site and shows the name and the host on each option', async () => {
|
||||
await openDelete('Journal')
|
||||
trigger().click()
|
||||
await flushPromises()
|
||||
|
||||
const options = Array.from(box().querySelectorAll('[role="option"]')).map((option) =>
|
||||
textOf(option),
|
||||
)
|
||||
expect(options).toEqual([
|
||||
'Renew renew.example',
|
||||
'Projects projects.example',
|
||||
'Archive archive.example',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites delete: with articles succeeds', () => {
|
||||
it('sends one DELETE with reassign_to, reads Moving articles… and toasts', async () => {
|
||||
const backend = createBackend()
|
||||
const hold = pending<unknown>()
|
||||
backend.responders['DELETE /v1/site/2'] = () => hold.promise
|
||||
await openDelete('Journal', backend)
|
||||
await click('Move 38 articles and delete', box())
|
||||
|
||||
expect(button('Moving articles…', box())).toBeTruthy()
|
||||
hold.resolve(undefined)
|
||||
await flushPromises()
|
||||
|
||||
const deletes = callsTo(backend, 'DELETE', '/v1/site/2')
|
||||
expect(deletes).toHaveLength(1)
|
||||
expect(deletes[0]!.query.get('reassign_to')).toBe('1')
|
||||
expect(dialog()).toBeNull()
|
||||
expect(rowTexts().join(' ')).not.toContain('Journal')
|
||||
expect(document.body.textContent).toContain('Site deleted')
|
||||
expect(document.body.textContent).toContain('38 articles moved to Renew.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites delete: no articles', () => {
|
||||
it('asks a plain confirmation with no select and sends no reassign_to', async () => {
|
||||
const backend = await openDelete('Archive')
|
||||
|
||||
const text = textOf(box())
|
||||
expect(text).toContain('Delete this site?')
|
||||
expect(text).toContain(
|
||||
'"Archive" will be deleted. No articles belong to it. This can\'t be undone.',
|
||||
)
|
||||
expect(trigger()).toBeNull()
|
||||
expect(button('Delete site', box())).toBeTruthy()
|
||||
|
||||
await click('Delete site', box())
|
||||
|
||||
const deletes = callsTo(backend, 'DELETE', '/v1/site/4')
|
||||
expect(deletes).toHaveLength(1)
|
||||
expect(deletes[0]!.query.has('reassign_to')).toBe(false)
|
||||
expect(rowTexts().join(' ')).not.toContain('Archive')
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['status 500', failWith(500)],
|
||||
['a network error', networkFailure],
|
||||
])('Sites delete: failure with %s', (_name, responder) => {
|
||||
it('keeps the dialog open with the notice and enables the buttons again', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['DELETE /v1/site/2'] = responder
|
||||
await openDelete('Journal', backend)
|
||||
await click('Move 38 articles and delete', box())
|
||||
|
||||
const alert = textOf(box().querySelector('[role="alert"]'))
|
||||
expect(alert).toContain("Couldn't delete the site")
|
||||
expect(alert).toContain('Nothing was changed. Try again in a moment.')
|
||||
expect(button('Cancel', box())!.hasAttribute('disabled')).toBe(false)
|
||||
expect(button('Move 38 articles and delete', box())!.hasAttribute('disabled')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites delete: server refusals', () => {
|
||||
it('keeps the dialog open and asks for a destination on 409 site_has_articles', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['DELETE /v1/site/2'] = failWith(409, 'site_has_articles')
|
||||
await openDelete('Journal', backend)
|
||||
await click('Move 38 articles and delete', box())
|
||||
|
||||
expect(dialog()).not.toBeNull()
|
||||
expect(textOf(box())).toContain('Choose where the articles go. This site has 38 articles.')
|
||||
})
|
||||
|
||||
it('closes and toasts on 403 default_site', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['DELETE /v1/site/2'] = failWith(403, 'default_site')
|
||||
await openDelete('Journal', backend)
|
||||
await click('Move 38 articles and delete', box())
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(document.body.textContent).toContain(
|
||||
"The default site can't be deleted. Make another site the default first.",
|
||||
)
|
||||
})
|
||||
|
||||
it('closes, removes the row and toasts on 404', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['DELETE /v1/site/2'] = failWith(404, 'site_not_found')
|
||||
await openDelete('Journal', backend)
|
||||
await click('Move 38 articles and delete', box())
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(rowTexts().join(' ')).not.toContain('Journal')
|
||||
expect(document.body.textContent).toContain('That site no longer exists')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,417 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import {
|
||||
blur,
|
||||
button,
|
||||
callsTo,
|
||||
chooseMenuItem,
|
||||
click,
|
||||
createBackend,
|
||||
dialog,
|
||||
failWith,
|
||||
field,
|
||||
labelled,
|
||||
mountDirectory,
|
||||
networkFailure,
|
||||
openRowMenu,
|
||||
pending,
|
||||
rowTexts,
|
||||
textOf,
|
||||
typeInto,
|
||||
unmountDirectory,
|
||||
} from './directoryTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const BASE_URL_MESSAGES = {
|
||||
scheme: 'Use https. For example https://blog.example.com.',
|
||||
path: 'Remove the path. Use only the host, like https://blog.example.com.',
|
||||
invalid: 'Enter a full URL, like https://blog.example.com.',
|
||||
}
|
||||
const SAVE_FAILED = "Couldn't save the site. Your changes are still here. Try again in a moment."
|
||||
|
||||
afterEach(unmountDirectory)
|
||||
|
||||
async function openAdd(options: Parameters<typeof mountDirectory>[2] = {}) {
|
||||
const backend = await mountDirectory('sites', '/settings/sites', options)
|
||||
await click('Add site')
|
||||
return backend
|
||||
}
|
||||
|
||||
async function openEdit(name: string) {
|
||||
const backend = await mountDirectory('sites', '/settings/sites')
|
||||
await openRowMenu(name)
|
||||
await chooseMenuItem('Edit')
|
||||
return backend
|
||||
}
|
||||
|
||||
async function fillValid(name = 'Journal 2', url = 'https://journal2.example') {
|
||||
await typeInto(field('e.g. Journal'), name)
|
||||
await typeInto(field('https://blog.example.com'), url)
|
||||
}
|
||||
|
||||
function box() {
|
||||
return dialog()!
|
||||
}
|
||||
|
||||
function submit(label = 'Add site') {
|
||||
return click(label, box())
|
||||
}
|
||||
|
||||
describe('Sites form: add, empty', () => {
|
||||
it('opens a slide-over with every field, placeholder and help', async () => {
|
||||
await openAdd()
|
||||
|
||||
const text = textOf(box())
|
||||
for (const copy of [
|
||||
'Add site',
|
||||
'Name',
|
||||
'Key',
|
||||
'Base URL',
|
||||
'The public address of the frontend. Use https and no path.',
|
||||
'Default social image (optional)',
|
||||
'Choose image',
|
||||
'Used when an article has no image of its own.',
|
||||
'Default robots policy',
|
||||
'Index',
|
||||
'Noindex',
|
||||
"Search engines may list this site's pages.",
|
||||
'Locale (optional)',
|
||||
'A language tag. Leave empty if the site has no single language.',
|
||||
'Make this the default site',
|
||||
'New articles start here. The current default is Renew.',
|
||||
'Canonical URL preview',
|
||||
'Cancel',
|
||||
]) {
|
||||
expect(text).toContain(copy)
|
||||
}
|
||||
expect(field('e.g. Journal')).toBeTruthy()
|
||||
expect(field('Generated from the name')).toBeTruthy()
|
||||
expect(field('https://blog.example.com')).toBeTruthy()
|
||||
expect(field('e.g. en or fr-CH')).toBeTruthy()
|
||||
expect(button('Add site', box())).toBeTruthy()
|
||||
})
|
||||
|
||||
it('focuses Name', async () => {
|
||||
await openAdd()
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(field('e.g. Journal'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites form: key from the name', () => {
|
||||
it('fills the key with an Auto badge and unlocks it with the pencil', async () => {
|
||||
await openAdd()
|
||||
await typeInto(field('e.g. Journal'), 'Journal Online')
|
||||
|
||||
const key = labelled('Key') as HTMLInputElement
|
||||
expect(key.value).toBe('journal-online')
|
||||
expect(textOf(box())).toContain('Auto')
|
||||
expect(textOf(box())).toContain(
|
||||
"Lowercase letters, numbers and hyphens. Used in API paths. It can't be changed after the site is added.",
|
||||
)
|
||||
|
||||
await click('Edit key', box())
|
||||
await typeInto(labelled('Key') as HTMLInputElement, 'custom-key')
|
||||
await typeInto(field('e.g. Journal'), 'Something else')
|
||||
|
||||
expect((labelled('Key') as HTMLInputElement).value).toBe('custom-key')
|
||||
expect(textOf(box())).not.toContain('Auto')
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['http://blog.example.com', BASE_URL_MESSAGES.scheme],
|
||||
['https://blog.example.com/en', BASE_URL_MESSAGES.path],
|
||||
['blog', BASE_URL_MESSAGES.invalid],
|
||||
])('Sites form: base URL "%s" is not valid', (value, message) => {
|
||||
it('shows the message on leaving the field and sends nothing', async () => {
|
||||
const backend = await openAdd()
|
||||
await fillValid('Journal 2', value)
|
||||
await blur(field('https://blog.example.com'))
|
||||
|
||||
expect(textOf(box())).toContain(message)
|
||||
await submit()
|
||||
expect(callsTo(backend, 'POST', '/v1/site/')).toHaveLength(0)
|
||||
expect(textOf(box())).toContain(message)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites form: base URL refused by the server', () => {
|
||||
it('shows the generic URL message on 400 invalid_site_url', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['POST /v1/site/'] = failWith(400, 'invalid_site_url')
|
||||
await openAdd({ backend })
|
||||
await fillValid()
|
||||
await submit()
|
||||
|
||||
expect(textOf(box())).toContain(BASE_URL_MESSAGES.invalid)
|
||||
})
|
||||
})
|
||||
|
||||
describe.each(['http://localhost:5173', 'http://127.0.0.1:8081', 'http://[::1]:5173'])(
|
||||
'Sites form: loopback host %s accepts http',
|
||||
(value) => {
|
||||
it('shows no base URL error', async () => {
|
||||
await openAdd()
|
||||
await fillValid('Journal 2', value)
|
||||
await blur(field('https://blog.example.com'))
|
||||
|
||||
const text = textOf(box())
|
||||
expect(text).not.toContain(BASE_URL_MESSAGES.scheme)
|
||||
expect(text).not.toContain(BASE_URL_MESSAGES.invalid)
|
||||
expect(text).not.toContain(BASE_URL_MESSAGES.path)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
describe('Sites form: add succeeds', () => {
|
||||
it('sends exactly one POST, shows Saving… with disabled fields, then closes and lists the site', async () => {
|
||||
const backend = createBackend()
|
||||
const hold = pending<unknown>()
|
||||
backend.responders['POST /v1/site/'] = () => hold.promise
|
||||
await openAdd({ backend })
|
||||
await typeInto(field('e.g. Journal'), 'Notes')
|
||||
await typeInto(field('https://blog.example.com'), 'https://notes.example')
|
||||
box().querySelector<HTMLElement>('[role="radio"][value="noindex"]')!.click()
|
||||
await flushPromises()
|
||||
await submit()
|
||||
|
||||
expect(button('Saving…', box())).toBeTruthy()
|
||||
expect(field('e.g. Journal').disabled).toBe(true)
|
||||
hold.resolve({
|
||||
id: 5,
|
||||
name: 'Notes',
|
||||
key: 'notes',
|
||||
base_url: 'https://notes.example',
|
||||
default_social_image_id: null,
|
||||
default_robots: 'noindex',
|
||||
locale: null,
|
||||
is_default: false,
|
||||
article_count: 0,
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const posts = callsTo(backend, 'POST', '/v1/site/')
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(posts[0]!.body).toEqual({
|
||||
name: 'Notes',
|
||||
key: 'notes',
|
||||
base_url: 'https://notes.example',
|
||||
default_robots: 'noindex',
|
||||
is_default: false,
|
||||
})
|
||||
expect(dialog()).toBeNull()
|
||||
expect(rowTexts().at(-1)).toContain('Notes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites form: duplicate key', () => {
|
||||
it('flags the key on leaving the field and applies the suggestion', async () => {
|
||||
await openAdd()
|
||||
await typeInto(field('e.g. Journal'), 'Journal')
|
||||
await click('Edit key', box())
|
||||
const key = labelled('Key') as HTMLInputElement
|
||||
await blur(key)
|
||||
|
||||
expect(textOf(box())).toContain(
|
||||
'A site with the key "journal" already exists. Choose another key.',
|
||||
)
|
||||
await click('Use journal-2', box())
|
||||
|
||||
expect((labelled('Key') as HTMLInputElement).value).toBe('journal-2')
|
||||
expect(textOf(box())).not.toContain('already exists')
|
||||
})
|
||||
|
||||
it('flags the key when the server answers 409 site_key_taken', async () => {
|
||||
const backend = createBackend()
|
||||
backend.sites = backend.sites.filter((site) => site.key !== 'journal')
|
||||
backend.responders['POST /v1/site/'] = failWith(409, 'site_key_taken')
|
||||
await openAdd({ backend })
|
||||
await fillValid('Journal', 'https://journal.example')
|
||||
await submit()
|
||||
|
||||
expect(textOf(box())).toContain(
|
||||
'A site with the key "journal" already exists. Choose another key.',
|
||||
)
|
||||
expect(textOf(box())).toContain('Use journal-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['status 500', failWith(500)],
|
||||
['a network error', networkFailure],
|
||||
])('Sites form: save failed with %s', (_name, responder) => {
|
||||
it('shows the notice, keeps the values and enables the form again', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['POST /v1/site/'] = responder
|
||||
await openAdd({ backend })
|
||||
await fillValid()
|
||||
await submit()
|
||||
|
||||
expect(textOf(box().querySelector('[role="alert"]'))).toContain(SAVE_FAILED)
|
||||
expect(field('e.g. Journal').value).toBe('Journal 2')
|
||||
expect(field('https://blog.example.com').value).toBe('https://journal2.example')
|
||||
expect(field('e.g. Journal').disabled).toBe(false)
|
||||
expect(button('Add site', box())).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites form: invalid locale from the server', () => {
|
||||
it('shows the locale message and keeps the panel open', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['POST /v1/site/'] = failWith(400, 'invalid_locale')
|
||||
await openAdd({ backend })
|
||||
await fillValid()
|
||||
await typeInto(field('e.g. en or fr-CH'), 'english')
|
||||
await submit()
|
||||
|
||||
expect(textOf(box())).toContain('Use a language tag like en or fr-CH.')
|
||||
expect(dialog()).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites form: edit the default site', () => {
|
||||
it('fills the fields, locks the key and keeps the default switch on and locked', async () => {
|
||||
await openEdit('Renew')
|
||||
|
||||
const text = textOf(box())
|
||||
expect(text).toContain('Edit Renew')
|
||||
expect(field('e.g. Journal').value).toBe('Renew')
|
||||
const key = labelled('Key') as HTMLInputElement
|
||||
expect(key.value).toBe('renew')
|
||||
expect(key.readOnly || key.disabled).toBe(true)
|
||||
expect(box().querySelector('[data-testid="slug-lock"]')).not.toBeNull()
|
||||
expect(text).toContain("Used in API paths, so it can't change.")
|
||||
expect(text).toContain('Default site')
|
||||
expect(text).toContain(
|
||||
'New articles start on the default site. To change it, make another site the default.',
|
||||
)
|
||||
const toggle = box().querySelector<HTMLButtonElement>('[role="switch"]')!
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('true')
|
||||
expect(toggle.disabled || toggle.getAttribute('data-disabled') !== null).toBe(true)
|
||||
expect(button('Save changes', box())).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites form: edit another site, noindex', () => {
|
||||
it('shows Noindex selected with its help and sends a PATCH without the key', async () => {
|
||||
const backend = await openEdit('Projects')
|
||||
|
||||
const text = textOf(box())
|
||||
expect(text).toContain('Edit Projects')
|
||||
expect(text).toContain("Asks search engines not to list this site's pages.")
|
||||
expect(text).toContain('Make this the default site')
|
||||
const noindex = box().querySelector('[role="radio"][value="noindex"]')!
|
||||
expect(noindex.getAttribute('aria-checked')).toBe('true')
|
||||
|
||||
await typeInto(field('e.g. Journal'), 'Projects 2')
|
||||
await submit('Save changes')
|
||||
|
||||
const patches = callsTo(backend, 'PATCH', '/v1/site/3')
|
||||
expect(patches).toHaveLength(1)
|
||||
expect(patches[0]!.body).toEqual({ name: 'Projects 2' })
|
||||
expect(patches[0]!.body).not.toHaveProperty('key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites form: make another site the default from the form', () => {
|
||||
it('sends the changed fields, then the default route, and never is_default in the first PATCH', async () => {
|
||||
const backend = await openEdit('Projects')
|
||||
await typeInto(field('e.g. Journal'), 'Projects 2')
|
||||
box().querySelector<HTMLElement>('[role="switch"]')!.click()
|
||||
await flushPromises()
|
||||
await submit('Save changes')
|
||||
|
||||
const order = backend.calls
|
||||
.filter((call) => call.method === 'PATCH')
|
||||
.map((call) => call.path)
|
||||
expect(order).toEqual(['/v1/site/3', '/v1/site/3/default'])
|
||||
expect(backend.calls.find((call) => call.path === '/v1/site/3')!.body).not.toHaveProperty(
|
||||
'is_default',
|
||||
)
|
||||
expect(
|
||||
textOf(document.body.querySelectorAll('[data-testid="directory-row"]')[2]),
|
||||
).toContain('Default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites form: discard changes', () => {
|
||||
it('asks before closing a changed form with Escape and keeps it on Keep editing', async () => {
|
||||
await openAdd()
|
||||
await typeInto(field('e.g. Journal'), 'Notes')
|
||||
box().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
const ask = document.body.querySelector('[role="alertdialog"]')!
|
||||
expect(textOf(ask)).toContain('Discard changes?')
|
||||
await click('Keep editing', ask)
|
||||
|
||||
expect(field('e.g. Journal').value).toBe('Notes')
|
||||
})
|
||||
|
||||
it('discards on confirmation and closes an unchanged form with Cancel straight away', async () => {
|
||||
await openAdd()
|
||||
await typeInto(field('e.g. Journal'), 'Notes')
|
||||
await click('Cancel', box())
|
||||
await click('Discard changes', document.body.querySelector('[role="alertdialog"]')!)
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||
|
||||
await click('Add site')
|
||||
await click('Cancel', box())
|
||||
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites form: deep link', () => {
|
||||
it('opens the edit slide-over for a known key', async () => {
|
||||
await mountDirectory('sites', '/settings/sites?edit=projects')
|
||||
|
||||
expect(textOf(box())).toContain('Edit Projects')
|
||||
})
|
||||
|
||||
it('shows an info toast and opens nothing for an unknown key', async () => {
|
||||
await mountDirectory('sites', '/settings/sites?edit=nope')
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(document.body.textContent).toContain('That site no longer exists')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Slide-over accessibility', () => {
|
||||
it('moves focus into the panel and returns it to the opening control on close', async () => {
|
||||
await mountDirectory('sites', '/settings/sites')
|
||||
const opener = button('Add site')!
|
||||
opener.focus()
|
||||
opener.click()
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
expect(box().contains(document.activeElement)).toBe(true)
|
||||
await click('Cancel', box())
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(opener)
|
||||
})
|
||||
|
||||
it('ignores Escape while a request is running', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['POST /v1/site/'] = () => pending<unknown>().promise
|
||||
await openAdd({ backend })
|
||||
await fillValid()
|
||||
await submit()
|
||||
box().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(dialog()).not.toBeNull()
|
||||
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
void useApi
|
||||
@@ -0,0 +1,323 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import {
|
||||
callsTo,
|
||||
chooseMenuItem,
|
||||
click,
|
||||
createBackend,
|
||||
dialog,
|
||||
failWith,
|
||||
mountDirectory,
|
||||
networkFailure,
|
||||
openRowMenu,
|
||||
pending,
|
||||
rowTexts,
|
||||
rows,
|
||||
textOf,
|
||||
unmountDirectory,
|
||||
button,
|
||||
} from './directoryTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const DEFAULT_SITE_TIP = "The default site can't be deleted. Make another site the default first."
|
||||
|
||||
afterEach(unmountDirectory)
|
||||
|
||||
describe('Sites: default (owner)', () => {
|
||||
it('shows the header, the columns, the rows, the counts and the badges', async () => {
|
||||
await mountDirectory('sites', '/settings/sites')
|
||||
|
||||
expect(document.body.querySelector('h1')?.textContent).toBe('Sites')
|
||||
expect(document.body.textContent).toContain('The frontends that display your content.')
|
||||
expect(button('Add site')).toBeTruthy()
|
||||
const header = document.body.querySelector(
|
||||
'[data-testid="directory-row"]',
|
||||
)!.previousElementSibling!
|
||||
expect(textOf(header)).toBe('Site Base URL Articles')
|
||||
expect(rowTexts()).toEqual([
|
||||
'Renew Default renew https://renew.example 268 articles',
|
||||
'Journal journal https://journal.example 38 articles',
|
||||
'Projects Noindex projects https://projects.example 6 articles',
|
||||
'Archive archive https://archive.example No articles',
|
||||
])
|
||||
})
|
||||
|
||||
it('draws cards below md with a full-width Add site button pinned to the bottom', async () => {
|
||||
await mountDirectory('sites', '/settings/sites', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(document.body.querySelectorAll('[data-testid="directory-card"]')).toHaveLength(4)
|
||||
expect(document.body.querySelector('[data-testid="directory-row"]')).toBeNull()
|
||||
const pinned = button('Add site')!
|
||||
expect(pinned.className).toContain('fixed')
|
||||
expect(pinned.className).toContain('inset-x-4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites: only the default site', () => {
|
||||
it('lists the row with the hint and draws no empty-state wall', async () => {
|
||||
const backend = createBackend()
|
||||
backend.sites = backend.sites.slice(0, 1)
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
|
||||
expect(rows()).toHaveLength(1)
|
||||
expect(document.body.textContent).toContain(
|
||||
'Add another site if your content is shown on more than one frontend. Each article belongs to one site.',
|
||||
)
|
||||
expect(document.body.textContent).not.toContain('No sites yet')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites: loading', () => {
|
||||
it('draws nothing before 150 ms, then skeleton rows with aria-busy', async () => {
|
||||
vi.useFakeTimers()
|
||||
const backend = createBackend()
|
||||
const hold = pending<unknown>()
|
||||
backend.responders['GET /v1/site/'] = () => hold.promise
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
|
||||
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
|
||||
expect(
|
||||
document.body.querySelectorAll('[data-testid="directory-skeleton-row"]'),
|
||||
).toHaveLength(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS)
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
|
||||
expect(
|
||||
document.body.querySelectorAll('[data-testid="directory-skeleton-row"]'),
|
||||
).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['status 500', failWith(500)],
|
||||
['a network error', networkFailure],
|
||||
])('Sites: error on load with %s', (_name, responder) => {
|
||||
it('shows the alert, no rows, and Try again requests the list again', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['GET /v1/site/'] = responder
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
|
||||
const alert = document.body.querySelector('[role="alert"]')!
|
||||
expect(textOf(alert)).toContain("Couldn't load sites")
|
||||
expect(textOf(alert)).toContain('Check your connection and try again.')
|
||||
expect(rows()).toHaveLength(0)
|
||||
expect(textOf(alert)).not.toContain('500')
|
||||
|
||||
delete backend.responders['GET /v1/site/']
|
||||
await click('Try again')
|
||||
|
||||
expect(callsTo(backend, 'GET', '/v1/site/')).toHaveLength(2)
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
expect(rows()).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites: row menu', () => {
|
||||
it('offers Edit, Set as default and Delete… for a non-default site', async () => {
|
||||
await mountDirectory('sites', '/settings/sites')
|
||||
await openRowMenu('Journal')
|
||||
|
||||
const labels = Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf)
|
||||
expect(labels).toEqual(['Edit', 'Set as default', 'Delete…'])
|
||||
})
|
||||
|
||||
it('opens a bottom sheet titled with the site name below md', async () => {
|
||||
await mountDirectory('sites', '/settings/sites', { width: MOBILE_WIDTH })
|
||||
await openRowMenu('Journal')
|
||||
|
||||
const sheet = dialog()!
|
||||
expect(textOf(sheet.querySelector('h2'))).toBe('Journal')
|
||||
expect(sheet.querySelectorAll('[role="menuitem"]')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites: delete unavailable on the default site', () => {
|
||||
it('keeps Delete… focusable with aria-disabled and sends nothing when activated', async () => {
|
||||
const backend = await mountDirectory('sites', '/settings/sites')
|
||||
await openRowMenu('Renew')
|
||||
|
||||
const item = button(/^Delete…/)!
|
||||
expect(item.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(item.hasAttribute('disabled')).toBe(false)
|
||||
expect(item.getAttribute('data-disabled')).toBeNull()
|
||||
expect(textOf(document.getElementById(item.getAttribute('aria-describedby')!))).toBe(
|
||||
DEFAULT_SITE_TIP,
|
||||
)
|
||||
item.focus()
|
||||
expect(document.activeElement).toBe(item)
|
||||
|
||||
item.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(dialog()?.getAttribute('role')).not.toBe('alertdialog')
|
||||
expect(backend.calls.filter((call) => call.method === 'DELETE')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('writes the explanation in the sheet below md', async () => {
|
||||
await mountDirectory('sites', '/settings/sites', { width: MOBILE_WIDTH })
|
||||
await openRowMenu('Renew')
|
||||
|
||||
expect(textOf(dialog())).toContain(DEFAULT_SITE_TIP)
|
||||
expect(button('Delete…')!.getAttribute('aria-disabled')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites: set as default', () => {
|
||||
async function openDialog(backend = createBackend()) {
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
await openRowMenu('Journal')
|
||||
await chooseMenuItem('Set as default')
|
||||
return backend
|
||||
}
|
||||
|
||||
it('asks, sends one PATCH without a body, shows Saving… and moves the badge with a toast', async () => {
|
||||
const backend = createBackend()
|
||||
const hold = pending<unknown>()
|
||||
backend.responders['PATCH /v1/site/2/default'] = () => hold.promise
|
||||
await openDialog(backend)
|
||||
|
||||
const box = dialog()!
|
||||
expect(textOf(box)).toContain('Make Journal the default site?')
|
||||
expect(textOf(box)).toContain(
|
||||
'New articles start on this site. Articles that are already on other sites stay where they are.',
|
||||
)
|
||||
expect(button('Cancel', box)).toBeTruthy()
|
||||
|
||||
await click('Make default', box)
|
||||
expect(button('Saving…', box)).toBeTruthy()
|
||||
hold.resolve({})
|
||||
await flushPromises()
|
||||
|
||||
const patches = callsTo(backend, 'PATCH', '/v1/site/2/default')
|
||||
expect(patches).toHaveLength(1)
|
||||
expect(patches[0]!.body).toBeUndefined()
|
||||
expect(textOf(rows()[1])).toContain('Default')
|
||||
expect(textOf(rows()[0])).not.toContain('Default')
|
||||
expect(document.body.textContent).toContain('Default site changed')
|
||||
expect(document.body.textContent).toContain('"Journal" is now the default site.')
|
||||
})
|
||||
|
||||
it('keeps the dialog open with a notice on a 500', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['PATCH /v1/site/2/default'] = failWith(500)
|
||||
await openDialog(backend)
|
||||
await click('Make default', dialog()!)
|
||||
|
||||
expect(dialog()).not.toBeNull()
|
||||
expect(textOf(dialog()!.querySelector('[role="alert"]'))).toContain(
|
||||
"Couldn't save the site. Your changes are still here. Try again in a moment.",
|
||||
)
|
||||
})
|
||||
|
||||
it('closes, removes the row and toasts on a 404', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['PATCH /v1/site/2/default'] = failWith(404, 'site_not_found')
|
||||
await openDialog(backend)
|
||||
await click('Make default', dialog()!)
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(rowTexts().join(' ')).not.toContain('Journal')
|
||||
expect(document.body.textContent).toContain('That site no longer exists')
|
||||
expect(document.body.textContent).toContain(
|
||||
'It was deleted somewhere else, so we removed it from the list.',
|
||||
)
|
||||
})
|
||||
|
||||
it('closes and shows the owner-only toast on a 403', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['PATCH /v1/site/2/default'] = failWith(
|
||||
403,
|
||||
'Only the owner can do this.',
|
||||
)
|
||||
await openDialog(backend)
|
||||
await click('Make default', dialog()!)
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(document.body.textContent).toContain('Only the owner can manage sites')
|
||||
expect(document.body.textContent).toContain('Ask the owner of this site.')
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['admin', '/settings/sites'],
|
||||
['member', '/settings/sites'],
|
||||
['admin', '/settings/sites?edit=journal'],
|
||||
])('Sites: forbidden (403 page) for %s on %s', (role, path) => {
|
||||
it('explains instead of redirecting, opens no panel and Back to Settings goes to /settings', async () => {
|
||||
const backend = await mountDirectory('sites', path, { role })
|
||||
|
||||
const text = document.body.textContent ?? ''
|
||||
expect(text).toContain("You don't have access to Sites")
|
||||
expect(text).toContain(
|
||||
'Only the owner can manage sites. Ask the owner of this site if you need a change.',
|
||||
)
|
||||
expect(router.currentRoute.value.fullPath).toBe(path)
|
||||
expect(dialog()).toBeNull()
|
||||
expect(
|
||||
document.body.querySelector('nav[aria-label="Main"] a[href="/settings/sites"]'),
|
||||
).toBeNull()
|
||||
expect(callsTo(backend, 'GET', '/v1/site/')).toHaveLength(0)
|
||||
|
||||
const push = vi.spyOn(router, 'push').mockResolvedValue(undefined)
|
||||
await click('Back to Settings')
|
||||
|
||||
expect(push).toHaveBeenCalledWith('/settings')
|
||||
push.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites: the API answers 403 where the SPA expected the owner', () => {
|
||||
it('shows the 403 page when the answer is for the page load', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['GET /v1/site/'] = failWith(403, 'Only the owner can do this.')
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
|
||||
expect(document.body.textContent).toContain("You don't have access to Sites")
|
||||
expect(rows()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shows an error toast when the answer is for an action', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['DELETE /v1/site/4'] = failWith(403, 'Only the owner can do this.')
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
await openRowMenu('Archive')
|
||||
await chooseMenuItem('Delete…')
|
||||
await click('Delete site', dialog()!)
|
||||
|
||||
expect(document.body.textContent).toContain('Only the owner can manage sites')
|
||||
expect(document.body.textContent).toContain('Ask the owner of this site.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites: the sidebar for the owner and for an admin', () => {
|
||||
it('shows a Publishing group with a Sites entry linking to /settings/sites for the owner', async () => {
|
||||
await mountDirectory('sites', '/settings/sites')
|
||||
|
||||
const nav = document.body.querySelector('nav[aria-label="Main"]')!
|
||||
expect(textOf(nav)).toContain('Publishing')
|
||||
const link = nav.querySelector<HTMLAnchorElement>('a[href="/settings/sites"]')!
|
||||
expect(textOf(link)).toBe('Sites')
|
||||
expect(link.getAttribute('aria-current')).toBe('page')
|
||||
})
|
||||
|
||||
it('has no Publishing group and no Sites entry for an admin', async () => {
|
||||
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
|
||||
|
||||
const nav = document.body.querySelector('nav[aria-label="Main"]')!
|
||||
expect(textOf(nav)).not.toContain('Publishing')
|
||||
expect(nav.querySelector('a[href="/settings/sites"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
void useApi
|
||||
void DESKTOP_WIDTH
|
||||
@@ -0,0 +1,445 @@
|
||||
import { vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import UApp from '@nuxt/ui/components/App.vue'
|
||||
import router from '@/router'
|
||||
import { ApiError, NetworkError, useApi, type MeAuthor } from '@/composables/useApi'
|
||||
import type { Author } from '@/composables/useAuthors'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import AuthorsView from '../AuthorsView.vue'
|
||||
import SitesView from '../SitesView.vue'
|
||||
|
||||
export interface Call {
|
||||
method: string
|
||||
path: string
|
||||
raw: string
|
||||
query: URLSearchParams
|
||||
body: Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
export type Responder = (call: Call) => unknown | Promise<unknown>
|
||||
|
||||
export interface Backend {
|
||||
sites: Site[]
|
||||
authors: Author[]
|
||||
users: unknown[]
|
||||
calls: Call[]
|
||||
responders: Record<string, Responder>
|
||||
}
|
||||
|
||||
export function sitesFixture(): Site[] {
|
||||
const base = {
|
||||
default_social_image_id: null,
|
||||
default_robots: 'index' as const,
|
||||
locale: null,
|
||||
}
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
id: 1,
|
||||
name: 'Renew',
|
||||
key: 'renew',
|
||||
base_url: 'https://renew.example',
|
||||
is_default: true,
|
||||
article_count: 268,
|
||||
},
|
||||
{
|
||||
...base,
|
||||
id: 2,
|
||||
name: 'Journal',
|
||||
key: 'journal',
|
||||
base_url: 'https://journal.example',
|
||||
is_default: false,
|
||||
article_count: 38,
|
||||
},
|
||||
{
|
||||
...base,
|
||||
id: 3,
|
||||
name: 'Projects',
|
||||
key: 'projects',
|
||||
base_url: 'https://projects.example',
|
||||
default_robots: 'noindex',
|
||||
is_default: false,
|
||||
article_count: 6,
|
||||
},
|
||||
{
|
||||
...base,
|
||||
id: 4,
|
||||
name: 'Archive',
|
||||
key: 'archive',
|
||||
base_url: 'https://archive.example',
|
||||
is_default: false,
|
||||
article_count: 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function author(overrides: Partial<Author> & Pick<Author, 'id' | 'name' | 'slug'>): Author {
|
||||
return {
|
||||
bio: '',
|
||||
avatar_id: null,
|
||||
website: null,
|
||||
social_links: [],
|
||||
user_id: null,
|
||||
is_linked: false,
|
||||
article_count: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function authorsFixture(withEmails = true): Author[] {
|
||||
const list = [
|
||||
author({
|
||||
id: 2,
|
||||
name: 'Inès Duarte',
|
||||
slug: 'ines-duarte',
|
||||
bio: 'Site manager and restoration carpenter.',
|
||||
website: 'https://ines-duarte.example',
|
||||
social_links: [{ type: 'linkedin', url: 'https://www.linkedin.com/in/ines-duarte' }],
|
||||
user_id: 3,
|
||||
is_linked: true,
|
||||
article_count: 9,
|
||||
user_email: 'ines.duarte@renew.example',
|
||||
}),
|
||||
author({
|
||||
id: 7,
|
||||
name: 'Camille Roche',
|
||||
slug: 'camille-roche',
|
||||
bio: 'Freelance writer on lime and stone.',
|
||||
article_count: 3,
|
||||
}),
|
||||
author({
|
||||
id: 9,
|
||||
name: 'Dr. Alexandra Konstantinopoulou-Whitfield',
|
||||
slug: 'dr-alexandra-konstantinopoulou-whitfield',
|
||||
article_count: 1,
|
||||
}),
|
||||
author({ id: 10, name: 'Léa Fontaine', slug: 'lea-fontaine' }),
|
||||
]
|
||||
return withEmails ? list : list.map((item) => ({ ...item, user_email: undefined }))
|
||||
}
|
||||
|
||||
export function usersFixture() {
|
||||
const user = (id: number, username: string, display: string, email: string, role: string) => ({
|
||||
kind: 'user',
|
||||
id,
|
||||
username,
|
||||
display_name: display,
|
||||
email,
|
||||
role,
|
||||
status: 'accepted',
|
||||
created_at: '2026-03-12T09:00:00Z',
|
||||
})
|
||||
return [
|
||||
user(3, 'ines', 'Inès Duarte', 'ines.duarte@renew.example', 'admin'),
|
||||
user(1, 'marta', 'Marta Alves', 'marta@renew.example', 'owner'),
|
||||
user(4, 'rui', 'Rui Santos', 'rui@renew.example', 'admin'),
|
||||
]
|
||||
}
|
||||
|
||||
function fail(status: number, code?: string): never {
|
||||
throw new ApiError(status, undefined, code)
|
||||
}
|
||||
|
||||
function parseBody(options: RequestInit | undefined) {
|
||||
return typeof options?.body === 'string'
|
||||
? (JSON.parse(options.body) as Record<string, unknown>)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function pageOf(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.authors.filter((item) =>
|
||||
`${item.name} ${item.slug}`.toLowerCase().includes(q),
|
||||
)
|
||||
return {
|
||||
items: matching.slice((page - 1) * size, page * size),
|
||||
total: matching.length,
|
||||
page,
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if (method === 'POST' && path === '/v1/site/') {
|
||||
if (backend.sites.some((site) => site.key === body?.key)) {
|
||||
fail(409, 'site_key_taken')
|
||||
}
|
||||
const created = {
|
||||
id: 100,
|
||||
default_social_image_id: null,
|
||||
locale: null,
|
||||
article_count: 0,
|
||||
...body,
|
||||
} as unknown as Site
|
||||
backend.sites.push(created)
|
||||
return created
|
||||
}
|
||||
if (siteMatch && method === 'PATCH') {
|
||||
const site = backend.sites.find((candidate) => candidate.id === Number(siteMatch[1]))
|
||||
if (!site) {
|
||||
fail(404, 'site_not_found')
|
||||
}
|
||||
if (siteMatch[2]) {
|
||||
backend.sites.forEach((candidate) => (candidate.is_default = candidate === site))
|
||||
} else {
|
||||
Object.assign(site, body)
|
||||
}
|
||||
return structuredClone(site)
|
||||
}
|
||||
if (siteMatch && method === 'DELETE') {
|
||||
const id = Number(siteMatch[1])
|
||||
const site = backend.sites.find((candidate) => candidate.id === id)
|
||||
if (!site) {
|
||||
fail(404, 'site_not_found')
|
||||
}
|
||||
if (site.is_default) {
|
||||
fail(403, 'default_site')
|
||||
}
|
||||
const reassign = query.get('reassign_to')
|
||||
if (site.article_count > 0 && reassign === null) {
|
||||
fail(409, 'site_has_articles')
|
||||
}
|
||||
backend.sites = backend.sites.filter((candidate) => candidate.id !== id)
|
||||
return undefined
|
||||
}
|
||||
if (method === 'GET' && path === '/v1/author/') {
|
||||
return pageOf(backend, query)
|
||||
}
|
||||
if (method === 'POST' && path === '/v1/author/') {
|
||||
const created = { id: 200, is_linked: body?.user_id != null, article_count: 0, ...body }
|
||||
backend.authors.push(created as unknown as Author)
|
||||
return created
|
||||
}
|
||||
if (authorMatch && method === 'PATCH') {
|
||||
const target = backend.authors.find((candidate) => candidate.id === Number(authorMatch[1]))
|
||||
if (!target) {
|
||||
fail(404, 'author_not_found')
|
||||
}
|
||||
Object.assign(target, body)
|
||||
if (body && 'user_id' in body) {
|
||||
target.is_linked = body.user_id !== null
|
||||
}
|
||||
return structuredClone(target)
|
||||
}
|
||||
if (authorMatch && method === 'DELETE') {
|
||||
const id = Number(authorMatch[1])
|
||||
backend.authors = backend.authors.filter((candidate) => candidate.id !== id)
|
||||
return undefined
|
||||
}
|
||||
if (method === 'GET' && path === '/v1/user/') {
|
||||
return backend.users
|
||||
}
|
||||
if (method === 'GET' && path === '/v1/media/') {
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 0, unused: 0, duplicates: 0 },
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method} ${path}`)
|
||||
}
|
||||
|
||||
export function createBackend(): Backend {
|
||||
return {
|
||||
sites: sitesFixture(),
|
||||
authors: authorsFixture(),
|
||||
users: usersFixture(),
|
||||
calls: [],
|
||||
responders: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function callsTo(backend: Backend, method: string, path: string) {
|
||||
return backend.calls.filter((call) => call.method === method && call.path === path)
|
||||
}
|
||||
|
||||
export function failWith(status: number, code?: string): Responder {
|
||||
return () => fail(status, code)
|
||||
}
|
||||
|
||||
export const networkFailure: Responder = () => {
|
||||
throw new NetworkError()
|
||||
}
|
||||
|
||||
export function pending<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
export interface MountOptions {
|
||||
role?: string
|
||||
width?: number
|
||||
backend?: Backend
|
||||
author?: MeAuthor
|
||||
email?: string
|
||||
}
|
||||
|
||||
export const kit: { wrapper: VueWrapper | null; backend: Backend } = {
|
||||
wrapper: null,
|
||||
backend: createBackend(),
|
||||
}
|
||||
|
||||
export async function mountDirectory(
|
||||
view: 'sites' | 'authors',
|
||||
url: string,
|
||||
options: MountOptions = {},
|
||||
) {
|
||||
setViewportWidth(options.width ?? DESKTOP_WIDTH)
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
setActivePinia(createPinia())
|
||||
const backend = options.backend ?? createBackend()
|
||||
kit.backend = backend
|
||||
const session = useSessionStore()
|
||||
session.session = {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
email: options.email ?? 'marta@renew.example',
|
||||
displayName: 'Marta Alves',
|
||||
role: (options.role ?? 'owner') as never,
|
||||
...(options.author ? { author: options.author } : {}),
|
||||
}
|
||||
session.status = 'ready'
|
||||
const request = vi.fn(async (path: string, init?: RequestInit) => {
|
||||
const [pathname = '', search = ''] = path.split('?')
|
||||
const call: Call = {
|
||||
method: init?.method ?? 'GET',
|
||||
path: pathname,
|
||||
raw: path,
|
||||
query: new URLSearchParams(search),
|
||||
body: parseBody(init),
|
||||
}
|
||||
backend.calls.push(call)
|
||||
const responder =
|
||||
backend.responders[`${call.method} ${path}`] ??
|
||||
backend.responders[`${call.method} ${pathname}`]
|
||||
return responder ? responder(call) : defaultRoute(backend, call)
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
|
||||
request,
|
||||
} as unknown as ReturnType<typeof useApi>)
|
||||
await router.push(url)
|
||||
const component = view === 'sites' ? SitesView : AuthorsView
|
||||
kit.wrapper = mount(
|
||||
defineComponent({ render: () => h(UApp, null, { default: () => h(component) }) }),
|
||||
{ attachTo: document.body, global: { plugins: [router, uiPlugin] } },
|
||||
)
|
||||
await flushPromises()
|
||||
return backend
|
||||
}
|
||||
|
||||
export async function unmountDirectory() {
|
||||
kit.wrapper?.unmount()
|
||||
kit.wrapper = null
|
||||
vi.useRealTimers()
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
}
|
||||
|
||||
export function textOf(element: Element | null | undefined) {
|
||||
if (!element) {
|
||||
return ''
|
||||
}
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
|
||||
const parts: string[] = []
|
||||
while (walker.nextNode()) {
|
||||
const text = walker.currentNode.textContent?.replace(/\s+/g, ' ').trim()
|
||||
if (text) {
|
||||
parts.push(text)
|
||||
}
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
export function button(label: string | RegExp, root: ParentNode = document.body) {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>('button, [role="menuitem"], a')).find(
|
||||
(element) => {
|
||||
const text = element.getAttribute('aria-label') || textOf(element)
|
||||
return typeof label === 'string' ? text === label : label.test(text)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function click(label: string | RegExp, root: ParentNode = document.body) {
|
||||
const target = button(label, root)
|
||||
if (!target) {
|
||||
throw new Error(`No control named ${String(label)}`)
|
||||
}
|
||||
target.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export function dialog() {
|
||||
return document.body.querySelector<HTMLElement>('[role="dialog"], [role="alertdialog"]')
|
||||
}
|
||||
|
||||
export function rows() {
|
||||
return Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>(
|
||||
'[data-testid="directory-row"], [data-testid="directory-card"]',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function rowTexts() {
|
||||
return rows().map((row) => textOf(row))
|
||||
}
|
||||
|
||||
export async function openRowMenu(name: string) {
|
||||
await click(`Actions for ${name}`)
|
||||
}
|
||||
|
||||
export async function chooseMenuItem(label: string) {
|
||||
await click(label)
|
||||
}
|
||||
|
||||
export async function typeInto(element: HTMLInputElement | HTMLTextAreaElement, value: string) {
|
||||
element.value = value
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export async function blur(element: HTMLElement) {
|
||||
element.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
|
||||
element.dispatchEvent(new Event('blur'))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export function field(placeholder: string) {
|
||||
return document.body.querySelector<HTMLInputElement>(`[placeholder="${placeholder}"]`)!
|
||||
}
|
||||
|
||||
export function labelled(label: string) {
|
||||
const labelElement = Array.from(document.body.querySelectorAll('label')).find((element) =>
|
||||
textOf(element).startsWith(label),
|
||||
)
|
||||
const id = labelElement?.getAttribute('for')
|
||||
return id ? document.getElementById(id)! : null
|
||||
}
|
||||
|
||||
export function toasts() {
|
||||
return textOf(
|
||||
document.body.querySelector(
|
||||
'[data-slot="viewport"], [role="region"][aria-label*="otification"]',
|
||||
),
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user