feat: recent activity card on Home and Retention card on Settings
This commit is contained in:
1 parent
35db442cd3
commit
43469ef08d
33 files changed
+2748
-204
No files matched your search
+157
-134
@@ -7,6 +7,7 @@ import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import NewArticleModal from '@/components/NewArticleModal.vue'
|
||||
import RecentActivityCard from '@/components/RecentActivityCard.vue'
|
||||
import UpdateBanner from '@/components/UpdateBanner.vue'
|
||||
import { useArticles, type Article } from '@/composables/useArticles'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
@@ -180,7 +181,7 @@ onMounted(loadArticles)
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="flex max-w-244 flex-col gap-5 lg:gap-7">
|
||||
<div class="flex flex-col gap-5 lg:gap-7">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div class="flex min-w-50 flex-1 flex-col gap-1">
|
||||
<h1 data-testid="greeting" class="text-2xl font-semibold text-highlighted">
|
||||
@@ -205,146 +206,168 @@ onMounted(loadArticles)
|
||||
@dismiss="dismissBanner"
|
||||
/>
|
||||
|
||||
<template v-if="(loadState === 'loading' && showSkeleton) || loadState === 'default'">
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div
|
||||
v-for="tile in statTiles"
|
||||
:key="tile.label"
|
||||
data-testid="stat-tile"
|
||||
class="flex flex-col gap-1 border border-default bg-default p-4"
|
||||
<div
|
||||
class="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_--spacing(105)] lg:items-start"
|
||||
>
|
||||
<RecentActivityCard class="lg:order-2" />
|
||||
<div class="flex min-w-0 flex-col gap-5 lg:order-1 lg:gap-7">
|
||||
<template
|
||||
v-if="(loadState === 'loading' && showSkeleton) || loadState === 'default'"
|
||||
>
|
||||
<template v-if="loadState === 'loading'">
|
||||
<USkeleton class="h-7 w-10 rounded-none bg-accented" />
|
||||
<USkeleton class="h-3 w-17.5 rounded-none bg-accented" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-2xl font-semibold text-highlighted">{{
|
||||
formatNumber(tile.value)
|
||||
}}</span>
|
||||
<span class="text-muted">{{ tile.label }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div
|
||||
v-for="tile in statTiles"
|
||||
:key="tile.label"
|
||||
data-testid="stat-tile"
|
||||
class="flex flex-col gap-1 border border-default bg-default p-4"
|
||||
>
|
||||
<template v-if="loadState === 'loading'">
|
||||
<USkeleton class="h-7 w-10 rounded-none bg-accented" />
|
||||
<USkeleton class="h-3 w-17.5 rounded-none bg-accented" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-2xl font-semibold text-highlighted">{{
|
||||
formatNumber(tile.value)
|
||||
}}</span>
|
||||
<span class="text-muted">{{ tile.label }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="flex flex-col gap-2.5">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<h2 class="text-lg font-semibold text-highlighted">
|
||||
{{ t('Recently updated') }}
|
||||
</h2>
|
||||
<RouterLink to="/article" class="text-toned underline underline-offset-3">
|
||||
{{ t('All articles') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div
|
||||
v-if="loadState === 'loading'"
|
||||
aria-busy="true"
|
||||
class="border border-default bg-default"
|
||||
<section class="flex flex-col gap-2.5">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<h2 class="text-lg font-semibold text-highlighted">
|
||||
{{ t('Recently updated') }}
|
||||
</h2>
|
||||
<RouterLink
|
||||
to="/article"
|
||||
class="text-toned underline underline-offset-3"
|
||||
>
|
||||
{{ t('All articles') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div
|
||||
v-if="loadState === 'loading'"
|
||||
aria-busy="true"
|
||||
class="border border-default bg-default"
|
||||
>
|
||||
<div
|
||||
v-for="n in RECENT_LIST_SIZE"
|
||||
:key="n"
|
||||
class="flex min-h-13 items-center gap-3 border-t border-muted px-3 py-2 lg:min-h-11"
|
||||
>
|
||||
<USkeleton
|
||||
class="h-3 max-w-[60%] flex-1 rounded-none bg-accented"
|
||||
/>
|
||||
<USkeleton class="h-5 w-14 rounded-none bg-accented" />
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
v-else
|
||||
data-testid="recent-list"
|
||||
class="border border-default bg-default"
|
||||
>
|
||||
<li v-for="article in recentArticles" :key="article.id">
|
||||
<RouterLink
|
||||
:to="`/article/${article.id}/edit`"
|
||||
class="flex min-h-13 items-center gap-3 border-t border-muted px-3 py-2 lg:min-h-11"
|
||||
>
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate font-medium text-highlighted"
|
||||
>
|
||||
{{ article.title }}
|
||||
</span>
|
||||
<ArticleStatusBadge :online="article.is_online" />
|
||||
<span class="hidden w-27.5 text-right text-muted lg:inline">
|
||||
{{ formatRelativeDate(article.updated_at) }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<UEmpty
|
||||
v-else-if="loadState === 'empty'"
|
||||
variant="outline"
|
||||
icon="i-lucide-file-text"
|
||||
:title="t('Write your first article')"
|
||||
:description="
|
||||
t(
|
||||
'It starts as a draft, so nothing appears on your site until you publish it.',
|
||||
)
|
||||
"
|
||||
:ui="{
|
||||
root: 'gap-3 rounded-none border border-default px-6 py-12 ring-0 sm:px-6 sm:py-12 lg:px-6 lg:py-12',
|
||||
header: 'max-w-none gap-3',
|
||||
avatar: 'mb-0 size-11 rounded-none bg-elevated text-xl text-toned',
|
||||
title: 'text-[1.125rem] font-semibold',
|
||||
description: 'max-w-85 text-base text-wrap text-muted',
|
||||
body: 'max-w-none',
|
||||
}"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
icon="i-lucide-plus"
|
||||
class="h-11 px-4 lg:h-8"
|
||||
@click="openNewArticleModal"
|
||||
>
|
||||
{{ t('New article') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UEmpty>
|
||||
|
||||
<UAlert
|
||||
v-else-if="loadState === 'error'"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
:title="t('Couldn\'t load your articles')"
|
||||
:description="t('Check your connection and try again.')"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
class="h-7 bg-default px-2.5 text-xs text-highlighted"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="loadArticles"
|
||||
>
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<div class="flex items-start gap-3.5 border border-default bg-default p-4">
|
||||
<div
|
||||
v-for="n in RECENT_LIST_SIZE"
|
||||
:key="n"
|
||||
class="flex min-h-13 items-center gap-3 border-t border-muted px-3 py-2 lg:min-h-11"
|
||||
class="flex size-9 shrink-0 items-center justify-center bg-secondary-200 text-primary-900 dark:bg-secondary-300"
|
||||
>
|
||||
<USkeleton class="h-3 max-w-[60%] flex-1 rounded-none bg-accented" />
|
||||
<USkeleton class="h-5 w-14 rounded-none bg-accented" />
|
||||
<UIcon name="i-lucide-plug" class="size-4.5" />
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<h2 class="text-base/5 font-semibold text-highlighted">
|
||||
{{ t('Connect your website') }}
|
||||
</h2>
|
||||
<p class="text-pretty text-muted">
|
||||
{{
|
||||
t(
|
||||
'Your site reads published articles from RenewCMS. Share this guide with whoever builds it.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<RouterLink
|
||||
to="/integration"
|
||||
class="mt-1 self-start font-medium text-highlighted underline underline-offset-3"
|
||||
>
|
||||
{{ t('Open the guide') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<ul v-else data-testid="recent-list" class="border border-default bg-default">
|
||||
<li v-for="article in recentArticles" :key="article.id">
|
||||
<RouterLink
|
||||
:to="`/article/${article.id}/edit`"
|
||||
class="flex min-h-13 items-center gap-3 border-t border-muted px-3 py-2 lg:min-h-11"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate font-medium text-highlighted">
|
||||
{{ article.title }}
|
||||
</span>
|
||||
<ArticleStatusBadge :online="article.is_online" />
|
||||
<span class="hidden w-27.5 text-right text-muted lg:inline">
|
||||
{{ formatRelativeDate(article.updated_at) }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<UEmpty
|
||||
v-else-if="loadState === 'empty'"
|
||||
variant="outline"
|
||||
icon="i-lucide-file-text"
|
||||
:title="t('Write your first article')"
|
||||
:description="
|
||||
t('It starts as a draft, so nothing appears on your site until you publish it.')
|
||||
"
|
||||
:ui="{
|
||||
root: 'gap-3 rounded-none border border-default px-6 py-12 ring-0 sm:px-6 sm:py-12 lg:px-6 lg:py-12',
|
||||
header: 'max-w-none gap-3',
|
||||
avatar: 'mb-0 size-11 rounded-none bg-elevated text-xl text-toned',
|
||||
title: 'text-[1.125rem] font-semibold',
|
||||
description: 'max-w-85 text-base text-wrap text-muted',
|
||||
body: 'max-w-none',
|
||||
}"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
icon="i-lucide-plus"
|
||||
class="h-11 px-4 lg:h-8"
|
||||
@click="openNewArticleModal"
|
||||
>
|
||||
{{ t('New article') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UEmpty>
|
||||
|
||||
<UAlert
|
||||
v-else-if="loadState === 'error'"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
:title="t('Couldn\'t load your articles')"
|
||||
:description="t('Check your connection and try again.')"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
class="h-7 bg-default px-2.5 text-xs text-highlighted"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="loadArticles"
|
||||
>
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<div class="flex items-start gap-3.5 border border-default bg-default p-4">
|
||||
<div
|
||||
class="flex size-9 shrink-0 items-center justify-center bg-secondary-200 text-primary-900 dark:bg-secondary-300"
|
||||
>
|
||||
<UIcon name="i-lucide-plug" class="size-4.5" />
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<h2 class="text-base/5 font-semibold text-highlighted">
|
||||
{{ t('Connect your website') }}
|
||||
</h2>
|
||||
<p class="text-pretty text-muted">
|
||||
{{
|
||||
t(
|
||||
'Your site reads published articles from RenewCMS. Share this guide with whoever builds it.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<RouterLink
|
||||
to="/integration"
|
||||
class="mt-1 self-start font-medium text-highlighted underline underline-offset-3"
|
||||
>
|
||||
{{ t('Open the guide') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,10 +7,11 @@ import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import MediaCleanupCard from '@/components/MediaCleanupCard.vue'
|
||||
import RestartOverlay from '@/components/RestartOverlay.vue'
|
||||
import RetentionCard from '@/components/RetentionCard.vue'
|
||||
import UpdatesCard from '@/components/UpdatesCard.vue'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useRetryAfter } from '@/composables/useRetryAfter'
|
||||
import { useSettings } from '@/composables/useSettings'
|
||||
import { useSettings, type WorkspaceSettings } from '@/composables/useSettings'
|
||||
import type { RestartKind, UpdateStatus } from '@/composables/useUpdate'
|
||||
import { reloadPage } from '@/lib/reloadPage'
|
||||
import { displayVersion, isCheckDue } from '@/lib/updateFormat'
|
||||
@@ -52,7 +53,9 @@ const retry = useRetryAfter()
|
||||
|
||||
const updatesCard = ref<InstanceType<typeof UpdatesCard> | null>(null)
|
||||
const loadState = ref<LoadState>('loading')
|
||||
const version = ref('')
|
||||
const settings = ref<WorkspaceSettings | null>(null)
|
||||
const version = computed(() => settings.value?.version ?? '')
|
||||
const isOwner = computed(() => session.session?.role === 'owner')
|
||||
const showSkeleton = ref(false)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@@ -76,8 +79,7 @@ async function loadSettings() {
|
||||
}, SKELETON_DELAY_MS)
|
||||
|
||||
try {
|
||||
const settings = await getSettings()
|
||||
version.value = settings.version
|
||||
settings.value = await getSettings()
|
||||
loadState.value = 'ready'
|
||||
} catch (error) {
|
||||
if (!isHandledGlobally(error)) {
|
||||
@@ -458,6 +460,15 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
|
||||
<MediaCleanupCard />
|
||||
|
||||
<RetentionCard
|
||||
v-if="isOwner"
|
||||
:settings="settings"
|
||||
:load-state="loadState"
|
||||
:show-skeleton="showSkeleton"
|
||||
@saved="settings = $event"
|
||||
@reload="loadSettings"
|
||||
/>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -0,0 +1,384 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import type { ActivityItem } from '@/lib/activity'
|
||||
import HomeView from '../HomeView.vue'
|
||||
import { anonymous, makeItem, target } from './activityFixtures'
|
||||
|
||||
const ACTIVITY_ROUTE = 'GET /v1/activity?size=5'
|
||||
const ARTICLES_ROUTE = 'GET /v1/article/?format=html'
|
||||
|
||||
type Handler = () => Response | Promise<Response>
|
||||
|
||||
function response(status: number, body: unknown = null) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: () => null },
|
||||
json: async () => body,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
function page(items: ActivityItem[], total = items.length) {
|
||||
return response(200, { items, total, page: 1, size: 5 })
|
||||
}
|
||||
|
||||
function recentItems(count: number) {
|
||||
const created = new Date(Date.now() - 30_000).toISOString()
|
||||
return Array.from({ length: count }, (_, index) =>
|
||||
makeItem({ id: index + 1, created_at: created }),
|
||||
)
|
||||
}
|
||||
|
||||
const articles = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'First article',
|
||||
body: '',
|
||||
images: [],
|
||||
is_online: true,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-06T00:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
let wrapper: Awaited<ReturnType<typeof mountHome>> | null = null
|
||||
let calls: string[] = []
|
||||
|
||||
async function mountHome(role: 'owner' | 'admin' | undefined, handlers: Record<string, Handler>) {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', role }
|
||||
session.status = 'ready'
|
||||
calls = []
|
||||
global.fetch = vi.fn((url: string, init?: RequestInit) => {
|
||||
const key = `${init?.method ?? 'GET'} ${url}`
|
||||
calls.push(key)
|
||||
const handler = handlers[key]
|
||||
if (!handler) {
|
||||
return Promise.reject(new TypeError(`Unexpected fetch call: ${key}`))
|
||||
}
|
||||
return Promise.resolve(handler())
|
||||
}) as unknown as typeof fetch
|
||||
await router.push('/home')
|
||||
const mounted = mount(HomeView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
wrapper = mounted
|
||||
await flushPromises()
|
||||
return mounted
|
||||
}
|
||||
|
||||
function card() {
|
||||
return wrapper!.find('[data-testid="recent-activity-card"]')
|
||||
}
|
||||
|
||||
function rows() {
|
||||
return wrapper!.findAll('[data-testid="recent-activity-row"]')
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
vi.useRealTimers()
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe('Home, owner or admin, the last five events with the actor name', () => {
|
||||
it.each(['owner', 'admin'] as const)('shows five rows with names for %s', async (role) => {
|
||||
await mountHome(role, {
|
||||
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
expect(card().find('h2').text()).toBe('Recent activity')
|
||||
const viewAll = card().find('a[href="/activity"]')
|
||||
expect(viewAll.text()).toBe('View all')
|
||||
expect(rows()).toHaveLength(5)
|
||||
const first = rows()[0]!
|
||||
expect(first.find('[data-testid="activity-sentence"]').text()).toBe(
|
||||
'Published the article Spring release',
|
||||
)
|
||||
expect(first.find('[data-testid="activity-sentence"] .font-semibold').text()).toBe(
|
||||
'Spring release',
|
||||
)
|
||||
expect(first.find('[data-testid="recent-activity-meta"]').text()).toMatch(
|
||||
/^Marta Alves · .+/,
|
||||
)
|
||||
expect(first.text()).toContain('MA')
|
||||
expect(calls.filter((call) => call === ACTIVITY_ROUTE)).toHaveLength(1)
|
||||
expect(calls.some((call) => call.includes('actor'))).toBe(false)
|
||||
expect(calls.some((call) => call.includes('summary') || call.includes('export'))).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home, regular user, own events only and no actor name', () => {
|
||||
it.each([['editor' as const], [undefined]])('shows only the time for %s', async (role) => {
|
||||
await mountHome(role as undefined, {
|
||||
[ACTIVITY_ROUTE]: () => page(recentItems(3)),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
expect(card().find('h2').text()).toBe('Your recent activity')
|
||||
expect(rows()).toHaveLength(3)
|
||||
const meta = rows()[0]!.find('[data-testid="recent-activity-meta"]').text()
|
||||
expect(meta).not.toContain('Marta')
|
||||
expect(meta).not.toContain('·')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home rows, failure and security markers', () => {
|
||||
it('shows the badge and the shield as the outcome and security dictate', async () => {
|
||||
const items = [
|
||||
makeItem({ id: 1 }),
|
||||
makeItem({ id: 2, outcome: 'failed' }),
|
||||
makeItem({ id: 3, security: true }),
|
||||
makeItem({ id: 4, outcome: 'failed', security: true }),
|
||||
]
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => page(items),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
const marks = rows().map((row) => ({
|
||||
badge: row.text().includes('Failed'),
|
||||
shield: row.find('[data-testid="recent-activity-security"]').exists(),
|
||||
}))
|
||||
expect(marks).toEqual([
|
||||
{ badge: false, shield: false },
|
||||
{ badge: true, shield: false },
|
||||
{ badge: false, shield: true },
|
||||
{ badge: true, shield: false },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home rows, unknown action, failed login and not interactive', () => {
|
||||
it('renders an unknown action and a failed login without throwing', async () => {
|
||||
const items = [
|
||||
makeItem({ id: 1, action: 'article.archive', target: null }),
|
||||
makeItem({
|
||||
id: 2,
|
||||
action: 'auth.login_failed',
|
||||
category: 'auth',
|
||||
actor: anonymous('alex@example.com'),
|
||||
target: null,
|
||||
outcome: 'failed',
|
||||
}),
|
||||
]
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => page(items),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
expect(rows()[0]!.text()).toContain('article.archive')
|
||||
expect(rows()[1]!.text()).toContain('Failed login attempt for alex@example.com')
|
||||
expect(rows()[1]!.text()).toContain('Failed')
|
||||
})
|
||||
|
||||
it('has no button or link inside a row and keeps the route on a click', async () => {
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => page(recentItems(2)),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
const row = rows()[0]!
|
||||
await row.trigger('click')
|
||||
|
||||
expect(row.find('a, button').exists()).toBe(false)
|
||||
expect(router.currentRoute.value.path).toBe('/home')
|
||||
})
|
||||
|
||||
it('clamps a long sentence to two lines', async () => {
|
||||
const items = [makeItem({ id: 1, target: target('article', '8', 'T'.repeat(150)) })]
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => page(items),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
expect(rows()[0]!.find('.line-clamp-2').exists()).toBe(true)
|
||||
expect(rows()[0]!.find('[data-testid="recent-activity-meta"]').classes()).toContain(
|
||||
'truncate',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home card, skeleton rows only after 150 ms', () => {
|
||||
it('draws nothing, then five skeletons, then the rows', async () => {
|
||||
vi.useFakeTimers()
|
||||
let resolve: (() => void) | null = null
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () =>
|
||||
new Promise((done) => {
|
||||
resolve = () => done(page(recentItems(5)))
|
||||
}),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
expect(card().attributes('aria-busy')).toBeUndefined()
|
||||
expect(wrapper!.findAll('[data-testid="recent-activity-skeleton"]')).toHaveLength(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
|
||||
expect(card().attributes('aria-busy')).toBe('true')
|
||||
expect(wrapper!.findAll('[data-testid="recent-activity-skeleton"]')).toHaveLength(5)
|
||||
|
||||
resolve?.()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await flushPromises()
|
||||
|
||||
expect(card().attributes('aria-busy')).toBeUndefined()
|
||||
expect(rows()).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('never shows a skeleton for a fast response', async () => {
|
||||
vi.useFakeTimers()
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
|
||||
expect(wrapper!.findAll('[data-testid="recent-activity-skeleton"]')).toHaveLength(0)
|
||||
expect(rows()).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home card, error shows a retryable alert that leaves the page alone', () => {
|
||||
it.each([
|
||||
['500', () => response(500)],
|
||||
['403', () => response(403)],
|
||||
['network', () => Promise.reject(new TypeError('offline'))],
|
||||
])('shows the alert on %s and recovers on Try again', async (_name, failure) => {
|
||||
let attempt = 0
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => {
|
||||
attempt += 1
|
||||
return attempt === 1 ? (failure() as Response) : page(recentItems(2))
|
||||
},
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
const alert = card().find('[role="alert"]')
|
||||
expect(alert.text()).toContain("Couldn't load recent activity")
|
||||
expect(wrapper!.find('[data-testid="stat-tile"]').exists()).toBe(true)
|
||||
expect(wrapper!.text()).toContain('Connect your website')
|
||||
|
||||
const retry = alert.findAll('button').find((button) => button.text() === 'Try again')
|
||||
await retry!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(calls.filter((call) => call === ACTIVITY_ROUTE)).toHaveLength(2)
|
||||
expect(card().find('[role="alert"]').exists()).toBe(false)
|
||||
expect(rows()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home card, empty when there are no events yet', () => {
|
||||
it.each([
|
||||
['owner' as const, 'Actions on your site will appear here.'],
|
||||
[undefined, 'Your actions will appear here.'],
|
||||
])('says so for %s', async (role, text) => {
|
||||
await mountHome(role as 'owner', {
|
||||
[ACTIVITY_ROUTE]: () => page([], 0),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
const empty = card().find('[data-testid="recent-activity-empty"]')
|
||||
expect(empty.text()).toContain('No activity yet')
|
||||
expect(empty.text()).toContain(text)
|
||||
expect(card().find('a[href="/activity"]').text()).toBe('View all')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home card and article list are independent', () => {
|
||||
it('shows the articles alert and the activity rows together', async () => {
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
|
||||
[ARTICLES_ROUTE]: () => response(500),
|
||||
})
|
||||
|
||||
expect(wrapper!.text()).toContain("Couldn't load your articles")
|
||||
expect(rows()).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('shows the articles empty state and the card error together', async () => {
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => response(500),
|
||||
[ARTICLES_ROUTE]: () => response(200, []),
|
||||
})
|
||||
|
||||
expect(wrapper!.text()).toContain('Write your first article')
|
||||
expect(card().text()).toContain("Couldn't load recent activity")
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home layout is two columns from lg and stacked below', () => {
|
||||
it('uses the lg two-column grid with the 420 px column and no max-width cap', async () => {
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => page(recentItems(1)),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
const grid = card().element.parentElement!
|
||||
expect(grid.className).toContain('lg:grid-cols-[minmax(0,1fr)_--spacing(105)]')
|
||||
expect(grid.className).toContain('lg:items-start')
|
||||
expect(grid.className).toContain('gap-4')
|
||||
const capped = [grid, grid.parentElement!].some((element) =>
|
||||
/(^|\s)max-w-/.test(element.className),
|
||||
)
|
||||
expect(capped).toBe(false)
|
||||
expect(card().classes()).toContain('lg:order-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home card, the request URL', () => {
|
||||
it('asks for exactly /v1/activity?size=5 and nothing else', async () => {
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
const activityCalls = calls.filter((call) => call.includes('/v1/activity'))
|
||||
expect(activityCalls).toEqual(['GET /v1/activity?size=5'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home card, "View all" opens the activity page', () => {
|
||||
it('navigates to /activity', async () => {
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => page(recentItems(1)),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
const push = vi.spyOn(router, 'push')
|
||||
|
||||
await card().find('a[href="/activity"]').trigger('click')
|
||||
|
||||
expect(push).toHaveBeenCalledWith('/activity')
|
||||
push.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home card, the French interface', () => {
|
||||
it('titles and words the card in French', async () => {
|
||||
setLocale('fr')
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => page([], 0),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
expect(card().find('h2').text()).toBe('Activité récente')
|
||||
expect(card().text()).toContain('Tout voir')
|
||||
expect(card().text()).toContain('Aucune activité pour le moment')
|
||||
})
|
||||
})
|
||||
@@ -107,7 +107,11 @@ async function mountHome(handlers: Record<string, () => Response | Promise<Respo
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.status = 'ready'
|
||||
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
|
||||
global.fetch = fetchRouter({
|
||||
'GET /v1/activity?size=5': () =>
|
||||
Promise.resolve(mockResponse(200, { items: [], total: 0, page: 1, size: 5 })),
|
||||
...handlers,
|
||||
}) as unknown as typeof fetch
|
||||
await router.push('/home')
|
||||
const wrapper = mount(HomeView, {
|
||||
attachTo: document.body,
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import USelectMenu from '@nuxt/ui/components/SelectMenu.vue'
|
||||
import router from '@/router'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||||
import type { WorkspaceSettings } from '@/composables/useSettings'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import SettingsView from '../SettingsView.vue'
|
||||
import { DEFAULT_PURGE_SETTINGS, PURGE_SETTINGS_PATH } from './updateTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return {
|
||||
...actual,
|
||||
useApi: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const SETTINGS_PATH = '/v1/settings/'
|
||||
const SAVED_VISIBLE_MS = 2000
|
||||
|
||||
function at(day: number, hour: number) {
|
||||
return new Date(2026, 9, day, hour, 0).toISOString()
|
||||
}
|
||||
|
||||
function settings(overrides: Partial<WorkspaceSettings> = {}): WorkspaceSettings {
|
||||
return {
|
||||
version: 'v2.2.0-beta',
|
||||
activity_retention_days: 90,
|
||||
activity_last_cleanup_at: at(6, 3),
|
||||
activity_last_cleanup_removed: 142,
|
||||
activity_next_cleanup_at: at(7, 3),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
let requestMock: ReturnType<typeof vi.fn>
|
||||
let getMeMock: ReturnType<typeof vi.fn>
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
async function mountPage(
|
||||
role: 'owner' | 'admin' | 'editor' | undefined,
|
||||
load: () => Promise<unknown>,
|
||||
patch: (body: unknown) => Promise<unknown> = () => Promise.resolve(settings()),
|
||||
) {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', id: 1, role }
|
||||
session.status = 'ready'
|
||||
requestMock = vi.fn((path: string, init?: RequestInit) => {
|
||||
if (path === SETTINGS_PATH && init?.method === 'PATCH') {
|
||||
return patch(JSON.parse(init.body as string))
|
||||
}
|
||||
if (path === SETTINGS_PATH) {
|
||||
return load()
|
||||
}
|
||||
if (path === PURGE_SETTINGS_PATH) {
|
||||
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
getMeMock = vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1, role })
|
||||
vi.mocked(useApi).mockReturnValue({ getMe: getMeMock, request: requestMock })
|
||||
await router.push('/settings')
|
||||
wrapper = mount(SettingsView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function retention() {
|
||||
return document.body.querySelector('[data-testid="retention-card"]')
|
||||
}
|
||||
|
||||
function text() {
|
||||
return retention()?.textContent?.replace(/\s+/g, ' ') ?? ''
|
||||
}
|
||||
|
||||
function line(testId: string) {
|
||||
return document.body.querySelector(`[data-testid="${testId}"]`)?.textContent?.trim()
|
||||
}
|
||||
|
||||
function patches() {
|
||||
return requestMock.mock.calls.filter(([, init]) => init?.method === 'PATCH')
|
||||
}
|
||||
|
||||
function settingsGets() {
|
||||
return requestMock.mock.calls.filter(
|
||||
([path, init]) => path === SETTINGS_PATH && init?.method !== 'PATCH',
|
||||
)
|
||||
}
|
||||
|
||||
function button(label: string) {
|
||||
return Array.from(document.body.querySelectorAll('button')).find(
|
||||
(candidate) => candidate.textContent?.trim() === label,
|
||||
)
|
||||
}
|
||||
|
||||
async function choose(days: number) {
|
||||
wrapper!.findComponent(USelectMenu).vm.$emit('update:modelValue', days)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function selectedLabel() {
|
||||
const select = wrapper!.findComponent(USelectMenu)
|
||||
const value = select.props('modelValue')
|
||||
return (select.props('items') as { value: number; label: string }[]).find(
|
||||
(item) => item.value === value,
|
||||
)?.label
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
vi.useRealTimers()
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('Settings retention is hidden for everyone but the owner', () => {
|
||||
it.each([['admin' as const], ['editor' as const], [undefined]])(
|
||||
'renders nothing for %s',
|
||||
async (role) => {
|
||||
await mountPage(role, () => Promise.resolve(settings()))
|
||||
|
||||
expect(retention()).toBeNull()
|
||||
expect(document.body.textContent).not.toContain('Keep activity for')
|
||||
expect(document.body.textContent).toContain('Media cleanup')
|
||||
expect(settingsGets()).toHaveLength(1)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Settings retention, Default with 90 days', () => {
|
||||
it('shows the header, the select, the helper, the lock alert and the lines', async () => {
|
||||
await mountPage('owner', () => Promise.resolve(settings()))
|
||||
|
||||
expect(text()).toContain('Retention')
|
||||
expect(text()).toContain('How long activity is kept.')
|
||||
expect(selectedLabel()).toBe('90 days')
|
||||
expect(text()).toContain('Older events are removed automatically.')
|
||||
expect(text()).toContain('Activity logs are append-only')
|
||||
expect(line('retention-last')).toBe('6 Oct 2026, 03:00 · 142 events removed')
|
||||
expect(line('retention-next')).toBe('7 Oct 2026, 03:00')
|
||||
expect(text()).not.toContain('Saved')
|
||||
expect(patches()).toHaveLength(0)
|
||||
expect(settingsGets()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reads Never before the first cleanup', async () => {
|
||||
await mountPage('owner', () =>
|
||||
Promise.resolve(settings({ activity_last_cleanup_at: null })),
|
||||
)
|
||||
|
||||
expect(line('retention-last')).toBe('Never')
|
||||
expect(line('retention-next')).toBe('7 Oct 2026, 03:00')
|
||||
})
|
||||
|
||||
it('reads Never and Not scheduled for Forever when never cleaned', async () => {
|
||||
await mountPage('owner', () =>
|
||||
Promise.resolve(
|
||||
settings({
|
||||
activity_retention_days: 0,
|
||||
activity_last_cleanup_at: null,
|
||||
activity_next_cleanup_at: null,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(selectedLabel()).toBe('Forever')
|
||||
expect(line('retention-last')).toBe('Never')
|
||||
expect(line('retention-next')).toBe('Not scheduled')
|
||||
expect(text()).toContain('Nothing is removed automatically. The log grows over time.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings retention, skeleton and load failure', () => {
|
||||
it('draws nothing in the body for 150 ms, then skeleton lines', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pending = deferred<unknown>()
|
||||
await mountPage('owner', () => pending.promise)
|
||||
|
||||
expect(retention()?.getAttribute('aria-busy')).toBeNull()
|
||||
expect(text()).not.toContain('Keep activity for')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
|
||||
expect(retention()?.getAttribute('aria-busy')).toBe('true')
|
||||
expect(text()).not.toContain('Keep activity for')
|
||||
})
|
||||
|
||||
it('shows the shared alert and re-runs the shared load', async () => {
|
||||
let attempt = 0
|
||||
await mountPage('owner', () => {
|
||||
attempt += 1
|
||||
return attempt === 1 ? Promise.reject(new ApiError(500)) : Promise.resolve(settings())
|
||||
})
|
||||
|
||||
expect(text()).toContain("Couldn't reach RenewCMS. Check your connection and try again.")
|
||||
|
||||
const inCard = Array.from(retention()!.querySelectorAll('button')).find(
|
||||
(candidate) => candidate.textContent?.trim() === 'Try again',
|
||||
)
|
||||
inCard!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(settingsGets()).toHaveLength(2)
|
||||
expect(selectedLabel()).toBe('90 days')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings retention, a longer period saves at once', () => {
|
||||
it.each([
|
||||
[90, 365],
|
||||
[30, 90],
|
||||
[90, 0],
|
||||
])('saves %s to %s with one PATCH and shows Saved for 2 s', async (from, to) => {
|
||||
vi.useFakeTimers()
|
||||
const pending = deferred<unknown>()
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings({ activity_retention_days: from })),
|
||||
() => pending.promise,
|
||||
)
|
||||
|
||||
await choose(to)
|
||||
|
||||
expect(patches()).toHaveLength(1)
|
||||
expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_days: to }))
|
||||
expect(text()).toContain('Saving…')
|
||||
expect(text()).not.toContain('Events older than')
|
||||
|
||||
pending.resolve(
|
||||
settings({
|
||||
activity_retention_days: to,
|
||||
activity_next_cleanup_at: to === 0 ? null : at(8, 3),
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(text()).toContain('Saved')
|
||||
expect(line('retention-next')).toBe(to === 0 ? 'Not scheduled' : '8 Oct 2026, 03:00')
|
||||
expect(line('retention-last')).toBe('6 Oct 2026, 03:00 · 142 events removed')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(SAVED_VISIBLE_MS)
|
||||
|
||||
expect(text()).not.toContain('Saved')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings retention, shortening asks first', () => {
|
||||
it('warns without sending, then applies', async () => {
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings()),
|
||||
() => Promise.resolve(settings({ activity_retention_days: 30 })),
|
||||
)
|
||||
|
||||
await choose(30)
|
||||
|
||||
expect(patches()).toHaveLength(0)
|
||||
expect(selectedLabel()).toBe('30 days')
|
||||
expect(text()).toContain('Events older than 30 days will be removed')
|
||||
expect(text()).toContain(
|
||||
"This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone.",
|
||||
)
|
||||
|
||||
button('Apply 30 days')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(patches()).toHaveLength(1)
|
||||
expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_days: 30 }))
|
||||
expect(text()).not.toContain('Events older than')
|
||||
expect(text()).toContain('Saved')
|
||||
})
|
||||
|
||||
it('cancels back to the saved period without any request', async () => {
|
||||
await mountPage('owner', () => Promise.resolve(settings()))
|
||||
await choose(30)
|
||||
|
||||
button('Cancel')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(text()).not.toContain('Events older than')
|
||||
expect(selectedLabel()).toBe('90 days')
|
||||
expect(patches()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[0, 365, true],
|
||||
[365, 90, true],
|
||||
[90, 90, false],
|
||||
[30, 0, false],
|
||||
])('saved %s then chosen %s warns: %s', async (saved, chosen, warns) => {
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings({ activity_retention_days: saved })),
|
||||
() => Promise.resolve(settings({ activity_retention_days: chosen })),
|
||||
)
|
||||
|
||||
await choose(chosen)
|
||||
|
||||
expect(text().includes('Events older than')).toBe(warns)
|
||||
expect(patches()).toHaveLength(saved === chosen || warns ? 0 : 1)
|
||||
})
|
||||
|
||||
it('drops the warning when a longer period is chosen', async () => {
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings()),
|
||||
() => Promise.resolve(settings({ activity_retention_days: 365 })),
|
||||
)
|
||||
await choose(30)
|
||||
|
||||
await choose(365)
|
||||
|
||||
expect(text()).not.toContain('Events older than')
|
||||
expect(patches()).toHaveLength(1)
|
||||
expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_days: 365 }))
|
||||
})
|
||||
|
||||
it('describes a shortening from Forever without a date', async () => {
|
||||
await mountPage('owner', () =>
|
||||
Promise.resolve(
|
||||
settings({ activity_retention_days: 0, activity_next_cleanup_at: null }),
|
||||
),
|
||||
)
|
||||
|
||||
await choose(365)
|
||||
|
||||
expect(text()).toContain('Events older than 365 days will be removed')
|
||||
expect(text()).toContain(
|
||||
"Older events will be removed at the next cleanup. It can't be undone.",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings retention, save error reverts the control and Try again retries', () => {
|
||||
it.each([
|
||||
['500', () => Promise.reject(new ApiError(500))],
|
||||
['400', () => Promise.reject(new ApiError(400))],
|
||||
['403', () => Promise.reject(new ApiError(403))],
|
||||
['network', () => Promise.reject(new NetworkError())],
|
||||
])('on %s', async (_name, failure) => {
|
||||
let attempt = 0
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings()),
|
||||
() => {
|
||||
attempt += 1
|
||||
return attempt === 1
|
||||
? failure()
|
||||
: Promise.resolve(settings({ activity_retention_days: 365 }))
|
||||
},
|
||||
)
|
||||
|
||||
await choose(365)
|
||||
|
||||
expect(text()).toContain("Couldn't save the retention period")
|
||||
expect(text()).toContain('Your previous setting is still in place.')
|
||||
expect(selectedLabel()).toBe('90 days')
|
||||
expect(line('retention-next')).toBe('7 Oct 2026, 03:00')
|
||||
expect(text()).not.toContain('Saved')
|
||||
|
||||
const retry = Array.from(retention()!.querySelectorAll('button')).find(
|
||||
(candidate) => candidate.textContent?.trim() === 'Try again',
|
||||
)
|
||||
retry!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(patches()).toHaveLength(2)
|
||||
expect(patches()[1]![1].body).toBe(JSON.stringify({ activity_retention_days: 365 }))
|
||||
expect(text()).toContain('Saved')
|
||||
expect(text()).not.toContain("Couldn't save")
|
||||
})
|
||||
|
||||
it('retries an applied shortening without asking again', async () => {
|
||||
let attempt = 0
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings()),
|
||||
() => {
|
||||
attempt += 1
|
||||
return attempt === 1
|
||||
? Promise.reject(new ApiError(500))
|
||||
: Promise.resolve(settings({ activity_retention_days: 30 }))
|
||||
},
|
||||
)
|
||||
await choose(30)
|
||||
button('Apply 30 days')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(text()).not.toContain('Events older than')
|
||||
expect(text()).toContain("Couldn't save the retention period")
|
||||
expect(selectedLabel()).toBe('90 days')
|
||||
|
||||
Array.from(retention()!.querySelectorAll('button'))
|
||||
.find((candidate) => candidate.textContent?.trim() === 'Try again')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(patches()).toHaveLength(2)
|
||||
expect(text()).not.toContain('Events older than')
|
||||
})
|
||||
|
||||
it('refreshes the session on a 403 and removes the card for a demoted owner', async () => {
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings()),
|
||||
() => Promise.reject(new ApiError(403)),
|
||||
)
|
||||
getMeMock.mockResolvedValue({ verified: true, username: 'marta', id: 1, role: 'admin' })
|
||||
|
||||
await choose(365)
|
||||
|
||||
expect(getMeMock).toHaveBeenCalled()
|
||||
expect(retention()).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no card alert on a 401', async () => {
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings()),
|
||||
() => Promise.reject(new ApiError(401)),
|
||||
)
|
||||
|
||||
await choose(365)
|
||||
|
||||
expect(text()).not.toContain("Couldn't save")
|
||||
expect(selectedLabel()).toBe('90 days')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings retention, offline disables the controls', () => {
|
||||
it('disables the select and the warning buttons', async () => {
|
||||
await mountPage('owner', () => Promise.resolve(settings()))
|
||||
await choose(30)
|
||||
|
||||
const online = vi.spyOn(window.navigator, 'onLine', 'get').mockReturnValue(false)
|
||||
window.dispatchEvent(new Event('offline'))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper!.findComponent(USelectMenu).props('disabled')).toBe(true)
|
||||
expect(button('Apply 30 days')!.disabled).toBe(true)
|
||||
expect(button('Cancel')!.disabled).toBe(true)
|
||||
online.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings retention, the French interface', () => {
|
||||
it('words the card in French', async () => {
|
||||
setLocale('fr')
|
||||
await mountPage('owner', () => Promise.resolve(settings()))
|
||||
|
||||
expect(text()).toContain('Conservation')
|
||||
expect(text()).toContain('Conserver l’activité pendant')
|
||||
expect(text()).toContain('Dernier nettoyage')
|
||||
expect(text()).toContain('Prochain nettoyage')
|
||||
expect(selectedLabel()).toBe('90 jours')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user