feat: editorial article list

This commit is contained in:
Florian Sylvain committed 2026-11-02 07:28:44 +01:00
1 parent a0b3c58c52
commit a671b81f80
103 files changed
+6493 -1805

No files matched your search

-149
View File
@@ -1,149 +0,0 @@
<script setup lang="ts">
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 { useArticleMenu } from '@/composables/useArticleMenu'
import { seoChipOf } from '@/lib/articleSeoStatus'
const props = withDefaults(
defineProps<{
articles: Article[]
loading: boolean
skeletonRows?: number
busyIds: Set<number>
now?: Date
}>(),
{ skeletonRows: 5 },
)
const emit = defineEmits<{
publish: [id: number]
unpublish: [id: number]
delete: [article: Article]
'copy-url': [id: number]
}>()
const router = useRouter()
function editPath(article: Article) {
return `/article/${article.id}/edit`
}
function isBusy(article: Article) {
return props.busyIds.has(article.id)
}
function pendingFor(article: Article) {
if (!isBusy(article)) {
return undefined
}
return article.is_online ? ('unpublish' as const) : ('publish' as const)
}
const { actionsFor, publishHint } = useArticleMenu({
isBusy,
edit: (article) => router.push(editPath(article)),
publish: (id) => emit('publish', id),
unpublish: (id) => emit('unpublish', id),
copyUrl: (id) => emit('copy-url', id),
remove: (article) => emit('delete', article),
})
function onRowClick(article: Article, event: MouseEvent) {
if ((event.target as HTMLElement).closest('[data-row-menu]')) {
return
}
router.push(editPath(article))
}
</script>
<template>
<ul
class="-mx-4 border-y border-default bg-default md:hidden"
:aria-busy="loading ? 'true' : undefined"
>
<template v-if="loading">
<li
v-for="n in skeletonRows"
:key="n"
class="flex min-h-17 flex-col justify-center gap-2 border-t border-muted py-3 pl-4 pr-1 first:border-t-0"
>
<USkeleton class="h-3.5 w-3/4 rounded-none bg-accented" />
<USkeleton class="h-3 w-2/5 rounded-none bg-accented" />
</li>
</template>
<template v-else>
<li v-if="articles.length === 0">
<slot name="empty" />
</li>
<li
v-for="article in articles"
:key="article.id"
class="relative flex min-h-17 items-center gap-2 border-t border-muted py-3 pl-4 pr-1 first:border-t-0"
:class="{ 'opacity-55': isBusy(article) }"
@click="onRowClick(article, $event)"
>
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
<RouterLink
:to="editPath(article)"
:data-row-title="article.id"
class="truncate text-[0.9375rem]/[21px] font-medium text-highlighted"
@click.stop
>
{{ article.title }}
</RouterLink>
<div class="flex items-center gap-2.5 text-xs text-muted">
<ArticleStatusBadge
:online="article.is_online"
size="sm"
:pending="pendingFor(article)"
/>
<span
v-if="publishHint(article)"
class="sr-only"
data-testid="publish-hint"
>{{ publishHint(article) }}</span
>
<SeoStatusChip
v-if="seoChipOf(article)"
:chip="seoChipOf(article)!"
size="sm"
/>
<span :title="article.updated_at"
>{{ t('Updated') }}
{{ formatRelativeDate(article.updated_at, now) }}</span
>
</div>
<div class="flex min-w-0 items-center gap-2 text-xs text-muted">
<AuthorStack
v-if="article.authors?.length"
:authors="article.authors"
:size="22"
/>
<span class="truncate">{{ authorsSummary(article.authors ?? []) }}</span>
</div>
</div>
<UDropdownMenu
:items="actionsFor(article)"
:content="{ align: 'end' }"
data-row-menu
>
<UButton
icon="i-lucide-ellipsis-vertical"
color="neutral"
variant="ghost"
:aria-label="t('Actions for {0}', [article.title])"
class="size-11 justify-center text-toned"
@click.stop
/>
</UDropdownMenu>
</li>
</template>
</ul>
</template>
+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ArticleFilterFields from '@/components/ArticleFilterFields.vue'
import { useIsDesktop } from '@/composables/useIsDesktop'
import type { ArticleFilterFieldProps } from '@/lib/articleFilterFields'
import { languageName } from '@/lib/siteLanguages'
import { locale } from '@/i18n'
const props = defineProps<{
fields: ArticleFilterFieldProps
chipCount: number
}>()
const emit = defineEmits<{
'update:site': [site: string]
'update:lang': [lang: string]
'update:status': [status: ArticleFilterFieldProps['status']]
'update:author': [author: number]
'update:seo': [on: boolean]
'open-filters': []
}>()
const search = defineModel<string>('search', { required: true })
const desktop = useIsDesktop()
const placeholder = computed(() =>
props.fields.lang
? t('Search {0} titles', [languageName(props.fields.lang, locale.value)])
: t('Search titles in every language'),
)
const filtersLabel = computed(() =>
props.chipCount ? t('Filters · {0}', [props.chipCount]) : t('Filters'),
)
const filtersAria = computed(() =>
props.chipCount ? t('Filters, {0} active', [props.chipCount]) : t('Filters'),
)
</script>
<template>
<div class="flex flex-wrap items-center gap-2">
<UInput
v-model="search"
type="search"
:placeholder="placeholder"
:aria-label="placeholder"
class="min-w-0 flex-1 md:min-w-50"
:ui="{
base: 'h-11 rounded-none text-base ring-0 border border-accented bg-default md:h-8 md:text-sm',
}"
/>
<template v-if="desktop">
<ArticleFilterFields
v-bind="fields"
@update:site="emit('update:site', $event)"
@update:lang="emit('update:lang', $event)"
@update:status="emit('update:status', $event)"
@update:author="emit('update:author', $event)"
@update:seo="emit('update:seo', $event)"
/>
</template>
<UButton
v-else
color="neutral"
variant="outline"
icon="i-lucide-sliders-horizontal"
:aria-label="filtersAria"
class="h-11 shrink-0 border border-accented bg-default px-3 text-highlighted ring-0"
@click="emit('open-filters')"
>
{{ filtersLabel }}
</UButton>
</div>
</template>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { t } from '@/i18n'
import type { FilterChip, FilterChipKey } from '@/lib/articleFilterChips'
defineProps<{ chips: FilterChip[] }>()
const emit = defineEmits<{ remove: [key: FilterChipKey]; clear: [] }>()
</script>
<template>
<div
v-if="chips.length"
data-testid="article-filter-chips"
class="flex flex-wrap items-center gap-1.5"
>
<UBadge
v-for="chip in chips"
:key="chip.key"
color="neutral"
variant="outline"
data-testid="article-filter-chip"
:ui="{
base: 'h-9 gap-1.5 rounded-none border border-accented bg-default py-0 ps-2 pe-1 text-xs font-normal text-highlighted ring-0 md:h-6',
}"
>
{{ chip.label }}
<template #trailing>
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-x"
:aria-label="t('Remove filter {0}', [chip.label])"
class="size-7 justify-center rounded-none p-0 text-muted md:size-4"
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('remove', chip.key)"
/>
</template>
</UBadge>
<UButton
color="neutral"
variant="ghost"
class="h-11 px-3 text-sm font-medium text-toned md:h-7"
@click="emit('clear')"
>
{{ t('Clear all') }}
</UButton>
</div>
</template>
+160
View File
@@ -0,0 +1,160 @@
<script setup lang="ts">
import { locale, t, formatNumber } from '@/i18n'
import { computed } from 'vue'
import ArticleFilterSelect, { type FilterItem } from '@/components/ArticleFilterSelect.vue'
import { SEO_FILTER, type ArticleStatus } from '@/lib/articleListQuery'
import type { ArticleFilterFieldProps } from '@/lib/articleFilterFields'
import { languageName, type SiteLanguage } from '@/lib/siteLanguages'
const ANY = 'any'
const SITE_PREFIX = 'site:'
const props = defineProps<
ArticleFilterFieldProps & {
labeled?: boolean
}
>()
const emit = defineEmits<{
'update:site': [site: string]
'update:lang': [lang: string]
'update:status': [status: '' | ArticleStatus]
'update:author': [author: number]
'update:seo': [on: boolean]
}>()
const siteItems = computed<FilterItem[]>(() => [
{ label: t('All sites'), value: ANY },
...props.sites.map((site) => ({ label: site.name, value: SITE_PREFIX + site.key })),
])
const languageItems = computed<FilterItem[]>(() => {
const row = (language: SiteLanguage): FilterItem => ({
label: languageName(language.lang, locale.value),
value: language.lang,
trailing:
language.version_count === 1
? t('1 version')
: t('{0} versions', [formatNumber(language.version_count)]),
})
const enabled = props.languages.filter((language) => language.enabled)
const disabled = props.languages.filter((language) => !language.enabled)
return [
{ type: 'label', label: t('Enabled') },
{ label: t('All languages'), value: ANY },
...enabled.map(row),
...(disabled.length
? [
{ type: 'separator' as const },
{ type: 'label' as const, label: t('Disabled') },
...disabled.map(row),
{
type: 'label' as const,
label: t(
'Disabled languages keep their versions. They can’t get new ones.',
),
},
]
: []),
]
})
const statusItems = computed<FilterItem[]>(() => [
{ label: t('Any status'), value: ANY },
{ label: t('Published'), value: 'online' },
{ label: t('Draft'), value: 'draft' },
])
const authorItems = computed<FilterItem[]>(() => [
{ label: t('Any author'), value: ANY },
...props.authors.map((author) => ({
label: author.name,
value: String(author.id),
trailing: formatNumber(author.article_count),
})),
])
const seoItems = computed<FilterItem[]>(() => [
{ label: t('Any SEO state'), value: ANY },
{
label: t('Needs attention'),
value: SEO_FILTER,
trailing: props.suggestions === null ? undefined : formatNumber(props.suggestions),
},
])
const siteModel = computed({
get: () => (props.site ? SITE_PREFIX + props.site : ANY),
set: (value: string) =>
emit('update:site', value === ANY ? '' : value.slice(SITE_PREFIX.length)),
})
const langModel = computed({
get: () => props.lang || ANY,
set: (value: string) => emit('update:lang', value === ANY ? '' : value),
})
const statusModel = computed({
get: () => props.status || ANY,
set: (value: string) => emit('update:status', value === ANY ? '' : (value as ArticleStatus)),
})
const authorModel = computed({
get: () => (props.author ? String(props.author) : ANY),
set: (value: string) => emit('update:author', value === ANY ? 0 : Number(value)),
})
const seoModel = computed({
get: () => props.seo || ANY,
set: (value: string) => emit('update:seo', value !== ANY),
})
const WRAPPER = computed(() =>
props.labeled ? 'flex flex-col gap-1.5' : 'w-full md:w-37.5 md:flex-none',
)
</script>
<template>
<div v-if="severalSites" :class="WRAPPER">
<span v-if="labeled" class="font-medium text-highlighted">{{ t('Site') }}</span>
<ArticleFilterSelect
v-model="siteModel"
:items="siteItems"
:label="t('Site')"
:active="site !== ''"
/>
</div>
<div :class="WRAPPER">
<span v-if="labeled" class="font-medium text-highlighted">{{ t('Language') }}</span>
<ArticleFilterSelect
v-model="langModel"
:items="languageItems"
:label="t('Language')"
:active="lang !== ''"
/>
</div>
<div :class="WRAPPER">
<span v-if="labeled" class="font-medium text-highlighted">{{ t('Status') }}</span>
<ArticleFilterSelect
v-model="statusModel"
:items="statusItems"
:label="t('Status')"
:active="status !== ''"
/>
</div>
<div :class="WRAPPER">
<span v-if="labeled" class="font-medium text-highlighted">{{ t('Author') }}</span>
<ArticleFilterSelect
v-model="authorModel"
:items="authorItems"
:label="t('Author')"
:active="author !== 0"
/>
</div>
<div :class="WRAPPER">
<span v-if="labeled" class="font-medium text-highlighted">{{ t('SEO') }}</span>
<ArticleFilterSelect
v-model="seoModel"
:items="seoItems"
:label="t('SEO')"
:active="seo !== ''"
/>
</div>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
export interface FilterOption {
label: string
value: string
trailing?: string
muted?: boolean
}
export type FilterItem = FilterOption | { type: 'label'; label: string } | { type: 'separator' }
defineProps<{
items: FilterItem[]
label: string
active: boolean
}>()
const model = defineModel<string>({ required: true })
</script>
<template>
<USelectMenu
v-model="model"
:items="items"
value-key="value"
:search-input="false"
trailing-icon="i-lucide-chevrons-up-down"
:aria-label="label"
class="w-full"
:ui="{
base: `h-11 rounded-none text-base ring-0 border border-accented bg-default md:h-8 md:text-sm ${active ? 'text-highlighted' : 'text-muted'}`,
trailingIcon: 'size-3.5 text-muted',
content: 'min-w-55 rounded-none shadow-(--shadow-popover)',
item: 'min-h-11 items-center px-3 py-0 data-[state=checked]:bg-(--rc-sand) data-[state=checked]:text-(--rc-on-sand) md:min-h-9',
label: 'px-3 py-1 text-xs font-semibold uppercase tracking-wide text-muted',
}"
>
<template #item-trailing="{ item }">
<span v-if="(item as FilterOption).trailing" class="font-mono text-xs text-muted">
{{ (item as FilterOption).trailing }}
</span>
</template>
</USelectMenu>
</template>
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed } from 'vue'
import ArticleFilterFields from '@/components/ArticleFilterFields.vue'
import { BOTTOM_SHEET_UI } from '@/lib/sheetUi'
import type { ArticleFilterFieldProps } from '@/lib/articleFilterFields'
const props = defineProps<{
fields: ArticleFilterFieldProps
total: number
chipCount: number
}>()
const emit = defineEmits<{
'update:site': [site: string]
'update:lang': [lang: string]
'update:status': [status: ArticleFilterFieldProps['status']]
'update:author': [author: number]
'update:seo': [on: boolean]
clear: []
}>()
const SHEET_CONTENT =
'max-h-11/12 pt-3 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)'
const open = defineModel<boolean>('open', { default: false })
const showLabel = computed(() =>
props.total === 1 ? t('Show 1 result') : t('Show {0} results', [formatNumber(props.total)]),
)
</script>
<template>
<USlideover
v-model:open="open"
side="bottom"
:title="t('Filters')"
:description="t('Filters')"
:ui="{ ...BOTTOM_SHEET_UI, content: SHEET_CONTENT }"
>
<template #close>
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-x"
:aria-label="t('Close filters')"
class="size-11 justify-center"
@click="open = false"
/>
</template>
<template #body>
<div data-testid="article-filters-sheet" class="flex flex-col gap-3.5">
<ArticleFilterFields
labeled
v-bind="fields"
@update:site="emit('update:site', $event)"
@update:lang="emit('update:lang', $event)"
@update:status="emit('update:status', $event)"
@update:author="emit('update:author', $event)"
@update:seo="emit('update:seo', $event)"
/>
</div>
</template>
<template #footer>
<div class="flex w-full gap-2">
<UButton
color="neutral"
variant="outline"
class="h-11 justify-center border border-accented bg-default px-3 text-highlighted ring-0"
@click="emit('clear')"
>
{{ t('Clear all') }}
</UButton>
<UButton class="h-11 flex-1 justify-center px-3" @click="open = false">
{{ showLabel }}
</UButton>
</div>
</template>
</USlideover>
</template>
+73
View File
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { locale, t } from '@/i18n'
import { computed } from 'vue'
import { languageName } from '@/lib/siteLanguages'
const QUERY_MAX_LENGTH = 40
const props = defineProps<{
variant: 'no-results' | 'nothing-missing' | 'no-matches'
query?: string
lang?: string
}>()
const emit = defineEmits<{ action: [] }>()
const language = computed(() => (props.lang ? languageName(props.lang, locale.value) : ''))
const content = computed(() => {
if (props.variant === 'nothing-missing') {
return {
icon: 'i-lucide-circle-check',
title: language.value
? t('Every article you can see has a {0} version', [language.value])
: t('Every article you can see is fully translated'),
description: language.value
? t('Articles without a {0} version will appear here.', [language.value])
: t('Articles that need a translation will appear here.'),
action: undefined,
}
}
if (props.variant === 'no-matches') {
return {
icon: 'i-lucide-search',
title: t('No articles match these filters'),
description: t('Remove a filter to see more articles.'),
action: t('Clear all'),
}
}
return {
icon: 'i-lucide-search',
title: t('No articles match "{0}"', [(props.query ?? '').slice(0, QUERY_MAX_LENGTH)]),
description: t('Search covers titles in every language you can read.'),
action: t('Clear search'),
}
})
</script>
<template>
<UEmpty
variant="naked"
:icon="content.icon"
:title="content.title"
:description="content.description"
:ui="{
root: 'gap-2 border border-default bg-default px-4 py-12',
avatar: 'mb-0 size-7 rounded-none bg-transparent p-0 text-toned',
icon: 'size-7',
title: 'text-base font-semibold text-highlighted',
description: 'max-w-90 text-base text-toned text-pretty',
}"
>
<template v-if="content.action" #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 md:h-7"
@click="emit('action')"
>
{{ content.action }}
</UButton>
</template>
</UEmpty>
</template>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { t } from '@/i18n'
const emit = defineEmits<{ retry: [] }>()
</script>
<template>
<UAlert
role="alert"
color="error"
variant="soft"
icon="i-lucide-circle-x"
:close="false"
:title="t('Couldn\'t load articles')"
:description="t('Your filters and page are kept. Try again.')"
:ui="{
root: 'items-start gap-2.5 rounded-none px-3.5 py-3',
title: 'font-semibold text-highlighted',
description: 'text-toned',
}"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 md:h-7"
@click="emit('retry')"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { t } from '@/i18n'
const OPACITIES = ['opacity-100', 'opacity-88', 'opacity-76', 'opacity-64', 'opacity-52']
</script>
<template>
<div aria-busy="true" class="flex flex-col gap-2.5" data-testid="article-list-skeleton">
<span role="status" class="sr-only">{{ t('Loading articles') }}</span>
<USkeleton
v-for="(opacity, index) in OPACITIES"
:key="index"
class="h-14 rounded-none bg-elevated md:h-11"
:class="opacity"
/>
</div>
</template>
+99
View File
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed, ref } from 'vue'
import { useScrollEdges } from '@/composables/useScrollEdges'
import type { ListTab, ListTabKey } from '@/lib/storyList'
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
const SCROLL_STEP_PX = 96
const EMPTY_COUNT = '–'
const props = defineProps<{
tabs: ListTab[]
active: ListTabKey
}>()
const emit = defineEmits<{ select: [key: ListTabKey] }>()
const scroller = ref<HTMLElement | null>(null)
const { hiddenBefore, hiddenAfter } = useScrollEdges(scroller)
const model = computed({
get: () => props.active,
set: (value: ListTabKey) => emit('select', value),
})
const items = computed(() =>
props.tabs.map((tab) => ({
label: tab.label,
value: tab.key,
count: tab.count === null ? EMPTY_COUNT : formatNumber(tab.count),
})),
)
function scroll(direction: -1 | 1) {
scroller.value?.scrollBy({ left: direction * SCROLL_STEP_PX })
}
</script>
<template>
<div class="relative flex items-center gap-1 border-b border-default">
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-chevron-left"
:aria-label="t('Scroll tabs left')"
:disabled="!hiddenBefore"
class="size-11 shrink-0 justify-center md:hidden"
@click="scroll(-1)"
/>
<div class="relative min-w-0 flex-1">
<div
ref="scroller"
class="overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
<UTabs
v-model="model"
:items="items"
variant="link"
color="neutral"
:content="false"
:aria-label="t('Article views')"
class="w-max"
:ui="{
list: 'gap-4 border-b-0 p-0 md:gap-6',
trigger: `${UNDERLINE_TAB_TRIGGER} shrink-0 whitespace-nowrap md:h-9.5 max-md:h-11 max-md:text-base`,
}"
>
<template #default="{ item }">
<span class="truncate">{{ item.label }}</span
>{{ ' '
}}<span class="font-normal text-muted">{{
(item as (typeof items)[number]).count
}}</span>
</template>
</UTabs>
</div>
<div
v-if="hiddenAfter"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 right-0 w-7 bg-linear-to-r from-transparent to-(--ui-bg) md:hidden"
/>
<div
v-if="hiddenBefore"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 left-0 w-7 bg-linear-to-l from-transparent to-(--ui-bg) md:hidden"
/>
</div>
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-chevron-right"
:aria-label="t('Scroll tabs right')"
:disabled="!hiddenAfter"
class="size-11 shrink-0 justify-center md:hidden"
@click="scroll(1)"
/>
</div>
</template>
-98
View File
@@ -1,98 +0,0 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ArticleSiteFilter from '@/components/ArticleSiteFilter.vue'
import NeedsAttentionToggle from '@/components/NeedsAttentionToggle.vue'
import type { ArticleCounts, ArticleStatus } from '@/composables/useArticles'
import type { Site } from '@/composables/useSites'
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
const props = defineProps<{
status: '' | ArticleStatus
counts: ArticleCounts
needsAttentionCount: number | null
needsAttention: boolean
site: string
sites: Site[]
severalSites: boolean
}>()
const emit = defineEmits<{
'update:status': [status: '' | ArticleStatus]
'update:needsAttention': [on: boolean]
'update:site': [site: string]
}>()
const search = defineModel<string>('search', { required: true })
const activeTab = computed<'' | ArticleStatus>({
get: () => props.status,
set: (value) => emit('update:status', value),
})
const tabItems = computed(() => [
{
get label() {
return t('All {0}', [props.counts.all])
},
value: '',
},
{
get label() {
return t('Online {0}', [props.counts.online])
},
value: 'online',
},
{
get label() {
return t('Drafts {0}', [props.counts.draft])
},
value: 'draft',
},
])
</script>
<template>
<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"
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 md:flex-row md:items-end md:gap-4 md:pb-1.5">
<UInput
v-model="search"
type="search"
icon="i-lucide-search"
:placeholder="t('Search by title')"
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="needsAttention"
:count="needsAttentionCount"
class="md:-order-1"
@update:model-value="emit('update:needsAttention', $event)"
/>
<ArticleSiteFilter
v-if="severalSites"
:model-value="site"
:sites="sites"
@update:model-value="emit('update:site', $event)"
/>
</div>
</div>
</template>
-46
View File
@@ -1,46 +0,0 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { Site } from '@/composables/useSites'
const ALL_SITES = 'all'
const SITE_PREFIX = 'site:'
const props = defineProps<{ sites: Site[] }>()
const siteKey = defineModel<string>({ default: '' })
const items = computed(() => [
{ label: t('All sites'), value: ALL_SITES },
...props.sites.map((site) => ({
label: site.name,
value: SITE_PREFIX + site.key,
})),
])
const selected = computed({
get: () => (siteKey.value ? SITE_PREFIX + siteKey.value : ALL_SITES),
set: (value: string) => {
siteKey.value = value === ALL_SITES ? '' : value.slice(SITE_PREFIX.length)
},
})
</script>
<template>
<USelectMenu
v-model="selected"
:items="items"
value-key="value"
icon="i-lucide-globe"
:search-input="false"
:aria-label="t('Site')"
class="w-full md:w-37.5"
:ui="{
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)',
item: 'min-h-11 px-2 py-0 data-[state=checked]:bg-elevated sm:min-h-8',
}"
/>
</template>
-381
View File
@@ -1,381 +0,0 @@
<script setup lang="ts">
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 { useArticleMenu } from '@/composables/useArticleMenu'
import { seoChipOf } from '@/lib/articleSeoStatus'
import { DEFAULT_ARTICLE_SORT, type ArticleSortField } from '@/lib/articleListQuery'
const props = withDefaults(
defineProps<{
articles: Article[]
loading: boolean
skeletonRows?: number
sort?: string
busyIds: Set<number>
sites?: Site[]
now?: Date
}>(),
{ skeletonRows: 6, sort: DEFAULT_ARTICLE_SORT, sites: () => [] },
)
const emit = defineEmits<{
publish: [id: number]
unpublish: [id: number]
delete: [article: Article]
'copy-url': [id: number]
sort: [field: ArticleSortField]
}>()
const router = useRouter()
const SINGLE_SITE_WIDTHS =
'[&:nth-child(2)]:w-26 [&:nth-child(3)]:w-27.5 [&:nth-child(4)]:w-32.5 [&:nth-child(5)]:w-35 [&:nth-child(6)]:w-11'
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))
const sorting = computed(() => {
const [id, direction] = props.sort.split(':')
return [{ id: id!, desc: direction === 'desc' }]
})
const tableRoot = ref<HTMLElement | null>(null)
const SORTABLE_COLUMNS = ['title', 'status', 'created_at', 'updated_at']
watchPostEffect(() => {
const [active, direction] = props.sort.split(':')
tableRoot.value?.querySelectorAll('thead th').forEach((header, index) => {
if (SORTABLE_COLUMNS.includes(active!) && columns.value[index]?.id === active) {
header.setAttribute('aria-sort', direction === 'desc' ? 'descending' : 'ascending')
} else {
header.removeAttribute('aria-sort')
}
})
})
const COLUMN_NAMES = {
title: () => t('title'),
status: () => t('status'),
created_at: () => t('created'),
updated_at: () => t('updated'),
}
const caption = computed(() => {
if (props.sort === DEFAULT_ARTICLE_SORT) {
return t('Articles, sorted by last updated, newest first')
}
const [field, direction] = props.sort.split(':') as [ArticleSortField, string]
return t('Articles, sorted by {0}, {1}', [
COLUMN_NAMES[field](),
direction === 'desc' ? t('descending') : t('ascending'),
])
})
const columns = computed(() => [
{
accessorKey: 'title',
id: 'title',
get header() {
return t('Title')
},
enableSorting: false,
},
{
id: 'authors',
get header() {
return t('Authors')
},
enableSorting: false,
},
...(showSite.value
? [
{
id: 'site',
get header() {
return t('Site')
},
enableSorting: false,
},
]
: []),
{
accessorKey: 'is_online',
id: 'status',
get header() {
return t('Status')
},
size: 110,
enableSorting: false,
},
{
accessorKey: 'created_at',
id: 'created_at',
get header() {
return t('Created')
},
size: 130,
enableSorting: false,
},
{
accessorKey: 'updated_at',
id: 'updated_at',
get header() {
return t('Updated')
},
size: 140,
enableSorting: false,
},
{ id: 'actions', header: '', size: 44 },
])
function siteNameOf(article: Article) {
return props.sites.find((site) => site.id === article.site_id)?.name ?? ''
}
function authorNames(article: Article) {
return (article.authors ?? []).map((author) => author.name).join(', ')
}
function sortIcon(field: string) {
const [id, direction] = props.sort.split(':')
if (id !== field) {
return undefined
}
return direction === 'asc' ? 'i-lucide-arrow-up' : 'i-lucide-arrow-down'
}
function editPath(article: Article) {
return `/article/${article.id}/edit`
}
function isBusy(article: Article) {
return props.busyIds.has(article.id)
}
function pendingFor(article: Article) {
if (!isBusy(article)) {
return undefined
}
return article.is_online ? ('unpublish' as const) : ('publish' as const)
}
const { actionsFor, publishHint } = useArticleMenu({
isBusy,
edit: (article) => router.push(editPath(article)),
publish: (id) => emit('publish', id),
unpublish: (id) => emit('unpublish', id),
copyUrl: (id) => emit('copy-url', id),
remove: (article) => emit('delete', article),
})
function onSelect(_event: Event, row: { original: Article }) {
if (props.loading) {
return
}
router.push(editPath(row.original))
}
</script>
<template>
<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="rows"
:columns="columns"
:ui="{
base: 'table-fixed',
tr: 'cursor-pointer',
th: `px-3 ${columnWidths}`,
td: `h-11 px-3 py-0 ${columnWidths}`,
}"
:sorting="sorting"
:sorting-options="{ manualSorting: true }"
@select="onSelect"
>
<template #caption>
<caption class="sr-only">
{{
caption
}}
</caption>
</template>
<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)"
>
{{ t('Title') }}
<UIcon
v-if="sortIcon(column.id)"
:name="sortIcon(column.id)!"
:data-sort-direction="props.sort.split(':')[1]"
class="size-3"
/>
</button>
</template>
<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)"
>
{{ t('Status') }}
<UIcon
v-if="sortIcon(column.id)"
:name="sortIcon(column.id)!"
:data-sort-direction="props.sort.split(':')[1]"
class="size-3"
/>
</button>
</template>
<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)"
>
{{ t('Created') }}
<UIcon
v-if="sortIcon(column.id)"
:name="sortIcon(column.id)!"
:data-sort-direction="props.sort.split(':')[1]"
class="size-3"
/>
</button>
</template>
<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)"
>
{{ t('Updated') }}
<UIcon
v-if="sortIcon(column.id)"
:name="sortIcon(column.id)!"
:data-sort-direction="props.sort.split(':')[1]"
class="size-3"
/>
</button>
</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"
@click.stop
>
{{ row.original.title }}
</RouterLink>
</template>
<template #authors-cell="{ row }">
<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"
/>
<span v-else :aria-label="t('No authors')" class="text-dimmed">—</span>
</span>
</template>
<template #site-cell="{ row }">
<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 }">
<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"
/>
<span
v-if="publishHint(row.original)"
class="sr-only"
data-testid="publish-hint"
>{{ publishHint(row.original) }}</span
>
<SeoStatusChip
v-if="seoChipOf(row.original)"
:chip="seoChipOf(row.original)!"
/>
</span>
</template>
<template #created_at-cell="{ row }">
<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 }">
<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
v-if="!loading"
:items="actionsFor(row.original)"
:content="{ align: 'end' }"
>
<UButton
icon="i-lucide-ellipsis"
color="neutral"
variant="ghost"
:aria-label="t('Actions for {0}', [row.original.title])"
class="size-8 justify-center text-toned"
@click.stop
/>
</UDropdownMenu>
</template>
<template #empty>
<slot name="empty" />
</template>
</UTable>
</div>
</template>
@@ -1,31 +0,0 @@
<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>
-52
View File
@@ -1,52 +0,0 @@
<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>
+120
View File
@@ -0,0 +1,120 @@
<script setup lang="ts">
import { locale, t } from '@/i18n'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import StoryCoverage from '@/components/StoryCoverage.vue'
import StoryRowMenu from '@/components/StoryRowMenu.vue'
import type { StoryItem } from '@/composables/useArticles'
import { languageName } from '@/lib/siteLanguages'
import {
can,
editPath,
opensLine,
rowAuthors,
rowUpdated,
shownInLine,
type ListMode,
} from '@/lib/storyList'
import type { StoryMenuHandlers } from '@/lib/storyMenu'
const props = defineProps<{
items: StoryItem[]
mode: ListMode
defaultLang: string
busyIds: Set<number>
handlers: StoryMenuHandlers
lang: string
now?: Date
}>()
const emit = defineEmits<{ 'add-translation': [item: StoryItem, lang: string] }>()
const router = useRouter()
const languageLabel = computed(() => languageName(props.lang, locale.value))
function onRowClick(item: StoryItem, event: MouseEvent) {
if (
(event.target as HTMLElement).closest(
'[data-row-menu],[data-testid="story-coverage"],button',
)
) {
return
}
router.push(editPath(item))
}
function byline(item: StoryItem) {
return `${rowAuthors(item, props.mode)} · ${rowUpdated(item, props.mode, props.now)}`
}
</script>
<template>
<ul class="border border-default bg-default" data-testid="story-cards">
<li
v-for="item in items"
:key="item.id"
class="flex min-h-14 flex-wrap items-center gap-x-3 gap-y-1.5 border-t border-default px-4 py-2.5 first:border-t-0"
:class="busyIds.has(item.id) ? 'opacity-55' : ''"
@click="onRowClick(item, $event)"
>
<div
class="order-0 flex min-w-0 flex-col gap-0.5"
:class="mode === 'missing' ? 'flex-1 basis-0' : 'flex-1 basis-7/10'"
>
<RouterLink
:to="editPath(item)"
:lang="item.lang || undefined"
:data-row-title="item.id"
class="truncate font-medium text-highlighted"
@click.stop
>
{{ item.title }}
</RouterLink>
<span
v-if="!item.is_online && !can(item.capabilities, 'publish')"
class="sr-only"
data-testid="publish-hint"
>{{ t('Ask an editor to publish.') }}</span
>
<span v-if="busyIds.has(item.id)" role="status" class="sr-only">{{
item.is_online ? t('Unpublishing…') : t('Publishing…')
}}</span>
<span
v-if="mode === 'story' && shownInLine(item, defaultLang)"
class="truncate text-xs text-muted"
>
{{ shownInLine(item, defaultLang) }}
</span>
<span v-if="mode === 'missing'" class="truncate text-xs text-muted">{{
opensLine(item)
}}</span>
<span v-if="mode === 'version'" class="text-xs text-muted">{{ byline(item) }}</span>
</div>
<div v-if="mode === 'version'" class="order-1 flex gap-1.5">
<ArticleStatusBadge :online="item.is_online" size="sm" />
</div>
<UButton
v-if="mode === 'missing'"
color="neutral"
variant="outline"
icon="i-lucide-plus"
:aria-label="t('Add {0} translation to {1}', [languageLabel, item.title])"
class="order-1 size-11 justify-center border border-accented bg-default text-highlighted ring-0"
@click.stop="emit('add-translation', item, lang)"
/>
<StoryRowMenu :item="item" :handlers="handlers" class="order-1" />
<StoryCoverage
v-if="mode === 'story'"
:item="item"
wrap
class="order-2 basis-full"
@add-translation="(row, l) => emit('add-translation', row, l)"
/>
<span v-if="mode === 'story'" class="order-3 flex-1 text-xs text-muted">{{
byline(item)
}}</span>
</li>
</ul>
</template>
+134
View File
@@ -0,0 +1,134 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useIsDesktop } from '@/composables/useIsDesktop'
import type { StoryItem } from '@/composables/useArticles'
import {
COVERAGE_LIMIT_DESKTOP,
COVERAGE_LIMIT_NARROW,
coverageChips,
overflowLabel,
type CoverageChip,
} from '@/lib/storyList'
const props = defineProps<{
item: StoryItem
wrap?: boolean
}>()
const emit = defineEmits<{ 'add-translation': [item: StoryItem, lang: string] }>()
const router = useRouter()
const wideScreen = useIsDesktop('80rem')
const buttonsEnabled = useIsDesktop('48rem')
const CHIP_UI = {
base: 'h-5.5 gap-1 rounded-none px-1.5 font-mono text-xs font-medium ring-0',
trailingIcon: 'size-3',
}
const chips = computed(() =>
coverageChips(props.item.coverage ?? [], props.item.missing_langs ?? []),
)
const limit = computed(() => (wideScreen.value ? COVERAGE_LIMIT_DESKTOP : COVERAGE_LIMIT_NARROW))
const shown = computed(() => chips.value.slice(0, limit.value))
const hidden = computed(() => chips.value.slice(limit.value))
function iconOf(chip: CoverageChip) {
if (chip.status === 'published') {
return 'i-lucide-circle-check'
}
return chip.status === 'draft' ? 'i-lucide-pencil' : 'i-lucide-plus'
}
function classOf(chip: CoverageChip) {
const tone =
chip.status === 'missing'
? 'border border-dashed border-accented bg-default text-muted'
: 'border border-accented bg-default text-highlighted'
return chip.disabled ? `${tone} opacity-75` : tone
}
function openVersion(chip: CoverageChip) {
if (chip.entry) {
router.push(`/article/${chip.entry.id}/edit`)
}
}
</script>
<template>
<div
data-testid="story-coverage"
class="flex gap-1.5"
:class="wrap ? 'flex-wrap' : 'flex-nowrap'"
>
<template v-for="chip in shown" :key="chip.lang">
<UBadge
v-if="chip.status === 'missing' && buttonsEnabled"
as="button"
type="button"
color="neutral"
variant="outline"
:trailing-icon="iconOf(chip)"
:aria-label="chip.label"
:class="classOf(chip)"
:ui="CHIP_UI"
@click.stop="emit('add-translation', item, chip.lang)"
>
{{ chip.code }}
</UBadge>
<UBadge
v-else
color="neutral"
variant="outline"
:trailing-icon="iconOf(chip)"
:aria-label="chip.label"
:class="classOf(chip)"
:ui="CHIP_UI"
>
{{ chip.code }}
</UBadge>
</template>
<UPopover
v-if="hidden.length"
:content="{ align: 'start' }"
:ui="{ content: 'rounded-none' }"
>
<UBadge
as="button"
type="button"
color="neutral"
variant="subtle"
:aria-label="overflowLabel(hidden)"
class="bg-elevated text-highlighted"
:ui="CHIP_UI"
@click.stop
>
+{{ hidden.length }}
</UBadge>
<template #content>
<ul class="flex flex-col py-1">
<li v-for="chip in hidden" :key="chip.lang">
<UBadge
as="button"
type="button"
color="neutral"
variant="outline"
:trailing-icon="iconOf(chip)"
:aria-label="chip.label"
:class="`m-1 ${classOf(chip)}`"
:ui="CHIP_UI"
@click.stop="
chip.entry
? openVersion(chip)
: buttonsEnabled && emit('add-translation', item, chip.lang)
"
>
{{ chip.code }}
</UBadge>
</li>
</ul>
</template>
</UPopover>
</div>
</template>
+138
View File
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import { useIsDesktop } from '@/composables/useIsDesktop'
import type { StoryItem } from '@/composables/useArticles'
import { storyMenuItems, type StoryMenuHandlers, type StoryMenuItem } from '@/lib/storyMenu'
const props = defineProps<{
item: StoryItem
handlers: StoryMenuHandlers
small?: boolean
}>()
const desktop = useIsDesktop()
const open = ref(false)
const anotherOpen = ref(false)
const items = computed(() => storyMenuItems(props.item, props.handlers))
const label = computed(() => t('Actions for {0}', [props.item.title]))
function dropdownItem(item: StoryMenuItem): Record<string, unknown> {
return {
label: item.label,
icon: item.icon,
color: item.destructive ? 'error' : undefined,
children: item.children?.map((child) => [dropdownItem(child)]).flat(),
onSelect: item.onSelect,
}
}
const dropdownItems = computed(() => {
const regular = items.value.filter((item) => !item.destructive).map(dropdownItem)
const destructive = items.value.filter((item) => item.destructive).map(dropdownItem)
return destructive.length ? [regular, destructive] : [regular]
})
function select(item: StoryMenuItem) {
if (item.children) {
anotherOpen.value = !anotherOpen.value
return
}
open.value = false
item.onSelect()
}
const TRIGGER_CLASS =
'shrink-0 justify-center text-toned data-[state=open]:outline-2 data-[state=open]:outline-offset-2 data-[state=open]:outline-primary'
</script>
<template>
<UDropdownMenu
v-if="desktop"
:items="dropdownItems"
:content="{ align: 'end' }"
:ui="{
content: 'w-60 rounded-none p-1 shadow-(--shadow-popover)',
group: 'p-0',
separator: 'my-1',
item: 'h-8 gap-2 rounded-none px-2 text-sm',
itemLeadingIcon: 'size-4',
}"
>
<UButton
icon="i-lucide-ellipsis"
color="neutral"
variant="ghost"
:aria-label="label"
data-row-menu
class="size-7"
:class="TRIGGER_CLASS"
@click.stop
/>
</UDropdownMenu>
<template v-else>
<UButton
icon="i-lucide-ellipsis"
color="neutral"
variant="ghost"
:aria-label="label"
data-row-menu
class="size-11"
:class="TRIGGER_CLASS"
@click.stop="open = true"
/>
<USlideover
v-model:open="open"
side="bottom"
:title="item.title"
:description="item.title"
:ui="{
content:
'max-h-none pt-3 pb-6 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
header: 'min-h-11 border-0 px-4 py-0 sm:px-4',
title: 'text-md font-semibold text-highlighted',
description: 'sr-only',
close: 'top-0 end-1.5 size-11 justify-center p-0',
body: 'p-0 sm:p-0',
}"
>
<template #body>
<div role="menu" :aria-label="item.title" class="flex flex-col">
<template v-for="entry in items" :key="entry.key">
<UButton
role="menuitem"
color="neutral"
variant="ghost"
block
:icon="entry.icon"
:trailing-icon="entry.children ? 'i-lucide-chevron-down' : undefined"
class="min-h-13 justify-start gap-3 px-4 text-start text-md"
:class="entry.destructive ? 'text-error' : 'text-highlighted'"
:ui="{ leadingIcon: 'size-4' }"
@click="select(entry)"
>
{{ entry.label }}
</UButton>
<template v-if="entry.children && anotherOpen">
<UButton
v-for="child in entry.children"
:key="child.key"
role="menuitem"
color="neutral"
variant="ghost"
block
class="min-h-13 justify-start gap-3 ps-11 pe-4 text-start text-md text-highlighted"
@click="select(child)"
>
{{ child.label }}
</UButton>
</template>
</template>
</div>
</template>
</USlideover>
</template>
</template>
+227
View File
@@ -0,0 +1,227 @@
<script setup lang="ts">
import { locale, t } from '@/i18n'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import StoryCoverage from '@/components/StoryCoverage.vue'
import StoryRowMenu from '@/components/StoryRowMenu.vue'
import type { StoryItem } from '@/composables/useArticles'
import { DEFAULT_ARTICLE_SORT, type ArticleSortField } from '@/lib/articleListQuery'
import {
can,
editPath,
opensLine,
rowAuthors,
rowUpdated,
shownInLine,
type ListMode,
} from '@/lib/storyList'
import type { StoryMenuHandlers } from '@/lib/storyMenu'
import { languageName } from '@/lib/siteLanguages'
const props = defineProps<{
items: StoryItem[]
mode: ListMode
sort: string
defaultLang: string
busyIds: Set<number>
handlers: StoryMenuHandlers
lang: string
now?: Date
}>()
const emit = defineEmits<{
sort: [field: ArticleSortField]
'add-translation': [item: StoryItem, lang: string]
}>()
const router = useRouter()
const showHeader = computed(() => props.mode === 'story')
function open(item: StoryItem) {
router.push(editPath(item))
}
function ariaSort(field: ArticleSortField) {
const [active, direction] = props.sort.split(':')
if (active !== field) {
return undefined
}
return direction === 'desc' ? ('descending' as const) : ('ascending' as const)
}
function sortIcon(field: ArticleSortField) {
const [active, direction] = props.sort.split(':')
if (active !== field) {
return undefined
}
return direction === 'asc' ? 'i-lucide-arrow-up' : 'i-lucide-arrow-down'
}
const COLUMN_NAMES = {
title: () => t('title'),
status: () => t('status'),
created_at: () => t('created'),
updated_at: () => t('updated'),
}
const caption = computed(() => {
if (props.sort === DEFAULT_ARTICLE_SORT) {
return t('Articles, sorted by last updated, newest first')
}
const [field, direction] = props.sort.split(':') as [ArticleSortField, string]
return t('Articles, sorted by {0}, {1}', [
COLUMN_NAMES[field](),
direction === 'desc' ? t('descending') : t('ascending'),
])
})
const languageLabel = computed(() => languageName(props.lang, locale.value))
const CELL = 'py-0 pr-0 pl-3 align-middle'
const SORT_BUTTON = 'inline-flex items-center gap-1 text-muted font-medium'
</script>
<template>
<div class="border border-default bg-default" data-testid="story-table">
<table class="w-full table-fixed border-collapse text-base">
<caption class="sr-only">
{{
caption
}}
</caption>
<colgroup>
<col />
<col v-if="mode === 'story'" class="w-110.5" />
<col v-if="mode === 'version'" class="w-36" />
<col v-if="mode !== 'missing'" class="w-30.5" />
<col v-if="mode !== 'missing'" class="w-23" />
<col v-if="mode === 'missing'" class="w-62" />
<col class="w-14" />
</colgroup>
<thead :class="showHeader ? 'bg-elevated text-muted' : 'sr-only'">
<tr class="text-left font-medium">
<th class="px-4 py-2 font-medium" :aria-sort="ariaSort('title')">
<button type="button" :class="SORT_BUTTON" @click="emit('sort', 'title')">
{{ t('Article') }}
<UIcon
v-if="sortIcon('title')"
:name="sortIcon('title')!"
:data-sort-direction="sort.split(':')[1]"
class="size-3"
/>
</button>
</th>
<th v-if="mode === 'story'" class="py-2 pr-0 pl-3 font-medium">
{{ t('Languages') }}
</th>
<th v-if="mode === 'version'" class="py-2 pr-0 pl-3 font-medium">
{{ t('Status') }}
</th>
<th v-if="mode !== 'missing'" class="py-2 pr-0 pl-3 font-medium">
{{ t('Author') }}
</th>
<th
v-if="mode !== 'missing'"
class="py-2 pr-0 pl-3 font-medium"
:aria-sort="ariaSort('updated_at')"
>
<button
type="button"
:class="SORT_BUTTON"
@click="emit('sort', 'updated_at')"
>
{{ t('Updated') }}
<UIcon
v-if="sortIcon('updated_at')"
:name="sortIcon('updated_at')!"
:data-sort-direction="sort.split(':')[1]"
class="size-3"
/>
</button>
</th>
<th v-if="mode === 'missing'" class="pl-3">
<span class="sr-only">{{ t('Add translation…') }}</span>
</th>
<th class="pl-3">
<span class="sr-only">{{ t('Actions') }}</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in items"
:key="row.id"
class="h-12 cursor-pointer border-t border-default"
:class="busyIds.has(row.id) ? 'opacity-55' : ''"
@click="open(row)"
>
<td class="px-4 py-2.5 align-middle">
<div class="flex min-w-0 flex-col gap-0.5">
<RouterLink
:to="editPath(row)"
:lang="row.lang || undefined"
:data-row-title="row.id"
class="truncate font-medium text-highlighted"
@click.stop
>
{{ row.title }}
</RouterLink>
<span
v-if="!row.is_online && !can(row.capabilities, 'publish')"
class="sr-only"
data-testid="publish-hint"
>{{ t('Ask an editor to publish.') }}</span
>
<span v-if="busyIds.has(row.id)" role="status" class="sr-only">{{
row.is_online ? t('Unpublishing…') : t('Publishing…')
}}</span>
<span
v-if="mode === 'story' && shownInLine(row, defaultLang)"
class="truncate text-xs/4.5 text-muted"
>
{{ shownInLine(row, defaultLang) }}
</span>
<span v-if="mode === 'missing'" class="truncate text-xs/4.5 text-muted">
{{ opensLine(row) }}
</span>
</div>
</td>
<td v-if="mode === 'story'" :class="CELL">
<StoryCoverage
:item="row"
@add-translation="(item, l) => emit('add-translation', item, l)"
/>
</td>
<td v-if="mode === 'version'" :class="CELL">
<ArticleStatusBadge :online="row.is_online" size="sm" />
</td>
<td v-if="mode !== 'missing'" :class="`${CELL} truncate text-muted`">
{{ rowAuthors(row, mode) }}
</td>
<td v-if="mode !== 'missing'" :class="`${CELL} whitespace-nowrap text-muted`">
{{ rowUpdated(row, mode, now) }}
</td>
<td v-if="mode === 'missing'" :class="`${CELL} text-right`">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-plus"
:aria-label="
t('Add {0} translation to {1}', [languageLabel, row.title])
"
class="h-7 border border-accented bg-default px-3 text-highlighted ring-0"
@click.stop="emit('add-translation', row, lang)"
>
{{ t('Add {0} translation', [languageLabel]) }}
</UButton>
</td>
<td :class="`${CELL} pr-4 text-right`">
<StoryRowMenu :item="row" :handlers="handlers" />
</td>
</tr>
</tbody>
</table>
</div>
</template>
@@ -69,4 +69,24 @@ describe('useArticles', () => {
expect(result).toEqual(envelope)
})
it('asks for the grouped list and sends lang, tab and author only when set', async () => {
await useArticles().listStories({ page: 1, size: 5 })
await useArticles().listStories({
page: 2,
size: 5,
lang: 'fr',
tab: 'missing',
author: 7,
})
expect(request).toHaveBeenNthCalledWith(
1,
'/v1/article/?format=html&page=1&size=5&group=story',
)
expect(request).toHaveBeenNthCalledWith(
2,
'/v1/article/?format=html&page=2&size=5&group=story&lang=fr&tab=missing&author=7',
)
})
})
@@ -0,0 +1,47 @@
import { ref, watch } from 'vue'
import { useAuthors, type Author } from '@/composables/useAuthors'
import { useSiteLanguages } from '@/composables/useSiteLanguages'
import type { SiteLanguage } from '@/lib/siteLanguages'
export function useArticleFilterOptions(siteKey: () => string) {
const { getLanguages } = useSiteLanguages()
const { listAllAuthors } = useAuthors()
const languages = ref<SiteLanguage[]>([])
const languagesKnown = ref(false)
const authors = ref<Author[]>([])
let token = 0
async function loadLanguages(key: string) {
const current = ++token
languagesKnown.value = false
if (!key) {
languages.value = []
return
}
try {
const response = await getLanguages(key)
if (current !== token) {
return
}
languages.value = response.languages
languagesKnown.value = true
} catch {
if (current === token) {
languages.value = []
}
}
}
async function loadAuthors() {
try {
authors.value = await listAllAuthors()
} catch {
authors.value = []
}
}
watch(siteKey, loadLanguages, { immediate: true })
loadAuthors()
return { languages, languagesKnown, authors }
}
+58 -13
View File
@@ -1,11 +1,6 @@
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import {
useArticles,
type Article,
type ArticleCounts,
type ArticleStatus,
} from '@/composables/useArticles'
import { useArticles, type StoryCounts, type StoryItem } from '@/composables/useArticles'
import {
usePagedQuery,
useSearchInput,
@@ -21,7 +16,9 @@ import {
toRouteQuery,
type ArticleListQuery,
type ArticleSortField,
type ArticleStatus,
} from '@/lib/articleListQuery'
import { activeTabKey, queryForTab, type ListTabKey } from '@/lib/storyList'
import { PAGE_SIZES } from '@/lib/pageQuery'
import { ARTICLES_PAGE_SIZE_KEY, readStoredPageSize, storePageSize } from '@/lib/pageSizeMemory'
@@ -37,7 +34,7 @@ interface ArticleListOptions {
export function useArticleList({ sitesSettled, usableSite }: ArticleListOptions) {
const route = useRoute()
const { listArticles } = useArticles()
const { listStories } = useArticles()
function parseWithStoredSize(query: typeof route.query): ArticleListQuery {
const parsed = parseArticleQuery(query)
@@ -48,13 +45,13 @@ export function useArticleList({ sitesSettled, usableSite }: ArticleListOptions)
return stored === null ? parsed : { ...parsed, size: stored }
}
const items = ref<Article[]>([])
const counts = ref<ArticleCounts>({ all: 0, online: 0, draft: 0 })
const items = ref<StoryItem[]>([])
const counts = ref<StoryCounts | null>(null)
const loadFailed = ref(false)
async function request(query: ArticleListQuery, context: { loaded: boolean }) {
try {
const response = await listArticles(query)
const response = await listStories(query)
return {
kind: 'loaded',
total: response.total,
@@ -97,7 +94,7 @@ export function useArticleList({ sitesSettled, usableSite }: ArticleListOptions)
(q) => load({ ...desired.value, q, page: 1 }, 'replace'),
)
const needsAttentionCount = computed(() => counts.value.suggestions ?? null)
const needsAttentionCount = computed(() => counts.value?.suggestions ?? null)
const settled = computed(() => hasLoaded.value && !loadFailed.value)
const showPaginator = computed(() => settled.value && total.value > 0)
@@ -110,14 +107,57 @@ export function useArticleList({ sitesSettled, usableSite }: ArticleListOptions)
if (status === desired.value.status) {
return
}
load({ ...desired.value, status, page: 1 }, 'push')
load({ ...desired.value, status, tab: '', page: 1 }, 'push')
}
function changeSite(site: string) {
if (site === desired.value.site) {
return
}
load({ ...desired.value, site, page: 1 }, 'push')
load({ ...desired.value, site, lang: '', page: 1 }, 'push')
}
function changeLang(lang: string) {
if (lang === desired.value.lang) {
return
}
load({ ...desired.value, lang, page: 1 }, 'push')
}
function changeTab(key: ListTabKey) {
if (key === activeTabKey(desired.value)) {
return
}
load(queryForTab(desired.value, key), 'push')
}
function changeAuthor(author: number) {
if (author === desired.value.author) {
return
}
load({ ...desired.value, author, page: 1 }, 'push')
}
function clearFilters() {
load(
{
...desired.value,
site: '',
lang: '',
tab: '',
status: '',
author: 0,
seo: '',
page: 1,
},
'push',
)
}
function dropUnknownLanguage(known: string[]) {
if (desired.value.lang && !known.includes(desired.value.lang)) {
load({ ...desired.value, lang: '', page: 1 }, 'replace')
}
}
function changeSeo(on: boolean) {
@@ -163,6 +203,11 @@ export function useArticleList({ sitesSettled, usableSite }: ArticleListOptions)
changeStatus,
changeSite,
changeSeo,
changeLang,
changeTab,
changeAuthor,
clearFilters,
dropUnknownLanguage,
changeSort,
changeSearch: search.changeSearch,
clearSearch: search.clearSearch,
-80
View File
@@ -1,80 +0,0 @@
import { t } from '@/i18n'
import { useContentAccess } from '@/composables/useContentAccess'
import type { Article } from '@/composables/useArticles'
import { ARTICLE_TYPE_KEY } from '@/lib/routeAccess'
interface ArticleMenuHandlers {
isBusy: (article: Article) => boolean
edit: (article: Article) => void
publish: (id: number) => void
unpublish: (id: number) => void
copyUrl: (id: number) => void
remove: (article: Article) => void
}
export function useArticleMenu(handlers: ArticleMenuHandlers) {
const access = useContentAccess(ARTICLE_TYPE_KEY, 'articles')
function publishHint(article: Article) {
return !article.is_online && !access.canPublish(article.created_by)
? t('Ask an editor to publish.')
: ''
}
function actionsFor(article: Article) {
const row = access.rowAccess(article.created_by)
const toggle = access.canPublish(article.created_by)
? [
{
get label() {
return article.is_online ? t('Unpublish') : t('Publish')
},
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
disabled: handlers.isBusy(article),
onSelect: () => {
if (article.is_online) {
handlers.unpublish(article.id)
} else {
handlers.publish(article.id)
}
},
},
]
: []
return [
[
{
get label() {
return t('Edit')
},
icon: 'i-lucide-pencil',
disabled: !row.canEdit,
description: row.canEdit ? undefined : row.editReason,
onSelect: () => handlers.edit(article),
},
...toggle,
{
get label() {
return t('Copy API URL')
},
icon: 'i-lucide-link',
onSelect: () => handlers.copyUrl(article.id),
},
],
[
{
get label() {
return t('Delete…')
},
icon: 'i-lucide-trash-2',
color: 'error' as const,
disabled: !row.canDelete,
description: row.canDelete ? undefined : row.deleteReason,
onSelect: () => handlers.remove(article),
},
],
]
}
return { actionsFor, publishHint, access }
}
+17 -75
View File
@@ -2,25 +2,16 @@ import { nextTick, ref, type Ref } from 'vue'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { useArticleLifecycle } from '@/composables/useArticleLifecycle'
import type { Article, ArticleCounts } from '@/composables/useArticles'
import type { Article, StoryItem } from '@/composables/useArticles'
import type { ArticleListQuery } from '@/lib/articleListQuery'
import { pageCountFor } from '@/lib/pagination'
interface ArticleRowActionsState {
items: Ref<Article[]>
total: Ref<number>
counts: Ref<ArticleCounts>
items: Ref<StoryItem[]>
shown: Ref<ArticleListQuery>
reload: (query: ArticleListQuery, mode: 'push' | 'replace') => void
}
export function useArticleRowActions({
items,
total,
counts,
shown,
reload,
}: ArticleRowActionsState) {
export function useArticleRowActions({ items, shown, reload }: ArticleRowActionsState) {
const lifecycle = useArticleLifecycle()
const toast = useLocalizedToast()
const busyIds = ref<Set<number>>(new Set())
@@ -35,48 +26,12 @@ export function useArticleRowActions({
busyIds.value = next
}
function shiftCounts(article: Article, delta: number) {
const key = article.is_online ? 'online' : 'draft'
counts.value = {
...counts.value,
all: Math.max(0, counts.value.all + delta),
[key]: Math.max(0, counts.value[key] + delta),
}
}
function detachRow(id: number) {
if (!items.value.some((article) => article.id === id)) {
return
}
items.value = items.value.filter((article) => article.id !== id)
total.value = Math.max(0, total.value - 1)
if (items.value.length === 0 && total.value > 0) {
const page = Math.min(shown.value.page, pageCountFor(total.value, shown.value.size))
reload({ ...shown.value, page }, 'replace')
}
}
function removeArticle(article: Article) {
shiftCounts(article, -1)
detachRow(article.id)
}
function replaceArticle(updated: Article, previous: Article) {
items.value = items.value.map((existing) =>
existing.id === updated.id ? updated : existing,
function refresh(removed = false) {
const emptied = removed && items.value.length === 1 && shown.value.page > 1
reload(
{ ...shown.value, page: emptied ? shown.value.page - 1 : shown.value.page },
'replace',
)
if (updated.is_online === previous.is_online) {
return
}
counts.value = {
...counts.value,
online: counts.value.online + (updated.is_online ? 1 : -1),
draft: counts.value.draft + (updated.is_online ? -1 : 1),
}
const filter = shown.value.status
if (filter && (filter === 'online') !== updated.is_online) {
detachRow(updated.id)
}
}
function notFoundToast(article: Article) {
@@ -96,34 +51,21 @@ export function useArticleRowActions({
function setOnline(article: Article, makeOnline: boolean) {
return lifecycle.changeOnline(article, makeOnline, {
pending: (busy) => setBusy(article.id, busy),
onUpdated: (updated) => replaceArticle(updated, article),
onUpdated: () => refresh(),
onGone: () => goneFromList(article),
})
}
function findItem(id: number) {
return items.value.find((existing) => existing.id === id)
function handlePublish(article: Article) {
return setOnline(article, true)
}
function handlePublish(id: number) {
const article = findItem(id)
if (article) {
setOnline(article, true)
}
function handleUnpublish(article: Article) {
return setOnline(article, false)
}
function handleUnpublish(id: number) {
const article = findItem(id)
if (article) {
setOnline(article, false)
}
}
async function handleCopyUrl(id: number) {
const article = findItem(id)
if (!article) {
return
}
async function handleCopyUrl(article: Article) {
const id = article.id
const url = `${window.location.origin}/v1/article/${id}`
try {
await navigator.clipboard.writeText(url)
@@ -153,8 +95,8 @@ export function useArticleRowActions({
}
function goneFromList(article: Article) {
removeArticle(article)
toast.add(() => notFoundToast(article))
refresh(true)
}
async function focusRowOrHeading(idsAtOpen: number[], deletedId: number) {
@@ -171,7 +113,7 @@ export function useArticleRowActions({
async function handleDelete(article: Article) {
const idsAtOpen = items.value.map((existing) => existing.id)
const confirmed = await lifecycle.confirmAndDelete(article, {
onDeleted: () => removeArticle(article),
onDeleted: () => refresh(true),
onGone: () => goneFromList(article),
})
if (confirmed) {
+80 -23
View File
@@ -2,7 +2,7 @@ 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, SEO_FILTER } from '@/lib/articleListQuery'
import type { ArticleStatus, ArticleTab, SEO_FILTER } from '@/lib/articleListQuery'
export type { Byline }
@@ -49,32 +49,89 @@ export interface ListArticlesParams {
seo?: '' | typeof SEO_FILTER
}
export type ArticleCapability = 'read' | 'edit' | 'publish' | 'delete' | 'create_translation'
export interface CoverageEntry {
id: number
lang: string
title: string
is_online: boolean
language_enabled: boolean
capabilities: ArticleCapability[]
}
export interface StoryItem extends Article {
story_id?: number
story_updated_at: string
story_authors: Byline[]
coverage: CoverageEntry[]
missing_langs: string[]
capabilities: ArticleCapability[]
}
export interface StoryCounts extends ArticleCounts {
missing: number
}
export interface StoryPage {
items: StoryItem[]
total: number
page: number
size: number
counts: StoryCounts
}
export interface ListStoriesParams extends ListArticlesParams {
lang?: string
tab?: ArticleTab
author?: number
}
function listQuery(params: ListArticlesParams) {
const query = new URLSearchParams({
format: 'html',
page: String(params.page),
size: String(params.size),
})
if (params.q) {
query.set('q', params.q)
}
if (params.status) {
query.set('status', params.status)
}
if (params.sort) {
query.set('sort', params.sort)
}
if (params.site) {
query.set('site', params.site)
}
if (params.seo) {
query.set('seo', params.seo)
}
return query
}
export function useArticles() {
const { request } = useApi()
function listArticles(params: ListArticlesParams): Promise<ArticlePage> {
const query = new URLSearchParams({
format: 'html',
page: String(params.page),
size: String(params.size),
})
if (params.q) {
query.set('q', params.q)
}
if (params.status) {
query.set('status', params.status)
}
if (params.sort) {
query.set('sort', params.sort)
}
if (params.site) {
query.set('site', params.site)
}
if (params.seo) {
query.set('seo', params.seo)
}
return request<ArticlePage>(`/v1/article/?${query.toString()}`)
return request<ArticlePage>(`/v1/article/?${listQuery(params).toString()}`)
}
return { listArticles }
function listStories(params: ListStoriesParams): Promise<StoryPage> {
const query = listQuery(params)
query.set('group', 'story')
if (params.lang) {
query.set('lang', params.lang)
}
if (params.tab) {
query.set('tab', params.tab)
}
if (params.author) {
query.set('author', String(params.author))
}
return request<StoryPage>(`/v1/article/?${query.toString()}`)
}
return { listArticles, listStories }
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { useMediaQuery } from '@vueuse/core'
export type DesktopBreakpoint = '40rem' | '48rem' | '64rem'
export type DesktopBreakpoint = '40rem' | '48rem' | '64rem' | '80rem'
export function useIsDesktop(minWidth: DesktopBreakpoint = '48rem') {
return useMediaQuery(`(min-width: ${minWidth})`)
+77
View File
@@ -0,0 +1,77 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { articleListMessages } from './articleListMessages'
import { messages } from './messages'
const SOURCES = [
'components/ArticleFilterBar.vue',
'components/ArticleFilterChips.vue',
'components/ArticleFilterFields.vue',
'components/ArticleFiltersSheet.vue',
'components/ArticleListEmpty.vue',
'components/ArticleListFailure.vue',
'components/ArticleListTabs.vue',
'components/StoryCardList.vue',
'components/StoryCoverage.vue',
'components/StoryRowMenu.vue',
'components/StoryTable.vue',
'lib/articleFilterChips.ts',
'lib/storyList.ts',
'lib/storyMenu.ts',
'views/ArticleView.vue',
]
const SAME_IN_FRENCH = new Set([
'SEO',
'Site',
'Article',
'Articles',
'Actions',
'1 version',
'{0} versions',
'1 article',
'{0} articles',
])
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
const NUMBERED = /\{\d+\}/g
function keysIn(source: string) {
return [...source.matchAll(LITERAL)].map((match) =>
(match[1] ?? match[2] ?? '').replace(/\\(['"\\])/g, '$1'),
)
}
describe('Article list messages', () => {
const keys = SOURCES.flatMap((file) =>
keysIn(readFileSync(join(import.meta.dirname, '..', file), 'utf8')).map((key) => ({
file,
key,
})),
)
it('finds the strings of the list screen', () => {
expect(keys.length).toBeGreaterThan(60)
})
it.each(keys.map(({ file, key }) => [file, key]))(
'%s: "%s" has a French entry',
(_file, key) => {
const french = (messages as Record<string, string>)[key as string]
expect(french).toMatch(/\S/)
if (!SAME_IN_FRENCH.has(key as string)) {
expect(french).not.toBe(key)
}
},
)
it.each(Object.entries(articleListMessages))(
'keeps the placeholders of "%s" in French',
(english, french) => {
expect([...french.matchAll(NUMBERED)].map((m) => m[0]).sort()).toEqual(
[...english.matchAll(NUMBERED)].map((m) => m[0]).sort(),
)
},
)
})
+80
View File
@@ -0,0 +1,80 @@
export const articleListMessages = {
'Needs translation': 'À traduire',
All: 'Tous',
Published: 'Publiés',
Drafts: 'Brouillons',
'Not in {0}': 'Pas en {0}',
'Article views': 'Vues des articles',
'Scroll tabs left': 'Faire défiler les onglets vers la gauche',
'Scroll tabs right': 'Faire défiler les onglets vers la droite',
'{0} {1} versions': '{0} versions en {1}',
'1 {0} version': '1 version en {0}',
'{0} {1} drafts': '{0} brouillons en {1}',
'1 {0} draft': '1 brouillon en {0}',
'{0} published {1} versions': '{0} versions publiées en {1}',
'1 published {0} version': '1 version publiée en {0}',
'{0} articles not in {1}': '{0} articles sans version en {1}',
'1 article not in {0}': '1 article sans version en {0}',
'{0} articles need translation': '{0} articles à traduire',
'1 article needs translation': '1 article à traduire',
'Search titles in every language': 'Rechercher dans les titres de toutes les langues',
'Search {0} titles': 'Rechercher dans les titres en {0}',
'All languages': 'Toutes les langues',
'Any status': 'Tout statut',
'Any author': 'Tout auteur',
'Any SEO state': 'Tout état SEO',
'Filters · {0}': 'Filtres · {0}',
'Filters, {0} active': 'Filtres, {0} actifs',
'Close filters': 'Fermer les filtres',
'Site: {0}': 'Site : {0}',
'Status: {0}': 'Statut : {0}',
'Author: {0}': 'Auteur : {0}',
'SEO: Needs attention': 'SEO : À examiner',
'Disabled languages keep their versions. They can’t get new ones.':
'Les langues désactivées gardent leurs versions. Elles n’en reçoivent pas de nouvelles.',
'{0} versions': '{0} versions',
'1 version': '1 version',
Authors: 'Auteurs',
SEO: 'SEO',
Actions: 'Actions',
'No articles match these filters': 'Aucun article ne correspond à ces filtres',
'Remove a filter to see more articles.': 'Retirez un filtre pour voir plus d’articles.',
'Shown in {0}': 'Affiché en {0}',
'Shown in {0} · language disabled': 'Affiché en {0} · langue désactivée',
'Opens {0} · published': 'Ouvre {0} · publié',
'Opens {0} · draft': 'Ouvre {0} · brouillon',
'Opens {0} · published · language disabled': 'Ouvre {0} · publié · langue désactivée',
'Opens {0} · draft · language disabled': 'Ouvre {0} · brouillon · langue désactivée',
'Add {0} translation': 'Ajouter une traduction en {0}',
'Add {0} translation to {1}': 'Ajouter une traduction en {0} à {1}',
'Open {0}': 'Ouvrir {0}',
'Open another language…': 'Ouvrir une autre langue…',
'Add translation…': 'Ajouter une traduction…',
'Publish {0}': 'Publier {0}',
'Unpublish {0}': 'Dépublier {0}',
'Delete {0} version': 'Supprimer la version {0}',
'Open this version': 'Ouvrir cette version',
published: 'publié',
draft: 'brouillon',
'no version': 'aucune version',
'language disabled': 'langue désactivée',
'{0}: {1}': '{0} : {1}',
'{0} more languages: {1}': '{0} autres langues : {1}',
'1 more language: {0}': '1 autre langue : {0}',
'Every article you can see has a {0} version':
'Tous les articles que vous voyez ont une version en {0}',
'Articles without a {0} version will appear here.':
'Les articles sans version en {0} apparaîtront ici.',
'Every article you can see is fully translated':
'Tous les articles que vous voyez sont entièrement traduits',
'Articles that need a translation will appear here.':
'Les articles à traduire apparaîtront ici.',
'Search covers titles in every language you can read.':
'La recherche porte sur les titres de toutes les langues que vous pouvez lire.',
"Couldn't load articles": 'Impossible de charger les articles',
'Your filters and page are kept. Try again.':
'Vos filtres et votre page sont conservés. Réessayez.',
'Showing only what you can read': 'Seul ce que vous pouvez lire s’affiche',
"Languages, counts and missing-translation results reflect the versions you have permission to see. You can't create translations on this site.":
'Les langues, les totaux et les traductions manquantes reflètent les versions que vous avez le droit de voir. Vous ne pouvez pas créer de traductions sur ce site.',
}
+2
View File
@@ -1,7 +1,9 @@
import { articleListMessages } from './articleListMessages'
import { localizationMessages } from './localizationMessages'
export const messages = {
...localizationMessages,
...articleListMessages,
'Not found': 'Introuvable',
'Edit article': 'Modifier l’article',
unselected: 'désélectionnée',
-4
View File
@@ -5,11 +5,7 @@ import { API_GROUPS, SESSION_GROUPS } from '@/data/apiCatalogue'
import { messages } from './messages'
const SOURCES = [
'components/ArticleCardList.vue',
'components/ArticleListNotice.vue',
'components/ArticleListToolbar.vue',
'components/ArticleSiteFilter.vue',
'components/ArticleTable.vue',
'components/AuthorList.vue',
'components/AuthorProfileCard.vue',
'components/AuthorSelect.vue',
-3
View File
@@ -10,20 +10,17 @@ import type { ResolvedSeo, SeoSuggestion, SuggestionCode } from '@/lib/seoTypes'
const SOURCES = [
'components/ApiPreviewDialog.vue',
'components/ArticleListNotice.vue',
'components/ArticleListToolbar.vue',
'components/EditorHeader.vue',
'components/EditorMobileBar.vue',
'components/EditorSkeleton.vue',
'components/EmptyState.vue',
'components/FallbackChip.vue',
'components/NeedsAttentionToggle.vue',
'components/SearchPreview.vue',
'components/SearchSharingSection.vue',
'components/SeoAdvancedSection.vue',
'components/SeoDisclosure.vue',
'components/SeoPanel.vue',
'components/SeoSocialSection.vue',
'components/SeoStatusChip.vue',
'components/SocialCardPreview.vue',
'components/SoftCounter.vue',
'components/SuggestionList.vue',
+15 -5
View File
@@ -13,7 +13,7 @@ import { initializeLocale, setLocale, t } from './index'
import { messages } from './messages'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
import { formatSize, pluralArticles } from '@/lib/mediaFormat'
import { articleEnvelope, articleListUrl } from '@/views/__tests__/articleEnvelope'
import { storyEnvelope, storyListUrl } from '@/views/__tests__/articleEnvelope'
import { accessFor, ownerAccess } from '@/test/access'
vi.mock('@/composables/useApi', async () => {
@@ -371,9 +371,13 @@ describe('Hardener localization outcomes', () => {
updated_at: '2025-02-14T12:00:00Z',
}
request.mockImplementation(async (url: string) =>
articleEnvelope([article], {
q: new URL(url, 'http://localhost').searchParams.get('q') ?? '',
}),
url.startsWith('/v1/article/')
? storyEnvelope([article], {
q: new URL(url, 'http://localhost').searchParams.get('q') ?? '',
})
: url.startsWith('/v1/author/')
? { items: [], total: 0, page: 1, size: 100 }
: [],
)
setLocale('fr')
await mountRoute('/article?q=absent')
@@ -438,7 +442,13 @@ describe('Hardener localization outcomes', () => {
updated_at: '2025-02-14T12:00:00Z',
}
request.mockImplementation(async (url: string) =>
url === articleListUrl() ? articleEnvelope([article]) : article,
url === storyListUrl()
? storyEnvelope([article])
: url.startsWith('/v1/site/') || url.startsWith('/v1/author/')
? url.startsWith('/v1/author/')
? { items: [], total: 0, page: 1, size: 100 }
: []
: article,
)
await mountRoute(path)
if (path === '/article') {
@@ -11,6 +11,9 @@ describe('parseArticleQuery', () => {
sort: '',
site: '',
seo: '',
lang: '',
tab: '',
author: 0,
})
})
@@ -31,6 +34,9 @@ describe('parseArticleQuery', () => {
sort: 'title:asc',
site: '',
seo: '',
lang: '',
tab: '',
author: 0,
})
})
@@ -55,6 +61,51 @@ describe('parseArticleQuery', () => {
})
})
describe('localized list parameters', () => {
it('reads lang, tab and author', () => {
expect(parseArticleQuery({ lang: 'fr', tab: 'missing', author: '12' })).toMatchObject({
lang: 'fr',
tab: 'missing',
author: 12,
})
})
it.each(['english', 'x', '', 'fr_FR', '12'])('falls back from the lang "%s"', (lang) => {
expect(parseArticleQuery({ lang }).lang).toBe('')
})
it.each(['review', 'all', 'published', ''])('falls back from the tab "%s"', (tab) => {
expect(parseArticleQuery({ tab }).tab).toBe('')
})
it.each(['abc', '0', '-1', '', '1.5'])('falls back from the author "%s"', (author) => {
expect(parseArticleQuery({ author }).author).toBe(0)
})
it('drops the status when the tab is Not in language', () => {
expect(parseArticleQuery({ tab: 'missing', status: 'draft' }).status).toBe('')
})
it('writes lang, tab and author and omits their defaults', () => {
expect(
toRouteQuery({
...parseArticleQuery({}),
lang: 'fr',
tab: 'missing',
author: 4,
}),
).toEqual({ lang: 'fr', tab: 'missing', author: '4' })
expect(toRouteQuery(parseArticleQuery({}))).toEqual({})
})
it('tells queries apart by lang, tab and author', () => {
const base = parseArticleQuery({})
expect(sameArticleQuery(base, { ...base, lang: 'fr' })).toBe(false)
expect(sameArticleQuery(base, { ...base, tab: 'missing' })).toBe(false)
expect(sameArticleQuery(base, { ...base, author: 3 })).toBe(false)
})
})
describe('sort parameter', () => {
it.each(['title:asc', 'status:desc', 'created_at:asc', 'created_at:desc'])(
'keeps %s',
+125
View File
@@ -0,0 +1,125 @@
import { afterEach, describe, expect, it } from 'vitest'
import { setLocale } from '@/i18n'
import { parseArticleQuery } from '@/lib/articleListQuery'
import {
activeTabKey,
coverageChips,
listTabs,
overflowLabel,
queryForTab,
unitLabel,
} from '@/lib/storyList'
import type { CoverageEntry } from '@/composables/useArticles'
const counts = { all: 4, online: 2, draft: 2, suggestions: 0, missing: 4 }
function query(raw: Record<string, string> = {}) {
return parseArticleQuery(raw)
}
function entry(lang: string, isOnline: boolean, enabled = true): CoverageEntry {
return {
id: 1,
lang,
title: lang,
is_online: isOnline,
language_enabled: enabled,
capabilities: ['read'],
}
}
afterEach(() => setLocale('en'))
describe('listTabs', () => {
it('offers All articles and Needs translation when no language is selected', () => {
expect(
listTabs(query(), { ...counts, all: 8, missing: 7 }).map((tab) => [
tab.label,
tab.count,
]),
).toEqual([
['All articles', 8],
['Needs translation', 7],
])
})
it('offers All, Published, Drafts and Not in French for a language', () => {
expect(
listTabs(query({ lang: 'fr' }), counts).map((tab) => [tab.label, tab.count]),
).toEqual([
['All', 4],
['Published', 2],
['Drafts', 2],
['Not in French', 4],
])
})
it('has no count before the first response', () => {
expect(listTabs(query(), null).map((tab) => tab.count)).toEqual([null, null])
})
it('names the language in French lowercase', () => {
setLocale('fr')
expect(listTabs(query({ lang: 'de' }), counts)[3]!.label).toBe('Pas en allemand')
})
})
describe('tab selection', () => {
it('reads the active tab from tab and status', () => {
expect(activeTabKey(query())).toBe('all')
expect(activeTabKey(query({ lang: 'fr', status: 'draft' }))).toBe('draft')
expect(activeTabKey(query({ lang: 'fr', tab: 'missing' }))).toBe('missing')
expect(activeTabKey(query({ status: 'draft' }))).toBe('all')
})
it('resets page and the other tab filters when a tab is chosen', () => {
const base = { ...query({ lang: 'fr', status: 'draft' }), page: 3 }
expect(queryForTab(base, 'missing')).toMatchObject({ tab: 'missing', status: '', page: 1 })
expect(queryForTab(base, 'online')).toMatchObject({ tab: '', status: 'online', page: 1 })
expect(queryForTab(base, 'all')).toMatchObject({ tab: '', status: '', page: 1 })
})
})
describe('unitLabel', () => {
it.each([
[{}, 8, '8 articles'],
[{}, 1, '1 article'],
[{ lang: 'fr' }, 4, '4 French versions'],
[{ lang: 'fr' }, 1, '1 French version'],
[{ lang: 'fr', tab: 'missing' }, 4, '4 articles not in French'],
[{ tab: 'missing' }, 7, '7 articles need translation'],
[{ tab: 'missing' }, 1, '1 article needs translation'],
[{ lang: 'fr', status: 'draft' }, 2, '2 French drafts'],
[{ lang: 'fr', status: 'online' }, 2, '2 published French versions'],
])('%j with %i rows reads "%s"', (raw, total, label) => {
expect(unitLabel(query(raw), total)).toBe(label)
})
})
describe('coverageChips', () => {
it('keeps the response order, then the dashed missing languages', () => {
const chips = coverageChips([entry('en', true), entry('es', true, false)], ['fr', 'de'])
expect(chips.map((chip) => [chip.code, chip.status, chip.disabled])).toEqual([
['EN', 'published', false],
['ES', 'published', true],
['FR', 'missing', false],
['DE', 'missing', false],
])
expect(chips.map((chip) => chip.label)).toEqual([
'English: published',
'Spanish: published, language disabled',
'French: no version',
'German: no version',
])
})
it('names the hidden languages of the overflow chip', () => {
const chips = coverageChips([], ['fr', 'de', 'ar'])
expect(overflowLabel(chips)).toBe('3 more languages: French, German, Arabic')
expect(overflowLabel(chips.slice(0, 1))).toBe('1 more language: French')
})
})
+50
View File
@@ -0,0 +1,50 @@
import { locale, t } from '@/i18n'
import type { Author } from '@/composables/useAuthors'
import type { Site } from '@/composables/useSites'
import type { ArticleListQuery } from '@/lib/articleListQuery'
import { languageName } from '@/lib/siteLanguages'
export type FilterChipKey = 'site' | 'lang' | 'status' | 'author' | 'seo'
export interface FilterChip {
key: FilterChipKey
label: string
}
export function filterChips(
query: ArticleListQuery,
sites: Site[],
authors: Author[],
): FilterChip[] {
if (!query.status && !query.author && !query.seo) {
return []
}
const chips: FilterChip[] = []
const site = sites.find((entry) => entry.key === query.site)
if (query.site) {
chips.push({ key: 'site', label: t('Site: {0}', [site?.name ?? query.site]) })
}
if (query.lang) {
chips.push({
key: 'lang',
label: t('Language: {0}', [languageName(query.lang, locale.value)]),
})
}
if (query.status) {
chips.push({
key: 'status',
label: t('Status: {0}', [query.status === 'draft' ? t('Draft') : t('Published')]),
})
}
if (query.author) {
const author = authors.find((entry) => entry.id === query.author)
chips.push({
key: 'author',
label: t('Author: {0}', [author?.name ?? String(query.author)]),
})
}
if (query.seo) {
chips.push({ key: 'seo', label: t('SEO: Needs attention') })
}
return chips
}
+17
View File
@@ -0,0 +1,17 @@
import type { Author } from '@/composables/useAuthors'
import type { Site } from '@/composables/useSites'
import type { ArticleStatus, SEO_FILTER } from '@/lib/articleListQuery'
import type { SiteLanguage } from '@/lib/siteLanguages'
export interface ArticleFilterFieldProps {
site: string
sites: Site[]
severalSites: boolean
lang: string
languages: SiteLanguage[]
status: '' | ArticleStatus
author: number
authors: Author[]
seo: '' | typeof SEO_FILTER
suggestions: number | null
}
+24 -1
View File
@@ -9,6 +9,9 @@ import {
} from '@/lib/pageQuery'
export type ArticleStatus = 'online' | 'draft'
export type ArticleTab = '' | 'missing'
const LANGUAGE_TAG = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/
export const DEFAULT_ARTICLE_SORT = 'updated_at:desc'
export const ARTICLE_SORT_FIELDS = ['title', 'status', 'created_at', 'updated_at'] as const
@@ -24,6 +27,9 @@ export interface ArticleListQuery {
sort: string
site: string
seo: '' | typeof SEO_FILTER
lang: string
tab: ArticleTab
author: number
}
const SORT_PATTERN = new RegExp(`^(${ARTICLE_SORT_FIELDS.join('|')}):(asc|desc)$`)
@@ -44,15 +50,20 @@ export function nextSort(current: string, field: ArticleSortField) {
export function parseArticleQuery(query: LocationQuery): ArticleListQuery {
const page = firstQueryValue(query.page)
const size = Number(firstQueryValue(query.size))
const tab = firstQueryValue(query.tab) === 'missing' ? 'missing' : ''
const status = firstQueryValue(query.status)
const lang = firstQueryValue(query.lang)
return {
page: parsePositiveInteger(page) ?? 1,
size: PAGE_SIZES.includes(size) ? size : DEFAULT_PAGE_SIZE,
q: firstQueryValue(query.q).slice(0, SEARCH_MAX_LENGTH),
status: status === 'online' || status === 'draft' ? status : '',
status: tab === '' && (status === 'online' || status === 'draft') ? status : '',
sort: validSort(firstQueryValue(query.sort)),
site: firstQueryValue(query.site).slice(0, SITE_KEY_MAX),
seo: firstQueryValue(query.seo) === SEO_FILTER ? SEO_FILTER : '',
lang: LANGUAGE_TAG.test(lang) ? lang : '',
tab,
author: parsePositiveInteger(firstQueryValue(query.author)) ?? 0,
}
}
@@ -79,6 +90,15 @@ export function toRouteQuery(query: ArticleListQuery) {
if (query.seo) {
route.seo = query.seo
}
if (query.lang) {
route.lang = query.lang
}
if (query.tab) {
route.tab = query.tab
}
if (query.author) {
route.author = String(query.author)
}
return route
}
@@ -90,6 +110,9 @@ export function sameArticleQuery(left: ArticleListQuery, right: ArticleListQuery
left.status === right.status &&
left.site === right.site &&
left.seo === right.seo &&
left.lang === right.lang &&
left.tab === right.tab &&
left.author === right.author &&
effectiveSort(left.sort) === effectiveSort(right.sort)
)
}
-17
View File
@@ -1,17 +0,0 @@
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
}
+196
View File
@@ -0,0 +1,196 @@
import { locale, t } from '@/i18n'
import type {
ArticleCapability,
CoverageEntry,
StoryCounts,
StoryItem,
} from '@/composables/useArticles'
import type { ArticleListQuery, ArticleStatus } from '@/lib/articleListQuery'
import { authorsSummary } from '@/lib/articleMeta'
import { formatAbsoluteDate, formatRelativeDate } from '@/lib/relativeDate'
import { languageName } from '@/lib/siteLanguages'
export type ListTabKey = 'all' | 'online' | 'draft' | 'missing'
export interface ListTab {
key: ListTabKey
label: string
count: number | null
}
export interface CoverageChip {
lang: string
code: string
status: 'published' | 'draft' | 'missing'
disabled: boolean
label: string
entry: CoverageEntry | null
}
export const COVERAGE_LIMIT_DESKTOP = 5
export const COVERAGE_LIMIT_NARROW = 4
export function nameInSentence(tag: string) {
const name = languageName(tag, locale.value)
return locale.value === 'fr' ? name.toLocaleLowerCase('fr') : name
}
export function activeTabKey(query: ArticleListQuery): ListTabKey {
if (query.tab === 'missing') {
return 'missing'
}
if (query.lang && query.status) {
return query.status
}
return 'all'
}
export function listTabs(query: ArticleListQuery, counts: StoryCounts | null): ListTab[] {
const count = (key: keyof StoryCounts) => (counts ? (counts[key] ?? 0) : null)
if (!query.lang) {
return [
{ key: 'all', label: t('All articles'), count: count('all') },
{ key: 'missing', label: t('Needs translation'), count: count('missing') },
]
}
return [
{ key: 'all', label: t('All'), count: count('all') },
{ key: 'online', label: t('Published'), count: count('online') },
{ key: 'draft', label: t('Drafts'), count: count('draft') },
{
key: 'missing',
label: t('Not in {0}', [nameInSentence(query.lang)]),
count: count('missing'),
},
]
}
export function queryForTab(query: ArticleListQuery, key: ListTabKey): ArticleListQuery {
const status: '' | ArticleStatus = key === 'online' || key === 'draft' ? key : ''
return { ...query, tab: key === 'missing' ? 'missing' : '', status, page: 1 }
}
export function unitLabel(query: ArticleListQuery, total: number) {
const plural = total !== 1
if (!query.lang) {
if (query.tab === 'missing') {
return plural
? t('{0} articles need translation', [total])
: t('1 article needs translation')
}
return plural ? t('{0} articles', [total]) : t('1 article')
}
const name = nameInSentence(query.lang)
if (query.tab === 'missing') {
return plural
? t('{0} articles not in {1}', [total, name])
: t('1 article not in {0}', [name])
}
if (query.status === 'draft') {
return plural ? t('{0} {1} drafts', [total, name]) : t('1 {0} draft', [name])
}
if (query.status === 'online') {
return plural
? t('{0} published {1} versions', [total, name])
: t('1 published {0} version', [name])
}
return plural ? t('{0} {1} versions', [total, name]) : t('1 {0} version', [name])
}
function chipLabel(chip: Omit<CoverageChip, 'label'>) {
const status =
chip.status === 'published'
? t('published')
: chip.status === 'draft'
? t('draft')
: t('no version')
const suffix = chip.disabled ? `, ${t('language disabled')}` : ''
return `${t('{0}: {1}', [languageName(chip.lang, locale.value), status])}${suffix}`
}
export function coverageChips(coverage: CoverageEntry[], missing: string[]): CoverageChip[] {
const existing = coverage.map((entry) => {
const chip = {
lang: entry.lang,
code: entry.lang.toUpperCase(),
status: entry.is_online ? ('published' as const) : ('draft' as const),
disabled: !entry.language_enabled,
entry,
}
return { ...chip, label: chipLabel(chip) }
})
const absent = missing.map((lang) => {
const chip = {
lang,
code: lang.toUpperCase(),
status: 'missing' as const,
disabled: false,
entry: null,
}
return { ...chip, label: chipLabel(chip) }
})
return [...existing, ...absent]
}
export function overflowLabel(hidden: CoverageChip[]) {
const names = hidden.map((chip) => languageName(chip.lang, locale.value)).join(', ')
return hidden.length === 1
? t('1 more language: {0}', [names])
: t('{0} more languages: {1}', [hidden.length, names])
}
export function can(capabilities: ArticleCapability[] | undefined, capability: ArticleCapability) {
return (capabilities ?? []).includes(capability)
}
export type ListMode = 'story' | 'version' | 'missing'
export function listMode(query: ArticleListQuery): ListMode {
if (query.lang) {
return query.tab === 'missing' ? 'missing' : 'version'
}
return 'story'
}
export function shownInLine(item: StoryItem, defaultLang: string) {
if (!item.lang || !defaultLang || item.lang === defaultLang) {
return ''
}
const name = languageName(item.lang, locale.value)
const disabled =
item.coverage?.find((entry) => entry.id === item.id)?.language_enabled === false
return disabled ? t('Shown in {0} · language disabled', [name]) : t('Shown in {0}', [name])
}
export function opensLine(item: StoryItem) {
const name = languageName(item.lang ?? '', locale.value)
const disabled =
item.coverage?.find((entry) => entry.id === item.id)?.language_enabled === false
if (disabled) {
return item.is_online
? t('Opens {0} · published · language disabled', [name])
: t('Opens {0} · draft · language disabled', [name])
}
return item.is_online ? t('Opens {0} · published', [name]) : t('Opens {0} · draft', [name])
}
export function storyUpdated(iso: string, now: Date = new Date()) {
const text = formatRelativeDate(iso, now)
const date = new Date(iso)
if (text !== formatAbsoluteDate(date) || date.getFullYear() !== now.getFullYear()) {
return text
}
return formatAbsoluteDate(date).replace(/\s*\d{4}$/, '')
}
export function editPath(item: StoryItem) {
return `/article/${item.id}/edit`
}
export function rowAuthors(item: StoryItem, mode: ListMode) {
return authorsSummary((mode === 'story' ? item.story_authors : item.authors) ?? [])
}
export function rowUpdated(item: StoryItem, mode: ListMode, now?: Date) {
return storyUpdated(mode === 'story' ? item.story_updated_at : item.updated_at, now)
}
+94
View File
@@ -0,0 +1,94 @@
import { locale, t } from '@/i18n'
import type { StoryItem } from '@/composables/useArticles'
import { can } from '@/lib/storyList'
import { languageName } from '@/lib/siteLanguages'
export interface StoryMenuItem {
key: string
label: string
icon: string
destructive?: boolean
children?: StoryMenuItem[]
onSelect: () => void
}
export interface StoryMenuHandlers {
open: (id: number) => void
addTranslation: (item: StoryItem) => void
publish: (item: StoryItem) => void
unpublish: (item: StoryItem) => void
copyUrl: (item: StoryItem) => void
remove: (item: StoryItem) => void
}
export function versionName(item: Pick<StoryItem, 'lang'>) {
return item.lang ? languageName(item.lang, locale.value) : ''
}
export function storyMenuItems(item: StoryItem, handlers: StoryMenuHandlers): StoryMenuItem[] {
const name = versionName(item)
const others = (item.coverage ?? []).filter((entry) => entry.id !== item.id)
const items: StoryMenuItem[] = [
{
key: 'open',
label: name ? t('Open {0}', [name]) : t('Edit'),
icon: 'i-lucide-file-pen',
onSelect: () => handlers.open(item.id),
},
]
if ((item.coverage ?? []).length > 1) {
items.push({
key: 'open-another',
label: t('Open another language…'),
icon: 'i-lucide-languages',
onSelect: () => undefined,
children: others.map((entry) => ({
key: `open-${entry.id}`,
label: languageName(entry.lang, locale.value),
icon: 'i-lucide-file-pen',
onSelect: () => handlers.open(entry.id),
})),
})
}
if (can(item.capabilities, 'create_translation') && (item.missing_langs ?? []).length > 0) {
items.push({
key: 'add-translation',
label: t('Add translation…'),
icon: 'i-lucide-plus',
onSelect: () => handlers.addTranslation(item),
})
}
if (can(item.capabilities, 'publish')) {
items.push(
item.is_online
? {
key: 'unpublish',
label: name ? t('Unpublish {0}', [name]) : t('Unpublish'),
icon: 'i-lucide-eye-off',
onSelect: () => handlers.unpublish(item),
}
: {
key: 'publish',
label: name ? t('Publish {0}', [name]) : t('Publish'),
icon: 'i-lucide-eye',
onSelect: () => handlers.publish(item),
},
)
}
items.push({
key: 'copy-url',
label: t('Copy API URL'),
icon: 'i-lucide-copy',
onSelect: () => handlers.copyUrl(item),
})
if (can(item.capabilities, 'delete')) {
items.push({
key: 'delete',
label: name ? t('Delete {0} version', [name]) : t('Delete…'),
icon: 'i-lucide-trash-2',
destructive: true,
onSelect: () => handlers.remove(item),
})
}
return items
}
+492
View File
@@ -0,0 +1,492 @@
type ArticleCapability = 'read' | 'edit' | 'publish' | 'delete' | 'create_translation'
interface Byline {
id: number
name: string
slug: string
}
interface CoverageEntry {
id: number
lang: string
title: string
is_online: boolean
language_enabled: boolean
capabilities: ArticleCapability[]
}
interface StoryItem {
id: number
title: string
body: string
is_online: boolean
lang: string
site_id: number
story_id: number
created_by: number
authors: Byline[]
created_at: string
updated_at: string
story_updated_at: string
story_authors: Byline[]
coverage: CoverageEntry[]
missing_langs: string[]
capabilities: ArticleCapability[]
}
interface StoryCounts {
all: number
online: number
draft: number
suggestions: number
missing: number
}
interface StoryPage {
items: StoryItem[]
total: number
page: number
size: number
counts: StoryCounts
}
type VersionState = [status: 'pub' | 'draft', needsReview: 0 | 1]
interface FixtureStory {
n: number
title: string
versions: Record<string, VersionState>
titles: Record<string, string>
by: string
updatedAt: string
seo?: boolean
}
export interface StorySite {
key: string
id: number
langs: string[]
disabled: string[]
def: string
stories: FixtureStory[]
}
export interface StoryViewer {
read?: string[]
create?: boolean
scope?: (story: FixtureStory) => boolean
}
export interface StoryRequest {
site: string
lang: string
tab: string
status: string
author: string
seo: string
q: string
page: number
size: number
}
export const BYLINES: Record<string, { id: number; name: string; slug: string }> = {
'Marta Alves': { id: 1, name: 'Marta Alves', slug: 'marta-alves' },
'Camille Roche': { id: 2, name: 'Camille Roche', slug: 'camille-roche' },
'Luis Ferrer': { id: 3, name: 'Luis Ferrer', slug: 'luis-ferrer' },
}
function story(
n: number,
title: string,
versions: Record<string, VersionState>,
titles: Record<string, string>,
by: string,
updatedAt: string,
seo = false,
): FixtureStory {
return { n, title, versions, titles, by, updatedAt, seo }
}
const JOURNAL_STORIES = [
story(
1,
'How we price a bathroom renovation',
{ en: ['pub', 0], fr: ['draft', 1], de: ['pub', 0], ar: ['draft', 1] },
{
fr: 'Comment nous chiffrons une rénovation de salle de bain',
de: 'So kalkulieren wir eine Badsanierung',
ar: 'كيف نسعّر تجديد الحمّام',
},
'Marta Alves',
'2026-10-30T10:00:00Z',
),
story(
2,
'A guide to lime plaster',
{ en: ['pub', 0], fr: ['pub', 0], de: ['pub', 0] },
{ fr: "Guide de l'enduit à la chaux", de: 'Ein Leitfaden zu Kalkputz' },
'Camille Roche',
'2026-10-29T09:00:00Z',
),
story(
3,
'Winter checklist for timber windows',
{ en: ['pub', 0], fr: ['pub', 1] },
{ fr: "Liste d'hiver pour les fenêtres en bois" },
'Luis Ferrer',
'2026-10-28T09:00:00Z',
),
story(
4,
'Spring newsletter',
{ en: ['draft', 0] },
{},
'Marta Alves',
'2026-10-27T09:00:00Z',
true,
),
story(
5,
'Roof inspection checklist',
{ en: ['pub', 0], de: ['draft', 0] },
{ de: 'Checkliste für die Dachprüfung' },
'Luis Ferrer',
'2026-10-14T09:00:00Z',
true,
),
story(
6,
'Why we reuse original floorboards',
{ en: ['pub', 0], fr: ['draft', 0] },
{ fr: "Pourquoi nous réutilisons les lames de parquet d'origine" },
'Camille Roche',
'2026-10-13T09:00:00Z',
),
story(
7,
'Our first passive-house retrofit',
{ en: ['pub', 0], de: ['pub', 0] },
{ de: 'Unsere erste Passivhaus-Sanierung' },
'Marta Alves',
'2026-10-12T09:00:00Z',
),
story(
8,
'Cómo elegir un contratista',
{ es: ['pub', 0] },
{},
'Luis Ferrer',
'2026-10-10T09:00:00Z',
),
]
const PRESS_STORIES = [
story(
11,
'Opening hours over the holidays',
{ en: ['pub', 0], fr: ['pub', 0] },
{ fr: 'Horaires pendant les fêtes' },
'Marta Alves',
'2026-10-31T09:00:00Z',
),
story(
12,
'Meet the new site manager',
{ en: ['pub', 0], fr: ['draft', 1] },
{ fr: 'Rencontrez la nouvelle cheffe de chantier' },
'Luis Ferrer',
'2026-10-29T09:00:00Z',
),
story(
13,
'Annual report',
{ en: ['draft', 0], fr: ['pub', 0] },
{ fr: 'Rapport annuel' },
'Marta Alves',
'2026-10-14T09:00:00Z',
),
]
const ATELIER_STORIES = [
story(
21,
'A complete guide to renovating a ground-floor kitchen and bathroom in a listed building, with every cost itemised for owners and tenants',
{
en: ['pub', 0],
'en-GB': ['draft', 0],
fr: ['draft', 1],
de: ['pub', 0],
'pt-BR': ['draft', 0],
'zh-Hans': ['pub', 0],
ja: ['pub', 0],
ar: ['draft', 0],
},
{},
'Marta Alves',
'2026-10-30T10:00:00Z',
true,
),
story(
22,
'Heat pumps for stone farmhouses',
{ en: ['pub', 0], 'en-GB': ['pub', 0], fr: ['draft', 0], de: ['pub', 0], ja: ['draft', 0] },
{},
'Luis Ferrer',
'2026-10-29T09:00:00Z',
),
story(
23,
'Sourcing reclaimed oak beams',
{ en: ['draft', 0], 'en-GB': ['draft', 0] },
{},
'Camille Roche',
'2026-10-14T09:00:00Z',
true,
),
]
export const STORY_SITES: Record<string, StorySite> = {
journal: {
key: 'journal',
id: 1,
langs: ['en', 'fr', 'de', 'ar'],
disabled: ['es', 'it'],
def: 'en',
stories: JOURNAL_STORIES,
},
press: {
key: 'press',
id: 6,
langs: ['en', 'fr'],
disabled: [],
def: 'en',
stories: PRESS_STORIES,
},
atelier: {
key: 'atelier',
id: 2,
langs: ['en', 'en-GB', 'fr', 'de', 'pt-BR', 'zh-Hans', 'ja', 'ar'],
disabled: [],
def: 'en',
stories: ATELIER_STORIES,
},
}
export const LIMITED_VIEWER: StoryViewer = {
read: ['en'],
create: false,
scope: (entry) => ['Camille Roche', 'Luis Ferrer'].includes(entry.by),
}
const FULL: ArticleCapability[] = ['read', 'edit', 'publish', 'delete', 'create_translation']
const READ_ONLY: ArticleCapability[] = ['read']
export function requestOf(url: URL): StoryRequest {
const params = url.searchParams
return {
site: params.get('site') ?? 'journal',
lang: params.get('lang') ?? '',
tab: params.get('tab') ?? '',
status: params.get('status') ?? '',
author: params.get('author') ?? '',
seo: params.get('seo') ?? '',
q: (params.get('q') ?? '').toLowerCase(),
page: Number(params.get('page') ?? 1),
size: Number(params.get('size') ?? 25),
}
}
function titleIn(entry: FixtureStory, lang: string) {
return entry.titles[lang] ?? entry.title
}
function idOf(entry: FixtureStory, lang: string) {
return entry.n * 100 + Object.keys(entry.versions).indexOf(lang) + 1
}
function representative(entry: FixtureStory, def: string) {
return entry.versions[def] ? def : Object.keys(entry.versions).sort()[0]!
}
function authorId(name: string) {
return BYLINES[name]?.id ?? 0
}
function visibleVersions(entry: FixtureStory, viewer: StoryViewer) {
return Object.fromEntries(
Object.entries(entry.versions).filter(
([lang]) => !viewer.read || viewer.read.includes(lang),
),
)
}
function coverageOf(entry: FixtureStory, site: StorySite, viewer: StoryViewer): CoverageEntry[] {
const own = visibleVersions(entry, viewer)
const ordered = [site.def, ...site.langs.filter((lang) => lang !== site.def)]
const langs = [
...ordered.filter((lang) => own[lang]),
...Object.keys(own).filter((lang) => !site.langs.includes(lang)),
]
return langs.map((lang) => ({
id: idOf(entry, lang),
lang,
title: titleIn(entry, lang),
is_online: own[lang]![0] === 'pub',
language_enabled: site.langs.includes(lang),
capabilities: viewer.create === false ? READ_ONLY : FULL,
}))
}
function missingOf(entry: FixtureStory, site: StorySite, viewer: StoryViewer) {
if (viewer.create === false) {
return []
}
const own = visibleVersions(entry, viewer)
return site.langs.filter((lang) => !own[lang])
}
function itemOf(
entry: FixtureStory,
lang: string,
site: StorySite,
viewer: StoryViewer,
): StoryItem {
const own = visibleVersions(entry, viewer)
const byline = BYLINES[entry.by]!
return {
id: idOf(entry, lang),
title: titleIn(entry, lang),
body: '',
is_online: own[lang]![0] === 'pub',
lang,
site_id: site.id,
story_id: entry.n,
created_by: authorId(entry.by),
authors: [byline],
created_at: entry.updatedAt,
updated_at: entry.updatedAt,
story_updated_at: entry.updatedAt,
story_authors: [byline],
coverage: coverageOf(entry, site, viewer),
missing_langs: missingOf(entry, site, viewer),
capabilities: viewer.create === false ? READ_ONLY : FULL,
}
}
export function storyPage(request: StoryRequest, viewer: StoryViewer = {}): StoryPage {
const site = STORY_SITES[request.site] ?? STORY_SITES.journal!
const author = request.author ? Number(request.author) : 0
const status = request.status === 'online' ? 'pub' : request.status
const readable = site.stories
.map((entry) => ({ entry, own: visibleVersions(entry, viewer) }))
.filter(
({ entry, own }) =>
Object.keys(own).length > 0 && (!viewer.scope || viewer.scope(entry)),
)
const passes = (
entry: FixtureStory,
versions: VersionState[],
langs: string[],
useStatus: boolean,
) =>
(!useStatus || !status || versions.some((version) => version[0] === status)) &&
(!author || authorId(entry.by) === author) &&
(!request.seo || entry.seo) &&
(!request.q || langs.some((lang) => titleIn(entry, lang).toLowerCase().includes(request.q)))
const anyVersion = (
entry: FixtureStory,
own: Record<string, VersionState>,
useStatus: boolean,
) => passes(entry, Object.values(own), Object.keys(own), useStatus)
const create = viewer.create !== false
const rows = (useStatus: boolean) => {
if (request.lang) {
const lang = request.lang
const inLang = readable.filter(
({ entry, own }) => own[lang] && passes(entry, [own[lang]!], [lang], useStatus),
)
const missing = readable.filter(
({ entry, own }) => !own[lang] && anyVersion(entry, own, useStatus),
)
return { inLang, missing }
}
const base = readable.filter(({ entry, own }) => anyVersion(entry, own, useStatus))
const missing = base.filter(({ own }) => create && site.langs.some((lang) => !own[lang]))
return { inLang: base, missing }
}
const listed = rows(true)
const unfiltered = rows(false)
const chosen = request.tab === 'missing' ? listed.missing : listed.inLang
const start = (request.page - 1) * request.size
const items = chosen.slice(start, start + request.size).map(({ entry, own }) => {
const lang =
request.lang && request.tab !== 'missing'
? request.lang
: representative(entry, site.def)
return itemOf(entry, own[lang] ? lang : representative(entry, site.def), site, viewer)
})
const stateOf = (entry: FixtureStory, own: Record<string, VersionState>) =>
request.lang ? [own[request.lang]!] : Object.values(own)
const counts: StoryCounts = {
all: unfiltered.inLang.length,
online: unfiltered.inLang.filter(({ entry, own }) =>
stateOf(entry, own).some((version) => version[0] === 'pub'),
).length,
draft: unfiltered.inLang.filter(({ entry, own }) =>
stateOf(entry, own).some((version) => version[0] === 'draft'),
).length,
suggestions: site.stories.filter((entry) => entry.seo).length,
missing: unfiltered.missing.length,
}
return { items, total: chosen.length, page: request.page, size: request.size, counts }
}
export function storyPageFor(url: URL, viewer: StoryViewer = {}) {
return storyPage(requestOf(url), viewer)
}
export function siteLanguagesBody(siteKey: string) {
const site = STORY_SITES[siteKey] ?? STORY_SITES.journal!
const count = (lang: string) =>
site.stories.filter((entry) => entry.versions[lang] !== undefined).length
const published = (lang: string) =>
site.stories.filter((entry) => entry.versions[lang]?.[0] === 'pub').length
const row = (lang: string, enabled: boolean) => ({
lang,
enabled,
is_default: lang === site.def,
version_count: count(lang),
published_count: published(lang),
social_locale: null,
social_locale_state: 'not_set',
})
return {
revision: `${site.key}-1`,
default: site.def,
needs_setup: false,
languages: [
...site.langs.map((lang) => row(lang, true)),
...site.disabled.map((lang) => row(lang, false)),
],
available: [],
}
}
export function storyAuthorsBody() {
const counts = [3, 2, 3]
return {
items: Object.values(BYLINES).map((byline, index) => ({
...byline,
bio: '',
avatar_id: null,
website: null,
social_links: [],
user_id: null,
is_linked: false,
article_count: counts[index],
})),
total: 3,
page: 1,
size: 100,
}
}
+192 -72
View File
@@ -1,39 +1,52 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import DashboardShell from '@/components/DashboardShell.vue'
import ArticleTable from '@/components/ArticleTable.vue'
import ArticleCardList from '@/components/ArticleCardList.vue'
import StoryTable from '@/components/StoryTable.vue'
import StoryCardList from '@/components/StoryCardList.vue'
import ArticleFilterBar from '@/components/ArticleFilterBar.vue'
import ArticleFilterChips from '@/components/ArticleFilterChips.vue'
import ArticleFiltersSheet from '@/components/ArticleFiltersSheet.vue'
import ArticleListEmpty from '@/components/ArticleListEmpty.vue'
import ArticleListFailure from '@/components/ArticleListFailure.vue'
import ArticleListSkeleton from '@/components/ArticleListSkeleton.vue'
import ArticleListNotice from '@/components/ArticleListNotice.vue'
import ArticleListToolbar from '@/components/ArticleListToolbar.vue'
import ArticleListTabs from '@/components/ArticleListTabs.vue'
import EmptyState from '@/components/EmptyState.vue'
import OwnScopeEmpty from '@/components/OwnScopeEmpty.vue'
import ReadOnlyBanner from '@/components/ReadOnlyBanner.vue'
import NewArticleModal from '@/components/NewArticleModal.vue'
import AppPaginator from '@/components/AppPaginator.vue'
import { useArticleFilterOptions } from '@/composables/useArticleFilterOptions'
import { useArticleList } from '@/composables/useArticleList'
import { useArticleRowActions } from '@/composables/useArticleRowActions'
import { useArticleSites } from '@/composables/useArticleSites'
import { useContentAccess } from '@/composables/useContentAccess'
import { useIsDesktop } from '@/composables/useIsDesktop'
import type { Article } from '@/composables/useArticles'
import { effectiveSort } from '@/lib/articleListQuery'
import { filterChips, type FilterChipKey } from '@/lib/articleFilterChips'
import type { ArticleFilterFieldProps } from '@/lib/articleFilterFields'
import { ARTICLE_TYPE_KEY } from '@/lib/routeAccess'
const DEFAULT_SKELETON_ROWS = 6
import { activeTabKey, listMode, listTabs, unitLabel } from '@/lib/storyList'
import type { StoryMenuHandlers } from '@/lib/storyMenu'
const router = useRouter()
const desktop = useIsDesktop()
const access = useContentAccess(ARTICLE_TYPE_KEY, 'articles')
const writerLine = computed(() =>
access.readsOwnOnly.value
? t('Online articles and the drafts you created.')
: access.canCreate.value &&
access.updateScope.value === 'all' &&
access.publishScope.value === 'none'
? t('You can write and edit all articles. Publishing is done by an editor.')
: t("Everything you've written, online and in draft."),
)
const writerLine = computed(() => {
if (access.readsOwnOnly.value) {
return t('Online articles and the drafts you created.')
}
return access.canCreate.value &&
access.updateScope.value === 'all' &&
access.publishScope.value === 'none'
? t('You can write and edit all articles. Publishing is done by an editor.')
: ''
})
const list = useArticleList({
sitesSettled: () => siteList.sitesSettled.value,
@@ -50,7 +63,6 @@ const {
notice,
showSkeleton,
searchInput,
needsAttentionCount,
settled,
skeletonActive,
showPaginator,
@@ -60,16 +72,58 @@ const {
const siteList = useArticleSites(() => shown.value.site)
const { sites, severalSites, siteName } = siteList
const newArticleModalOpen = ref(false)
const filtersOpen = ref(false)
const skeletonRows = computed(() => items.value.length || DEFAULT_SKELETON_ROWS)
const optionsSiteKey = computed(
() =>
shown.value.site ||
sites.value.find((site) => site.is_default)?.key ||
sites.value[0]?.key ||
'',
)
const { languages, languagesKnown, authors } = useArticleFilterOptions(() => optionsSiteKey.value)
const defaultLang = computed(
() => languages.value.find((language) => language.is_default)?.lang ?? '',
)
watch([languagesKnown, hasLoaded], ([known, loaded]) => {
if (known && loaded) {
list.dropUnknownLanguage(languages.value.map((language) => language.lang))
}
})
const mode = computed(() => listMode(shown.value))
const tabs = computed(() => listTabs(desired.value, counts.value))
const activeTab = computed(() => activeTabKey(desired.value))
const unit = computed(() => (settled.value ? unitLabel(shown.value, total.value) : ''))
const fields = computed<ArticleFilterFieldProps>(() => ({
site: desired.value.site,
sites: sites.value,
severalSites: severalSites.value,
lang: desired.value.lang,
languages: languages.value,
status: desired.value.status,
author: desired.value.author,
authors: authors.value,
seo: desired.value.seo,
suggestions: list.needsAttentionCount.value,
}))
const chips = computed(() => filterChips(desired.value, sites.value, authors.value))
const emptyVariant = computed(() => {
if (!settled.value || skeletonActive.value || total.value > 0) {
return null
}
if (shown.value.tab === 'missing') {
return 'nothing-missing' as const
}
if (shown.value.q) {
return 'no-results' as const
}
if (shown.value.author || shown.value.lang) {
return 'no-matches' as const
}
if (shown.value.status === 'draft') {
return 'no-drafts' as const
}
@@ -88,12 +142,27 @@ const emptyVariant = computed(() => {
const { busyIds, handlePublish, handleUnpublish, handleCopyUrl, handleDelete } =
useArticleRowActions({
items,
total,
counts,
shown,
reload: list.load,
})
function openVersion(id: number) {
router.push(`/article/${id}/edit`)
}
function addTranslation(item: Article) {
openVersion(item.id)
}
const menuHandlers: StoryMenuHandlers = {
open: openVersion,
addTranslation,
publish: handlePublish,
unpublish: handleUnpublish,
copyUrl: handleCopyUrl,
remove: handleDelete,
}
function openNewArticleModal() {
newArticleModalOpen.value = true
}
@@ -109,6 +178,20 @@ function clearSearch() {
})
}
function removeChip(key: FilterChipKey) {
if (key === 'site') {
list.changeSite('')
} else if (key === 'lang') {
list.changeLang('')
} else if (key === 'status') {
list.changeStatus('')
} else if (key === 'author') {
list.changeAuthor(0)
} else {
list.changeSeo(false)
}
}
function onEmptyAction() {
if (emptyVariant.value === 'needs-attention') {
list.changeSeo(false)
@@ -122,6 +205,10 @@ function onEmptyAction() {
clearSearch()
return
}
if (emptyVariant.value === 'no-matches') {
list.clearFilters()
return
}
if (emptyVariant.value === 'no-online') {
list.changeStatus('draft')
return
@@ -136,28 +223,34 @@ onMounted(() => {
<template>
<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">
<h1
data-testid="articles-heading"
tabindex="-1"
class="text-xl font-semibold text-highlighted"
>
{{ t('Articles') }}
</h1>
<p class="text-muted">{{ writerLine }}</p>
</div>
<div class="flex flex-col gap-3 md:gap-4">
<div class="flex min-h-9.5 items-baseline gap-3">
<h1
data-testid="articles-heading"
tabindex="-1"
class="text-xl font-semibold text-highlighted"
>
{{ t('Articles') }}
</h1>
<p
aria-live="polite"
data-testid="unit-label"
class="min-w-0 flex-1 truncate font-medium text-muted"
>
{{ unit }}
</p>
<UButton
v-if="access.canCreate.value"
icon="i-lucide-plus"
class="hidden h-8 px-3 sm:flex"
class="size-11 justify-center md:h-8 md:w-auto md:px-3"
@click="openNewArticleModal"
>
{{ t('New article') }}
<span class="max-md:sr-only">{{ t('New article') }}</span>
</UButton>
</div>
<p v-if="writerLine" class="-mt-1 text-muted">{{ writerLine }}</p>
<ReadOnlyBanner
v-if="access.readsOwnOnly.value"
icon="i-lucide-eye-off"
@@ -168,21 +261,32 @@ onMounted(() => {
)
"
/>
<ArticleListToolbar
v-model:search="searchInput"
:status="desired.status"
:counts="counts"
:needs-attention-count="needsAttentionCount"
:needs-attention="desired.seo !== ''"
:site="desired.site"
:sites="sites"
:several-sites="severalSites"
@update:status="list.changeStatus"
@update:needs-attention="list.changeSeo"
@update:site="list.changeSite"
<ReadOnlyBanner
v-else-if="!access.canCreate.value"
:title="t('Showing only what you can read')"
:description="
t(
'Languages, counts and missing-translation results reflect the versions you have permission to see. You can\'t create translations on this site.',
)
"
/>
<ArticleFilterBar
v-model:search="searchInput"
:fields="fields"
:chip-count="chips.length"
@update:site="list.changeSite"
@update:lang="list.changeLang"
@update:status="list.changeStatus"
@update:author="list.changeAuthor"
@update:seo="list.changeSeo"
@open-filters="filtersOpen = true"
/>
<ArticleFilterChips :chips="chips" @remove="removeChip" @clear="list.clearFilters" />
<ArticleListTabs :tabs="tabs" :active="activeTab" @select="list.changeTab" />
<ArticleListNotice
v-if="notice"
:kind="notice.kind"
@@ -191,7 +295,8 @@ onMounted(() => {
@retry="list.retryNotice"
/>
<template v-if="hasLoaded || loadFailed || showSkeleton">
<ArticleListFailure v-if="loadFailed" @retry="list.load(desired, 'replace')" />
<template v-else-if="hasLoaded || showSkeleton">
<OwnScopeEmpty
v-if="emptyVariant === 'no-drafts' && access.readsOwnOnly.value"
icon="i-lucide-file-text"
@@ -204,35 +309,46 @@ onMounted(() => {
:action="access.canCreate.value ? t('New article') : undefined"
@action="openNewArticleModal"
/>
<ArticleListEmpty
v-else-if="
emptyVariant === 'no-results' ||
emptyVariant === 'nothing-missing' ||
emptyVariant === 'no-matches'
"
:variant="emptyVariant"
:query="shown.q"
:lang="shown.lang"
@action="onEmptyAction"
/>
<EmptyState
v-else-if="emptyVariant"
:variant="emptyVariant"
:query="emptyVariant === 'no-site' ? siteName : shown.q"
@action="onEmptyAction"
/>
<ArticleListSkeleton v-else-if="skeletonActive" />
<template v-else>
<ArticleTable
:articles="items"
:loading="skeletonActive"
:skeleton-rows="skeletonRows"
<StoryTable
v-if="desktop"
:items="items"
:mode="mode"
:sort="effectiveSort(shown.sort)"
:sites="severalSites ? sites : []"
:default-lang="defaultLang"
:busy-ids="busyIds"
:handlers="menuHandlers"
:lang="shown.lang"
@sort="list.changeSort"
@publish="handlePublish"
@unpublish="handleUnpublish"
@delete="handleDelete"
@copy-url="handleCopyUrl"
@add-translation="addTranslation"
/>
<ArticleCardList
:articles="items"
:loading="skeletonActive"
:skeleton-rows="skeletonRows"
<StoryCardList
v-else
:items="items"
:mode="mode"
:default-lang="defaultLang"
:busy-ids="busyIds"
@publish="handlePublish"
@unpublish="handleUnpublish"
@delete="handleDelete"
@copy-url="handleCopyUrl"
:handlers="menuHandlers"
:lang="shown.lang"
@add-translation="addTranslation"
/>
</template>
</template>
@@ -249,14 +365,18 @@ onMounted(() => {
/>
</div>
<UButton
v-if="access.canCreate.value"
icon="i-lucide-plus"
class="fixed inset-x-4 bottom-4 h-12 justify-center text-[0.9375rem] shadow-(--shadow-popover) sm:hidden"
@click="openNewArticleModal"
>
{{ t('New article') }}
</UButton>
<ArticleFiltersSheet
v-model:open="filtersOpen"
:fields="fields"
:total="total"
:chip-count="chips.length"
@update:site="list.changeSite"
@update:lang="list.changeLang"
@update:status="list.changeStatus"
@update:author="list.changeAuthor"
@update:seo="list.changeSeo"
@clear="list.clearFilters"
/>
<NewArticleModal v-model:open="newArticleModalOpen" @created="onArticleCreated" />
</DashboardShell>
@@ -0,0 +1,619 @@
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 { customAccess } from '@/test/access'
import {
LIMITED_VIEWER,
siteLanguagesBody,
storyAuthorsBody,
storyPageFor,
type StoryViewer,
} from '@/test/storyLists'
import {
callsTo,
createMetaBackend,
failWith,
mountMeta,
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
const SKELETON_WAIT_MS = 220
beforeEach(() => {
localStorage.clear()
})
afterEach(async () => {
setLocale('en')
await unmountMeta()
})
function backendFor(viewer: StoryViewer = {}, reader = false): MetaBackend {
const [first] = renewSites()
const sites = ['journal', 'atelier', 'press'].map((key, index) => ({
...first!,
id: index + 1,
key,
name: key.charAt(0).toUpperCase() + key.slice(1),
is_default: index === 0,
}))
const backend = createMetaBackend({ sites })
if (reader) {
backend.me.access = customAccess('content.read', 'Reader')
}
backend.responders['GET /v1/article/'] = (call) =>
storyPageFor(new URL(call.raw, 'http://localhost'), viewer)
backend.responders['GET /v1/site/journal/languages'] = () => siteLanguagesBody('journal')
backend.responders['GET /v1/site/atelier/languages'] = () => siteLanguagesBody('atelier')
backend.responders['GET /v1/author/'] = () => storyAuthorsBody()
return backend
}
function listCalls(backend: MetaBackend) {
return callsTo(backend, 'GET', '/v1/article/')
}
function lastCall(backend: MetaBackend) {
return listCalls(backend).at(-1)!
}
function unitLabel() {
return textOf(document.body.querySelector('[data-testid="unit-label"]'))
}
function tabs() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]')).map(textOf)
}
function rowTitles() {
return Array.from(document.body.querySelectorAll('[data-row-title]')).map(textOf)
}
function coverageOf(title: string) {
const row = Array.from(document.body.querySelectorAll<HTMLElement>('tbody tr, li')).find(
(candidate) => textOf(candidate).includes(title),
)!
return Array.from(
row.querySelectorAll<HTMLElement>('[data-testid="story-coverage"] [aria-label]'),
).map((chip) => chip.getAttribute('aria-label'))
}
function currentQuery() {
return { ...router.currentRoute.value.query }
}
function summary() {
return document.body.querySelector('[data-testid="paginator-summary"]')?.textContent?.trim()
}
async function openMenu(title: string) {
document.body.querySelector<HTMLElement>(`button[aria-label="Actions for ${title}"]`)!.click()
await flushPromises()
}
function menuLabels() {
return Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf)
}
async function openSelect(label: string) {
document.body.querySelector<HTMLElement>(`button[aria-label="${label}"]`)!.click()
await flushPromises()
return Array.from(document.body.querySelectorAll('[role="option"]')).map(textOf)
}
async function chooseOption(label: string, option: string) {
await openSelect(label)
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((item) => textOf(item).startsWith(option))!
.click()
await flushPromises()
}
function buttonNamed(label: string) {
return Array.from(document.body.querySelectorAll<HTMLButtonElement>('button')).find(
(item) => textOf(item) === label || item.getAttribute('aria-label') === label,
)
}
describe('loc.l7.all, All languages, page 1', () => {
it('shows 5 of 8 rows, the unit, two tabs and the pager', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&size=5', { backend })
expect(lastCall(backend).query.get('group')).toBe('story')
expect(unitLabel()).toBe('8 articles')
expect(tabs()).toEqual(['All articles 8', 'Needs translation 7'])
expect(rowTitles()).toHaveLength(5)
expect(summary()).toBe('Showing 1–5 of 8')
})
it('renders the coverage chips in the order of the response, dashed for missing', async () => {
await mountMeta('articles', '/article?site=journal&size=5', { backend: backendFor() })
expect(coverageOf('How we price a bathroom renovation')).toEqual([
'English: published',
'French: draft',
'German: published',
'Arabic: draft',
])
expect(coverageOf('Winter checklist for timber windows')).toEqual([
'English: published',
'French: published',
'German: no version',
'Arabic: no version',
])
})
it('names the language of a representative that is not the default', async () => {
await mountMeta('articles', '/article?site=journal&page=2&size=5', {
backend: backendFor(),
})
await flushPromises()
expect(textOf(document.body)).toContain('Shown in Spanish · language disabled')
})
})
describe('loc.l7.page2, All languages, page 2', () => {
it('shows the last 3 rows and keeps page in the URL', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&page=2&size=5', { backend })
expect(rowTitles()).toEqual([
'Why we reuse original floorboards',
'Our first passive-house retrofit',
'Cómo elegir un contratista',
])
expect(summary()).toBe('Showing 6–8 of 8')
expect(currentQuery()).toEqual({ site: 'journal', page: '2', size: '5' })
})
})
describe('loc.l7.fr, French selected', () => {
it('shows one row per French version, four tabs and the French unit', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&lang=fr&size=5', { backend })
expect(lastCall(backend).query.get('lang')).toBe('fr')
expect(unitLabel()).toBe('4 French versions')
expect(tabs()).toEqual(['All 4', 'Published 2', 'Drafts 2', 'Not in French 4'])
expect(rowTitles()).toEqual([
'Comment nous chiffrons une rénovation de salle de bain',
"Guide de l'enduit à la chaux",
"Liste d'hiver pour les fenêtres en bois",
"Pourquoi nous réutilisons les lames de parquet d'origine",
])
expect(
document.body.querySelector<HTMLInputElement>('input[type="search"]')!.placeholder,
).toBe('Search French titles')
})
it('asks for status=draft with the Drafts tab and drops page', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&lang=fr&size=5&page=1', { backend })
const tab = Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]')).find(
(item) => textOf(item).startsWith('Drafts'),
)!
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
tab.click()
await flushPromises()
expect(lastCall(backend).query.get('status')).toBe('draft')
expect(unitLabel()).toBe('2 French drafts')
expect(currentQuery()).toEqual({
site: 'journal',
lang: 'fr',
size: '5',
status: 'draft',
})
})
})
describe('loc.l7.frMissing, Not in French', () => {
it('lists the articles without a French version with an Add French translation button', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&lang=fr&tab=missing&size=5', {
backend,
})
expect(lastCall(backend).query.get('tab')).toBe('missing')
expect(unitLabel()).toBe('4 articles not in French')
expect(rowTitles()).toEqual([
'Spring newsletter',
'Roof inspection checklist',
'Our first passive-house retrofit',
'Cómo elegir un contratista',
])
expect(textOf(document.body)).toContain('Opens English · draft')
expect(textOf(document.body)).toContain('Opens Spanish · published · language disabled')
expect(
document.body.querySelectorAll('button[aria-label^="Add French translation to"]'),
).toHaveLength(4)
expect(tabs().at(-1)).toBe('Not in French 4')
})
})
describe('loc.l7.emptyMissing, Nothing missing', () => {
it('shows the nothing-missing state with no pager and no summary', async () => {
await mountMeta('articles', '/article?site=press&lang=fr&tab=missing', {
backend: backendFor(),
})
expect(textOf(document.body)).toContain('Every article you can see has a French version')
expect(textOf(document.body)).toContain(
'Articles without a French version will appear here.',
)
expect(unitLabel()).toBe('0 articles not in French')
expect(summary()).toBeUndefined()
expect(document.body.querySelector('nav[aria-label="Pagination"]')).toBeNull()
expect(document.body.querySelector('table')).toBeNull()
})
it('names no language when none is selected', async () => {
await mountMeta('articles', '/article?site=press&tab=missing', { backend: backendFor() })
expect(textOf(document.body)).toContain('Every article you can see is fully translated')
expect(unitLabel()).toBe('0 articles need translation')
})
})
describe('loc.l7.noResults, No results', () => {
it('shows the search empty state and every count at 0', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&q=zinc', { backend })
expect(textOf(document.body)).toContain('No articles match "zinc"')
expect(textOf(document.body)).toContain(
'Search covers titles in every language you can read.',
)
expect(tabs()).toEqual(['All articles 0', 'Needs translation 0'])
expect(summary()).toBeUndefined()
expect(lastCall(backend).query.get('q')).toBe('zinc')
})
})
describe('loc.l7.frLoading, Loading', () => {
it('shows the counts as a dash, skeleton rows and aria-busy while the request is pending', async () => {
const backend = backendFor()
backend.responders['GET /v1/article/'] = () => new Promise(() => undefined)
await mountMeta('articles', '/article?site=journal&lang=fr', { backend })
await new Promise((resolve) => setTimeout(resolve, SKELETON_WAIT_MS))
await flushPromises()
expect(tabs()).toEqual(['All –', 'Published –', 'Drafts –', 'Not in French –'])
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
expect(unitLabel()).toBe('')
})
})
describe('loc.l7.listFail, Failed', () => {
it('shows the failure with the filters kept and retries the same request', async () => {
const backend = backendFor()
backend.responders['GET /v1/article/'] = failWith(500)
await mountMeta('articles', '/article?site=journal&lang=fr&q=chaux', { backend })
expect(textOf(document.body)).toContain("Couldn't load articles")
expect(textOf(document.body)).toContain('Your filters and page are kept. Try again.')
expect(tabs()).toEqual(['All –', 'Published –', 'Drafts –', 'Not in French –'])
expect(document.body.querySelector<HTMLInputElement>('input[type="search"]')!.value).toBe(
'chaux',
)
expect(currentQuery()).toEqual({ site: 'journal', lang: 'fr', q: 'chaux' })
backend.responders['GET /v1/article/'] = (call) =>
storyPageFor(new URL(call.raw, 'http://localhost'))
buttonNamed('Try again')!.click()
await flushPromises()
expect(textOf(document.body)).not.toContain("Couldn't load articles")
expect(lastCall(backend).query.get('q')).toBe('chaux')
expect(rowTitles()).toHaveLength(1)
})
})
describe('loc.l7.limited, Limited access', () => {
it('shows no dashed chips, no Add translation item and the limited-access banner', async () => {
const backend = backendFor(LIMITED_VIEWER, true)
await mountMeta('articles', '/article?site=journal', { backend })
expect(textOf(document.body)).toContain('Showing only what you can read')
expect(
document.body.querySelectorAll('[data-testid="story-coverage"] button'),
).toHaveLength(0)
const chips = Array.from(
document.body.querySelectorAll('[data-testid="story-coverage"] [aria-label]'),
).map((chip) => chip.getAttribute('aria-label') ?? '')
expect(chips.some((label) => label.includes('no version'))).toBe(false)
await openMenu(rowTitles()[0]!)
expect(menuLabels().some((label) => label.startsWith('Add translation'))).toBe(false)
})
})
describe('loc.l7.author, Author filter', () => {
it('narrows to Marta Alves, shows the chip and clears everything with Clear all', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&author=1', { backend })
expect(lastCall(backend).query.get('author')).toBe('1')
expect(unitLabel()).toBe('3 articles')
const chips = Array.from(
document.body.querySelectorAll('[data-testid="article-filter-chip"]'),
).map(textOf)
expect(chips).toEqual(['Site: Journal', 'Author: Marta Alves'])
buttonNamed('Clear all')!.click()
await flushPromises()
expect(currentQuery()).toEqual({})
expect(lastCall(backend).query.has('author')).toBe(false)
expect(lastCall(backend).query.has('site')).toBe(false)
expect(document.body.querySelector('[data-testid="article-filter-chip"]')).toBeNull()
})
it('removes one chip and keeps the others', async () => {
await mountMeta('articles', '/article?site=journal&author=1&status=draft', {
backend: backendFor(),
})
buttonNamed('Remove filter Author: Marta Alves')!.click()
await flushPromises()
expect(currentQuery()).toEqual({ site: 'journal', status: 'draft' })
})
it('picks an author from the Author select with the counts of articles', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal', { backend })
expect(await openSelect('Author')).toEqual([
'Any author',
'Marta Alves 3',
'Camille Roche 2',
'Luis Ferrer 3',
])
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((item) => textOf(item).startsWith('Marta'))!
.click()
await flushPromises()
expect(currentQuery().author).toBe('1')
expect(lastCall(backend).query.get('page')).toBe('1')
})
})
describe('loc.l7.langFilter, Language filter', () => {
it('groups the Enabled and Disabled languages with their version counts', async () => {
await mountMeta('articles', '/article?site=journal', { backend: backendFor() })
const options = await openSelect('Language')
expect(options).toEqual([
'All languages',
'English 7 versions',
'French 4 versions',
'German 4 versions',
'Arabic 1 version',
'Spanish 1 version',
'Italian 0 versions',
])
expect(textOf(document.body)).toContain('Enabled')
expect(textOf(document.body)).toContain('Disabled')
expect(textOf(document.body)).toContain(
'Disabled languages keep their versions. They can’t get new ones.',
)
})
it('selects a language, drops the page and shows the French tabs', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&page=2&size=5', { backend })
await chooseOption('Language', 'French')
expect(currentQuery()).toEqual({ site: 'journal', size: '5', lang: 'fr' })
expect(lastCall(backend).query.get('page')).toBe('1')
expect(tabs()).toEqual(['All 4', 'Published 2', 'Drafts 2', 'Not in French 4'])
})
})
describe('loc.l7.rowMenu, Row menu', () => {
const expected = [
'Open English',
'Open another language…',
'Add translation…',
'Unpublish English',
'Copy API URL',
'Delete English version',
]
it('lists the same actions on desktop', async () => {
await mountMeta('articles', '/article?site=journal&size=5', { backend: backendFor() })
await openMenu('Winter checklist for timber windows')
expect(menuLabels()).toEqual(expected)
})
it('lists the same actions on mobile', async () => {
await mountMeta('articles', '/article?site=journal&size=5', {
width: MOBILE_WIDTH,
backend: backendFor(),
})
await openMenu('Winter checklist for timber windows')
expect(menuLabels()).toEqual(expected)
})
it('hides Add translation on an article that has every language', async () => {
await mountMeta('articles', '/article?site=journal&size=5', { backend: backendFor() })
await openMenu('How we price a bathroom renovation')
expect(menuLabels()).not.toContain('Add translation…')
expect(menuLabels()).toContain('Open another language…')
})
it('gives every row a menu button', async () => {
await mountMeta('articles', '/article?site=journal&lang=fr&tab=missing&size=5', {
backend: backendFor(),
})
expect(document.body.querySelectorAll('button[aria-label^="Actions for"]')).toHaveLength(4)
})
})
describe('loc.l7.filters, Mobile filter sheet', () => {
it('opens a sheet with Journal, French and Draft and shows the result count', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&lang=fr&status=draft', {
width: MOBILE_WIDTH,
backend,
})
const trigger = document.body.querySelector<HTMLElement>(
'button[aria-label="Filters, 3 active"]',
)!
expect(textOf(trigger)).toBe('Filters · 3')
expect(document.body.querySelector('button[aria-label="Language"]')).toBeNull()
trigger.click()
await flushPromises()
const sheet = document.body.querySelector<HTMLElement>(
'[data-testid="article-filters-sheet"]',
)!
expect(
['Site', 'Language', 'Status', 'Author', 'SEO'].every((label) =>
textOf(sheet).includes(label),
),
).toBe(true)
expect(sheet.querySelector('button[aria-label="Language"]')!.textContent).toContain(
'French',
)
expect(sheet.querySelector('button[aria-label="Status"]')!.textContent).toContain('Draft')
expect(buttonNamed('Clear all')).toBeDefined()
expect(buttonNamed('Show 2 results')).toBeDefined()
})
})
describe('loc.l7.tabsScroll, Tabs scrolled', () => {
it('keeps Not in French reachable with the scroll buttons beside the tabs', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&lang=fr', {
width: MOBILE_WIDTH,
backend,
})
expect(buttonNamed('Scroll tabs left')).toBeDefined()
expect(buttonNamed('Scroll tabs right')).toBeDefined()
const tab = Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]')).find(
(item) => textOf(item).startsWith('Not in French'),
)!
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
tab.click()
await flushPromises()
expect(lastCall(backend).query.get('tab')).toBe('missing')
expect(currentQuery()).toEqual({ site: 'journal', lang: 'fr', tab: 'missing' })
})
})
describe('loc.l7.long, Eight languages', () => {
it('collapses the chips to +3 on desktop and +4 on mobile with named overflow chips', async () => {
await mountMeta('articles', '/article?site=atelier&status=draft&seo=suggestions', {
backend: backendFor(),
})
const desktopOverflow = document.body.querySelector<HTMLElement>(
'[data-testid="story-coverage"] [aria-label^="3 more languages"]',
)!
expect(textOf(desktopOverflow)).toBe('+3')
expect(desktopOverflow.getAttribute('aria-label')).toBe(
'3 more languages: Chinese (Simplified), Japanese, Arabic',
)
await unmountMeta()
await mountMeta('articles', '/article?site=atelier&status=draft&seo=suggestions', {
width: MOBILE_WIDTH,
backend: backendFor(),
})
const mobileOverflow = document.body.querySelector<HTMLElement>(
'[data-testid="story-coverage"] [aria-label$="Arabic"]',
)!
expect(textOf(mobileOverflow)).toBe('+4')
})
})
describe('URL state', () => {
it.each([
{ query: 'lang=english', expected: {} },
{ query: 'tab=review', expected: {} },
{ query: 'author=abc', expected: {} },
{ query: 'page=abc', expected: {} },
{ query: 'lang=fr&tab=missing&status=draft', expected: { lang: 'fr', tab: 'missing' } },
])('falls back from ?$query and replaces the URL', async ({ query, expected }) => {
const backend = backendFor()
await mountMeta('articles', `/article?site=journal&${query}`, { backend })
expect(currentQuery()).toEqual({ site: 'journal', ...expected })
const first = listCalls(backend)[0]!.query
expect(first.get('group')).toBe('story')
expect(first.get('tab')).toBe(expected.tab ?? null)
})
it('drops a language the site does not have once the languages are known', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&lang=pt', { backend })
await flushPromises()
expect(currentQuery()).toEqual({ site: 'journal' })
expect(lastCall(backend).query.has('lang')).toBe(false)
})
it('drops page on every filter change', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&page=2&size=5', { backend })
await chooseOption('Status', 'Draft')
expect(currentQuery().page).toBeUndefined()
expect(lastCall(backend).query.get('page')).toBe('1')
await chooseOption('Language', 'French')
expect(currentQuery().page).toBeUndefined()
await typeInto(
document.body.querySelector<HTMLInputElement>('input[type="search"]')!,
'chaux',
)
await new Promise((resolve) => setTimeout(resolve, SEARCH_WAIT_MS))
await flushPromises()
expect(currentQuery()).toMatchObject({ q: 'chaux', lang: 'fr', status: 'draft' })
expect(currentQuery().page).toBeUndefined()
})
it('reads the language names in French', async () => {
setLocale('fr')
await mountMeta('articles', '/article?site=journal&lang=fr&tab=missing&size=5', {
backend: backendFor(),
})
expect(unitLabel()).toBe('4 articles sans version en français')
expect(tabs().at(-1)).toBe('Pas en français 4')
})
it('sends group=story on the very first request and nothing for the hidden review tab', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?tab=review', { backend })
expect(tabs().some((label) => label.startsWith('Needs review'))).toBe(false)
expect(listCalls(backend)[0]!.query.get('tab')).toBeNull()
})
})
@@ -1,9 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import { customAccess, writerAccess } from '@/test/access'
import type { Access } from '@/lib/permissions'
import { writerAccess } from '@/test/access'
import type { ArticleCapability } from '@/composables/useArticles'
import {
articleOf,
createMetaBackend,
@@ -27,18 +26,17 @@ beforeEach(() => {
afterEach(unmountMeta)
const SESSION_USER = 1
const EVERYTHING: ArticleCapability[] = ['read', 'edit', 'publish', 'delete', 'create_translation']
function grants(publish: string, remove: string, update: string): Access {
return customAccess(
`content.read:article=all, content.create:article=all, content.publish:article=${publish}, content.delete:article=${remove}, content.update:article=${update}`,
'Contributor',
)
}
async function mountArticles(access: Access, articles = [articleOf(1, 'Article 1')]) {
async function mountArticles(
capabilitiesFor: MetaBackend['capabilitiesFor'],
articles = [articleOf(1, 'Article 1')],
configure: (backend: MetaBackend) => void = () => undefined,
) {
const backend: MetaBackend = createMetaBackend()
backend.articles = articles
backend.me.access = access
backend.capabilitiesFor = capabilitiesFor
configure(backend)
await mountMeta('articles', '/article', { backend })
return backend
}
@@ -48,103 +46,79 @@ async function openMenu(title: string) {
await flushPromises()
}
function menuItem(label: string) {
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')).find(
(item) => textOf(item).startsWith(label),
function menuLabels() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')).map(
(item) => textOf(item),
)
}
function isDisabled(item: HTMLElement | undefined) {
return item?.getAttribute('aria-disabled') === 'true' || item?.hasAttribute('data-disabled')
}
describe('Article controls under all and none', () => {
it('offers every control to a role that holds all of them', async () => {
await mountArticles(grants('all', 'all', 'all'))
describe('Row actions follow the capabilities of the response', () => {
it('offers every action when the row carries every capability', async () => {
await mountArticles(() => EVERYTHING)
await openMenu('Article 1')
expect(menuItem('Publish')).toBeDefined()
expect(isDisabled(menuItem('Delete'))).toBe(false)
expect(isDisabled(menuItem('Edit'))).toBe(false)
expect(menuLabels()).toEqual(['Edit', 'Publish', 'Copy API URL', 'Delete…'])
expect(textOf(document.body)).not.toContain('Ask an editor to publish.')
})
it('hides Publish, keeps the Draft status with the editor hint and disables Delete', async () => {
await mountArticles(grants('none', 'none', 'all'))
it('hides Publish and Delete and keeps the editor hint on a draft without those capabilities', async () => {
await mountArticles(() => ['read', 'edit'])
expect(document.body.querySelector('[data-testid="publish-hint"]')?.textContent).toBe(
'Ask an editor to publish.',
)
await openMenu('Article 1')
expect(menuItem('Publish')).toBeUndefined()
expect(isDisabled(menuItem('Delete'))).toBe(true)
expect(textOf(document.body)).toContain("Your role can't delete articles.")
expect(isDisabled(menuItem('Edit'))).toBe(false)
expect(menuLabels()).toEqual(['Edit', 'Copy API URL'])
})
it('disables Edit as well when the role cannot update', async () => {
await mountArticles(grants('none', 'none', 'none'))
it('keeps only Open and Copy API URL on a read-only row', async () => {
await mountArticles(() => ['read'])
await openMenu('Article 1')
expect(isDisabled(menuItem('Edit'))).toBe(true)
expect(textOf(document.body)).toContain("Your role can't edit articles.")
expect(menuLabels()).toEqual(['Edit', 'Copy API URL'])
})
})
describe('The Writer built-in Articles list', () => {
it('reads the Writer header, enables Edit and disables Delete with its reason', async () => {
await mountArticles(writerAccess())
it('reads the Writer header and the server decides the row actions', async () => {
await mountArticles(
() => ['read', 'edit', 'create_translation'],
undefined,
(backend) => {
backend.me.access = writerAccess()
},
)
expect(textOf(document.body)).toContain(
'You can write and edit all articles. Publishing is done by an editor.',
)
await openMenu('Article 1')
expect(isDisabled(menuItem('Edit'))).toBe(false)
expect(isDisabled(menuItem('Delete'))).toBe(true)
expect(textOf(document.body)).toContain("Your role can't delete articles.")
expect(menuItem('Publish')).toBeUndefined()
expect(menuLabels()).toEqual(['Edit', 'Copy API URL'])
})
})
describe('Article controls under own scope', () => {
const OWN = grants('own', 'own', 'own')
describe('Article actions on the rows of other people', () => {
const articles = [
articleOf(1, 'Article 1', { created_by: SESSION_USER }),
articleOf(2, 'Article 2', { created_by: 9 }),
]
const own = (article: { created_by?: number }): ArticleCapability[] =>
article.created_by === SESSION_USER ? EVERYTHING : ['read']
it('makes Edit, Delete and Publish available on the article the person created', async () => {
await mountArticles(OWN, articles)
it('shows the actions the response grants on the article the person created', async () => {
await mountArticles(own, articles)
await openMenu('Article 1')
expect(isDisabled(menuItem('Edit'))).toBe(false)
expect(isDisabled(menuItem('Delete'))).toBe(false)
expect(menuItem('Publish')).toBeDefined()
expect(menuLabels()).toEqual(['Edit', 'Publish', 'Copy API URL', 'Delete…'])
})
it('disables Edit and Delete and hides Publish on an article by someone else', async () => {
await mountArticles(OWN, articles)
it('hides Publish and Delete on an article by someone else', async () => {
await mountArticles(own, articles)
await openMenu('Article 2')
expect(isDisabled(menuItem('Edit'))).toBe(true)
expect(isDisabled(menuItem('Delete'))).toBe(true)
expect(textOf(document.body)).toContain('You can only edit content you created.')
expect(menuItem('Publish')).toBeUndefined()
expect(menuLabels()).toEqual(['Edit', 'Copy API URL'])
expect(document.body.querySelector('tr [data-testid="publish-hint"]')?.textContent).toBe(
'Ask an editor to publish.',
)
})
})
describe('A stale menu', () => {
it('disables Delete with its reason once the session loses the permission', async () => {
await mountArticles(grants('all', 'all', 'all'))
const session = useSessionStore()
session.session = { ...session.session!, access: grants('all', 'none', 'all') }
await flushPromises()
await openMenu('Article 1')
expect(isDisabled(menuItem('Delete'))).toBe(true)
expect(textOf(document.body)).toContain("Your role can't delete articles.")
})
})
+78 -87
View File
@@ -7,8 +7,9 @@ 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 { storyEnvelope, storyListRoute } from './articleEnvelope'
import { ownerAccess } from '@/test/access'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
const NOW = new Date('2026-08-26T10:00:00Z')
const HOUR_MS = 60 * 60 * 1000
@@ -133,7 +134,10 @@ function sixArticleFixture(): Article[] {
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountArticles(handlers: Record<string, () => Response | Promise<Response>>) {
async function mountArticles(
handlers: Record<string, () => Response | Promise<Response>>,
path = '/article',
) {
setActivePinia(createPinia())
overlayMock = createOverlayMock()
toastAdd = vi.fn()
@@ -143,7 +147,7 @@ async function mountArticles(handlers: Record<string, () => Response | Promise<R
global.fetch = fetchRouter({
...handlers,
}) as unknown as typeof fetch
await router.push('/article')
await router.push(path)
const wrapper = mount(ArticleView, {
attachTo: document.body,
global: {
@@ -159,21 +163,6 @@ function findButton(wrapper: Awaited<ReturnType<typeof mount>>, label: string) {
return wrapper.findAll('button').find((button) => button.text().trim() === label)
}
async function clickTab(label: string) {
const button = Array.from(document.body.querySelectorAll('button')).find(
(el) => el.textContent?.trim() === label,
) as HTMLButtonElement | undefined
button?.focus()
button?.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
}
async function openRowMenu(wrapper: Awaited<ReturnType<typeof mount>>, title: string) {
const button = document.body.querySelector<HTMLButtonElement>(
`button[aria-label="Actions for ${title}"]`,
@@ -191,6 +180,7 @@ async function chooseMenuItem(label: string) {
}
beforeEach(() => {
setViewportWidth(DESKTOP_WIDTH)
vi.useFakeTimers()
vi.setSystemTime(NOW)
})
@@ -205,12 +195,12 @@ afterEach(async () => {
describe('Default — the list loads sorted by last updated, newest first', () => {
it('shows the breadcrumb, tab counts and every row in fixture order', async () => {
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
})
expect(wrapper.text()).toContain('All 6')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 2')
expect(wrapper.text()).toContain('All articles 6')
expect(wrapper.text()).toContain('Needs translation 0')
expect(wrapper.find('[data-testid="unit-label"]').text()).toBe('6 articles')
const titles = sixArticleFixture().map((a) => a.title)
const rendered = wrapper.text()
@@ -231,10 +221,10 @@ describe('Loading — skeleton rows while the first request is in flight', () =>
it('shows skeleton rows and aria-busy, then replaces them once resolved', async () => {
let resolveList: (() => void) | null = null
const wrapper = await mountArticles({
[articleListRoute()]: () =>
[storyListRoute()]: () =>
new Promise((resolve) => {
resolveList = () =>
resolve(mockResponse(200, articleEnvelope(sixArticleFixture())))
resolve(mockResponse(200, storyEnvelope(sixArticleFixture())))
}),
})
@@ -253,7 +243,7 @@ describe('Loading — skeleton rows while the first request is in flight', () =>
describe('Row menu open shows Edit, the status-appropriate action, Copy API URL and Delete…', () => {
it('opens a menu named "Actions for Winter checklist for timber windows"', async () => {
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -274,7 +264,7 @@ describe('Copy API URL succeeds — the absolute URL is copied and confirmed', (
})
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -301,7 +291,7 @@ describe('Copy API URL fails — the clipboard write itself is rejected', () =>
})
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -319,12 +309,10 @@ describe('Copy API URL fails — the clipboard write itself is rejected', () =>
describe('Empty — zero articles', () => {
it('shows the no-articles empty state and opens NewArticleModal', async () => {
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope([])),
[storyListRoute()]: () => mockResponse(200, storyEnvelope([])),
})
expect(wrapper.text()).toContain('All 0')
expect(wrapper.text()).toContain('Online 0')
expect(wrapper.text()).toContain('Drafts 0')
expect(wrapper.text()).toContain('All articles 0')
expect(wrapper.text()).toContain('No articles yet')
expect(wrapper.text()).toContain(
'Write your first one. It stays a draft until you publish it.',
@@ -340,20 +328,20 @@ describe('Empty — zero articles', () => {
describe('Initial load failure — an error must not render the no-articles empty state', () => {
it('does not show "No articles yet" when GET /v1/article/ fails', async () => {
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(500),
[storyListRoute()]: () => mockResponse(500),
})
expect(wrapper.text()).not.toContain('No articles yet')
})
})
describe('No results — a search matches nothing, but the tab counts still show the full list', () => {
describe('No results — a search matches nothing and the counts follow the search', () => {
it('shows the no-results empty state, then clears the search', async () => {
const all = sixArticleFixture()
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(all)),
[articleListRoute({ q: 'tiles' })]: () =>
mockResponse(200, articleEnvelope(all, { q: 'tiles' })),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(all)),
[storyListRoute({ q: 'tiles' })]: () =>
mockResponse(200, storyEnvelope(all, { q: 'tiles' })),
})
const searchInput = wrapper.find('input[type="search"]')
@@ -362,38 +350,32 @@ describe('No results — a search matches nothing, but the tab counts still show
await flushPromises()
expect(wrapper.text()).toContain('No articles match "tiles"')
expect(wrapper.text()).toContain(
'Check the spelling, or search for a shorter part of the title.',
)
expect(wrapper.text()).toContain('All 6')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 2')
expect(wrapper.text()).toContain('Search covers titles in every language you can read.')
expect(wrapper.text()).toContain('All articles 0')
await findButton(wrapper, 'Clear search')?.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('How we price a bathroom renovation')
const inputEl = document.querySelector('input[placeholder="Search by title"]')
const inputEl = document.querySelector('input[type="search"]')
expect(inputEl).toBe(document.activeElement)
})
})
describe('An empty tab shows its own EmptyState, while the other tab still counts correctly', () => {
describe('An empty status filter shows its own EmptyState and the counts stay', () => {
it('shows No drafts when every article is online', async () => {
const onlineOnly = sixArticleFixture()
.slice(0, 4)
.map((a) => ({ ...a, is_online: true }))
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(onlineOnly)),
[articleListRoute({ status: 'draft' })]: () =>
mockResponse(200, articleEnvelope(onlineOnly, { status: 'draft' })),
})
const wrapper = await mountArticles(
{
[storyListRoute({ status: 'draft' })]: () =>
mockResponse(200, storyEnvelope(onlineOnly, { status: 'draft' })),
},
'/article?status=draft',
)
await clickTab('Drafts 0')
expect(wrapper.text()).toContain('All 4')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 0')
expect(wrapper.text()).toContain('All articles 4')
expect(wrapper.text()).toContain('No drafts')
expect(wrapper.text()).toContain("Everything you've written is online.")
})
@@ -402,17 +384,15 @@ describe('An empty tab shows its own EmptyState, while the other tab still count
const draftsOnly = sixArticleFixture()
.slice(0, 4)
.map((a) => ({ ...a, is_online: false }))
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(draftsOnly)),
[articleListRoute({ status: 'online' })]: () =>
mockResponse(200, articleEnvelope(draftsOnly, { status: 'online' })),
})
const wrapper = await mountArticles(
{
[storyListRoute({ status: 'online' })]: () =>
mockResponse(200, storyEnvelope(draftsOnly, { status: 'online' })),
},
'/article?status=online',
)
await clickTab('Online 0')
expect(wrapper.text()).toContain('All 4')
expect(wrapper.text()).toContain('Online 0')
expect(wrapper.text()).toContain('Drafts 4')
expect(wrapper.text()).toContain('All articles 4')
expect(wrapper.text()).toContain('Nothing is online yet')
expect(wrapper.text()).toContain('Publish a draft and it will appear on your website.')
})
@@ -421,7 +401,7 @@ describe('An empty tab shows its own EmptyState, while the other tab still count
describe('Action in progress — publishing a row disables only that row', () => {
it('sends the PATCH and shows the busy label for that row alone', async () => {
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
'PATCH /v1/article/4/publish?format=html': () => new Promise(() => {}),
})
@@ -441,7 +421,7 @@ describe('Published — a 200 updates the row in place and raises a toast', () =
updated_at: iso(0),
}
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
'PATCH /v1/article/4/publish?format=html': () => mockResponse(200, updated),
})
@@ -469,7 +449,7 @@ describe('Action failed, publish — a 500 or a network error leaves the row unc
])('shows Retry for $name and re-sends the same request', async ({ handler }) => {
const patchCall = vi.fn(handler)
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
'PATCH /v1/article/4/publish?format=html': patchCall,
})
@@ -508,7 +488,7 @@ describe('Action failed, unpublish — a 500 or a network error leaves the row u
])('shows Retry for $name and re-sends the same request', async ({ handler }) => {
const patchCall = vi.fn(handler)
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
'PATCH /v1/article/3/unpublish?format=html': patchCall,
})
@@ -537,9 +517,13 @@ describe('Action failed, unpublish — a 500 or a network error leaves the row u
describe('Row vanished, draft row — a 404 on publish or delete removes the row and shows an info toast', () => {
it('removes the row on a 404 publish response', async () => {
let current = sixArticleFixture()
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/2/publish?format=html': () => mockResponse(404),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(current)),
'PATCH /v1/article/2/publish?format=html': () => {
current = current.filter((article) => article.id !== 2)
return mockResponse(404)
},
})
await openRowMenu(wrapper, 'Choosing lime plaster for old stone walls')
@@ -547,9 +531,7 @@ describe('Row vanished, draft row — a 404 on publish or delete removes the row
await flushPromises()
expect(wrapper.text()).not.toContain('Choosing lime plaster for old stone walls')
expect(wrapper.text()).toContain('All 5')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 1')
expect(wrapper.text()).toContain('All articles 5')
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-not-found-2',
color: 'info',
@@ -560,9 +542,13 @@ describe('Row vanished, draft row — a 404 on publish or delete removes the row
})
it('removes the row when the delete confirmation resolves 404', async () => {
let current = sixArticleFixture()
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'DELETE /v1/article/2': () => mockResponse(404),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(current)),
'DELETE /v1/article/2': () => {
current = current.filter((article) => article.id !== 2)
return mockResponse(404)
},
})
await openRowMenu(wrapper, 'Choosing lime plaster for old stone walls')
@@ -571,6 +557,7 @@ describe('Row vanished, draft row — a 404 on publish or delete removes the row
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
await flushPromises()
expect(wrapper.text()).not.toContain('Choosing lime plaster for old stone walls')
expect(toastAdd).toHaveBeenCalledWith({
@@ -585,9 +572,13 @@ describe('Row vanished, draft row — a 404 on publish or delete removes the row
describe('Row vanished, online row — a 404 on unpublish removes the row and shows an info toast', () => {
it('removes the row and updates the tab counts', async () => {
let current = sixArticleFixture()
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/3/unpublish?format=html': () => mockResponse(404),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(current)),
'PATCH /v1/article/3/unpublish?format=html': () => {
current = current.filter((article) => article.id !== 3)
return mockResponse(404)
},
})
await openRowMenu(wrapper, 'Before and after: the Rue Verte kitchen')
@@ -595,9 +586,7 @@ describe('Row vanished, online row — a 404 on unpublish removes the row and sh
await flushPromises()
expect(wrapper.text()).not.toContain('Before and after: the Rue Verte kitchen')
expect(wrapper.text()).toContain('All 5')
expect(wrapper.text()).toContain('Online 3')
expect(wrapper.text()).toContain('Drafts 2')
expect(wrapper.text()).toContain('All articles 5')
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-not-found-3',
color: 'info',
@@ -611,7 +600,7 @@ describe('Row vanished, online row — a 404 on unpublish removes the row and sh
describe('Delete confirm — opens the dialog with the row title and image count', () => {
it('opens ConfirmDialog with the title and 3 images', async () => {
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -631,9 +620,13 @@ describe('Delete confirm — opens the dialog with the row title and image count
describe('Deleted — a 204 closes the dialog, removes the row, and moves focus to the next row', () => {
it('removes the row, recomputes tabs and raises the success toast', async () => {
let current = sixArticleFixture()
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'DELETE /v1/article/4': () => mockResponse(204),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(current)),
'DELETE /v1/article/4': () => {
current = current.filter((article) => article.id !== 4)
return mockResponse(204)
},
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -645,9 +638,7 @@ describe('Deleted — a 204 closes the dialog, removes the row, and moves focus
await flushPromises()
expect(wrapper.text()).not.toContain('Winter checklist for timber windows')
expect(wrapper.text()).toContain('All 5')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 1')
expect(wrapper.text()).toContain('All articles 5')
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-deleted-4',
color: 'success',
@@ -661,7 +652,7 @@ describe('Deleted — a 204 closes the dialog, removes the row, and moves focus
describe('Delete failed — a 500 keeps the article and rejects with the inline message', () => {
it('throws the inline error message and leaves the article in the list', async () => {
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
'DELETE /v1/article/4': () => mockResponse(500),
})
@@ -8,7 +8,7 @@ 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 { storyEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
import { ownerAccess } from '@/test/access'
const KEY = 'renewcms-page-size-articles'
@@ -53,7 +53,7 @@ function installFetch() {
ok: true,
status: 200,
headers: { get: () => null },
json: async () => articleEnvelope(articles, params),
json: async () => storyEnvelope(articles, params),
} as unknown as Response)
}
throw new Error(`Unexpected fetch call: ${method} ${url}`)
@@ -5,11 +5,11 @@ import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { setLocale } from '@/i18n'
import { useSessionStore } from '@/stores/session'
import type { Article, ArticlePage } from '@/composables/useArticles'
import type { Article, StoryPage } 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 { storyEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
import { ownerAccess } from '@/test/access'
const TOTAL = 312
@@ -77,7 +77,7 @@ let deleteHandler: (id: number) => Response | Promise<Response>
let wrapper: Awaited<ReturnType<typeof mount>> | null = null
function respondFromFixture(params: EnvelopeParams) {
return mockResponse(200, articleEnvelope(articles, params))
return mockResponse(200, storyEnvelope(articles, params))
}
function installFetch() {
@@ -256,7 +256,7 @@ describe('3 Last page', () => {
})
describe('4 Single page', () => {
it('keeps the summary and the page-size select, hides the paginator and reads the tab counts', async () => {
it('keeps the summary and the page-size select, hides the paginator and reads the tab count', async () => {
articles = buildArticles(6).map((article, index) => ({
...article,
is_online: index < 4,
@@ -266,9 +266,7 @@ describe('4 Single page', () => {
expect(summary()).toBe('Showing 1–6 of 6')
expect(document.body.querySelector('nav[aria-label="Pagination"]')).toBeNull()
expect(pageSizeTrigger()).not.toBeNull()
expect(wrapper.text()).toContain('All 6')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 2')
expect(wrapper.text()).toContain('All articles 6')
})
})
@@ -351,7 +349,7 @@ describe('7a A shrinking server cannot loop the last-page redirect', () => {
listHandler = (params) => {
calls += 1
return mockResponse(200, {
...articleEnvelope(articles, params),
...storyEnvelope(articles, params),
items: [],
total: 25 * (30 - calls),
})
@@ -375,7 +373,7 @@ describe('7b Out of range with total 0 shows the empty state, no redirect', () =
})
describe('8 Search resets to page 1', () => {
it('sends q with page 1, updates the URL on success and leaves the tab counts alone', async () => {
it('sends q with page 1, updates the URL on success and follows the search in the tab count', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const wrapper = await mountAt('/article?page=7')
listCalls = []
@@ -389,14 +387,12 @@ describe('8 Search resets to page 1', () => {
expect(listCalls).toEqual([{ page: 1, size: 25, q: 'plaster', status: '', sort: '' }])
expect(router.currentRoute.value.fullPath).toBe('/article?q=plaster')
expect(summary()).toBe('Showing 1–15 of 15')
expect(wrapper.text()).toContain('All 312')
expect(wrapper.text()).toContain('Online 208')
expect(wrapper.text()).toContain('Drafts 104')
expect(wrapper.text()).toContain('All articles 15')
})
})
describe('9 Empty after search', () => {
it('hides the paginator and the summary and keeps the tab counts', async () => {
it('hides the paginator and the summary and shows every count at 0', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const wrapper = await mountAt('/article')
@@ -405,15 +401,11 @@ describe('9 Empty after search', () => {
await flushPromises()
expect(wrapper.text()).toContain('No articles match "tiles"')
expect(wrapper.text()).toContain(
'Check the spelling, or search for a shorter part of the title.',
)
expect(wrapper.text()).toContain('Search covers titles in every language you can read.')
expect(wrapper.text()).toContain('Clear search')
expect(summary()).toBeUndefined()
expect(document.body.querySelector('nav[aria-label="Pagination"]')).toBeNull()
expect(wrapper.text()).toContain('All 312')
expect(wrapper.text()).toContain('Online 208')
expect(wrapper.text()).toContain('Drafts 104')
expect(wrapper.text()).toContain('All articles 0')
})
})
@@ -494,12 +486,12 @@ describe('Keyboard, the page-size list', () => {
})
describe('Tabs filter on the server', () => {
it('sends status=draft with page 1, updates the URL on success and keeps the counts', async () => {
it('sends tab=missing with page 1 and updates the URL on success', async () => {
const wrapper = await mountAt('/article?page=3')
listCalls = []
const tab = Array.from(document.body.querySelectorAll('button')).find((button) =>
button.textContent?.trim().startsWith('Drafts'),
button.textContent?.trim().startsWith('Needs translation'),
)
tab?.focus()
tab?.dispatchEvent(
@@ -511,11 +503,11 @@ describe('Tabs filter on the server', () => {
)
await flushPromises()
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: 'draft', sort: '' }])
expect(router.currentRoute.value.fullPath).toBe('/article?status=draft')
expect(wrapper.text()).toContain('All 312')
expect(wrapper.text()).toContain('Online 208')
expect(wrapper.text()).toContain('Drafts 104')
expect(listCalls).toEqual([
{ page: 1, size: 25, q: '', status: '', sort: '', tab: 'missing' },
])
expect(router.currentRoute.value.fullPath).toBe('/article?tab=missing')
expect(wrapper.text()).toContain('All articles 312')
})
})
@@ -559,13 +551,13 @@ describe('Deleting the last row of the last page', () => {
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }])
expect(router.currentRoute.value.fullPath).toBe('/article')
expect(summary()).toBe('Showing 1–25 of 25')
expect(document.body.textContent).toContain('All 25')
expect(document.body.textContent).toContain('All articles 25')
})
})
describe('The envelope is the only shape read', () => {
it('renders rows from items', async () => {
const page: ArticlePage = articleEnvelope(buildArticles(3))
const page: StoryPage = storyEnvelope(buildArticles(3))
listHandler = () => mockResponse(200, page)
const wrapper = await mountAt('/article')
@@ -591,9 +583,7 @@ function headerArrow(label: string) {
describe('8a Sort headers, server-side, reset to page 1', () => {
it.each([
['Title', 'title:asc'],
['Status', 'status:asc'],
['Created', 'created_at:asc'],
['Article', 'title:asc'],
['Updated', 'updated_at:asc'],
])('the first click on %s sends sort=%s and page 1', async (label, sort) => {
const wrapper = await mountAt('/article?page=7')
@@ -604,14 +594,14 @@ describe('8a Sort headers, server-side, reset to page 1', () => {
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort }])
expect(router.currentRoute.value.fullPath).toBe(`/article?sort=${sort}`)
expect(headerArrow(label)).toBe('up')
expect(wrapper.text()).toContain('All 312')
expect(wrapper.text()).toContain('All articles 312')
})
it('keeps the URL unchanged until the response arrives', async () => {
await mountAt('/article?page=7')
listHandler = () => new Promise(() => undefined)
await click(headerButton('Title'))
await click(headerButton('Article'))
expect(router.currentRoute.value.fullPath).toBe('/article?page=7')
})
@@ -622,10 +612,10 @@ describe('8b Toggling the sorted header', () => {
await mountAt('/article?sort=title:asc')
listCalls = []
await click(headerButton('Title'))
await click(headerButton('Article'))
expect(listCalls.at(-1)).toMatchObject({ sort: 'title:desc', page: 1 })
expect(router.currentRoute.value.fullPath).toBe('/article?sort=title:desc')
expect(headerArrow('Title')).toBe('down')
expect(headerArrow('Article')).toBe('down')
await click(headerButton('Updated'))
await click(headerButton('Updated'))
@@ -665,13 +655,11 @@ describe('8c Sort is kept across paging, page size and search', () => {
})
describe('8d A sorted URL on load', () => {
it('sends the sort and shows the Created arrow-down', async () => {
await mountAt('/article?sort=created_at:desc&page=2')
it('sends the sort and shows the Article arrow-down', async () => {
await mountAt('/article?sort=title:desc&page=2')
expect(listCalls).toEqual([
{ page: 2, size: 25, q: '', status: '', sort: 'created_at:desc' },
])
expect(headerArrow('Created')).toBe('down')
expect(listCalls).toEqual([{ page: 2, size: 25, q: '', status: '', sort: 'title:desc' }])
expect(headerArrow('Article')).toBe('down')
})
it('falls back to the default for an invalid sort and replaces the URL', async () => {
@@ -686,7 +674,7 @@ describe('8d A sorted URL on load', () => {
describe('8e No client-side re-sorting', () => {
it('renders the rows in exactly the order the server returned', async () => {
listHandler = (params) => {
const page = articleEnvelope(articles, params)
const page = storyEnvelope(articles, params)
return mockResponse(200, {
...page,
items: [...page.items].reverse(),
@@ -697,7 +685,7 @@ describe('8e No client-side re-sorting', () => {
const rendered = Array.from(document.body.querySelectorAll('table [data-row-title]')).map(
(row) => Number(row.getAttribute('data-row-title')),
)
const expected = articleEnvelope(articles, { sort: 'title:asc' })
const expected = storyEnvelope(articles, { sort: 'title:asc' })
.items.map((article) => article.id)
.reverse()
expect(rendered).toEqual(expected)
@@ -711,12 +699,12 @@ describe('8f Caption follows the sort', () => {
'Articles, sorted by last updated, newest first',
)
await click(headerButton('Title'))
await click(headerButton('Article'))
expect(document.body.querySelector('caption')?.textContent?.trim()).toBe(
'Articles, sorted by title, ascending',
)
expect(document.body.querySelector('th[aria-sort="ascending"]')?.textContent).toContain(
'Title',
'Article',
)
})
})
@@ -730,7 +718,7 @@ describe('8g Mobile card list', () => {
)
expect(
Array.from(document.body.querySelectorAll('ul button')).filter((button) =>
/^(Title|Status|Created|Updated)$/.test(button.textContent?.trim() ?? ''),
/^(Article|Updated)$/.test(button.textContent?.trim() ?? ''),
),
).toHaveLength(0)
})
@@ -63,7 +63,7 @@ describe('Articles list: Read = Own with drafts of their own', () => {
expect(page()).toContain(
'Your role reads online articles and the drafts you created. The counts follow the same rule.',
)
expect(tabs()).toEqual(expect.arrayContaining(['All 5', 'Online 3', 'Drafts 2']))
expect(tabs()).toEqual(['All articles 5', 'Needs translation 0'])
})
it('keeps New article enabled when the role can create', async () => {
@@ -83,16 +83,16 @@ describe('Articles list: Read = Own with drafts of their own', () => {
total: 2,
page: 1,
size: 25,
counts: { all: 2, online: 2, draft: 0, suggestions: 0 },
counts: { all: 2, online: 2, draft: 0, suggestions: 0, missing: 0 },
})
await mountMeta('articles', '/article', { backend })
expect(tabs()).toEqual(expect.arrayContaining(['All 2', 'Online 2', 'Drafts 0']))
expect(tabs()).toEqual(['All articles 2', 'Needs translation 0'])
})
})
describe('Articles list: no drafts of their own', () => {
it('shows the own-drafts empty state on the Draft tab with the counts 3, 3, 0', async () => {
it('shows the own-drafts empty state on the Draft tab with the count 3', async () => {
await mountMeta('articles', '/article?status=draft', {
backend: backendFor(READ_OWN, BOARD_ARTICLES.slice(0, 3)),
})
@@ -101,7 +101,7 @@ describe('Articles list: no drafts of their own', () => {
expect(page()).toContain(
"Drafts you create appear here. Your role can't see drafts by other people.",
)
expect(tabs()).toEqual(expect.arrayContaining(['All 3', 'Online 3', 'Drafts 0']))
expect(tabs()).toEqual(['All articles 3', 'Needs translation 0'])
const action = Array.from(
document.body.querySelectorAll('main button, #dashboard-content button'),
).find((candidate) => textOf(candidate) === 'New article')
@@ -81,44 +81,22 @@ function currentQuery() {
return { ...router.currentRoute.value.query }
}
describe('Articles: the Authors column on every frame', () => {
describe('Articles: the Author column on every frame', () => {
it.each([
{ authors: 'no authors', names: [], avatars: 0, more: '', text: '—' },
{
authors: 'one author',
names: FIVE.slice(0, 1),
avatars: 1,
more: '',
text: '',
},
{
authors: 'three',
names: FIVE.slice(0, 3),
avatars: 3,
more: '',
text: '',
},
{ authors: 'five', names: FIVE, avatars: 3, more: '+2', text: '' },
])('$authors: $avatars avatar(s) and "$more"', async ({ names, avatars, more, text }) => {
{ authors: 'no authors', names: [], text: 'No authors' },
{ authors: 'one author', names: FIVE.slice(0, 1), text: 'Inès Duarte' },
{ authors: 'three', names: FIVE.slice(0, 3), text: 'Inès Duarte +2' },
{ authors: 'five', names: FIVE, text: 'Inès Duarte +4' },
])('$authors: reads "$text"', async ({ names, text }) => {
await mountMeta('articles', '/article', {
backend: backendWith('one', [withAuthors(1, names)]),
})
const authorsCell = cell(bodyRows()[0]!, 1)
const stack = authorsCell.querySelector('[data-testid="author-stack"]')
expect(stack?.querySelectorAll('[aria-hidden="true"]').length ?? 0).toBe(avatars)
expect(
authorsCell.querySelector('[data-testid="author-stack-more"]')?.textContent ?? '',
).toBe(more)
if (text) {
expect(textOf(authorsCell)).toBe(text)
expect(authorsCell.querySelector('[aria-label="No authors"]')).not.toBeNull()
}
expect(headers()[1]).toBe('Authors')
expect(authorsCell.querySelector('[title]')?.getAttribute('title')).toBe(names.join(', '))
expect(textOf(cell(bodyRows()[0]!, 2))).toBe(text)
expect(headers()[2]).toBe('Author')
})
it('draws avatars with the first name and no site on the mobile cards', async () => {
it('reads the first name and no site on the mobile cards', async () => {
await mountMeta('articles', '/article', {
width: MOBILE_WIDTH,
backend: backendWith('several', [
@@ -131,27 +109,24 @@ describe('Articles: the Authors column on every frame', () => {
document.body.querySelectorAll<HTMLElement>('li [data-row-title]'),
).map((title) => title.closest('li') as HTMLElement)
expect(textOf(cards[0])).toContain('Inès Duarte +2')
expect(
cards[0]!.querySelectorAll('[data-testid="author-stack"] [aria-hidden="true"]'),
).toHaveLength(3)
expect(textOf(cards[1])).toContain('No authors')
expect(textOf(cards[0])).not.toContain('Journal')
})
})
describe('Articles: the Site column and the Site filter', () => {
it('has neither with one site', async () => {
describe('Articles: the Site filter', () => {
it('has no Site filter with one site', async () => {
await mountMeta('articles', '/article', {
backend: backendWith('one', [withAuthors(1, ['Inès Duarte'])]),
})
expect(headers()).toEqual(['Title', 'Authors', 'Status', 'Created', 'Updated', ''])
expect(headers()).toEqual(['Article', 'Languages', 'Author', 'Updated', 'Actions'])
expect(siteSelect()).toBeNull()
expect(textOf(document.body)).not.toContain('All sites')
})
it.each(['owner', 'admin', 'verified'] as const)(
'shows a read-only Site column and a Site select for a %s with several sites',
'shows a Site select for a %s with several sites',
async (role) => {
const backend = backendWith('several', [
withAuthors(1, ['Inès Duarte'], { site_id: 2 }),
@@ -160,17 +135,7 @@ describe('Articles: the Site column and the Site filter', () => {
backend.me.access = accessFor(role)
await mountMeta('articles', '/article', { backend })
expect(headers()).toEqual([
'Title',
'Authors',
'Site',
'Status',
'Created',
'Updated',
'',
])
expect(bodyRows().map((row) => textOf(cell(row, 2)))).toEqual(['Journal', 'Renew'])
expect(cell(bodyRows()[0]!, 2).querySelector('button, a, input')).toBeNull()
expect(headers()).toEqual(['Article', 'Languages', 'Author', 'Updated', 'Actions'])
siteSelect()!.click()
await flushPromises()
expect(
@@ -179,12 +144,11 @@ describe('Articles: the Site column and the Site filter', () => {
},
)
it('drops the Site column and filter when the sites cannot be loaded', async () => {
it('drops the Site filter when the sites cannot be loaded', async () => {
const backend = backendWith('several', [withAuthors(1, ['Inès Duarte'])])
backend.responders['GET /v1/site/'] = failWith(500)
await mountMeta('articles', '/article', { backend })
expect(headers()).toEqual(['Title', 'Authors', 'Status', 'Created', 'Updated', ''])
expect(siteSelect()).toBeNull()
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
@@ -205,8 +169,6 @@ describe('Articles: choosing a site', () => {
await mountMeta('articles', '/article?page=3&size=5&status=online&sort=title:asc', {
backend,
})
const before = textOf(document.body.querySelector('[role="tablist"]'))
await chooseSite('Journal')
expect(currentQuery()).toEqual({
@@ -220,7 +182,9 @@ describe('Articles: choosing a site', () => {
expect(last.query.get('page')).toBe('1')
expect(last.query.get('status')).toBe('online')
expect(last.query.get('sort')).toBe('title:asc')
expect(textOf(document.body.querySelector('[role="tablist"]'))).toBe(before)
expect(textOf(document.body.querySelector('[role="tablist"]'))).toBe(
'All articles 20 Needs translation 0',
)
expect(siteSelect()!.textContent).toContain('Journal')
})
@@ -294,7 +258,7 @@ describe('Articles: choosing a site', () => {
act: async () => {
const tab = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="tab"]'),
).find((item) => textOf(item).startsWith('Drafts'))!
).find((item) => textOf(item).startsWith('Needs translation'))!
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
tab.click()
await flushPromises()
@@ -304,7 +268,7 @@ describe('Articles: choosing a site', () => {
action: 'changes the sort',
act: async () => {
Array.from(document.body.querySelectorAll<HTMLElement>('thead button'))
.find((item) => textOf(item).startsWith('Title'))!
.find((item) => textOf(item).startsWith('Article'))!
.click()
await flushPromises()
},
+48 -187
View File
@@ -2,7 +2,6 @@ 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,
@@ -66,12 +65,24 @@ function backendWith(articles: Article[], sites: 'one' | 'several' = 'one'): Met
return backend
}
function toggle() {
return document.body.querySelector<HTMLElement>('[data-testid="needs-attention-toggle"]')!
function seoSelect() {
return document.body.querySelector<HTMLElement>('button[aria-label="SEO"]')!
}
function chips(root: ParentNode = document.body) {
return Array.from(root.querySelectorAll<HTMLElement>('[data-testid="seo-status-chip"]'))
async function openSeoOptions() {
seoSelect().click()
await flushPromises()
return Array.from(document.body.querySelectorAll('[role="option"]')).map((option) =>
textOf(option),
)
}
async function chooseSeo(label: string) {
await openSeoOptions()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((option) => textOf(option).startsWith(label))!
.click()
await flushPromises()
}
function bodyRows() {
@@ -90,135 +101,19 @@ 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')
describe('The SEO filter', () => {
it('offers Any SEO state and Needs attention with the count of the response', async () => {
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)')
expect(seoSelect().textContent).toContain('Any SEO state')
const options = await openSeoOptions()
expect(options).toEqual(['Any SEO state', 'Needs attention 3'])
})
it('turns on keeping q, status, sort, site and size, and resets the page', async () => {
@@ -228,7 +123,7 @@ describe('Needs attention toggle', () => {
'/article?page=1&size=50&q=Article&status=online&sort=title:asc&site=renew',
{ backend },
)
await clickToggle()
await chooseSeo('Needs attention')
expect(currentQuery()).toEqual({
size: '50',
@@ -249,21 +144,22 @@ describe('Needs attention toggle', () => {
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()
await chooseSeo('Any SEO state')
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 () => {
it('opens a URL with the filter already on and shows its chip', async () => {
const backend = backendWith(boardArticles())
await mountMeta('articles', '/article?seo=suggestions&page=1', { backend })
expect(toggle().getAttribute('aria-checked')).toBe('true')
expect(seoSelect().textContent).toContain('Needs attention')
expect(mainCalls(backend)[0]!.query.get('seo')).toBe('suggestions')
expect(bodyRows()).toHaveLength(3)
expect(textOf(document.body.querySelector('[role="tablist"]'))).toContain('All 5')
expect(textOf(document.body.querySelector('[role="tablist"]'))).toContain('All articles 3')
expect(textOf(document.body)).toContain('SEO: Needs attention')
})
it.each([
@@ -278,19 +174,16 @@ describe('Needs attention toggle', () => {
await mountMeta('articles', `/article${query}`, { backend })
expect(currentQuery()).toEqual({})
expect(toggle().getAttribute('aria-checked')).toBe('false')
expect(seoSelect().textContent).toContain('Any SEO state')
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 }) => {
it('keeps ?seo=suggestions and resets the page when the user searches', async () => {
const backend = backendWith(boardArticles())
await mountMeta('articles', '/article?seo=suggestions&page=2&size=5', { backend })
await act()
await search('Article')
expect(currentQuery().seo).toBe('suggestions')
expect(currentQuery().page).toBeUndefined()
@@ -302,13 +195,20 @@ describe('Needs attention toggle', () => {
await mountMeta('articles', '/article', { backend })
expect(document.body.querySelector('button[aria-label="Site"]')).toBeNull()
await clickToggle()
await chooseSeo('Needs attention')
expect(currentQuery()).toEqual({ seo: 'suggestions' })
expect(bodyRows()).toHaveLength(3)
})
it('reads the options in French', async () => {
setLocale('fr')
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
expect(await openSeoOptions()).toEqual(['Tout état SEO', 'À traiter 3'])
})
})
describe('Needs attention count', () => {
describe('The Needs attention count', () => {
function envelope(counts: Record<string, number>) {
return { items: [], total: 0, page: 1, size: 25, counts }
}
@@ -317,12 +217,8 @@ describe('Needs attention count', () => {
const backend = backendWith(boardArticles(), 'several')
await mountMeta('articles', '/article?q=Article&status=online&site=renew', { backend })
expect(textOf(toggle())).toBe('Needs attention 3')
expect(
callsTo(backend, 'GET', '/v1/article/').filter(
(call) => !call.query.has('format') || call.query.get('size') === '5',
),
).toHaveLength(0)
expect((await openSeoOptions())[1]).toBe('Needs attention 3')
expect(countCalls(backend)).toHaveLength(0)
})
it.each([
@@ -331,18 +227,19 @@ describe('Needs attention count', () => {
])('shows $label', async ({ suggestions, label }) => {
const backend = backendWith(boardArticles())
backend.responders['GET /v1/article/'] = () =>
envelope({ all: 5, online: 2, draft: 3, suggestions })
envelope({ all: 5, online: 2, draft: 3, suggestions, missing: 0 })
await mountMeta('articles', '/article', { backend })
expect(textOf(toggle())).toBe(label)
expect((await openSeoOptions())[1]).toBe(label)
})
it('shows no count when counts.suggestions is absent', async () => {
const backend = backendWith(boardArticles())
backend.responders['GET /v1/article/'] = () => envelope({ all: 5, online: 2, draft: 3 })
backend.responders['GET /v1/article/'] = () =>
envelope({ all: 5, online: 2, draft: 3, missing: 0 })
await mountMeta('articles', '/article', { backend })
expect(textOf(toggle())).toBe('Needs attention')
expect((await openSeoOptions())[1]).toBe('Needs attention')
})
it('shows no count before the first response arrives', async () => {
@@ -350,7 +247,7 @@ describe('Needs attention count', () => {
backend.responders['GET /v1/article/'] = () => new Promise(() => {})
await mountMeta('articles', '/article', { backend })
expect(textOf(toggle())).toBe('Needs attention')
expect((await openSeoOptions())[1]).toBe('Needs attention')
})
it.each([
@@ -361,45 +258,9 @@ describe('Needs attention count', () => {
backend.responders['GET /v1/article/'] = responder
await mountMeta('articles', '/article', { backend })
expect(textOf(toggle())).toBe('Needs attention')
expect((await openSeoOptions())[1]).toBe('Needs attention')
expect(currentQuery()).toEqual({})
})
it('keeps the last count while a new request is in flight', async () => {
const backend = backendWith(boardArticles(), 'several')
await mountMeta('articles', '/article', { backend })
expect(textOf(toggle())).toBe('Needs attention 3')
backend.responders['GET /v1/article/'] = () => new Promise(() => {})
await clickTab('Drafts')
expect(textOf(toggle())).toBe('Needs attention 3')
})
it('does not change when q, tab, site, sort, page or size change', async () => {
const backend = backendWith(
Array.from({ length: 30 }, (_, index) => withSeo(index + 1, 'suggestions', 1)),
'several',
)
await mountMeta('articles', '/article?size=5', { backend })
expect(textOf(toggle())).toBe('Needs attention 30')
document.body.querySelector<HTMLElement>('button[aria-label="Next page"]')!.click()
await flushPromises()
await clickTab('Drafts')
await search('Article 1')
expect(textOf(toggle())).toBe('Needs attention 30')
expect(countCalls(backend)).toHaveLength(0)
})
it('reads counts.suggestions 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', () => {
@@ -97,12 +97,10 @@ describe('Sites and authors across Editor, Articles and Profile', () => {
expect(useSessionStore().session?.author).toMatchObject({ id: 200 })
await navigate('/article')
expect(
stackRows()[0]!.querySelectorAll('[data-testid="author-stack"] [aria-hidden="true"]'),
).toHaveLength(1)
expect(textOf(stackRows()[0]!.children[2])).toBe('Zoe Lambert')
})
it('a verified account creates its profile from the hint, saves, and the Authors column shows the avatar', async () => {
it('a verified account creates its profile from the hint, saves, and the Author column shows the name', async () => {
const backend = createMetaBackend({
sites: renewSites().slice(0, 1),
access: accessFor('verified'),
@@ -118,9 +116,7 @@ describe('Sites and authors across Editor, Articles and Profile', () => {
expect(patchCalls(backend)[0]!.body).toEqual({ author_ids: [300] })
await navigate('/article')
expect(
stackRows()[0]!.querySelectorAll('[data-testid="author-stack"] [aria-hidden="true"]'),
).toHaveLength(1)
expect(textOf(stackRows()[0]!.children[2])).toBe('Marta Alves')
await navigate('/profile')
expect(textOf(document.body)).toContain('marta-alves')
})
+118 -1
View File
@@ -1,4 +1,11 @@
import type { Article, ArticlePage, ArticleStatus } from '@/composables/useArticles'
import type {
Article,
ArticleCapability,
ArticlePage,
ArticleStatus,
StoryItem,
StoryPage,
} from '@/composables/useArticles'
export interface EnvelopeParams {
page?: number
@@ -8,6 +15,28 @@ export interface EnvelopeParams {
sort?: string
site?: string
seo?: '' | 'suggestions'
lang?: string
tab?: '' | 'missing'
author?: number
}
export const ALL_CAPABILITIES: ArticleCapability[] = [
'read',
'edit',
'publish',
'delete',
'create_translation',
]
export function storyOf(article: Article, capabilities = ALL_CAPABILITIES): StoryItem {
return {
...article,
story_updated_at: article.updated_at,
story_authors: article.authors ?? [],
coverage: [],
missing_langs: [],
capabilities,
}
}
function fold(value: string) {
@@ -42,6 +71,41 @@ export function articleEnvelope(all: Article[], params: EnvelopeParams = {}): Ar
}
}
export function storyEnvelope(all: Article[], params: EnvelopeParams = {}): StoryPage {
const { page = 1, size = 25, q = '', status = '', sort = '', seo = '' } = params
const matching = all
.filter(
(article) =>
!seo ||
(article.seo_status === 'suggestions' && (article.seo_suggestions ?? 0) > 0),
)
.filter((article) => !q || fold(article.title).includes(fold(q)))
const filtered = matching.filter(
(article) => !status || article.is_online === (status === 'online'),
)
if (sort) {
const [name, direction] = sort.split(':') as [string, string]
const field = (name === 'status' ? 'is_online' : name) as keyof Article
filtered.sort((left, right) => {
const order = String(left[field]).localeCompare(String(right[field]))
return direction === 'desc' ? -order : order
})
}
const online = matching.filter((article) => article.is_online).length
return {
items: filtered.slice((page - 1) * size, page * size).map((article) => storyOf(article)),
total: filtered.length,
page,
size,
counts: {
all: matching.length,
online,
draft: matching.length - online,
missing: 0,
},
}
}
export function articleListUrl(params: EnvelopeParams = {}) {
const { page = 1, size = 25, q = '', status = '', sort = '', site = '', seo = '' } = params
const query = new URLSearchParams({
@@ -81,5 +145,58 @@ export function parseListUrl(url: string): EnvelopeParams {
sort: query.get('sort') ?? '',
...(query.get('site') ? { site: query.get('site') ?? '' } : {}),
...(query.get('seo') ? { seo: 'suggestions' as const } : {}),
...(query.get('lang') ? { lang: query.get('lang') ?? '' } : {}),
...(query.get('tab') ? { tab: 'missing' as const } : {}),
...(query.get('author') ? { author: Number(query.get('author')) } : {}),
}
}
export function storyListUrl(params: EnvelopeParams = {}) {
const {
page = 1,
size = 25,
q = '',
status = '',
sort = '',
site = '',
seo = '',
lang = '',
tab = '',
author = 0,
} = params
const query = new URLSearchParams({
format: 'html',
page: String(page),
size: String(size),
})
if (q) {
query.set('q', q)
}
if (status) {
query.set('status', status)
}
if (sort) {
query.set('sort', sort)
}
if (site) {
query.set('site', site)
}
if (seo) {
query.set('seo', seo)
}
query.set('group', 'story')
if (lang) {
query.set('lang', lang)
}
if (tab) {
query.set('tab', tab)
}
if (author) {
query.set('author', String(author))
}
return `/v1/article/?${query.toString()}`
}
export function storyListRoute(params: EnvelopeParams = {}) {
return `GET ${storyListUrl(params)}`
}
+19 -7
View File
@@ -5,7 +5,8 @@ import uiPlugin from '@nuxt/ui/vue-plugin'
import App from '@/App.vue'
import router from '@/router'
import { ApiError, useApi, type MeAuthor, type MeResult } from '@/composables/useApi'
import type { Article, Byline } from '@/composables/useArticles'
import type { Article, ArticleCapability, Byline } from '@/composables/useArticles'
import { storyOf } from './articleEnvelope'
import type { Author } from '@/composables/useAuthors'
import type { Site } from '@/composables/useSites'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
@@ -39,6 +40,7 @@ export type MetaRole = 'owner' | 'admin' | 'verified'
export interface MetaBackend extends Backend {
article: Article
articles: Article[]
capabilitiesFor?: (article: Article) => ArticleCapability[]
me: MeResult
meAuthorSlug?: string
meSlugAdjusted?: boolean
@@ -169,31 +171,41 @@ function listArticles(backend: MetaBackend, query: URLSearchParams) {
const page = Number(query.get('page') ?? 1)
const siteKey = query.get('site')
const status = query.get('status')
const grouped = query.get('group') === 'story'
const q = (query.get('q') ?? '').toLowerCase()
const siteId = backend.sites.find((site) => site.key === siteKey)?.id
const matching = backend.articles
const base = backend.articles
.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
const matching = base.filter(
(article) => !status || article.is_online === (status === 'online'),
)
const countable = grouped ? base : backend.articles
const online = countable.filter((article) => article.is_online).length
const rows = matching.slice((page - 1) * size, page * size)
return {
items: structuredClone(matching.slice((page - 1) * size, page * size)),
items: structuredClone(
grouped
? rows.map((article) => storyOf(article, backend.capabilitiesFor?.(article)))
: rows,
),
total: matching.length,
page,
size,
counts: {
all: backend.articles.length,
all: countable.length,
online,
draft: backend.articles.length - online,
draft: countable.length - online,
suggestions: backend.articles.filter(
(article) =>
article.seo_status === 'suggestions' && (article.seo_suggestions ?? 0) > 0,
).length,
...(grouped ? { missing: 0 } : {}),
},
}
}
@@ -107,6 +107,6 @@ describe('Every state has a setup', () => {
describe('Existing screens are unaffected', () => {
it('reports the parser as the only discovery for localization screens', () => {
const names = Object.keys(visualStates).filter((name) => name.startsWith('localization-'))
expect(names).toEqual(['localization-site-languages'])
expect(names).toEqual(['localization-site-languages', 'localization-list'])
})
})
+120
View File
@@ -0,0 +1,120 @@
import type { Page } from 'playwright'
import { accessFor } from '../src/test/access'
import {
siteLanguagesBody,
storyAuthorsBody,
storyPageFor,
type StoryViewer,
} from '../src/test/storyLists'
import type { ApiMock, VisualState } from './states'
const ok = (body: unknown): ApiMock => ({ status: 200, body })
function site(id: number, key: string, name: string, isDefault = false) {
return {
id,
name,
key,
site_name: name,
title_template: '%title% | %site%',
default_description: '',
base_url: `https://${key}.example.com`,
default_social_image_id: null,
default_robots: 'index',
locale: 'en',
is_default: isDefault,
article_count: 0,
public_url_pattern: null,
}
}
const sites = [
site(1, 'journal', 'Journal', true),
site(2, 'atelier', 'Atelier'),
site(3, 'press', 'Press'),
]
function session(access: unknown): ApiMock {
return {
status: 200,
body: {
id: 1,
is_verified: true,
username: 'marta',
email: 'marta@renew.example',
display_name: 'Marta Alves',
access,
},
}
}
function listApi(
overrides: Record<string, ApiMock> = {},
viewer: StoryViewer = {},
access: unknown = accessFor('owner'),
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': session(access),
'GET /v1/site/': ok(sites),
'GET /v1/site/journal/languages': {
status: 200,
bodyFor: () => siteLanguagesBody('journal'),
},
'GET /v1/site/atelier/languages': {
status: 200,
bodyFor: () => siteLanguagesBody('atelier'),
},
'GET /v1/site/press/languages': { status: 200, bodyFor: () => siteLanguagesBody('press') },
'GET /v1/author/': { status: 200, bodyFor: () => storyAuthorsBody() },
'GET /v1/article/': { status: 200, bodyFor: (url) => storyPageFor(url, viewer) },
...overrides,
}
}
const clockSensitive = (path: string): VisualState => ({ path, skip: 'clock-sensitive' })
async function scrollTabsRight(page: Page) {
const button = page.getByRole('button', { name: 'Scroll tabs right', exact: true })
if (await button.isVisible()) {
await button.click()
await page.mouse.move(0, 0)
}
}
export const articleListStates: Record<string, VisualState> = {
'loc.l7.all': clockSensitive('/article?site=journal&size=5'),
'loc.l7.page2': { path: '/article?site=journal&page=2&size=5', api: listApi() },
'loc.l7.fr': clockSensitive('/article?site=journal&lang=fr&size=5'),
'loc.l7.frMissing': {
path: '/article?site=journal&lang=fr&tab=missing&size=5',
api: listApi(),
},
'loc.l7.emptyMissing': {
path: '/article?site=press&lang=fr&tab=missing&size=5',
api: listApi(),
},
'loc.l7.noResults': { path: '/article?site=journal&q=zinc&size=5', api: listApi() },
'loc.l7.frLoading': {
path: '/article?site=journal&lang=fr&size=5',
api: listApi({ 'GET /v1/article/': { status: 200, hang: true } }),
steps: async (page) => {
await page.waitForTimeout(400)
},
},
'loc.l7.listFail': {
path: '/article?site=journal&size=5',
api: listApi({ 'GET /v1/article/': { status: 500, body: 'Failed to retrieve articles' } }),
},
'loc.l7.limited': clockSensitive('/article?site=journal&size=5'),
'loc.l7.review': { path: '/article?site=journal&size=5', skip: 'pending' },
'loc.l7.author': clockSensitive('/article?site=journal&author=1&size=5'),
'loc.l7.langFilter': clockSensitive('/article?site=journal&size=5'),
'loc.l7.rowMenu': clockSensitive('/article?site=journal&size=5'),
'loc.l7.filters': clockSensitive('/article?site=journal&lang=fr&status=draft&size=5'),
'loc.l7.tabsScroll': {
path: '/article?site=journal&lang=fr&tab=missing&size=5',
api: listApi(),
steps: scrollTabsRight,
},
'loc.l7.long': clockSensitive('/article?site=atelier&status=draft&seo=suggestions&size=5'),
}
+7
View File
@@ -1517,6 +1517,13 @@ async function main() {
lines.push(`PENDING ${state} · ${viewport} · ${theme}: slice B2`)
continue
}
if (skip === 'superseded') {
skipped++
lines.push(
`SKIP ${state} · ${viewport} · ${theme}: board describes the list replaced by localization-list`,
)
continue
}
if (skip) {
skipped++
lines.push(`SKIP ${state} · ${viewport} · ${theme}: ${skip}`)
+39 -233
View File
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'
import type { Page } from 'playwright'
import { accessFor, customAccess, roleRefFor } from '../src/test/access'
import { approvedExceptions } from './approvedExceptions'
import { articleListStates } from './articleListStates'
import { localizationStates } from './localizationStates'
import { rolesStates } from './rolesStates'
@@ -22,7 +23,7 @@ export interface DiffException {
export interface VisualState {
exception?: DiffException
skip?: 'clock-sensitive' | 'pending'
skip?: 'clock-sensitive' | 'pending' | 'superseded'
path: string
origin?: string
now?: string
@@ -957,21 +958,6 @@ function articleListMock(all: ListedArticle[], needsAttention?: number): ApiMock
return { status: 200, bodyFor: (url) => articleEnvelopeFor(all, url, needsAttention) }
}
function articlesFixtureWithImages() {
return articlesFixture().map((article) =>
article.id === 4
? {
...article,
images: [
{ id: 10, url: '/images/10.png' },
{ id: 11, url: '/images/11.png' },
{ id: 12, url: '/images/12.png' },
],
}
: article,
)
}
function articlesApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
return {
'GET /v1/auth/me': verifiedSession,
@@ -981,49 +967,7 @@ function articlesApi(overrides: Record<string, ApiMock> = {}): Record<string, Ap
}
}
function multiSiteArticlesApi(overrides: Record<string, ApiMock> = {}) {
return articlesApi({ 'GET /v1/site/': boardSiteList(4), ...overrides })
}
async function openSiteFilter(page: Page) {
await page.locator('button[aria-label="Site"]:visible').click()
await page.getByRole('option').first().waitFor()
await page.mouse.move(0, 0)
}
const clockSensitiveArticles = (path: string): VisualState => ({
path,
skip: 'clock-sensitive',
})
const pagination: Record<string, VisualState> = {
'First page': clockSensitiveArticles('/article'),
'Middle page': clockSensitiveArticles('/article?page=7'),
'Last page': clockSensitiveArticles('/article?page=13'),
'Single page': clockSensitiveArticles('/article'),
'Loading next page': clockSensitiveArticles('/article?page=6'),
'Error loading a page': clockSensitiveArticles('/article?page=6'),
'Page out of range': clockSensitiveArticles('/article?page=99'),
'Search resets to page 1': clockSensitiveArticles('/article?page=7'),
'Empty after search': {
path: '/article',
api: articlesApi(),
steps: async (page) => {
await page.getByPlaceholder('Search by title').fill('tiles')
await page.getByText('No articles match "tiles"').waitFor()
},
},
'Page size menu': clockSensitiveArticles('/article?page=2'),
'Focus-visible': clockSensitiveArticles('/article?page=7'),
}
async function openArticleRowMenu(page: Page, title: string) {
await page.getByRole('button', { name: `Actions for ${title}` }).click()
}
async function chooseArticleRowMenuItem(page: Page, label: string) {
await page.getByRole('menuitem', { name: label, exact: true }).click()
}
const supersededArticleState: VisualState = { path: '/article', skip: 'superseded' }
async function fillResetRequestEmail(page: Page, email: string) {
await page.getByPlaceholder('you@example.com').fill(email)
@@ -9505,180 +9449,41 @@ 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,
api: multiSiteArticlesApi(),
},
'Site filter open': {
path: '/article',
now: articlesNow,
api: multiSiteArticlesApi(),
steps: openSiteFilter,
},
'No articles on a site': {
path: '/article?site=archive',
now: articlesNow,
api: multiSiteArticlesApi(),
steps: async (page) => {
await page.getByText('No articles on "Archive"').waitFor()
},
},
Default: {
path: '/article',
now: articlesNow,
api: articlesApi(),
},
'Row menu open': {
path: '/article',
now: articlesNow,
api: articlesApi(),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
},
},
Loading: {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': { ...articleListMock(articlesFixture()), hang: true },
}),
},
Empty: {
path: '/article',
now: articlesNow,
api: articlesApi({ 'GET /v1/article/': articleListMock([]) }),
},
'No results': {
path: '/article',
now: articlesNow,
api: articlesApi(),
steps: async (page) => {
await page.getByPlaceholder('Search by title').fill('tiles')
await page.waitForTimeout(200)
},
},
'No drafts': {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': articleListMock(
articlesFixture()
.slice(0, 4)
.map((article) => ({ ...article, is_online: true })),
),
}),
steps: async (page) => {
await page.getByRole('tab', { name: /^Drafts/ }).focus()
await page.keyboard.press('Enter')
},
},
'Action in progress': {
path: '/article',
now: articlesNow,
api: articlesApi({
'PATCH /v1/article/4/publish': { status: 200, hang: true },
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Publish')
},
},
Published: clockSensitiveArticles('/article'),
'Action failed': {
path: '/article',
now: articlesNow,
api: articlesApi({ 'PATCH /v1/article/3/unpublish': { status: 500 } }),
steps: async (page) => {
await openArticleRowMenu(page, 'Before and after: the Rue Verte kitchen')
await chooseArticleRowMenuItem(page, 'Unpublish')
await page.getByText("Couldn't unpublish").first().waitFor()
},
},
'Row vanished (404)': {
path: '/article',
now: articlesNow,
api: articlesApi({ 'PATCH /v1/article/2/publish': { status: 404 } }),
steps: async (page) => {
await openArticleRowMenu(page, 'Choosing lime plaster for old stone walls')
await chooseArticleRowMenuItem(page, 'Publish')
await page.getByText('That article no longer exists').first().waitFor()
},
},
'Delete: confirm': {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': articleListMock(articlesFixtureWithImages()),
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Delete…')
await page.getByRole('alertdialog').waitFor()
},
},
'Delete: in progress': {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': articleListMock(articlesFixtureWithImages()),
'DELETE /v1/article/4': { status: 204, hang: true },
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Delete…')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete article' })
.click()
},
},
'Delete: failed': {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': articleListMock(articlesFixtureWithImages()),
'DELETE /v1/article/4': { status: 500 },
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Delete…')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete article' })
.click()
await page.getByText("Couldn't delete the article").waitFor()
},
},
Deleted: {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': articleListMock(articlesFixtureWithImages()),
'DELETE /v1/article/4': { status: 204 },
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Delete…')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete article' })
.click()
await page.getByText('Article deleted').first().waitFor()
},
},
},
articles: Object.fromEntries(
[
'First page',
'Middle page',
'Last page',
'Single page',
'Loading next page',
'Error loading a page',
'Page out of range',
'Search resets to page 1',
'Empty after search',
'Page size menu',
'Focus-visible',
'Status chips',
'Needs attention on',
'Needs attention: none',
'Several sites',
'Site filter open',
'No articles on a site',
'Default',
'Row menu open',
'Loading',
'Empty',
'No results',
'No drafts',
'Action in progress',
'Published',
'Action failed',
'Row vanished (404)',
'Delete: confirm',
'Delete: in progress',
'Delete: failed',
'Deleted',
].map((state) => [state, supersededArticleState]),
),
home: {
Default: {
path: '/home',
@@ -10541,4 +10346,5 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
),
'media-picker': pickerStates,
'localization-site-languages': localizationStates,
'localization-list': articleListStates,
}