feat: SEO SPA Sites, Articles and Integration (4/4)
This commit is contained in:
1 parent
cb737b7ae3
commit
02fb8751fd
47 files changed
+3218
-227
No files matched your search
@@ -3,10 +3,12 @@ import { t } from '@/i18n'
|
||||
|
||||
import { useRouter } from 'vue-router'
|
||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import SeoStatusChip from '@/components/SeoStatusChip.vue'
|
||||
import AuthorStack from '@/components/AuthorStack.vue'
|
||||
import { authorsSummary } from '@/lib/articleMeta'
|
||||
import { formatRelativeDate } from '@/lib/relativeDate'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { seoChipOf } from '@/lib/articleSeoStatus'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -137,6 +139,11 @@ function onRowClick(article: Article, event: MouseEvent) {
|
||||
size="sm"
|
||||
:pending="pendingFor(article)"
|
||||
/>
|
||||
<SeoStatusChip
|
||||
v-if="seoChipOf(article)"
|
||||
:chip="seoChipOf(article)!"
|
||||
size="sm"
|
||||
/>
|
||||
<span :title="article.updated_at"
|
||||
>{{ t('Updated') }}
|
||||
{{ formatRelativeDate(article.updated_at, now) }}</span
|
||||
|
||||
@@ -34,9 +34,9 @@ const selected = computed({
|
||||
icon="i-lucide-globe"
|
||||
:search-input="false"
|
||||
:aria-label="t('Site')"
|
||||
class="w-full sm:w-37.5"
|
||||
class="w-full md:w-37.5"
|
||||
:ui="{
|
||||
base: `h-11 rounded-none text-lg ring-(--ui-border-accented) sm:h-8 sm:text-sm ${siteKey ? 'text-highlighted' : 'text-muted'}`,
|
||||
base: `h-11 rounded-none text-lg ring-(--ui-border-accented) md:h-8 md:text-sm ${siteKey ? 'text-highlighted' : 'text-muted'}`,
|
||||
leadingIcon: 'size-3.5',
|
||||
trailingIcon: 'size-3.5 text-muted',
|
||||
content: 'rounded-none shadow-(--shadow-popover)',
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
withDefaults(
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
online: boolean
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
@@ -11,6 +13,9 @@ withDefaults(
|
||||
size: 'md',
|
||||
},
|
||||
)
|
||||
|
||||
const themeSize = computed(() => (props.size === 'sm' ? 'md' : props.size))
|
||||
const compact = computed(() => (props.size === 'sm' ? 'h-5 px-1.5' : undefined))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -19,13 +24,20 @@ withDefaults(
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-loader-circle"
|
||||
:size="size"
|
||||
:class="size === 'lg' ? undefined : 'px-2'"
|
||||
:size="themeSize"
|
||||
:class="[size === 'lg' ? undefined : 'px-2', compact]"
|
||||
:ui="{ leadingIcon: 'animate-spin' }"
|
||||
>
|
||||
{{ pending === 'publish' ? t('Publishing…') : t('Unpublishing…') }}
|
||||
</UBadge>
|
||||
<UBadge v-else-if="online" color="neutral" variant="solid" icon="i-lucide-globe" :size="size">
|
||||
<UBadge
|
||||
v-else-if="online"
|
||||
color="neutral"
|
||||
variant="solid"
|
||||
icon="i-lucide-globe"
|
||||
:size="themeSize"
|
||||
:class="compact"
|
||||
>
|
||||
{{ t('Online') }}
|
||||
</UBadge>
|
||||
<UBadge
|
||||
@@ -33,8 +45,8 @@ withDefaults(
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-pencil-line"
|
||||
:size="size"
|
||||
:class="size === 'lg' ? undefined : 'px-2'"
|
||||
:size="themeSize"
|
||||
:class="[size === 'lg' ? undefined : 'px-2', compact]"
|
||||
>
|
||||
{{ t('Draft') }}
|
||||
</UBadge>
|
||||
|
||||
@@ -4,10 +4,12 @@ import { t } from '@/i18n'
|
||||
import { computed, ref, watchPostEffect } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import SeoStatusChip from '@/components/SeoStatusChip.vue'
|
||||
import AuthorStack from '@/components/AuthorStack.vue'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { seoChipOf } from '@/lib/articleSeoStatus'
|
||||
import { DEFAULT_ARTICLE_SORT, type ArticleSortField } from '@/lib/articleListQuery'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -20,7 +22,7 @@ const props = withDefaults(
|
||||
sites?: Site[]
|
||||
now?: Date
|
||||
}>(),
|
||||
{ skeletonRows: 5, sort: DEFAULT_ARTICLE_SORT, sites: () => [] },
|
||||
{ skeletonRows: 6, sort: DEFAULT_ARTICLE_SORT, sites: () => [] },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -38,6 +40,15 @@ const SINGLE_SITE_WIDTHS =
|
||||
const SEVERAL_SITES_WIDTHS =
|
||||
'[&:nth-child(2)]:w-26 [&:nth-child(3)]:w-32.5 [&:nth-child(4)]:w-27.5 [&:nth-child(5)]:w-32.5 [&:nth-child(6)]:w-35 [&:nth-child(7)]:w-11'
|
||||
|
||||
const rows = computed(() =>
|
||||
props.loading
|
||||
? Array.from(
|
||||
{ length: props.skeletonRows },
|
||||
(_, index) => ({ id: -(index + 1) }) as Article,
|
||||
)
|
||||
: props.articles,
|
||||
)
|
||||
|
||||
const showSite = computed(() => props.sites.length > 1)
|
||||
const columnWidths = computed(() => (showSite.value ? SEVERAL_SITES_WIDTHS : SINGLE_SITE_WIDTHS))
|
||||
|
||||
@@ -212,29 +223,22 @@ function actionsFor(article: Article) {
|
||||
}
|
||||
|
||||
function onSelect(_event: Event, row: { original: Article }) {
|
||||
if (props.loading) {
|
||||
return
|
||||
}
|
||||
router.push(editPath(row.original))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" aria-busy="true" class="hidden border border-default bg-default md:block">
|
||||
<span role="status" class="sr-only">{{ t('Loading articles') }}</span>
|
||||
<div
|
||||
v-for="n in skeletonRows"
|
||||
:key="n"
|
||||
class="flex h-11 items-center gap-3 border-t border-muted px-3 first:border-t-0"
|
||||
>
|
||||
<USkeleton class="h-3 max-w-[55%] flex-1 rounded-none bg-accented" />
|
||||
<USkeleton class="h-6 w-15 rounded-none bg-accented" />
|
||||
<USkeleton v-if="showSite" class="h-3 w-17.5 rounded-none bg-accented" />
|
||||
<USkeleton class="h-5 w-15 rounded-none bg-accented" />
|
||||
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
|
||||
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else ref="tableRoot" class="hidden border border-default bg-default md:block">
|
||||
<div
|
||||
ref="tableRoot"
|
||||
:aria-busy="loading ? 'true' : undefined"
|
||||
class="hidden border border-default bg-default md:block"
|
||||
>
|
||||
<span v-if="loading" role="status" class="sr-only">{{ t('Loading articles') }}</span>
|
||||
<UTable
|
||||
:data="articles"
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
:ui="{
|
||||
base: 'table-fixed',
|
||||
@@ -257,6 +261,7 @@ function onSelect(_event: Event, row: { original: Article }) {
|
||||
<template #title-header="{ column }">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
class="inline-flex items-center gap-1 text-highlighted"
|
||||
@click.stop="emit('sort', column.id as ArticleSortField)"
|
||||
>
|
||||
@@ -273,6 +278,7 @@ function onSelect(_event: Event, row: { original: Article }) {
|
||||
<template #status-header="{ column }">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
class="inline-flex items-center gap-1 text-highlighted"
|
||||
@click.stop="emit('sort', column.id as ArticleSortField)"
|
||||
>
|
||||
@@ -289,6 +295,7 @@ function onSelect(_event: Event, row: { original: Article }) {
|
||||
<template #created_at-header="{ column }">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
class="inline-flex items-center gap-1 text-highlighted"
|
||||
@click.stop="emit('sort', column.id as ArticleSortField)"
|
||||
>
|
||||
@@ -305,6 +312,7 @@ function onSelect(_event: Event, row: { original: Article }) {
|
||||
<template #updated_at-header="{ column }">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
class="inline-flex items-center gap-1 text-highlighted"
|
||||
@click.stop="emit('sort', column.id as ArticleSortField)"
|
||||
>
|
||||
@@ -319,7 +327,9 @@ function onSelect(_event: Event, row: { original: Article }) {
|
||||
</template>
|
||||
|
||||
<template #title-cell="{ row }">
|
||||
<USkeleton v-if="loading" class="h-3 w-3/5 rounded-none bg-accented" />
|
||||
<RouterLink
|
||||
v-else
|
||||
:to="editPath(row.original)"
|
||||
:data-row-title="row.original.id"
|
||||
class="font-medium text-highlighted"
|
||||
@@ -330,7 +340,8 @@ function onSelect(_event: Event, row: { original: Article }) {
|
||||
</template>
|
||||
|
||||
<template #authors-cell="{ row }">
|
||||
<span class="flex min-w-0 items-center" :title="authorNames(row.original)">
|
||||
<USkeleton v-if="loading" class="h-6 w-15 rounded-none bg-accented" />
|
||||
<span v-else class="flex min-w-0 items-center" :title="authorNames(row.original)">
|
||||
<AuthorStack
|
||||
v-if="row.original.authors?.length"
|
||||
:authors="row.original.authors"
|
||||
@@ -340,32 +351,46 @@ function onSelect(_event: Event, row: { original: Article }) {
|
||||
</template>
|
||||
|
||||
<template #site-cell="{ row }">
|
||||
<span class="block truncate text-toned">{{ siteNameOf(row.original) }}</span>
|
||||
<USkeleton v-if="loading" class="h-3 w-17.5 rounded-none bg-accented" />
|
||||
<span v-else class="block truncate text-toned">{{ siteNameOf(row.original) }}</span>
|
||||
</template>
|
||||
|
||||
<template #status-cell="{ row }">
|
||||
<ArticleStatusBadge
|
||||
:online="row.original.is_online"
|
||||
size="sm"
|
||||
:pending="pendingFor(row.original)"
|
||||
:aria-busy="isBusy(row.original) ? 'true' : undefined"
|
||||
/>
|
||||
<USkeleton v-if="loading" class="h-5 w-15 rounded-none bg-accented" />
|
||||
<span v-else class="flex items-center gap-1.5">
|
||||
<ArticleStatusBadge
|
||||
:online="row.original.is_online"
|
||||
size="md"
|
||||
:pending="pendingFor(row.original)"
|
||||
:aria-busy="isBusy(row.original) ? 'true' : undefined"
|
||||
/>
|
||||
<SeoStatusChip
|
||||
v-if="seoChipOf(row.original)"
|
||||
:chip="seoChipOf(row.original)!"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #created_at-cell="{ row }">
|
||||
<span class="text-muted">{{
|
||||
<USkeleton v-if="loading" class="h-3 w-20 rounded-none bg-accented" />
|
||||
<span v-else class="text-muted">{{
|
||||
formatAbsoluteDateFromIso(row.original.created_at)
|
||||
}}</span>
|
||||
</template>
|
||||
|
||||
<template #updated_at-cell="{ row }">
|
||||
<span class="text-muted" :title="row.original.updated_at">{{
|
||||
<USkeleton v-if="loading" class="h-3 w-20 rounded-none bg-accented" />
|
||||
<span v-else class="text-muted" :title="row.original.updated_at">{{
|
||||
formatRelativeDate(row.original.updated_at, now)
|
||||
}}</span>
|
||||
</template>
|
||||
|
||||
<template #actions-cell="{ row }">
|
||||
<UDropdownMenu :items="actionsFor(row.original)" :content="{ align: 'end' }">
|
||||
<UDropdownMenu
|
||||
v-if="!loading"
|
||||
:items="actionsFor(row.original)"
|
||||
:content="{ align: 'end' }"
|
||||
>
|
||||
<UButton
|
||||
icon="i-lucide-ellipsis"
|
||||
color="neutral"
|
||||
|
||||
@@ -78,8 +78,10 @@ function focusOnOpen(event: Event) {
|
||||
nextTick(() => target.focus())
|
||||
}
|
||||
|
||||
function focusFirstInvalid() {
|
||||
nextTick(() => form.value?.$el.querySelector<HTMLElement>(INVALID_SELECTOR)?.focus())
|
||||
async function focusFirstInvalid() {
|
||||
await new Promise((resolve) => setTimeout(resolve))
|
||||
await nextTick()
|
||||
form.value?.$el.querySelector<HTMLElement>(INVALID_SELECTOR)?.focus()
|
||||
}
|
||||
|
||||
defineExpose({ focusFirstInvalid })
|
||||
|
||||
@@ -6,7 +6,13 @@ import { computed } from 'vue'
|
||||
const QUERY_MAX_LENGTH = 40
|
||||
|
||||
const props = defineProps<{
|
||||
variant: 'no-articles' | 'no-results' | 'no-drafts' | 'no-online' | 'no-site'
|
||||
variant:
|
||||
| 'no-articles'
|
||||
| 'no-results'
|
||||
| 'no-drafts'
|
||||
| 'no-online'
|
||||
| 'no-site'
|
||||
| 'needs-attention'
|
||||
query?: string
|
||||
}>()
|
||||
|
||||
@@ -62,6 +68,19 @@ const variants = computed(() => ({
|
||||
action: t('Show all sites'),
|
||||
primary: false,
|
||||
},
|
||||
'needs-attention': {
|
||||
icon: 'i-lucide-circle-check',
|
||||
get title() {
|
||||
return t('Nothing needs attention')
|
||||
},
|
||||
get description() {
|
||||
return t(
|
||||
'Every article has a clean check. Suggestions are optional, so this is just a bonus.',
|
||||
)
|
||||
},
|
||||
action: t('Show all articles'),
|
||||
primary: false,
|
||||
},
|
||||
'no-online': {
|
||||
icon: 'i-lucide-globe',
|
||||
get title() {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
defineProps<{
|
||||
count: number | null
|
||||
}>()
|
||||
|
||||
const pressed = defineModel<boolean>({ default: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UButton
|
||||
role="switch"
|
||||
:aria-checked="pressed"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-lightbulb"
|
||||
data-testid="needs-attention-toggle"
|
||||
class="h-11 shrink-0 justify-start gap-2 border px-2.5 text-md ring-0 md:h-8 md:text-sm"
|
||||
:class="
|
||||
pressed
|
||||
? 'border-(--rc-sand) bg-(--rc-sand) font-medium text-(--rc-on-sand) hover:bg-(--rc-sand)'
|
||||
: 'border-accented bg-default font-normal text-toned'
|
||||
"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="pressed = !pressed"
|
||||
>
|
||||
{{ t('Needs attention') }}
|
||||
<span v-if="count !== null" class="font-mono text-2xs font-medium">{{ count }}</span>
|
||||
</UButton>
|
||||
</template>
|
||||
@@ -13,9 +13,10 @@ const props = withDefaults(
|
||||
phase: SeoCheckPhase
|
||||
status: SeoCheckStatus | null
|
||||
suggestions: SeoSuggestion[]
|
||||
dismissedCount?: number
|
||||
sheet?: boolean
|
||||
}>(),
|
||||
{ sheet: false },
|
||||
{ sheet: false, dismissedCount: 0 },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -24,6 +25,7 @@ const emit = defineEmits<{
|
||||
preview: []
|
||||
retry: []
|
||||
go: [field: SuggestionField]
|
||||
restore: []
|
||||
}>()
|
||||
|
||||
const bodyId = useId()
|
||||
@@ -93,6 +95,8 @@ const buttonClass = computed(
|
||||
compact
|
||||
:phase="phase"
|
||||
:suggestions="suggestions"
|
||||
:dismissed-count="dismissedCount"
|
||||
@restore="emit('restore')"
|
||||
@go="emit('go', $event)"
|
||||
@more="emit('edit', 'suggestions')"
|
||||
@retry="emit('retry')"
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
type PreviewDevice,
|
||||
type SeoPreviewInput,
|
||||
} from '@/lib/seoPreview'
|
||||
import type { SeoFocus, SeoSuggestion, SuggestionField } from '@/lib/seoTypes'
|
||||
import type { SeoFocus, SeoSuggestion, SuggestionCode, SuggestionField } from '@/lib/seoTypes'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const PANEL_FIELDS: SuggestionField[] = [
|
||||
@@ -58,6 +58,8 @@ const props = defineProps<{
|
||||
site: Site | undefined
|
||||
phase: SeoCheckPhase
|
||||
suggestions: SeoSuggestion[]
|
||||
dismissedCount: number
|
||||
dismissingCode: SuggestionCode | null
|
||||
slugVerdict: { available: boolean; suggestion: string | null } | null
|
||||
checkedSlug: string | undefined
|
||||
slugTakenOnSave: string | null
|
||||
@@ -74,6 +76,8 @@ const emit = defineEmits<{
|
||||
close: []
|
||||
preview: []
|
||||
go: [field: SuggestionField]
|
||||
dismiss: [code: SuggestionCode]
|
||||
restore: []
|
||||
}>()
|
||||
|
||||
const [DefineSuggestions, Suggestions] = createReusableTemplate()
|
||||
@@ -349,6 +353,10 @@ const areaUi = { base: `min-h-30 text-lg md:min-h-24 md:text-sm ${DISABLED_FIELD
|
||||
<SuggestionList
|
||||
:phase="phase"
|
||||
:suggestions="suggestions"
|
||||
:dismissed-count="dismissedCount"
|
||||
:dismissing-code="dismissingCode"
|
||||
@dismiss="emit('dismiss', $event)"
|
||||
@restore="emit('restore')"
|
||||
@go="goTo"
|
||||
@retry="emit('check', true)"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { SeoChip } from '@/lib/articleSeoStatus'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
chip: SeoChip
|
||||
size?: 'sm' | 'md'
|
||||
}>(),
|
||||
{ size: 'md' },
|
||||
)
|
||||
|
||||
const description = computed(() => {
|
||||
if (props.chip.kind === 'hidden') {
|
||||
return t('Hidden from search engines')
|
||||
}
|
||||
return props.chip.count === 1 ? t('1 suggestion') : t('{0} suggestions', [props.chip.count])
|
||||
})
|
||||
|
||||
const appearance = computed(() =>
|
||||
props.chip.kind === 'hidden'
|
||||
? { icon: 'i-lucide-eye-off', class: 'border border-accented bg-transparent text-toned' }
|
||||
: {
|
||||
icon: 'i-lucide-lightbulb',
|
||||
class: 'border border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning',
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UBadge
|
||||
role="img"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
:icon="appearance.icon"
|
||||
:title="description"
|
||||
:aria-label="description"
|
||||
data-testid="seo-status-chip"
|
||||
:class="appearance.class"
|
||||
:ui="{
|
||||
base: [
|
||||
size === 'md' ? 'h-5.5' : 'h-5',
|
||||
'shrink-0 gap-1 px-1.5 py-0 text-xs font-medium whitespace-nowrap',
|
||||
],
|
||||
leadingIcon: 'size-3',
|
||||
}"
|
||||
>
|
||||
{{ chip.kind === 'hidden' ? t('Hidden') : chip.count }}
|
||||
</UBadge>
|
||||
</template>
|
||||
@@ -2,17 +2,25 @@
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import CharacterCounter from '@/components/CharacterCounter.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 {
|
||||
useSites,
|
||||
type NewSite,
|
||||
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,
|
||||
characterCount,
|
||||
siteUrlMessage,
|
||||
siteUrlProblem,
|
||||
suggestFreeSlug,
|
||||
@@ -20,6 +28,14 @@ import {
|
||||
import { copy } from '@/lib/directoryCopy'
|
||||
import { classifyFailure } from '@/lib/directoryErrors'
|
||||
import { baseUrlProblem, nameProblem, siteKeyProblem } from '@/lib/directoryRules'
|
||||
import {
|
||||
DEFAULT_DESCRIPTION_MAX,
|
||||
DEFAULT_TITLE_TEMPLATE,
|
||||
SITE_NAME_MAX,
|
||||
TITLE_TEMPLATE_MAX,
|
||||
exampleTitle,
|
||||
templateProblem,
|
||||
} from '@/lib/siteTitleTemplate'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
|
||||
const PREVIEW_FALLBACK_URL = 'https://journal.example.com'
|
||||
@@ -47,6 +63,9 @@ const currentDefault = computed(() => props.sites.find((site) => site.is_default
|
||||
const values = reactive({
|
||||
name: props.site?.name ?? '',
|
||||
baseUrl: props.site?.base_url ?? '',
|
||||
siteName: props.site?.site_name ?? '',
|
||||
template: props.site?.title_template ?? DEFAULT_TITLE_TEMPLATE,
|
||||
description: props.site?.default_description ?? '',
|
||||
imageId: props.site?.default_social_image_id ?? null,
|
||||
robots: (props.site?.default_robots ?? 'index') as SiteRobots,
|
||||
locale: props.site?.locale ?? '',
|
||||
@@ -62,6 +81,7 @@ const errors = reactive({
|
||||
suggestion: '',
|
||||
url: '',
|
||||
locale: '',
|
||||
template: '',
|
||||
})
|
||||
const saving = ref(false)
|
||||
const notice = ref<DirectoryNotice | null>(null)
|
||||
@@ -72,6 +92,9 @@ function snapshot() {
|
||||
values.name,
|
||||
slug.value.value,
|
||||
values.baseUrl,
|
||||
values.siteName,
|
||||
values.template,
|
||||
values.description,
|
||||
values.imageId,
|
||||
values.robots,
|
||||
values.locale,
|
||||
@@ -102,12 +125,23 @@ const switchHelp = computed(() =>
|
||||
currentDefault.value?.name ?? '',
|
||||
]),
|
||||
)
|
||||
const example = computed(() => exampleTitle(values.template, values.siteName, values.name))
|
||||
const descriptionCount = computed(() => characterCount(values.description))
|
||||
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 defaultsRows = computed(() => [
|
||||
{ label: t('Title'), text: t('Article title, then the template.') },
|
||||
{
|
||||
label: t('Description'),
|
||||
text: t("Used last, after the article's description, excerpt and body."),
|
||||
},
|
||||
{ label: t('Image'), text: t('Used when an article has no cover image.') },
|
||||
{ label: t('Robots'), text: t('Used unless an article is hidden from search engines.') },
|
||||
])
|
||||
const picker = computed(() => ({
|
||||
title: t('Choose an image'),
|
||||
description: t('The default social image for {0}.', [values.name || t('this site')]),
|
||||
@@ -190,11 +224,24 @@ function checkKeyShape() {
|
||||
return errors.key === ''
|
||||
}
|
||||
|
||||
function onTemplate(value: string) {
|
||||
values.template = value
|
||||
if (errors.template && templateProblem(value) === '') {
|
||||
errors.template = ''
|
||||
}
|
||||
}
|
||||
|
||||
function checkTemplate() {
|
||||
errors.template = templateProblem(values.template)
|
||||
return errors.template === ''
|
||||
}
|
||||
|
||||
function validate() {
|
||||
const nameOk = checkName()
|
||||
const keyOk = nameOk ? checkKeyShape() : true
|
||||
const urlOk = checkUrl()
|
||||
return nameOk && keyOk && urlOk
|
||||
const templateOk = checkTemplate()
|
||||
return nameOk && keyOk && urlOk && templateOk
|
||||
}
|
||||
|
||||
function changedFields(site: Site) {
|
||||
@@ -205,6 +252,15 @@ function changedFields(site: Site) {
|
||||
if (values.baseUrl.trim() !== site.base_url) {
|
||||
patch.base_url = values.baseUrl.trim()
|
||||
}
|
||||
if (values.siteName.trim() !== (site.site_name ?? '')) {
|
||||
patch.site_name = values.siteName.trim()
|
||||
}
|
||||
if (values.template.trim() !== (site.title_template ?? DEFAULT_TITLE_TEMPLATE)) {
|
||||
patch.title_template = values.template.trim()
|
||||
}
|
||||
if (values.description.trim() !== (site.default_description ?? '')) {
|
||||
patch.default_description = values.description.trim()
|
||||
}
|
||||
if (values.robots !== site.default_robots) {
|
||||
patch.default_robots = values.robots
|
||||
}
|
||||
@@ -217,6 +273,20 @@ function changedFields(site: Site) {
|
||||
return patch
|
||||
}
|
||||
|
||||
function newSeoFields() {
|
||||
const fields: Pick<NewSite, 'site_name' | 'title_template' | 'default_description'> = {}
|
||||
if (values.siteName.trim()) {
|
||||
fields.site_name = values.siteName.trim()
|
||||
}
|
||||
if (values.template.trim() !== DEFAULT_TITLE_TEMPLATE) {
|
||||
fields.title_template = values.template.trim()
|
||||
}
|
||||
if (values.description.trim()) {
|
||||
fields.default_description = values.description.trim()
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
async function create() {
|
||||
const created = await createSite({
|
||||
name: values.name.trim(),
|
||||
@@ -225,6 +295,7 @@ async function create() {
|
||||
default_robots: values.robots,
|
||||
...(values.locale.trim() ? { locale: values.locale.trim() } : {}),
|
||||
...(values.imageId !== null ? { default_social_image_id: values.imageId } : {}),
|
||||
...newSeoFields(),
|
||||
is_default: values.makeDefault,
|
||||
})
|
||||
emit('saved', created)
|
||||
@@ -257,6 +328,10 @@ function handleFailure(error: unknown) {
|
||||
errors.url = siteUrlMessage('invalid')
|
||||
return
|
||||
}
|
||||
if (kind === 'invalidTitleTemplate') {
|
||||
errors.template = t('The template must contain %title%.')
|
||||
return
|
||||
}
|
||||
if (kind === 'invalidLocale') {
|
||||
errors.locale = t('Use a language tag like en or fr-CH.')
|
||||
return
|
||||
@@ -370,6 +445,69 @@ async function submit() {
|
||||
:picker="picker"
|
||||
/>
|
||||
|
||||
<DirectoryField
|
||||
:label="t('Site name')"
|
||||
:help="t('Shown in titles through the template, and sent as the Open Graph site name.')"
|
||||
>
|
||||
<UInput
|
||||
:model-value="values.siteName"
|
||||
:placeholder="t('e.g. Renew')"
|
||||
:maxlength="SITE_NAME_MAX"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 text-lg md:h-10 md:text-sm' }"
|
||||
@update:model-value="values.siteName = String($event)"
|
||||
/>
|
||||
</DirectoryField>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-1.5">
|
||||
<DirectoryField
|
||||
:label="t('Title template')"
|
||||
:error="errors.template"
|
||||
:help="t('Use %title% for the article title and %site% for the site name.')"
|
||||
>
|
||||
<UInput
|
||||
:model-value="values.template"
|
||||
:color="errors.template ? 'error' : undefined"
|
||||
:maxlength="TITLE_TEMPLATE_MAX"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 font-mono text-base md:h-10 md:text-code' }"
|
||||
@update:model-value="onTemplate(String($event))"
|
||||
@blur="checkTemplate"
|
||||
/>
|
||||
</DirectoryField>
|
||||
<div
|
||||
v-if="!errors.template"
|
||||
class="flex min-w-0 items-center gap-1.5 bg-elevated px-2.5 py-2"
|
||||
>
|
||||
<span class="shrink-0 text-xs text-muted">{{ t('Example') }}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-highlighted">{{ example }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DirectoryField
|
||||
:label="t('Default description')"
|
||||
optional
|
||||
:help="t('Used when an article has no description, excerpt or body text.')"
|
||||
>
|
||||
<template #hint>
|
||||
<CharacterCounter
|
||||
:value="descriptionCount"
|
||||
:max="DEFAULT_DESCRIPTION_MAX"
|
||||
variant="field"
|
||||
/>
|
||||
</template>
|
||||
<UTextarea
|
||||
:model-value="values.description"
|
||||
:placeholder="t('One or two sentences about the site.')"
|
||||
:maxlength="DEFAULT_DESCRIPTION_MAX"
|
||||
class="w-full"
|
||||
:ui="{ base: 'min-h-30 text-lg md:min-h-24 md:text-sm' }"
|
||||
@update:model-value="values.description = String($event)"
|
||||
/>
|
||||
</DirectoryField>
|
||||
|
||||
<DirectoryField :label="t('Default robots policy')" :help="robotsHelp">
|
||||
<URadioGroup
|
||||
v-model="values.robots"
|
||||
@@ -430,6 +568,13 @@ async function submit() {
|
||||
t('Shown when the base URL is valid.')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5 bg-elevated p-3">
|
||||
<span class="text-xs text-muted">{{ t('How articles use these defaults') }}</span>
|
||||
<div v-for="row in defaultsRows" :key="row.label" class="flex gap-2">
|
||||
<span class="w-21 shrink-0 font-medium text-highlighted">{{ row.label }}</span>
|
||||
<span class="text-pretty text-toned">{{ row.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DirectoryPanel>
|
||||
</template>
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { isChecking, type SeoCheckPhase } from '@/composables/useSeoCheck'
|
||||
import { suggestionLinkLabel, suggestionMessage } from '@/lib/seoCopy'
|
||||
import type { SeoSuggestion, SuggestionField } from '@/lib/seoTypes'
|
||||
import type { SeoSuggestion, SuggestionCode, SuggestionField } from '@/lib/seoTypes'
|
||||
|
||||
const COMPACT_LIMIT = 3
|
||||
const SKELETON_WIDTHS = ['w-7/10', 'w-9/10', 'w-11/20']
|
||||
@@ -14,14 +14,18 @@ const props = withDefaults(
|
||||
phase: SeoCheckPhase
|
||||
suggestions: SeoSuggestion[]
|
||||
compact?: boolean
|
||||
dismissedCount?: number
|
||||
dismissingCode?: SuggestionCode | null
|
||||
}>(),
|
||||
{ compact: false },
|
||||
{ compact: false, dismissedCount: 0, dismissingCode: null },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
go: [field: SuggestionField]
|
||||
more: []
|
||||
retry: []
|
||||
dismiss: [code: SuggestionCode]
|
||||
restore: []
|
||||
}>()
|
||||
|
||||
const shown = computed(() =>
|
||||
@@ -29,10 +33,57 @@ const shown = computed(() =>
|
||||
)
|
||||
const hiddenCount = computed(() => props.suggestions.length - shown.value.length)
|
||||
const checking = computed(() => isChecking(props.phase))
|
||||
const dismissedText = computed(() =>
|
||||
props.dismissedCount === 1
|
||||
? t('1 dismissed suggestion is hidden.')
|
||||
: t('{0} dismissed suggestions are hidden.', [props.dismissedCount]),
|
||||
)
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
let removedCode: SuggestionCode | null = null
|
||||
let removedIndex = 0
|
||||
|
||||
function dismiss(code: SuggestionCode, index: number) {
|
||||
removedCode = code
|
||||
removedIndex = index
|
||||
emit('dismiss', code)
|
||||
}
|
||||
|
||||
async function focusAfterRemoval() {
|
||||
await nextTick()
|
||||
const buttons = root.value?.querySelectorAll<HTMLElement>('[data-testid="suggestion-dismiss"]')
|
||||
if (buttons && buttons.length > 0) {
|
||||
buttons[Math.min(removedIndex, buttons.length - 1)]?.focus()
|
||||
return
|
||||
}
|
||||
root.value?.querySelector<HTMLElement>('[data-testid="suggestions-restore"]')?.focus()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.suggestions.map((suggestion) => suggestion.code),
|
||||
(codes) => {
|
||||
if (removedCode !== null && !codes.includes(removedCode)) {
|
||||
removedCode = null
|
||||
focusAfterRemoval()
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.dismissingCode,
|
||||
(code) => {
|
||||
if (code === null && removedCode !== null) {
|
||||
removedCode = null
|
||||
focusAfterRemoval()
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="root"
|
||||
class="flex flex-col gap-3 bg-elevated p-3"
|
||||
:aria-busy="checking ? 'true' : undefined"
|
||||
data-testid="suggestion-list"
|
||||
@@ -69,12 +120,24 @@ const checking = computed(() => isChecking(props.phase))
|
||||
<UIcon name="i-lucide-circle-check" class="mt-0.5 size-3.5 shrink-0 text-success" />
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-semibold text-highlighted">{{ t('All good') }}</span>
|
||||
<span class="text-muted">{{ t('Nothing to suggest for this article.') }}</span>
|
||||
<span class="text-muted">{{
|
||||
dismissedCount > 0 ? dismissedText : t('Nothing to suggest for this article.')
|
||||
}}</span>
|
||||
<UButton
|
||||
v-if="dismissedCount > 0"
|
||||
color="neutral"
|
||||
variant="link"
|
||||
class="min-h-11 self-start p-0 text-xs font-medium text-highlighted underline underline-offset-3 md:min-h-5"
|
||||
data-testid="suggestions-restore"
|
||||
@click="emit('restore')"
|
||||
>
|
||||
{{ t('Show dismissed') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="suggestion in shown"
|
||||
v-for="(suggestion, index) in shown"
|
||||
:key="suggestion.code"
|
||||
class="flex items-start gap-2"
|
||||
data-testid="suggestion"
|
||||
@@ -95,13 +158,47 @@ const checking = computed(() => isChecking(props.phase))
|
||||
{{ suggestionLinkLabel(suggestion.field) }}
|
||||
</UButton>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="!compact"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-lucide-x"
|
||||
:loading="dismissingCode === suggestion.code"
|
||||
:disabled="dismissingCode !== null"
|
||||
:aria-busy="dismissingCode === suggestion.code ? 'true' : undefined"
|
||||
:aria-label="t('Dismiss suggestion: {0}', [suggestionMessage(suggestion)])"
|
||||
class="size-11 shrink-0 justify-center p-0 text-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary md:size-6"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
data-testid="suggestion-dismiss"
|
||||
@click="dismiss(suggestion.code, index)"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
v-if="!compact"
|
||||
class="text-xs/4.5 text-pretty text-muted"
|
||||
data-testid="suggestions-note"
|
||||
>{{ t('Suggestions never block saving or publishing.') }}</span
|
||||
>{{
|
||||
t(
|
||||
'Suggestions never block saving or publishing. Dismiss one and it stays hidden for this article.',
|
||||
)
|
||||
}}</span
|
||||
>
|
||||
<div
|
||||
v-if="!compact && dismissedCount > 0"
|
||||
class="flex flex-col gap-0.5"
|
||||
data-testid="suggestions-dismissed"
|
||||
>
|
||||
<span class="text-xs/4.5 text-muted">{{ dismissedText }}</span>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="link"
|
||||
class="min-h-11 self-start p-0 text-xs font-medium text-highlighted underline underline-offset-3 md:min-h-5"
|
||||
data-testid="suggestions-restore"
|
||||
@click="emit('restore')"
|
||||
>
|
||||
{{ t('Show dismissed') }}
|
||||
</UButton>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="hiddenCount > 0"
|
||||
color="neutral"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { DEFAULT_ARTICLE_SORT } from '@/lib/articleListQuery'
|
||||
import { useArticleRowActions } from '../useArticleRowActions'
|
||||
|
||||
const confirmDelete = vi.fn()
|
||||
|
||||
vi.mock('@/composables/useArticleActions', () => ({
|
||||
useArticleActions: () => ({
|
||||
confirmDelete: (...args: unknown[]) => confirmDelete(...args),
|
||||
publish: vi.fn(),
|
||||
deleteArticle: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/presentation', () => ({
|
||||
useLocalizedToast: () => ({ add: vi.fn() }),
|
||||
}))
|
||||
|
||||
const article = {
|
||||
id: 4,
|
||||
title: 'Winter checklist',
|
||||
body: '',
|
||||
is_online: false,
|
||||
created_at: '2026-09-02T09:00:00Z',
|
||||
updated_at: '2026-09-05T09:00:00Z',
|
||||
} as Article
|
||||
|
||||
function setup() {
|
||||
return useArticleRowActions({
|
||||
items: ref([article]),
|
||||
total: ref(1),
|
||||
counts: ref({ all: 1, online: 0, draft: 1 }),
|
||||
shown: ref({
|
||||
page: 1,
|
||||
size: 25,
|
||||
q: '',
|
||||
status: '',
|
||||
sort: DEFAULT_ARTICLE_SORT,
|
||||
site: '',
|
||||
seo: '',
|
||||
}),
|
||||
reload: vi.fn(),
|
||||
})
|
||||
}
|
||||
|
||||
describe('handleDelete', () => {
|
||||
it('opens one confirm dialog when asked twice in a row', async () => {
|
||||
confirmDelete.mockReset()
|
||||
let settle: (value: boolean) => void = () => undefined
|
||||
confirmDelete.mockReturnValue(new Promise<boolean>((resolve) => (settle = resolve)))
|
||||
const { handleDelete } = setup()
|
||||
|
||||
const first = handleDelete(article)
|
||||
const second = handleDelete(article)
|
||||
expect(confirmDelete).toHaveBeenCalledTimes(1)
|
||||
|
||||
settle(false)
|
||||
await Promise.all([first, second])
|
||||
})
|
||||
|
||||
it('allows another dialog once the first one is settled', async () => {
|
||||
confirmDelete.mockReset()
|
||||
confirmDelete.mockResolvedValue(false)
|
||||
const { handleDelete } = setup()
|
||||
|
||||
await handleDelete(article)
|
||||
await handleDelete(article)
|
||||
|
||||
expect(confirmDelete).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -180,9 +180,19 @@ export function useArticleRowActions({
|
||||
document.querySelector<HTMLElement>('[data-testid="articles-heading"]')?.focus()
|
||||
}
|
||||
|
||||
let deleteDialogOpen = false
|
||||
|
||||
async function handleDelete(article: Article) {
|
||||
if (deleteDialogOpen) {
|
||||
return
|
||||
}
|
||||
deleteDialogOpen = true
|
||||
const idsAtOpen = items.value.map((existing) => existing.id)
|
||||
const confirmed = await actions.confirmDelete(article, () => performDelete(article))
|
||||
const confirmed = await actions
|
||||
.confirmDelete(article, () => performDelete(article))
|
||||
.finally(() => {
|
||||
deleteDialogOpen = false
|
||||
})
|
||||
if (confirmed) {
|
||||
focusRowOrHeading(idsAtOpen, article.id)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { SuggestionCode } from '@/lib/seoTypes'
|
||||
import type { ArticleSeo } from '@/lib/articleSeo'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { Byline } from '@/lib/articleTypes'
|
||||
import type { ArticleStatus } from '@/lib/articleListQuery'
|
||||
import type { ArticleStatus, SEO_FILTER } from '@/lib/articleListQuery'
|
||||
|
||||
export type { Byline }
|
||||
|
||||
@@ -14,6 +15,9 @@ export interface Article extends Partial<ArticleSeo> {
|
||||
authors?: Byline[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
seo_status?: string
|
||||
seo_dismissed?: SuggestionCode[]
|
||||
seo_suggestions?: number
|
||||
}
|
||||
|
||||
export type { ArticleStatus }
|
||||
@@ -39,6 +43,13 @@ export interface ListArticlesParams {
|
||||
status?: ArticleStatus | ''
|
||||
sort?: string
|
||||
site?: string
|
||||
seo?: '' | typeof SEO_FILTER
|
||||
}
|
||||
|
||||
export interface CountNeedsAttentionParams {
|
||||
q?: string
|
||||
status?: ArticleStatus | ''
|
||||
site?: string
|
||||
}
|
||||
|
||||
export function useArticles() {
|
||||
@@ -62,8 +73,26 @@ export function useArticles() {
|
||||
if (params.site) {
|
||||
query.set('site', params.site)
|
||||
}
|
||||
if (params.seo) {
|
||||
query.set('seo', params.seo)
|
||||
}
|
||||
return request<ArticlePage>(`/v1/article/?${query.toString()}`)
|
||||
}
|
||||
|
||||
return { listArticles }
|
||||
async function countNeedsAttention(params: CountNeedsAttentionParams): Promise<number> {
|
||||
const query = new URLSearchParams({ seo: 'suggestions', page: '1', size: '5' })
|
||||
if (params.q) {
|
||||
query.set('q', params.q)
|
||||
}
|
||||
if (params.status) {
|
||||
query.set('status', params.status)
|
||||
}
|
||||
if (params.site) {
|
||||
query.set('site', params.site)
|
||||
}
|
||||
const page = await request<Pick<ArticlePage, 'total'>>(`/v1/article/?${query.toString()}`)
|
||||
return page.total
|
||||
}
|
||||
|
||||
return { listArticles, countNeedsAttention }
|
||||
}
|
||||
@@ -43,11 +43,13 @@ export function useSeoCheck(options: SeoCheckOptions) {
|
||||
}, SEO_CHECK_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
async function send(body: SeoCheckBody, payload: string) {
|
||||
async function send(body: SeoCheckBody, payload: string, silent: boolean) {
|
||||
controller?.abort()
|
||||
controller = new AbortController()
|
||||
const current = ++ticket
|
||||
phase.value = 'pending'
|
||||
if (!silent) {
|
||||
phase.value = 'pending'
|
||||
}
|
||||
try {
|
||||
const answer = await request<SeoCheckResult>(
|
||||
`/v1/article/${options.articleId}/seo/check`,
|
||||
@@ -81,7 +83,7 @@ export function useSeoCheck(options: SeoCheckOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
function run(force = false) {
|
||||
function run(force = false, silent = false) {
|
||||
clearTimer()
|
||||
const body = options.body()
|
||||
const payload = JSON.stringify(body)
|
||||
@@ -89,7 +91,7 @@ export function useSeoCheck(options: SeoCheckOptions) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
lastPayload = payload
|
||||
return send(body, payload)
|
||||
return send(body, payload, silent)
|
||||
}
|
||||
|
||||
function runNow() {
|
||||
@@ -100,6 +102,26 @@ export function useSeoCheck(options: SeoCheckOptions) {
|
||||
return run(true)
|
||||
}
|
||||
|
||||
function runQuietly() {
|
||||
return run(true, true)
|
||||
}
|
||||
|
||||
function dropSuggestion(code: string) {
|
||||
const current = result.value
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
const remaining = current.suggestions.filter((suggestion) => suggestion.code !== code)
|
||||
result.value = {
|
||||
...current,
|
||||
suggestions: remaining,
|
||||
status:
|
||||
remaining.length === 0 && current.status === 'suggestions'
|
||||
? 'ready'
|
||||
: current.status,
|
||||
}
|
||||
}
|
||||
|
||||
function restore() {
|
||||
const payload = JSON.stringify(options.body())
|
||||
const answer = answers.get(payload)
|
||||
@@ -143,6 +165,8 @@ export function useSeoCheck(options: SeoCheckOptions) {
|
||||
schedule,
|
||||
runNow,
|
||||
runFresh,
|
||||
runQuietly,
|
||||
dropSuggestion,
|
||||
restore,
|
||||
invalidate,
|
||||
stop,
|
||||
|
||||
@@ -26,6 +26,9 @@ export interface NewSite {
|
||||
default_robots: SiteRobots
|
||||
locale?: string
|
||||
default_social_image_id?: number
|
||||
site_name?: string
|
||||
title_template?: string
|
||||
default_description?: string
|
||||
is_default: boolean
|
||||
}
|
||||
|
||||
@@ -35,6 +38,9 @@ export interface SitePatch {
|
||||
default_robots?: SiteRobots
|
||||
locale?: string | null
|
||||
default_social_image_id?: number | null
|
||||
site_name?: string
|
||||
title_template?: string
|
||||
default_description?: string
|
||||
}
|
||||
|
||||
export function useSites() {
|
||||
|
||||
@@ -21,7 +21,6 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
|
||||
Articles: [
|
||||
'GET /v1/article/',
|
||||
'GET /v1/article/{id}',
|
||||
'GET /v1/article/{id}/seo',
|
||||
'POST /v1/article/',
|
||||
'PATCH /v1/article/{id}',
|
||||
'PATCH /v1/article/{id}/publish',
|
||||
@@ -29,6 +28,7 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
|
||||
'DELETE /v1/article/{id}',
|
||||
'POST /v1/article/{id}/seo/check',
|
||||
],
|
||||
'Search and sharing': ['GET /v1/article/{id}/seo', 'GET /v1/site/{key}/sitemap'],
|
||||
Media: [
|
||||
'GET /v1/media/',
|
||||
'POST /v1/media/',
|
||||
@@ -50,7 +50,7 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
|
||||
'GET /v1/auth/me',
|
||||
],
|
||||
Settings: ['GET /v1/settings'],
|
||||
Sites: ['GET /v1/site/', 'GET /v1/site/{key}/sitemap'],
|
||||
Sites: ['GET /v1/site/'],
|
||||
Authors: ['GET /v1/author/'],
|
||||
}
|
||||
|
||||
@@ -207,7 +207,16 @@ function expectOnlyKeys(value: unknown, allowed: string[]) {
|
||||
}
|
||||
|
||||
describe('Catalogue routes', () => {
|
||||
it('holds exactly the 29 key-callable routes in 6 groups, in order', () => {
|
||||
it('holds exactly the 29 key-callable routes in 7 groups, in order', () => {
|
||||
expect(API_GROUPS.map((group) => group.title)).toEqual([
|
||||
'Articles',
|
||||
'Search and sharing',
|
||||
'Media',
|
||||
'Users and invites',
|
||||
'Settings',
|
||||
'Sites',
|
||||
'Authors',
|
||||
])
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]),
|
||||
@@ -242,7 +251,7 @@ describe('Catalogue routes', () => {
|
||||
),
|
||||
...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const),
|
||||
]
|
||||
expect(described).toHaveLength(6 + 29 + 25 + 6)
|
||||
expect(described).toHaveLength(7 + 29 + 25 + 6)
|
||||
const blank = described.filter(([, description]) => description.trim() === '')
|
||||
expect(blank.map(([name]) => name)).toEqual([])
|
||||
})
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface ErrorRow {
|
||||
status: number
|
||||
meaning: string
|
||||
messages: string[]
|
||||
note?: string
|
||||
}
|
||||
|
||||
export interface EndpointExamples {
|
||||
@@ -596,15 +597,6 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
json(article()),
|
||||
{ query: [FORMAT_QUERY] },
|
||||
),
|
||||
endpoint(
|
||||
'art-seo',
|
||||
'GET',
|
||||
'/v1/article/{id}/seo',
|
||||
'Get the resolved SEO object of an article: title, description, canonical address, robots, Open Graph, Twitter, image, authors, dates and ready JSON-LD. Drafts answer 404 unless the caller is verified.',
|
||||
'Public',
|
||||
[e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Article not found')],
|
||||
json(resolvedSeo()),
|
||||
),
|
||||
endpoint(
|
||||
'art-create',
|
||||
'POST',
|
||||
@@ -821,6 +813,33 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'seo',
|
||||
title: 'Search and sharing',
|
||||
description:
|
||||
"RenewCMS doesn't render pages or add meta tags. These endpoints give your frontend the resolved SEO data and the list of published URLs.",
|
||||
endpoints: [
|
||||
endpoint(
|
||||
'art-seo',
|
||||
'GET',
|
||||
'/v1/article/{id}/seo',
|
||||
'Get the resolved SEO object of an article: title with the site template applied, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks and json_ld. Drafts answer 404 to anonymous callers.',
|
||||
'Public',
|
||||
[e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Article not found')],
|
||||
json(resolvedSeo()),
|
||||
),
|
||||
endpoint(
|
||||
'site-sitemap',
|
||||
'GET',
|
||||
'/v1/site/{key}/sitemap',
|
||||
'List the published URLs of a site, by id ascending, as an array of { url, modified_at, noindex }. The total is in the X-Total-Count header.',
|
||||
'Public',
|
||||
[e(400, BODY_MISMATCH), e(401, UNAUTHORIZED), e(404, 'site_not_found')],
|
||||
json(sitemapPage()),
|
||||
{ query: SITEMAP_QUERY },
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'media',
|
||||
title: 'Media',
|
||||
@@ -1199,7 +1218,7 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
key: 'sites',
|
||||
title: 'Sites',
|
||||
description:
|
||||
'The sites an article can belong to. Any verified user can read them and anyone can read a sitemap; changes are made in the browser by the owner.',
|
||||
'The sites an article can belong to. Any verified user can read them; changes are made in the browser by the owner.',
|
||||
endpoints: [
|
||||
endpoint(
|
||||
'site-list',
|
||||
@@ -1226,16 +1245,6 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
}),
|
||||
]),
|
||||
),
|
||||
endpoint(
|
||||
'site-sitemap',
|
||||
'GET',
|
||||
'/v1/site/{key}/sitemap',
|
||||
'List the published URLs of a site, by id ascending, as an array. The total is in the X-Total-Count header.',
|
||||
'Public',
|
||||
[e(400, BODY_MISMATCH), e(401, UNAUTHORIZED), e(404, 'site_not_found')],
|
||||
json(sitemapPage()),
|
||||
{ query: SITEMAP_QUERY },
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1407,6 +1416,7 @@ export const ERROR_ROWS: ErrorRow[] = [
|
||||
status: 403,
|
||||
meaning: 'Forbidden',
|
||||
messages: [SESSION_REQUIRED_MESSAGE, OWNER_REQUIRED_MESSAGE, VERIFY_REQUIRED],
|
||||
note: 'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.',
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
|
||||
@@ -29,6 +29,7 @@ const SOURCES = [
|
||||
'lib/directory.ts',
|
||||
'lib/directoryCopy.ts',
|
||||
'lib/directoryRules.ts',
|
||||
'lib/siteTitleTemplate.ts',
|
||||
'views/AuthorsView.vue',
|
||||
'views/SitesView.vue',
|
||||
]
|
||||
@@ -44,6 +45,9 @@ const SAME_IN_FRENCH = new Set([
|
||||
'Sites',
|
||||
'Guest',
|
||||
'Linked',
|
||||
'Description',
|
||||
'Image',
|
||||
'Robots',
|
||||
])
|
||||
|
||||
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
|
||||
|
||||
+115
-4
@@ -1967,10 +1967,6 @@ export const messages = {
|
||||
'Lié à votre compte. Les nouveaux articles commencent avec cet auteur.',
|
||||
"You don't have an author profile yet. Create one to be credited on your own articles.":
|
||||
'Vous n’avez pas encore de profil d’auteur. Créez-en un pour être crédité sur vos propres articles.',
|
||||
'The sites an article can belong to. Any verified user can read them and anyone can read a sitemap; changes are made in the browser by the owner.':
|
||||
'Les sites auxquels un article peut appartenir. Tout utilisateur vérifié peut les lire et tout le monde peut lire un sitemap ; les modifications se font dans le navigateur par le propriétaire.',
|
||||
'List the published URLs of a site, by id ascending, as an array. The total is in the X-Total-Count header.':
|
||||
'Liste les adresses publiées d’un site, par identifiant croissant, sous forme de tableau. Le total est dans l’en-tête X-Total-Count.',
|
||||
'The request cannot be processed due to a mismatch in the format of the body.':
|
||||
'La requête ne peut pas être traitée car le format du corps ne correspond pas.',
|
||||
site_not_found: 'Site introuvable',
|
||||
@@ -2144,6 +2140,121 @@ export const messages = {
|
||||
'cover image': 'image de couverture',
|
||||
'site social image': 'image de partage du site',
|
||||
'site address + slug': 'adresse du site + slug',
|
||||
'Site name': 'Nom du site',
|
||||
'Shown in titles through the template, and sent as the Open Graph site name.':
|
||||
'Affiché dans les titres via le modèle, et envoyé comme nom de site Open Graph.',
|
||||
'e.g. Renew': 'p. ex. Renew',
|
||||
'Title template': 'Modèle de titre',
|
||||
'Use %title% for the article title and %site% for the site name.':
|
||||
'Utilisez %title% pour le titre de l’article et %site% pour le nom du site.',
|
||||
'The template must contain %title%.': 'Le modèle doit contenir %title%.',
|
||||
Example: 'Exemple',
|
||||
'Default description': 'Description par défaut',
|
||||
'Used when an article has no description, excerpt or body text.':
|
||||
'Utilisée quand un article n’a ni description, ni extrait, ni texte.',
|
||||
'One or two sentences about the site.': 'Une ou deux phrases sur le site.',
|
||||
'How articles use these defaults': 'Comment les articles utilisent ces valeurs par défaut',
|
||||
'Article title, then the template.': 'Titre de l’article, puis le modèle.',
|
||||
Description: 'Description',
|
||||
"Used last, after the article's description, excerpt and body.":
|
||||
'Utilisée en dernier, après la description, l’extrait et le texte de l’article.',
|
||||
'Used when an article has no cover image.':
|
||||
'Utilisée quand un article n’a pas d’image de couverture.',
|
||||
Robots: 'Robots',
|
||||
'Used unless an article is hidden from search engines.':
|
||||
'Utilisée sauf si l’article est masqué des moteurs de recherche.',
|
||||
'Needs attention': 'À traiter',
|
||||
Hidden: 'Masqué',
|
||||
'Hidden from search engines': 'Masqué des moteurs de recherche',
|
||||
'Nothing needs attention': 'Rien à traiter',
|
||||
'Every article has a clean check. Suggestions are optional, so this is just a bonus.':
|
||||
'Tous les articles passent la vérification. Les suggestions sont facultatives, c’est un petit plus.',
|
||||
'Show all articles': 'Afficher tous les articles',
|
||||
'1 dismissed suggestion is hidden.': '1 suggestion ignorée est masquée.',
|
||||
'{0} dismissed suggestions are hidden.': '{0} suggestions ignorées sont masquées.',
|
||||
'Show dismissed': 'Afficher les suggestions ignorées',
|
||||
'Dismiss suggestion: {0}': 'Ignorer la suggestion : {0}',
|
||||
'Suggestions never block saving or publishing. Dismiss one and it stays hidden for this article.':
|
||||
'Les suggestions ne bloquent jamais l’enregistrement ni la publication. Ignorez-en une et elle reste masquée pour cet article.',
|
||||
"Couldn't dismiss the suggestion": 'Impossible d’ignorer la suggestion',
|
||||
'The key of a site. Only its articles are listed. An unknown key answers an empty page.':
|
||||
'La clé d’un site. Seuls ses articles sont listés. Une clé inconnue renvoie une page vide.',
|
||||
'ready, suggestions or hidden. Only the articles with that SEO status are listed, before paging; counts stay unfiltered. Any other value is refused.':
|
||||
'ready, suggestions ou hidden. Seuls les articles ayant ce statut SEO sont listés, avant la pagination ; les compteurs restent non filtrés. Toute autre valeur est refusée.',
|
||||
'Update the body of an article, move it to another site, or edit its SEO fields. Send at least one field; absent fields are untouched.':
|
||||
'Modifie le corps d’un article, le déplace vers un autre site ou modifie ses champs SEO. Envoyez au moins un champ ; les champs absents ne sont pas touchés.',
|
||||
'slug_taken (the X-Slug-Suggestion header holds the first free slug)':
|
||||
'slug_taken (l’en-tête X-Slug-Suggestion contient le premier slug libre)',
|
||||
'Up to 10 000 characters. An empty string empties the body; null is refused.':
|
||||
'Jusqu’à 10 000 caractères. Une chaîne vide vide le corps ; null est refusé.',
|
||||
'The id of an existing site, 1 or more. null is refused.':
|
||||
'L’identifiant d’un site existant, 1 ou plus. null est refusé.',
|
||||
'Lowercase letters, digits and single dashes, 1 to 100 characters, unique on the site. An empty string regenerates it from the title. null is refused.':
|
||||
'Lettres minuscules, chiffres et tirets simples, de 1 à 100 caractères, unique sur le site. Une chaîne vide le régénère à partir du titre. null est refusé.',
|
||||
'Up to 300 characters. An empty string clears it; null is refused.':
|
||||
'Jusqu’à 300 caractères. Une chaîne vide l’efface ; null est refusé.',
|
||||
'Up to 120 characters. An empty string clears it; null is refused.':
|
||||
'Jusqu’à 120 caractères. Une chaîne vide l’efface ; null est refusé.',
|
||||
'Up to 320 characters. An empty string clears it; null is refused.':
|
||||
'Jusqu’à 320 caractères. Une chaîne vide l’efface ; null est refusé.',
|
||||
'The id of an existing image, 1 or more. null removes the cover.':
|
||||
'L’identifiant d’une image existante, 1 ou plus. null retire la couverture.',
|
||||
'Up to 200 characters. An empty string clears it; null is refused.':
|
||||
'Jusqu’à 200 caractères. Une chaîne vide l’efface ; null est refusé.',
|
||||
'The id of an existing image, 1 or more. null removes the social image.':
|
||||
'L’identifiant d’une image existante, 1 ou plus. null retire l’image de partage.',
|
||||
'An absolute https address with a host, up to 2048 characters. An empty string clears it; null is refused.':
|
||||
'Une adresse https absolue avec un hôte, jusqu’à 2048 caractères. Une chaîne vide l’efface ; null est refusé.',
|
||||
'Only a flag: the article stays online. null is refused.':
|
||||
'Un simple indicateur : l’article reste en ligne. null est refusé.',
|
||||
'Dismissed suggestion codes: title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt, short. Duplicates are collapsed; null is refused.':
|
||||
'Codes des suggestions ignorées : title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt, short. Les doublons sont fusionnés ; null est refusé.',
|
||||
'Check the SEO of an article without saving anything. Send {} for the saved state, or fields that replace the saved values for this check only.':
|
||||
'Vérifie le SEO d’un article sans rien enregistrer. Envoyez {} pour l’état enregistré, ou des champs qui remplacent les valeurs enregistrées pour cette vérification seulement.',
|
||||
'Replaces the saved body for this check only. null is refused.':
|
||||
'Remplace le corps enregistré pour cette vérification seulement. null est refusé.',
|
||||
'Same rules as PATCH. An empty string regenerates it from the title. null is refused.':
|
||||
'Mêmes règles que PATCH. Une chaîne vide le régénère à partir du titre. null est refusé.',
|
||||
'Up to 300 characters. null is refused.': 'Jusqu’à 300 caractères. null est refusé.',
|
||||
'Up to 120 characters. null is refused.': 'Jusqu’à 120 caractères. null est refusé.',
|
||||
'Up to 320 characters. null is refused.': 'Jusqu’à 320 caractères. null est refusé.',
|
||||
'The id of an existing image. null means no cover.':
|
||||
'L’identifiant d’une image existante. null signifie pas de couverture.',
|
||||
'Up to 200 characters. null is refused.': 'Jusqu’à 200 caractères. null est refusé.',
|
||||
'The id of an existing image. null means no social image.':
|
||||
'L’identifiant d’une image existante. null signifie pas d’image de partage.',
|
||||
'An absolute https address with a host, up to 2048 characters. null is refused.':
|
||||
'Une adresse https absolue avec un hôte, jusqu’à 2048 caractères. null est refusé.',
|
||||
'null is refused.': 'null est refusé.',
|
||||
'Dismissed suggestion codes, replacing the saved list for this check only. null is refused.':
|
||||
'Codes des suggestions ignorées, qui remplacent la liste enregistrée pour cette vérification seulement. null est refusé.',
|
||||
"RenewCMS doesn't render pages or add meta tags. These endpoints give your frontend the resolved SEO data and the list of published URLs.":
|
||||
'RenewCMS n’affiche pas de pages et n’ajoute pas de balises meta. Ces routes donnent à votre frontend les données SEO résolues et la liste des URL publiées.',
|
||||
'Get the resolved SEO object of an article: title with the site template applied, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks and json_ld. Drafts answer 404 to anonymous callers.':
|
||||
'Renvoie l’objet SEO résolu d’un article : title avec le modèle du site appliqué, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks et json_ld. Les brouillons répondent 404 aux appelants anonymes.',
|
||||
'List the published URLs of a site, by id ascending, as an array of { url, modified_at, noindex }. The total is in the X-Total-Count header.':
|
||||
'Liste les adresses publiées d’un site, par identifiant croissant, sous forme de tableau de { url, modified_at, noindex }. Le total est dans l’en-tête X-Total-Count.',
|
||||
'The sites an article can belong to. Any verified user can read them; changes are made in the browser by the owner.':
|
||||
'Les sites auxquels un article peut appartenir. Tout utilisateur vérifié peut les lire ; les modifications se font dans le navigateur par le propriétaire.',
|
||||
'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.':
|
||||
'Les valeurs SEO par défaut d’un site sont réservées au propriétaire. Les champs SEO d’un article demandent seulement le droit de modifier l’article.',
|
||||
'The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).':
|
||||
'Le serveur attend un identifiant au format d’un entier non signé de 32 bits (uint32).',
|
||||
'Article not found': 'Article introuvable',
|
||||
'Choose an image to keep and at least one other image to merge into it.':
|
||||
'Choisissez une image à conserver et au moins une autre image à fusionner dedans.',
|
||||
'Some of the images to merge changed. Refresh and try again.':
|
||||
'Certaines images à fusionner ont changé. Actualisez et réessayez.',
|
||||
'List the articles, authors and sites that use an image, with the places where each one uses it.':
|
||||
'Liste les articles, auteurs et sites qui utilisent une image, avec les endroits où chacun l’utilise.',
|
||||
'Log in. Made for the browser. A key is ignored here, with no 403.':
|
||||
'Se connecter. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||
'Log out. Made for the browser. A key is ignored here, with no 403.':
|
||||
'Se déconnecter. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||
'Register. Made for the browser. A key is ignored here, with no 403.':
|
||||
'S’inscrire. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||
'Reset password. Made for the browser. A key is ignored here, with no 403.':
|
||||
'Réinitialiser le mot de passe. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { API_GROUPS, ERROR_ROWS, PUBLIC_AUTH_ROWS, SESSION_GROUPS } from '@/data/apiCatalogue'
|
||||
import { messages } from './messages'
|
||||
|
||||
const SEO_ROUTES = ['/seo', '/sitemap', '/seo/check']
|
||||
const SAME_IN_FRENCH = new Set(['Articles', 'Sites', 'Hello world.'])
|
||||
const CODES = /^[a-z_]+(?: \(.*\))?$/
|
||||
|
||||
function frenchOf(text: string) {
|
||||
return (messages as Record<string, string>)[text]
|
||||
}
|
||||
|
||||
describe('Search and sharing catalogue in French', () => {
|
||||
const group = API_GROUPS.find((candidate) => candidate.key === 'seo')!
|
||||
const seoEndpoints = API_GROUPS.flatMap((candidate) => candidate.endpoints).filter((item) =>
|
||||
SEO_ROUTES.some((suffix) => item.path.endsWith(suffix)),
|
||||
)
|
||||
const texts = [
|
||||
group.title,
|
||||
group.description,
|
||||
API_GROUPS.find((candidate) => candidate.key === 'sites')!.description,
|
||||
...ERROR_ROWS.flatMap((row) => (row.note ? [row.note] : [])),
|
||||
...seoEndpoints.flatMap((item) => [
|
||||
item.description,
|
||||
...item.query.map((field) => field.rule),
|
||||
...item.body.map((field) => field.rule),
|
||||
...item.errors.map((error) => error.caption).filter((caption) => !CODES.test(caption)),
|
||||
]),
|
||||
]
|
||||
|
||||
it('covers the group, the note and both routes', () => {
|
||||
expect(seoEndpoints).toHaveLength(3)
|
||||
expect(texts.length).toBeGreaterThan(10)
|
||||
})
|
||||
|
||||
it.each([...new Set(texts)])('"%s" has its own French entry', (text) => {
|
||||
expect(frenchOf(text)).toMatch(/\S/)
|
||||
if (!SAME_IN_FRENCH.has(text)) {
|
||||
expect(frenchOf(text)).not.toBe(text)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Whole catalogue in French, API-literal errors excepted', () => {
|
||||
const texts = [
|
||||
...API_GROUPS.flatMap((group) => [
|
||||
group.title,
|
||||
group.description,
|
||||
...group.endpoints.flatMap((item) => [
|
||||
item.description,
|
||||
...item.query.map((field) => field.rule),
|
||||
...item.body.map((field) => field.rule),
|
||||
]),
|
||||
]),
|
||||
...SESSION_GROUPS.flatMap((group) => [
|
||||
group.title,
|
||||
...group.rows.map((row) => row.description),
|
||||
]),
|
||||
...PUBLIC_AUTH_ROWS.map((row) => row.description),
|
||||
...ERROR_ROWS.flatMap((row) => (row.note ? [row.note] : [])),
|
||||
]
|
||||
|
||||
it.each([...new Set(texts)])('"%s" has its own French entry', (text) => {
|
||||
expect(frenchOf(text)).toMatch(/\S/)
|
||||
if (!SAME_IN_FRENCH.has(text)) {
|
||||
expect(frenchOf(text)).not.toBe(text)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,10 @@ import type { ResolvedSeo, SeoSuggestion, SuggestionCode } from '@/lib/seoTypes'
|
||||
|
||||
const SOURCES = [
|
||||
'components/ApiPreviewDialog.vue',
|
||||
'components/EmptyState.vue',
|
||||
'components/NeedsAttentionToggle.vue',
|
||||
'components/SeoStatusChip.vue',
|
||||
'views/ArticleView.vue',
|
||||
'components/FallbackChip.vue',
|
||||
'components/SearchPreview.vue',
|
||||
'components/SearchSharingSection.vue',
|
||||
|
||||
@@ -10,6 +10,7 @@ describe('parseArticleQuery', () => {
|
||||
status: '',
|
||||
sort: '',
|
||||
site: '',
|
||||
seo: '',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +30,7 @@ describe('parseArticleQuery', () => {
|
||||
status: 'draft',
|
||||
sort: 'title:asc',
|
||||
site: '',
|
||||
seo: '',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,7 +69,7 @@ describe('sort parameter', () => {
|
||||
})
|
||||
|
||||
it('omits the default from the route and treats it as equal to no sort', () => {
|
||||
const base = { page: 1, size: 25, q: '', status: '' as const, site: '' }
|
||||
const base = { page: 1, size: 25, q: '', status: '' as const, site: '', seo: '' as const }
|
||||
expect(toRouteQuery({ ...base, sort: 'updated_at:desc' })).toEqual({})
|
||||
expect(toRouteQuery({ ...base, sort: 'title:asc' })).toEqual({
|
||||
sort: 'title:asc',
|
||||
@@ -98,6 +100,7 @@ describe('toRouteQuery', () => {
|
||||
status: '',
|
||||
sort: '',
|
||||
site: '',
|
||||
seo: '',
|
||||
}),
|
||||
).toEqual({})
|
||||
})
|
||||
@@ -111,6 +114,7 @@ describe('toRouteQuery', () => {
|
||||
status: 'online',
|
||||
sort: 'title:desc',
|
||||
site: '',
|
||||
seo: '',
|
||||
}),
|
||||
).toEqual({
|
||||
page: '2',
|
||||
@@ -131,6 +135,7 @@ describe('sameArticleQuery', () => {
|
||||
status: '' as const,
|
||||
sort: '',
|
||||
site: '',
|
||||
seo: '',
|
||||
}
|
||||
expect(sameArticleQuery(base, { ...base })).toBe(true)
|
||||
expect(sameArticleQuery(base, { ...base, page: 2 })).toBe(false)
|
||||
@@ -148,7 +153,7 @@ describe('site parameter', () => {
|
||||
})
|
||||
|
||||
it('omits an empty site and tells two queries apart by their site', () => {
|
||||
const base = { page: 1, size: 25, q: '', status: '' as const, sort: '' }
|
||||
const base = { page: 1, size: 25, q: '', status: '' as const, sort: '', seo: '' as const }
|
||||
expect(toRouteQuery({ ...base, site: '' })).toEqual({})
|
||||
expect(sameArticleQuery({ ...base, site: 'journal' }, { ...base, site: '' })).toBe(false)
|
||||
expect(sameArticleQuery({ ...base, site: 'journal' }, { ...base, site: 'journal' })).toBe(
|
||||
@@ -156,3 +161,24 @@ describe('site parameter', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('seo parameter', () => {
|
||||
it('reads only the value suggestions and writes it after the site', () => {
|
||||
const parsed = parseArticleQuery({ site: 'journal', seo: 'suggestions' })
|
||||
expect(parsed.seo).toBe('suggestions')
|
||||
expect(Object.keys(toRouteQuery(parsed))).toEqual(['site', 'seo'])
|
||||
})
|
||||
|
||||
it.each(['maybe', '', 'READY', 'ready', 'hidden', 'suggestions,x'])(
|
||||
'drops the unknown value "%s"',
|
||||
(seo) => {
|
||||
expect(parseArticleQuery({ seo }).seo).toBe('')
|
||||
},
|
||||
)
|
||||
|
||||
it('tells two queries apart by their seo filter', () => {
|
||||
const base = parseArticleQuery({})
|
||||
expect(sameArticleQuery(base, { ...base, seo: 'suggestions' })).toBe(false)
|
||||
expect(sameArticleQuery(base, { ...base })).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,8 @@ export const DEFAULT_ARTICLE_SORT = 'updated_at:desc'
|
||||
export const ARTICLE_SORT_FIELDS = ['title', 'status', 'created_at', 'updated_at'] as const
|
||||
export type ArticleSortField = (typeof ARTICLE_SORT_FIELDS)[number]
|
||||
|
||||
export const SEO_FILTER = 'suggestions'
|
||||
|
||||
const SITE_KEY_MAX = 32
|
||||
const POSITIVE_INTEGER = /^[1-9]\d*$/
|
||||
|
||||
@@ -19,6 +21,7 @@ export interface ArticleListQuery {
|
||||
status: ArticleStatus | ''
|
||||
sort: string
|
||||
site: string
|
||||
seo: '' | typeof SEO_FILTER
|
||||
}
|
||||
|
||||
function first(value: LocationQuery[string] | undefined) {
|
||||
@@ -52,6 +55,7 @@ export function parseArticleQuery(query: LocationQuery): ArticleListQuery {
|
||||
status: status === 'online' || status === 'draft' ? status : '',
|
||||
sort: validSort(first(query.sort)),
|
||||
site: first(query.site).slice(0, SITE_KEY_MAX),
|
||||
seo: first(query.seo) === SEO_FILTER ? SEO_FILTER : '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +79,9 @@ export function toRouteQuery(query: ArticleListQuery) {
|
||||
if (query.site) {
|
||||
route.site = query.site
|
||||
}
|
||||
if (query.seo) {
|
||||
route.seo = query.seo
|
||||
}
|
||||
return route
|
||||
}
|
||||
|
||||
@@ -85,6 +92,7 @@ export function sameArticleQuery(left: ArticleListQuery, right: ArticleListQuery
|
||||
left.q === right.q &&
|
||||
left.status === right.status &&
|
||||
left.site === right.site &&
|
||||
left.seo === right.seo &&
|
||||
effectiveSort(left.sort) === effectiveSort(right.sort)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export type SeoChip = { kind: 'suggestions'; count: number } | { kind: 'hidden' }
|
||||
|
||||
interface SeoStatusSource {
|
||||
seo_status?: unknown
|
||||
seo_suggestions?: unknown
|
||||
}
|
||||
|
||||
export function seoChipOf(article: SeoStatusSource): SeoChip | null {
|
||||
if (article.seo_status === 'hidden') {
|
||||
return { kind: 'hidden' }
|
||||
}
|
||||
const count = article.seo_suggestions
|
||||
if (article.seo_status === 'suggestions' && typeof count === 'number' && count > 0) {
|
||||
return { kind: 'suggestions', count }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export const SITE_CODES = {
|
||||
defaultSite: 'default_site',
|
||||
invalidUrl: 'invalid_site_url',
|
||||
invalidLocale: 'invalid_locale',
|
||||
invalidTitleTemplate: 'invalid_title_template',
|
||||
} as const
|
||||
|
||||
export const AUTHOR_CODES = {
|
||||
@@ -45,6 +46,7 @@ export type FailureKind =
|
||||
| 'keyTaken'
|
||||
| 'invalidSiteUrl'
|
||||
| 'invalidLocale'
|
||||
| 'invalidTitleTemplate'
|
||||
| 'slugTaken'
|
||||
| 'userLinked'
|
||||
| 'bioTooLong'
|
||||
@@ -73,6 +75,7 @@ export function classifyFailure(error: unknown, context: FailureContext): Failur
|
||||
[`409 ${SITE_CODES.hasArticles}`]: 'hasArticles',
|
||||
[`400 ${SITE_CODES.invalidUrl}`]: 'invalidSiteUrl',
|
||||
[`400 ${SITE_CODES.invalidLocale}`]: 'invalidLocale',
|
||||
[`400 ${SITE_CODES.invalidTitleTemplate}`]: 'invalidTitleTemplate',
|
||||
},
|
||||
author: {
|
||||
[`409 ${AUTHOR_CODES.slugTaken}`]: 'slugTaken',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { t } from '@/i18n'
|
||||
|
||||
export const SITE_NAME_MAX = 100
|
||||
export const TITLE_TEMPLATE_MAX = 200
|
||||
export const DEFAULT_DESCRIPTION_MAX = 320
|
||||
export const DEFAULT_TITLE_TEMPLATE = '%title% | %site%'
|
||||
|
||||
const TITLE_TOKEN = '%title%'
|
||||
const SITE_TOKEN = '%site%'
|
||||
const SAMPLE_TITLE = 'How we price a bathroom renovation'
|
||||
const SAMPLE_SITE_NAME = 'Renew'
|
||||
|
||||
export function templateProblem(template: string) {
|
||||
return template.includes(TITLE_TOKEN) ? '' : t('The template must contain %title%.')
|
||||
}
|
||||
|
||||
export function exampleTitle(template: string, siteName: string, name: string) {
|
||||
const site = siteName.trim() || name.trim() || SAMPLE_SITE_NAME
|
||||
return template.split(TITLE_TOKEN).join(SAMPLE_TITLE).split(SITE_TOKEN).join(site)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import EmptyState from '@/components/EmptyState.vue'
|
||||
import NewArticleModal from '@/components/NewArticleModal.vue'
|
||||
import AppPaginator from '@/components/AppPaginator.vue'
|
||||
import ArticleSiteFilter from '@/components/ArticleSiteFilter.vue'
|
||||
import NeedsAttentionToggle from '@/components/NeedsAttentionToggle.vue'
|
||||
import { useArticleRowActions } from '@/composables/useArticleRowActions'
|
||||
import {
|
||||
useArticles,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
import { useSites, type Site } from '@/composables/useSites'
|
||||
import {
|
||||
ARTICLE_PAGE_SIZES,
|
||||
SEO_FILTER,
|
||||
effectiveSort,
|
||||
nextSort,
|
||||
parseArticleQuery,
|
||||
@@ -30,11 +32,12 @@ import {
|
||||
type ArticleListQuery,
|
||||
} from '@/lib/articleListQuery'
|
||||
import { pageCountFor } from '@/lib/pagination'
|
||||
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
|
||||
import { ARTICLES_PAGE_SIZE_KEY, readStoredPageSize, storePageSize } from '@/lib/pageSizeMemory'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const SEARCH_DEBOUNCE_MS = 120
|
||||
const DEFAULT_SKELETON_ROWS = 5
|
||||
const DEFAULT_SKELETON_ROWS = 6
|
||||
const ARTICLES_PATH = '/article'
|
||||
|
||||
type UrlMode = 'push' | 'replace'
|
||||
@@ -42,13 +45,14 @@ type Notice = { kind: 'range' } | { kind: 'error'; query: ArticleListQuery; mode
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { listArticles } = useArticles()
|
||||
const { listArticles, countNeedsAttention } = useArticles()
|
||||
const { listSites } = useSites()
|
||||
|
||||
const items = ref<Article[]>([])
|
||||
const sites = ref<Site[]>([])
|
||||
const total = ref(0)
|
||||
const counts = ref<ArticleCounts>({ all: 0, online: 0, draft: 0 })
|
||||
const needsAttentionCount = ref<number | null>(null)
|
||||
function parseWithStoredSize(query: typeof route.query): ArticleListQuery {
|
||||
const parsed = parseArticleQuery(query)
|
||||
if (query.size !== undefined) {
|
||||
@@ -71,6 +75,8 @@ const newArticleModalOpen = ref(false)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let requestToken = 0
|
||||
let countToken = 0
|
||||
let countedFor: string | null = null
|
||||
let syncedTarget: Record<string, string> | null = null
|
||||
|
||||
const searchInput = ref(shown.value.q)
|
||||
@@ -135,6 +141,9 @@ const emptyVariant = computed(() => {
|
||||
if (shown.value.status === 'online') {
|
||||
return 'no-online' as const
|
||||
}
|
||||
if (shown.value.seo) {
|
||||
return 'needs-attention' as const
|
||||
}
|
||||
if (shown.value.site) {
|
||||
return 'no-site' as const
|
||||
}
|
||||
@@ -174,8 +183,37 @@ async function syncUrl(query: ArticleListQuery, mode: UrlMode) {
|
||||
await router[mode]({ query: target })
|
||||
}
|
||||
|
||||
function countKey(query: ArticleListQuery) {
|
||||
return JSON.stringify([query.q, query.status, query.site])
|
||||
}
|
||||
|
||||
async function refreshNeedsAttentionCount(query: ArticleListQuery) {
|
||||
const key = countKey(query)
|
||||
if (query.seo || key === countedFor) {
|
||||
return
|
||||
}
|
||||
const token = ++countToken
|
||||
countedFor = key
|
||||
needsAttentionCount.value = null
|
||||
try {
|
||||
const result = await countNeedsAttention({
|
||||
q: query.q,
|
||||
status: query.status,
|
||||
site: query.site,
|
||||
})
|
||||
if (token === countToken) {
|
||||
needsAttentionCount.value = result
|
||||
}
|
||||
} catch {
|
||||
if (token === countToken) {
|
||||
countedFor = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function load(query: ArticleListQuery, mode: UrlMode, successNotice: Notice | null = null) {
|
||||
const token = ++requestToken
|
||||
refreshNeedsAttentionCount(query)
|
||||
desired.value = query
|
||||
loading.value = true
|
||||
showSkeleton.value = false
|
||||
@@ -209,6 +247,11 @@ async function load(query: ArticleListQuery, mode: UrlMode, successNotice: Notic
|
||||
items.value = response.items
|
||||
total.value = response.total
|
||||
counts.value = response.counts
|
||||
if (effective.seo) {
|
||||
countToken++
|
||||
countedFor = countKey(effective)
|
||||
needsAttentionCount.value = response.total
|
||||
}
|
||||
shown.value = effective
|
||||
desired.value = effective
|
||||
hasLoaded.value = true
|
||||
@@ -258,6 +301,14 @@ function changeSite(site: string) {
|
||||
load({ ...desired.value, site, page: 1 }, 'push')
|
||||
}
|
||||
|
||||
function changeSeo(on: boolean) {
|
||||
load({ ...desired.value, seo: on ? SEO_FILTER : '', page: 1 }, 'push')
|
||||
}
|
||||
|
||||
function clearSeoFilter() {
|
||||
changeSeo(false)
|
||||
}
|
||||
|
||||
function changeSort(field: ArticleSortField) {
|
||||
load(
|
||||
{
|
||||
@@ -347,6 +398,10 @@ function clearSearch() {
|
||||
}
|
||||
|
||||
function onEmptyAction() {
|
||||
if (emptyVariant.value === 'needs-attention') {
|
||||
clearSeoFilter()
|
||||
return
|
||||
}
|
||||
if (emptyVariant.value === 'no-site') {
|
||||
changeSite('')
|
||||
return
|
||||
@@ -383,7 +438,7 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell>
|
||||
<DashboardShell compact-padding>
|
||||
<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">
|
||||
@@ -407,23 +462,38 @@ onBeforeUnmount(() => {
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col-reverse gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div
|
||||
class="flex flex-col-reverse gap-3 md:flex-row md:items-end md:gap-4 md:border-b md:border-default"
|
||||
>
|
||||
<UTabs
|
||||
v-model="activeTab"
|
||||
:items="tabItems"
|
||||
variant="link"
|
||||
color="neutral"
|
||||
:content="false"
|
||||
:ui="{ list: 'gap-5', trigger: 'px-0' }"
|
||||
class="md:-mb-px md:flex-1"
|
||||
:ui="{
|
||||
list: 'gap-5 border-b-0 p-0 max-md:border-b',
|
||||
trigger: `${UNDERLINE_TAB_TRIGGER} max-md:text-base`,
|
||||
}"
|
||||
/>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-end md:gap-4 md:pb-1.5">
|
||||
<UInput
|
||||
v-model="searchInput"
|
||||
type="search"
|
||||
icon="i-lucide-search"
|
||||
:placeholder="t('Search by title')"
|
||||
class="w-full sm:w-65"
|
||||
:ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }"
|
||||
class="w-full md:w-65"
|
||||
:ui="{
|
||||
base: 'h-11 text-lg md:h-8 md:text-sm',
|
||||
leadingIcon: 'size-4 md:size-3.5',
|
||||
}"
|
||||
/>
|
||||
<NeedsAttentionToggle
|
||||
:model-value="desired.seo !== ''"
|
||||
:count="needsAttentionCount"
|
||||
class="md:-order-1"
|
||||
@update:model-value="changeSeo"
|
||||
/>
|
||||
<ArticleSiteFilter
|
||||
v-if="severalSites"
|
||||
|
||||
@@ -35,7 +35,7 @@ import { readSeoSectionOpen, storeSeoSectionOpen } from '@/lib/seoSectionMemory'
|
||||
import { SEO_LIMITS, charCount, seoChanges, seoOf, type ArticleSeo } from '@/lib/articleSeo'
|
||||
import { hostOf, slugify } from '@/lib/directory'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
import type { SeoCheckBody, SeoFocus, SuggestionField } from '@/lib/seoTypes'
|
||||
import type { SeoCheckBody, SeoFocus, SuggestionCode, SuggestionField } from '@/lib/seoTypes'
|
||||
import { bodyCounts } from '@/lib/bodyCounts'
|
||||
import { useCanManageVariants } from '@/composables/useCanManageVariants'
|
||||
import { bodyHasVariantImage } from '@/lib/variants/blockVariant'
|
||||
@@ -93,6 +93,8 @@ const panelSaving = ref(false)
|
||||
const slugTakenOnSave = ref<string | null>(null)
|
||||
const canonicalRejected = ref(false)
|
||||
const apiPreviewOpen = ref(false)
|
||||
const dismissedCodes = ref<SuggestionCode[]>([])
|
||||
const dismissingCode = ref<SuggestionCode | null>(null)
|
||||
let panelSaveFailed = false
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@@ -153,6 +155,61 @@ const check = useSeoCheck({
|
||||
onUnauthorized: draft.mirrorToStorage,
|
||||
})
|
||||
|
||||
function reportDismissFailure() {
|
||||
toast.add(() => ({
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
get title() {
|
||||
return t("Couldn't dismiss the suggestion")
|
||||
},
|
||||
get description() {
|
||||
return t('Try again in a moment.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async function saveDismissed(next: SuggestionCode[]) {
|
||||
try {
|
||||
const updated = await request<Pick<Article, 'seo_dismissed'>>(`/v1/article/${articleId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ seo_dismissed: next }),
|
||||
})
|
||||
dismissedCodes.value = updated?.seo_dismissed ?? next
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
loadState.value = 'notFound'
|
||||
} else if (err instanceof ApiError && err.status === 401) {
|
||||
draft.mirrorToStorage()
|
||||
} else {
|
||||
reportDismissFailure()
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function dismissSuggestion(code: SuggestionCode) {
|
||||
if (dismissingCode.value !== null) {
|
||||
return
|
||||
}
|
||||
dismissingCode.value = code
|
||||
const saved = await saveDismissed([...new Set([...dismissedCodes.value, code])])
|
||||
if (saved) {
|
||||
check.dropSuggestion(code)
|
||||
check.invalidate()
|
||||
check.runQuietly()
|
||||
}
|
||||
dismissingCode.value = null
|
||||
}
|
||||
|
||||
async function restoreDismissed() {
|
||||
if (await saveDismissed([])) {
|
||||
check.invalidate()
|
||||
check.runFresh()
|
||||
}
|
||||
}
|
||||
|
||||
function editBody(newBody: string) {
|
||||
draft.edit(newBody)
|
||||
check.schedule()
|
||||
@@ -309,6 +366,7 @@ async function loadArticle() {
|
||||
fetchSites(),
|
||||
])
|
||||
article.value = result
|
||||
dismissedCodes.value = result.seo_dismissed ?? []
|
||||
sites.value = siteList
|
||||
draft.load(
|
||||
result.body,
|
||||
@@ -559,12 +617,14 @@ onBeforeRouteLeave(async () => {
|
||||
:phase="check.phase.value"
|
||||
:status="check.status.value"
|
||||
:suggestions="check.suggestions.value"
|
||||
:dismissed-count="dismissedCodes.length"
|
||||
:sheet="sheet"
|
||||
@update:remembered="rememberSeoSection"
|
||||
@edit="openPanel"
|
||||
@preview="apiPreviewOpen = true"
|
||||
@retry="check.runFresh"
|
||||
@go="goToField"
|
||||
@restore="restoreDismissed"
|
||||
/>
|
||||
</template>
|
||||
</ArticleDetails>
|
||||
@@ -756,6 +816,8 @@ onBeforeRouteLeave(async () => {
|
||||
:site="siteOfArticle"
|
||||
:phase="check.phase.value"
|
||||
:suggestions="check.suggestions.value"
|
||||
:dismissed-count="dismissedCodes.length"
|
||||
:dismissing-code="dismissingCode"
|
||||
:slug-verdict="check.slugVerdict.value"
|
||||
:checked-slug="check.checkedSlug.value"
|
||||
:slug-taken-on-save="slugTakenOnSave"
|
||||
@@ -769,6 +831,8 @@ onBeforeRouteLeave(async () => {
|
||||
@close="closePanel"
|
||||
@preview="apiPreviewOpen = true"
|
||||
@go="goToField"
|
||||
@dismiss="dismissSuggestion"
|
||||
@restore="restoreDismissed"
|
||||
/>
|
||||
<ApiPreviewDialog
|
||||
v-model:open="apiPreviewOpen"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { t, translate } from '@/i18n'
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import CodeSnippet from '@/components/CodeSnippet.vue'
|
||||
import CodeText from '@/components/CodeText.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
@@ -21,6 +22,7 @@ import { SECTION_TITLE } from '@/lib/apiReference'
|
||||
|
||||
const SPY_OFFSET_PX = 120
|
||||
|
||||
const route = useRoute()
|
||||
const origin = computed(() => window.location.origin)
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
@@ -151,6 +153,10 @@ let scroller: HTMLElement | null = null
|
||||
onMounted(() => {
|
||||
scroller = root.value ? scrollParent(root.value) : null
|
||||
scroller?.addEventListener('scroll', updateActive, { passive: true })
|
||||
const target = route.hash.slice(1)
|
||||
if (sections.value.some((section) => section.id === target)) {
|
||||
nextTick(() => goTo(target))
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => scroller?.removeEventListener('scroll', updateActive))
|
||||
@@ -354,6 +360,12 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
|
||||
<span class="text-pretty text-toned">{{
|
||||
quoteMessages(row.original.messages)
|
||||
}}</span>
|
||||
<span
|
||||
v-if="row.original.note"
|
||||
class="text-pretty text-muted"
|
||||
data-testid="error-note"
|
||||
>{{ translate(row.original.note) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -362,6 +374,12 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
|
||||
<span class="text-pretty">{{
|
||||
quoteMessages(row.original.messages)
|
||||
}}</span>
|
||||
<span
|
||||
v-if="row.original.note"
|
||||
class="text-pretty text-muted"
|
||||
data-testid="error-note"
|
||||
>{{ translate(row.original.note) }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</UTable>
|
||||
|
||||
@@ -7,7 +7,12 @@ import { useSessionStore } from '@/stores/session'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import ArticleView from '../ArticleView.vue'
|
||||
import { oneSite } from './editorMetaKit'
|
||||
import { articleEnvelope, articleListRoute } from './articleEnvelope'
|
||||
import {
|
||||
NEEDS_ATTENTION_ROUTE,
|
||||
articleEnvelope,
|
||||
articleListRoute,
|
||||
needsAttentionCountPage,
|
||||
} from './articleEnvelope'
|
||||
|
||||
const NOW = new Date('2026-08-26T10:00:00Z')
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
@@ -139,7 +144,10 @@ async function mountArticles(handlers: Record<string, () => Response | Promise<R
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.status = 'ready'
|
||||
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
|
||||
global.fetch = fetchRouter({
|
||||
[NEEDS_ATTENTION_ROUTE]: () => mockResponse(200, needsAttentionCountPage()),
|
||||
...handlers,
|
||||
}) as unknown as typeof fetch
|
||||
await router.push('/article')
|
||||
const wrapper = mount(ArticleView, {
|
||||
attachTo: document.body,
|
||||
|
||||
@@ -8,7 +8,13 @@ import type { Article } from '@/composables/useArticles'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import ArticleView from '../ArticleView.vue'
|
||||
import { oneSite } from './editorMetaKit'
|
||||
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
|
||||
import {
|
||||
articleEnvelope,
|
||||
isNeedsAttentionCount,
|
||||
needsAttentionCountPage,
|
||||
parseListUrl,
|
||||
type EnvelopeParams,
|
||||
} from './articleEnvelope'
|
||||
|
||||
const KEY = 'renewcms-page-size-articles'
|
||||
const TOTAL = 312
|
||||
@@ -45,6 +51,9 @@ function installFetch() {
|
||||
json: async () => oneSite,
|
||||
} as unknown as Response)
|
||||
}
|
||||
if (method === 'GET' && isNeedsAttentionCount(url)) {
|
||||
return Promise.resolve(mockResponse(200, needsAttentionCountPage()))
|
||||
}
|
||||
if (method === 'GET' && url.startsWith('/v1/article/?')) {
|
||||
const params = parseListUrl(url)
|
||||
listCalls.push(params)
|
||||
|
||||
@@ -9,7 +9,13 @@ import type { Article, ArticlePage } from '@/composables/useArticles'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import ArticleView from '../ArticleView.vue'
|
||||
import { oneSite } from './editorMetaKit'
|
||||
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
|
||||
import {
|
||||
articleEnvelope,
|
||||
isNeedsAttentionCount,
|
||||
needsAttentionCountPage,
|
||||
parseListUrl,
|
||||
type EnvelopeParams,
|
||||
} from './articleEnvelope'
|
||||
|
||||
const TOTAL = 312
|
||||
const PLASTER_EVERY = 20
|
||||
@@ -85,6 +91,9 @@ function installFetch() {
|
||||
if (method === 'GET' && url === '/v1/site/') {
|
||||
return Promise.resolve(mockResponse(200, oneSite))
|
||||
}
|
||||
if (method === 'GET' && isNeedsAttentionCount(url)) {
|
||||
return Promise.resolve(mockResponse(200, needsAttentionCountPage()))
|
||||
}
|
||||
if (method === 'GET' && url.startsWith('/v1/article/?')) {
|
||||
const params = parseListUrl(url)
|
||||
listCalls.push(params)
|
||||
|
||||
@@ -64,7 +64,7 @@ function siteSelect() {
|
||||
}
|
||||
|
||||
function listCalls(backend: MetaBackend) {
|
||||
return callsTo(backend, 'GET', '/v1/article/')
|
||||
return callsTo(backend, 'GET', '/v1/article/').filter((call) => call.query.has('format'))
|
||||
}
|
||||
|
||||
async function chooseSite(label: string) {
|
||||
|
||||
@@ -19,6 +19,7 @@ const STUB_ORIGIN = 'https://cms.renew.example'
|
||||
const SECTION_LABELS = [
|
||||
'Authentication',
|
||||
'Articles',
|
||||
'Search and sharing',
|
||||
'Media',
|
||||
'Users and invites',
|
||||
'Settings',
|
||||
@@ -170,15 +171,17 @@ describe('Default, every card collapsed', () => {
|
||||
expect(row(wrapper, 'POST /v1/article/').text()).toContain('POST')
|
||||
})
|
||||
|
||||
it('groups the cards as 9, 10, 6 and 1', async () => {
|
||||
it('groups the cards as 8, 2, 10, 6 and 1', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
expect(
|
||||
['articles', 'media', 'users', 'settings'].map(
|
||||
['articles', 'seo', 'media', 'users', 'settings'].map(
|
||||
(id) => section(wrapper, id).findAllComponents(EndpointCard).length,
|
||||
),
|
||||
).toEqual([9, 10, 6, 1])
|
||||
expect(section(wrapper, 'articles').text()).toContain('9 routes')
|
||||
).toEqual([8, 2, 10, 6, 1])
|
||||
expect(section(wrapper, 'articles').text()).toContain('8 routes')
|
||||
expect(section(wrapper, 'seo').text()).toContain('2 routes')
|
||||
expect(section(wrapper, 'sites').text()).toContain('1 route')
|
||||
expect(section(wrapper, 'settings').text()).toContain('1 route')
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import {
|
||||
articleOf,
|
||||
callsTo,
|
||||
createMetaBackend,
|
||||
failWith,
|
||||
mountMeta,
|
||||
networkFailure,
|
||||
renewSites,
|
||||
textOf,
|
||||
typeInto,
|
||||
unmountMeta,
|
||||
type MetaBackend,
|
||||
} from './metaTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const SEARCH_WAIT_MS = 320
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
setLocale('en')
|
||||
await unmountMeta()
|
||||
})
|
||||
|
||||
function withSeo(
|
||||
id: number,
|
||||
status: string | undefined,
|
||||
count: number | undefined,
|
||||
overrides: Partial<Article> = {},
|
||||
) {
|
||||
return articleOf(id, `Article ${id}`, {
|
||||
is_online: id % 2 === 0,
|
||||
...(status === undefined ? {} : { seo_status: status }),
|
||||
...(count === undefined ? {} : { seo_suggestions: count }),
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
function boardArticles() {
|
||||
return [
|
||||
withSeo(1, 'ready', 0),
|
||||
withSeo(2, 'suggestions', 2),
|
||||
withSeo(3, 'hidden', 0),
|
||||
withSeo(4, 'suggestions', 3),
|
||||
withSeo(5, 'suggestions', 1),
|
||||
]
|
||||
}
|
||||
|
||||
function backendWith(articles: Article[], sites: 'one' | 'several' = 'one'): MetaBackend {
|
||||
const backend = createMetaBackend({
|
||||
sites: sites === 'one' ? renewSites().slice(0, 1) : renewSites(),
|
||||
})
|
||||
backend.articles = articles
|
||||
return backend
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
return document.body.querySelector<HTMLElement>('[data-testid="needs-attention-toggle"]')!
|
||||
}
|
||||
|
||||
function chips(root: ParentNode = document.body) {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>('[data-testid="seo-status-chip"]'))
|
||||
}
|
||||
|
||||
function bodyRows() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('tbody tr'))
|
||||
}
|
||||
|
||||
function mainCalls(backend: MetaBackend) {
|
||||
return callsTo(backend, 'GET', '/v1/article/').filter((call) => call.query.has('format'))
|
||||
}
|
||||
|
||||
function countCalls(backend: MetaBackend) {
|
||||
return callsTo(backend, 'GET', '/v1/article/').filter((call) => !call.query.has('format'))
|
||||
}
|
||||
|
||||
function currentQuery() {
|
||||
return { ...router.currentRoute.value.query }
|
||||
}
|
||||
|
||||
async function clickToggle() {
|
||||
toggle().click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function search(value: string) {
|
||||
await typeInto(document.body.querySelector<HTMLInputElement>('input[type="search"]')!, value)
|
||||
await new Promise((resolve) => setTimeout(resolve, SEARCH_WAIT_MS))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function clickTab(label: string) {
|
||||
const tab = Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]')).find(
|
||||
(item) => textOf(item).startsWith(label),
|
||||
)!
|
||||
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
tab.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('SeoStatusChip: status table', () => {
|
||||
it.each([
|
||||
{ status: 'ready', count: 0, text: '', label: '' },
|
||||
{ status: 'suggestions', count: 2, text: '2', label: '2 suggestions' },
|
||||
{ status: 'suggestions', count: 1, text: '1', label: '1 suggestion' },
|
||||
{ status: 'suggestions', count: 3, text: '3', label: '3 suggestions' },
|
||||
{ status: 'hidden', count: 0, text: 'Hidden', label: 'Hidden from search engines' },
|
||||
{ status: 'hidden', count: 2, text: 'Hidden', label: 'Hidden from search engines' },
|
||||
])('$status with $count shows "$text"', async ({ status, count, text, label }) => {
|
||||
const backend = backendWith([withSeo(1, status, count)])
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
const found = chips(bodyRows()[0])
|
||||
if (!label) {
|
||||
expect(found).toHaveLength(0)
|
||||
return
|
||||
}
|
||||
expect(found).toHaveLength(1)
|
||||
const chip = found[0]!
|
||||
expect(textOf(chip)).toBe(text)
|
||||
expect(chip.getAttribute('title')).toBe(label)
|
||||
expect(chip.getAttribute('aria-label')).toBe(label)
|
||||
expect(chip.getAttribute('role')).toBe('img')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ item: 'without seo_status', article: withSeo(1, undefined, undefined) },
|
||||
{ item: 'with suggestions and a count of 0', article: withSeo(1, 'suggestions', 0) },
|
||||
{ item: 'with an unknown status', article: withSeo(1, 'surprise', 4) },
|
||||
])('shows nothing for an item $item', async ({ article }) => {
|
||||
await mountMeta('articles', '/article', { backend: backendWith([article]) })
|
||||
|
||||
expect(chips()).toHaveLength(0)
|
||||
expect(bodyRows()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('sits in the Status cell next to the badge on desktop and in the meta row on mobile', async () => {
|
||||
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
|
||||
const statusCell = bodyRows()[1]!.children[2] as HTMLElement
|
||||
expect(chips(statusCell)).toHaveLength(1)
|
||||
expect(textOf(statusCell)).toContain('Online')
|
||||
expect(chips(document.body.querySelector('tbody')!)).toHaveLength(4)
|
||||
await unmountMeta()
|
||||
|
||||
await mountMeta('articles', '/article', {
|
||||
width: MOBILE_WIDTH,
|
||||
backend: backendWith(boardArticles()),
|
||||
})
|
||||
const cards = Array.from(document.body.querySelectorAll<HTMLElement>('li'))
|
||||
const second = cards.find((card) => textOf(card).includes('Article 2'))!
|
||||
expect(chips(second)).toHaveLength(1)
|
||||
expect(chips(second)[0]!.className).toContain('h-5')
|
||||
})
|
||||
|
||||
it('reads the chip in French', async () => {
|
||||
setLocale('fr')
|
||||
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
|
||||
|
||||
const labels = chips().map((chip) => chip.getAttribute('aria-label'))
|
||||
expect(labels).toContain('2 suggestions')
|
||||
expect(labels).toContain('1 suggestion')
|
||||
expect(labels).toContain('Masqué des moteurs de recherche')
|
||||
expect(textOf(toggle())).toContain('À traiter')
|
||||
})
|
||||
|
||||
it('keeps the chip when a row is published', async () => {
|
||||
const backend = backendWith(boardArticles())
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
document.body
|
||||
.querySelector<HTMLElement>('button[aria-label="Actions for Article 3"]')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||
.find((item) => textOf(item) === 'Publish')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(chips(bodyRows()[2])).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Needs attention toggle', () => {
|
||||
it('is a switch with the board count, left of the search on desktop', async () => {
|
||||
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
|
||||
|
||||
expect(toggle().getAttribute('role')).toBe('switch')
|
||||
expect(toggle().getAttribute('aria-checked')).toBe('false')
|
||||
expect(textOf(toggle())).toBe('Needs attention 3')
|
||||
expect(toggle().className).toContain('md:-order-1')
|
||||
expect(toggle().className).toContain('md:h-8')
|
||||
})
|
||||
|
||||
it('is 44 px high on mobile', async () => {
|
||||
await mountMeta('articles', '/article', {
|
||||
width: MOBILE_WIDTH,
|
||||
backend: backendWith(boardArticles()),
|
||||
})
|
||||
|
||||
expect(toggle().className).toContain('h-11')
|
||||
})
|
||||
|
||||
it('uses the sand fill when on', async () => {
|
||||
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
|
||||
await clickToggle()
|
||||
|
||||
expect(toggle().getAttribute('aria-checked')).toBe('true')
|
||||
expect(toggle().className).toContain('bg-(--rc-sand)')
|
||||
expect(toggle().className).toContain('text-(--rc-on-sand)')
|
||||
})
|
||||
|
||||
it('turns on keeping q, status, sort, site and size, and resets the page', async () => {
|
||||
const backend = backendWith(boardArticles(), 'several')
|
||||
await mountMeta(
|
||||
'articles',
|
||||
'/article?page=1&size=50&q=Article&status=online&sort=title:asc&site=renew',
|
||||
{ backend },
|
||||
)
|
||||
await clickToggle()
|
||||
|
||||
expect(currentQuery()).toEqual({
|
||||
size: '50',
|
||||
q: 'Article',
|
||||
status: 'online',
|
||||
sort: 'title:asc',
|
||||
site: 'renew',
|
||||
seo: 'suggestions',
|
||||
})
|
||||
const last = mainCalls(backend).at(-1)!
|
||||
expect(last.query.get('seo')).toBe('suggestions')
|
||||
expect(last.query.get('page')).toBe('1')
|
||||
expect(last.query.get('size')).toBe('50')
|
||||
expect(last.query.get('q')).toBe('Article')
|
||||
expect(last.query.get('sort')).toBe('title:asc')
|
||||
})
|
||||
|
||||
it('turns off, drops seo, resets the page and requests the list without seo', async () => {
|
||||
const backend = backendWith(boardArticles())
|
||||
await mountMeta('articles', '/article?seo=suggestions&page=2&size=5', { backend })
|
||||
await clickToggle()
|
||||
|
||||
expect(currentQuery()).toEqual({ size: '5' })
|
||||
expect(mainCalls(backend).at(-1)!.query.has('seo')).toBe(false)
|
||||
expect(mainCalls(backend).at(-1)!.query.get('page')).toBe('1')
|
||||
})
|
||||
|
||||
it('opens a URL with the filter already on', async () => {
|
||||
const backend = backendWith(boardArticles())
|
||||
await mountMeta('articles', '/article?seo=suggestions&page=1', { backend })
|
||||
|
||||
expect(toggle().getAttribute('aria-checked')).toBe('true')
|
||||
expect(mainCalls(backend)[0]!.query.get('seo')).toBe('suggestions')
|
||||
expect(bodyRows()).toHaveLength(3)
|
||||
expect(textOf(document.body.querySelector('[role="tablist"]'))).toContain('All 5')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'?seo=maybe',
|
||||
'?seo=',
|
||||
'?seo=READY',
|
||||
'?seo=ready',
|
||||
'?seo=hidden',
|
||||
'?seo=suggestions,x',
|
||||
])('drops %s silently', async (query) => {
|
||||
const backend = backendWith(boardArticles())
|
||||
await mountMeta('articles', `/article${query}`, { backend })
|
||||
|
||||
expect(currentQuery()).toEqual({})
|
||||
expect(toggle().getAttribute('aria-checked')).toBe('false')
|
||||
expect(mainCalls(backend).every((call) => !call.query.has('seo'))).toBe(true)
|
||||
expect(document.body.querySelector('[role="alert"], [role="status"]')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ action: 'searches', act: () => search('Article') },
|
||||
{ action: 'changes the tab', act: () => clickTab('Drafts') },
|
||||
])('keeps ?seo=suggestions and resets the page when the user $action', async ({ act }) => {
|
||||
const backend = backendWith(boardArticles())
|
||||
await mountMeta('articles', '/article?seo=suggestions&page=2&size=5', { backend })
|
||||
|
||||
await act()
|
||||
|
||||
expect(currentQuery().seo).toBe('suggestions')
|
||||
expect(currentQuery().page).toBeUndefined()
|
||||
expect(mainCalls(backend).at(-1)!.query.get('seo')).toBe('suggestions')
|
||||
})
|
||||
|
||||
it('behaves the same on a single-site install', async () => {
|
||||
const backend = backendWith(boardArticles(), 'one')
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
expect(document.body.querySelector('button[aria-label="Site"]')).toBeNull()
|
||||
await clickToggle()
|
||||
expect(currentQuery()).toEqual({ seo: 'suggestions' })
|
||||
expect(bodyRows()).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Needs attention count', () => {
|
||||
it('reads the total of the extra request with the current q, status and site', async () => {
|
||||
const backend = backendWith(boardArticles(), 'several')
|
||||
await mountMeta('articles', '/article?q=Article&status=online&site=renew', { backend })
|
||||
|
||||
const calls = countCalls(backend)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.raw).toBe(
|
||||
'/v1/article/?seo=suggestions&page=1&size=5&q=Article&status=online&site=renew',
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ total: 3, label: 'Needs attention 3' },
|
||||
{ total: 0, label: 'Needs attention 0' },
|
||||
])('shows $label', async ({ total, label }) => {
|
||||
const backend = backendWith(boardArticles())
|
||||
backend.responders['GET /v1/article/'] = (call) =>
|
||||
call.query.has('format')
|
||||
? {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 0, online: 0, draft: 0 },
|
||||
}
|
||||
: { items: [], total, page: 1, size: 5, counts: { all: 0, online: 0, draft: 0 } }
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
expect(textOf(toggle())).toBe(label)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ failure: 'status 500', responder: failWith(500) },
|
||||
{ failure: 'a network error', responder: networkFailure },
|
||||
])('shows no count and no notice on $failure', async ({ responder }) => {
|
||||
const backend = backendWith(boardArticles())
|
||||
const defaults = backend.responders
|
||||
backend.responders['GET /v1/article/'] = (call) => {
|
||||
if (!call.query.has('format')) {
|
||||
return responder(call)
|
||||
}
|
||||
return defaults
|
||||
? {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 5, online: 2, draft: 3 },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
expect(textOf(toggle())).toBe('Needs attention')
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
expect(currentQuery()).toEqual({})
|
||||
})
|
||||
|
||||
it('refreshes when q, tab or site change and never for page, size or sort', async () => {
|
||||
const backend = backendWith(
|
||||
Array.from({ length: 30 }, (_, index) => withSeo(index + 1, 'suggestions', 1)),
|
||||
'several',
|
||||
)
|
||||
await mountMeta('articles', '/article?size=5', { backend })
|
||||
expect(countCalls(backend)).toHaveLength(1)
|
||||
|
||||
document.body.querySelector<HTMLElement>('button[aria-label="Next page"]')!.click()
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('thead button'))
|
||||
.find((item) => textOf(item).startsWith('Title'))!
|
||||
.click()
|
||||
await flushPromises()
|
||||
expect(countCalls(backend)).toHaveLength(1)
|
||||
|
||||
await clickTab('Drafts')
|
||||
expect(countCalls(backend)).toHaveLength(2)
|
||||
await search('Article 1')
|
||||
expect(countCalls(backend)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('uses the main response total while the filter is on, with no extra request', async () => {
|
||||
const backend = backendWith(boardArticles())
|
||||
await mountMeta('articles', '/article?seo=suggestions', { backend })
|
||||
|
||||
expect(countCalls(backend)).toHaveLength(0)
|
||||
expect(textOf(toggle())).toBe('Needs attention 3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Needs attention: empty states', () => {
|
||||
it('shows Nothing needs attention and returns to the list with Show all articles', async () => {
|
||||
const backend = backendWith([withSeo(1, 'ready', 0), withSeo(2, 'ready', 0)])
|
||||
await mountMeta('articles', '/article?seo=suggestions&q=', { backend })
|
||||
|
||||
const text = textOf(document.body)
|
||||
expect(text).toContain('Nothing needs attention')
|
||||
expect(text).toContain(
|
||||
'Every article has a clean check. Suggestions are optional, so this is just a bonus.',
|
||||
)
|
||||
expect(document.body.querySelector('[data-testid="paginator-summary"]')).toBeNull()
|
||||
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('button'))
|
||||
.find((item) => textOf(item) === 'Show all articles')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(currentQuery()).toEqual({})
|
||||
expect(mainCalls(backend).at(-1)!.query.has('seo')).toBe(false)
|
||||
expect(bodyRows()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ query: 'q=tiles', title: 'No articles match "tiles"' },
|
||||
{ query: 'status=draft', title: 'No drafts' },
|
||||
{ query: 'status=online', title: 'Nothing is online yet' },
|
||||
])('keeps the existing state for $query with the filter on', async ({ query, title }) => {
|
||||
const backend = backendWith([withSeo(1, 'ready', 0)])
|
||||
await mountMeta('articles', `/article?seo=suggestions&${query}`, { backend })
|
||||
|
||||
expect(textOf(document.body)).toContain(title)
|
||||
expect(currentQuery().seo).toBe('suggestions')
|
||||
})
|
||||
|
||||
it('prefers Nothing needs attention to the site state', async () => {
|
||||
const backend = backendWith([withSeo(1, 'ready', 0)], 'several')
|
||||
await mountMeta('articles', '/article?seo=suggestions&site=archive', { backend })
|
||||
|
||||
expect(textOf(document.body)).toContain('Nothing needs attention')
|
||||
expect(textOf(document.body)).not.toContain('No articles on "Archive"')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,284 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import type { SuggestionCode } from '@/lib/seoTypes'
|
||||
import { failWith, networkFailure, pending, textOf, unmountMeta } from './metaTestKit'
|
||||
import {
|
||||
checkAnswer,
|
||||
checkCalls,
|
||||
mountSeo,
|
||||
openPanel,
|
||||
openSection,
|
||||
patchBodies,
|
||||
sectionBody,
|
||||
sectionSummary,
|
||||
seoBackend,
|
||||
suggestion,
|
||||
} from './seoTestKit'
|
||||
|
||||
const emptyRectList = () => Object.assign([], { item: () => null }) as unknown as DOMRectList
|
||||
|
||||
Range.prototype.getClientRects = emptyRectList
|
||||
Range.prototype.getBoundingClientRect = () => new DOMRect()
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(unmountMeta)
|
||||
|
||||
const TITLE_MESSAGE =
|
||||
'The meta title is 74 characters. Search engines may cut it off after about 60.'
|
||||
|
||||
const ALL = [
|
||||
suggestion('title_long', 'seo_title', { length: 74, max: 60 }),
|
||||
suggestion('cover_alt', 'cover_image_alt'),
|
||||
suggestion('no_h', 'body'),
|
||||
]
|
||||
|
||||
function backendWithDismissals(saved: SuggestionCode[] = [], all = ALL) {
|
||||
const backend = seoBackend({ article: { seo_dismissed: saved } })
|
||||
const state = { saved }
|
||||
backend.responders['POST /v1/article/12/seo/check'] = () =>
|
||||
checkAnswer(all.filter((item) => !state.saved.includes(item.code)))
|
||||
backend.responders['PATCH /v1/article/12'] = (call) => {
|
||||
state.saved = [...new Set(call.body!.seo_dismissed as SuggestionCode[])]
|
||||
return { ...backend.article, seo_dismissed: state.saved }
|
||||
}
|
||||
return backend
|
||||
}
|
||||
|
||||
function dismissButtons(root: ParentNode = document.body) {
|
||||
return Array.from(
|
||||
root.querySelectorAll<HTMLButtonElement>('[data-testid="suggestion-dismiss"]'),
|
||||
)
|
||||
}
|
||||
|
||||
function panelRows(root: ParentNode) {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>('[data-testid="suggestion"]'))
|
||||
}
|
||||
|
||||
async function dismiss(index: number) {
|
||||
dismissButtons()[index]!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function toastText() {
|
||||
return textOf(document.body.querySelector('[data-slot="viewport"]'))
|
||||
}
|
||||
|
||||
describe('Dismiss x: rendering', () => {
|
||||
it('draws one button per panel row and a footnote, and none in the compact list', async () => {
|
||||
const backend = backendWithDismissals()
|
||||
await mountSeo(backend)
|
||||
const compact = await openSection()
|
||||
expect(dismissButtons(compact)).toHaveLength(0)
|
||||
expect(textOf(compact)).not.toContain('Dismiss one and it stays hidden')
|
||||
|
||||
const panel = await openPanel()
|
||||
expect(dismissButtons(panel)).toHaveLength(3)
|
||||
expect(textOf(panel)).toContain(
|
||||
'Suggestions never block saving or publishing. Dismiss one and it stays hidden for this article.',
|
||||
)
|
||||
})
|
||||
|
||||
it('names the button with the message and orders it after the Go to link', async () => {
|
||||
await mountSeo(backendWithDismissals())
|
||||
const panel = await openPanel()
|
||||
|
||||
const first = dismissButtons(panel)[0]!
|
||||
expect(first.getAttribute('aria-label')).toBe(`Dismiss suggestion: ${TITLE_MESSAGE}`)
|
||||
expect(first.className).toContain('focus-visible:outline-2')
|
||||
const row = panelRows(panel)[0]!
|
||||
const link = Array.from(row.querySelectorAll('button')).find((button) =>
|
||||
textOf(button).startsWith('Go to'),
|
||||
)!
|
||||
expect(link.compareDocumentPosition(first) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
)
|
||||
})
|
||||
|
||||
it('is 44 px on mobile and 24 px on desktop', async () => {
|
||||
await mountSeo(backendWithDismissals(), { width: MOBILE_WIDTH })
|
||||
const panel = await openPanel()
|
||||
|
||||
expect(dismissButtons(panel)[0]!.className).toContain('size-11')
|
||||
expect(dismissButtons(panel)[0]!.className).toContain('md:size-6')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dismiss x: behaviour', () => {
|
||||
it('sends one PATCH with the saved list plus the code and nothing else', async () => {
|
||||
const backend = backendWithDismissals(['no_au'])
|
||||
await mountSeo(backend)
|
||||
await openPanel()
|
||||
const before = checkCalls(backend).length
|
||||
await dismiss(0)
|
||||
|
||||
expect(patchBodies(backend)).toEqual([{ seo_dismissed: ['no_au', 'title_long'] }])
|
||||
expect(checkCalls(backend)).toHaveLength(before + 1)
|
||||
})
|
||||
|
||||
it('keeps the row and shows a busy button until the PATCH answers', async () => {
|
||||
const backend = backendWithDismissals()
|
||||
const hold = pending<unknown>()
|
||||
const answer = backend.responders['PATCH /v1/article/12']!
|
||||
backend.responders['PATCH /v1/article/12'] = async (call) => {
|
||||
await hold.promise
|
||||
return answer(call)
|
||||
}
|
||||
await mountSeo(backend)
|
||||
const panel = await openPanel()
|
||||
await dismiss(0)
|
||||
|
||||
expect(panelRows(panel)).toHaveLength(3)
|
||||
expect(dismissButtons(panel)[0]!.disabled).toBe(true)
|
||||
expect(dismissButtons(panel)[0]!.getAttribute('aria-busy')).toBe('true')
|
||||
hold.resolve(undefined)
|
||||
await flushPromises()
|
||||
expect(panelRows(panel)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('removes the row, keeps the order and updates the summary', async () => {
|
||||
const backend = backendWithDismissals()
|
||||
await mountSeo(backend)
|
||||
const panel = await openPanel()
|
||||
await dismiss(0)
|
||||
|
||||
const rows = panelRows(panel).map(textOf)
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]).toContain('The cover image has no alt text')
|
||||
expect(rows[1]).toContain('The body has no headings')
|
||||
})
|
||||
|
||||
it('shows All good with the dismissed line when the last one goes, and focuses the link', async () => {
|
||||
const backend = backendWithDismissals([], [ALL[0]!])
|
||||
await mountSeo(backend)
|
||||
const panel = await openPanel()
|
||||
await dismiss(0)
|
||||
|
||||
const text = textOf(panel)
|
||||
expect(text).toContain('All good')
|
||||
expect(text).toContain('1 dismissed suggestion is hidden.')
|
||||
const restore = panel.querySelector<HTMLElement>('[data-testid="suggestions-restore"]')!
|
||||
expect(textOf(restore)).toBe('Show dismissed')
|
||||
expect(document.activeElement).toBe(restore)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ position: 'first', index: 0, target: 0 },
|
||||
{ position: 'second', index: 1, target: 1 },
|
||||
{ position: 'last', index: 2, target: 1 },
|
||||
])('moves focus after dismissing the $position row', async ({ index, target }) => {
|
||||
const backend = backendWithDismissals()
|
||||
await mountSeo(backend)
|
||||
const panel = await openPanel()
|
||||
await dismiss(index)
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(dismissButtons(panel)[target])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ failure: 'status 500', responder: failWith(500) },
|
||||
{ failure: 'a network error', responder: networkFailure },
|
||||
])('keeps the row and toasts on $failure', async ({ responder }) => {
|
||||
const backend = backendWithDismissals()
|
||||
backend.responders['PATCH /v1/article/12'] = responder
|
||||
await mountSeo(backend)
|
||||
const panel = await openPanel()
|
||||
const before = checkCalls(backend).length
|
||||
await dismiss(0)
|
||||
|
||||
expect(panelRows(panel)).toHaveLength(3)
|
||||
expect(dismissButtons(panel)[0]!.disabled).toBe(false)
|
||||
expect(toastText()).toContain("Couldn't dismiss the suggestion")
|
||||
expect(toastText()).toContain('Try again in a moment.')
|
||||
expect(checkCalls(backend)).toHaveLength(before)
|
||||
})
|
||||
|
||||
it('shows the Not found state on a 404', async () => {
|
||||
const backend = backendWithDismissals()
|
||||
backend.responders['PATCH /v1/article/12'] = failWith(404)
|
||||
await mountSeo(backend)
|
||||
await openPanel()
|
||||
await dismiss(0)
|
||||
|
||||
expect(textOf(document.body)).toContain("This article doesn't exist.")
|
||||
})
|
||||
|
||||
it('shows nothing and sends no check on a 401', async () => {
|
||||
const backend = backendWithDismissals()
|
||||
backend.responders['PATCH /v1/article/12'] = failWith(401)
|
||||
await mountSeo(backend)
|
||||
const panel = await openPanel()
|
||||
const before = checkCalls(backend).length
|
||||
const toastsBefore = toastText()
|
||||
await dismiss(0)
|
||||
|
||||
expect(panelRows(panel)).toHaveLength(3)
|
||||
expect(toastText()).toBe(toastsBefore)
|
||||
expect(checkCalls(backend)).toHaveLength(before)
|
||||
})
|
||||
|
||||
it('restores every code with Show dismissed and checks again', async () => {
|
||||
const backend = backendWithDismissals(['title_long', 'short'], [ALL[0]!])
|
||||
await mountSeo(backend)
|
||||
const compact = await openSection()
|
||||
expect(textOf(compact)).toContain('2 dismissed suggestions are hidden.')
|
||||
const link = compact.querySelector<HTMLElement>('[data-testid="suggestions-restore"]')!
|
||||
link.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(patchBodies(backend)).toEqual([{ seo_dismissed: [] }])
|
||||
expect(sectionBody()!.querySelectorAll('[data-testid="suggestion"]')).toHaveLength(1)
|
||||
expect(sectionSummary()).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['owner', 'admin', 'verified'] as const)('is available to the %s', async (role) => {
|
||||
const backend = backendWithDismissals()
|
||||
backend.me.role = role === 'verified' ? undefined : role
|
||||
await mountSeo(backend)
|
||||
const panel = await openPanel()
|
||||
|
||||
expect(dismissButtons(panel)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('keeps Save enabled and does not touch the draft', async () => {
|
||||
const backend = backendWithDismissals()
|
||||
await mountSeo(backend)
|
||||
await openPanel()
|
||||
await dismiss(0)
|
||||
|
||||
expect(patchBodies(backend)).toHaveLength(1)
|
||||
const save = document.body.querySelector<HTMLButtonElement>('[data-testid="save-button"]')
|
||||
expect(save?.getAttribute('data-state') ?? 'saved').not.toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dismiss x: French', () => {
|
||||
it('reads the name, footnote and plural in French', async () => {
|
||||
const { setLocale } = await import('@/i18n')
|
||||
setLocale('fr')
|
||||
try {
|
||||
await mountSeo(backendWithDismissals(['no_au', 'short'], ALL))
|
||||
const panel = await openPanel()
|
||||
|
||||
expect(dismissButtons(panel)[0]!.getAttribute('aria-label')).toMatch(
|
||||
/^Ignorer la suggestion : /,
|
||||
)
|
||||
expect(textOf(panel)).toContain(
|
||||
'Ignorez-en une et elle reste masquée pour cet article.',
|
||||
)
|
||||
expect(textOf(panel)).toContain('2 suggestions ignorées sont masquées.')
|
||||
} finally {
|
||||
setLocale('en')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,216 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import EndpointCard from '@/components/EndpointCard.vue'
|
||||
import { API_GROUPS, ERROR_ROWS } from '@/data/apiCatalogue'
|
||||
import IntegrationView from '../IntegrationView.vue'
|
||||
|
||||
const ORIGIN = 'https://cms.renew.example'
|
||||
const BODY_MISMATCH = 'The request cannot be processed due to a mismatch in the format of the body.'
|
||||
const NOTE =
|
||||
'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.'
|
||||
const GROUP_DESCRIPTION =
|
||||
"RenewCMS doesn't render pages or add meta tags. These endpoints give your frontend the resolved SEO data and the list of published URLs."
|
||||
|
||||
let currentWrapper: VueWrapper | null = null
|
||||
let fetchSpy: ReturnType<typeof vi.fn>
|
||||
|
||||
async function mountIntegration(path = '/integration') {
|
||||
vi.stubGlobal('location', { ...window.location, origin: ORIGIN })
|
||||
fetchSpy = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.status = 'ready'
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
await router.push(path)
|
||||
currentWrapper = mount(IntegrationView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
return currentWrapper
|
||||
}
|
||||
|
||||
function section(wrapper: VueWrapper, id: string) {
|
||||
return wrapper.get(`[data-anchor="${id}"]`)
|
||||
}
|
||||
|
||||
function cardOf(wrapper: VueWrapper, route: string) {
|
||||
const [method, path] = route.split(' ')
|
||||
return wrapper
|
||||
.findAllComponents(EndpointCard)
|
||||
.find(
|
||||
(item) =>
|
||||
item.props('endpoint').method === method && item.props('endpoint').path === path,
|
||||
)!
|
||||
}
|
||||
|
||||
async function expand(wrapper: VueWrapper, route: string) {
|
||||
const found = wrapper
|
||||
.findAll('button[aria-expanded]')
|
||||
.find((button) => button.attributes('aria-label')?.startsWith(`${route}. `))!
|
||||
await found.trigger('click')
|
||||
await flushPromises()
|
||||
return cardOf(wrapper, route)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
currentWrapper?.unmount()
|
||||
currentWrapper = null
|
||||
vi.unstubAllGlobals()
|
||||
setLocale('en')
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe('Search and sharing group', () => {
|
||||
it('sits directly after Articles with its description and 2 routes', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const keys = API_GROUPS.map((group) => group.key)
|
||||
expect(keys.indexOf('seo')).toBe(keys.indexOf('articles') + 1)
|
||||
const group = section(wrapper, 'seo')
|
||||
expect(group.text()).toContain('Search and sharing')
|
||||
expect(group.text()).toContain(GROUP_DESCRIPTION)
|
||||
expect(group.text()).toContain('2 routes')
|
||||
})
|
||||
|
||||
it('holds both cards, Public and API key OK, collapsed, with no duplicate route', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const group = section(wrapper, 'seo')
|
||||
const cards = group.findAllComponents(EndpointCard)
|
||||
expect(
|
||||
cards.map((item) => `${item.props('endpoint').method} ${item.props('endpoint').path}`),
|
||||
).toEqual(['GET /v1/article/{id}/seo', 'GET /v1/site/{key}/sitemap'])
|
||||
for (const item of cards) {
|
||||
expect(item.text()).toContain('Public')
|
||||
expect(item.text()).toContain('API key OK')
|
||||
expect(item.find('button[aria-expanded]').attributes('aria-expanded')).toBe('false')
|
||||
}
|
||||
const all = wrapper
|
||||
.findAllComponents(EndpointCard)
|
||||
.map((item) => `${item.props('endpoint').method} ${item.props('endpoint').path}`)
|
||||
expect(all.filter((route) => route.endsWith('/seo'))).toHaveLength(1)
|
||||
expect(all.filter((route) => route.endsWith('/sitemap'))).toHaveLength(1)
|
||||
expect(
|
||||
section(wrapper, 'articles')
|
||||
.findAllComponents(EndpointCard)
|
||||
.map((item) => item.props('endpoint').path),
|
||||
).toContain('/v1/article/{id}/seo/check')
|
||||
expect(section(wrapper, 'sites').text()).not.toContain('sitemap')
|
||||
expect(section(wrapper, 'sites').text()).toContain('1 route')
|
||||
})
|
||||
|
||||
it('describes the resolved SEO object and shows the real error rows', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const seo = await expand(wrapper, 'GET /v1/article/{id}/seo')
|
||||
|
||||
const text = seo.text()
|
||||
expect(text).toContain('Drafts answer 404 to anonymous callers.')
|
||||
for (const name of [
|
||||
'canonical_url',
|
||||
'robots',
|
||||
'og',
|
||||
'twitter',
|
||||
'image',
|
||||
'authors',
|
||||
'published_at',
|
||||
'modified_at',
|
||||
'fallbacks',
|
||||
'json_ld',
|
||||
]) {
|
||||
expect(text).toContain(name)
|
||||
}
|
||||
expect(text).toContain('No request body.')
|
||||
for (const tab of ['fetch', 'curl', 'Response']) {
|
||||
expect(seo.findAll('[role="tab"]').map((item) => item.text())).toContain(tab)
|
||||
}
|
||||
expect(text).toContain('Copy')
|
||||
expect(text).toContain('400')
|
||||
expect(text).toContain('401')
|
||||
expect(text).toContain('404')
|
||||
})
|
||||
|
||||
it('shows the sitemap query rows, item shape, total header and real error strings', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const sitemap = await expand(wrapper, 'GET /v1/site/{key}/sitemap')
|
||||
|
||||
const text = sitemap.text()
|
||||
expect(text).toContain('A whole number from 1. Default 1.')
|
||||
expect(text).toContain('25, 50, 100 or 500. Default 100.')
|
||||
expect(text).toContain('{ url, modified_at, noindex }')
|
||||
expect(text).toContain('X-Total-Count')
|
||||
expect(text).toContain('/v1/site/renew-blog/sitemap')
|
||||
expect(text).toContain(BODY_MISMATCH)
|
||||
expect(text).toContain('site_not_found')
|
||||
expect(text).not.toContain('Invalid page or size.')
|
||||
})
|
||||
|
||||
it('lists the index in order and scrolls to the group with the deep link #seo', async () => {
|
||||
const wrapper = await mountIntegration('/integration#seo')
|
||||
|
||||
const labels = wrapper
|
||||
.get('nav[aria-label="On this page"]')
|
||||
.findAll('button')
|
||||
.map((button) => button.text())
|
||||
expect(labels).toEqual([
|
||||
'Authentication',
|
||||
'Articles',
|
||||
'Search and sharing',
|
||||
'Media',
|
||||
'Users and invites',
|
||||
'Settings',
|
||||
'Sites',
|
||||
'Authors',
|
||||
'Images and variants',
|
||||
'Session only',
|
||||
'Errors',
|
||||
'Notes',
|
||||
])
|
||||
expect(Element.prototype.scrollIntoView).toHaveBeenCalled()
|
||||
const current = wrapper
|
||||
.get('nav[aria-label="On this page"]')
|
||||
.findAll('button[aria-current]')
|
||||
.map((button) => button.text())
|
||||
expect(current).toEqual(['Search and sharing'])
|
||||
})
|
||||
|
||||
it('adds the 403 note under the messages and leaves them unchanged', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const forbidden = ERROR_ROWS.find((row) => row.status === 403)!
|
||||
expect(forbidden.messages).toHaveLength(3)
|
||||
expect(forbidden.note).toBe(NOTE)
|
||||
const notes = wrapper.findAll('[data-testid="error-note"]').map((item) => item.text())
|
||||
expect(notes).toEqual([NOTE, NOTE])
|
||||
expect(ERROR_ROWS.filter((row) => row.note)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('makes no /v1 request when opening and expanding the new cards', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
await expand(wrapper, 'GET /v1/article/{id}/seo')
|
||||
await expand(wrapper, 'GET /v1/site/{key}/sitemap')
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads the group, the cards and the note in French', async () => {
|
||||
setLocale('fr')
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const group = section(wrapper, 'seo')
|
||||
expect(group.text()).toContain('Recherche et partage')
|
||||
expect(group.text()).toContain('RenewCMS n’affiche pas de pages')
|
||||
expect(group.text()).toContain('2 routes')
|
||||
expect(wrapper.text()).toContain(
|
||||
'Les valeurs SEO par défaut d’un site sont réservées au propriétaire.',
|
||||
)
|
||||
expect(section(wrapper, 'sites').text()).not.toContain('sitemap')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,342 @@
|
||||
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,
|
||||
mountDirectory,
|
||||
networkFailure,
|
||||
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() }
|
||||
})
|
||||
|
||||
const TEMPLATE_ERROR = 'The template must contain %title%.'
|
||||
const SAVE_FAILED = "Couldn't save the site. Your changes are still here. Try again in a moment."
|
||||
const SITE_NAME_HELP = 'Shown in titles through the template, and sent as the Open Graph site name.'
|
||||
const DESCRIPTION_HELP = 'Used when an article has no description, excerpt or body text.'
|
||||
|
||||
afterEach(unmountDirectory)
|
||||
|
||||
function box() {
|
||||
return dialog()!
|
||||
}
|
||||
|
||||
function templateField() {
|
||||
return box().querySelector<HTMLInputElement>('input[maxlength="200"]')!
|
||||
}
|
||||
|
||||
function siteNameField() {
|
||||
return field('e.g. Renew')
|
||||
}
|
||||
|
||||
function descriptionField() {
|
||||
return field('One or two sentences about the site.') as unknown as HTMLTextAreaElement
|
||||
}
|
||||
|
||||
async function openEdit(name = 'Renew', backend = createBackend()) {
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
await openRowMenu(name)
|
||||
await chooseMenuItem('Edit')
|
||||
return backend
|
||||
}
|
||||
|
||||
async function openAdd(backend = createBackend()) {
|
||||
await mountDirectory('sites', '/settings/sites', { backend })
|
||||
await click('Add site')
|
||||
return backend
|
||||
}
|
||||
|
||||
function submit(label = 'Save changes') {
|
||||
return click(label, box())
|
||||
}
|
||||
|
||||
describe('Sites SEO defaults: field order and copy', () => {
|
||||
it('lists the fields in the board order, then the preview and the defaults block', async () => {
|
||||
await openEdit()
|
||||
|
||||
const text = textOf(box())
|
||||
const order = [
|
||||
'Name',
|
||||
'Key',
|
||||
'Base URL',
|
||||
'Default social image',
|
||||
'Site name',
|
||||
'Title template',
|
||||
'Example',
|
||||
'Default description',
|
||||
'Default robots policy',
|
||||
'Locale',
|
||||
'Default site',
|
||||
'Canonical URL preview',
|
||||
'How articles use these defaults',
|
||||
]
|
||||
const positions = order.map((label) => text.indexOf(label))
|
||||
expect(positions.every((position) => position >= 0)).toBe(true)
|
||||
expect([...positions].sort((a, b) => a - b)).toEqual(positions)
|
||||
})
|
||||
|
||||
it('edit: shows the saved values, helpers, example, counter and the defaults block', async () => {
|
||||
await openEdit()
|
||||
|
||||
const text = textOf(box())
|
||||
expect(siteNameField().value).toBe('Renew')
|
||||
expect(text).toContain(SITE_NAME_HELP)
|
||||
expect(text).not.toContain('Site name (optional)')
|
||||
expect(templateField().value).toBe('%title% | %site%')
|
||||
expect(text).toContain('Use %title% for the article title and %site% for the site name.')
|
||||
expect(text).toContain('Example How we price a bathroom renovation | Renew')
|
||||
expect(text).toContain('Default description (optional)')
|
||||
expect(text).toContain('0 / 320')
|
||||
expect(text).toContain(DESCRIPTION_HELP)
|
||||
for (const copy of [
|
||||
'Title Article title, then the template.',
|
||||
"Description Used last, after the article's description, excerpt and body.",
|
||||
'Image Used when an article has no cover image.',
|
||||
'Robots Used unless an article is hidden from search engines.',
|
||||
]) {
|
||||
expect(text).toContain(copy)
|
||||
}
|
||||
expect(templateField().className).toContain('font-mono')
|
||||
})
|
||||
|
||||
it('add: starts empty with the default template', async () => {
|
||||
await openAdd()
|
||||
|
||||
expect(siteNameField().value).toBe('')
|
||||
expect(templateField().value).toBe('%title% | %site%')
|
||||
expect(descriptionField().value).toBe('')
|
||||
expect(textOf(box())).toContain('How articles use these defaults')
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['Journal', 'Journal', '%title% – %site%', 'How we price a bathroom renovation – Journal'],
|
||||
['Journal', '', '%title% – %site%', 'How we price a bathroom renovation – Journal'],
|
||||
['Journal', 'Le Journal', '%site%: %title%', 'Le Journal: How we price a bathroom renovation'],
|
||||
['Journal', 'Journal', '%title%', 'How we price a bathroom renovation'],
|
||||
['Journal', 'Journal', '%site% / %site%', 'Journal / Journal'],
|
||||
['', '', '%title% | %site%', 'How we price a bathroom renovation | Renew'],
|
||||
])(
|
||||
'Sites SEO defaults: example for name "%s", site name "%s", template "%s"',
|
||||
(name, siteName, template, example) => {
|
||||
it('follows the typing and sends nothing', async () => {
|
||||
const backend = await openAdd()
|
||||
const before = backend.calls.length
|
||||
await typeInto(field('e.g. Journal'), name)
|
||||
await typeInto(siteNameField(), siteName)
|
||||
await typeInto(templateField(), template)
|
||||
|
||||
expect(textOf(box())).toContain(`Example ${example}`)
|
||||
expect(backend.calls).toHaveLength(before)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
describe.each(['just text', '%site%', '%TITLE%', ''])(
|
||||
'Sites SEO defaults: template "%s"',
|
||||
(template) => {
|
||||
it('is refused on blur and on submit, with nothing sent', async () => {
|
||||
const backend = await openEdit()
|
||||
await typeInto(templateField(), template)
|
||||
await blur(templateField())
|
||||
|
||||
expect(textOf(box())).toContain(TEMPLATE_ERROR)
|
||||
expect(textOf(box())).not.toContain('Example')
|
||||
|
||||
await typeInto(templateField(), template)
|
||||
await submit()
|
||||
expect(callsTo(backend, 'PATCH', '/v1/site/1')).toHaveLength(0)
|
||||
expect(dialog()).not.toBeNull()
|
||||
expect(document.activeElement).toBe(templateField())
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
describe('Sites SEO defaults: template errors', () => {
|
||||
it('clears the error and shows the example again on a valid value', async () => {
|
||||
await openEdit()
|
||||
await typeInto(templateField(), 'nope')
|
||||
await blur(templateField())
|
||||
await typeInto(templateField(), '%title%')
|
||||
|
||||
expect(textOf(box())).not.toContain(TEMPLATE_ERROR)
|
||||
expect(textOf(box())).toContain('Example')
|
||||
})
|
||||
|
||||
it('shows the same message when the API answers 400 invalid_title_template', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['PATCH /v1/site/1'] = failWith(400, 'invalid_title_template')
|
||||
await openEdit('Renew', backend)
|
||||
await typeInto(templateField(), '%title% – %site%')
|
||||
await submit()
|
||||
|
||||
expect(textOf(box())).toContain(TEMPLATE_ERROR)
|
||||
expect(templateField().value).toBe('%title% – %site%')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites SEO defaults: limits and counter', () => {
|
||||
it('stops the inputs at 100, 200 and 320 characters', async () => {
|
||||
await openAdd()
|
||||
|
||||
expect(siteNameField().getAttribute('maxlength')).toBe('100')
|
||||
expect(templateField().getAttribute('maxlength')).toBe('200')
|
||||
expect(descriptionField().getAttribute('maxlength')).toBe('320')
|
||||
})
|
||||
|
||||
it.each([0, 45, 320])('counts %i characters without blocking', async (count) => {
|
||||
await openAdd()
|
||||
await typeInto(descriptionField(), 'a'.repeat(count))
|
||||
|
||||
expect(textOf(box())).toContain(`${count} / 320`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites SEO defaults: save payloads', () => {
|
||||
it('sends only the changed template', async () => {
|
||||
const backend = await openEdit()
|
||||
await typeInto(templateField(), '%title% – %site%')
|
||||
await submit()
|
||||
|
||||
const patches = callsTo(backend, 'PATCH', '/v1/site/1')
|
||||
expect(patches).toHaveLength(1)
|
||||
expect(patches[0]!.body).toEqual({ title_template: '%title% – %site%' })
|
||||
expect(dialog()).toBeNull()
|
||||
})
|
||||
|
||||
it('sends site name, description and robots together, trimmed', async () => {
|
||||
const backend = await openEdit()
|
||||
await typeInto(siteNameField(), ' Renew Journal ')
|
||||
await typeInto(descriptionField(), 'Notes on renewal work. ')
|
||||
box().querySelector<HTMLElement>('[role="radio"][value="noindex"]')!.click()
|
||||
await flushPromises()
|
||||
await submit()
|
||||
|
||||
const patches = callsTo(backend, 'PATCH', '/v1/site/1')
|
||||
expect(patches).toHaveLength(1)
|
||||
expect(patches[0]!.body).toEqual({
|
||||
site_name: 'Renew Journal',
|
||||
default_description: 'Notes on renewal work.',
|
||||
default_robots: 'noindex',
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a site without the three fields when untouched', async () => {
|
||||
const backend = await openAdd()
|
||||
await typeInto(field('e.g. Journal'), 'Notes')
|
||||
await typeInto(field('https://blog.example.com'), 'https://notes.example')
|
||||
await submit('Add site')
|
||||
|
||||
const body = callsTo(backend, 'POST', '/v1/site/')[0]!.body!
|
||||
expect(body).not.toHaveProperty('site_name')
|
||||
expect(body).not.toHaveProperty('title_template')
|
||||
expect(body).not.toHaveProperty('default_description')
|
||||
})
|
||||
|
||||
it('creates a site with the SEO values', async () => {
|
||||
const backend = await openAdd()
|
||||
await typeInto(field('e.g. Journal'), 'Notes')
|
||||
await typeInto(field('https://blog.example.com'), 'https://notes.example')
|
||||
await typeInto(siteNameField(), 'Renew Shop')
|
||||
await typeInto(templateField(), '%title% - %site%')
|
||||
await typeInto(descriptionField(), 'A shop about renewal.')
|
||||
await submit('Add site')
|
||||
|
||||
expect(callsTo(backend, 'POST', '/v1/site/')[0]!.body).toMatchObject({
|
||||
site_name: 'Renew Shop',
|
||||
title_template: '%title% - %site%',
|
||||
default_description: 'A shop about renewal.',
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a site without the new fields on the defaults and sends nothing on save', async () => {
|
||||
const backend = createBackend()
|
||||
for (const site of backend.sites) {
|
||||
delete site.site_name
|
||||
delete site.title_template
|
||||
delete site.default_description
|
||||
}
|
||||
await openEdit('Renew', backend)
|
||||
|
||||
expect(siteNameField().value).toBe('')
|
||||
expect(descriptionField().value).toBe('')
|
||||
expect(templateField().value).toBe('%title% | %site%')
|
||||
await submit()
|
||||
expect(callsTo(backend, 'PATCH', '/v1/site/1')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each(['site name', 'template', 'description'])(
|
||||
'asks to discard a changed %s',
|
||||
async (which) => {
|
||||
await openEdit()
|
||||
const target =
|
||||
which === 'site name'
|
||||
? siteNameField()
|
||||
: which === 'template'
|
||||
? templateField()
|
||||
: descriptionField()
|
||||
await typeInto(target, 'changed %title%')
|
||||
await click('Cancel', box())
|
||||
|
||||
expect(textOf(document.body.querySelector('[role="alertdialog"]'))).toContain(
|
||||
'Discard changes?',
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['status 500', failWith(500)],
|
||||
['a network error', networkFailure],
|
||||
])('Sites SEO defaults: save failed with %s', (_name, responder) => {
|
||||
it('keeps the panel, the notice and the three values', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['PATCH /v1/site/1'] = responder
|
||||
await openEdit('Renew', backend)
|
||||
await typeInto(siteNameField(), 'Renew Journal')
|
||||
await typeInto(templateField(), '%title% – %site%')
|
||||
await typeInto(descriptionField(), 'Notes.')
|
||||
await submit()
|
||||
|
||||
expect(textOf(box().querySelector('[role="alert"]'))).toContain(SAVE_FAILED)
|
||||
expect(siteNameField().value).toBe('Renew Journal')
|
||||
expect(templateField().value).toBe('%title% – %site%')
|
||||
expect(descriptionField().value).toBe('Notes.')
|
||||
expect(button('Save changes', box())?.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites SEO defaults: sessions', () => {
|
||||
it('shows the owner-only toast when the PATCH answers 403', async () => {
|
||||
const backend = createBackend()
|
||||
backend.responders['PATCH /v1/site/1'] = failWith(403, 'owner_only')
|
||||
await openEdit('Renew', backend)
|
||||
await typeInto(templateField(), '%title% – %site%')
|
||||
await submit()
|
||||
|
||||
expect(document.body.textContent).toContain('Only the owner can manage sites')
|
||||
expect(document.body.textContent).toContain('Ask the owner of this site.')
|
||||
})
|
||||
|
||||
it('shows the 403 page to an admin', async () => {
|
||||
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
|
||||
|
||||
expect(document.body.textContent).toContain("You don't have access to Sites")
|
||||
expect(document.body.textContent).not.toContain('Title template')
|
||||
expect(vi.mocked(useApi)).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -6,15 +6,25 @@ export interface EnvelopeParams {
|
||||
q?: string
|
||||
status?: ArticleStatus | ''
|
||||
sort?: string
|
||||
site?: string
|
||||
seo?: '' | 'suggestions'
|
||||
}
|
||||
|
||||
export const NEEDS_ATTENTION_TOTAL = 3
|
||||
export const NEEDS_ATTENTION_ROUTE = 'GET /v1/article/?seo=suggestions&page=1&size=5'
|
||||
|
||||
function fold(value: string) {
|
||||
return value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
export function articleEnvelope(all: Article[], params: EnvelopeParams = {}): ArticlePage {
|
||||
const { page = 1, size = 25, q = '', status = '', sort = '' } = params
|
||||
const { page = 1, size = 25, q = '', status = '', sort = '', seo = '' } = params
|
||||
const filtered = all
|
||||
.filter(
|
||||
(article) =>
|
||||
!seo ||
|
||||
(article.seo_status === 'suggestions' && (article.seo_suggestions ?? 0) > 0),
|
||||
)
|
||||
.filter((article) => !status || article.is_online === (status === 'online'))
|
||||
.filter((article) => !q || fold(article.title).includes(fold(q)))
|
||||
if (sort) {
|
||||
@@ -35,8 +45,17 @@ export function articleEnvelope(all: Article[], params: EnvelopeParams = {}): Ar
|
||||
}
|
||||
}
|
||||
|
||||
export function isNeedsAttentionCount(url: string) {
|
||||
const query = new URL(url, 'http://localhost').searchParams
|
||||
return query.get('seo') === 'suggestions' && query.get('size') === '5' && !query.has('format')
|
||||
}
|
||||
|
||||
export function needsAttentionCountPage(total = NEEDS_ATTENTION_TOTAL): ArticlePage {
|
||||
return { items: [], total, page: 1, size: 5, counts: { all: 0, online: 0, draft: 0 } }
|
||||
}
|
||||
|
||||
export function articleListUrl(params: EnvelopeParams = {}) {
|
||||
const { page = 1, size = 25, q = '', status = '', sort = '' } = params
|
||||
const { page = 1, size = 25, q = '', status = '', sort = '', site = '', seo = '' } = params
|
||||
const query = new URLSearchParams({
|
||||
format: 'html',
|
||||
page: String(page),
|
||||
@@ -51,6 +70,12 @@ export function articleListUrl(params: EnvelopeParams = {}) {
|
||||
if (sort) {
|
||||
query.set('sort', sort)
|
||||
}
|
||||
if (site) {
|
||||
query.set('site', site)
|
||||
}
|
||||
if (seo) {
|
||||
query.set('seo', seo)
|
||||
}
|
||||
return `/v1/article/?${query.toString()}`
|
||||
}
|
||||
|
||||
@@ -66,5 +91,7 @@ export function parseListUrl(url: string): EnvelopeParams {
|
||||
q: query.get('q') ?? '',
|
||||
status: (query.get('status') ?? '') as ArticleStatus | '',
|
||||
sort: query.get('sort') ?? '',
|
||||
...(query.get('site') ? { site: query.get('site') ?? '' } : {}),
|
||||
...(query.get('seo') ? { seo: 'suggestions' as const } : {}),
|
||||
}
|
||||
}
|
||||
@@ -36,12 +36,15 @@ export function sitesFixture(): Site[] {
|
||||
default_social_image_id: null,
|
||||
default_robots: 'index' as const,
|
||||
locale: null,
|
||||
title_template: '%title% | %site%',
|
||||
default_description: '',
|
||||
}
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
id: 1,
|
||||
name: 'Renew',
|
||||
site_name: 'Renew',
|
||||
key: 'renew',
|
||||
base_url: 'https://renew.example',
|
||||
is_default: true,
|
||||
@@ -51,6 +54,7 @@ export function sitesFixture(): Site[] {
|
||||
...base,
|
||||
id: 2,
|
||||
name: 'Journal',
|
||||
site_name: 'Journal',
|
||||
key: 'journal',
|
||||
base_url: 'https://journal.example',
|
||||
is_default: false,
|
||||
@@ -60,6 +64,7 @@ export function sitesFixture(): Site[] {
|
||||
...base,
|
||||
id: 3,
|
||||
name: 'Projects',
|
||||
site_name: 'Projects',
|
||||
key: 'projects',
|
||||
base_url: 'https://projects.example',
|
||||
default_robots: 'noindex',
|
||||
@@ -70,6 +75,7 @@ export function sitesFixture(): Site[] {
|
||||
...base,
|
||||
id: 4,
|
||||
name: 'Archive',
|
||||
site_name: 'Archive',
|
||||
key: 'archive',
|
||||
base_url: 'https://archive.example',
|
||||
is_default: false,
|
||||
|
||||
@@ -174,6 +174,11 @@ function listArticles(backend: MetaBackend, query: URLSearchParams) {
|
||||
.filter((article) => !siteKey || article.site_id === siteId)
|
||||
.filter((article) => !status || article.is_online === (status === 'online'))
|
||||
.filter((article) => !q || article.title.toLowerCase().includes(q))
|
||||
.filter(
|
||||
(article) =>
|
||||
query.get('seo') !== 'suggestions' ||
|
||||
(article.seo_status === 'suggestions' && (article.seo_suggestions ?? 0) > 0),
|
||||
)
|
||||
const online = backend.articles.filter((article) => article.is_online).length
|
||||
return {
|
||||
items: structuredClone(matching.slice((page - 1) * size, page * size)),
|
||||
|
||||
@@ -53,6 +53,18 @@ const extraSources: Record<string, Source[]> = {
|
||||
states: ['With articles', 'Choose destination', 'No articles'],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: officeBoard,
|
||||
group: 'Sites: SEO defaults',
|
||||
states: [
|
||||
'Edit: SEO defaults',
|
||||
'Add: defaults',
|
||||
'Template: custom',
|
||||
'Template: not valid',
|
||||
'Save failed',
|
||||
],
|
||||
alias: {},
|
||||
},
|
||||
],
|
||||
authors: [
|
||||
{
|
||||
@@ -195,6 +207,12 @@ const extraSources: Record<string, Source[]> = {
|
||||
states: ['Several sites', 'Site filter open', 'No articles on a site'],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: officeBoard,
|
||||
group: 'Articles: search status',
|
||||
states: ['Status chips', 'Needs attention on', 'Needs attention: none'],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: officeBoard,
|
||||
group: 'Articles: pagination',
|
||||
|
||||
+155
-119
@@ -837,6 +837,8 @@ function articlesFixture() {
|
||||
site_id: 1,
|
||||
authors: boardBylines('Inès Duarte', 'Camille Roche'),
|
||||
is_online: false,
|
||||
seo_status: 'suggestions',
|
||||
seo_suggestions: 2,
|
||||
created_at: '2026-09-09T09:00:00Z',
|
||||
updated_at: articlesFixtureIso(1 * DAY_MS),
|
||||
},
|
||||
@@ -848,6 +850,8 @@ function articlesFixture() {
|
||||
site_id: 3,
|
||||
authors: boardBylines('Jonas Pereira'),
|
||||
is_online: true,
|
||||
seo_status: 'hidden',
|
||||
seo_suggestions: 0,
|
||||
created_at: '2026-08-28T09:00:00Z',
|
||||
updated_at: articlesFixtureIso(3 * DAY_MS),
|
||||
},
|
||||
@@ -865,6 +869,8 @@ function articlesFixture() {
|
||||
'Léa Fontaine',
|
||||
),
|
||||
is_online: false,
|
||||
seo_status: 'suggestions',
|
||||
seo_suggestions: 3,
|
||||
created_at: '2026-09-02T09:00:00Z',
|
||||
updated_at: articlesFixtureIso(5 * DAY_MS),
|
||||
},
|
||||
@@ -906,7 +912,22 @@ function foldTitle(value: string) {
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function articleEnvelopeFor(all: ListedArticle[], url: URL) {
|
||||
const boardNeedsAttentionCount = 3
|
||||
|
||||
function articleEnvelopeFor(
|
||||
all: ListedArticle[],
|
||||
url: URL,
|
||||
needsAttention = boardNeedsAttentionCount,
|
||||
) {
|
||||
if (url.searchParams.get('seo') === 'suggestions' && !url.searchParams.has('format')) {
|
||||
return {
|
||||
items: [],
|
||||
total: needsAttention,
|
||||
page: 1,
|
||||
size: 5,
|
||||
counts: { all: 0, online: 0, draft: 0 },
|
||||
}
|
||||
}
|
||||
const page = Number(url.searchParams.get('page') ?? 1)
|
||||
const size = Number(url.searchParams.get('size') ?? 25)
|
||||
const q = url.searchParams.get('q') ?? ''
|
||||
@@ -927,8 +948,8 @@ function articleEnvelopeFor(all: ListedArticle[], url: URL) {
|
||||
}
|
||||
}
|
||||
|
||||
function articleListMock(all: ListedArticle[]): ApiMock {
|
||||
return { status: 200, bodyFor: (url) => articleEnvelopeFor(all, url) }
|
||||
function articleListMock(all: ListedArticle[], needsAttention?: number): ApiMock {
|
||||
return { status: 200, bodyFor: (url) => articleEnvelopeFor(all, url, needsAttention) }
|
||||
}
|
||||
|
||||
function articlesFixtureWithImages() {
|
||||
@@ -4715,6 +4736,9 @@ const directorySites = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Renew',
|
||||
site_name: 'Renew',
|
||||
title_template: '%title% | %site%',
|
||||
default_description: '',
|
||||
key: 'renew',
|
||||
base_url: 'https://renew.example',
|
||||
default_social_image_id: 11,
|
||||
@@ -4726,6 +4750,9 @@ const directorySites = [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Journal',
|
||||
site_name: 'Journal',
|
||||
title_template: '%title% | %site%',
|
||||
default_description: '',
|
||||
key: 'journal',
|
||||
base_url: 'https://journal.renew.example',
|
||||
default_social_image_id: null,
|
||||
@@ -4737,6 +4764,9 @@ const directorySites = [
|
||||
{
|
||||
id: 3,
|
||||
name: 'Projects',
|
||||
site_name: 'Projects',
|
||||
title_template: '%title% | %site%',
|
||||
default_description: '',
|
||||
key: 'projects',
|
||||
base_url: 'https://projects.renew.example',
|
||||
default_social_image_id: 12,
|
||||
@@ -4748,6 +4778,9 @@ const directorySites = [
|
||||
{
|
||||
id: 4,
|
||||
name: 'Archive',
|
||||
site_name: 'Archive',
|
||||
title_template: '%title% | %site%',
|
||||
default_description: '',
|
||||
key: 'archive',
|
||||
base_url: 'https://archive.renew.example',
|
||||
default_social_image_id: null,
|
||||
@@ -4807,6 +4840,13 @@ function siteFormField(page: Page, placeholder: string) {
|
||||
return page.getByRole('dialog').getByPlaceholder(placeholder)
|
||||
}
|
||||
|
||||
const siteSeoDescription =
|
||||
'Practical advice on renovating old buildings, from the people who do the work.'
|
||||
|
||||
function siteTemplateField(page: Page) {
|
||||
return page.getByRole('dialog').locator('input[maxlength="200"]')
|
||||
}
|
||||
|
||||
async function scrollPanelToTop(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('[role="dialog"] [data-slot="body"]').forEach((body) => {
|
||||
@@ -4942,6 +4982,79 @@ const sitesStates: Record<string, VisualState> = {
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'Edit: SEO defaults': {
|
||||
path: '/settings/sites',
|
||||
api: sitesApi({
|
||||
'GET /v1/site/': {
|
||||
status: 200,
|
||||
body: directorySites.map((site) =>
|
||||
site.id === 1 ? { ...site, default_description: siteSeoDescription } : site,
|
||||
),
|
||||
},
|
||||
}),
|
||||
steps: async (page) => {
|
||||
await openSiteEdit(page, 'Renew')
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'Add: defaults': {
|
||||
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(
|
||||
'https://journal.renew.example',
|
||||
)
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
'Template: custom': {
|
||||
path: '/settings/sites',
|
||||
api: sitesApi({
|
||||
'GET /v1/site/': {
|
||||
status: 200,
|
||||
body: directorySites.map((site) =>
|
||||
site.id === 2 ? { ...site, title_template: '%title% – %site%' } : site,
|
||||
),
|
||||
},
|
||||
}),
|
||||
steps: async (page) => {
|
||||
await openSiteEdit(page, 'Journal')
|
||||
await siteTemplateField(page).focus()
|
||||
await scrollPanelToTop(page)
|
||||
},
|
||||
},
|
||||
'Template: not valid': {
|
||||
path: '/settings/sites',
|
||||
api: sitesApi(),
|
||||
steps: async (page) => {
|
||||
await openSiteEdit(page, 'Journal')
|
||||
await siteTemplateField(page).fill('%site%')
|
||||
await siteTemplateField(page).blur()
|
||||
await page.getByRole('dialog').getByText('The template must contain %title%.').waitFor()
|
||||
await scrollPanelToTop(page)
|
||||
},
|
||||
},
|
||||
'Save failed': {
|
||||
path: '/settings/sites',
|
||||
api: sitesApi({
|
||||
'GET /v1/site/': {
|
||||
status: 200,
|
||||
body: directorySites.map((site) =>
|
||||
site.id === 1 ? { ...site, site_name: 'Ren' } : site,
|
||||
),
|
||||
},
|
||||
'PATCH /v1/site/1': { status: 500 },
|
||||
}),
|
||||
steps: async (page) => {
|
||||
await openSiteEdit(page, 'Renew')
|
||||
await siteFormField(page, 'e.g. Renew').fill('Renew')
|
||||
await submitDirectoryPanel(page, 'Save changes')
|
||||
await page.getByRole('dialog').getByText("Couldn't save the site").waitFor()
|
||||
},
|
||||
},
|
||||
'With articles': {
|
||||
path: '/settings/sites',
|
||||
api: sitesApi(),
|
||||
@@ -5694,8 +5807,6 @@ async function seoSaveFromPanel(page: Page) {
|
||||
.click()
|
||||
}
|
||||
|
||||
const dismissColumnReason =
|
||||
'The board draws a dismiss column and its footnote in the suggestion list; dismissal is deferred to slice 4'
|
||||
const boardOmitsMobileDialogReason =
|
||||
"The board's mobile frame omits the Create author dialog, so only the Details sheet is drawn"
|
||||
|
||||
@@ -5731,23 +5842,15 @@ const editorSeoStates: Record<string, VisualState> = {
|
||||
true,
|
||||
),
|
||||
'Open: other site': seoSidebarState({ article: { site_id: 2 }, sites: [seoJournal()] }, true),
|
||||
'Panel: defaults': withMobileException(
|
||||
seoPanelState({ check: seoCheck(['cover_none']) }),
|
||||
26.5,
|
||||
dismissColumnReason,
|
||||
),
|
||||
'Panel: defaults': seoPanelState({ check: seoCheck(['cover_none']) }),
|
||||
'Panel: filled in': seoPanelState({ article: seoFilled }),
|
||||
'Panel: outside the soft ranges': withMobileException(
|
||||
seoPanelState({
|
||||
article: {
|
||||
seo_title: seoLongTitle,
|
||||
seo_description: 'What a bathroom renovation costs.',
|
||||
},
|
||||
check: seoCheck(['title_long', 'desc_short']),
|
||||
}),
|
||||
15.5,
|
||||
dismissColumnReason,
|
||||
),
|
||||
'Panel: outside the soft ranges': seoPanelState({
|
||||
article: {
|
||||
seo_title: seoLongTitle,
|
||||
seo_description: 'What a bathroom renovation costs.',
|
||||
},
|
||||
check: seoCheck(['title_long', 'desc_short']),
|
||||
}),
|
||||
'Panel: slug edited': seoPanelState({ article: { slug: 'bathroom-costs' } }),
|
||||
'Panel: slug taken': seoPanelState(
|
||||
{
|
||||
@@ -5765,16 +5868,12 @@ const editorSeoStates: Record<string, VisualState> = {
|
||||
await page.getByText('is already used on Renew.').waitFor()
|
||||
},
|
||||
),
|
||||
'Panel: cover without alt': withMobileException(
|
||||
seoPanelState({
|
||||
article: seoCoverNoAlt,
|
||||
check: seoCheck(['cover_alt']),
|
||||
}),
|
||||
17,
|
||||
dismissColumnReason,
|
||||
),
|
||||
'Panel: choose cover': withMobileException(
|
||||
seoPanelState({ check: seoCheck(['cover_none']) }, async (page) => {
|
||||
'Panel: cover without alt': seoPanelState({
|
||||
article: seoCoverNoAlt,
|
||||
check: seoCheck(['cover_alt']),
|
||||
}),
|
||||
'Panel: choose cover': {
|
||||
...seoPanelState({ check: seoCheck(['cover_none']) }, async (page) => {
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'Choose image', exact: true })
|
||||
@@ -5784,9 +5883,12 @@ const editorSeoStates: Record<string, VisualState> = {
|
||||
await page.locator('[data-picker-tile][aria-label^="kitchen-after.webp,"]').click()
|
||||
await page.mouse.move(0, 0)
|
||||
}),
|
||||
29,
|
||||
dismissColumnReason,
|
||||
),
|
||||
exception: {
|
||||
maxPercent: { desktop: 10, mobile: 29 },
|
||||
reason: 'The shared media picker sheet is taller and shows more tiles than the board on mobile (28.29% light, 24.08% dark); the picker is not changed here',
|
||||
approvedBy: 'fsylvain',
|
||||
},
|
||||
},
|
||||
'Panel: social sharing open': seoPanelState(
|
||||
{ article: { cover_image_id: 1, cover_image_alt: seoCoverAlt } },
|
||||
(page) => seoOpenDisclosure(page, 'Social sharing'),
|
||||
@@ -5813,19 +5915,12 @@ const editorSeoStates: Record<string, VisualState> = {
|
||||
async () => {},
|
||||
700,
|
||||
),
|
||||
'Panel: mobile search preview': withMobileException(
|
||||
seoPanelState(
|
||||
{ article: { seo_title: seoLongTitle }, check: seoCheck(['title_long']) },
|
||||
async (page) => {
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('tab', { name: 'Mobile', exact: true })
|
||||
.click()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
),
|
||||
17.5,
|
||||
dismissColumnReason,
|
||||
'Panel: mobile search preview': seoPanelState(
|
||||
{ article: { seo_title: seoLongTitle }, check: seoCheck(['title_long']) },
|
||||
async (page) => {
|
||||
await page.getByRole('dialog').getByRole('tab', { name: 'Mobile', exact: true }).click()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
),
|
||||
'Panel: checking': seoPanelState({ check: { status: 200, hang: true } }),
|
||||
'Panel: saving': seoPanelState(
|
||||
@@ -6282,9 +6377,9 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
'6. Errors': integrationState({
|
||||
steps: (page) => scrollToAnchor(page, 'errors', { desktop: 60, mobile: 55 }),
|
||||
exception: {
|
||||
maxPercent: { desktop: 12.5, mobile: 19 },
|
||||
reason: 'A 409 row is new, the typical-cause text uses the real server messages in place of the longer board prose, and the board changed (17.33 % mobile already at HEAD against the updated board)',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
maxPercent: { desktop: 16, mobile: 22 },
|
||||
reason: 'The 403 note under the Errors table has no layout in the design (design gap) and pushes the following rows down; the 409 row is new and the board already differed at HEAD',
|
||||
approvedBy: 'fsylvain',
|
||||
},
|
||||
}),
|
||||
'I1 · Many variants': integrationImagesState(integrationManyVariants),
|
||||
@@ -6357,6 +6452,15 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
},
|
||||
articles: {
|
||||
...pagination,
|
||||
'Status chips': clockSensitiveArticles('/article'),
|
||||
'Needs attention on': clockSensitiveArticles('/article?seo=suggestions'),
|
||||
'Needs attention: none': {
|
||||
path: '/article?seo=suggestions',
|
||||
api: articlesApi({ 'GET /v1/article/': articleListMock([], 0) }),
|
||||
steps: async (page) => {
|
||||
await page.getByText('Nothing needs attention').waitFor()
|
||||
},
|
||||
},
|
||||
'Several sites': {
|
||||
path: '/article',
|
||||
now: articlesNow,
|
||||
@@ -6380,11 +6484,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
path: '/article',
|
||||
now: articlesNow,
|
||||
api: articlesApi(),
|
||||
exception: {
|
||||
maxPercent: 3.7,
|
||||
reason: 'UTable column boundaries land ~9-12px off the board grid (110/130/140/44px), unresolved by nth-child width overrides on table-fixed',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
},
|
||||
'Row menu open': {
|
||||
path: '/article',
|
||||
@@ -6393,11 +6492,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
steps: async (page) => {
|
||||
await openArticleRowMenu(page, 'Winter checklist for timber windows')
|
||||
},
|
||||
exception: {
|
||||
maxPercent: 7.5,
|
||||
reason: 'Same UTable column-boundary offset as Default, plus the open row menu popover rendering',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
},
|
||||
Loading: {
|
||||
path: '/article',
|
||||
@@ -6405,21 +6499,11 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
api: articlesApi({
|
||||
'GET /v1/article/': { ...articleListMock(articlesFixture()), hang: true },
|
||||
}),
|
||||
exception: {
|
||||
maxPercent: 10.6,
|
||||
reason: 'Skeleton placeholder block sizes/positions only approximate the board skeleton shape',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
},
|
||||
Empty: {
|
||||
path: '/article',
|
||||
now: articlesNow,
|
||||
api: articlesApi({ 'GET /v1/article/': articleListMock([]) }),
|
||||
exception: {
|
||||
maxPercent: 3.6,
|
||||
reason: 'Same UTable/EmptyState geometry offset as Default, carried into the empty layout',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
},
|
||||
'No results': {
|
||||
path: '/article',
|
||||
@@ -6429,11 +6513,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
await page.getByPlaceholder('Search by title').fill('tiles')
|
||||
await page.waitForTimeout(200)
|
||||
},
|
||||
exception: {
|
||||
maxPercent: 3.3,
|
||||
reason: 'Same UTable/EmptyState geometry offset as Default, carried into the no-results layout',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
},
|
||||
'No drafts': {
|
||||
path: '/article',
|
||||
@@ -6449,11 +6528,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
await page.getByRole('tab', { name: /^Drafts/ }).focus()
|
||||
await page.keyboard.press('Enter')
|
||||
},
|
||||
exception: {
|
||||
maxPercent: 2.7,
|
||||
reason: 'Same UTable/EmptyState geometry offset as Default, carried into the no-drafts layout',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
},
|
||||
'Action in progress': {
|
||||
path: '/article',
|
||||
@@ -6465,36 +6539,8 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
await openArticleRowMenu(page, 'Winter checklist for timber windows')
|
||||
await chooseArticleRowMenuItem(page, 'Publish')
|
||||
},
|
||||
exception: {
|
||||
maxPercent: 3.6,
|
||||
reason: 'Same UTable column-boundary offset as Default, plus the busy badge/spinner rendering',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
},
|
||||
Published: {
|
||||
path: '/article',
|
||||
now: articlesNow,
|
||||
api: articlesApi({
|
||||
'PATCH /v1/article/4/publish': {
|
||||
status: 200,
|
||||
body: {
|
||||
...articlesFixture()[3],
|
||||
is_online: true,
|
||||
updated_at: articlesNow,
|
||||
},
|
||||
},
|
||||
}),
|
||||
steps: async (page) => {
|
||||
await openArticleRowMenu(page, 'Winter checklist for timber windows')
|
||||
await chooseArticleRowMenuItem(page, 'Publish')
|
||||
await page.getByText('Article published').first().waitFor()
|
||||
},
|
||||
exception: {
|
||||
maxPercent: 4.6,
|
||||
reason: 'Same UTable column-boundary offset as Default, plus the success toast rendering',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
},
|
||||
Published: clockSensitiveArticles('/article'),
|
||||
'Action failed': {
|
||||
path: '/article',
|
||||
now: articlesNow,
|
||||
@@ -6504,11 +6550,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
await chooseArticleRowMenuItem(page, 'Unpublish')
|
||||
await page.getByText("Couldn't unpublish").first().waitFor()
|
||||
},
|
||||
exception: {
|
||||
maxPercent: 5.7,
|
||||
reason: 'Same UTable column-boundary offset as Default, plus the error toast with Retry rendering',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
},
|
||||
'Row vanished (404)': {
|
||||
path: '/article',
|
||||
@@ -6519,11 +6560,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
await chooseArticleRowMenuItem(page, 'Publish')
|
||||
await page.getByText('That article no longer exists').first().waitFor()
|
||||
},
|
||||
exception: {
|
||||
maxPercent: 4.3,
|
||||
reason: 'Same UTable column-boundary offset as Default, plus the info toast after row removal',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
},
|
||||
'Delete: confirm': {
|
||||
path: '/article',
|
||||
|
||||
Reference in new issue
Block a user