feat: editorial article list
This commit is contained in:
1 parent
a0b3c58c52
commit
a671b81f80
103 files changed
+6493
-1805
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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})`)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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.',
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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.")
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user