feat: recent activity card on Home and Retention card on Settings

This commit is contained in:
fsylvain committed 2026-10-07 16:01:28 +02:00
1 parent 35db442cd3
commit 43469ef08d
33 files changed
+2748 -204

No files matched your search

+3 -30
View File
@@ -2,8 +2,9 @@
import { t } from '@/i18n'
import { computed } from 'vue'
import ActivityAvatar from '@/components/ActivityAvatar.vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import { initials, type ActivityActor } from '@/lib/activity'
import { type ActivityActor } from '@/lib/activity'
const props = defineProps<{
actor: ActivityActor
@@ -12,39 +13,11 @@ const props = defineProps<{
const displayName = computed(() =>
props.actor.type === 'anonymous' ? t('Not signed in') : props.actor.name,
)
const iconName = computed(() => {
switch (props.actor.type) {
case 'api_key':
return 'i-lucide-key-round'
case 'system':
return 'i-lucide-cog'
default:
return 'i-lucide-user-round-x'
}
})
</script>
<template>
<span class="flex min-w-0 items-center gap-2" data-testid="activity-actor">
<UAvatar
v-if="actor.type === 'user'"
aria-hidden="true"
:text="initials(actor.name)"
:ui="{
root: 'size-6 shrink-0 bg-(--ui-color-secondary-200) dark:bg-(--ui-color-secondary-300)',
fallback: 'pt-px text-3xs font-semibold text-(--ui-color-primary-900)',
}"
/>
<UAvatar
v-else
aria-hidden="true"
:icon="iconName"
:ui="{
root: 'size-6 shrink-0 border border-accented bg-elevated',
icon: 'size-3.5 text-toned',
}"
/>
<ActivityAvatar :actor="actor" />
<span class="min-w-0 truncate font-medium text-highlighted">{{ displayName }}</span>
<ActivityBadge v-if="actor.type === 'api_key'" tone="neutral" :label="t('API key')" />
<ActivityBadge v-else-if="actor.type === 'system'" tone="neutral" :label="t('System')" />
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { computed } from 'vue'
import { initials, type ActivityActor } from '@/lib/activity'
const props = defineProps<{
actor: ActivityActor
}>()
const iconName = computed(() => {
switch (props.actor.type) {
case 'api_key':
return 'i-lucide-key-round'
case 'system':
return 'i-lucide-cog'
default:
return 'i-lucide-user-round-x'
}
})
</script>
<template>
<UAvatar
v-if="actor.type === 'user'"
aria-hidden="true"
:text="initials(actor.name)"
:ui="{
root: 'size-6 shrink-0 bg-(--ui-color-secondary-200) dark:bg-(--ui-color-secondary-300)',
fallback: 'pt-px text-3xs font-semibold text-(--ui-color-primary-900)',
}"
/>
<UAvatar
v-else
aria-hidden="true"
:icon="iconName"
:ui="{
root: 'size-6 shrink-0 border border-accented bg-elevated',
icon: 'size-3.5 text-toned',
}"
/>
</template>
+150
View File
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { t } from '@/i18n'
import ActivityAvatar from '@/components/ActivityAvatar.vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import ActivitySentence from '@/components/ActivitySentence.vue'
import type { ActivityItem } from '@/lib/activity'
import { fullLocalTime, recentWhenLabel } from '@/lib/activityTime'
const SKELETON_ROWS = 5
const SKELETON_SENTENCE_WIDTHS = ['w-17/20', 'w-7/10', 'w-9/10', 'w-3/5', 'w-4/5']
defineProps<{
items: ActivityItem[]
loading: boolean
showSkeleton: boolean
error: boolean
own: boolean
}>()
defineEmits<{
retry: []
}>()
function metaLine(item: ActivityItem, own: boolean) {
const when = recentWhenLabel(item.created_at)
if (own) return when
const name = item.actor.type === 'anonymous' ? t('Not signed in') : item.actor.name
return `${name} · ${when}`
}
</script>
<template>
<UCard
data-testid="recent-activity-card"
:aria-busy="loading && showSkeleton ? 'true' : undefined"
:ui="{
root: 'min-w-0 rounded-none border border-default bg-default shadow-none ring-0 divide-y-0',
header: 'flex items-center gap-2 border-b border-default px-4 py-3 sm:px-4 sm:py-3',
body: 'flex flex-col px-4 pt-1 pb-1.5 sm:px-4 sm:pt-1 sm:pb-1.5',
}"
>
<template #header>
<h2 class="flex-1 text-base font-semibold text-highlighted">
{{ own ? t('Your recent activity') : t('Recent activity') }}
</h2>
<RouterLink
to="/activity"
class="flex items-center gap-1 font-medium text-highlighted underline underline-offset-3 max-lg:min-h-11 lg:min-h-5"
>
{{ t('View all') }}
<UIcon name="i-lucide-arrow-right" class="size-3.5" />
</RouterLink>
</template>
<template v-if="loading">
<template v-if="showSkeleton">
<div
v-for="n in SKELETON_ROWS"
:key="n"
data-testid="recent-activity-skeleton"
class="flex gap-2.5 py-2.5"
:class="n > 1 ? 'border-t border-muted' : ''"
>
<USkeleton class="size-6 shrink-0 rounded-none bg-accented" />
<div class="flex flex-1 flex-col gap-1.5">
<USkeleton
class="h-3 rounded-none bg-accented"
:class="SKELETON_SENTENCE_WIDTHS[n - 1]"
/>
<USkeleton class="h-2.5 w-7/20 rounded-none bg-accented" />
</div>
</div>
</template>
</template>
<UAlert
v-else-if="error"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load recent activity')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 lg:h-7"
@click="$emit('retry')"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<div
v-else-if="items.length === 0"
data-testid="recent-activity-empty"
class="flex flex-col items-center gap-1.5 py-6 text-center"
>
<UIcon name="i-lucide-history" class="size-5 text-muted" />
<span class="font-semibold text-highlighted">{{ t('No activity yet') }}</span>
<span class="max-w-60 text-muted">
{{
own
? t('Your actions will appear here.')
: t('Actions on your site will appear here.')
}}
</span>
</div>
<ul v-else class="flex flex-col">
<li
v-for="(item, index) in items"
:key="item.id"
data-testid="recent-activity-row"
class="flex items-start gap-2.5 py-2.5"
:class="index > 0 ? 'border-t border-muted' : ''"
>
<ActivityAvatar :actor="item.actor" />
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span
class="line-clamp-2 text-default wrap-anywhere"
data-testid="recent-activity-sentence"
>
<ActivitySentence :item="item" />
</span>
<span
data-testid="recent-activity-meta"
:title="fullLocalTime(item.created_at)"
class="truncate text-xs/5 text-muted"
>
{{ metaLine(item, own) }}
</span>
</div>
<ActivityBadge v-if="item.outcome === 'failed'" tone="fail" :label="t('Failed')" />
<UIcon
v-else-if="item.security"
name="i-lucide-shield-alert"
data-testid="recent-activity-security"
class="size-3.5 shrink-0 text-warning"
/>
</li>
</ul>
</UCard>
</template>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import ActivityFeedCard from '@/components/ActivityFeedCard.vue'
import { useActivity } from '@/composables/useActivity'
import type { ActivityItem } from '@/lib/activity'
import { useSessionStore } from '@/stores/session'
const SKELETON_DELAY_MS = 150
const RECENT_SIZE = 5
const session = useSessionStore()
const { listRecentActivity } = useActivity()
const items = ref<ActivityItem[]>([])
const loading = ref(true)
const error = ref(false)
const showSkeleton = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
const own = computed(() => session.session?.role !== 'owner' && session.session?.role !== 'admin')
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
async function load() {
loading.value = true
error.value = false
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
try {
const page = await listRecentActivity(RECENT_SIZE)
items.value = page.items.slice(0, RECENT_SIZE)
} catch {
error.value = true
} finally {
clearSkeletonTimer()
showSkeleton.value = false
loading.value = false
}
}
onMounted(load)
onBeforeUnmount(clearSkeletonTimer)
</script>
<template>
<ActivityFeedCard
:items="items"
:loading="loading"
:show-skeleton="showSkeleton"
:error="error"
:own="own"
@retry="load"
/>
</template>
+337
View File
@@ -0,0 +1,337 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useOnline } from '@vueuse/core'
import { ApiError } from '@/composables/useApi'
import { useSettings, type WorkspaceSettings } from '@/composables/useSettings'
import {
SAVED_VISIBLE_MS,
helperCopy,
isShorter,
lastCleanupLine,
nextCleanupLine,
retentionOptions,
shorteningDescription,
} from '@/lib/activityRetention'
import { useSessionStore } from '@/stores/session'
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
const SKELETON_BAR = 'rounded-none bg-accented'
const OUTLINE_BUTTON =
'h-11 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7'
const HTTP_UNAUTHORIZED = 401
const HTTP_FORBIDDEN = 403
type SaveStatus = 'idle' | 'saving' | 'saved'
const props = defineProps<{
settings: WorkspaceSettings | null
loadState: 'loading' | 'ready' | 'error'
showSkeleton: boolean
}>()
const emit = defineEmits<{
saved: [settings: WorkspaceSettings]
reload: []
}>()
const session = useSessionStore()
const { updateRetention } = useSettings()
const online = useOnline()
const selected = ref(props.settings?.activity_retention_days ?? 0)
const shortening = ref<number | null>(null)
const failedValue = ref<number | null>(null)
const saveStatus = ref<SaveStatus>('idle')
let savedTimer: ReturnType<typeof setTimeout> | null = null
let pendingSaves = 0
const offline = computed(() => !online.value)
const skeletonVisible = computed(() => props.loadState === 'loading' && props.showSkeleton)
const savedDays = computed(() => props.settings?.activity_retention_days ?? 0)
const options = computed(() => retentionOptions())
watch(savedDays, (days) => {
if (shortening.value === null && pendingSaves === 0) {
selected.value = days
}
})
function clearSavedTimer() {
if (savedTimer !== null) {
clearTimeout(savedTimer)
savedTimer = null
}
}
function finishSaving(succeeded: boolean) {
if (!succeeded) {
saveStatus.value = 'idle'
return
}
saveStatus.value = 'saved'
savedTimer = setTimeout(() => {
saveStatus.value = 'idle'
}, SAVED_VISIBLE_MS)
}
async function save(days: number) {
pendingSaves += 1
clearSavedTimer()
failedValue.value = null
selected.value = days
saveStatus.value = 'saving'
let succeeded = false
try {
const updated = await updateRetention(days)
emit('saved', updated)
selected.value = updated.activity_retention_days
succeeded = true
} catch (error) {
selected.value = savedDays.value
const status = error instanceof ApiError ? error.status : 0
if (status !== HTTP_UNAUTHORIZED) {
failedValue.value = days
}
if (status === HTTP_FORBIDDEN) {
session.refresh()
}
} finally {
pendingSaves -= 1
if (pendingSaves === 0) {
finishSaving(succeeded)
}
}
}
function choose(value: number) {
if (offline.value) {
return
}
shortening.value = null
failedValue.value = null
if (value === savedDays.value) {
selected.value = value
return
}
if (isShorter(value, savedDays.value)) {
selected.value = value
shortening.value = value
return
}
save(value)
}
function apply() {
const days = shortening.value
shortening.value = null
if (days !== null) {
save(days)
}
}
function cancel() {
shortening.value = null
selected.value = savedDays.value
}
function retry() {
if (failedValue.value !== null) {
save(failedValue.value)
}
}
onBeforeUnmount(clearSavedTimer)
</script>
<template>
<UCard
as="section"
aria-labelledby="retention-h"
data-testid="retention-card"
:aria-busy="skeletonVisible ? 'true' : undefined"
:ui="{
root: 'rounded-none border border-default bg-default shadow-none ring-0 divide-y-0',
header: 'flex items-center gap-2 border-b border-default px-4 py-3 sm:px-4 sm:py-3',
body: 'flex flex-col gap-4 p-4 sm:p-4',
}"
>
<template #header>
<div class="flex min-w-0 flex-1 flex-col">
<h2 id="retention-h" class="text-base font-semibold text-highlighted">
{{ t('Retention') }}
</h2>
<p class="text-xs text-muted">{{ t('How long activity is kept.') }}</p>
</div>
<span
aria-live="polite"
data-testid="retention-save-status"
class="flex h-6 shrink-0 items-center"
>
<span
v-if="saveStatus === 'saving'"
class="inline-flex items-center gap-1.5 text-xs text-muted"
>
<UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />
{{ t('Saving…') }}
</span>
<span
v-else-if="saveStatus === 'saved'"
class="inline-flex items-center gap-1.5 text-xs text-success"
>
<UIcon name="i-lucide-check" class="size-3.5" />
{{ t('Saved') }}
</span>
</span>
</template>
<template v-if="skeletonVisible">
<USkeleton :class="[SKELETON_BAR, 'h-3.5 w-30']" />
<USkeleton :class="[SKELETON_BAR, 'h-8 w-full max-w-65']" />
<USkeleton :class="[SKELETON_BAR, 'h-3 w-3/5']" />
<USkeleton :class="[SKELETON_BAR, 'h-3.5 w-9/10']" />
<USkeleton :class="[SKELETON_BAR, 'h-3.5 w-7/10']" />
</template>
<UAlert
v-else-if="loadState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:description="t(UNREACHABLE_COPY)"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
:class="OUTLINE_BUTTON"
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('reload')"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<template v-else-if="loadState === 'ready' && settings">
<UAlert
v-if="failedValue !== null"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t save the retention period')"
:description="t('Your previous setting is still in place.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
:class="OUTLINE_BUTTON"
@click="retry"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<div class="flex max-w-65 flex-col gap-1.5">
<span id="retention-label" class="font-medium text-highlighted">
{{ t('Keep activity for') }}
</span>
<USelectMenu
:model-value="selected"
:items="options"
value-key="value"
:search-input="false"
:disabled="offline"
aria-labelledby="retention-label"
color="neutral"
variant="outline"
class="w-full"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default px-2.5 text-sm ring-0 md:h-8',
trailingIcon: 'size-3.5 text-muted',
content: 'min-w-48',
}"
@update:model-value="choose($event as number)"
/>
<span class="text-xs text-muted">{{ helperCopy(selected) }}</span>
</div>
<UAlert
v-if="shortening !== null"
role="alert"
color="warning"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-triangle-alert"
:title="t('Events older than {0} days will be removed', [shortening])"
:description="shorteningDescription(settings)"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
:disabled="offline"
:class="OUTLINE_BUTTON"
@click="apply"
>
{{ t('Apply {0} days', [shortening]) }}
</UButton>
<UButton
color="neutral"
variant="outline"
:disabled="offline"
:class="OUTLINE_BUTTON"
@click="cancel"
>
{{ t('Cancel') }}
</UButton>
</template>
</UAlert>
<UAlert
role="status"
color="info"
variant="soft"
:close="false"
icon="i-lucide-lock"
:title="t('Activity logs are append-only')"
:description="
t(
'Events can\'t be edited or deleted from the app. Only the retention period removes them.',
)
"
/>
<USeparator />
<dl class="flex flex-col gap-1.5">
<div class="flex flex-col md:flex-row md:gap-3">
<dt class="shrink-0 text-muted md:w-30">{{ t('Last cleanup') }}</dt>
<dd data-testid="retention-last" class="text-highlighted">
{{ lastCleanupLine(settings) }}
</dd>
</div>
<div class="flex flex-col md:flex-row md:gap-3">
<dt class="shrink-0 text-muted md:w-30">{{ t('Next cleanup') }}</dt>
<dd data-testid="retention-next" class="text-highlighted">
{{ nextCleanupLine(settings) }}
</dd>
</div>
</dl>
</template>
</UCard>
</template>
+5 -1
View File
@@ -31,6 +31,10 @@ export function useActivity() {
return request<ActivityPage>(`/v1/activity?${listQuery(filters, page, size, withActor)}`)
}
function listRecentActivity(size: number): Promise<ActivityPage> {
return request<ActivityPage>(`/v1/activity?size=${size}`)
}
function getSummary(filters: ActivityFilters): Promise<ActivitySummary> {
return request<ActivitySummary>(`/v1/activity/summary?${summaryQuery(filters)}`)
}
@@ -54,7 +58,7 @@ export function useActivity() {
}
}
return { listActivity, getSummary, exportActivity, listPeople }
return { listActivity, listRecentActivity, getSummary, exportActivity, listPeople }
}
export function downloadBlob(blob: Blob, fileName: string) {
+17 -2
View File
@@ -2,14 +2,29 @@ import { useApi } from '@/composables/useApi'
export interface WorkspaceSettings {
version: string
activity_retention_days: number
activity_last_cleanup_at: string | null
activity_last_cleanup_removed: number
activity_next_cleanup_at: string | null
}
const SETTINGS_PATH = '/v1/settings/'
const JSON_HEADERS = { 'Content-Type': 'application/json' }
export function useSettings() {
const { request } = useApi()
function getSettings(): Promise<WorkspaceSettings> {
return request<WorkspaceSettings>('/v1/settings/')
return request<WorkspaceSettings>(SETTINGS_PATH)
}
return { getSettings }
function updateRetention(days: number): Promise<WorkspaceSettings> {
return request<WorkspaceSettings>(SETTINGS_PATH, {
method: 'PATCH',
headers: JSON_HEADERS,
body: JSON.stringify({ activity_retention_days: days }),
})
}
return { getSettings, updateRetention }
}
+32
View File
@@ -1183,6 +1183,38 @@ export const messages = {
'Failed to check for updates': 'Échec de la recherche de mises à jour',
'Failed to download the update {0}': 'Échec du téléchargement de la mise à jour {0}',
'Failed to restart': 'Échec du redémarrage',
'Recent activity': 'Activité récente',
'Your recent activity': 'Votre activité récente',
'View all': 'Tout voir',
"Couldn't load recent activity": 'Impossible de charger l’activité récente',
'Actions on your site will appear here.': 'Les actions sur votre site apparaîtront ici.',
'Your actions will appear here.': 'Vos actions apparaîtront ici.',
Retention: 'Conservation',
'How long activity is kept.': 'Durée de conservation de l’activité.',
'Keep activity for': 'Conserver l’activité pendant',
'90 days': '90 jours',
'365 days': '365 jours',
'Older events are removed automatically.':
'Les anciens événements sont supprimés automatiquement.',
'Nothing is removed automatically. The log grows over time.':
'Rien n’est supprimé automatiquement. Le journal grossit avec le temps.',
'Activity logs are append-only': 'Le journal d’activité est en ajout seul',
"Events can't be edited or deleted from the app. Only the retention period removes them.":
'Les événements ne peuvent pas être modifiés ni supprimés depuis l’application. Seule la durée de conservation les supprime.',
'Last cleanup': 'Dernier nettoyage',
'Next cleanup': 'Prochain nettoyage',
'Not scheduled': 'Non planifié',
'{0} event removed': '{0} événement supprimé',
'{0} events removed': '{0} événements supprimés',
'Events older than {0} days will be removed':
'Les événements de plus de {0} jours seront supprimés',
"This happens at the next cleanup on {0}. It can't be undone.":
'Cela se produira au prochain nettoyage, le {0}. Cette action est irréversible.',
"Older events will be removed at the next cleanup. It can't be undone.":
'Les anciens événements seront supprimés au prochain nettoyage. Cette action est irréversible.',
'Apply {0} days': 'Appliquer {0} jours',
"Couldn't save the retention period": 'Impossible d’enregistrer la durée de conservation',
'Your previous setting is still in place.': 'Votre réglage précédent est conservé.',
} as const
export type MessageKey = keyof typeof messages
@@ -0,0 +1,88 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { initializeLocale, setLocale } from '@/i18n'
import type { WorkspaceSettings } from '@/composables/useSettings'
import { recentWhenLabel } from '../activityTime'
import {
helperCopy,
isShorter,
lastCleanupLine,
nextCleanupLine,
shorteningDescription,
} from '../activityRetention'
const NOW = new Date(2026, 9, 6, 15, 0)
function at(day: number, hour: number, minute: number) {
return new Date(2026, 9, day, hour, minute).toISOString()
}
function settings(overrides: Partial<WorkspaceSettings> = {}): WorkspaceSettings {
return {
version: 'v2.2.0-beta',
activity_retention_days: 90,
activity_last_cleanup_at: at(6, 3, 0),
activity_last_cleanup_removed: 142,
activity_next_cleanup_at: at(7, 3, 0),
...overrides,
}
}
beforeEach(() => {
localStorage.clear()
initializeLocale()
})
afterEach(() => {
localStorage.clear()
initializeLocale()
})
describe('recentWhenLabel follows the day', () => {
it.each([
[at(6, 14, 58), '2 min ago'],
[at(6, 12, 0), '3 h ago'],
[at(5, 14, 20), 'Yesterday, 14:20'],
[at(3, 14, 20), '3 Oct 2026, 14:20'],
])('%s reads %s', (iso, label) => {
expect(recentWhenLabel(iso, NOW)).toBe(label)
})
it('reads French labels in French', () => {
setLocale('fr')
expect(recentWhenLabel(at(5, 14, 20), NOW)).toBe('Hier, 14:20')
expect(recentWhenLabel(at(3, 14, 20), NOW)).toBe('3 oct. 2026, 14:20')
})
})
describe('retention periods', () => {
it('ranks 30 < 90 < 365 < Forever', () => {
expect(isShorter(30, 90)).toBe(true)
expect(isShorter(365, 0)).toBe(true)
expect(isShorter(0, 365)).toBe(false)
expect(isShorter(90, 90)).toBe(false)
})
})
describe('retention lines', () => {
it('reads the last cleanup with its count', () => {
expect(lastCleanupLine(settings())).toBe('6 Oct 2026, 03:00 · 142 events removed')
expect(lastCleanupLine(settings({ activity_last_cleanup_removed: 1 }))).toBe(
'6 Oct 2026, 03:00 · 1 event removed',
)
})
it('reads Never before the first cleanup and Not scheduled for Forever', () => {
expect(lastCleanupLine(settings({ activity_last_cleanup_at: null }))).toBe('Never')
expect(nextCleanupLine(settings({ activity_next_cleanup_at: null }))).toBe('Not scheduled')
expect(nextCleanupLine(settings())).toBe('7 Oct 2026, 03:00')
})
it('words the helper and the warning', () => {
expect(helperCopy(90)).toBe('Older events are removed automatically.')
expect(helperCopy(0)).toBe('Nothing is removed automatically. The log grows over time.')
expect(shorteningDescription(settings())).toBe(
"This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone.",
)
})
})
+62
View File
@@ -0,0 +1,62 @@
import { t } from '@/i18n'
import type { WorkspaceSettings } from '@/composables/useSettings'
import { localDayTime } from '@/lib/activityTime'
export const FOREVER_DAYS = 0
export const SAVED_VISIBLE_MS = 2000
const RANKED_PERIODS = [30, 90, 365, FOREVER_DAYS]
export function retentionOptions() {
return RANKED_PERIODS.map((value) => ({ value, label: periodLabel(value) }))
}
export function periodLabel(days: number) {
switch (days) {
case 30:
return t('30 days')
case 90:
return t('90 days')
case 365:
return t('365 days')
default:
return t('Forever')
}
}
export function periodRank(days: number) {
return RANKED_PERIODS.indexOf(days)
}
export function isShorter(candidate: number, saved: number) {
return periodRank(candidate) < periodRank(saved)
}
export function helperCopy(days: number) {
return days === FOREVER_DAYS
? t('Nothing is removed automatically. The log grows over time.')
: t('Older events are removed automatically.')
}
export function lastCleanupLine(settings: WorkspaceSettings) {
if (!settings.activity_last_cleanup_at) {
return t('Never')
}
const removed = settings.activity_last_cleanup_removed
const count = t(removed === 1 ? '{0} event removed' : '{0} events removed', [removed])
return `${localDayTime(settings.activity_last_cleanup_at)} · ${count}`
}
export function nextCleanupLine(settings: WorkspaceSettings) {
return settings.activity_next_cleanup_at
? localDayTime(settings.activity_next_cleanup_at)
: t('Not scheduled')
}
export function shorteningDescription(settings: WorkspaceSettings) {
return settings.activity_next_cleanup_at
? t("This happens at the next cleanup on {0}. It can't be undone.", [
localDayTime(settings.activity_next_cleanup_at),
])
: t("Older events will be removed at the next cleanup. It can't be undone.")
}
+15
View File
@@ -64,6 +64,21 @@ export function whenLabel(iso: string, now: Date = new Date()) {
return t('{0} h ago', [Math.floor(elapsed / HOUR_MS)])
}
export function recentWhenLabel(iso: string, now: Date = new Date()) {
const date = new Date(iso)
const key = localDayKey(date)
if (key === localDayKey(now)) return whenLabel(iso, now)
const today = startOfDay(now)
const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1)
if (key === localDayKey(yesterday)) return `${t('Yesterday')}, ${clock(date, false, false)}`
return localDayTime(iso)
}
export function localDayTime(iso: string) {
const date = new Date(iso)
return `${formatAbsoluteDay(date)}, ${clock(date, false, false)}`
}
export function fullLocalTime(iso: string) {
const date = new Date(iso)
return `${formatAbsoluteDay(date)}, ${clock(date, true, false)}`
+157 -134
View File
@@ -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>
+15 -4
View File
@@ -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')
})
})
+5 -1
View File
@@ -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')
})
})
+107 -6
View File
@@ -165,6 +165,18 @@ const verifiedSession: ApiMock = {
}
const updateRunningVersion = 'v2.2.0-beta'
const newCardAbsentReason = 'new card absent from board frame (dashed placeholder)'
const homeActivityMock: ApiMock = {
status: 200,
body: { items: [], total: 0, page: 1, size: 5 },
}
const workspaceSettingsBody = {
version: updateRunningVersion,
activity_retention_days: 90,
activity_last_cleanup_at: '2026-10-06T03:00:00Z',
activity_last_cleanup_removed: 142,
activity_next_cleanup_at: '2026-10-07T03:00:00Z',
}
const updateLatestTag = 'v2.3.0'
const updateReleaseUrl = 'https://git.floriansylvain.fr/fsylvain/RenewCMS/releases/tag/v2.3.0'
const updateReleasedAt = '2026-09-24T08:00:00Z'
@@ -291,7 +303,7 @@ function updateApi(
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': role === 'owner' ? verifiedSession : adminSession,
'GET /v1/settings/': { status: 200, body: { version: updateRunningVersion } },
'GET /v1/settings/': { status: 200, body: workspaceSettingsBody },
'GET /v1/system/update': { status: 200, body: status },
'POST /v1/system/update/check': check,
}
@@ -545,6 +557,7 @@ const newArticleBody = 'Small rooms show every grout line, so we start with the
function newArticleHomeApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
return {
'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
...overrides,
}
@@ -2378,7 +2391,7 @@ function cleanupApi(
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': sessionWithRole(role),
'GET /v1/settings/': { status: 200, body: { version: updateRunningVersion } },
'GET /v1/settings/': { status: 200, body: workspaceSettingsBody },
'GET /v1/system/update': { status: 200, hang: true },
[purgeSettingsPath]: settings,
...overrides,
@@ -2392,8 +2405,9 @@ function settingsBody(body: unknown): ApiMock {
function cleanupState(
api: Record<string, ApiMock>,
steps?: (page: Page) => Promise<void>,
exception?: DiffException,
): VisualState {
return { path: '/settings', api, steps }
return { path: '/settings', api, steps, exception }
}
async function openCleanupDialog(page: Page) {
@@ -2486,7 +2500,11 @@ const cleanupStates: Record<string, VisualState> = {
await page.getByText('Enter a whole number of days between 1 and 365.').waitFor()
}),
'Read-only (non-admin)': cleanupState(cleanupApi(settingsBody(purgeOnBody), {}, 'member')),
'Loading skeleton': cleanupState(cleanupApi({ status: 200, hang: true })),
'Loading skeleton': cleanupState(cleanupApi({ status: 200, hang: true }), undefined, {
maxPercent: 17.5,
reason: newCardAbsentReason,
approvedBy: 'fallen',
}),
Offline: cleanupState(cleanupApi(settingsBody(purgeOnBody)), goOffline),
'Loading preview': cleanupState(
cleanupDialogApi({ [purgePreviewPath]: { status: 200, hang: true } }),
@@ -2960,6 +2978,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
},
@@ -3102,13 +3121,34 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
'Up to date': { path: '/settings', skip: 'clock-sensitive' },
Checking: {
path: '/settings',
exception: {
maxPercent: 11.3,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
api: updateApi('owner', updateNeverChecked(), updateCheckHangs),
},
'Check failed': { path: '/settings', skip: 'clock-sensitive' },
'Update available': { path: '/settings', skip: 'clock-sensitive' },
'Update available, notes open': { path: '/settings', skip: 'clock-sensitive' },
Downloading: { path: '/settings', api: updateApi('owner', updateDownloading()) },
Verifying: { path: '/settings', api: updateApi('owner', updateJob('verifying')) },
Downloading: {
path: '/settings',
api: updateApi('owner', updateDownloading()),
exception: {
maxPercent: 10.6,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
},
Verifying: {
path: '/settings',
api: updateApi('owner', updateJob('verifying')),
exception: {
maxPercent: 10.8,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
},
'Ready to install': { path: '/settings', api: updateApi('owner', updateJob('ready')) },
'Restart: confirm': {
path: '/settings',
@@ -3148,14 +3188,29 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
},
'Download failed': {
path: '/settings',
exception: {
maxPercent: 10.9,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
api: updateApi('owner', updateJob('failed', { error: 'download' })),
},
'Verification failed': {
path: '/settings',
exception: {
maxPercent: 10.5,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
api: updateApi('owner', updateJob('failed', { error: 'verification' })),
},
'Too many requests (429)': {
path: '/settings',
exception: {
maxPercent: 11.3,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
api: updateApi('owner', updateNeverChecked(), {
status: 429,
headers: { 'Retry-After': '42' },
@@ -3163,10 +3218,20 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
},
'Not prepared': {
path: '/settings',
exception: {
maxPercent: 11.9,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
api: updateApi('owner', updateAvailable()),
},
'Not prepared: command copied': {
path: '/settings',
exception: {
maxPercent: 11.9,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
origin: integrationOrigin,
api: updateApi('owner', updateAvailable()),
steps: async (page) => {
@@ -3491,52 +3556,88 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
home: {
Default: {
path: '/home',
exception: {
maxPercent: 13.5,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
},
},
Loading: {
path: '/home',
exception: {
maxPercent: 18.6,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 200, body: sixArticlesFixture, hang: true },
},
},
'Empty, first run': {
path: '/home',
exception: {
maxPercent: 11.4,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 200, body: [] },
},
},
Error: {
path: '/home',
exception: {
maxPercent: 25.3,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 500 },
},
},
'Update available (owner)': {
path: '/home',
exception: {
maxPercent: 11.9,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
'GET /v1/system/update': { status: 200, body: updateAvailable() },
},
},
'Update ready to install (owner)': {
path: '/home',
exception: {
maxPercent: 11.9,
reason: newCardAbsentReason,
approvedBy: 'fallen',
},
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
'GET /v1/system/update': { status: 200, body: updateJob('ready') },
},