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,32 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { initialsOf } from '@/lib/directory'
|
||||
|
||||
const TONES = ['bg-(--rc-sand)', 'bg-(--ui-bg-accented)', 'bg-(--ui-bg-elevated)'] as const
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
name: string
|
||||
size?: number
|
||||
ring?: boolean
|
||||
}>(),
|
||||
{ size: 32, ring: false },
|
||||
)
|
||||
|
||||
const tone = computed(() => TONES[props.name.length % TONES.length])
|
||||
const fontSize = computed(() => `${Math.round(props.size * 0.36)}px`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UAvatar
|
||||
:alt="name"
|
||||
:text="initialsOf(name)"
|
||||
aria-hidden="true"
|
||||
:style="{ width: `${size}px`, height: `${size}px`, fontSize }"
|
||||
:class="[tone, ring ? 'border-2 border-(--ui-bg)' : 'border border-default']"
|
||||
:ui="{
|
||||
root: 'shrink-0 rounded-none text-highlighted',
|
||||
fallback: 'font-semibold leading-none text-highlighted',
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,536 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import AuthorAvatar from '@/components/AuthorAvatar.vue'
|
||||
import CharacterCounter from '@/components/CharacterCounter.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import DirectoryPanel from '@/components/DirectoryPanel.vue'
|
||||
import MediaField from '@/components/MediaField.vue'
|
||||
import SlugField from '@/components/SlugField.vue'
|
||||
import SocialLinksRepeater from '@/components/SocialLinksRepeater.vue'
|
||||
import UserSelect from '@/components/UserSelect.vue'
|
||||
import {
|
||||
useAuthors,
|
||||
type Author,
|
||||
type AuthorPatch,
|
||||
type SocialLink,
|
||||
} from '@/composables/useAuthors'
|
||||
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
||||
import { useSlugField } from '@/composables/useSlugField'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { useLocalizedOverlay } from '@/i18n/presentation'
|
||||
import { AUTHOR_SLUG_MAX, characterCount, suggestFreeSlug } from '@/lib/directory'
|
||||
import { COPY, copy } from '@/lib/directoryCopy'
|
||||
import { classifyFailure, isSessionError } from '@/lib/directoryErrors'
|
||||
import {
|
||||
BIO_MAX,
|
||||
authorSlugProblem,
|
||||
bioProblem,
|
||||
nameProblem,
|
||||
optionalSecureUrlProblem,
|
||||
secureUrlProblem,
|
||||
} from '@/lib/directoryRules'
|
||||
import type { LinkableUser, DirectoryNotice } from '@/lib/directoryTypes'
|
||||
import type { SocialLinkRow } from '@/lib/socialLinks'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
author?: Author
|
||||
ownProfile?: boolean
|
||||
ownEmail?: string
|
||||
knownSlugs?: string[]
|
||||
}>(),
|
||||
{ author: undefined, ownProfile: false, ownEmail: '', knownSlugs: () => [] },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: [author: Author]
|
||||
updated: [author: Author]
|
||||
gone: [id: number]
|
||||
}>()
|
||||
|
||||
const { createAuthor, updateAuthor, listLinkableUsers } = useAuthors()
|
||||
const toasts = useDirectoryToasts()
|
||||
const overlay = useLocalizedOverlay()
|
||||
|
||||
const editing = computed(() => props.author !== undefined)
|
||||
const initialLinks: SocialLinkRow[] = (props.author?.social_links ?? []).map(
|
||||
(link: SocialLink, index) => ({ key: index + 1, type: link.type, url: link.url }),
|
||||
)
|
||||
|
||||
const values = reactive({
|
||||
name: props.author?.name ?? '',
|
||||
bio: props.author?.bio ?? '',
|
||||
website: props.author?.website ?? '',
|
||||
avatarId: props.author?.avatar_id ?? null,
|
||||
userId: null as number | null,
|
||||
})
|
||||
const links = ref<SocialLinkRow[]>(initialLinks)
|
||||
const avatarItem = ref<MediaItem | null>(null)
|
||||
const baseUserId = ref<number | null>(props.author?.user_id ?? null)
|
||||
const linkedEmail = ref(props.author?.user_email ?? props.ownEmail)
|
||||
const slug = useSlugField(AUTHOR_SLUG_MAX, props.author?.slug)
|
||||
slug.setSource(values.name)
|
||||
|
||||
const errors = reactive({
|
||||
name: '',
|
||||
slug: '',
|
||||
suggestion: '',
|
||||
bio: '',
|
||||
website: '',
|
||||
user: '',
|
||||
})
|
||||
const linkErrors = ref<Record<number, string>>({})
|
||||
const saving = ref(false)
|
||||
const notice = ref<DirectoryNotice | null>(null)
|
||||
const panel = ref<{ focusFirstInvalid: () => void } | null>(null)
|
||||
const rejectedSlugs = new Set<string>()
|
||||
|
||||
const userStatus = ref<'idle' | 'loading' | 'ready' | 'error'>('idle')
|
||||
const users = ref<LinkableUser[]>([])
|
||||
const userMenuOpen = ref(false)
|
||||
|
||||
function otherFields() {
|
||||
return JSON.stringify([
|
||||
values.name,
|
||||
slug.value.value,
|
||||
values.bio,
|
||||
values.website,
|
||||
values.avatarId,
|
||||
links.value.map((link) => [link.type, link.url]),
|
||||
])
|
||||
}
|
||||
const initial = otherFields()
|
||||
const dirty = computed(() => otherFields() !== initial || values.userId !== null)
|
||||
|
||||
const bioCount = computed(() => characterCount(values.bio))
|
||||
const title = computed(() => (props.author ? t('Edit {0}', [props.author.name]) : t('Add author')))
|
||||
const submitLabel = computed(() => (editing.value ? t('Save changes') : t('Add author')))
|
||||
const showLinkedBox = computed(() => baseUserId.value !== null && !props.ownProfile)
|
||||
const picker = computed(() => ({
|
||||
title: t('Choose an image'),
|
||||
description: t('The avatar for {0}.', [values.name || t('this author')]),
|
||||
hint: t('Shown as a square. Choose an image with the face or logo near the middle.'),
|
||||
confirmLabel: t('Use image'),
|
||||
}))
|
||||
|
||||
function retryUsers() {
|
||||
userStatus.value = 'idle'
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
if (userStatus.value === 'loading' || userStatus.value === 'ready') {
|
||||
return
|
||||
}
|
||||
userStatus.value = 'loading'
|
||||
try {
|
||||
users.value = await listLinkableUsers()
|
||||
userStatus.value = 'ready'
|
||||
} catch {
|
||||
userStatus.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
function onName(value: string) {
|
||||
values.name = value
|
||||
slug.setSource(value)
|
||||
errors.name = ''
|
||||
clearSlugError()
|
||||
}
|
||||
|
||||
function clearSlugError() {
|
||||
errors.slug = ''
|
||||
errors.suggestion = ''
|
||||
}
|
||||
|
||||
function onSlug(value: string) {
|
||||
slug.setValue(value)
|
||||
clearSlugError()
|
||||
}
|
||||
|
||||
function resetSlug() {
|
||||
slug.reset()
|
||||
clearSlugError()
|
||||
}
|
||||
|
||||
function checkName() {
|
||||
errors.name = nameProblem(values.name)
|
||||
return errors.name === ''
|
||||
}
|
||||
|
||||
function checkBio() {
|
||||
errors.bio = bioProblem(values.bio)
|
||||
return errors.bio === ''
|
||||
}
|
||||
|
||||
function checkWebsite() {
|
||||
errors.website = optionalSecureUrlProblem(values.website)
|
||||
return errors.website === ''
|
||||
}
|
||||
|
||||
function checkLink(key: number) {
|
||||
const row = links.value.find((candidate) => candidate.key === key)
|
||||
if (!row) {
|
||||
return true
|
||||
}
|
||||
const problem = secureUrlProblem(row.url)
|
||||
linkErrors.value = { ...linkErrors.value, [key]: problem }
|
||||
return problem === ''
|
||||
}
|
||||
|
||||
function checkLinks() {
|
||||
return links.value.map((row) => checkLink(row.key)).every(Boolean)
|
||||
}
|
||||
|
||||
function checkSlug() {
|
||||
errors.slug = authorSlugProblem(slug.value.value)
|
||||
return errors.slug === ''
|
||||
}
|
||||
|
||||
function validate() {
|
||||
const results = [checkName(), checkSlug(), checkBio(), checkWebsite(), checkLinks()]
|
||||
return results.every(Boolean)
|
||||
}
|
||||
|
||||
function onLinksChange(rows: SocialLinkRow[]) {
|
||||
links.value = rows
|
||||
}
|
||||
|
||||
function socialPayload(): SocialLink[] {
|
||||
return links.value.map((row) => ({ type: row.type, url: row.url.trim() }))
|
||||
}
|
||||
|
||||
function changedFields(author: Author) {
|
||||
const patch: AuthorPatch = {}
|
||||
if (values.name.trim() !== author.name) {
|
||||
patch.name = values.name.trim()
|
||||
}
|
||||
if (slug.value.value !== author.slug) {
|
||||
patch.slug = slug.value.value
|
||||
}
|
||||
if (values.bio !== author.bio) {
|
||||
patch.bio = values.bio
|
||||
}
|
||||
if (values.avatarId !== author.avatar_id) {
|
||||
patch.avatar_id = values.avatarId
|
||||
}
|
||||
if ((values.website.trim() || null) !== author.website) {
|
||||
patch.website = values.website.trim() || null
|
||||
}
|
||||
if (JSON.stringify(socialPayload()) !== JSON.stringify(author.social_links)) {
|
||||
patch.social_links = socialPayload()
|
||||
}
|
||||
if (values.userId !== null) {
|
||||
patch.user_id = values.userId
|
||||
}
|
||||
return patch
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (saving.value) {
|
||||
return
|
||||
}
|
||||
notice.value = null
|
||||
if (!validate()) {
|
||||
panel.value?.focusFirstInvalid()
|
||||
return
|
||||
}
|
||||
const author = props.author
|
||||
if (author && !dirty.value) {
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const saved = author
|
||||
? await updateAuthor(author.id, changedFields(author))
|
||||
: await createAuthor({
|
||||
name: values.name.trim(),
|
||||
slug: slug.value.value,
|
||||
bio: values.bio,
|
||||
avatar_id: values.avatarId,
|
||||
website: values.website.trim() || null,
|
||||
social_links: socialPayload(),
|
||||
user_id: values.userId,
|
||||
})
|
||||
emit('saved', saved)
|
||||
} catch (error) {
|
||||
handleFailure(error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showSlugTaken() {
|
||||
const taken = slug.value.value
|
||||
if (slug.mode.value === 'auto') {
|
||||
slug.unlock()
|
||||
}
|
||||
rejectedSlugs.add(taken)
|
||||
errors.slug = t('The slug "{0}" is already used by another author.', [taken])
|
||||
errors.suggestion = suggestFreeSlug(
|
||||
taken,
|
||||
(candidate) => rejectedSlugs.has(candidate) || props.knownSlugs.includes(candidate),
|
||||
AUTHOR_SLUG_MAX,
|
||||
)
|
||||
}
|
||||
|
||||
function showInvalidUrl() {
|
||||
const first = links.value[0]
|
||||
if (values.website.trim() || !first) {
|
||||
errors.website = copy('urlHttps')
|
||||
return
|
||||
}
|
||||
linkErrors.value = { ...linkErrors.value, [first.key]: copy('urlHttps') }
|
||||
}
|
||||
|
||||
function handleFailure(error: unknown) {
|
||||
const kind = classifyFailure(error, 'author')
|
||||
if (kind === 'session') {
|
||||
return
|
||||
}
|
||||
if (kind === 'slugTaken') {
|
||||
showSlugTaken()
|
||||
return
|
||||
}
|
||||
if (kind === 'userLinked') {
|
||||
errors.user = copy('userLinked')
|
||||
return
|
||||
}
|
||||
if (kind === 'bioTooLong') {
|
||||
errors.bio = copy('shortenBio')
|
||||
return
|
||||
}
|
||||
if (kind === 'invalidUrl') {
|
||||
showInvalidUrl()
|
||||
return
|
||||
}
|
||||
if (toasts.report(kind, 'author')) {
|
||||
if (kind === 'gone' && props.author) {
|
||||
emit('gone', props.author.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
notice.value = { description: copy('authorSaveFailed') }
|
||||
}
|
||||
|
||||
async function confirmUnlink() {
|
||||
const author = props.author
|
||||
if (!author) {
|
||||
return
|
||||
}
|
||||
const email = linkedEmail.value
|
||||
await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
get title() {
|
||||
return t('Unlink {0}?', [author.name])
|
||||
},
|
||||
get description() {
|
||||
return t(
|
||||
'{0} will stay on their articles. New articles by {1} will no longer start with this author.',
|
||||
[author.name, email],
|
||||
)
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Unlink user')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Cancel')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Unlinking…')
|
||||
},
|
||||
tone: 'neutral',
|
||||
action: async () => {
|
||||
try {
|
||||
const updated = await updateAuthor(author.id, { user_id: null })
|
||||
baseUserId.value = null
|
||||
linkedEmail.value = ''
|
||||
emit('updated', updated)
|
||||
toasts.userUnlinked(author.name)
|
||||
} catch (error) {
|
||||
if (isSessionError(error)) {
|
||||
return false
|
||||
}
|
||||
throw new Error(COPY.nothingChanged)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
.open()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryPanel
|
||||
ref="panel"
|
||||
:title="title"
|
||||
:submit-label="submitLabel"
|
||||
:saving="saving"
|
||||
:dirty="dirty"
|
||||
:notice="notice"
|
||||
@close="emit('close')"
|
||||
@submit="submit"
|
||||
>
|
||||
<MediaField
|
||||
v-model="values.avatarId"
|
||||
v-model:item="avatarItem"
|
||||
:label="t('Avatar')"
|
||||
square
|
||||
:avatar-name="values.name || 'Author'"
|
||||
:picker="picker"
|
||||
/>
|
||||
|
||||
<DirectoryField :label="t('Name')" :error="errors.name">
|
||||
<UInput
|
||||
:model-value="values.name"
|
||||
:placeholder="t('e.g. Camille Roche')"
|
||||
:color="errors.name ? 'error' : undefined"
|
||||
:data-autofocus="editing ? undefined : ''"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 text-lg md:h-10 md:text-sm' }"
|
||||
@update:model-value="onName(String($event))"
|
||||
@blur="checkName"
|
||||
/>
|
||||
</DirectoryField>
|
||||
|
||||
<SlugField
|
||||
:label="t('Slug')"
|
||||
:mode="slug.mode.value"
|
||||
:value="slug.value.value"
|
||||
:error="errors.slug"
|
||||
:suggestion="errors.suggestion"
|
||||
:help="
|
||||
t(
|
||||
'Generated from the name. Used in public author URLs and API paths. Unique across all authors.',
|
||||
)
|
||||
"
|
||||
:manual-help="t('Lowercase letters, numbers and hyphens. Unique across all authors.')"
|
||||
@update:value="onSlug"
|
||||
@unlock="slug.unlock"
|
||||
@reset="resetSlug"
|
||||
@suggest="onSlug"
|
||||
/>
|
||||
|
||||
<DirectoryField :label="t('Bio')" optional :error="errors.bio">
|
||||
<template #hint>
|
||||
<CharacterCounter :value="bioCount" :max="BIO_MAX" variant="field" />
|
||||
</template>
|
||||
<UTextarea
|
||||
v-model="values.bio"
|
||||
:placeholder="t('A sentence or two about the author.')"
|
||||
:color="errors.bio ? 'error' : undefined"
|
||||
class="w-full"
|
||||
:ui="{ base: 'min-h-30 text-lg md:min-h-24 md:text-sm' }"
|
||||
@update:model-value="errors.bio = ''"
|
||||
@blur="checkBio"
|
||||
/>
|
||||
</DirectoryField>
|
||||
|
||||
<DirectoryField :label="t('Website')" optional :error="errors.website">
|
||||
<UInput
|
||||
v-model="values.website"
|
||||
placeholder="https://example.com"
|
||||
:color="errors.website ? 'error' : undefined"
|
||||
inputmode="url"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 font-mono text-base md:h-10 md:text-code' }"
|
||||
@update:model-value="errors.website = ''"
|
||||
@blur="checkWebsite"
|
||||
/>
|
||||
</DirectoryField>
|
||||
|
||||
<SocialLinksRepeater
|
||||
:model-value="links"
|
||||
:errors="linkErrors"
|
||||
:disabled="saving"
|
||||
@update:model-value="onLinksChange"
|
||||
@blur="checkLink"
|
||||
/>
|
||||
|
||||
<DirectoryField
|
||||
v-if="ownProfile"
|
||||
:label="t('Linked user')"
|
||||
:help="t('This is your author profile. Only an owner or admin can change the link.')"
|
||||
>
|
||||
<div
|
||||
class="flex min-h-11 items-center gap-2.5 border border-default bg-elevated px-2.5 md:min-h-10"
|
||||
>
|
||||
<UIcon name="i-lucide-link" class="size-3.5 shrink-0 text-info" />
|
||||
<span class="min-w-0 flex-1 truncate text-highlighted">{{ linkedEmail }}</span>
|
||||
<UIcon name="i-lucide-lock" class="size-3.5 shrink-0 text-muted" />
|
||||
</div>
|
||||
</DirectoryField>
|
||||
|
||||
<DirectoryField
|
||||
v-else-if="showLinkedBox"
|
||||
:label="t('Linked user')"
|
||||
optional
|
||||
:help="t('New articles by this user start with this author.')"
|
||||
>
|
||||
<div
|
||||
class="flex min-h-11 items-center gap-2.5 border border-accented py-0 pr-1.5 pl-2.5 md:min-h-10"
|
||||
>
|
||||
<UIcon name="i-lucide-link" class="size-3.5 shrink-0 text-info" />
|
||||
<span class="min-w-0 flex-1 truncate text-highlighted">{{ linkedEmail }}</span>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
|
||||
@click="confirmUnlink"
|
||||
>
|
||||
{{ t('Unlink') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</DirectoryField>
|
||||
|
||||
<DirectoryField
|
||||
v-else
|
||||
:label="t('Linked user')"
|
||||
optional
|
||||
:error="errors.user"
|
||||
:help="
|
||||
userMenuOpen
|
||||
? ''
|
||||
: t(
|
||||
'Link a CMS user if this author has an account. Guest writers need no link.',
|
||||
)
|
||||
"
|
||||
>
|
||||
<UserSelect
|
||||
v-model="values.userId"
|
||||
v-model:open="userMenuOpen"
|
||||
:users="users"
|
||||
:status="userStatus === 'idle' ? 'ready' : userStatus"
|
||||
:idle-placeholder="t('No linked user')"
|
||||
:invalid="errors.user !== ''"
|
||||
clearable
|
||||
@open="loadUsers"
|
||||
@retry="retryUsers"
|
||||
@update:model-value="errors.user = ''"
|
||||
/>
|
||||
</DirectoryField>
|
||||
|
||||
<template #preview>
|
||||
<div class="flex items-start gap-3 bg-elevated p-3">
|
||||
<AuthorAvatar :name="values.name || 'Author'" :size="40" />
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span class="text-xs text-muted">{{ t('Byline preview') }}</span>
|
||||
<span class="truncate font-semibold text-highlighted">{{
|
||||
values.name || t('Author name')
|
||||
}}</span>
|
||||
<span class="line-clamp-2 text-xs/4.5 text-toned">{{
|
||||
values.bio || t('The bio appears here.')
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DirectoryPanel>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import AuthorAvatar from '@/components/AuthorAvatar.vue'
|
||||
import DirectoryBadge from '@/components/DirectoryBadge.vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import DirectoryPanel from '@/components/DirectoryPanel.vue'
|
||||
import type { Author } from '@/composables/useAuthors'
|
||||
import { pluralArticles } from '@/lib/directory'
|
||||
|
||||
defineProps<{ author: Author }>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryPanel :title="author.name" read-only @close="emit('close')">
|
||||
<div class="flex items-center gap-3.5">
|
||||
<AuthorAvatar :name="author.name" :size="64" />
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span class="truncate font-mono text-xs/4.5 text-muted">{{ author.slug }}</span>
|
||||
<div class="flex items-center gap-2 text-xs text-muted">
|
||||
<DirectoryBadge :kind="author.is_linked ? 'linked' : 'guest'" />
|
||||
<span>{{ pluralArticles(author.article_count) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DirectoryField v-if="author.bio" :label="t('Bio')">
|
||||
<p class="text-pretty text-toned">{{ author.bio }}</p>
|
||||
</DirectoryField>
|
||||
<DirectoryField v-if="author.website" :label="t('Website')">
|
||||
<ULink
|
||||
:to="author.website"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-mono text-code text-highlighted underline underline-offset-3"
|
||||
>{{ author.website }}</ULink
|
||||
>
|
||||
</DirectoryField>
|
||||
<div class="flex gap-2.5 bg-elevated p-3 text-toned">
|
||||
<UIcon name="i-lucide-lock" class="mt-0.75 size-3.5 shrink-0" />
|
||||
<span>{{ t('Only an owner or admin can change this author.') }}</span>
|
||||
</div>
|
||||
</DirectoryPanel>
|
||||
</template>
|
||||
@@ -64,9 +64,15 @@ const session = useSessionStore()
|
||||
const update = useUpdateStore()
|
||||
const api = useApi()
|
||||
|
||||
const canManageMedia = computed(
|
||||
() => session.session?.role === 'owner' || session.session?.role === 'admin',
|
||||
)
|
||||
const publishingChildren = computed(() => [
|
||||
{ label: t('Publishing'), type: 'label' as const },
|
||||
{
|
||||
label: t('Sites'),
|
||||
to: '/settings/sites',
|
||||
icon: 'i-lucide-globe',
|
||||
active: !props.noCurrentNav && route.path === '/settings/sites',
|
||||
},
|
||||
])
|
||||
|
||||
const settingsChildren = computed(() => [
|
||||
{ label: t('Media'), type: 'label' as const },
|
||||
@@ -82,6 +88,7 @@ const settingsChildren = computed(() => [
|
||||
icon: 'i-lucide-database',
|
||||
active: !props.noCurrentNav && route.path === '/settings/media/cache',
|
||||
},
|
||||
...(session.isOwner ? publishingChildren.value : []),
|
||||
])
|
||||
|
||||
const navItems = computed(() =>
|
||||
@@ -107,6 +114,13 @@ const navItems = computed(() =>
|
||||
to: '/media',
|
||||
icon: 'i-lucide-images',
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('Authors')
|
||||
},
|
||||
to: '/authors',
|
||||
icon: 'i-lucide-feather',
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('Users')
|
||||
@@ -144,7 +158,7 @@ const navItems = computed(() =>
|
||||
return {
|
||||
...item,
|
||||
active: !props.noCurrentNav && parentActive,
|
||||
...(isSettings && canManageMedia.value
|
||||
...(isSettings && session.canManageContent
|
||||
? {
|
||||
children: settingsChildren.value,
|
||||
defaultOpen: route.path.startsWith(item.to),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ pinned?: boolean }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UButton
|
||||
icon="i-lucide-plus"
|
||||
:class="
|
||||
pinned
|
||||
? 'fixed inset-x-4 bottom-4 h-12 justify-center text-md shadow-(--shadow-popover)'
|
||||
: 'h-8 px-3'
|
||||
"
|
||||
:ui="pinned ? { base: 'gap-2', leadingIcon: 'size-4.5' } : undefined"
|
||||
>
|
||||
<slot />
|
||||
</UButton>
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
kind: 'default' | 'noindex' | 'linked' | 'guest'
|
||||
}>()
|
||||
|
||||
const badges = {
|
||||
default: {
|
||||
get label() {
|
||||
return t('Default')
|
||||
},
|
||||
icon: 'i-lucide-star',
|
||||
class: 'border border-(--rc-sand) bg-(--rc-sand) text-(--rc-on-sand)',
|
||||
},
|
||||
noindex: {
|
||||
get label() {
|
||||
return t('Noindex')
|
||||
},
|
||||
icon: 'i-lucide-eye-off',
|
||||
class: 'border border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning',
|
||||
},
|
||||
linked: {
|
||||
get label() {
|
||||
return t('Linked')
|
||||
},
|
||||
icon: 'i-lucide-link',
|
||||
class: 'border border-(--rc-info-soft) bg-(--rc-info-soft) text-info',
|
||||
},
|
||||
guest: {
|
||||
get label() {
|
||||
return t('Guest')
|
||||
},
|
||||
icon: 'i-lucide-user',
|
||||
class: 'border border-accented bg-transparent text-toned',
|
||||
},
|
||||
} as const
|
||||
|
||||
const badge = computed(() => badges[props.kind])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UBadge
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
:icon="badge.icon"
|
||||
:class="badge.class"
|
||||
:ui="{
|
||||
base: 'h-5 shrink-0 gap-1 px-1.5 py-0 text-xs whitespace-nowrap',
|
||||
leadingIcon: 'size-3',
|
||||
}"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</UBadge>
|
||||
</template>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
confirmLabel: string
|
||||
pendingLabel: string
|
||||
cancelLabel?: string
|
||||
tone?: 'danger' | 'neutral'
|
||||
pending?: boolean
|
||||
notice?: DirectoryNotice | null
|
||||
closable?: boolean
|
||||
confirmDisabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
cancelLabel: '',
|
||||
tone: 'danger',
|
||||
pending: false,
|
||||
notice: null,
|
||||
closable: false,
|
||||
confirmDisabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ confirm: []; close: [] }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: true })
|
||||
|
||||
const confirmText = computed(() => (props.pending ? props.pendingLabel : props.confirmLabel))
|
||||
const closeButton = computed(() =>
|
||||
props.closable && !props.pending
|
||||
? { class: 'size-7 justify-center text-muted max-md:size-11' }
|
||||
: false,
|
||||
)
|
||||
|
||||
function onOpenChange(value: boolean) {
|
||||
if (!value && props.pending) {
|
||||
return
|
||||
}
|
||||
open.value = value
|
||||
if (!value) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
onOpenChange(false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
:open="open"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:dismissible="!pending"
|
||||
:close="closeButton"
|
||||
:content="{ role: 'alertdialog', 'aria-busy': pending ? 'true' : undefined }"
|
||||
:ui="{
|
||||
content: `${SHEET_CONTENT_CLASS} sm:top-35 sm:max-w-115`,
|
||||
header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
|
||||
title: 'text-lg/6 font-semibold',
|
||||
description: 'mt-1 text-sm text-muted text-pretty',
|
||||
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
||||
footer: SHEET_FOOTER_CLASS,
|
||||
}"
|
||||
@update:open="onOpenChange"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4">
|
||||
<UAlert
|
||||
v-if="notice"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-lucide-circle-x"
|
||||
:title="notice.title"
|
||||
:description="notice.description"
|
||||
:ui="{
|
||||
root: 'items-start gap-2.5 px-3.5 py-3',
|
||||
title: 'font-semibold',
|
||||
description: 'text-toned',
|
||||
}"
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
block
|
||||
class="h-11 justify-center text-sm text-highlighted disabled:text-dimmed disabled:opacity-100 sm:h-8 sm:w-auto sm:px-3"
|
||||
:disabled="pending"
|
||||
@click="onCancel"
|
||||
>
|
||||
{{ cancelLabel || t('Cancel') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
:color="tone === 'danger' ? 'error' : 'primary'"
|
||||
:loading="pending"
|
||||
:disabled="pending || confirmDisabled"
|
||||
block
|
||||
class="h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3"
|
||||
@click="emit('confirm')"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
optional?: boolean
|
||||
error?: string
|
||||
help?: string
|
||||
name?: string
|
||||
}>(),
|
||||
{ label: '', optional: false, error: '', help: '', name: undefined },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UFormField
|
||||
:name="name"
|
||||
:error="error || undefined"
|
||||
:ui="{
|
||||
root: 'min-w-0',
|
||||
wrapper: 'w-full',
|
||||
labelWrapper: 'w-full justify-between gap-2',
|
||||
label: 'font-medium text-highlighted',
|
||||
hint: 'font-mono text-xs/4 font-normal text-muted',
|
||||
container: 'mt-1.5',
|
||||
help: 'mt-1.5 text-xs/4.5 text-pretty text-muted',
|
||||
error: 'mt-1.5 text-xs/4.5 text-error',
|
||||
}"
|
||||
>
|
||||
<template v-if="label" #label>
|
||||
{{ label }}
|
||||
<span v-if="optional" class="font-normal text-muted"> {{ t('(optional)') }}</span>
|
||||
</template>
|
||||
<template v-if="$slots.hint" #hint>
|
||||
<slot name="hint" />
|
||||
</template>
|
||||
<slot />
|
||||
<template v-if="error" #error>
|
||||
<span class="flex items-start gap-1.5" role="alert">
|
||||
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{{ error }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="help && !error" #help>
|
||||
{{ help }}
|
||||
</template>
|
||||
</UFormField>
|
||||
</template>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts" generic="T extends { id: number }">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import DirectoryRowMenu from '@/components/DirectoryRowMenu.vue'
|
||||
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const DEFAULT_SKELETON_ROWS = 3
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: T[]
|
||||
columns: string
|
||||
headings: string[]
|
||||
loading?: boolean
|
||||
skeletonRows?: number
|
||||
readOnly?: boolean
|
||||
rowTitle: (item: T) => string
|
||||
menuItems?: (item: T) => DirectoryMenuItem[]
|
||||
label: string
|
||||
}>(),
|
||||
{
|
||||
loading: false,
|
||||
skeletonRows: DEFAULT_SKELETON_ROWS,
|
||||
readOnly: false,
|
||||
menuItems: () => [],
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ open: [item: T] }>()
|
||||
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const openMenuId = ref<number | null>(null)
|
||||
|
||||
function setMenuOpen(item: T, isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
openMenuId.value = item.id
|
||||
} else if (openMenuId.value === item.id) {
|
||||
openMenuId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function rowClass(item: T) {
|
||||
return [
|
||||
openMenuId.value === item.id ? 'bg-elevated' : 'bg-default',
|
||||
props.readOnly ? 'cursor-pointer hover:bg-elevated' : '',
|
||||
]
|
||||
}
|
||||
|
||||
function activate(item: T) {
|
||||
if (props.readOnly) {
|
||||
emit('open', item)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="group"
|
||||
:aria-label="label"
|
||||
:aria-busy="loading ? 'true' : undefined"
|
||||
class="border border-default bg-default max-md:-mx-4 max-md:border-x-0"
|
||||
>
|
||||
<template v-if="loading">
|
||||
<span role="status" class="sr-only">{{ t('Loading') }}</span>
|
||||
<div
|
||||
v-for="n in skeletonRows"
|
||||
:key="n"
|
||||
data-testid="directory-skeleton-row"
|
||||
class="flex h-13 items-center gap-3 border-b border-muted px-4 max-md:h-18"
|
||||
>
|
||||
<USkeleton class="size-7 shrink-0 rounded-none bg-accented max-md:size-8" />
|
||||
<div class="flex flex-1 flex-col gap-2">
|
||||
<USkeleton class="h-3.25 w-2/5 rounded-none bg-accented" />
|
||||
<USkeleton class="h-2.75 w-3/5 rounded-none bg-accented" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="isDesktop">
|
||||
<div
|
||||
class="grid h-9 items-center gap-3 bg-elevated px-3 font-medium text-muted"
|
||||
:style="{ gridTemplateColumns: columns }"
|
||||
>
|
||||
<span v-for="(heading, index) in headings" :key="index">{{ heading }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
data-testid="directory-row"
|
||||
class="grid min-h-13 items-center gap-3 border-t border-default px-3"
|
||||
:class="rowClass(item)"
|
||||
:style="{ gridTemplateColumns: columns }"
|
||||
@click="activate(item)"
|
||||
>
|
||||
<slot name="cells" :item="item" />
|
||||
<span class="flex justify-center">
|
||||
<UButton
|
||||
v-if="readOnly"
|
||||
icon="i-lucide-chevron-right"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:aria-label="t('View {0}', [rowTitle(item)])"
|
||||
class="size-8 justify-center text-muted"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click.stop="emit('open', item)"
|
||||
/>
|
||||
<DirectoryRowMenu
|
||||
v-else
|
||||
:items="menuItems(item)"
|
||||
:title="rowTitle(item)"
|
||||
:desktop="true"
|
||||
@update:open="setMenuOpen(item, $event)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
data-testid="directory-card"
|
||||
class="flex min-h-19 items-center gap-2 border-b border-default py-3 pr-1 pl-4"
|
||||
:class="rowClass(item)"
|
||||
@click="activate(item)"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<slot name="card" :item="item" />
|
||||
</div>
|
||||
<UButton
|
||||
v-if="readOnly"
|
||||
icon="i-lucide-chevron-right"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:aria-label="t('View {0}', [rowTitle(item)])"
|
||||
class="size-11 justify-center text-muted"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click.stop="emit('open', item)"
|
||||
/>
|
||||
<DirectoryRowMenu
|
||||
v-else
|
||||
:items="menuItems(item)"
|
||||
:title="rowTitle(item)"
|
||||
:desktop="false"
|
||||
@update:open="setMenuOpen(item, $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref, useId } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { useLocalizedOverlay } from '@/i18n/presentation'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const AUTOFOCUS_SELECTOR = '[data-autofocus]'
|
||||
const INVALID_SELECTOR = '[aria-invalid="true"]'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
submitLabel?: string
|
||||
saving?: boolean
|
||||
dirty?: boolean
|
||||
readOnly?: boolean
|
||||
notice?: DirectoryNotice | null
|
||||
}>(),
|
||||
{ submitLabel: '', saving: false, dirty: false, readOnly: false, notice: null },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ close: []; submit: [] }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: true })
|
||||
|
||||
const overlay = useLocalizedOverlay()
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const formId = useId()
|
||||
const form = ref<{ $el: HTMLElement } | null>(null)
|
||||
const side = computed(() => (isDesktop.value ? 'right' : 'bottom'))
|
||||
const primaryLabel = computed(() => (props.saving ? t('Saving…') : props.submitLabel))
|
||||
let discardOpen = false
|
||||
|
||||
const closeButton = computed(() => ({
|
||||
icon: 'i-lucide-x',
|
||||
color: 'neutral' as const,
|
||||
variant: 'ghost' as const,
|
||||
'aria-label': t('Close'),
|
||||
class: 'size-7 justify-center text-muted max-md:top-1.5 max-md:end-1.5 max-md:size-11',
|
||||
ui: { leadingIcon: 'size-4' },
|
||||
}))
|
||||
|
||||
const overlayUi = computed(() => ({
|
||||
content: isDesktop.value
|
||||
? 'w-full max-w-120'
|
||||
: 'h-23/25 max-h-none pt-3 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
|
||||
header: 'h-14 min-h-14 gap-3 px-4 py-0 sm:px-4 md:px-5',
|
||||
wrapper: 'min-w-0 flex-1',
|
||||
title: 'truncate text-lg font-semibold',
|
||||
description: 'sr-only',
|
||||
body: 'flex min-h-0 flex-1 flex-col gap-4.5 overflow-y-auto p-4 sm:p-4 md:p-5',
|
||||
footer: `gap-2 px-4 py-3 sm:px-4 max-md:flex-col-reverse md:px-5 ${props.readOnly ? 'md:justify-end' : 'md:justify-between'}`,
|
||||
}))
|
||||
|
||||
function focusOnOpen(event: Event) {
|
||||
const container = event.target instanceof HTMLElement ? event.target : null
|
||||
const target = container?.querySelector<HTMLElement>(AUTOFOCUS_SELECTOR)
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
nextTick(() => target.focus())
|
||||
}
|
||||
|
||||
function focusFirstInvalid() {
|
||||
nextTick(() => form.value?.$el.querySelector<HTMLElement>(INVALID_SELECTOR)?.focus())
|
||||
}
|
||||
|
||||
defineExpose({ focusFirstInvalid })
|
||||
|
||||
async function confirmDiscard() {
|
||||
if (discardOpen) {
|
||||
return
|
||||
}
|
||||
discardOpen = true
|
||||
const discard = await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
get title() {
|
||||
return t('Discard changes?')
|
||||
},
|
||||
get description() {
|
||||
return t("You have unsaved changes. If you close now, they're lost.")
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Discard changes')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Keep editing')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Discard changes')
|
||||
},
|
||||
tone: 'danger',
|
||||
action: async () => undefined,
|
||||
},
|
||||
})
|
||||
.open()
|
||||
discardOpen = false
|
||||
if (discard) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
function requestClose() {
|
||||
if (props.saving) {
|
||||
return
|
||||
}
|
||||
if (!props.dirty || props.readOnly) {
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
confirmDiscard()
|
||||
}
|
||||
|
||||
function onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
requestClose()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USlideover
|
||||
:open="open"
|
||||
:side="side"
|
||||
:title="title"
|
||||
:description="title"
|
||||
:close="closeButton"
|
||||
:dismissible="!saving"
|
||||
:content="{ onOpenAutoFocus: focusOnOpen }"
|
||||
:ui="overlayUi"
|
||||
@update:open="onOpenChange"
|
||||
>
|
||||
<template #body>
|
||||
<UAlert
|
||||
v-if="notice"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-lucide-circle-x"
|
||||
:title="notice.title"
|
||||
:description="notice.description"
|
||||
:ui="{
|
||||
root: 'shrink-0 items-start gap-2.5 px-3.5 py-3',
|
||||
description: 'text-toned',
|
||||
}"
|
||||
/>
|
||||
<UForm
|
||||
:id="formId"
|
||||
ref="form"
|
||||
:state="{}"
|
||||
:validate-on="[]"
|
||||
:disabled="saving"
|
||||
class="flex flex-col gap-4.5"
|
||||
@submit="emit('submit')"
|
||||
>
|
||||
<slot />
|
||||
</UForm>
|
||||
<slot name="preview" />
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:disabled="saving"
|
||||
class="h-11 w-full justify-center border border-accented bg-default px-3 text-sm text-highlighted ring-0 md:h-8 md:w-auto"
|
||||
@click="requestClose"
|
||||
>
|
||||
{{ readOnly ? t('Close') : t('Cancel') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="!readOnly"
|
||||
type="submit"
|
||||
:form="formId"
|
||||
:loading="saving"
|
||||
:disabled="saving"
|
||||
class="h-11 w-full justify-center px-4 text-sm md:h-8 md:w-auto"
|
||||
>
|
||||
{{ primaryLabel }}
|
||||
</UButton>
|
||||
</template>
|
||||
</USlideover>
|
||||
</template>
|
||||
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref, useId } from 'vue'
|
||||
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
|
||||
|
||||
const props = defineProps<{
|
||||
items: DirectoryMenuItem[]
|
||||
title: string
|
||||
desktop: boolean
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const hintId = useId()
|
||||
const trigger = ref<{ $el: HTMLElement } | null>(null)
|
||||
const label = computed(() => t('Actions for {0}', [props.title]))
|
||||
const hint = computed(() => props.items.find((item) => item.unavailable)?.hint ?? '')
|
||||
|
||||
const groups = computed(() => {
|
||||
const result: DirectoryMenuItem[][] = []
|
||||
for (const item of props.items) {
|
||||
if (item.separated || result.length === 0) {
|
||||
result.push([])
|
||||
}
|
||||
result[result.length - 1]!.push(item)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function select(item: DirectoryMenuItem, event?: Event) {
|
||||
if (item.unavailable) {
|
||||
event?.preventDefault()
|
||||
return
|
||||
}
|
||||
open.value = false
|
||||
item.onSelect(trigger.value?.$el ?? null)
|
||||
}
|
||||
|
||||
const menuItems = computed(() =>
|
||||
groups.value.map((group) =>
|
||||
group.map((item) => ({
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
color: item.destructive ? ('error' as const) : undefined,
|
||||
slot: item.unavailable ? ('unavailable' as const) : undefined,
|
||||
'aria-disabled': item.unavailable ? 'true' : undefined,
|
||||
'aria-describedby': item.unavailable ? hintId : undefined,
|
||||
class: item.unavailable ? 'opacity-40' : undefined,
|
||||
onSelect: (event: Event) => select(item, event),
|
||||
})),
|
||||
),
|
||||
)
|
||||
|
||||
const TRIGGER_CLASS =
|
||||
'size-8 shrink-0 justify-center text-muted data-[state=open]:outline-2 data-[state=open]:outline-offset-2 data-[state=open]:outline-primary max-md:size-11'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDropdownMenu
|
||||
v-if="desktop"
|
||||
v-model:open="open"
|
||||
:items="menuItems"
|
||||
:content="{ align: 'end' }"
|
||||
:ui="{
|
||||
content: 'w-55 p-1',
|
||||
group: 'p-0',
|
||||
separator: 'my-1',
|
||||
item: 'h-8 gap-2 px-2 text-sm',
|
||||
itemLeadingIcon: 'size-4',
|
||||
}"
|
||||
>
|
||||
<UButton
|
||||
ref="trigger"
|
||||
icon="i-lucide-ellipsis"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:aria-label="label"
|
||||
:class="TRIGGER_CLASS"
|
||||
@click.stop
|
||||
/>
|
||||
<template #unavailable="{ item }">
|
||||
<UTooltip :text="hint" :content="{ side: 'left' }">
|
||||
<span class="flex items-center gap-2">
|
||||
<UIcon :name="item.icon" class="size-4 shrink-0 text-error" />
|
||||
<span class="truncate text-error">{{ item.label }}</span>
|
||||
<span :id="hintId" aria-hidden="true" class="sr-only">{{ hint }}</span>
|
||||
</span>
|
||||
</UTooltip>
|
||||
</template>
|
||||
</UDropdownMenu>
|
||||
|
||||
<template v-else>
|
||||
<UButton
|
||||
ref="trigger"
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:aria-label="label"
|
||||
:class="TRIGGER_CLASS"
|
||||
:ui="{ leadingIcon: 'size-4.5' }"
|
||||
@click.stop="open = true"
|
||||
/>
|
||||
<USlideover
|
||||
v-model:open="open"
|
||||
side="bottom"
|
||||
:title="title"
|
||||
:description="title"
|
||||
:ui="{
|
||||
content:
|
||||
'max-h-none pt-3 pb-6 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
|
||||
header: 'min-h-11 border-0 px-4 py-0 sm:px-4',
|
||||
title: 'text-md font-semibold text-highlighted',
|
||||
description: 'sr-only',
|
||||
close: 'top-0 end-1.5 size-11 justify-center p-0',
|
||||
body: 'p-0 sm:p-0',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div role="menu" :aria-label="title" class="flex flex-col">
|
||||
<template v-for="item in items" :key="item.key">
|
||||
<USeparator v-if="item.separated" class="my-1" />
|
||||
<UButton
|
||||
role="menuitem"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
block
|
||||
:icon="item.icon"
|
||||
:aria-disabled="item.unavailable ? 'true' : undefined"
|
||||
:aria-describedby="item.unavailable ? hintId : undefined"
|
||||
class="min-h-12 justify-start gap-3 px-4 text-start text-md"
|
||||
:class="[
|
||||
item.destructive ? 'text-error' : 'text-highlighted',
|
||||
item.unavailable ? 'opacity-40' : '',
|
||||
]"
|
||||
:ui="{ leadingIcon: 'size-4.5' }"
|
||||
@click="select(item, $event)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</UButton>
|
||||
</template>
|
||||
<p v-if="hint" :id="hintId" class="px-4 pt-1 pl-11.5 text-xs/4.5 text-muted">
|
||||
{{ hint }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
</template>
|
||||
</template>
|
||||
@@ -49,13 +49,15 @@ import {
|
||||
|
||||
type Corner = 'tl' | 'tr' | 'bl' | 'br'
|
||||
import { useImageUpload } from '@/composables/useImageUpload'
|
||||
import type { ImageChoice } from '@/lib/imageChoice'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
initialFile?: File | null
|
||||
inArticleIds?: number[]
|
||||
choose?: ImageChoice
|
||||
}>(),
|
||||
{ initialFile: null, inArticleIds: () => [] },
|
||||
{ initialFile: null, inArticleIds: () => [], choose: undefined },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -186,11 +188,17 @@ const showCropActions = computed(() => !onLibraryTab.value && phase.value === 'c
|
||||
|
||||
const wideModal = computed(() => showTabs.value || phase.value === 'crop')
|
||||
|
||||
const modalTitle = computed(() =>
|
||||
phase.value === 'crop' ? t('Crop and resize') : t('Add an image'),
|
||||
)
|
||||
const modalTitle = computed(() => {
|
||||
if (phase.value === 'crop') {
|
||||
return t('Crop and resize')
|
||||
}
|
||||
return props.choose?.title ?? t('Add an image')
|
||||
})
|
||||
|
||||
const modalDescription = computed(() => {
|
||||
if (props.choose && phase.value !== 'crop') {
|
||||
return props.choose.description
|
||||
}
|
||||
if (phase.value !== 'crop' || !chosenFile.value) {
|
||||
return undefined
|
||||
}
|
||||
@@ -805,6 +813,8 @@ onUnmounted(() => {
|
||||
:first-new-id="library.firstNewId.value"
|
||||
:in-article-ids="inArticleIds"
|
||||
:selected="selected"
|
||||
:plain="choose !== undefined"
|
||||
:hint="choose?.hint"
|
||||
@search="searchLibrary"
|
||||
@select="selectItem"
|
||||
@load-more="library.loadMore"
|
||||
@@ -1123,7 +1133,7 @@ onUnmounted(() => {
|
||||
:disabled="!selected"
|
||||
@click="insertSelected"
|
||||
>
|
||||
{{ t('Insert image') }}
|
||||
{{ choose?.confirmLabel ?? t('Insert image') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="showRejectedActions"
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { onMounted, ref } from 'vue'
|
||||
import DirectoryDialog from '@/components/DirectoryDialog.vue'
|
||||
import type { DirectoryNotice, LinkableUser } from '@/lib/directoryTypes'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import UserSelect from '@/components/UserSelect.vue'
|
||||
import { useAuthors, type Author } from '@/composables/useAuthors'
|
||||
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
||||
import { copy } from '@/lib/directoryCopy'
|
||||
import { classifyFailure } from '@/lib/directoryErrors'
|
||||
|
||||
const props = defineProps<{ author: Author }>()
|
||||
|
||||
const emit = defineEmits<{ close: []; linked: [author: Author]; gone: [id: number] }>()
|
||||
|
||||
const { listLinkableUsers, updateAuthor } = useAuthors()
|
||||
const toasts = useDirectoryToasts()
|
||||
|
||||
const userId = ref<number | null>(null)
|
||||
const users = ref<LinkableUser[]>([])
|
||||
const status = ref<'loading' | 'ready' | 'error'>('loading')
|
||||
const pending = ref(false)
|
||||
const fieldError = ref('')
|
||||
const notice = ref<DirectoryNotice | null>(null)
|
||||
|
||||
async function loadUsers() {
|
||||
status.value = 'loading'
|
||||
try {
|
||||
users.value = await listLinkableUsers()
|
||||
status.value = 'ready'
|
||||
} catch {
|
||||
status.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
|
||||
function fail(error: unknown) {
|
||||
const kind = classifyFailure(error, 'author')
|
||||
if (kind === 'session') {
|
||||
return
|
||||
}
|
||||
if (kind === 'userLinked') {
|
||||
fieldError.value = copy('userLinked')
|
||||
return
|
||||
}
|
||||
if (toasts.report(kind, 'author')) {
|
||||
if (kind === 'gone') {
|
||||
emit('gone', props.author.id)
|
||||
} else {
|
||||
emit('close')
|
||||
}
|
||||
return
|
||||
}
|
||||
notice.value = {
|
||||
title: copy('authorSaveFailedTitle'),
|
||||
description: copy('nothingChanged'),
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (userId.value === null || pending.value) {
|
||||
return
|
||||
}
|
||||
pending.value = true
|
||||
notice.value = null
|
||||
fieldError.value = ''
|
||||
try {
|
||||
const updated = await updateAuthor(props.author.id, { user_id: userId.value })
|
||||
emit('linked', updated)
|
||||
} catch (error) {
|
||||
fail(error)
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryDialog
|
||||
:title="t('Link {0} to a user?', [author.name])"
|
||||
:description="t('Only users without an author are listed.')"
|
||||
:confirm-label="t('Link user')"
|
||||
:pending-label="t('Saving…')"
|
||||
tone="neutral"
|
||||
closable
|
||||
:pending="pending"
|
||||
:notice="notice"
|
||||
:confirm-disabled="userId === null"
|
||||
@confirm="confirm"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<DirectoryField :label="t('Linked user')" :error="fieldError">
|
||||
<UserSelect
|
||||
v-model="userId"
|
||||
:users="users"
|
||||
:status="status"
|
||||
:disabled="pending"
|
||||
:invalid="fieldError !== ''"
|
||||
@retry="loadUsers"
|
||||
@update:model-value="fieldError = ''"
|
||||
/>
|
||||
</DirectoryField>
|
||||
</DirectoryDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import AuthorAvatar from '@/components/AuthorAvatar.vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import ImageUploader from '@/components/ImageUploader.vue'
|
||||
import MediaThumb from '@/components/MediaThumb.vue'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import type { ImageChoice } from '@/lib/imageChoice'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
help?: string
|
||||
picker: ImageChoice
|
||||
avatarName?: string
|
||||
square?: boolean
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{ help: '', avatarName: '', square: false, disabled: false },
|
||||
)
|
||||
|
||||
const imageId = defineModel<number | null>({ default: null })
|
||||
const item = defineModel<MediaItem | null>('item', { default: null })
|
||||
|
||||
const pickerOpen = ref(false)
|
||||
const BUTTON_CLASS =
|
||||
'h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7'
|
||||
|
||||
const hasImage = computed(() => imageId.value !== null)
|
||||
const chooseLabel = computed(() => (hasImage.value ? t('Change image') : t('Choose image')))
|
||||
const caption = computed(() => {
|
||||
if (item.value) {
|
||||
return item.value.filename
|
||||
}
|
||||
return imageId.value === null ? '' : t('Image #{0}', [imageId.value])
|
||||
})
|
||||
|
||||
function choose(picked: MediaItem) {
|
||||
item.value = picked
|
||||
imageId.value = picked.id
|
||||
}
|
||||
|
||||
function remove() {
|
||||
item.value = null
|
||||
imageId.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryField :label="label" optional :help="help">
|
||||
<div class="flex items-center gap-3">
|
||||
<template v-if="square">
|
||||
<MediaThumb
|
||||
v-if="item"
|
||||
:item="item"
|
||||
size="md"
|
||||
class="size-16 border border-default"
|
||||
/>
|
||||
<AuthorAvatar v-else :name="avatarName" :size="64" />
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-12.5 w-24 shrink-0 items-center justify-center overflow-hidden border text-muted"
|
||||
:class="
|
||||
hasImage
|
||||
? 'border-accented bg-(--rc-sand)'
|
||||
: 'border-dashed border-accented bg-default'
|
||||
"
|
||||
>
|
||||
<MediaThumb v-if="item" :item="item" size="sm" class="size-full" />
|
||||
<UIcon v-else name="i-lucide-image" class="size-4.5" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<span
|
||||
v-if="caption"
|
||||
class="truncate font-mono text-xs text-highlighted"
|
||||
data-testid="media-field-caption"
|
||||
>{{ caption }}</span
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-images"
|
||||
:disabled="disabled"
|
||||
:class="BUTTON_CLASS"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="pickerOpen = true"
|
||||
>
|
||||
{{ chooseLabel }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="hasImage"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:disabled="disabled"
|
||||
:class="BUTTON_CLASS"
|
||||
@click="remove"
|
||||
>
|
||||
{{ t('Remove') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ImageUploader
|
||||
v-model:open="pickerOpen"
|
||||
:choose="props.picker"
|
||||
@inserted="choose"
|
||||
@uploaded="choose"
|
||||
/>
|
||||
</DirectoryField>
|
||||
</template>
|
||||
@@ -38,6 +38,8 @@ const props = defineProps<{
|
||||
selected: MediaItem | null
|
||||
variants?: ImageVariant[]
|
||||
variantsReady?: boolean
|
||||
plain?: boolean
|
||||
hint?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -64,7 +66,12 @@ const searching = computed(() => props.query !== '')
|
||||
const summary = computed(() =>
|
||||
searching.value ? t('Newest first') : t('Newest first · {0} images', [props.total]),
|
||||
)
|
||||
const scrollHeight = computed(() => (props.selected ? 'max-h-32 md:max-h-49.5' : 'max-h-77'))
|
||||
const scrollHeight = computed(() => {
|
||||
if (props.plain) {
|
||||
return 'max-h-71 md:max-h-89'
|
||||
}
|
||||
return props.selected ? 'max-h-32 md:max-h-49.5' : 'max-h-77'
|
||||
})
|
||||
const showEmptyLibrary = computed(
|
||||
() => props.status === 'ready' && props.items.length === 0 && !searching.value,
|
||||
)
|
||||
@@ -72,7 +79,9 @@ const showNoResults = computed(
|
||||
() => props.status === 'ready' && props.items.length === 0 && searching.value,
|
||||
)
|
||||
const showGrid = computed(() => props.status === 'ready' && props.items.length > 0)
|
||||
const showLoadMore = computed(() => showGrid.value && props.hasMore && !props.selected)
|
||||
const showLoadMore = computed(
|
||||
() => showGrid.value && props.hasMore && (props.plain || !props.selected),
|
||||
)
|
||||
const noResultsTitle = computed(() => t('No images match \u0022{0}\u0022', [props.query]))
|
||||
|
||||
function clearDebounce() {
|
||||
@@ -239,9 +248,15 @@ onBeforeUnmount(clearDebounce)
|
||||
</div>
|
||||
|
||||
<template v-else-if="showGrid">
|
||||
<span data-testid="picker-summary" class="text-xs/5 text-muted">{{ summary }}</span>
|
||||
<span v-if="!plain" data-testid="picker-summary" class="text-xs/5 text-muted">{{
|
||||
summary
|
||||
}}</span>
|
||||
<div class="-m-1 min-h-0 overflow-y-auto p-1" :class="scrollHeight">
|
||||
<div ref="grid" class="grid grid-cols-3 gap-x-2 gap-y-3 md:grid-cols-4 md:gap-x-3">
|
||||
<div
|
||||
ref="grid"
|
||||
class="grid grid-cols-3 md:grid-cols-4"
|
||||
:class="plain ? 'gap-2.5' : 'gap-x-2 gap-y-3 md:gap-x-3'"
|
||||
>
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
@@ -311,8 +326,10 @@ onBeforeUnmount(clearDebounce)
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-if="hint" class="text-xs/5 text-muted">{{ hint }}</p>
|
||||
|
||||
<div
|
||||
v-if="selected"
|
||||
v-if="selected && !plain"
|
||||
data-testid="picker-panel"
|
||||
class="flex flex-col gap-3 border-t border-default pt-3"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
aria-labelledby="publishing-links-h"
|
||||
data-testid="publishing-links-card"
|
||||
class="flex flex-col gap-3 border border-default bg-default p-4 lg:p-5"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h2 id="publishing-links-h" class="text-md/6 font-semibold text-highlighted">
|
||||
{{ t('Publishing') }}
|
||||
</h2>
|
||||
<p class="text-pretty text-muted">{{ t('Where your content is shown.') }}</p>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<RouterLink
|
||||
to="/settings/sites"
|
||||
class="flex min-h-11 items-center gap-3 border-t border-default py-2 text-highlighted focus-visible:outline-2 focus-visible:outline-primary"
|
||||
>
|
||||
<UIcon name="i-lucide-globe" class="size-4 shrink-0" />
|
||||
<span class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="font-medium">{{ t('Manage sites') }}</span>
|
||||
<span class="text-muted">{{
|
||||
t('Base URLs, default image and robots policy')
|
||||
}}</span>
|
||||
</span>
|
||||
<UIcon name="i-lucide-chevron-right" class="size-3.5 shrink-0" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import AuthorAvatar from '@/components/AuthorAvatar.vue'
|
||||
import DirectoryDialog from '@/components/DirectoryDialog.vue'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
|
||||
export interface ReassignOption {
|
||||
value: string | number
|
||||
label: string
|
||||
description: string
|
||||
display: string
|
||||
avatarName?: string
|
||||
none?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
confirmLabel: string
|
||||
pendingLabel: string
|
||||
selectLabel?: string
|
||||
options?: ReassignOption[]
|
||||
summary?: string
|
||||
pending?: boolean
|
||||
notice?: DirectoryNotice | null
|
||||
closable?: boolean
|
||||
}>(),
|
||||
{
|
||||
selectLabel: '',
|
||||
options: () => [],
|
||||
summary: '',
|
||||
pending: false,
|
||||
notice: null,
|
||||
closable: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ confirm: []; close: [] }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: true })
|
||||
const selected = defineModel<string | number | undefined>('selected', { default: undefined })
|
||||
|
||||
const selectOpen = ref(false)
|
||||
const current = computed(() => props.options.find((option) => option.value === selected.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryDialog
|
||||
v-model:open="open"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:confirm-label="confirmLabel"
|
||||
:pending-label="pendingLabel"
|
||||
:pending="pending"
|
||||
:notice="notice"
|
||||
:closable="closable"
|
||||
@confirm="emit('confirm')"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<UFormField
|
||||
v-if="options.length"
|
||||
:label="selectLabel"
|
||||
:ui="{
|
||||
label: 'font-medium text-highlighted',
|
||||
container: 'mt-1.5',
|
||||
help: 'mt-1.5 text-xs/4.5 text-pretty text-muted',
|
||||
}"
|
||||
>
|
||||
<USelectMenu
|
||||
v-model="selected"
|
||||
v-model:open="selectOpen"
|
||||
:portal="false"
|
||||
:content="{ position: 'inline' }"
|
||||
:items="options"
|
||||
value-key="value"
|
||||
:search-input="false"
|
||||
:disabled="pending"
|
||||
class="w-full"
|
||||
:ui="{
|
||||
base: 'h-11 rounded-none text-lg ring-(--ui-border-accented) data-[state=open]:ring-2 data-[state=open]:ring-primary md:h-10 md:text-sm',
|
||||
trailingIcon: 'size-3.5 text-muted',
|
||||
content:
|
||||
'mt-1.5 max-h-63 rounded-none border border-default shadow-(--shadow-popover)',
|
||||
itemTrailingIcon: 'size-3.5 text-highlighted',
|
||||
itemLabel: 'font-medium',
|
||||
item: 'min-h-12 gap-2 px-2 py-1 data-[state=checked]:bg-elevated md:min-h-9',
|
||||
itemDescription: 'truncate text-xs text-muted',
|
||||
}"
|
||||
>
|
||||
<template #default>
|
||||
<span class="truncate">{{ current?.display }}</span>
|
||||
</template>
|
||||
<template #item-leading="{ item }">
|
||||
<span
|
||||
v-if="item.none"
|
||||
class="flex size-6 items-center justify-center border border-dashed border-accented text-muted"
|
||||
>
|
||||
<UIcon name="i-lucide-user-x" class="size-3.5" />
|
||||
</span>
|
||||
<AuthorAvatar v-else-if="item.avatarName" :name="item.avatarName" :size="24" />
|
||||
</template>
|
||||
</USelectMenu>
|
||||
<template v-if="summary && !selectOpen" #help>
|
||||
{{ summary }}
|
||||
</template>
|
||||
</UFormField>
|
||||
<slot />
|
||||
</DirectoryDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,429 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import DirectoryPanel from '@/components/DirectoryPanel.vue'
|
||||
import MediaField from '@/components/MediaField.vue'
|
||||
import SlugField from '@/components/SlugField.vue'
|
||||
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
||||
import { useSites, type Site, type SitePatch, type SiteRobots } from '@/composables/useSites'
|
||||
import { useSlugField } from '@/composables/useSlugField'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import {
|
||||
SITE_KEY_MAX,
|
||||
canonicalPreview,
|
||||
siteUrlMessage,
|
||||
siteUrlProblem,
|
||||
suggestFreeSlug,
|
||||
} from '@/lib/directory'
|
||||
import { copy } from '@/lib/directoryCopy'
|
||||
import { classifyFailure } from '@/lib/directoryErrors'
|
||||
import { baseUrlProblem, nameProblem, siteKeyProblem } from '@/lib/directoryRules'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
|
||||
const PREVIEW_FALLBACK_URL = 'https://journal.example.com'
|
||||
|
||||
const props = defineProps<{
|
||||
site?: Site
|
||||
sites: Site[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: [site: Site]
|
||||
updated: [site: Site]
|
||||
defaulted: [site: Site]
|
||||
gone: [id: number]
|
||||
}>()
|
||||
|
||||
const { createSite, updateSite, setDefaultSite } = useSites()
|
||||
const toasts = useDirectoryToasts()
|
||||
|
||||
const editing = computed(() => props.site !== undefined)
|
||||
const isCurrentDefault = computed(() => props.site?.is_default === true)
|
||||
const currentDefault = computed(() => props.sites.find((site) => site.is_default))
|
||||
|
||||
const values = reactive({
|
||||
name: props.site?.name ?? '',
|
||||
baseUrl: props.site?.base_url ?? '',
|
||||
imageId: props.site?.default_social_image_id ?? null,
|
||||
robots: (props.site?.default_robots ?? 'index') as SiteRobots,
|
||||
locale: props.site?.locale ?? '',
|
||||
makeDefault: props.site?.is_default ?? false,
|
||||
})
|
||||
const imageItem = ref<MediaItem | null>(null)
|
||||
const slug = useSlugField(SITE_KEY_MAX, props.site?.key)
|
||||
slug.setSource(values.name)
|
||||
|
||||
const errors = reactive({ name: '', key: '', suggestion: '', url: '', locale: '' })
|
||||
const saving = ref(false)
|
||||
const notice = ref<DirectoryNotice | null>(null)
|
||||
const panel = ref<{ focusFirstInvalid: () => void } | null>(null)
|
||||
|
||||
function snapshot() {
|
||||
return JSON.stringify([
|
||||
values.name,
|
||||
slug.value.value,
|
||||
values.baseUrl,
|
||||
values.imageId,
|
||||
values.robots,
|
||||
values.locale,
|
||||
values.makeDefault,
|
||||
])
|
||||
}
|
||||
const initial = snapshot()
|
||||
const dirty = computed(() => snapshot() !== initial)
|
||||
|
||||
const title = computed(() => (props.site ? t('Edit {0}', [props.site.name]) : t('Add site')))
|
||||
const submitLabel = computed(() => (editing.value ? t('Save changes') : t('Add site')))
|
||||
const robotsItems = computed(() => [
|
||||
{ label: t('Index'), value: 'index' },
|
||||
{ label: t('Noindex'), value: 'noindex' },
|
||||
])
|
||||
const robotsHelp = computed(() =>
|
||||
values.robots === 'index'
|
||||
? t("Search engines may list this site's pages.")
|
||||
: t("Asks search engines not to list this site's pages."),
|
||||
)
|
||||
const switchLabel = computed(() =>
|
||||
isCurrentDefault.value ? t('Default site') : t('Make this the default site'),
|
||||
)
|
||||
const switchHelp = computed(() =>
|
||||
isCurrentDefault.value
|
||||
? t('New articles start on the default site. To change it, make another site the default.')
|
||||
: t('New articles start here. The current default is {0}.', [
|
||||
currentDefault.value?.name ?? '',
|
||||
]),
|
||||
)
|
||||
const urlError = computed(() => errors.url)
|
||||
const previewUrl = computed(() =>
|
||||
values.baseUrl.trim() && !urlError.value && siteUrlProblem(values.baseUrl) === null
|
||||
? canonicalPreview(values.baseUrl)
|
||||
: canonicalPreview(PREVIEW_FALLBACK_URL),
|
||||
)
|
||||
const picker = computed(() => ({
|
||||
title: t('Choose an image'),
|
||||
description: t('The default social image for {0}.', [values.name || t('this site')]),
|
||||
confirmLabel: t('Use image'),
|
||||
}))
|
||||
|
||||
function takenKeys() {
|
||||
return props.sites.filter((site) => site.id !== props.site?.id).map((site) => site.key)
|
||||
}
|
||||
|
||||
function checkKey() {
|
||||
if (editing.value) {
|
||||
return true
|
||||
}
|
||||
const key = slug.value.value
|
||||
if (!key) {
|
||||
return true
|
||||
}
|
||||
if (takenKeys().includes(key)) {
|
||||
showKeyTaken(key)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function showKeyTaken(key: string) {
|
||||
if (slug.mode.value === 'auto') {
|
||||
slug.unlock()
|
||||
}
|
||||
errors.key = t('A site with the key "{0}" already exists. Choose another key.', [key])
|
||||
errors.suggestion = suggestFreeSlug(
|
||||
key,
|
||||
(candidate) => takenKeys().includes(candidate),
|
||||
SITE_KEY_MAX,
|
||||
)
|
||||
}
|
||||
|
||||
function clearKeyError() {
|
||||
errors.key = ''
|
||||
errors.suggestion = ''
|
||||
}
|
||||
|
||||
function onName(value: string) {
|
||||
values.name = value
|
||||
slug.setSource(value)
|
||||
errors.name = ''
|
||||
clearKeyError()
|
||||
}
|
||||
|
||||
function onKey(value: string) {
|
||||
slug.setValue(value)
|
||||
clearKeyError()
|
||||
}
|
||||
|
||||
function resetKey() {
|
||||
slug.reset()
|
||||
clearKeyError()
|
||||
}
|
||||
|
||||
function onBaseUrl(value: string) {
|
||||
values.baseUrl = value
|
||||
errors.url = ''
|
||||
}
|
||||
|
||||
function checkUrl() {
|
||||
errors.url = baseUrlProblem(values.baseUrl)
|
||||
return errors.url === ''
|
||||
}
|
||||
|
||||
function checkName() {
|
||||
errors.name = nameProblem(values.name)
|
||||
return errors.name === ''
|
||||
}
|
||||
|
||||
function checkKeyShape() {
|
||||
if (editing.value || !values.name.trim()) {
|
||||
return true
|
||||
}
|
||||
errors.key = siteKeyProblem(slug.value.value)
|
||||
return errors.key === ''
|
||||
}
|
||||
|
||||
function validate() {
|
||||
const nameOk = checkName()
|
||||
const keyOk = nameOk ? checkKeyShape() : true
|
||||
const urlOk = checkUrl()
|
||||
return nameOk && keyOk && urlOk
|
||||
}
|
||||
|
||||
function changedFields(site: Site) {
|
||||
const patch: SitePatch = {}
|
||||
if (values.name.trim() !== site.name) {
|
||||
patch.name = values.name.trim()
|
||||
}
|
||||
if (values.baseUrl.trim() !== site.base_url) {
|
||||
patch.base_url = values.baseUrl.trim()
|
||||
}
|
||||
if (values.robots !== site.default_robots) {
|
||||
patch.default_robots = values.robots
|
||||
}
|
||||
if (values.locale.trim() !== (site.locale ?? '')) {
|
||||
patch.locale = values.locale.trim() === '' ? null : values.locale.trim()
|
||||
}
|
||||
if (values.imageId !== site.default_social_image_id) {
|
||||
patch.default_social_image_id = values.imageId
|
||||
}
|
||||
return patch
|
||||
}
|
||||
|
||||
async function create() {
|
||||
const created = await createSite({
|
||||
name: values.name.trim(),
|
||||
key: slug.value.value,
|
||||
base_url: values.baseUrl.trim(),
|
||||
default_robots: values.robots,
|
||||
...(values.locale.trim() ? { locale: values.locale.trim() } : {}),
|
||||
...(values.imageId !== null ? { default_social_image_id: values.imageId } : {}),
|
||||
is_default: values.makeDefault,
|
||||
})
|
||||
emit('saved', created)
|
||||
}
|
||||
|
||||
async function update(site: Site) {
|
||||
const patch = changedFields(site)
|
||||
let latest = site
|
||||
if (Object.keys(patch).length > 0) {
|
||||
latest = await updateSite(site.id, patch)
|
||||
emit('updated', latest)
|
||||
}
|
||||
if (values.makeDefault && !site.is_default) {
|
||||
latest = await setDefaultSite(site.id)
|
||||
emit('defaulted', latest)
|
||||
}
|
||||
emit('saved', latest)
|
||||
}
|
||||
|
||||
function handleFailure(error: unknown) {
|
||||
const kind = classifyFailure(error, 'site')
|
||||
if (kind === 'session') {
|
||||
return
|
||||
}
|
||||
if (kind === 'keyTaken') {
|
||||
showKeyTaken(slug.value.value)
|
||||
return
|
||||
}
|
||||
if (kind === 'invalidSiteUrl') {
|
||||
errors.url = siteUrlMessage('invalid')
|
||||
return
|
||||
}
|
||||
if (kind === 'invalidLocale') {
|
||||
errors.locale = t('Use a language tag like en or fr-CH.')
|
||||
return
|
||||
}
|
||||
if (toasts.report(kind, 'site')) {
|
||||
if (kind === 'gone' && props.site) {
|
||||
emit('gone', props.site.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
notice.value = { description: copy('siteSaveFailed') }
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (saving.value) {
|
||||
return
|
||||
}
|
||||
notice.value = null
|
||||
if (!validate()) {
|
||||
panel.value?.focusFirstInvalid()
|
||||
return
|
||||
}
|
||||
if (props.site && !dirty.value) {
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (props.site) {
|
||||
await update(props.site)
|
||||
} else {
|
||||
await create()
|
||||
}
|
||||
} catch (error) {
|
||||
handleFailure(error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryPanel
|
||||
ref="panel"
|
||||
:title="title"
|
||||
:submit-label="submitLabel"
|
||||
:saving="saving"
|
||||
:dirty="dirty"
|
||||
:notice="notice"
|
||||
@close="emit('close')"
|
||||
@submit="submit"
|
||||
>
|
||||
<DirectoryField :label="t('Name')" :error="errors.name">
|
||||
<UInput
|
||||
:model-value="values.name"
|
||||
:placeholder="t('e.g. Journal')"
|
||||
:color="errors.name ? 'error' : undefined"
|
||||
:data-autofocus="editing ? undefined : ''"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 text-lg md:h-10 md:text-sm' }"
|
||||
@update:model-value="onName(String($event))"
|
||||
@blur="checkName"
|
||||
/>
|
||||
</DirectoryField>
|
||||
|
||||
<SlugField
|
||||
:label="t('Key')"
|
||||
:mode="slug.mode.value"
|
||||
:value="slug.value.value"
|
||||
:error="errors.key"
|
||||
:suggestion="errors.suggestion"
|
||||
:locked="editing"
|
||||
:help="
|
||||
t(
|
||||
'Lowercase letters, numbers and hyphens. Used in API paths. It can\'t be changed after the site is added.',
|
||||
)
|
||||
"
|
||||
:locked-help="t('Used in API paths, so it can\'t change.')"
|
||||
:edit-label="t('Edit key')"
|
||||
@update:value="onKey"
|
||||
@unlock="slug.unlock"
|
||||
@reset="resetKey"
|
||||
@suggest="onKey"
|
||||
@blur="checkKey"
|
||||
/>
|
||||
|
||||
<DirectoryField
|
||||
:label="t('Base URL')"
|
||||
:error="errors.url"
|
||||
:help="t('The public address of the frontend. Use https and no path.')"
|
||||
>
|
||||
<UInput
|
||||
:model-value="values.baseUrl"
|
||||
placeholder="https://blog.example.com"
|
||||
:color="errors.url ? 'error' : undefined"
|
||||
inputmode="url"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 font-mono text-base md:h-10 md:text-code' }"
|
||||
@update:model-value="onBaseUrl(String($event))"
|
||||
@blur="checkUrl"
|
||||
/>
|
||||
</DirectoryField>
|
||||
|
||||
<MediaField
|
||||
v-model="values.imageId"
|
||||
v-model:item="imageItem"
|
||||
:label="t('Default social image')"
|
||||
:help="t('Used when an article has no image of its own.')"
|
||||
:picker="picker"
|
||||
/>
|
||||
|
||||
<DirectoryField :label="t('Default robots policy')" :help="robotsHelp">
|
||||
<URadioGroup
|
||||
v-model="values.robots"
|
||||
:items="robotsItems"
|
||||
orientation="horizontal"
|
||||
variant="card"
|
||||
indicator="hidden"
|
||||
:legend="t('Default robots policy')"
|
||||
:ui="{
|
||||
root: 'w-full',
|
||||
legend: 'sr-only',
|
||||
fieldset: 'w-full gap-0',
|
||||
item: 'flex-1 rounded-none border-accented p-0 text-toned has-data-[state=checked]:border-accented has-data-[state=checked]:bg-(--rc-sand) has-data-[state=checked]:text-(--rc-on-sand)',
|
||||
label: 'flex h-11 w-full items-center justify-center text-sm font-normal text-inherit md:h-8',
|
||||
wrapper: 'w-full',
|
||||
}"
|
||||
/>
|
||||
</DirectoryField>
|
||||
|
||||
<DirectoryField
|
||||
:label="t('Locale')"
|
||||
optional
|
||||
:error="errors.locale"
|
||||
:help="t('A language tag. Leave empty if the site has no single language.')"
|
||||
>
|
||||
<UInput
|
||||
v-model="values.locale"
|
||||
:placeholder="t('e.g. en or fr-CH')"
|
||||
:color="errors.locale ? 'error' : undefined"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 font-mono text-base md:h-10 md:text-code' }"
|
||||
@update:model-value="errors.locale = ''"
|
||||
/>
|
||||
</DirectoryField>
|
||||
|
||||
<USwitch
|
||||
v-model="values.makeDefault"
|
||||
:disabled="isCurrentDefault"
|
||||
:label="switchLabel"
|
||||
:description="switchHelp"
|
||||
:ui="{
|
||||
root: 'items-start gap-3',
|
||||
base: 'mt-0.5 h-4.5 w-8',
|
||||
thumb: 'size-3.5',
|
||||
label: 'font-medium text-highlighted',
|
||||
description: 'text-xs/4.5 text-pretty text-muted',
|
||||
}"
|
||||
/>
|
||||
|
||||
<template #preview>
|
||||
<div class="flex flex-col gap-1 bg-elevated p-3">
|
||||
<span class="text-xs text-muted">{{ t('Canonical URL preview') }}</span>
|
||||
<span class="font-mono text-xs/4.5 break-all text-highlighted">{{
|
||||
previewUrl
|
||||
}}</span>
|
||||
<span v-if="errors.url" class="text-xs text-muted">{{
|
||||
t('Shown when the base URL is valid.')
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
</DirectoryPanel>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const emit = defineEmits<{ back: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UEmpty
|
||||
variant="naked"
|
||||
icon="i-lucide-lock"
|
||||
:title="t('You don\'t have access to Sites')"
|
||||
:description="
|
||||
t('Only the owner can manage sites. Ask the owner of this site if you need a change.')
|
||||
"
|
||||
: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-sm text-highlighted ring-0 sm:h-8"
|
||||
@click="emit('back')"
|
||||
>
|
||||
{{ t('Back to Settings') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UEmpty>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import type { SlugMode } from '@/composables/useSlugField'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
mode: SlugMode
|
||||
value: string
|
||||
name?: string
|
||||
error?: string
|
||||
suggestion?: string
|
||||
help?: string
|
||||
manualHelp?: string
|
||||
locked?: boolean
|
||||
lockedHelp?: string
|
||||
editLabel?: string
|
||||
}>(),
|
||||
{
|
||||
name: undefined,
|
||||
error: '',
|
||||
suggestion: '',
|
||||
help: '',
|
||||
manualHelp: '',
|
||||
locked: false,
|
||||
lockedHelp: '',
|
||||
editLabel: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:value': [value: string]
|
||||
unlock: []
|
||||
reset: []
|
||||
suggest: [value: string]
|
||||
blur: []
|
||||
}>()
|
||||
|
||||
const readonly = computed(() => props.locked || props.mode === 'empty' || props.mode === 'auto')
|
||||
const showAuto = computed(() => props.mode === 'auto' && !props.locked)
|
||||
const inputUi = computed(() => ({
|
||||
base: `h-11 font-mono text-base md:h-10 md:text-code ${readonly.value ? 'bg-elevated' : ''} ${showAuto.value ? 'max-md:h-11.5' : ''}`,
|
||||
}))
|
||||
const showReset = computed(
|
||||
() => !props.locked && (props.mode === 'manual' || (props.mode === 'saved' && props.error)),
|
||||
)
|
||||
const shownHelp = computed(() => {
|
||||
if (props.locked) {
|
||||
return props.lockedHelp
|
||||
}
|
||||
if (props.mode === 'manual' || props.mode === 'saved') {
|
||||
return props.manualHelp || props.help
|
||||
}
|
||||
return props.help
|
||||
})
|
||||
const pencilLabel = computed(() => props.editLabel || t('Edit slug'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-col gap-1.5">
|
||||
<DirectoryField :label="label" :name="name" :error="error" :help="shownHelp">
|
||||
<UInput
|
||||
:model-value="value"
|
||||
:readonly="readonly"
|
||||
:placeholder="t('Generated from the name')"
|
||||
:color="error ? 'error' : undefined"
|
||||
:highlight="mode === 'manual' && !error"
|
||||
class="w-full"
|
||||
:ui="inputUi"
|
||||
@update:model-value="emit('update:value', String($event))"
|
||||
@blur="emit('blur')"
|
||||
>
|
||||
<template v-if="locked" #trailing>
|
||||
<UIcon
|
||||
name="i-lucide-lock"
|
||||
data-testid="slug-lock"
|
||||
class="size-3.5 text-muted"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="showAuto" #trailing>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span
|
||||
class="inline-flex h-5 items-center border border-dashed border-accented px-1.5 font-mono text-2xs font-medium text-toned"
|
||||
>{{ t('Auto') }}</span
|
||||
>
|
||||
<UButton
|
||||
icon="i-lucide-pencil"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:aria-label="pencilLabel"
|
||||
class="size-7 justify-center text-highlighted max-md:size-11"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="emit('unlock')"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</UInput>
|
||||
</DirectoryField>
|
||||
<div v-if="suggestion" class="flex flex-wrap items-center gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-wand-sparkles"
|
||||
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
|
||||
:ui="{ leadingIcon: 'size-3' }"
|
||||
@click="emit('suggest', suggestion)"
|
||||
>
|
||||
{{ t('Use {0}', [suggestion]) }}
|
||||
</UButton>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="showReset"
|
||||
color="neutral"
|
||||
variant="link"
|
||||
icon="i-lucide-rotate-ccw"
|
||||
class="min-h-11 gap-1 self-start p-0 text-xs font-medium text-highlighted underline underline-offset-3 md:min-h-5"
|
||||
:ui="{ leadingIcon: 'size-3' }"
|
||||
@click="emit('reset')"
|
||||
>
|
||||
{{ t('Reset to match the name') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import {
|
||||
MAX_SOCIAL_LINKS,
|
||||
SOCIAL_PLACEHOLDERS,
|
||||
SOCIAL_TYPES,
|
||||
type SocialLinkRow,
|
||||
type SocialType,
|
||||
} from '@/lib/socialLinks'
|
||||
|
||||
defineProps<{
|
||||
errors: Record<number, string>
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ blur: [key: number] }>()
|
||||
|
||||
const rows = defineModel<SocialLinkRow[]>({ default: () => [] })
|
||||
|
||||
const typeItems = computed(() => [
|
||||
{ label: 'X', value: 'x' },
|
||||
{ label: 'LinkedIn', value: 'linkedin' },
|
||||
{ label: 'GitHub', value: 'github' },
|
||||
{ label: 'Mastodon', value: 'mastodon' },
|
||||
{ label: t('Custom'), value: 'custom' },
|
||||
])
|
||||
|
||||
const full = computed(() => rows.value.length >= MAX_SOCIAL_LINKS)
|
||||
const counter = computed(() => t('{0} of {1}', [rows.value.length, MAX_SOCIAL_LINKS]))
|
||||
|
||||
function nextKey() {
|
||||
return rows.value.reduce((highest, row) => Math.max(highest, row.key), 0) + 1
|
||||
}
|
||||
|
||||
function add() {
|
||||
if (full.value) {
|
||||
return
|
||||
}
|
||||
rows.value = [...rows.value, { key: nextKey(), type: SOCIAL_TYPES[0], url: '' }]
|
||||
}
|
||||
|
||||
function remove(key: number) {
|
||||
rows.value = rows.value.filter((row) => row.key !== key)
|
||||
}
|
||||
|
||||
function update(key: number, patch: Partial<SocialLinkRow>) {
|
||||
rows.value = rows.value.map((row) => (row.key === key ? { ...row, ...patch } : row))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-medium text-highlighted"
|
||||
>{{ t('Social links') }}
|
||||
<span class="font-normal text-muted"> {{ t('(optional)') }}</span></span
|
||||
>
|
||||
<DirectoryField v-for="(row, index) in rows" :key="row.key" :error="errors[row.key]">
|
||||
<div class="flex items-start gap-2" data-testid="social-link-row">
|
||||
<USelect
|
||||
:model-value="row.type"
|
||||
:items="typeItems"
|
||||
:disabled="disabled"
|
||||
:aria-label="t('Link type {0}', [index + 1])"
|
||||
class="w-27 shrink-0 max-md:w-28"
|
||||
:ui="{ base: 'h-11 text-lg md:h-10 md:text-sm' }"
|
||||
@update:model-value="update(row.key, { type: $event as SocialType })"
|
||||
/>
|
||||
<UInput
|
||||
:model-value="row.url"
|
||||
:placeholder="SOCIAL_PLACEHOLDERS[row.type]"
|
||||
:aria-label="t('Link URL {0}', [index + 1])"
|
||||
:color="errors[row.key] ? 'error' : undefined"
|
||||
:disabled="disabled"
|
||||
class="min-w-0 flex-1"
|
||||
:ui="{ base: 'h-11 font-mono text-base md:h-10 md:text-code' }"
|
||||
@update:model-value="update(row.key, { url: String($event) })"
|
||||
@blur="emit('blur', row.key)"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:disabled="disabled"
|
||||
:aria-label="t('Remove link {0}', [index + 1])"
|
||||
class="size-11 shrink-0 justify-center border border-accented bg-default text-muted ring-0 md:size-10"
|
||||
@click="remove(row.key)"
|
||||
/>
|
||||
</div>
|
||||
</DirectoryField>
|
||||
<div class="flex items-center gap-3">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-plus"
|
||||
:disabled="disabled || full"
|
||||
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="add"
|
||||
>
|
||||
{{ t('Add link') }}
|
||||
</UButton>
|
||||
<span v-if="full" class="text-xs text-muted">{{
|
||||
t('You can add up to {0} links.', [MAX_SOCIAL_LINKS])
|
||||
}}</span>
|
||||
<span class="text-xs text-muted">{{ counter }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import AuthorAvatar from '@/components/AuthorAvatar.vue'
|
||||
import type { LinkableUser } from '@/lib/directoryTypes'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
users: LinkableUser[]
|
||||
status: 'loading' | 'ready' | 'error'
|
||||
idlePlaceholder?: string
|
||||
clearable?: boolean
|
||||
disabled?: boolean
|
||||
invalid?: boolean
|
||||
}>(),
|
||||
{ idlePlaceholder: '', clearable: false, disabled: false, invalid: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ retry: []; open: [] }>()
|
||||
|
||||
const menuOpen = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const userId = defineModel<number | null>({ default: null })
|
||||
|
||||
const items = computed(() =>
|
||||
props.users.length === 0
|
||||
? []
|
||||
: [
|
||||
{ type: 'label' as const, label: t('Users without an author') },
|
||||
...props.users.map((user) => ({
|
||||
label: user.label,
|
||||
description: user.email,
|
||||
value: user.id,
|
||||
avatarName: user.label,
|
||||
})),
|
||||
],
|
||||
)
|
||||
|
||||
const placeholder = computed(() =>
|
||||
menuOpen.value || !props.idlePlaceholder ? t('Choose a user') : props.idlePlaceholder,
|
||||
)
|
||||
const chosen = computed(() => props.users.find((user) => user.id === userId.value))
|
||||
|
||||
function onOpen(value: boolean) {
|
||||
if (value) {
|
||||
emit('open')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USelectMenu
|
||||
v-model="userId"
|
||||
v-model:open="menuOpen"
|
||||
:portal="false"
|
||||
:content="{ position: 'inline' }"
|
||||
:items="items"
|
||||
value-key="value"
|
||||
:search-input="false"
|
||||
:placeholder="placeholder"
|
||||
:loading="status === 'loading'"
|
||||
:disabled="disabled"
|
||||
:color="invalid ? 'error' : undefined"
|
||||
:highlight="invalid"
|
||||
:clear="clearable && userId !== null"
|
||||
class="w-full"
|
||||
:ui="{
|
||||
base: 'h-11 rounded-none text-lg ring-(--ui-border-accented) md:h-10 md:text-sm',
|
||||
trailingIcon: 'size-3.5 text-muted',
|
||||
content: 'rounded-none shadow-(--shadow-popover)',
|
||||
label: 'px-2 py-1.5 text-xs text-muted',
|
||||
item: 'min-h-12 gap-2 px-2 py-1 data-[state=checked]:bg-elevated md:min-h-9',
|
||||
itemDescription: 'truncate text-xs text-muted',
|
||||
placeholder: 'text-muted',
|
||||
}"
|
||||
@update:open="onOpen"
|
||||
>
|
||||
<template #default>
|
||||
<span v-if="chosen" class="truncate text-highlighted">{{ chosen.label }}</span>
|
||||
<span v-else class="truncate text-muted">{{ placeholder }}</span>
|
||||
</template>
|
||||
<template #item-leading="{ item }">
|
||||
<AuthorAvatar v-if="'avatarName' in item" :name="String(item.avatarName)" :size="24" />
|
||||
</template>
|
||||
<template #empty>
|
||||
<div v-if="status === 'error'" class="flex flex-col items-start gap-2 p-3">
|
||||
<span class="text-error">{{ t("Couldn't load users") }}</span>
|
||||
<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-7"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'loading'"
|
||||
class="flex flex-col gap-2.5 p-3"
|
||||
aria-busy="true"
|
||||
>
|
||||
<USkeleton
|
||||
v-for="n in 3"
|
||||
:key="n"
|
||||
class="h-3 rounded-none bg-accented"
|
||||
:class="n === 3 ? 'w-3/5' : 'w-4/5'"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="p-3 text-muted">{{ t('Every user already has an author.') }}</div>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
</template>
|
||||
@@ -62,8 +62,8 @@ afterEach(async () => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('DashboardShell renders the seven navigation destinations', () => {
|
||||
it('lists Home, Articles, Media, Users, Activity, Integration and Settings in order, with only Articles carrying aria-current', async () => {
|
||||
describe('DashboardShell renders the eight navigation destinations', () => {
|
||||
it('lists Home, Articles, Media, Authors, Users, Activity, Integration and Settings in order, with only Articles carrying aria-current', async () => {
|
||||
const wrapper = await mountShell('/article')
|
||||
|
||||
const hrefs = wrapper
|
||||
@@ -73,6 +73,7 @@ describe('DashboardShell renders the seven navigation destinations', () => {
|
||||
'/home',
|
||||
'/article',
|
||||
'/media',
|
||||
'/authors',
|
||||
'/users',
|
||||
'/activity',
|
||||
'/integration',
|
||||
@@ -90,7 +91,7 @@ describe('DashboardShell renders the seven navigation destinations', () => {
|
||||
expect(integration.attributes('aria-current')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lists the same seven destinations in the mobile drawer and Settings current', async () => {
|
||||
it('lists the same eight destinations in the mobile drawer and Settings current', async () => {
|
||||
await mountShell('/settings', { width: MOBILE_WIDTH })
|
||||
|
||||
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
|
||||
@@ -104,13 +105,14 @@ describe('DashboardShell renders the seven navigation destinations', () => {
|
||||
'/home',
|
||||
'/article',
|
||||
'/media',
|
||||
'/authors',
|
||||
'/users',
|
||||
'/activity',
|
||||
'/integration',
|
||||
'/settings',
|
||||
])
|
||||
expect(links[6].querySelector('[class*="lucide"], svg')).not.toBeNull()
|
||||
expect(links[6].getAttribute('aria-current')).toBe('page')
|
||||
expect(links[7].querySelector('[class*="lucide"], svg')).not.toBeNull()
|
||||
expect(links[7].getAttribute('aria-current')).toBe('page')
|
||||
})
|
||||
|
||||
it('marks Media with aria-current on /media, in the sidebar and in the mobile drawer', async () => {
|
||||
@@ -269,11 +271,11 @@ describe('The account chip shows the display name and no item is current on /pro
|
||||
expect(trigger?.getAttribute('aria-label')).toBe('Marta Alves — account menu')
|
||||
})
|
||||
|
||||
it('marks none of the seven navigation items current on /profile', async () => {
|
||||
it('marks none of the eight navigation items current on /profile', async () => {
|
||||
const wrapper = await mountShell('/profile')
|
||||
|
||||
const links = wrapper.findAll('nav[aria-label="Main"] a')
|
||||
expect(links).toHaveLength(7)
|
||||
expect(links).toHaveLength(8)
|
||||
links.forEach((link) => expect(link.attributes('aria-current')).toBeUndefined())
|
||||
})
|
||||
})
|
||||
@@ -747,7 +749,7 @@ describe('DashboardShell mobile drawer rows', () => {
|
||||
const drawer = await openDrawer('/settings')
|
||||
|
||||
const links = Array.from(drawer.querySelectorAll('nav[aria-label="Main"] a'))
|
||||
expect(links).toHaveLength(9)
|
||||
expect(links).toHaveLength(11)
|
||||
for (const link of links) {
|
||||
expect(classesOf(link)).toContain('max-lg:h-11')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { h } from 'vue'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import DirectoryList from '../DirectoryList.vue'
|
||||
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
|
||||
import { MOBILE_WIDTH } from './viewport'
|
||||
import { byText, mountInApp, text, unmountApp } from './directoryKit'
|
||||
|
||||
interface Row {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const ROWS: Row[] = [
|
||||
{ id: 1, name: 'First' },
|
||||
{
|
||||
id: 2,
|
||||
name: 'A very long name that must be truncated in the row and shown in full on hover',
|
||||
},
|
||||
]
|
||||
|
||||
function menu(row: Row): DirectoryMenuItem[] {
|
||||
return [
|
||||
{ key: 'edit', label: 'Edit', icon: 'i-lucide-pencil', onSelect: () => undefined },
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete…',
|
||||
icon: 'i-lucide-trash-2',
|
||||
destructive: true,
|
||||
separated: true,
|
||||
unavailable: row.id === 1,
|
||||
hint: 'Not on the first row.',
|
||||
onSelect: () => undefined,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function props(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
items: ROWS,
|
||||
columns: 'minmax(0,1fr) 44px',
|
||||
headings: ['Name', ''],
|
||||
label: 'Rows',
|
||||
rowTitle: (row: Row) => row.name,
|
||||
menuItems: menu,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
const slots = {
|
||||
cells: ({ item }: { item: Row }) =>
|
||||
h('span', { class: 'truncate', title: item.name }, item.name),
|
||||
card: ({ item }: { item: Row }) =>
|
||||
h('span', { class: 'truncate', title: item.name }, item.name),
|
||||
}
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
describe('DirectoryList', () => {
|
||||
it('draws a header and one row per item with a menu button on desktop', async () => {
|
||||
await mountInApp(DirectoryList, props(), slots)
|
||||
|
||||
expect(
|
||||
text(document.querySelector('[data-testid="directory-row"]')!.previousElementSibling),
|
||||
).toBe('Name')
|
||||
expect(document.querySelectorAll('[data-testid="directory-row"]')).toHaveLength(2)
|
||||
expect(byText('button', 'Actions for First')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the full name available on hover for a truncated row', async () => {
|
||||
await mountInApp(DirectoryList, props(), slots)
|
||||
|
||||
const long = document.querySelectorAll<HTMLElement>('span.truncate')[1]!
|
||||
expect(long.getAttribute('title')).toBe(ROWS[1]!.name)
|
||||
})
|
||||
|
||||
it('draws cards below md', async () => {
|
||||
await mountInApp(DirectoryList, props(), slots, MOBILE_WIDTH)
|
||||
|
||||
expect(document.querySelectorAll('[data-testid="directory-card"]')).toHaveLength(2)
|
||||
expect(document.querySelector('[data-testid="directory-row"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('draws skeleton rows with aria-busy while loading', async () => {
|
||||
await mountInApp(DirectoryList, props({ loading: true, skeletonRows: 4 }), slots)
|
||||
|
||||
expect(document.querySelector('[aria-busy="true"]')).not.toBeNull()
|
||||
expect(document.querySelectorAll('[data-testid="directory-skeleton-row"]')).toHaveLength(4)
|
||||
expect(document.querySelector('[data-testid="directory-row"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('swaps the menu for a chevron and emits open in the read-only variant', async () => {
|
||||
const wrapper = await mountInApp(DirectoryList, props({ readOnly: true }), slots)
|
||||
|
||||
expect(document.querySelector('button[aria-label^="Actions for"]')).toBeNull()
|
||||
byText('button', 'View First')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const list = wrapper.findComponent(DirectoryList)
|
||||
expect(list.emitted('open')![0]).toEqual([ROWS[0]])
|
||||
})
|
||||
|
||||
it('keeps an unavailable item focusable with aria-disabled and its hint', async () => {
|
||||
await mountInApp(DirectoryList, props(), slots)
|
||||
byText('button', 'Actions for First')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const item = byText('[role="menuitem"]', /^Delete…/)!
|
||||
expect(item.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(item.hasAttribute('disabled')).toBe(false)
|
||||
expect(text(document.getElementById(item.getAttribute('aria-describedby')!))).toBe(
|
||||
'Not on the first row.',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { h } from 'vue'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import DirectoryPanel from '../DirectoryPanel.vue'
|
||||
import { MOBILE_WIDTH } from './viewport'
|
||||
import { byText, mountInApp, text, unmountApp } from './directoryKit'
|
||||
|
||||
function body() {
|
||||
return h('input', { 'data-autofocus': '', placeholder: 'Name' })
|
||||
}
|
||||
|
||||
function content() {
|
||||
return document.body.querySelector<HTMLElement>('[role="dialog"]')!
|
||||
}
|
||||
|
||||
function escape() {
|
||||
content().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
return flushPromises()
|
||||
}
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
describe('DirectoryPanel', () => {
|
||||
it('opens a right slide-over on desktop with a title, a Cancel and a submit button', async () => {
|
||||
await mountInApp(
|
||||
DirectoryPanel,
|
||||
{ title: 'Add thing', submitLabel: 'Add thing' },
|
||||
{ default: body },
|
||||
)
|
||||
|
||||
expect(text(content().querySelector('h2'))).toBe('Add thing')
|
||||
expect(content().className).toContain('max-w-120')
|
||||
expect(byText('button', 'Cancel', content())).toBeTruthy()
|
||||
expect(byText('button', 'Add thing', content())).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens a 92 percent bottom sheet below md', async () => {
|
||||
await mountInApp(
|
||||
DirectoryPanel,
|
||||
{ title: 'Add thing', submitLabel: 'Add thing' },
|
||||
{ default: body },
|
||||
MOBILE_WIDTH,
|
||||
)
|
||||
|
||||
expect(content().className).toContain('h-23/25')
|
||||
expect(content().className).toContain('before:w-9')
|
||||
})
|
||||
|
||||
it('moves focus into the panel', async () => {
|
||||
await mountInApp(
|
||||
DirectoryPanel,
|
||||
{ title: 'Add thing', submitLabel: 'Add' },
|
||||
{ default: body },
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(content().contains(document.activeElement)).toBe(true)
|
||||
})
|
||||
|
||||
it('closes straight away when nothing changed', async () => {
|
||||
const wrapper = await mountInApp(
|
||||
DirectoryPanel,
|
||||
{ title: 'T', submitLabel: 'Add' },
|
||||
{ default: body },
|
||||
)
|
||||
await escape()
|
||||
|
||||
expect(wrapper.findComponent(DirectoryPanel).emitted('close')).toHaveLength(1)
|
||||
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('asks Discard changes? when the form is dirty and closes only on confirmation', async () => {
|
||||
const wrapper = await mountInApp(
|
||||
DirectoryPanel,
|
||||
{ title: 'T', submitLabel: 'Add', dirty: true },
|
||||
{ default: body },
|
||||
)
|
||||
await escape()
|
||||
|
||||
const ask = document.body.querySelector<HTMLElement>('[role="alertdialog"]')!
|
||||
expect(text(ask)).toContain('Discard changes?')
|
||||
expect(wrapper.findComponent(DirectoryPanel).emitted('close')).toBeUndefined()
|
||||
|
||||
byText('button', 'Discard changes', ask)!.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(DirectoryPanel).emitted('close')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ignores Escape while a request is running and labels the button Saving…', async () => {
|
||||
const wrapper = await mountInApp(
|
||||
DirectoryPanel,
|
||||
{ title: 'T', submitLabel: 'Add', saving: true, dirty: true },
|
||||
{ default: body },
|
||||
)
|
||||
await escape()
|
||||
|
||||
expect(wrapper.findComponent(DirectoryPanel).emitted('close')).toBeUndefined()
|
||||
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
|
||||
expect(byText('button', 'Saving…', content())).toBeTruthy()
|
||||
expect(byText('button', 'Cancel', content())!.hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the failure notice inside the panel', async () => {
|
||||
await mountInApp(
|
||||
DirectoryPanel,
|
||||
{ title: 'T', submitLabel: 'Add', notice: { description: 'Could not save.' } },
|
||||
{ default: body },
|
||||
)
|
||||
|
||||
expect(text(content().querySelector('[role="alert"]'))).toBe('Could not save.')
|
||||
})
|
||||
|
||||
it('offers Close as the only action in the read-only variant', async () => {
|
||||
await mountInApp(DirectoryPanel, { title: 'Detail', readOnly: true }, { default: body })
|
||||
|
||||
const labels = Array.from(content().querySelectorAll('button'))
|
||||
.map((button) => text(button) || button.getAttribute('aria-label'))
|
||||
.filter(Boolean)
|
||||
expect(labels.filter((label) => label !== 'Close')).toEqual([])
|
||||
expect(byText('button', 'Cancel', content())).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import MediaField from '../MediaField.vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { byText, mountInApp, text, unmountApp } from './directoryKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const ITEM = {
|
||||
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,
|
||||
}
|
||||
|
||||
const PICKER = {
|
||||
title: 'Choose an image',
|
||||
description: 'The default social image for Journal.',
|
||||
hint: 'Shown as a square.',
|
||||
confirmLabel: 'Use image',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn(),
|
||||
request: vi.fn().mockResolvedValue({
|
||||
items: [ITEM],
|
||||
total: 1,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 1, unused: 1, duplicates: 0 },
|
||||
}),
|
||||
} as unknown as ReturnType<typeof useApi>)
|
||||
})
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
describe('MediaField', () => {
|
||||
it('shows an optional label, its help and a Choose image button when empty', async () => {
|
||||
await mountInApp(MediaField, {
|
||||
label: 'Default social image',
|
||||
help: 'Used when an article has no image of its own.',
|
||||
picker: PICKER,
|
||||
})
|
||||
|
||||
const content = text(document.body)
|
||||
expect(content).toContain('Default social image')
|
||||
expect(content).toContain('(optional)')
|
||||
expect(content).toContain('Used when an article has no image of its own.')
|
||||
expect(byText('button', 'Choose image')).toBeTruthy()
|
||||
expect(byText('button', 'Remove')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('opens the existing picker with the given title, description, hint and confirm label', async () => {
|
||||
await mountInApp(MediaField, {
|
||||
label: 'Avatar',
|
||||
picker: PICKER,
|
||||
square: true,
|
||||
avatarName: 'Camille',
|
||||
})
|
||||
byText('button', 'Choose image')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const picker = document.body.querySelector<HTMLElement>('[role="dialog"]')!
|
||||
expect(text(picker)).toContain('Choose an image')
|
||||
expect(text(picker)).toContain('The default social image for Journal.')
|
||||
expect(text(picker)).toContain('Shown as a square.')
|
||||
expect(byText('button', 'Use image', picker)!.hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns the picked image id through Use image and offers Change image and Remove', async () => {
|
||||
const wrapper = await mountInApp(MediaField, { label: 'Avatar', picker: PICKER })
|
||||
byText('button', 'Choose image')!.click()
|
||||
await flushPromises()
|
||||
const picker = document.body.querySelector<HTMLElement>('[role="dialog"]')!
|
||||
picker.querySelector<HTMLElement>('[data-picker-tile]')!.click()
|
||||
await flushPromises()
|
||||
byText('button', 'Use image', picker)!.click()
|
||||
await flushPromises()
|
||||
|
||||
const field = wrapper.findComponent(MediaField)
|
||||
expect(field.emitted('update:modelValue')![0]).toEqual([55])
|
||||
expect(byText('button', 'Change image')).toBeTruthy()
|
||||
expect(byText('button', 'Remove')).toBeTruthy()
|
||||
expect(text(document.body)).toContain('face.jpg')
|
||||
})
|
||||
|
||||
it('shows the chosen filename, Change image and Remove, and Remove clears the id', async () => {
|
||||
const wrapper = await mountInApp(MediaField, {
|
||||
label: 'Avatar',
|
||||
picker: PICKER,
|
||||
modelValue: 55,
|
||||
item: ITEM,
|
||||
})
|
||||
|
||||
expect(text(document.body)).toContain('face.jpg')
|
||||
expect(byText('button', 'Change image')).toBeTruthy()
|
||||
byText('button', 'Remove')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findComponent(MediaField).emitted('update:modelValue')![0]).toEqual([null])
|
||||
})
|
||||
|
||||
it('draws the avatar variant as a square with initials when no image is chosen', async () => {
|
||||
await mountInApp(MediaField, {
|
||||
label: 'Avatar',
|
||||
picker: PICKER,
|
||||
square: true,
|
||||
avatarName: 'Camille Roche',
|
||||
})
|
||||
|
||||
const avatar = document.body.querySelector<HTMLElement>(
|
||||
'[data-slot="root"] [style*="64px"]',
|
||||
)
|
||||
expect(avatar ?? document.body.querySelector('[style*="width: 64px"]')).not.toBeNull()
|
||||
expect(text(document.body)).toContain('CR')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import ReassignDialog from '../ReassignDialog.vue'
|
||||
import { byText, mountInApp, text, unmountApp } from './directoryKit'
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 1, label: 'Renew', description: 'renew.example', display: 'Renew · renew.example' },
|
||||
{
|
||||
value: 3,
|
||||
label: 'Projects',
|
||||
description: 'projects.example',
|
||||
display: 'Projects · projects.example',
|
||||
},
|
||||
]
|
||||
|
||||
function props(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
title: 'Delete this thing?',
|
||||
description: 'It will be deleted.',
|
||||
confirmLabel: 'Move 5 and delete',
|
||||
pendingLabel: 'Moving…',
|
||||
selectLabel: 'Move to',
|
||||
options: OPTIONS,
|
||||
selected: 1,
|
||||
summary: '5 will move to Renew.',
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function dialog() {
|
||||
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')!
|
||||
}
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
describe('ReassignDialog', () => {
|
||||
it('shows the copy, the selected option, the summary and both buttons', async () => {
|
||||
await mountInApp(ReassignDialog, props())
|
||||
|
||||
const content = text(dialog())
|
||||
expect(content).toContain('Delete this thing?')
|
||||
expect(content).toContain('It will be deleted.')
|
||||
expect(content).toContain('Move to')
|
||||
expect(text(dialog().querySelector('button[aria-haspopup="listbox"]'))).toBe(
|
||||
'Renew · renew.example',
|
||||
)
|
||||
expect(content).toContain('5 will move to Renew.')
|
||||
expect(byText('button', 'Cancel', dialog())).toBeTruthy()
|
||||
expect(byText('button', 'Move 5 and delete', dialog())).toBeTruthy()
|
||||
})
|
||||
|
||||
it('lists the options it is given with a name and a description', async () => {
|
||||
await mountInApp(ReassignDialog, props())
|
||||
dialog().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const labels = Array.from(dialog().querySelectorAll('[role="option"]')).map(text)
|
||||
expect(labels).toEqual(['Renew renew.example', 'Projects projects.example'])
|
||||
})
|
||||
|
||||
it('draws no select when there are no options', async () => {
|
||||
await mountInApp(ReassignDialog, props({ options: [], summary: '' }))
|
||||
|
||||
expect(dialog().querySelector('button[aria-haspopup="listbox"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('switches the confirm label and disables the buttons while pending', async () => {
|
||||
await mountInApp(ReassignDialog, props({ pending: true }))
|
||||
|
||||
expect(byText('button', 'Moving…', dialog())).toBeTruthy()
|
||||
expect(byText('button', 'Cancel', dialog())!.hasAttribute('disabled')).toBe(true)
|
||||
expect(dialog().getAttribute('aria-busy')).toBe('true')
|
||||
})
|
||||
|
||||
it('shows the failure notice with its title and text', async () => {
|
||||
await mountInApp(
|
||||
ReassignDialog,
|
||||
props({ notice: { title: 'Failed', description: 'Nothing was changed.' } }),
|
||||
)
|
||||
|
||||
expect(text(dialog().querySelector('[role="alert"]'))).toBe('Failed Nothing was changed.')
|
||||
})
|
||||
|
||||
it('emits confirm from the primary button', async () => {
|
||||
const wrapper = await mountInApp(ReassignDialog, props())
|
||||
byText('button', 'Move 5 and delete', dialog())!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findComponent(ReassignDialog).emitted('confirm')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import SlugField from '../SlugField.vue'
|
||||
import { byText, mountInApp, text, unmountApp } from './directoryKit'
|
||||
|
||||
function props(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
label: 'Slug',
|
||||
mode: 'empty',
|
||||
value: '',
|
||||
help: 'Generated from the name.',
|
||||
manualHelp: 'Lowercase letters.',
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function input() {
|
||||
return document.body.querySelector<HTMLInputElement>('input')!
|
||||
}
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
describe('SlugField', () => {
|
||||
it('shows a read-only placeholder when there is no name yet', async () => {
|
||||
await mountInApp(SlugField, props())
|
||||
|
||||
expect(input().readOnly).toBe(true)
|
||||
expect(input().placeholder).toBe('Generated from the name')
|
||||
expect(text(document.body)).toContain('Generated from the name.')
|
||||
})
|
||||
|
||||
it('shows the automatic value with the Auto badge and a pencil that unlocks it', async () => {
|
||||
const wrapper = await mountInApp(SlugField, props({ mode: 'auto', value: 'camille-roche' }))
|
||||
|
||||
expect(input().value).toBe('camille-roche')
|
||||
expect(input().readOnly).toBe(true)
|
||||
expect(text(document.body)).toContain('Auto')
|
||||
byText('button', 'Edit slug')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findComponent(SlugField).emitted('unlock')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('is editable in manual mode and offers Reset to match the name', async () => {
|
||||
const wrapper = await mountInApp(SlugField, props({ mode: 'manual', value: 'custom' }))
|
||||
const field = wrapper.findComponent(SlugField)
|
||||
|
||||
expect(input().readOnly).toBe(false)
|
||||
expect(text(document.body)).toContain('Lowercase letters.')
|
||||
input().value = 'custom-2'
|
||||
input().dispatchEvent(new Event('input', { bubbles: true }))
|
||||
expect(field.emitted('update:value')![0]).toEqual(['custom-2'])
|
||||
|
||||
byText('button', 'Reset to match the name')!.click()
|
||||
await flushPromises()
|
||||
expect(field.emitted('reset')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows the error and a suggestion that emits its value', async () => {
|
||||
const wrapper = await mountInApp(
|
||||
SlugField,
|
||||
props({
|
||||
mode: 'manual',
|
||||
value: 'camille-roche',
|
||||
error: 'Already used.',
|
||||
suggestion: 'camille-roche-2',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(text(document.body)).toContain('Already used.')
|
||||
byText('button', 'Use camille-roche-2')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findComponent(SlugField).emitted('suggest')![0]).toEqual(['camille-roche-2'])
|
||||
})
|
||||
|
||||
it('draws the locked variant with a lock and its own help, with no pencil', async () => {
|
||||
await mountInApp(
|
||||
SlugField,
|
||||
props({ mode: 'saved', value: 'renew', locked: true, lockedHelp: "Can't change." }),
|
||||
)
|
||||
|
||||
expect(input().readOnly).toBe(true)
|
||||
expect(document.querySelector('[data-testid="slug-lock"]')).not.toBeNull()
|
||||
expect(text(document.body)).toContain("Can't change.")
|
||||
expect(byText('button', 'Edit slug')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { defineComponent, h, type Component } 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 { DESKTOP_WIDTH, setViewportWidth } from './viewport'
|
||||
|
||||
export const holder: { wrapper: VueWrapper | null } = { wrapper: null }
|
||||
|
||||
export async function mountInApp(
|
||||
component: Component,
|
||||
props: Record<string, unknown> = {},
|
||||
slots: Record<string, () => unknown> = {},
|
||||
width = DESKTOP_WIDTH,
|
||||
) {
|
||||
setViewportWidth(width)
|
||||
Element.prototype.scrollIntoView = () => undefined
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
holder.wrapper = mount(
|
||||
defineComponent({
|
||||
render: () =>
|
||||
h(UApp, null, {
|
||||
default: () => h(component, props, slots as never),
|
||||
}),
|
||||
}),
|
||||
{ attachTo: document.body, global: { plugins: [router, uiPlugin, pinia] } },
|
||||
)
|
||||
await flushPromises()
|
||||
return holder.wrapper
|
||||
}
|
||||
|
||||
export function unmountApp() {
|
||||
holder.wrapper?.unmount()
|
||||
holder.wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
}
|
||||
|
||||
export function text(element: Element | null | undefined) {
|
||||
if (!element) {
|
||||
return ''
|
||||
}
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
|
||||
const parts: string[] = []
|
||||
while (walker.nextNode()) {
|
||||
const value = walker.currentNode.textContent?.replace(/\s+/g, ' ').trim()
|
||||
if (value) {
|
||||
parts.push(value)
|
||||
}
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
export function byText(selector: string, label: string | RegExp, root: ParentNode = document.body) {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>(selector)).find((element) => {
|
||||
const value = element.getAttribute('aria-label') || text(element)
|
||||
return typeof label === 'string' ? value === label : label.test(value)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
|
||||
import { useDirectoryCollection } from '@/composables/useDirectoryCollection'
|
||||
import { useDirectoryDelete } from '@/composables/useDirectoryDelete'
|
||||
import { useDirectoryView } from '@/composables/useDirectoryView'
|
||||
import { useSlugField } from '@/composables/useSlugField'
|
||||
import router from '@/router'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
function inComponent<T>(setup: () => T) {
|
||||
let result!: T
|
||||
wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = setup()
|
||||
return () => h('div')
|
||||
},
|
||||
}),
|
||||
{ global: { plugins: [router] } },
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((done, fail) => {
|
||||
resolve = done
|
||||
reject = fail
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('useDelayedSkeleton', () => {
|
||||
it('shows only after the delay and hides on stop', async () => {
|
||||
vi.useFakeTimers()
|
||||
const skeleton = inComponent(() => useDelayedSkeleton(150))
|
||||
skeleton.start()
|
||||
await vi.advanceTimersByTimeAsync(149)
|
||||
expect(skeleton.visible.value).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(skeleton.visible.value).toBe(true)
|
||||
skeleton.stop()
|
||||
expect(skeleton.visible.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDirectoryCollection', () => {
|
||||
it('loads, keeps the data on a later load and ignores a stale answer', async () => {
|
||||
const first = deferred<string>()
|
||||
const second = deferred<string>()
|
||||
const answers = [first, second]
|
||||
const collection = inComponent(() => useDirectoryCollection(() => answers.shift()!.promise))
|
||||
const early = collection.load([])
|
||||
const late = collection.load([])
|
||||
second.resolve('new')
|
||||
first.resolve('old')
|
||||
|
||||
expect(await late).toBe('ready')
|
||||
expect(await early).toBe('stale')
|
||||
expect(collection.data.value).toBe('new')
|
||||
expect(collection.state.value).toBe('ready')
|
||||
})
|
||||
|
||||
it('can refuse a result without committing it', async () => {
|
||||
const collection = inComponent(() => useDirectoryCollection(async () => 'page'))
|
||||
|
||||
expect(await collection.load([], { accept: () => false })).toBe('rejected')
|
||||
expect(collection.data.value).toBeNull()
|
||||
})
|
||||
|
||||
it('maps a 403 to forbidden, other failures to error and ignores a 401', async () => {
|
||||
const failures = [new ApiError(403), new ApiError(500), new ApiError(401)]
|
||||
const collection = inComponent(() =>
|
||||
useDirectoryCollection(async () => {
|
||||
throw failures.shift()
|
||||
}),
|
||||
)
|
||||
|
||||
await collection.load([])
|
||||
expect(collection.state.value).toBe('forbidden')
|
||||
await collection.load([])
|
||||
expect(collection.state.value).toBe('error')
|
||||
expect(await collection.load([])).toBe('stale')
|
||||
expect(collection.state.value).toBe('loading')
|
||||
})
|
||||
|
||||
it('draws the skeleton only after the delay on the first load', async () => {
|
||||
vi.useFakeTimers()
|
||||
const hold = deferred<string>()
|
||||
const collection = inComponent(() => useDirectoryCollection(() => hold.promise))
|
||||
const loading = collection.load([])
|
||||
expect(collection.showSkeleton.value).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
expect(collection.showSkeleton.value).toBe(true)
|
||||
hold.resolve('done')
|
||||
await loading
|
||||
expect(collection.showSkeleton.value).toBe(false)
|
||||
})
|
||||
|
||||
it('drops an answer that arrives after the component is gone', async () => {
|
||||
const hold = deferred<string>()
|
||||
const collection = inComponent(() => useDirectoryCollection(() => hold.promise))
|
||||
const loading = collection.load([])
|
||||
wrapper!.unmount()
|
||||
wrapper = null
|
||||
hold.resolve('late')
|
||||
|
||||
expect(await loading).toBe('stale')
|
||||
expect(collection.data.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDirectoryDelete', () => {
|
||||
it('runs once, clears the notice and reports failures to the handler', async () => {
|
||||
const removal = useDirectoryDelete<{ id: number }>()
|
||||
removal.open({ id: 1 })
|
||||
removal.notice.value = { description: 'old' }
|
||||
const run = vi.fn().mockRejectedValue(new Error('no'))
|
||||
const onFailure = vi.fn()
|
||||
|
||||
const first = removal.confirm(run, onFailure)
|
||||
await removal.confirm(run, onFailure)
|
||||
await first
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(1)
|
||||
expect(onFailure).toHaveBeenCalledWith(expect.any(Error), { id: 1 })
|
||||
expect(removal.pending.value).toBe(false)
|
||||
expect(removal.notice.value).toBeNull()
|
||||
})
|
||||
|
||||
it('does nothing without a target and closes cleanly', async () => {
|
||||
const removal = useDirectoryDelete<{ id: number }>()
|
||||
const run = vi.fn()
|
||||
await removal.confirm(run, vi.fn())
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
|
||||
removal.open({ id: 2 })
|
||||
removal.close()
|
||||
expect(removal.target.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSlugField', () => {
|
||||
it('follows the name until unlocked and goes back with reset', () => {
|
||||
const slug = useSlugField(32)
|
||||
expect(slug.mode.value).toBe('empty')
|
||||
slug.setSource('Camille Roche')
|
||||
expect(slug.mode.value).toBe('auto')
|
||||
expect(slug.value.value).toBe('camille-roche')
|
||||
slug.unlock()
|
||||
slug.setSource('Someone Else')
|
||||
expect(slug.mode.value).toBe('manual')
|
||||
expect(slug.value.value).toBe('camille-roche')
|
||||
slug.reset()
|
||||
expect(slug.value.value).toBe('someone-else')
|
||||
expect(slug.mode.value).toBe('auto')
|
||||
})
|
||||
|
||||
it('leaves the saved mode as soon as the value is edited and reset follows the name', () => {
|
||||
const slug = useSlugField(100, 'ines-duarte')
|
||||
slug.setSource('Inès Duarte')
|
||||
expect(slug.mode.value).toBe('saved')
|
||||
slug.setValue('ines-d')
|
||||
expect(slug.mode.value).toBe('manual')
|
||||
slug.setValue('ines-duarte')
|
||||
expect(slug.mode.value).toBe('saved')
|
||||
slug.setValue('other')
|
||||
slug.reset()
|
||||
expect(slug.value.value).toBe('ines-duarte')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDirectoryView', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
|
||||
it('opens a deep link once and ignores a lookup that finishes after unmount', async () => {
|
||||
await router.push('/authors?edit=zoe')
|
||||
const hold = deferred<string | null>()
|
||||
const view = inComponent(() => useDirectoryView())
|
||||
const open = vi.fn()
|
||||
const missing = vi.fn()
|
||||
|
||||
const following = view.followEditLink(() => hold.promise, open, missing)
|
||||
await view.followEditLink(() => hold.promise, open, missing)
|
||||
wrapper!.unmount()
|
||||
wrapper = null
|
||||
hold.resolve('found')
|
||||
await following
|
||||
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
expect(missing).not.toHaveBeenCalled()
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
it('reports an unknown value once and drops it from the URL', async () => {
|
||||
await router.push('/authors?edit=nobody&q=x')
|
||||
const view = inComponent(() => useDirectoryView())
|
||||
const missing = vi.fn()
|
||||
|
||||
await view.followEditLink(() => null, vi.fn(), missing)
|
||||
await flushPromises()
|
||||
|
||||
expect(missing).toHaveBeenCalledTimes(1)
|
||||
expect(router.currentRoute.value.query).toEqual({ q: 'x' })
|
||||
await router.push('/login')
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,12 @@ export class NetworkError extends Error {
|
||||
|
||||
export type UserRole = 'owner' | 'admin'
|
||||
|
||||
export interface MeAuthor {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface MeResult {
|
||||
id?: number
|
||||
verified: boolean
|
||||
@@ -30,6 +36,7 @@ export interface MeResult {
|
||||
email?: string
|
||||
displayName: string
|
||||
role?: UserRole
|
||||
author?: MeAuthor
|
||||
}
|
||||
|
||||
export type OnUnauthorized = 'redirect' | 'passthrough'
|
||||
@@ -129,7 +136,7 @@ export function useApi() {
|
||||
|
||||
if (response.status === 403) {
|
||||
redirectUnverified(onForbidden)
|
||||
throw new ApiError(403)
|
||||
throw new ApiError(403, undefined, await readErrorBody(response))
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
@@ -165,6 +172,7 @@ export function useApi() {
|
||||
email?: string
|
||||
display_name?: string | null
|
||||
role?: string
|
||||
author?: MeAuthor | null
|
||||
}>('/v1/auth/me', { onUnauthorized: 'passthrough' })
|
||||
return {
|
||||
id: body.id,
|
||||
@@ -173,6 +181,7 @@ export function useApi() {
|
||||
email: body.email,
|
||||
displayName: body.display_name ?? '',
|
||||
role: body.role as UserRole | undefined,
|
||||
...(body.author ? { author: body.author } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useUsers } from '@/composables/useUsers'
|
||||
import { reassignQuery } from '@/lib/directory'
|
||||
import { jsonInit } from '@/lib/directoryRequest'
|
||||
import type { LinkableUser } from '@/lib/directoryTypes'
|
||||
import type { SocialType } from '@/lib/socialLinks'
|
||||
|
||||
export interface SocialLink {
|
||||
type: SocialType
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface Author {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
bio: string
|
||||
avatar_id: number | null
|
||||
website: string | null
|
||||
social_links: SocialLink[]
|
||||
user_id: number | null
|
||||
is_linked: boolean
|
||||
article_count: number
|
||||
user_email?: string
|
||||
}
|
||||
|
||||
export interface AuthorPage {
|
||||
items: Author[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface AuthorListQuery {
|
||||
q: string
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface NewAuthor {
|
||||
name: string
|
||||
slug: string
|
||||
bio: string
|
||||
avatar_id: number | null
|
||||
website: string | null
|
||||
social_links: SocialLink[]
|
||||
user_id: number | null
|
||||
}
|
||||
|
||||
export type AuthorPatch = Partial<NewAuthor>
|
||||
|
||||
export type ReassignTarget = number | 'none' | undefined
|
||||
|
||||
const OPTION_PAGE_SIZE = 100
|
||||
|
||||
export function authorListPath(query: AuthorListQuery) {
|
||||
const parts = [
|
||||
...(query.q ? [`q=${encodeURIComponent(query.q)}`] : []),
|
||||
`page=${query.page}`,
|
||||
`size=${query.size}`,
|
||||
]
|
||||
return `/v1/author/?${parts.join('&')}`
|
||||
}
|
||||
|
||||
export function useAuthors() {
|
||||
const { request } = useApi()
|
||||
const { listLinkableUsers: listUsersWithoutAuthor } = useUsers()
|
||||
|
||||
function listAuthors(query: AuthorListQuery): Promise<AuthorPage> {
|
||||
return request<AuthorPage>(authorListPath(query))
|
||||
}
|
||||
|
||||
async function listAllAuthors(): Promise<Author[]> {
|
||||
const all: Author[] = []
|
||||
let page = 1
|
||||
let total = 0
|
||||
do {
|
||||
const response = await listAuthors({ q: '', page, size: OPTION_PAGE_SIZE })
|
||||
all.push(...response.items)
|
||||
total = response.total
|
||||
page += 1
|
||||
if (response.items.length === 0) {
|
||||
break
|
||||
}
|
||||
} while (all.length < total)
|
||||
return all
|
||||
}
|
||||
|
||||
async function listLinkableUsers(): Promise<LinkableUser[]> {
|
||||
const authors = await listAllAuthors()
|
||||
return listUsersWithoutAuthor(new Set(authors.map((author) => author.user_id)))
|
||||
}
|
||||
|
||||
function createAuthor(author: NewAuthor): Promise<Author> {
|
||||
return request<Author>('/v1/author/', jsonInit('POST', author))
|
||||
}
|
||||
|
||||
function updateAuthor(id: number, patch: AuthorPatch): Promise<Author> {
|
||||
return request<Author>(`/v1/author/${id}`, jsonInit('PATCH', patch))
|
||||
}
|
||||
|
||||
function deleteAuthor(id: number, reassignTo: ReassignTarget): Promise<void> {
|
||||
return request<void>(`/v1/author/${id}${reassignQuery(reassignTo)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
return {
|
||||
listAuthors,
|
||||
listAllAuthors,
|
||||
listLinkableUsers,
|
||||
createAuthor,
|
||||
updateAuthor,
|
||||
deleteAuthor,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
export const SKELETON_DELAY_MS = 150
|
||||
|
||||
export function useDelayedSkeleton(delay = SKELETON_DELAY_MS) {
|
||||
const visible = ref(false)
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function stop() {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function start() {
|
||||
stop()
|
||||
timer = setTimeout(() => {
|
||||
visible.value = true
|
||||
}, delay)
|
||||
}
|
||||
|
||||
onBeforeUnmount(stop)
|
||||
|
||||
return { visible, start, stop }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { onBeforeUnmount, ref, type Ref } from 'vue'
|
||||
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
|
||||
import { isSessionError, statusOf } from '@/lib/directoryErrors'
|
||||
|
||||
export type CollectionState = 'loading' | 'ready' | 'error' | 'forbidden'
|
||||
|
||||
export interface LoadOptions<T> {
|
||||
accept?: (result: T) => boolean
|
||||
}
|
||||
|
||||
export function useDirectoryCollection<T, A extends unknown[]>(
|
||||
fetcher: (...args: A) => Promise<T>,
|
||||
) {
|
||||
const state = ref<CollectionState>('loading')
|
||||
const data = ref<T | null>(null) as Ref<T | null>
|
||||
const skeleton = useDelayedSkeleton()
|
||||
let ticket = 0
|
||||
let alive = true
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
alive = false
|
||||
})
|
||||
|
||||
async function load(args: A, options: LoadOptions<T> = {}) {
|
||||
ticket += 1
|
||||
const current = ticket
|
||||
const first = data.value === null
|
||||
if (first) {
|
||||
state.value = 'loading'
|
||||
skeleton.start()
|
||||
}
|
||||
try {
|
||||
const result = await fetcher(...args)
|
||||
if (!alive || current !== ticket) {
|
||||
return 'stale' as const
|
||||
}
|
||||
if (options.accept && !options.accept(result)) {
|
||||
return 'rejected' as const
|
||||
}
|
||||
data.value = result
|
||||
state.value = 'ready'
|
||||
return 'ready' as const
|
||||
} catch (error) {
|
||||
if (!alive || current !== ticket || isSessionError(error)) {
|
||||
return 'stale' as const
|
||||
}
|
||||
data.value = null
|
||||
state.value = statusOf(error) === 403 ? 'forbidden' : 'error'
|
||||
return 'failed' as const
|
||||
} finally {
|
||||
if (alive && current === ticket) {
|
||||
skeleton.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { state, data, showSkeleton: skeleton.visible, load }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
|
||||
export function useDirectoryDelete<T>() {
|
||||
const target = ref<T | null>(null) as Ref<T | null>
|
||||
const pending = ref(false)
|
||||
const notice = ref<DirectoryNotice | null>(null)
|
||||
|
||||
function open(item: T) {
|
||||
notice.value = null
|
||||
target.value = item
|
||||
}
|
||||
|
||||
function close() {
|
||||
target.value = null
|
||||
notice.value = null
|
||||
}
|
||||
|
||||
async function confirm(
|
||||
run: (item: T) => Promise<void>,
|
||||
onFailure: (error: unknown, item: T) => Promise<void> | void,
|
||||
) {
|
||||
const item = target.value
|
||||
if (!item || pending.value) {
|
||||
return
|
||||
}
|
||||
pending.value = true
|
||||
notice.value = null
|
||||
try {
|
||||
await run(item)
|
||||
} catch (error) {
|
||||
await onFailure(error, item)
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { target, pending, notice, open, close, confirm }
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { t } from '@/i18n'
|
||||
import { copy } from '@/lib/directoryCopy'
|
||||
import type { FailureContext, FailureKind } from '@/lib/directoryErrors'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
|
||||
type ToastColor = 'success' | 'info' | 'error'
|
||||
|
||||
const ICONS: Record<ToastColor, string> = {
|
||||
success: 'i-lucide-circle-check',
|
||||
info: 'i-lucide-info',
|
||||
error: 'i-lucide-circle-x',
|
||||
}
|
||||
|
||||
export function useDirectoryToasts() {
|
||||
const toast = useLocalizedToast()
|
||||
|
||||
function show(id: string, color: ToastColor, title: () => string, description?: () => string) {
|
||||
toast.add(() => ({
|
||||
id,
|
||||
color,
|
||||
icon: ICONS[color],
|
||||
get title() {
|
||||
return title()
|
||||
},
|
||||
get description() {
|
||||
return description?.()
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function siteGone() {
|
||||
show(
|
||||
'site-gone',
|
||||
'info',
|
||||
() => t('That site no longer exists'),
|
||||
() => t('It was deleted somewhere else, so we removed it from the list.'),
|
||||
)
|
||||
}
|
||||
|
||||
function authorGone() {
|
||||
show(
|
||||
'author-gone',
|
||||
'info',
|
||||
() => t('That author no longer exists'),
|
||||
() => t('It was deleted somewhere else, so we removed it from the list.'),
|
||||
)
|
||||
}
|
||||
|
||||
function ownerOnly() {
|
||||
show(
|
||||
'site-owner-only',
|
||||
'error',
|
||||
() => t('Only the owner can manage sites'),
|
||||
() => t('Ask the owner of this site.'),
|
||||
)
|
||||
}
|
||||
|
||||
function defaultSiteBlocked() {
|
||||
show('site-default-blocked', 'error', () => copy('defaultSiteBlocked'))
|
||||
}
|
||||
|
||||
function authorRights() {
|
||||
show(
|
||||
'author-rights',
|
||||
'error',
|
||||
() => t('Only an owner or admin can change authors'),
|
||||
() => t('You can still edit your own author profile.'),
|
||||
)
|
||||
}
|
||||
|
||||
function siteDefaultChanged(name: string) {
|
||||
show(
|
||||
'site-default-changed',
|
||||
'success',
|
||||
() => t('Default site changed'),
|
||||
() => t('"{0}" is now the default site.', [name]),
|
||||
)
|
||||
}
|
||||
|
||||
function siteDeleted(count: number, destination: string) {
|
||||
show(
|
||||
'site-deleted',
|
||||
'success',
|
||||
() => t('Site deleted'),
|
||||
count > 0
|
||||
? () =>
|
||||
count === 1
|
||||
? t('1 article moved to {0}.', [destination])
|
||||
: t('{0} articles moved to {1}.', [count, destination])
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
|
||||
function authorDeleted(count: number, destination: string | null) {
|
||||
show(
|
||||
'author-deleted',
|
||||
'success',
|
||||
() => t('Author deleted'),
|
||||
count > 0
|
||||
? () => {
|
||||
if (destination === null) {
|
||||
return count === 1
|
||||
? t('1 article no longer has a byline.')
|
||||
: t('{0} articles no longer have a byline.', [count])
|
||||
}
|
||||
return count === 1
|
||||
? t('1 article now credits {0}.', [destination])
|
||||
: t('{0} articles now credit {1}.', [count, destination])
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
|
||||
function userUnlinked(name: string) {
|
||||
show(
|
||||
'author-unlinked',
|
||||
'success',
|
||||
() => t('User unlinked'),
|
||||
() => t('{0} is now a guest author.', [name]),
|
||||
)
|
||||
}
|
||||
|
||||
function report(kind: FailureKind, context: FailureContext) {
|
||||
const handlers: Partial<Record<FailureKind, () => void>> = {
|
||||
forbidden: ownerOnly,
|
||||
defaultSite: defaultSiteBlocked,
|
||||
rights: authorRights,
|
||||
gone: context === 'site' ? siteGone : authorGone,
|
||||
}
|
||||
const handler = handlers[kind]
|
||||
handler?.()
|
||||
return handler !== undefined
|
||||
}
|
||||
|
||||
return {
|
||||
report,
|
||||
siteGone,
|
||||
authorGone,
|
||||
ownerOnly,
|
||||
defaultSiteBlocked,
|
||||
authorRights,
|
||||
siteDefaultChanged,
|
||||
siteDeleted,
|
||||
authorDeleted,
|
||||
userUnlinked,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { nextTick, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
export function useDirectoryView() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
let opener: HTMLElement | null = null
|
||||
let followed = ''
|
||||
let ticket = 0
|
||||
let alive = true
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
alive = false
|
||||
})
|
||||
|
||||
function rememberOpener(element?: HTMLElement | null) {
|
||||
opener =
|
||||
element ??
|
||||
(document.activeElement instanceof HTMLElement ? document.activeElement : null)
|
||||
}
|
||||
|
||||
async function restoreFocus() {
|
||||
await nextTick()
|
||||
if (opener?.isConnected) {
|
||||
opener.focus()
|
||||
}
|
||||
opener = null
|
||||
}
|
||||
|
||||
function editParam() {
|
||||
const value = route.query.edit
|
||||
return typeof value === 'string' ? value : Array.isArray(value) ? (value[0] ?? '') : ''
|
||||
}
|
||||
|
||||
function dropEditParam() {
|
||||
followed = ''
|
||||
if (route.query.edit !== undefined) {
|
||||
router.replace({
|
||||
query: Object.fromEntries(
|
||||
Object.entries(route.query).filter(([key]) => key !== 'edit'),
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function leavePanel() {
|
||||
dropEditParam()
|
||||
return restoreFocus()
|
||||
}
|
||||
|
||||
async function followEditLink<T>(
|
||||
lookup: (value: string) => T | null | Promise<T | null>,
|
||||
open: (item: T) => void,
|
||||
missing: () => void,
|
||||
) {
|
||||
const value = editParam()
|
||||
if (!value || followed === value) {
|
||||
return
|
||||
}
|
||||
followed = value
|
||||
ticket += 1
|
||||
const current = ticket
|
||||
const item = await lookup(value)
|
||||
if (!alive || current !== ticket) {
|
||||
return
|
||||
}
|
||||
if (item === null) {
|
||||
missing()
|
||||
dropEditParam()
|
||||
return
|
||||
}
|
||||
open(item)
|
||||
}
|
||||
|
||||
return { rememberOpener, restoreFocus, editParam, dropEditParam, leavePanel, followEditLink }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { jsonInit } from '@/lib/directoryRequest'
|
||||
import { reassignQuery } from '@/lib/directory'
|
||||
|
||||
export type SiteRobots = 'index' | 'noindex'
|
||||
|
||||
export interface Site {
|
||||
id: number
|
||||
name: string
|
||||
key: string
|
||||
base_url: string
|
||||
default_social_image_id: number | null
|
||||
default_robots: SiteRobots
|
||||
locale: string | null
|
||||
is_default: boolean
|
||||
article_count: number
|
||||
}
|
||||
|
||||
export interface NewSite {
|
||||
name: string
|
||||
key: string
|
||||
base_url: string
|
||||
default_robots: SiteRobots
|
||||
locale?: string
|
||||
default_social_image_id?: number
|
||||
is_default: boolean
|
||||
}
|
||||
|
||||
export interface SitePatch {
|
||||
name?: string
|
||||
base_url?: string
|
||||
default_robots?: SiteRobots
|
||||
locale?: string | null
|
||||
default_social_image_id?: number | null
|
||||
}
|
||||
|
||||
export function useSites() {
|
||||
const { request } = useApi()
|
||||
|
||||
function listSites(): Promise<Site[]> {
|
||||
return request<Site[]>('/v1/site/')
|
||||
}
|
||||
|
||||
function createSite(site: NewSite): Promise<Site> {
|
||||
return request<Site>('/v1/site/', jsonInit('POST', site))
|
||||
}
|
||||
|
||||
function updateSite(id: number, patch: SitePatch): Promise<Site> {
|
||||
return request<Site>(`/v1/site/${id}`, jsonInit('PATCH', patch))
|
||||
}
|
||||
|
||||
function setDefaultSite(id: number): Promise<Site> {
|
||||
return request<Site>(`/v1/site/${id}/default`, { method: 'PATCH' })
|
||||
}
|
||||
|
||||
function deleteSite(id: number, reassignTo?: number): Promise<void> {
|
||||
return request<void>(`/v1/site/${id}${reassignQuery(reassignTo)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
return { listSites, createSite, updateSite, setDefaultSite, deleteSite }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { slugify } from '@/lib/directory'
|
||||
|
||||
export type SlugMode = 'empty' | 'auto' | 'manual' | 'saved'
|
||||
|
||||
export function useSlugField(max: number, saved?: string) {
|
||||
const source = ref('')
|
||||
const override = ref<string | null>(saved ?? null)
|
||||
const isSaved = saved !== undefined
|
||||
|
||||
const value = computed(() => override.value ?? slugify(source.value, max))
|
||||
const mode = computed<SlugMode>(() => {
|
||||
if (override.value !== null) {
|
||||
return isSaved && override.value === saved ? 'saved' : 'manual'
|
||||
}
|
||||
return source.value.trim() ? 'auto' : 'empty'
|
||||
})
|
||||
|
||||
function setSource(text: string) {
|
||||
source.value = text
|
||||
}
|
||||
|
||||
function unlock() {
|
||||
override.value = value.value
|
||||
}
|
||||
|
||||
function setValue(next: string) {
|
||||
override.value = next
|
||||
}
|
||||
|
||||
function reset() {
|
||||
override.value = isSaved ? slugify(source.value, max) : null
|
||||
}
|
||||
|
||||
return { source, value, mode, setSource, unlock, setValue, reset }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { LinkableUser } from '@/lib/directoryTypes'
|
||||
|
||||
export type UserListItem =
|
||||
| {
|
||||
@@ -50,6 +51,17 @@ export function useUsers() {
|
||||
return request<UserListItem[]>('/v1/user/')
|
||||
}
|
||||
|
||||
async function listLinkableUsers(
|
||||
takenUserIds: ReadonlySet<number | null>,
|
||||
): Promise<LinkableUser[]> {
|
||||
const users = await listUsers()
|
||||
return users.flatMap((user) =>
|
||||
user.kind === 'user' && !takenUserIds.has(user.id)
|
||||
? [{ id: user.id, label: user.display_name || user.username, email: user.email }]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
function inviteUser(email: string): Promise<CreatedInvite> {
|
||||
return request<CreatedInvite>('/v1/user/invite', {
|
||||
method: 'POST',
|
||||
@@ -66,5 +78,5 @@ export function useUsers() {
|
||||
return request<CreatedInvite>(`/v1/user/invite/${id}/renew`, { method: 'POST' })
|
||||
}
|
||||
|
||||
return { listUsers, inviteUser, cancelInvite, renewInvite }
|
||||
return { listUsers, listLinkableUsers, inviteUser, cancelInvite, renewInvite }
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { COPY } from '@/lib/directoryCopy'
|
||||
import { messages } from './messages'
|
||||
|
||||
const SOURCES = [
|
||||
'components/AuthorAvatar.vue',
|
||||
'components/AuthorFormPanel.vue',
|
||||
'components/AuthorViewPanel.vue',
|
||||
'components/DashboardShell.vue',
|
||||
'components/DirectoryBadge.vue',
|
||||
'components/DirectoryDialog.vue',
|
||||
'components/DirectoryField.vue',
|
||||
'components/DirectoryList.vue',
|
||||
'components/DirectoryPanel.vue',
|
||||
'components/DirectoryRowMenu.vue',
|
||||
'components/LinkUserDialog.vue',
|
||||
'components/MediaField.vue',
|
||||
'components/PublishingLinksCard.vue',
|
||||
'components/ReassignDialog.vue',
|
||||
'components/SiteFormPanel.vue',
|
||||
'components/SitesForbidden.vue',
|
||||
'components/SlugField.vue',
|
||||
'components/SocialLinksRepeater.vue',
|
||||
'components/UserSelect.vue',
|
||||
'composables/useDirectoryToasts.ts',
|
||||
'composables/useDirectoryView.ts',
|
||||
'lib/directory.ts',
|
||||
'lib/directoryCopy.ts',
|
||||
'lib/directoryRules.ts',
|
||||
'views/AuthorsView.vue',
|
||||
'views/SitesView.vue',
|
||||
]
|
||||
|
||||
const SAME_IN_FRENCH = new Set([
|
||||
'Articles',
|
||||
'Auto',
|
||||
'Cache',
|
||||
'Avatar',
|
||||
'Bio',
|
||||
'Noindex',
|
||||
'Site',
|
||||
'Sites',
|
||||
'Guest',
|
||||
'Linked',
|
||||
])
|
||||
|
||||
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
|
||||
|
||||
function keysIn(source: string) {
|
||||
return [...source.matchAll(LITERAL)].map((match) =>
|
||||
(match[1] ?? match[2] ?? '').replace(/\\(['"\\])/g, '$1'),
|
||||
)
|
||||
}
|
||||
|
||||
describe('Sites and authors messages', () => {
|
||||
const keys = SOURCES.flatMap((file) =>
|
||||
keysIn(readFileSync(join(import.meta.dirname, '..', file), 'utf8')).map((key) => ({
|
||||
file,
|
||||
key,
|
||||
})),
|
||||
)
|
||||
|
||||
it('finds the strings of both screens', () => {
|
||||
expect(keys.length).toBeGreaterThan(100)
|
||||
})
|
||||
|
||||
it.each(keys.map(({ file, key }) => [file, key]))(
|
||||
'%s: "%s" has its own French entry',
|
||||
(_file, key) => {
|
||||
const french = (messages as Record<string, string>)[key as string]
|
||||
|
||||
expect(french).toMatch(/\S/)
|
||||
if (!SAME_IN_FRENCH.has(key as string)) {
|
||||
expect(french).not.toBe(key)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it.each(Object.values(COPY))('shared copy "%s" has its own French entry', (key) => {
|
||||
const french = (messages as Record<string, string>)[key]
|
||||
|
||||
expect(french).toBeTruthy()
|
||||
expect(french).not.toBe(key)
|
||||
})
|
||||
})
|
||||
@@ -1685,6 +1685,228 @@ export const messages = {
|
||||
'Get settings.': 'Récupère les paramètres.',
|
||||
'Made for the browser. A key is ignored here, with no 403.':
|
||||
'Prévue pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||
'1 article moved to {0}.': '1 article déplacé vers {0}.',
|
||||
'1 article no longer has a byline.': '1 article n’a plus de signature.',
|
||||
'1 article now credits {0}.': '1 article est maintenant attribué à {0}.',
|
||||
'1 article will move to {0}. Their canonical host changes to {1}.':
|
||||
'1 article sera déplacé vers {0}. Son hôte canonique devient {1}.',
|
||||
'A language tag. Leave empty if the site has no single language.':
|
||||
'Une balise de langue. Laissez vide si le site n’a pas de langue unique.',
|
||||
'A sentence or two about the author.': 'Une phrase ou deux sur l’auteur.',
|
||||
'A site with the key "{0}" already exists. Choose another key.':
|
||||
'Un site avec la clé « {0} » existe déjà. Choisissez une autre clé.',
|
||||
'Add another site if your content is shown on more than one frontend. Each article belongs to one site.':
|
||||
'Ajoutez un autre site si votre contenu est affiché sur plusieurs frontends. Chaque article appartient à un seul site.',
|
||||
'Add author': 'Ajouter un auteur',
|
||||
'Add link': 'Ajouter un lien',
|
||||
'Add site': 'Ajouter un site',
|
||||
'Ask the owner of this site.': 'Demandez au propriétaire de ce site.',
|
||||
"Asks search engines not to list this site's pages.":
|
||||
'Demande aux moteurs de recherche de ne pas référencer les pages de ce site.',
|
||||
Author: 'Auteur',
|
||||
'Author deleted': 'Auteur supprimé',
|
||||
'Author name': 'Nom de l’auteur',
|
||||
Authors: 'Auteurs',
|
||||
"Authors are the names readers see on articles. They don't need an account.":
|
||||
'Les auteurs sont les noms que les lecteurs voient sur les articles. Ils n’ont pas besoin de compte.',
|
||||
Auto: 'Auto',
|
||||
Avatar: 'Avatar',
|
||||
'Back to Settings': 'Retour aux paramètres',
|
||||
'Base URLs, default image and robots policy':
|
||||
'URL de base, image par défaut et politique des robots',
|
||||
Bio: 'Bio',
|
||||
'Byline preview': 'Aperçu de la signature',
|
||||
"Can't delete this author": 'Impossible de supprimer cet auteur',
|
||||
'Canonical URL preview': 'Aperçu de l’URL canonique',
|
||||
'Change image': 'Changer d’image',
|
||||
'Check the spelling, or search for a shorter part of the name.':
|
||||
'Vérifiez l’orthographe, ou cherchez une partie plus courte du nom.',
|
||||
'Choose a user': 'Choisir un utilisateur',
|
||||
'Choose an image': 'Choisir une image',
|
||||
'Choose image': 'Choisir une image',
|
||||
'Choose where the articles go. This site has {0} articles.':
|
||||
'Choisissez où vont les articles. Ce site compte {0} articles.',
|
||||
"Couldn't delete the author": 'Impossible de supprimer l’auteur',
|
||||
"Couldn't delete the site": 'Impossible de supprimer le site',
|
||||
"Couldn't load authors": 'Impossible de charger les auteurs',
|
||||
"Couldn't load sites": 'Impossible de charger les sites',
|
||||
"Couldn't save the author": 'Impossible d’enregistrer l’auteur',
|
||||
"Couldn't save the author. Your changes are still here. Try again in a moment.":
|
||||
'Impossible d’enregistrer l’auteur. Vos modifications sont toujours là. Réessayez dans un instant.',
|
||||
"Couldn't save the site. Your changes are still here. Try again in a moment.":
|
||||
'Impossible d’enregistrer le site. Vos modifications sont toujours là. Réessayez dans un instant.',
|
||||
'Credit these articles to': 'Attribuer ces articles à',
|
||||
Default: 'Par défaut',
|
||||
'Default robots policy': 'Politique des robots par défaut',
|
||||
'Default site': 'Site par défaut',
|
||||
'Default site changed': 'Site par défaut modifié',
|
||||
'Default social image': 'Image sociale par défaut',
|
||||
'Delete and reassign 1 article': 'Supprimer et réattribuer 1 article',
|
||||
'Delete and reassign {0} articles': 'Supprimer et réattribuer {0} articles',
|
||||
'Delete author': 'Supprimer l’auteur',
|
||||
'Delete site': 'Supprimer le site',
|
||||
'Delete this author?': 'Supprimer cet auteur ?',
|
||||
'Delete this site?': 'Supprimer ce site ?',
|
||||
'Edit key': 'Modifier la clé',
|
||||
'Edit slug': 'Modifier l’identifiant',
|
||||
'Edit {0}': 'Modifier {0}',
|
||||
'Enter a full URL starting with https://.':
|
||||
'Saisissez une URL complète commençant par https://.',
|
||||
'Enter a full URL, like https://blog.example.com.':
|
||||
'Saisissez une URL complète, comme https://blog.example.com.',
|
||||
'Every user already has an author.': 'Chaque utilisateur a déjà un auteur.',
|
||||
'Generated from the name': 'Généré à partir du nom',
|
||||
'Generated from the name. Used in public author URLs and API paths. Unique across all authors.':
|
||||
'Généré à partir du nom. Utilisé dans les URL publiques des auteurs et les chemins d’API. Unique parmi tous les auteurs.',
|
||||
Guest: 'Invité',
|
||||
'Image #{0}': 'Image nº {0}',
|
||||
Index: 'Indexer',
|
||||
'Link URL {0}': 'URL du lien {0}',
|
||||
'Link a CMS user if this author has an account. Guest writers need no link.':
|
||||
'Liez un utilisateur du CMS si cet auteur a un compte. Les auteurs invités n’ont pas besoin de lien.',
|
||||
'Link type {0}': 'Type du lien {0}',
|
||||
'Link user': 'Lier un utilisateur',
|
||||
'Link {0} to a user?': 'Lier {0} à un utilisateur ?',
|
||||
Linked: 'Lié',
|
||||
"Linked to {0}. The user account isn't affected.":
|
||||
'Lié à {0}. Le compte utilisateur n’est pas modifié.',
|
||||
'Linked user': 'Utilisateur lié',
|
||||
Locale: 'Langue',
|
||||
'Lowercase letters, numbers and hyphens. Unique across all authors.':
|
||||
'Lettres minuscules, chiffres et traits d’union. Unique parmi tous les auteurs.',
|
||||
"Lowercase letters, numbers and hyphens. Used in API paths. It can't be changed after the site is added.":
|
||||
'Lettres minuscules, chiffres et traits d’union. Utilisée dans les chemins d’API. Elle ne peut plus être modifiée après l’ajout du site.',
|
||||
'Make default': 'Définir par défaut',
|
||||
'Make this the default site': 'Faire de ce site le site par défaut',
|
||||
'Make {0} the default site?': 'Faire de {0} le site par défaut ?',
|
||||
'Manage sites': 'Gérer les sites',
|
||||
'Move 1 article and delete': 'Déplacer 1 article et supprimer',
|
||||
'Move articles to': 'Déplacer les articles vers',
|
||||
'Move {0} articles and delete': 'Déplacer {0} articles et supprimer',
|
||||
'Moving articles…': 'Déplacement des articles…',
|
||||
'New articles by this user start with this author.':
|
||||
'Les nouveaux articles de cet utilisateur commencent avec cet auteur.',
|
||||
'New articles start here. The current default is {0}.':
|
||||
'Les nouveaux articles commencent ici. Le site par défaut actuel est {0}.',
|
||||
'New articles start on the default site. To change it, make another site the default.':
|
||||
'Les nouveaux articles commencent sur le site par défaut. Pour le changer, définissez un autre site par défaut.',
|
||||
'New articles start on this site. Articles that are already on other sites stay where they are.':
|
||||
'Les nouveaux articles commencent sur ce site. Les articles déjà présents sur d’autres sites y restent.',
|
||||
'No articles': 'Aucun article',
|
||||
'No authors match "{0}"': 'Aucun auteur ne correspond à « {0} »',
|
||||
'No authors yet': 'Aucun auteur pour l’instant',
|
||||
'No linked user': 'Aucun utilisateur lié',
|
||||
Nobody: 'Personne',
|
||||
Noindex: 'Noindex',
|
||||
'Nothing was changed. Try again in a moment.':
|
||||
'Rien n’a été modifié. Réessayez dans un instant.',
|
||||
'Only an owner or admin can change authors':
|
||||
'Seul un propriétaire ou un administrateur peut modifier les auteurs',
|
||||
'Only an owner or admin can change this author.':
|
||||
'Seul un propriétaire ou un administrateur peut modifier cet auteur.',
|
||||
'Only the owner can manage sites': 'Seul le propriétaire peut gérer les sites',
|
||||
'Only the owner can manage sites. Ask the owner of this site if you need a change.':
|
||||
'Seul le propriétaire peut gérer les sites. Demandez au propriétaire de ce site si vous avez besoin d’un changement.',
|
||||
'Only users without an author are listed.': 'Seuls les utilisateurs sans auteur sont listés.',
|
||||
Publishing: 'Publication',
|
||||
'Reassign articles': 'Réattribuer les articles',
|
||||
'Reassigning…': 'Réattribution…',
|
||||
'Remove link {0}': 'Supprimer le lien {0}',
|
||||
'Remove the byline': 'Supprimer la signature',
|
||||
'Remove the path. Use only the host, like https://blog.example.com.':
|
||||
'Retirez le chemin. Utilisez seulement l’hôte, comme https://blog.example.com.',
|
||||
'Reset to match the name': 'Rétablir selon le nom',
|
||||
'Search by name or slug': 'Rechercher par nom ou identifiant',
|
||||
"Search engines may list this site's pages.":
|
||||
'Les moteurs de recherche peuvent référencer les pages de ce site.',
|
||||
'Set as default': 'Définir par défaut',
|
||||
'Shorten the bio to 280 characters.': 'Raccourcissez la bio à 280 caractères.',
|
||||
'Shown as a square. Choose an image with the face or logo near the middle.':
|
||||
'Affichée en carré. Choisissez une image dont le visage ou le logo est près du centre.',
|
||||
'Shown when the base URL is valid.': 'Affiché lorsque l’URL de base est valide.',
|
||||
Site: 'Site',
|
||||
'Site deleted': 'Site supprimé',
|
||||
Sites: 'Sites',
|
||||
Slug: 'Identifiant',
|
||||
'Social links': 'Liens sociaux',
|
||||
'That author no longer exists': 'Cet auteur n’existe plus',
|
||||
'That site no longer exists': 'Ce site n’existe plus',
|
||||
'That user already has an author. Choose another user.':
|
||||
'Cet utilisateur a déjà un auteur. Choisissez un autre utilisateur.',
|
||||
'The articles keep any other authors. Articles with only this author will have no byline.':
|
||||
'Les articles conservent leurs autres auteurs. Les articles n’ayant que cet auteur n’auront plus de signature.',
|
||||
'The avatar for {0}.': 'L’avatar de {0}.',
|
||||
'The bio appears here.': 'La bio apparaît ici.',
|
||||
"The default site can't be deleted. Make another site the default first.":
|
||||
'Le site par défaut ne peut pas être supprimé. Définissez d’abord un autre site par défaut.',
|
||||
'The default social image for {0}.': 'L’image sociale par défaut de {0}.',
|
||||
'The frontends that display your content.': 'Les frontends qui affichent votre contenu.',
|
||||
"The names readers see on your articles. An author doesn't need an account.":
|
||||
'Les noms que les lecteurs voient sur vos articles. Un auteur n’a pas besoin de compte.',
|
||||
'The names readers see on your articles. Only an owner or admin can change authors.':
|
||||
'Les noms que les lecteurs voient sur vos articles. Seul un propriétaire ou un administrateur peut modifier les auteurs.',
|
||||
'The public address of the frontend. Use https and no path.':
|
||||
'L’adresse publique du frontend. Utilisez https et aucun chemin.',
|
||||
'The slug "{0}" is already used by another author.':
|
||||
'L’identifiant « {0} » est déjà utilisé par un autre auteur.',
|
||||
'This is your author profile. Only an owner or admin can change the link.':
|
||||
'Ceci est votre profil d’auteur. Seul un propriétaire ou un administrateur peut modifier le lien.',
|
||||
Unlink: 'Dissocier',
|
||||
'Unlink user': 'Dissocier l’utilisateur',
|
||||
'Unlink {0}?': 'Dissocier {0} ?',
|
||||
'Unlinking…': 'Dissociation…',
|
||||
'Use a language tag like en or fr-CH.': 'Utilisez une balise de langue comme en ou fr-CH.',
|
||||
'Use https. For example https://blog.example.com.':
|
||||
'Utilisez https. Par exemple https://blog.example.com.',
|
||||
'Use image': 'Utiliser l’image',
|
||||
'Use lowercase letters, numbers and hyphens, 2 to 32 characters.':
|
||||
'Utilisez des lettres minuscules, des chiffres et des traits d’union, de 2 à 32 caractères.',
|
||||
'Use {0}': 'Utiliser {0}',
|
||||
"Used in API paths, so it can't change.":
|
||||
'Utilisée dans les chemins d’API, elle ne peut donc pas changer.',
|
||||
'Used when an article has no image of its own.':
|
||||
'Utilisée lorsqu’un article n’a pas d’image propre.',
|
||||
'User unlinked': 'Utilisateur dissocié',
|
||||
'Users without an author': 'Utilisateurs sans auteur',
|
||||
'View {0}': 'Voir {0}',
|
||||
Website: 'Site web',
|
||||
'Where an article has several authors, {0} is removed and the others stay.':
|
||||
'Lorsqu’un article a plusieurs auteurs, {0} est retiré et les autres restent.',
|
||||
'Where your content is shown.': 'Où votre contenu est affiché.',
|
||||
'You can add up to {0} links.': 'Vous pouvez ajouter jusqu’à {0} liens.',
|
||||
'You can still edit your own author profile.':
|
||||
'Vous pouvez toujours modifier votre propre profil d’auteur.',
|
||||
"You don't have access to Sites": 'Vous n’avez pas accès aux sites',
|
||||
'"{0}" is credited on 1 article. Choose another author to credit instead, or remove {0} from those articles first.':
|
||||
'« {0} » est attribué à 1 article. Choisissez un autre auteur à lui attribuer, ou retirez {0} de cet article d’abord.',
|
||||
'"{0}" is credited on {1} articles. Choose another author to credit instead, or remove {0} from those articles first.':
|
||||
'« {0} » est attribué à {1} articles. Choisissez un autre auteur à leur attribuer, ou retirez {0} de ces articles d’abord.',
|
||||
'"{0}" is now the default site.': '« {0} » est maintenant le site par défaut.',
|
||||
'"{0}" will be deleted and is credited on 1 article. This can\'t be undone.':
|
||||
'« {0} » sera supprimé et est attribué à 1 article. Cette action est irréversible.',
|
||||
'"{0}" will be deleted and is credited on {1} articles. This can\'t be undone.':
|
||||
'« {0} » sera supprimé et est attribué à {1} articles. Cette action est irréversible.',
|
||||
'"{0}" will be deleted. 1 article belongs to it, so choose a site to move them to. This can\'t be undone.':
|
||||
'« {0} » sera supprimé. 1 article lui appartient, choisissez donc un site vers lequel le déplacer. Cette action est irréversible.',
|
||||
'"{0}" will be deleted. No articles belong to it. This can\'t be undone.':
|
||||
'« {0} » sera supprimé. Aucun article ne lui appartient. Cette action est irréversible.',
|
||||
'"{0}" will be deleted. No articles credit this author. This can\'t be undone.':
|
||||
'« {0} » sera supprimé. Aucun article n’est attribué à cet auteur. Cette action est irréversible.',
|
||||
'"{0}" will be deleted. {1} articles belong to it, so choose a site to move them to. This can\'t be undone.':
|
||||
'« {0} » sera supprimé. {1} articles lui appartiennent, choisissez donc un site vers lequel les déplacer. Cette action est irréversible.',
|
||||
'e.g. Camille Roche': 'p. ex. Camille Roche',
|
||||
'e.g. Journal': 'p. ex. Journal',
|
||||
'e.g. en or fr-CH': 'p. ex. en ou fr-CH',
|
||||
'this author': 'cet auteur',
|
||||
'this site': 'ce site',
|
||||
'{0} articles moved to {1}.': '{0} articles déplacés vers {1}.',
|
||||
'{0} articles no longer have a byline.': '{0} articles n’ont plus de signature.',
|
||||
'{0} articles now credit {1}.': '{0} articles sont maintenant attribués à {1}.',
|
||||
'{0} articles will move to {1}. Their canonical host changes to {2}.':
|
||||
'{0} articles seront déplacés vers {1}. Leur hôte canonique devient {2}.',
|
||||
'{0} is now a guest author.': '{0} est maintenant un auteur invité.',
|
||||
'{0} will stay on their articles. New articles by {1} will no longer start with this author.':
|
||||
'{0} restera sur ses articles. Les nouveaux articles de {1} ne commenceront plus avec cet auteur.',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AUTHOR_SLUG_MAX,
|
||||
SITE_KEY_MAX,
|
||||
SLUG_PATTERN,
|
||||
siteUrlProblem,
|
||||
slugify,
|
||||
suggestFreeSlug,
|
||||
} from '@/lib/directory'
|
||||
import {
|
||||
parseDirectoryQuery,
|
||||
toDirectoryRouteQuery,
|
||||
type DirectoryQuery,
|
||||
} from '@/lib/directoryQuery'
|
||||
|
||||
const ALPHABET = 'abcXYZ019 -_.,é ñ ß æ Ł 日本語 ! /'
|
||||
const SAMPLES = 400
|
||||
|
||||
function seeded(seed: number) {
|
||||
let state = seed
|
||||
return () => {
|
||||
state = (state * 1664525 + 1013904223) % 4294967296
|
||||
return state / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function randomText(next: () => number, length: number) {
|
||||
return Array.from(
|
||||
{ length },
|
||||
() => [...ALPHABET][Math.floor(next() * [...ALPHABET].length)],
|
||||
).join('')
|
||||
}
|
||||
|
||||
describe('slugify', () => {
|
||||
it('never ends or starts with a dash, matches the slug pattern and respects the maximum', () => {
|
||||
const next = seeded(7)
|
||||
for (let index = 0; index < SAMPLES; index += 1) {
|
||||
const max = 1 + Math.floor(next() * 40)
|
||||
const slug = slugify(randomText(next, Math.floor(next() * 80)), max)
|
||||
|
||||
expect(slug.length).toBeLessThanOrEqual(max)
|
||||
expect(slug === '' || SLUG_PATTERN.test(slug)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('trims the dash left by cutting at the maximum', () => {
|
||||
expect(slugify('Journal Renovation', 8)).toBe('journal')
|
||||
})
|
||||
|
||||
it('is idempotent', () => {
|
||||
const next = seeded(11)
|
||||
for (let index = 0; index < SAMPLES; index += 1) {
|
||||
const once = slugify(randomText(next, 30), AUTHOR_SLUG_MAX)
|
||||
|
||||
expect(slugify(once, AUTHOR_SLUG_MAX)).toBe(once)
|
||||
}
|
||||
})
|
||||
|
||||
it('folds diacritics and ligatures like the server', () => {
|
||||
expect(slugify('Inès Duarte', 100)).toBe('ines-duarte')
|
||||
expect(slugify('Straße Œuvre', 100)).toBe('strasse-oeuvre')
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestFreeSlug', () => {
|
||||
it('returns a free, valid candidate within the maximum for any taken set', () => {
|
||||
const next = seeded(3)
|
||||
for (let index = 0; index < SAMPLES; index += 1) {
|
||||
const max = 6 + Math.floor(next() * 30)
|
||||
const slug = slugify(randomText(next, 20), max) || 'a'
|
||||
const taken = new Set<string>()
|
||||
const rounds = Math.floor(next() * 6)
|
||||
for (let round = 0; round < rounds; round += 1) {
|
||||
taken.add(suggestFreeSlug(slug, (candidate) => taken.has(candidate), max))
|
||||
}
|
||||
const candidate = suggestFreeSlug(slug, (value) => taken.has(value), max)
|
||||
|
||||
expect(taken.has(candidate)).toBe(false)
|
||||
expect(candidate.length).toBeLessThanOrEqual(max)
|
||||
expect(SLUG_PATTERN.test(candidate)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a full-length site key within 32 characters', () => {
|
||||
const key = 'a'.repeat(SITE_KEY_MAX)
|
||||
|
||||
expect(suggestFreeSlug(key, () => false, SITE_KEY_MAX)).toBe(`${'a'.repeat(30)}-2`)
|
||||
})
|
||||
|
||||
it('replaces an existing numeric suffix', () => {
|
||||
expect(suggestFreeSlug('journal-2', (value) => value === 'journal-2', 32)).toBe('journal-3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('siteUrlProblem', () => {
|
||||
it.each([
|
||||
'https://blog.example.com',
|
||||
'https://blog.example.com/',
|
||||
'http://localhost:5173',
|
||||
'http://127.0.0.1:8081',
|
||||
'http://[::1]:5173',
|
||||
'https://localhost',
|
||||
])('accepts %s', (value) => {
|
||||
expect(siteUrlProblem(value)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://user@blog.example.com',
|
||||
'https://user:secret@blog.example.com',
|
||||
'https://:secret@blog.example.com',
|
||||
'http://user@localhost:5173',
|
||||
])('rejects credentials in %s', (value) => {
|
||||
expect(siteUrlProblem(value)).toBe('invalid')
|
||||
})
|
||||
|
||||
it('rejects credentials hidden among random paths and hosts', () => {
|
||||
const next = seeded(5)
|
||||
for (let index = 0; index < SAMPLES; index += 1) {
|
||||
const user = randomText(next, 1 + Math.floor(next() * 5)).replace(/[^a-z0-9]/gi, 'u')
|
||||
const problem = siteUrlProblem(`https://${user || 'u'}@host.example`)
|
||||
|
||||
expect(problem).not.toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['http://blog.example.com', 'scheme'],
|
||||
['https://blog.example.com/en', 'path'],
|
||||
['https://blog.example.com?x=1', 'path'],
|
||||
['blog', 'invalid'],
|
||||
['ftp://blog.example.com', 'invalid'],
|
||||
['', 'empty'],
|
||||
])('classifies %s as %s', (value, problem) => {
|
||||
expect(siteUrlProblem(value)).toBe(problem)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDirectoryQuery', () => {
|
||||
it('survives a round trip through the route query', () => {
|
||||
const next = seeded(13)
|
||||
const sizes = [5, 25, 50, 100, 30, 0, -1]
|
||||
for (let index = 0; index < SAMPLES; index += 1) {
|
||||
const raw: Record<string, string> = {}
|
||||
if (next() > 0.3) {
|
||||
raw.q = randomText(next, Math.floor(next() * 12))
|
||||
}
|
||||
if (next() > 0.3) {
|
||||
raw.page = String(Math.floor(next() * 12) - 2)
|
||||
}
|
||||
if (next() > 0.3) {
|
||||
raw.size = String(sizes[Math.floor(next() * sizes.length)])
|
||||
}
|
||||
const parsed = parseDirectoryQuery(raw)
|
||||
const again = parseDirectoryQuery(toDirectoryRouteQuery(parsed))
|
||||
|
||||
expect(again).toEqual(parsed)
|
||||
expect(parsed.page).toBeGreaterThanOrEqual(1)
|
||||
expect([5, 25, 50, 100]).toContain(parsed.size)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps other parameters such as edit and drops defaults', () => {
|
||||
const parsed: DirectoryQuery = { q: '', page: 1, size: 25 }
|
||||
|
||||
expect(toDirectoryRouteQuery(parsed, { edit: 'camille', page: '9' })).toEqual({
|
||||
edit: 'camille',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { classifyFailure, type FailureContext, type FailureKind } from '@/lib/directoryErrors'
|
||||
|
||||
const CASES: [FailureContext, number, string | undefined, FailureKind][] = [
|
||||
['site', 401, undefined, 'session'],
|
||||
['site', 403, 'default_site', 'defaultSite'],
|
||||
['site', 403, 'Only the owner can do this.', 'forbidden'],
|
||||
['site', 404, 'site_not_found', 'gone'],
|
||||
['site', 409, 'site_key_taken', 'keyTaken'],
|
||||
['site', 409, 'site_has_articles', 'hasArticles'],
|
||||
['site', 400, 'invalid_site_url', 'invalidSiteUrl'],
|
||||
['site', 400, 'invalid_locale', 'invalidLocale'],
|
||||
['site', 500, undefined, 'failed'],
|
||||
['author', 403, 'author_rights', 'rights'],
|
||||
['author', 404, 'author_not_found', 'gone'],
|
||||
['author', 409, 'author_slug_taken', 'slugTaken'],
|
||||
['author', 409, 'user_already_linked', 'userLinked'],
|
||||
['author', 409, 'author_has_articles', 'hasArticles'],
|
||||
['author', 422, 'bio_too_long', 'bioTooLong'],
|
||||
['author', 422, 'invalid_url', 'invalidUrl'],
|
||||
['author', 500, undefined, 'failed'],
|
||||
['author', 409, 'something_else', 'failed'],
|
||||
]
|
||||
|
||||
describe('classifyFailure', () => {
|
||||
it.each(CASES)('%s %s %s is %s', (context, status, code, kind) => {
|
||||
expect(classifyFailure(new ApiError(status, undefined, code), context)).toBe(kind)
|
||||
})
|
||||
|
||||
it('treats a network error as a generic failure', () => {
|
||||
expect(classifyFailure(new Error('offline'), 'site')).toBe('failed')
|
||||
})
|
||||
|
||||
it('does not let a site code leak into the author context', () => {
|
||||
expect(classifyFailure(new ApiError(409, undefined, 'site_key_taken'), 'author')).toBe(
|
||||
'failed',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
BIO_MAX,
|
||||
authorSlugProblem,
|
||||
baseUrlProblem,
|
||||
bioProblem,
|
||||
nameProblem,
|
||||
optionalSecureUrlProblem,
|
||||
secureUrlProblem,
|
||||
siteKeyProblem,
|
||||
} from '@/lib/directoryRules'
|
||||
|
||||
describe('directory validation rules', () => {
|
||||
it('asks for a name', () => {
|
||||
expect(nameProblem(' ')).toBe('Enter a name.')
|
||||
expect(nameProblem('Zoe')).toBe('')
|
||||
})
|
||||
|
||||
it('counts the bio in characters, not code units', () => {
|
||||
expect(bioProblem('x'.repeat(BIO_MAX))).toBe('')
|
||||
expect(bioProblem('x'.repeat(BIO_MAX + 1))).toBe('Shorten the bio to 280 characters.')
|
||||
expect(bioProblem('😀'.repeat(BIO_MAX))).toBe('')
|
||||
})
|
||||
|
||||
it('requires https for links and treats an empty website as fine', () => {
|
||||
expect(secureUrlProblem('http://x')).toBe('Enter a full URL starting with https://.')
|
||||
expect(secureUrlProblem('')).toBe('Enter a full URL starting with https://.')
|
||||
expect(secureUrlProblem('https://x.example')).toBe('')
|
||||
expect(optionalSecureUrlProblem('')).toBe('')
|
||||
expect(optionalSecureUrlProblem('example.com')).not.toBe('')
|
||||
})
|
||||
|
||||
it('validates author slugs and site keys', () => {
|
||||
expect(authorSlugProblem('')).toBe('')
|
||||
expect(authorSlugProblem('camille-roche')).toBe('')
|
||||
expect(authorSlugProblem('Camille Roche')).not.toBe('')
|
||||
expect(siteKeyProblem('journal')).toBe('')
|
||||
expect(siteKeyProblem('j')).not.toBe('')
|
||||
expect(siteKeyProblem('a'.repeat(33))).not.toBe('')
|
||||
expect(siteKeyProblem('Journal')).not.toBe('')
|
||||
})
|
||||
|
||||
it('maps a base URL to its message', () => {
|
||||
expect(baseUrlProblem('https://blog.example.com')).toBe('')
|
||||
expect(baseUrlProblem('http://blog.example.com')).toBe(
|
||||
'Use https. For example https://blog.example.com.',
|
||||
)
|
||||
expect(baseUrlProblem('https://u:p@blog.example.com')).toBe(
|
||||
'Enter a full URL, like https://blog.example.com.',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import { t } from '@/i18n'
|
||||
|
||||
export const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
||||
export const AUTHOR_SLUG_MAX = 100
|
||||
export const SITE_KEY_MAX = 32
|
||||
export const SITE_KEY_MIN = 2
|
||||
export const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]']
|
||||
|
||||
const LIGATURES: Record<string, string> = {
|
||||
æ: 'ae',
|
||||
œ: 'oe',
|
||||
ß: 'ss',
|
||||
ø: 'o',
|
||||
ł: 'l',
|
||||
đ: 'd',
|
||||
ı: 'i',
|
||||
}
|
||||
const COMBINING_MARKS = /\p{M}/gu
|
||||
const NON_SLUG_RUN = /[^a-z0-9]+/g
|
||||
const EDGE_DASHES = /^-+|-+$/g
|
||||
const TITLE_PREFIX = /^Dr\.\s*/
|
||||
const NUMBERED_SUFFIX = /-(\d+)$/
|
||||
|
||||
export function slugify(text: string, max: number) {
|
||||
const folded = text
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(COMBINING_MARKS, '')
|
||||
.replace(/[æœßøłđı]/g, (letter) => LIGATURES[letter] ?? letter)
|
||||
return folded.replace(NON_SLUG_RUN, '-').slice(0, max).replace(EDGE_DASHES, '')
|
||||
}
|
||||
|
||||
export function suggestFreeSlug(
|
||||
slug: string,
|
||||
isTaken: (candidate: string) => boolean,
|
||||
max: number,
|
||||
) {
|
||||
const base = slug.replace(NUMBERED_SUFFIX, '')
|
||||
let counter = 2
|
||||
for (;;) {
|
||||
const suffix = `-${counter}`
|
||||
const stem = base.slice(0, Math.max(1, max - suffix.length)).replace(EDGE_DASHES, '')
|
||||
const candidate = `${stem}${suffix}`
|
||||
if (!isTaken(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
counter += 1
|
||||
}
|
||||
}
|
||||
|
||||
export function hostOf(url: string) {
|
||||
return url.replace(/^https?:\/\//, '').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
export function initialsOf(name: string) {
|
||||
return name
|
||||
.replace(TITLE_PREFIX, '')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((word) => [...word][0] ?? '')
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
}
|
||||
|
||||
export function pluralArticles(count: number) {
|
||||
if (count === 0) {
|
||||
return t('No articles')
|
||||
}
|
||||
return t(count === 1 ? '1 article' : '{0} articles', [count])
|
||||
}
|
||||
|
||||
export type SiteUrlProblem = 'empty' | 'scheme' | 'path' | 'invalid'
|
||||
|
||||
function parseUrl(value: string) {
|
||||
try {
|
||||
return new URL(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function siteUrlProblem(value: string): SiteUrlProblem | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return 'empty'
|
||||
}
|
||||
const parsed = parseUrl(trimmed)
|
||||
if (
|
||||
!parsed ||
|
||||
!/^https?:$/.test(parsed.protocol) ||
|
||||
!parsed.hostname ||
|
||||
parsed.username ||
|
||||
parsed.password
|
||||
) {
|
||||
return 'invalid'
|
||||
}
|
||||
const loopback = LOOPBACK_HOSTS.includes(parsed.hostname)
|
||||
if (parsed.protocol === 'http:' && !loopback) {
|
||||
return 'scheme'
|
||||
}
|
||||
if (parsed.pathname.replace(/\/+$/, '') || parsed.search || parsed.hash) {
|
||||
return 'path'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function siteUrlMessage(problem: SiteUrlProblem) {
|
||||
if (problem === 'scheme') {
|
||||
return t('Use https. For example https://blog.example.com.')
|
||||
}
|
||||
if (problem === 'path') {
|
||||
return t('Remove the path. Use only the host, like https://blog.example.com.')
|
||||
}
|
||||
return t('Enter a full URL, like https://blog.example.com.')
|
||||
}
|
||||
|
||||
export function isSecureUrl(value: string) {
|
||||
const parsed = parseUrl(value.trim())
|
||||
return parsed !== null && parsed.protocol === 'https:' && parsed.hostname !== ''
|
||||
}
|
||||
|
||||
export function canonicalPreview(baseUrl: string) {
|
||||
const trimmed = baseUrl.trim().replace(/\/+$/, '')
|
||||
return `${trimmed}/how-we-price-a-bathroom-renovation`
|
||||
}
|
||||
|
||||
export function characterCount(text: string) {
|
||||
return [...text].length
|
||||
}
|
||||
|
||||
export function reassignQuery(target: number | string | undefined) {
|
||||
return target === undefined ? '' : `?reassign_to=${target}`
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { MessageKey } from '@/i18n/messages'
|
||||
|
||||
export const COPY = {
|
||||
nothingChanged: 'Nothing was changed. Try again in a moment.',
|
||||
siteSaveFailed: "Couldn't save the site. Your changes are still here. Try again in a moment.",
|
||||
authorSaveFailed:
|
||||
"Couldn't save the author. Your changes are still here. Try again in a moment.",
|
||||
defaultSiteBlocked: "The default site can't be deleted. Make another site the default first.",
|
||||
authorSaveFailedTitle: "Couldn't save the author",
|
||||
enterName: 'Enter a name.',
|
||||
shortenBio: 'Shorten the bio to 280 characters.',
|
||||
urlHttps: 'Enter a full URL starting with https://.',
|
||||
userLinked: 'That user already has an author. Choose another user.',
|
||||
loadAuthorsFailed: "Couldn't load authors",
|
||||
checkConnection: 'Check your connection and try again.',
|
||||
} as const satisfies Record<string, MessageKey>
|
||||
|
||||
export function copy(key: keyof typeof COPY) {
|
||||
return t(COPY[key])
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
|
||||
export const SITE_CODES = {
|
||||
notFound: 'site_not_found',
|
||||
keyTaken: 'site_key_taken',
|
||||
hasArticles: 'site_has_articles',
|
||||
defaultSite: 'default_site',
|
||||
invalidUrl: 'invalid_site_url',
|
||||
invalidLocale: 'invalid_locale',
|
||||
} as const
|
||||
|
||||
export const AUTHOR_CODES = {
|
||||
notFound: 'author_not_found',
|
||||
slugTaken: 'author_slug_taken',
|
||||
hasArticles: 'author_has_articles',
|
||||
userLinked: 'user_already_linked',
|
||||
rights: 'author_rights',
|
||||
bioTooLong: 'bio_too_long',
|
||||
invalidUrl: 'invalid_url',
|
||||
userNotFound: 'user_not_found',
|
||||
} as const
|
||||
|
||||
const SESSION_STATUSES = [401]
|
||||
|
||||
export function statusOf(error: unknown) {
|
||||
return error instanceof ApiError ? error.status : 0
|
||||
}
|
||||
|
||||
export function codeOf(error: unknown) {
|
||||
return error instanceof ApiError ? (error.body ?? '').trim() : ''
|
||||
}
|
||||
|
||||
export function isSessionError(error: unknown) {
|
||||
return SESSION_STATUSES.includes(statusOf(error))
|
||||
}
|
||||
|
||||
export type FailureContext = 'site' | 'author'
|
||||
|
||||
export type FailureKind =
|
||||
| 'session'
|
||||
| 'forbidden'
|
||||
| 'defaultSite'
|
||||
| 'rights'
|
||||
| 'gone'
|
||||
| 'keyTaken'
|
||||
| 'invalidSiteUrl'
|
||||
| 'invalidLocale'
|
||||
| 'slugTaken'
|
||||
| 'userLinked'
|
||||
| 'bioTooLong'
|
||||
| 'invalidUrl'
|
||||
| 'hasArticles'
|
||||
| 'failed'
|
||||
|
||||
export function classifyFailure(error: unknown, context: FailureContext): FailureKind {
|
||||
const status = statusOf(error)
|
||||
const code = codeOf(error)
|
||||
if (isSessionError(error)) {
|
||||
return 'session'
|
||||
}
|
||||
if (status === 404) {
|
||||
return 'gone'
|
||||
}
|
||||
if (status === 403) {
|
||||
if (context === 'author') {
|
||||
return 'rights'
|
||||
}
|
||||
return code === SITE_CODES.defaultSite ? 'defaultSite' : 'forbidden'
|
||||
}
|
||||
const table: Record<FailureContext, Record<string, FailureKind>> = {
|
||||
site: {
|
||||
[`409 ${SITE_CODES.keyTaken}`]: 'keyTaken',
|
||||
[`409 ${SITE_CODES.hasArticles}`]: 'hasArticles',
|
||||
[`400 ${SITE_CODES.invalidUrl}`]: 'invalidSiteUrl',
|
||||
[`400 ${SITE_CODES.invalidLocale}`]: 'invalidLocale',
|
||||
},
|
||||
author: {
|
||||
[`409 ${AUTHOR_CODES.slugTaken}`]: 'slugTaken',
|
||||
[`409 ${AUTHOR_CODES.userLinked}`]: 'userLinked',
|
||||
[`409 ${AUTHOR_CODES.hasArticles}`]: 'hasArticles',
|
||||
[`422 ${AUTHOR_CODES.bioTooLong}`]: 'bioTooLong',
|
||||
[`422 ${AUTHOR_CODES.invalidUrl}`]: 'invalidUrl',
|
||||
},
|
||||
}
|
||||
return table[context][`${status} ${code}`] ?? 'failed'
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface DirectoryMenuItem {
|
||||
key: string
|
||||
label: string
|
||||
icon: string
|
||||
destructive?: boolean
|
||||
unavailable?: boolean
|
||||
hint?: string
|
||||
separated?: boolean
|
||||
onSelect: (trigger: HTMLElement | null) => void
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
import {
|
||||
ARTICLE_PAGE_SIZES,
|
||||
ARTICLE_SEARCH_MAX_LENGTH,
|
||||
DEFAULT_ARTICLE_PAGE_SIZE,
|
||||
} from '@/lib/articleListQuery'
|
||||
|
||||
export interface DirectoryQuery {
|
||||
q: string
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
const POSITIVE_INTEGER = /^[1-9]\d*$/
|
||||
|
||||
function first(value: LocationQuery[string] | undefined) {
|
||||
const single = Array.isArray(value) ? value[0] : value
|
||||
return single ?? ''
|
||||
}
|
||||
|
||||
export function parseDirectoryQuery(query: LocationQuery): DirectoryQuery {
|
||||
const page = first(query.page)
|
||||
const size = Number(first(query.size))
|
||||
return {
|
||||
q: first(query.q).slice(0, ARTICLE_SEARCH_MAX_LENGTH),
|
||||
page: POSITIVE_INTEGER.test(page) ? Number(page) : 1,
|
||||
size: ARTICLE_PAGE_SIZES.includes(size) ? size : DEFAULT_ARTICLE_PAGE_SIZE,
|
||||
}
|
||||
}
|
||||
|
||||
export function toDirectoryRouteQuery(query: DirectoryQuery, rest: LocationQuery = {}) {
|
||||
const route: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(rest)) {
|
||||
if (key !== 'q' && key !== 'page' && key !== 'size') {
|
||||
route[key] = first(value)
|
||||
}
|
||||
}
|
||||
if (query.q) {
|
||||
route.q = query.q
|
||||
}
|
||||
if (query.page !== 1) {
|
||||
route.page = String(query.page)
|
||||
}
|
||||
if (query.size !== DEFAULT_ARTICLE_PAGE_SIZE) {
|
||||
route.size = String(query.size)
|
||||
}
|
||||
return route
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function jsonInit(method: string, body: unknown): RequestInit {
|
||||
return {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { t } from '@/i18n'
|
||||
import { copy } from '@/lib/directoryCopy'
|
||||
import {
|
||||
SITE_KEY_MAX,
|
||||
SITE_KEY_MIN,
|
||||
SLUG_PATTERN,
|
||||
characterCount,
|
||||
isSecureUrl,
|
||||
siteUrlMessage,
|
||||
siteUrlProblem,
|
||||
} from '@/lib/directory'
|
||||
|
||||
export const BIO_MAX = 280
|
||||
|
||||
export function nameProblem(name: string) {
|
||||
return name.trim() ? '' : copy('enterName')
|
||||
}
|
||||
|
||||
export function bioProblem(bio: string) {
|
||||
return characterCount(bio) > BIO_MAX ? copy('shortenBio') : ''
|
||||
}
|
||||
|
||||
export function secureUrlProblem(value: string) {
|
||||
return isSecureUrl(value) ? '' : copy('urlHttps')
|
||||
}
|
||||
|
||||
export function optionalSecureUrlProblem(value: string) {
|
||||
return value.trim() ? secureUrlProblem(value) : ''
|
||||
}
|
||||
|
||||
export function authorSlugProblem(slug: string) {
|
||||
if (!slug || SLUG_PATTERN.test(slug)) {
|
||||
return ''
|
||||
}
|
||||
return t('Lowercase letters, numbers and hyphens. Unique across all authors.')
|
||||
}
|
||||
|
||||
export function siteKeyProblem(key: string) {
|
||||
const valid = SLUG_PATTERN.test(key) && key.length >= SITE_KEY_MIN && key.length <= SITE_KEY_MAX
|
||||
return valid ? '' : t('Use lowercase letters, numbers and hyphens, 2 to 32 characters.')
|
||||
}
|
||||
|
||||
export function baseUrlProblem(value: string) {
|
||||
const problem = siteUrlProblem(value)
|
||||
return problem ? siteUrlMessage(problem) : ''
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface LinkableUser {
|
||||
id: number
|
||||
label: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface DirectoryNotice {
|
||||
title?: string
|
||||
description: string
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface ImageChoice {
|
||||
title: string
|
||||
description: string
|
||||
hint?: string
|
||||
confirmLabel: string
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export const SOCIAL_TYPES = ['x', 'linkedin', 'github', 'mastodon', 'custom'] as const
|
||||
export type SocialType = (typeof SOCIAL_TYPES)[number]
|
||||
|
||||
export const MAX_SOCIAL_LINKS = 5
|
||||
|
||||
export interface SocialLinkRow {
|
||||
key: number
|
||||
type: SocialType
|
||||
url: string
|
||||
}
|
||||
|
||||
export const SOCIAL_PLACEHOLDERS: Record<SocialType, string> = {
|
||||
x: 'https://x.com/username',
|
||||
linkedin: 'https://www.linkedin.com/in/username',
|
||||
github: 'https://github.com/username',
|
||||
mastodon: 'https://mastodon.social/@username',
|
||||
custom: 'https://example.com',
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import router from '@/router'
|
||||
|
||||
describe('Sites and authors routes', () => {
|
||||
it.each([
|
||||
['/settings/sites', 'Settings'],
|
||||
['/authors', 'Authors'],
|
||||
])('registers %s as a verified, lazy-loaded route', (path, title) => {
|
||||
const route = router.getRoutes().find((candidate) => candidate.path === path)!
|
||||
|
||||
expect(route.meta.auth).toBe('verified')
|
||||
expect(route.meta.title).toBe(title)
|
||||
expect(typeof route.components?.default).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -77,6 +77,11 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/MediaView.vue'),
|
||||
meta: { auth: 'verified', title: 'Media' },
|
||||
},
|
||||
{
|
||||
path: '/authors',
|
||||
component: () => import('@/views/AuthorsView.vue'),
|
||||
meta: { auth: 'verified', title: 'Authors' },
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
component: () => import('@/views/UsersView.vue'),
|
||||
@@ -112,6 +117,11 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/VariantsPage.vue'),
|
||||
meta: { auth: 'verified', title: 'Settings' },
|
||||
},
|
||||
{
|
||||
path: '/settings/sites',
|
||||
component: () => import('@/views/SitesView.vue'),
|
||||
meta: { auth: 'verified', title: 'Settings' },
|
||||
},
|
||||
{
|
||||
path: '/settings/media/cache',
|
||||
component: () => import('@/views/CachePage.vue'),
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
function sessionWith(role?: 'owner' | 'admin' | 'member') {
|
||||
const store = useSessionStore()
|
||||
store.session = { verified: true, displayName: '', ...(role ? { role: role as never } : {}) }
|
||||
return store
|
||||
}
|
||||
|
||||
describe('session role getters', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
|
||||
it.each([
|
||||
['owner', true, true],
|
||||
['admin', false, true],
|
||||
['member', false, false],
|
||||
[undefined, false, false],
|
||||
] as const)('%s: isOwner %s, canManageContent %s', (role, owner, manage) => {
|
||||
const store = sessionWith(role)
|
||||
|
||||
expect(store.isOwner).toBe(owner)
|
||||
expect(store.canManageContent).toBe(manage)
|
||||
})
|
||||
|
||||
it('is false without a session', () => {
|
||||
const store = useSessionStore()
|
||||
|
||||
expect(store.isOwner).toBe(false)
|
||||
expect(store.canManageContent).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ApiError, useApi, type UserRole } from '@/composables/useApi'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ApiError, useApi, type MeAuthor, type UserRole } from '@/composables/useApi'
|
||||
|
||||
export type SessionStatus = 'idle' | 'checking' | 'ready' | 'error'
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface Session {
|
||||
email?: string
|
||||
displayName: string
|
||||
role?: UserRole
|
||||
author?: MeAuthor
|
||||
}
|
||||
|
||||
export const useSessionStore = defineStore('session', () => {
|
||||
@@ -18,6 +19,11 @@ export const useSessionStore = defineStore('session', () => {
|
||||
const session = ref<Session | null>(null)
|
||||
let pending: Promise<void> | null = null
|
||||
|
||||
const isOwner = computed(() => session.value?.role === 'owner')
|
||||
const canManageContent = computed(
|
||||
() => session.value?.role === 'owner' || session.value?.role === 'admin',
|
||||
)
|
||||
|
||||
function check() {
|
||||
if (pending) {
|
||||
return pending
|
||||
@@ -71,5 +77,15 @@ export const useSessionStore = defineStore('session', () => {
|
||||
}
|
||||
}
|
||||
|
||||
return { status, session, check, retry, refresh, clear, setDisplayName }
|
||||
return {
|
||||
status,
|
||||
session,
|
||||
isOwner,
|
||||
canManageContent,
|
||||
check,
|
||||
retry,
|
||||
refresh,
|
||||
clear,
|
||||
setDisplayName,
|
||||
}
|
||||
})
|
||||
@@ -67,7 +67,7 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
const dismissedVersion = ref<string | null>(readDismissedVersion())
|
||||
let pendingLoad: Promise<void> | null = null
|
||||
|
||||
const isOwner = computed(() => session.session?.role === 'owner')
|
||||
const isOwner = computed(() => session.isOwner)
|
||||
const available = computed(() => status.value?.available === true)
|
||||
const showCue = computed(() => isOwner.value && available.value)
|
||||
const restarting = computed(() => status.value?.state === 'restarting')
|
||||
|
||||
@@ -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"]',
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -32,6 +32,61 @@ const VISUAL_REFERENCE =
|
||||
const officeBoard = 'RenewCMS Screens Back-office.dc.html'
|
||||
|
||||
const extraSources: Record<string, Source[]> = {
|
||||
sites: [
|
||||
{
|
||||
board: officeBoard,
|
||||
group: 'Sites: form',
|
||||
states: [
|
||||
'Add: empty',
|
||||
'Add: key from the name',
|
||||
'Add: URL not valid',
|
||||
'Add: duplicate key',
|
||||
'Add: save failed',
|
||||
'Edit: default site',
|
||||
'Edit: other site, noindex',
|
||||
],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: officeBoard,
|
||||
group: 'Sites: delete',
|
||||
states: ['With articles', 'Choose destination', 'No articles'],
|
||||
alias: {},
|
||||
},
|
||||
],
|
||||
authors: [
|
||||
{
|
||||
board: officeBoard,
|
||||
group: 'Authors: form',
|
||||
states: [
|
||||
'Add: empty',
|
||||
'Slug from the name',
|
||||
'Slug collision',
|
||||
'Validation',
|
||||
'Social links',
|
||||
'Linked user: choose',
|
||||
'Edit: linked author',
|
||||
'Avatar: picker',
|
||||
'Save failed',
|
||||
'Unlink: confirm',
|
||||
'Re-link',
|
||||
],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: officeBoard,
|
||||
group: 'Authors: delete',
|
||||
states: [
|
||||
'Reassign (recommended)',
|
||||
'Choose who',
|
||||
'Nobody',
|
||||
'Failed (shared by site and author delete)',
|
||||
'Server fallback (409)',
|
||||
'No articles',
|
||||
],
|
||||
alias: {},
|
||||
},
|
||||
],
|
||||
integration: [
|
||||
{
|
||||
board: officeBoard,
|
||||
|
||||
@@ -4484,7 +4484,727 @@ function withDetailTabsException(states: Record<string, VisualState>): Record<st
|
||||
}
|
||||
}
|
||||
|
||||
function directorySession(role: string, displayName = 'Marta Alves'): ApiMock {
|
||||
return {
|
||||
status: 200,
|
||||
body: { ...(verifiedSession.body as object), display_name: displayName, role },
|
||||
}
|
||||
}
|
||||
|
||||
const directorySites = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Renew',
|
||||
key: 'renew',
|
||||
base_url: 'https://renew.example',
|
||||
default_social_image_id: 11,
|
||||
default_robots: 'index',
|
||||
locale: 'en',
|
||||
is_default: true,
|
||||
article_count: 268,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Journal',
|
||||
key: 'journal',
|
||||
base_url: 'https://journal.renew.example',
|
||||
default_social_image_id: null,
|
||||
default_robots: 'index',
|
||||
locale: null,
|
||||
is_default: false,
|
||||
article_count: 38,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Projects',
|
||||
key: 'projects',
|
||||
base_url: 'https://projects.renew.example',
|
||||
default_social_image_id: 12,
|
||||
default_robots: 'noindex',
|
||||
locale: 'fr',
|
||||
is_default: false,
|
||||
article_count: 6,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Archive',
|
||||
key: 'archive',
|
||||
base_url: 'https://archive.renew.example',
|
||||
default_social_image_id: null,
|
||||
default_robots: 'noindex',
|
||||
locale: null,
|
||||
is_default: false,
|
||||
article_count: 0,
|
||||
},
|
||||
]
|
||||
|
||||
function sitesApi(
|
||||
overrides: Record<string, ApiMock> = {},
|
||||
role = 'owner',
|
||||
): Record<string, ApiMock> {
|
||||
return {
|
||||
'GET /v1/auth/me': directorySession(role),
|
||||
'GET /v1/site/': { status: 200, body: directorySites },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const listboxTrigger = 'button[aria-haspopup="listbox"]'
|
||||
|
||||
function isPhone(page: Page) {
|
||||
return (page.viewportSize()?.width ?? 0) < 768
|
||||
}
|
||||
|
||||
async function openDirectoryMenu(page: Page, name: string) {
|
||||
await page.getByRole('button', { name: `Actions for ${name}`, exact: true }).click()
|
||||
await page.getByRole('menuitem').first().waitFor()
|
||||
}
|
||||
|
||||
async function chooseDirectoryMenuItem(page: Page, label: string) {
|
||||
await page.getByRole('menuitem', { name: label, exact: true }).click()
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
async function openDirectoryAdd(page: Page, label: string) {
|
||||
await page.getByRole('button', { name: label, exact: true }).first().click()
|
||||
await page.getByRole('dialog').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
async function openSiteEdit(page: Page, name: string) {
|
||||
await openDirectoryMenu(page, name)
|
||||
await chooseDirectoryMenuItem(page, 'Edit')
|
||||
await page.getByRole('dialog').waitFor()
|
||||
}
|
||||
|
||||
async function openSiteDelete(page: Page, name: string) {
|
||||
await openDirectoryMenu(page, name)
|
||||
await chooseDirectoryMenuItem(page, 'Delete…')
|
||||
await page.getByRole('alertdialog').waitFor()
|
||||
}
|
||||
|
||||
function siteFormField(page: Page, placeholder: string) {
|
||||
return page.getByRole('dialog').getByPlaceholder(placeholder)
|
||||
}
|
||||
|
||||
async function scrollPanelToTop(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('[role="dialog"] [data-slot="body"]').forEach((body) => {
|
||||
body.scrollTop = 0
|
||||
})
|
||||
})
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
async function submitDirectoryPanel(page: Page, label: string) {
|
||||
await page.getByRole('dialog').getByRole('button', { name: label, exact: true }).click()
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
const sitesStates: Record<string, VisualState> = {
|
||||
Default: { path: '/settings/sites', api: sitesApi() },
|
||||
'Only the default site': {
|
||||
path: '/settings/sites',
|
||||
api: sitesApi({
|
||||
'GET /v1/site/': {
|
||||
status: 200,
|
||||
body: [{ ...directorySites[0], article_count: 312 }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
Loading: {
|
||||
path: '/settings/sites',
|
||||
api: sitesApi({ 'GET /v1/site/': { status: 200, hang: true } }),
|
||||
},
|
||||
Error: { path: '/settings/sites', api: sitesApi({ 'GET /v1/site/': { status: 500 } }) },
|
||||
'Row menu': {
|
||||
path: '/settings/sites',
|
||||
api: sitesApi(),
|
||||
steps: async (page) => {
|
||||
await openDirectoryMenu(page, 'Journal')
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'Default site: delete unavailable': {
|
||||
path: '/settings/sites',
|
||||
api: sitesApi(),
|
||||
steps: async (page) => {
|
||||
await openDirectoryMenu(page, 'Renew')
|
||||
if (!isPhone(page)) {
|
||||
await page.getByRole('menuitem', { name: 'Delete…' }).hover()
|
||||
await page.waitForTimeout(TOOLTIP_SETTLE_MS)
|
||||
}
|
||||
},
|
||||
},
|
||||
'Set as default: confirm': {
|
||||
path: '/settings/sites',
|
||||
api: sitesApi(),
|
||||
steps: async (page) => {
|
||||
await openDirectoryMenu(page, 'Journal')
|
||||
await chooseDirectoryMenuItem(page, 'Set as default')
|
||||
await page.getByRole('alertdialog').waitFor()
|
||||
},
|
||||
},
|
||||
'Hidden entry (admin)': { path: '/settings', skip: 'clock-sensitive' },
|
||||
'Forbidden (403)': {
|
||||
path: '/settings/sites',
|
||||
api: sitesApi({}, 'admin'),
|
||||
},
|
||||
'Add: empty': {
|
||||
path: '/settings/sites',
|
||||
api: sitesApi(),
|
||||
steps: (page) => openDirectoryAdd(page, 'Add site'),
|
||||
},
|
||||
'Add: key from the name': {
|
||||
path: '/settings/sites',
|
||||
api: sitesApi(),
|
||||
steps: async (page) => {
|
||||
await openDirectoryAdd(page, 'Add site')
|
||||
await siteFormField(page, 'e.g. Journal').fill('Journal')
|
||||
await siteFormField(page, 'https://blog.example.com').focus()
|
||||
},
|
||||
},
|
||||
'Add: URL not valid': {
|
||||
path: '/settings/sites',
|
||||
api: sitesApi(),
|
||||
steps: async (page) => {
|
||||
await openDirectoryAdd(page, 'Add site')
|
||||
await siteFormField(page, 'e.g. Journal').fill('Journal')
|
||||
await siteFormField(page, 'https://blog.example.com').fill(
|
||||
'http://journal.renew.example',
|
||||
)
|
||||
await siteFormField(page, 'https://blog.example.com').blur()
|
||||
await page.getByRole('dialog').getByText('Use https. For example').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'Add: duplicate key': {
|
||||
path: '/settings/sites',
|
||||
api: sitesApi(),
|
||||
steps: async (page) => {
|
||||
await openDirectoryAdd(page, 'Add site')
|
||||
await siteFormField(page, 'e.g. Journal').fill('Journal')
|
||||
await page.getByRole('button', { name: 'Edit key', exact: true }).click()
|
||||
await page.getByRole('dialog').getByLabel('Key', { exact: true }).focus()
|
||||
await siteFormField(page, 'https://blog.example.com').fill(
|
||||
'https://journal2.renew.example',
|
||||
)
|
||||
await page.getByRole('dialog').getByText('already exists').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'Add: save failed': {
|
||||
path: '/settings/sites',
|
||||
api: sitesApi({ 'POST /v1/site/': { status: 500 } }),
|
||||
steps: async (page) => {
|
||||
await openDirectoryAdd(page, 'Add site')
|
||||
await siteFormField(page, 'e.g. Journal').fill('Journal')
|
||||
await siteFormField(page, 'https://blog.example.com').fill(
|
||||
'https://journal.renew.example',
|
||||
)
|
||||
await submitDirectoryPanel(page, 'Add site')
|
||||
await page.getByRole('dialog').getByText("Couldn't save the site").waitFor()
|
||||
},
|
||||
},
|
||||
'Edit: default site': {
|
||||
path: '/settings/sites',
|
||||
api: sitesApi(),
|
||||
steps: async (page) => {
|
||||
await openSiteEdit(page, 'Renew')
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'Edit: other site, noindex': {
|
||||
path: '/settings/sites',
|
||||
api: sitesApi(),
|
||||
steps: async (page) => {
|
||||
await openSiteEdit(page, 'Projects')
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'With articles': {
|
||||
path: '/settings/sites',
|
||||
api: sitesApi(),
|
||||
steps: (page) => openSiteDelete(page, 'Journal'),
|
||||
},
|
||||
'Choose destination': {
|
||||
path: '/settings/sites',
|
||||
api: sitesApi(),
|
||||
steps: async (page) => {
|
||||
await openSiteDelete(page, 'Journal')
|
||||
await page.getByRole('alertdialog').locator(listboxTrigger).click()
|
||||
await page.getByRole('option').first().waitFor()
|
||||
},
|
||||
},
|
||||
'No articles': {
|
||||
path: '/settings/sites',
|
||||
api: sitesApi(),
|
||||
steps: (page) => openSiteDelete(page, 'Archive'),
|
||||
},
|
||||
}
|
||||
|
||||
const directoryLinks = {
|
||||
camille: [
|
||||
{ type: 'mastodon', url: 'https://mastodon.social/@camille' },
|
||||
{ type: 'linkedin', url: 'linkedin.com/in/camille' },
|
||||
],
|
||||
ines: [{ type: 'linkedin', url: 'https://www.linkedin.com/in/ines-duarte' }],
|
||||
}
|
||||
|
||||
interface DirectoryAuthor {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
bio: string
|
||||
avatar_id: number | null
|
||||
website: string | null
|
||||
social_links: { type: string; url: string }[]
|
||||
user_id: number | null
|
||||
is_linked: boolean
|
||||
article_count: number
|
||||
user_email?: string
|
||||
}
|
||||
|
||||
function directoryAuthors(
|
||||
overrides: Record<string, Partial<DirectoryAuthor>> = {},
|
||||
owner = true,
|
||||
): DirectoryAuthor[] {
|
||||
const base: DirectoryAuthor[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Inès Duarte',
|
||||
slug: 'ines-duarte',
|
||||
bio: 'Site manager and restoration carpenter. Writes about timber and joinery.',
|
||||
avatar_id: 12,
|
||||
website: 'https://ines-duarte.example',
|
||||
social_links: directoryLinks.ines,
|
||||
user_id: 3,
|
||||
is_linked: true,
|
||||
article_count: 9,
|
||||
user_email: 'ines.duarte@renew.example',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Jonas Pereira',
|
||||
slug: 'jonas-pereira',
|
||||
bio: 'Heat pumps, insulation and the numbers behind them.',
|
||||
avatar_id: 13,
|
||||
website: null,
|
||||
social_links: [],
|
||||
user_id: 2,
|
||||
is_linked: true,
|
||||
article_count: 6,
|
||||
user_email: 'jonas@renew.example',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Camille Roche',
|
||||
slug: 'camille-roche',
|
||||
bio: 'Freelance writer on lime and stone.',
|
||||
avatar_id: null,
|
||||
website: null,
|
||||
social_links: [],
|
||||
user_id: null,
|
||||
is_linked: false,
|
||||
article_count: 3,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Paul Huber',
|
||||
slug: 'paul-huber',
|
||||
bio: '',
|
||||
avatar_id: 14,
|
||||
website: null,
|
||||
social_links: [],
|
||||
user_id: null,
|
||||
is_linked: false,
|
||||
article_count: 2,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Léa Fontaine',
|
||||
slug: 'lea-fontaine',
|
||||
bio: '',
|
||||
avatar_id: null,
|
||||
website: null,
|
||||
social_links: [],
|
||||
user_id: null,
|
||||
is_linked: false,
|
||||
article_count: 0,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Dr. Alexandra Konstantinopoulou-Whitfield',
|
||||
slug: 'dr-alexandra-konstantinopoulou-whitfield',
|
||||
bio: 'Conservation architect.',
|
||||
avatar_id: null,
|
||||
website: null,
|
||||
social_links: [],
|
||||
user_id: null,
|
||||
is_linked: false,
|
||||
article_count: 1,
|
||||
},
|
||||
]
|
||||
return base.map((author) => {
|
||||
const merged = { ...author, ...(overrides[author.slug] ?? {}) }
|
||||
if (!owner) {
|
||||
delete merged.user_email
|
||||
}
|
||||
return merged
|
||||
})
|
||||
}
|
||||
|
||||
function authorListMock(authors: DirectoryAuthor[]): ApiMock {
|
||||
return {
|
||||
status: 200,
|
||||
bodyFor: (url) => {
|
||||
const q = (url.searchParams.get('q') ?? '').toLowerCase()
|
||||
const matching = authors.filter((author) =>
|
||||
`${author.name} ${author.slug}`.toLowerCase().includes(q),
|
||||
)
|
||||
const size = Number(url.searchParams.get('size') ?? 25)
|
||||
const page = Number(url.searchParams.get('page') ?? 1)
|
||||
return {
|
||||
items: matching.slice((page - 1) * size, page * size),
|
||||
total: matching.length,
|
||||
page,
|
||||
size,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const directoryUsers = [
|
||||
{
|
||||
kind: 'user',
|
||||
id: 3,
|
||||
username: 'ines',
|
||||
display_name: 'Inès Duarte',
|
||||
email: 'ines.duarte@renew.example',
|
||||
role: 'admin',
|
||||
status: 'accepted',
|
||||
created_at: '2026-06-18T09:00:00Z',
|
||||
},
|
||||
{
|
||||
kind: 'user',
|
||||
id: 2,
|
||||
username: 'jonas',
|
||||
display_name: 'Jonas Pereira',
|
||||
email: 'jonas@renew.example',
|
||||
role: 'admin',
|
||||
status: 'accepted',
|
||||
created_at: '2026-04-02T09:00:00Z',
|
||||
},
|
||||
{
|
||||
kind: 'user',
|
||||
id: 1,
|
||||
username: 'marta',
|
||||
display_name: 'Marta Alves',
|
||||
email: 'marta@renew.example',
|
||||
role: 'owner',
|
||||
status: 'accepted',
|
||||
created_at: '2026-03-12T09:00:00Z',
|
||||
},
|
||||
{
|
||||
kind: 'user',
|
||||
id: 4,
|
||||
username: 'rui',
|
||||
display_name: 'Rui Santos',
|
||||
email: 'rui@renew.example',
|
||||
role: 'admin',
|
||||
status: 'accepted',
|
||||
created_at: '2026-05-02T09:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
function authorsApi(
|
||||
overrides: Record<string, ApiMock> = {},
|
||||
options: { role?: string; authors?: DirectoryAuthor[] } = {},
|
||||
): Record<string, ApiMock> {
|
||||
const role = options.role ?? 'owner'
|
||||
return {
|
||||
'GET /v1/auth/me': directorySession(role),
|
||||
'GET /v1/author/': authorListMock(options.authors ?? directoryAuthors({}, role !== 'user')),
|
||||
'GET /v1/user/': { status: 200, body: directoryUsers },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function openAuthorEdit(page: Page, name: string) {
|
||||
await openDirectoryMenu(page, name)
|
||||
await chooseDirectoryMenuItem(page, 'Edit')
|
||||
await page.getByRole('dialog').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
async function openAuthorDelete(page: Page, name: string) {
|
||||
await openDirectoryMenu(page, name)
|
||||
await chooseDirectoryMenuItem(page, 'Delete…')
|
||||
await page.getByRole('alertdialog').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
const avatarPickerNames = [
|
||||
'north-wall.jpg',
|
||||
'kitchen-after.webp',
|
||||
'kitchen-before.webp',
|
||||
'logo-renew.svg',
|
||||
'window-frame.jpg',
|
||||
'lime-plaster.jpg',
|
||||
'floorboards.jpg',
|
||||
'bathroom-tiles.png',
|
||||
]
|
||||
|
||||
const avatarPickerTones = ['0', '1', '3']
|
||||
|
||||
function avatarPickerList() {
|
||||
const library = mediaItems()
|
||||
const items = avatarPickerNames
|
||||
.flatMap((name) => library.filter((item) => item.filename === name))
|
||||
.map((item, index) => ({
|
||||
...item,
|
||||
content_type: 'image/jpeg',
|
||||
sha256: `${avatarPickerTones[index % avatarPickerTones.length]}${item.sha256.slice(1)}`,
|
||||
}))
|
||||
return mediaList(items, pickerCounts)
|
||||
}
|
||||
|
||||
const authorsStates: Record<string, VisualState> = {
|
||||
Default: { path: '/authors', api: authorsApi() },
|
||||
Loading: {
|
||||
path: '/authors',
|
||||
api: authorsApi({ 'GET /v1/author/': { status: 200, hang: true } }),
|
||||
},
|
||||
Error: { path: '/authors', api: authorsApi({ 'GET /v1/author/': { status: 500 } }) },
|
||||
'Empty, first run': {
|
||||
path: '/authors',
|
||||
api: authorsApi({}, { authors: [] }),
|
||||
},
|
||||
'No results': { path: '/authors?q=zoe', api: authorsApi() },
|
||||
'Row menu': {
|
||||
path: '/authors',
|
||||
api: authorsApi(),
|
||||
steps: async (page) => {
|
||||
await openDirectoryMenu(page, 'Camille Roche')
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'Read-only list (other roles)': {
|
||||
path: '/authors',
|
||||
api: authorsApi({}, { role: 'user' }),
|
||||
},
|
||||
'Read-only detail': {
|
||||
path: '/authors',
|
||||
api: authorsApi({}, { role: 'user' }),
|
||||
steps: async (page) => {
|
||||
await page.getByRole('button', { name: 'View Inès Duarte', exact: true }).click()
|
||||
await page.getByRole('dialog').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'Add: empty': {
|
||||
path: '/authors',
|
||||
api: authorsApi(),
|
||||
steps: (page) => openDirectoryAdd(page, 'Add author'),
|
||||
},
|
||||
'Slug from the name': {
|
||||
path: '/authors',
|
||||
api: authorsApi(),
|
||||
steps: async (page) => {
|
||||
await openDirectoryAdd(page, 'Add author')
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByPlaceholder('e.g. Camille Roche')
|
||||
.fill('Camille Roche')
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByPlaceholder('A sentence or two about the author.')
|
||||
.fill('Freelance writer on lime and stone.')
|
||||
},
|
||||
},
|
||||
'Slug collision': {
|
||||
path: '/authors',
|
||||
api: authorsApi({ 'POST /v1/author/': { status: 409, body: 'author_slug_taken' } }),
|
||||
steps: async (page) => {
|
||||
await openDirectoryAdd(page, 'Add author')
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByPlaceholder('e.g. Camille Roche')
|
||||
.fill('Camille Roche')
|
||||
await page.getByRole('button', { name: 'Edit slug', exact: true }).click()
|
||||
await submitDirectoryPanel(page, 'Add author')
|
||||
await page.getByRole('dialog').getByText('already used').waitFor()
|
||||
},
|
||||
},
|
||||
Validation: {
|
||||
path: '/authors',
|
||||
api: authorsApi(),
|
||||
steps: async (page) => {
|
||||
await openDirectoryAdd(page, 'Add author')
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByPlaceholder('A sentence or two about the author.')
|
||||
.fill('x'.repeat(291))
|
||||
await page.getByRole('dialog').getByPlaceholder('https://example.com').fill('example')
|
||||
await submitDirectoryPanel(page, 'Add author')
|
||||
await page.getByRole('dialog').getByText('Enter a name.').waitFor()
|
||||
await scrollPanelToTop(page)
|
||||
},
|
||||
},
|
||||
'Social links': {
|
||||
path: '/authors',
|
||||
api: authorsApi(
|
||||
{},
|
||||
{
|
||||
authors: directoryAuthors({
|
||||
'camille-roche': { social_links: directoryLinks.camille },
|
||||
}),
|
||||
},
|
||||
),
|
||||
steps: async (page) => {
|
||||
await openAuthorEdit(page, 'Camille Roche')
|
||||
await page.getByRole('button', { name: 'Add link', exact: true }).click()
|
||||
await page.getByRole('dialog').getByLabel('Link URL 3').focus()
|
||||
await scrollPanelToTop(page)
|
||||
},
|
||||
},
|
||||
'Linked user: choose': {
|
||||
path: '/authors',
|
||||
api: authorsApi({}, { authors: directoryAuthors({ 'camille-roche': { bio: '' } }) }),
|
||||
steps: async (page) => {
|
||||
await openAuthorEdit(page, 'Camille Roche')
|
||||
await page.getByRole('dialog').locator(listboxTrigger).click()
|
||||
await page.getByRole('option').first().waitFor()
|
||||
await scrollPanelToTop(page)
|
||||
},
|
||||
},
|
||||
'Edit: linked author': {
|
||||
path: '/authors',
|
||||
api: authorsApi(),
|
||||
steps: (page) => openAuthorEdit(page, 'Inès Duarte'),
|
||||
},
|
||||
'Avatar: picker': {
|
||||
path: '/authors',
|
||||
api: authorsApi(
|
||||
{
|
||||
'GET /v1/media/': { status: 200, bodyFor: avatarPickerList },
|
||||
'GET /v1/image-variants': { status: 403 },
|
||||
},
|
||||
{ authors: directoryAuthors({ 'camille-roche': { bio: '' } }) },
|
||||
),
|
||||
steps: async (page) => {
|
||||
await openAuthorEdit(page, 'Camille Roche')
|
||||
await page.getByRole('button', { name: 'Choose image', exact: true }).click()
|
||||
await waitForPickerTiles(page)
|
||||
await page.locator('[data-picker-tile][aria-label^="kitchen-after.webp,"]').click()
|
||||
await scrollPanelToTop(page)
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'Save failed': {
|
||||
path: '/authors',
|
||||
api: authorsApi(
|
||||
{ 'PATCH /v1/author/3': { status: 500 } },
|
||||
{ authors: directoryAuthors({ 'camille-roche': { bio: '' } }) },
|
||||
),
|
||||
steps: async (page) => {
|
||||
await openAuthorEdit(page, 'Camille Roche')
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByPlaceholder('A sentence or two about the author.')
|
||||
.fill('Freelance writer on lime and stone.')
|
||||
await submitDirectoryPanel(page, 'Save changes')
|
||||
await page.getByRole('dialog').getByText("Couldn't save the author").waitFor()
|
||||
},
|
||||
},
|
||||
'Unlink: confirm': {
|
||||
path: '/authors',
|
||||
api: authorsApi(
|
||||
{},
|
||||
{
|
||||
authors: directoryAuthors({
|
||||
'ines-duarte': { social_links: [], bio: '', website: null },
|
||||
}),
|
||||
},
|
||||
),
|
||||
steps: async (page) => {
|
||||
await openAuthorEdit(page, 'Inès Duarte')
|
||||
await page.getByRole('button', { name: 'Unlink', exact: true }).click()
|
||||
await page.getByRole('alertdialog').waitFor()
|
||||
await scrollPanelToTop(page)
|
||||
},
|
||||
},
|
||||
'Re-link': {
|
||||
path: '/authors',
|
||||
api: authorsApi(),
|
||||
steps: async (page) => {
|
||||
await openDirectoryMenu(page, 'Camille Roche')
|
||||
await chooseDirectoryMenuItem(page, 'Link user')
|
||||
await page.getByRole('alertdialog').waitFor()
|
||||
await page.getByRole('alertdialog').locator(listboxTrigger).click()
|
||||
await page.getByRole('option').first().waitFor()
|
||||
},
|
||||
},
|
||||
'Reassign (recommended)': {
|
||||
path: '/authors',
|
||||
api: authorsApi(),
|
||||
steps: (page) => openAuthorDelete(page, 'Camille Roche'),
|
||||
},
|
||||
'Choose who': {
|
||||
path: '/authors',
|
||||
api: authorsApi(),
|
||||
steps: async (page) => {
|
||||
await openAuthorDelete(page, 'Camille Roche')
|
||||
await page.getByRole('alertdialog').locator(listboxTrigger).click()
|
||||
await page.getByRole('option').first().waitFor()
|
||||
},
|
||||
},
|
||||
Nobody: {
|
||||
path: '/authors',
|
||||
api: authorsApi(),
|
||||
steps: async (page) => {
|
||||
await openAuthorDelete(page, 'Camille Roche')
|
||||
await page.getByRole('alertdialog').locator(listboxTrigger).click()
|
||||
await page.getByRole('option', { name: /Nobody/ }).click()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'Failed (shared by site and author delete)': {
|
||||
path: '/authors',
|
||||
api: authorsApi({ 'DELETE /v1/author/3': { status: 500 } }),
|
||||
steps: async (page) => {
|
||||
await openAuthorDelete(page, 'Camille Roche')
|
||||
await page
|
||||
.getByRole('alertdialog')
|
||||
.getByRole('button', { name: 'Delete and reassign 3 articles', exact: true })
|
||||
.click()
|
||||
await page.getByRole('alertdialog').getByText("Couldn't delete the author").waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'Server fallback (409)': {
|
||||
path: '/authors',
|
||||
api: authorsApi({ 'DELETE /v1/author/3': { status: 409, body: 'author_has_articles' } }),
|
||||
steps: async (page) => {
|
||||
await openAuthorDelete(page, 'Camille Roche')
|
||||
await page
|
||||
.getByRole('alertdialog')
|
||||
.getByRole('button', { name: 'Delete and reassign 3 articles', exact: true })
|
||||
.click()
|
||||
await page.getByRole('alertdialog').getByText("Can't delete this author").waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'No articles': {
|
||||
path: '/authors',
|
||||
api: authorsApi(),
|
||||
steps: (page) => openAuthorDelete(page, 'Léa Fontaine'),
|
||||
},
|
||||
}
|
||||
|
||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
sites: sitesStates,
|
||||
authors: authorsStates,
|
||||
activity: activityStates,
|
||||
profile: {
|
||||
'Account menu': {
|
||||
|
||||
Reference in new issue
Block a user