feat: editorial article list
This commit is contained in:
1 parent
a0b3c58c52
commit
a671b81f80
103 files changed
+6493
-1805
No files matched your search
+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