feat: article editor core

This commit is contained in:
fsylvain committed 2026-09-27 02:11:38 +02:00
1 parent d493a181e1
commit a4734b2c91
55 files changed
+4186 -496

No files matched your search

+17 -1
View File
@@ -112,7 +112,8 @@
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--shadow-popover: 0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06);
--shadow-popover:
0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06);
--shadow-modal: 0 16px 48px rgb(17 17 17 / 0.18);
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
@@ -260,9 +261,24 @@ body {
.article-content li {
margin: 0 0 0.375rem;
}
.article-content li > p {
margin: 0;
}
.article-content li::marker {
color: var(--ui-text-muted);
}
.article-content [data-indent='1'] {
padding-left: 1.5rem;
}
.article-content [data-indent='2'] {
padding-left: 3rem;
}
.article-content [data-indent='3'] {
padding-left: 4.5rem;
}
.article-content [data-indent='4'] {
padding-left: 6rem;
}
.article-content blockquote {
margin: 1.75rem 0;
padding-left: 1.25rem;
+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Article } from '@/composables/useArticles'
const props = defineProps<{ article: Article }>()
defineEmits<{ 'copy-url': [] }>()
const open = defineModel<boolean>('open', { default: false })
const TITLE_NOTE =
"Titles can't be changed after an article is created. To use a different title, create a new article and delete this one."
const MONTH_NAMES = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
]
function isSameDay(a: Date, b: Date) {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
)
}
function formatDetailDate(iso: string, relative = false) {
const date = new Date(iso)
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
if (relative && isSameDay(date, new Date())) {
return `Today, ${hours}:${minutes}`
}
return `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}, ${hours}:${minutes}`
}
const createdLabel = computed(() => formatDetailDate(props.article.created_at))
const updatedLabel = computed(() =>
formatDetailDate(props.article.updated_at, true),
)
const publicUrl = computed(() => `/v1/article/${props.article.id}`)
const imagesCount = computed(() => props.article.images.length)
const apiNote = computed(() =>
props.article.is_online
? 'Live. Your website can read this article.'
: 'Returns 404 until the article is online.',
)
</script>
<template>
<div>
<UCard
data-testid="article-details"
class="hidden lg:flex"
:ui="{
root: 'flex-col gap-4 rounded-none bg-transparent shadow-none ring-0 divide-y-0',
header: 'p-0 sm:p-0',
body: 'flex flex-col gap-4 p-0 sm:p-0',
}"
>
<template #header>
<h2 class="text-base font-semibold text-highlighted">
Details
</h2>
</template>
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
<dt class="text-muted">Created</dt>
<dd class="text-highlighted">{{ createdLabel }}</dd>
<dt class="text-muted">Updated</dt>
<dd class="text-highlighted">{{ updatedLabel }}</dd>
<dt class="text-muted">ID</dt>
<dd class="font-mono text-highlighted">{{ article.id }}</dd>
<dt class="text-muted">Images</dt>
<dd class="text-highlighted">{{ imagesCount }}</dd>
</dl>
<div class="flex flex-col gap-1.5 text-sm">
<p class="text-muted">Public API URL</p>
<div
class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs"
>
<span class="flex-1">{{ publicUrl }}</span>
<UIcon name="i-lucide-copy" class="size-3.5" />
</div>
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
</div>
<div
class="flex flex-col gap-1 border-t border-default pt-3 text-sm"
>
<p class="text-muted">Title</p>
<p class="text-xs/[18px] text-toned">{{ TITLE_NOTE }}</p>
</div>
</UCard>
<USlideover
v-model:open="open"
side="right"
title="Details"
class="lg:hidden"
>
<template #body>
<div class="flex flex-col gap-4 text-sm">
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2">
<dt class="text-muted">Created</dt>
<dd class="text-highlighted">{{ createdLabel }}</dd>
<dt class="text-muted">Updated</dt>
<dd class="text-highlighted">{{ updatedLabel }}</dd>
<dt class="text-muted">ID</dt>
<dd class="font-mono text-highlighted">
{{ article.id }}
</dd>
<dt class="text-muted">Images</dt>
<dd class="text-highlighted">{{ imagesCount }}</dd>
</dl>
<div class="flex flex-col gap-1.5">
<p class="text-muted">Public API URL</p>
<div
class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs"
>
<span class="flex-1">{{ publicUrl }}</span>
<UIcon name="i-lucide-copy" class="size-3.5" />
</div>
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
</div>
<div
class="flex flex-col gap-1 border-t border-default pt-3"
>
<p class="text-muted">Title</p>
<p class="text-xs/[18px] text-toned">
{{ TITLE_NOTE }}
</p>
</div>
</div>
</template>
</USlideover>
</div>
</template>
+255
View File
@@ -0,0 +1,255 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { EditorToolbarItem } from '@nuxt/ui'
import type { Editor } from '@tiptap/core'
import { ArticleIndent } from '@/lib/articleIndent'
const props = withDefaults(
defineProps<{
modelValue: string
articleId: number
disabled?: boolean
}>(),
{
disabled: false,
},
)
const emit = defineEmits<{
'update:modelValue': [value: string]
'request-image': []
}>()
const editorInstance = ref<Editor | null>(null)
const extensions = [ArticleIndent]
function captureEditor(editor: Editor) {
editorInstance.value = editor
return ''
}
function onUpdate(value: string) {
emit('update:modelValue', value)
}
function currentBlockLabel(editor: Editor) {
for (let level = 1; level <= 4; level += 1) {
if (editor.isActive('heading', { level })) {
return `Heading ${level}`
}
}
return 'Paragraph'
}
function isDefaultAlignment(editor: Editor) {
return !['center', 'right', 'justify'].some((align) =>
editor.isActive({ textAlign: align }),
)
}
function buildItems(editor: Editor): EditorToolbarItem[][] {
return [
[
{
kind: 'undo',
icon: 'i-lucide-undo-2',
tooltip: { text: 'Undo' },
},
{
kind: 'redo',
icon: 'i-lucide-redo-2',
tooltip: { text: 'Redo' },
},
],
[
{
icon: 'i-lucide-pilcrow',
label: currentBlockLabel(editor),
trailingIcon: 'i-lucide-chevron-down',
ui: { trailingIcon: 'size-3' },
tooltip: { text: 'Paragraph style' },
items: [
{
icon: 'i-lucide-pilcrow',
label: 'Paragraph',
onSelect: () =>
editor.chain().focus().setParagraph().run(),
},
{
kind: 'heading',
level: 1,
icon: 'i-lucide-heading-1',
label: 'Heading 1',
tooltip: { text: 'Heading 1' },
},
{
kind: 'heading',
level: 2,
icon: 'i-lucide-heading-2',
label: 'Heading 2',
tooltip: { text: 'Heading 2' },
},
{
kind: 'heading',
level: 3,
icon: 'i-lucide-heading-3',
label: 'Heading 3',
tooltip: { text: 'Heading 3' },
},
{
kind: 'heading',
level: 4,
icon: 'i-lucide-heading-4',
label: 'Heading 4',
tooltip: { text: 'Heading 4' },
},
],
},
],
[
{
kind: 'mark',
mark: 'bold',
icon: 'i-lucide-bold',
tooltip: { text: 'Bold' },
},
{
kind: 'mark',
mark: 'italic',
icon: 'i-lucide-italic',
tooltip: { text: 'Italic' },
},
{
kind: 'mark',
mark: 'underline',
icon: 'i-lucide-underline',
tooltip: { text: 'Underline' },
},
{
kind: 'mark',
mark: 'strike',
icon: 'i-lucide-strikethrough',
tooltip: { text: 'Strikethrough' },
},
],
[
{
kind: 'textAlign',
align: 'left',
active: isDefaultAlignment(editor),
icon: 'i-lucide-align-left',
tooltip: { text: 'Align left' },
},
{
kind: 'textAlign',
align: 'center',
icon: 'i-lucide-align-center',
tooltip: { text: 'Align center' },
},
{
kind: 'textAlign',
align: 'right',
icon: 'i-lucide-align-right',
tooltip: { text: 'Align right' },
},
{
kind: 'textAlign',
align: 'justify',
icon: 'i-lucide-align-justify',
tooltip: { text: 'Justify' },
},
],
[
{
kind: 'bulletList',
icon: 'i-lucide-list',
tooltip: { text: 'Bullet list' },
},
{
kind: 'orderedList',
icon: 'i-lucide-list-ordered',
tooltip: { text: 'Numbered list' },
},
{
icon: 'i-lucide-indent-decrease',
tooltip: { text: 'Outdent' },
onClick: () => editor.chain().focus().outdent().run(),
},
{
icon: 'i-lucide-indent-increase',
tooltip: { text: 'Indent' },
onClick: () => editor.chain().focus().indent().run(),
},
],
[
{
kind: 'clearFormatting',
icon: 'i-lucide-remove-formatting',
tooltip: { text: 'Clear formatting' },
},
{
icon: 'i-lucide-image-plus',
label: 'Image',
tooltip: { text: 'Add an image' },
onClick: () => emit('request-image'),
},
],
]
}
defineExpose({
focus() {
editorInstance.value?.commands.focus()
},
})
</script>
<template>
<UEditor
v-slot="{ editor }"
:model-value="props.modelValue"
:extensions="extensions"
content-type="html"
:editable="!props.disabled"
:ui="{ base: 'article-content' }"
class="article-editor grid h-full min-h-0 grid-cols-1 grid-rows-[auto_minmax(0,1fr)_auto] lg:grid-cols-[minmax(0,1fr)_17.5rem]"
@update:model-value="onUpdate"
>
<span class="hidden">{{ captureEditor(editor) }}</span>
<UEditorToolbar
:editor="editor"
:items="buildItems(editor)"
layout="fixed"
class="col-span-full row-start-1 border-b border-default bg-default px-6 py-1.5"
/>
<div class="col-start-1 row-start-3">
<slot name="footer" />
</div>
<div
class="col-start-2 row-span-2 row-start-2 hidden min-h-0 lg:block"
>
<slot name="aside" />
</div>
</UEditor>
</template>
<style scoped>
.article-editor :deep([data-slot='content']) {
grid-column-start: 1;
grid-row-start: 2;
min-height: 0;
overflow-y: auto;
padding: 2.5rem 3rem;
background: var(--ui-bg);
}
.article-editor :deep(.ProseMirror) {
max-width: 42rem;
padding: 0;
outline: none;
}
@media (max-width: 47.999rem) {
.article-editor :deep([data-slot='content']) {
padding: 1.5rem 1rem;
}
}
</style>
+9 -3
View File
@@ -2,7 +2,7 @@
withDefaults(
defineProps<{
online: boolean
size?: 'sm' | 'md'
size?: 'sm' | 'md' | 'lg'
}>(),
{
size: 'md',
@@ -11,7 +11,13 @@ withDefaults(
</script>
<template>
<UBadge v-if="online" color="neutral" variant="solid" icon="i-lucide-globe" :size="size">
<UBadge
v-if="online"
color="neutral"
variant="solid"
icon="i-lucide-globe"
:size="size"
>
Online
</UBadge>
<UBadge
@@ -20,7 +26,7 @@ withDefaults(
variant="outline"
icon="i-lucide-pencil-line"
:size="size"
class="px-2"
:class="size === 'lg' ? undefined : 'px-2'"
>
Draft
</UBadge>
+15 -5
View File
@@ -33,18 +33,26 @@ onUnmounted(() => {
})
const controlSize = computed(() => (belowSm.value ? 'xl' : undefined))
const controlClass = computed(() => (belowSm.value ? '!h-11 !text-base' : '!h-10 !text-base'))
const inputClass = computed(() => (belowSm.value ? '!h-11 !text-lg' : '!h-10 !text-base'))
const controlClass = computed(() =>
belowSm.value ? '!h-11 !text-base' : '!h-10 !text-base',
)
const inputClass = computed(() =>
belowSm.value ? '!h-11 !text-lg' : '!h-10 !text-base',
)
const mainClass = computed(() => [
'flex min-h-screen flex-col items-center gap-5',
belowSm.value ? 'bg-default pt-10' : 'bg-muted pt-[12vh]',
])
const cardClass = computed(() => (belowSm.value ? 'w-full' : 'w-full max-w-[400px]'))
const cardClass = computed(() =>
belowSm.value ? 'w-full' : 'w-full max-w-[400px]',
)
const cardUi = computed(() => ({
root: belowSm.value ? 'rounded-none ring-0' : 'rounded-none ring-0 border border-default',
root: belowSm.value
? 'rounded-none ring-0'
: 'rounded-none ring-0 border border-default',
container: belowSm.value
? 'flex flex-col gap-6 px-5 pt-2 pb-0 lg:flex'
: 'flex flex-col gap-6 p-10 sm:p-10 lg:flex',
@@ -102,7 +110,9 @@ watch(
</template>
<template v-if="title" #title>
<h1 ref="titleEl" tabindex="-1" class="outline-none">{{ title }}</h1>
<h1 ref="titleEl" tabindex="-1" class="outline-none">
{{ title }}
</h1>
</template>
<slot v-if="slots.notice" name="notice" />
+136
View File
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
value: number
max: number
min?: number
warnAt?: number
variant?: 'inline' | 'footer'
}>(),
{
min: 0,
warnAt: 0.9,
variant: 'inline',
},
)
function formatThousands(count: number) {
return count.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
}
const overBy = computed(() => props.value - props.max)
const status = computed<'normal' | 'warn' | 'over'>(() => {
if (overBy.value > 0) {
return 'over'
}
if (props.value >= props.max * props.warnAt) {
return 'warn'
}
return 'normal'
})
const overText = computed(() =>
overBy.value === 1
? '1 character over. Shorten the text or remove an image. Formatting counts too.'
: `${formatThousands(overBy.value)} characters over. Shorten the text or remove an image. Formatting counts too.`,
)
const warnText = 'Close to the limit. Images and formatting count too.'
const message = computed(() => {
if (status.value === 'over') {
return overText.value
}
if (status.value === 'warn') {
return warnText
}
return ''
})
const announcement = ref('')
watch(status, (next, previous) => {
if (next === previous) {
return
}
if (next === 'over') {
announcement.value = overText.value
} else if (next === 'warn') {
announcement.value = 'Almost at the length limit.'
} else {
announcement.value = ''
}
})
</script>
<template>
<div
v-if="variant === 'footer'"
class="flex items-center gap-3 border-t border-default bg-default px-6 py-2"
>
<span
class="flex-1 text-xs"
:class="{
'text-warning': status === 'warn',
'text-error': status === 'over',
'text-muted': status === 'normal',
}"
>{{ message }}</span
>
<span
class="flex items-center gap-1.5 font-mono text-xs"
:class="{
'text-warning': status === 'warn',
'text-error': status === 'over',
'text-muted': status === 'normal',
}"
>
<UIcon
v-if="status === 'warn'"
name="i-lucide-triangle-alert"
class="size-3.5"
data-testid="character-counter-warning-icon"
/>
<UIcon
v-else-if="status === 'over'"
name="i-lucide-circle-alert"
class="size-3.5"
data-testid="character-counter-over-icon"
/>
{{ formatThousands(value) }} / {{ formatThousands(max) }}
</span>
<span aria-live="polite" class="sr-only">{{ announcement }}</span>
</div>
<div v-else class="flex flex-col gap-1">
<div
class="flex items-center gap-1.5 font-mono text-sm"
:class="{
'text-warning': status === 'warn',
'text-error': status === 'over',
'text-muted': status === 'normal',
}"
>
<UIcon
v-if="status === 'warn'"
name="i-lucide-triangle-alert"
class="size-4"
data-testid="character-counter-warning-icon"
/>
<UIcon
v-else-if="status === 'over'"
name="i-lucide-circle-alert"
class="size-4"
data-testid="character-counter-over-icon"
/>
<span
>{{ formatThousands(value) }} / {{ formatThousands(max) }}</span
>
</div>
<p v-if="status === 'over'" class="text-sm text-error">
{{ overText }}
</p>
<span aria-live="polite" class="sr-only">{{ announcement }}</span>
</div>
</template>
+35 -16
View File
@@ -1,15 +1,21 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
const props = defineProps<{
title: string
description: string
confirmLabel: string
cancelLabel: string
pendingLabel: string
tone: 'danger' | 'neutral'
action: () => Promise<void>
}>()
const props = withDefaults(
defineProps<{
title: string
description: string
confirmLabel: string
cancelLabel: string
pendingLabel: string
tone: 'danger' | 'neutral'
action: () => Promise<void>
dismissResolvesTo?: 'cancel' | 'confirm'
}>(),
{
dismissResolvesTo: 'cancel',
},
)
const emit = defineEmits<{ close: [confirmed: boolean] }>()
@@ -19,8 +25,12 @@ const error = ref('')
const cancelButton = ref<{ $el: HTMLElement } | null>(null)
let settled = false
const confirmColor = computed(() => (props.tone === 'danger' ? 'error' : 'primary'))
const confirmText = computed(() => (pending.value ? props.pendingLabel : props.confirmLabel))
const confirmColor = computed(() =>
props.tone === 'danger' ? 'error' : 'primary',
)
const confirmText = computed(() =>
pending.value ? props.pendingLabel : props.confirmLabel,
)
onMounted(async () => {
await nextTick()
@@ -38,7 +48,7 @@ function settle(confirmed: boolean) {
watch(open, (value) => {
if (!value) {
settle(false)
settle(props.dismissResolvesTo === 'confirm')
}
})
@@ -52,7 +62,10 @@ async function onConfirm() {
await props.action()
settle(true)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Something went wrong. Try again.'
error.value =
err instanceof Error
? err.message
: 'Something went wrong. Try again.'
} finally {
pending.value = false
}
@@ -73,13 +86,17 @@ function onCancel() {
:description="description"
:dismissible="!pending"
:close="false"
:content="{ role: 'alertdialog', 'aria-busy': pending ? 'true' : undefined }"
:content="{
role: 'alertdialog',
'aria-busy': pending ? 'true' : undefined,
}"
:ui="{
content:
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-[220px] sm:max-w-110 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
header: 'min-h-0 px-6 pt-6 pb-0 sm:px-6 max-sm:px-5 max-sm:pt-5',
title: 'text-lg max-sm:text-[1.0625rem]/6',
description: 'mt-2 text-base text-toned max-sm:text-[0.9375rem]/[23px]',
description:
'mt-2 text-base text-toned max-sm:text-[0.9375rem]/[23px]',
body: 'px-6 pt-4 pb-0 sm:px-6 sm:pt-4 sm:pb-0 max-sm:px-5',
footer: 'p-6 sm:px-6 max-sm:p-5',
}"
@@ -95,7 +112,9 @@ function onCancel() {
/>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<div
class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end"
>
<UButton
ref="cancelButton"
color="neutral"
+51 -18
View File
@@ -9,18 +9,41 @@ import ErrorView from '@/components/ErrorView.vue'
import OfflineBanner from '@/components/OfflineBanner.vue'
import UserMenu from '@/components/UserMenu.vue'
const props = withDefaults(
defineProps<{
mobileTitle?: string
fullBleed?: boolean
}>(),
{
mobileTitle: undefined,
fullBleed: false,
},
)
const bodyUi = computed(() =>
props.fullBleed
? 'gap-0 p-0 sm:gap-0 sm:p-0 lg:p-0'
: 'gap-0 px-4 pt-5 pb-22 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-22 lg:p-8',
)
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const api = useApi()
const navItems = [
{ label: 'Home', to: '/home', icon: 'i-lucide-home' },
{ label: 'Articles', to: '/article', icon: 'i-lucide-file-text' },
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
]
const navItems = computed(() =>
[
{ label: 'Home', to: '/home', icon: 'i-lucide-home' },
{ label: 'Articles', to: '/article', icon: 'i-lucide-file-text' },
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
].map((item) => ({
...item,
active: route.path === item.to || route.path.startsWith(`${item.to}/`),
})),
)
const pageTitle = computed(() => (route.meta.title as string) ?? '')
const routeTitle = computed(() => (route.meta.title as string) ?? '')
const pageTitle = computed(() => props.mobileTitle ?? routeTitle.value)
const sidebarCollapsed = ref(false)
const loggingOut = ref(false)
@@ -42,7 +65,9 @@ async function onLogout() {
<template>
<UDashboardGroup unit="rem">
<a href="#dashboard-content" class="sr-only focus:not-sr-only">Skip to content</a>
<a href="#dashboard-content" class="sr-only focus:not-sr-only"
>Skip to content</a
>
<UDashboardSidebar
v-model:collapsed="sidebarCollapsed"
@@ -88,9 +113,7 @@ async function onLogout() {
</template>
</UDashboardSidebar>
<UDashboardPanel
:ui="{ body: 'gap-0 px-4 pt-5 pb-22 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-22 lg:p-8' }"
>
<UDashboardPanel :ui="{ body: bodyUi }">
<template #header>
<UDashboardNavbar
:toggle="{ ui: { leadingIcon: 'size-5' } }"
@@ -115,12 +138,14 @@ async function onLogout() {
aria-hidden="true"
class="mx-1.5 hidden h-4 w-px bg-(--ui-border) lg:block"
/>
<UBreadcrumb
:items="[{ label: pageTitle }]"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
<slot name="breadcrumb">
<UBreadcrumb
:items="[{ label: routeTitle }]"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</slot>
</template>
<template #right>
<slot name="navbar-right" />
@@ -129,7 +154,12 @@ async function onLogout() {
</template>
<template #body>
<div id="dashboard-content">
<div
id="dashboard-content"
:class="
fullBleed ? 'flex h-full min-h-0 flex-1 flex-col' : ''
"
>
<OfflineBanner />
<AppBoot v-if="session.status === 'checking'" />
@@ -138,7 +168,10 @@ async function onLogout() {
kind="error"
@retry="onRetry"
/>
<div v-else>
<div
v-else
:class="fullBleed ? 'flex min-h-0 flex-1 flex-col' : ''"
>
<slot />
</div>
</div>
+50 -24
View File
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
const props = withDefaults(
defineProps<{
kind: 'not-found' | 'error'
kind: 'not-found' | 'error' | 'article-not-found'
breadcrumb?: string
}>(),
{
@@ -18,27 +18,40 @@ const emit = defineEmits<{
const router = useRouter()
const copy = computed(() =>
props.kind === 'not-found'
? {
title: 'Page not found',
text: 'The link may be mistyped, or the page may have moved.',
primary: 'Go to Home',
secondary: 'All articles',
}
: {
title: 'Something went wrong',
text: "This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.",
primary: 'Try again',
secondary: 'Go to Home',
},
)
const copy = computed(() => {
if (props.kind === 'not-found') {
return {
title: 'Page not found',
text: 'The link may be mistyped, or the page may have moved.',
primary: 'Go to Home',
secondary: 'All articles',
}
}
if (props.kind === 'article-not-found') {
return {
title: "This article doesn't exist.",
text: 'It may have been deleted, or the link is wrong.',
primary: 'All articles',
secondary: '',
}
}
return {
title: 'Something went wrong',
text: "This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.",
primary: 'Try again',
secondary: 'Go to Home',
}
})
function onPrimary() {
if (props.kind === 'error') {
emit('retry')
return
}
if (props.kind === 'article-not-found') {
router.push('/article')
return
}
router.push('/home')
}
@@ -52,17 +65,30 @@ function onSecondary() {
</script>
<template>
<div role="alert" class="space-y-4 p-6">
<nav aria-label="Breadcrumb">
<div role="alert" class="flex max-w-140 flex-col gap-3.5 p-8">
<nav v-if="kind !== 'article-not-found'" aria-label="Breadcrumb">
<span>{{ breadcrumb }}</span>
</nav>
<h1 tabindex="-1">{{ copy.title }}</h1>
<p>{{ copy.text }}</p>
<span
v-if="kind === 'article-not-found'"
aria-hidden="true"
class="font-mono text-sm text-muted"
>404</span
>
<h1 tabindex="-1" class="text-2xl font-semibold text-highlighted">
{{ copy.title }}
</h1>
<p v-if="copy.text" class="text-sm text-toned">{{ copy.text }}</p>
<div class="flex gap-2">
<UButton @click="onPrimary">{{ copy.primary }}</UButton>
<UButton variant="outline" color="neutral" @click="onSecondary">{{
copy.secondary
}}</UButton>
<UButton class="h-8" @click="onPrimary">{{ copy.primary }}</UButton>
<UButton
v-if="copy.secondary"
variant="outline"
color="neutral"
class="h-8"
@click="onSecondary"
>{{ copy.secondary }}</UButton
>
</div>
</div>
</template>
+13 -3
View File
@@ -16,16 +16,26 @@ function onCancel() {
<template #body>
<UForm :state="{ title, body }" class="space-y-4">
<UFormField label="Title" name="title">
<UInput v-model="title" placeholder="Article title" class="w-full" />
<UInput
v-model="title"
placeholder="Article title"
class="w-full"
/>
</UFormField>
<UFormField label="Body" name="body">
<UTextarea v-model="body" placeholder="Start writing…" class="w-full" />
<UTextarea
v-model="body"
placeholder="Start writing…"
class="w-full"
/>
</UFormField>
</UForm>
</template>
<template #footer>
<div class="flex w-full justify-end gap-2">
<UButton color="neutral" variant="outline" @click="onCancel">Cancel</UButton>
<UButton color="neutral" variant="outline" @click="onCancel"
>Cancel</UButton
>
<UButton disabled>Create article</UButton>
</div>
</template>
+9 -2
View File
@@ -44,8 +44,15 @@ const items = computed(() =>
<template>
<ul class="flex flex-col gap-1.5">
<li v-for="item in items" :key="item.label" class="flex items-center gap-2 text-xs/[18px]">
<UIcon :name="item.icon" :class="['size-3.5 shrink-0', item.iconClass]" />
<li
v-for="item in items"
:key="item.label"
class="flex items-center gap-2 text-xs/[18px]"
>
<UIcon
:name="item.icon"
:class="['size-3.5 shrink-0', item.iconClass]"
/>
<span :class="item.textClass">{{ item.label }}</span>
<span class="sr-only">{{ item.met ? 'met' : 'not met' }}</span>
</li>
+58
View File
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
online: boolean
dirty: boolean
pending?: string
disabled?: boolean
hideIcon?: boolean
}>(),
{
pending: undefined,
disabled: false,
hideIcon: false,
},
)
defineEmits<{ publish: []; unpublish: [] }>()
const label = computed(() => {
if (props.pending) {
return props.pending
}
if (props.online) {
return 'Unpublish'
}
if (props.dirty) {
return 'Save and publish'
}
return 'Publish'
})
const icon = computed(() => {
if (props.hideIcon) {
return undefined
}
if (props.online) {
return 'i-lucide-globe-lock'
}
return 'i-lucide-globe'
})
const variant = computed(() => (props.online ? 'outline' : 'solid'))
</script>
<template>
<UButton
:color="online ? 'neutral' : 'primary'"
:variant="variant"
:icon="icon"
:disabled="disabled"
:loading="Boolean(pending)"
@click="online ? $emit('unpublish') : $emit('publish')"
>
{{ label }}
</UButton>
</template>
+92
View File
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
const props = defineProps<{
state: 'clean' | 'dirty' | 'saving' | 'saved' | 'error' | 'offline'
savedAt?: Date | null
}>()
defineEmits<{ retry: [] }>()
const HOUR_MS = 60 * 60 * 1000
const TICK_MS = 15000
const now = ref(Date.now())
let ticker: ReturnType<typeof setInterval> | null = null
onMounted(() => {
ticker = setInterval(() => {
now.value = Date.now()
}, TICK_MS)
})
onUnmounted(() => {
if (ticker !== null) {
clearInterval(ticker)
}
})
function formatClock(date: Date) {
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `${hours}:${minutes}`
}
const savedText = computed(() => {
if (!props.savedAt) {
return 'Saved'
}
const diffMs = now.value - props.savedAt.getTime()
if (diffMs < HOUR_MS) {
return `Saved at ${formatClock(props.savedAt)}`
}
const minutes = Math.floor(diffMs / 60000)
return minutes === 1 ? 'Saved 1 min ago' : `Saved ${minutes} min ago`
})
</script>
<template>
<span
aria-live="polite"
class="inline-flex shrink-0 items-center gap-1.5 text-sm whitespace-nowrap"
:class="{
'text-warning': state === 'dirty',
'text-error': state === 'error',
'text-muted': state !== 'dirty' && state !== 'error',
}"
>
<template v-if="state === 'clean' || state === 'saved'">
<UIcon name="i-lucide-check" class="size-3.5" />
<span>{{ savedText }}</span>
</template>
<template v-else-if="state === 'dirty'">
<UIcon name="i-lucide-circle-dot" class="size-3.5" />
<span>Unsaved changes</span>
</template>
<template v-else-if="state === 'saving'">
<UIcon
name="i-lucide-loader-circle"
class="size-3.5 animate-spin"
/>
<span>Saving…</span>
</template>
<template v-else-if="state === 'error'">
<UIcon name="i-lucide-circle-alert" class="size-3.5" />
<span>Save failed</span>
<UButton
size="xs"
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-6 gap-1 px-2 text-xs text-highlighted"
@click="$emit('retry')"
>
Retry
</UButton>
</template>
<template v-else-if="state === 'offline'">
<UIcon name="i-lucide-wifi-off" class="size-3.5" />
<span>Waiting for connection</span>
</template>
</span>
</template>
+9 -3
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useColorMode, type ColorModePreference } from '@/composables/useColorMode'
import {
useColorMode,
type ColorModePreference,
} from '@/composables/useColorMode'
const props = defineProps<{
username: string
@@ -16,7 +19,9 @@ const colorMode = useColorMode()
const initials = computed(() => {
const parts = props.username.split(/[^a-zA-Z0-9]+/).filter(Boolean)
if (parts.length >= 2) {
return ((parts[0] ?? '').charAt(0) + (parts[1] ?? '').charAt(0)).toUpperCase()
return (
(parts[0] ?? '').charAt(0) + (parts[1] ?? '').charAt(0)
).toUpperCase()
}
return (parts[0] ?? '').slice(0, 2).toUpperCase()
})
@@ -70,7 +75,8 @@ const items = computed(() => [
size="sm"
:ui="{
root: 'size-6.5 bg-secondary-300',
fallback: 'text-[0.6875rem] font-semibold text-primary-900',
fallback:
'text-[0.6875rem] font-semibold text-primary-900',
}"
/>
</template>
+10 -3
View File
@@ -8,7 +8,10 @@ beforeEach(() => {
resetAuthShellNavigation()
})
function mountShell(props: Record<string, unknown>, slots: Record<string, unknown> = {}) {
function mountShell(
props: Record<string, unknown>,
slots: Record<string, unknown> = {},
) {
return mount(AuthShell, {
props,
slots,
@@ -63,13 +66,17 @@ describe('AuthShell renders its notice and footer slots', () => {
const notice = wrapper.find('span')
expect(notice.text()).toBe('Your session expired.')
expect(wrapper.findAll('[data-slot="root"][data-orientation]').length).toBe(1)
expect(
wrapper.findAll('[data-slot="root"][data-orientation]').length,
).toBe(1)
const card = wrapper.find('[data-testid="auth-card"]')
expect(card.element.contains(notice.element)).toBe(true)
const html = wrapper.html()
expect(html.indexOf('<h1')).toBeLessThan(html.indexOf('Your session expired.'))
expect(html.indexOf('<h1')).toBeLessThan(
html.indexOf('Your session expired.'),
)
const footerLink = wrapper.find('a[href="/register/reset/request"]')
expect(footerLink.exists()).toBe(true)
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import CharacterCounter from '../CharacterCounter.vue'
function mountCounter(value: number) {
return mount(CharacterCounter, {
props: { value, max: 10000 },
global: { plugins: [uiPlugin] },
})
}
describe('Below the warning threshold', () => {
it('shows the plain count with no icon', () => {
const wrapper = mountCounter(4318)
expect(wrapper.text()).toContain('4 318 / 10 000')
expect(
wrapper
.find('[data-testid="character-counter-warning-icon"]')
.exists(),
).toBe(false)
expect(
wrapper
.find('[data-testid="character-counter-over-icon"]')
.exists(),
).toBe(false)
})
})
describe('Near the limit — 90% or more', () => {
it('shows the warning icon without an over-limit message', () => {
const wrapper = mountCounter(9240)
expect(wrapper.text()).toContain('9 240 / 10 000')
expect(
wrapper
.find('[data-testid="character-counter-warning-icon"]')
.exists(),
).toBe(true)
expect(wrapper.text()).not.toContain('characters over')
})
})
describe('Too long — over the max', () => {
it('shows the over-limit icon and the exact overage copy', () => {
const wrapper = mountCounter(10214)
expect(wrapper.text()).toContain('10 214 / 10 000')
expect(
wrapper
.find('[data-testid="character-counter-over-icon"]')
.exists(),
).toBe(true)
expect(wrapper.text()).toContain(
'214 characters over. Shorten the text or remove an image. Formatting counts too.',
)
})
})
@@ -74,9 +74,14 @@ describe('Cancelling — the action is in flight and the dialog cannot be dismis
await clickButton('Cancel registration')
expect(action).toHaveBeenCalledTimes(1)
expect((findButtonElement('Keep my account') as HTMLButtonElement).disabled).toBe(true)
expect(
(findButtonElement('Keep my account') as HTMLButtonElement)
.disabled,
).toBe(true)
expect(findButtonElement('Cancelling…')).toBeTruthy()
expect((findButtonElement('Cancelling…') as HTMLButtonElement).disabled).toBe(true)
expect(
(findButtonElement('Cancelling…') as HTMLButtonElement).disabled,
).toBe(true)
expect(document.querySelector('[aria-busy="true"]')).toBeTruthy()
resolveAction?.()
@@ -112,18 +117,72 @@ describe('Cancelled — resolving the action closes the dialog', () => {
})
})
describe('Dismissing (Esc or the overlay) defaults to the cancel outcome', () => {
it('emits close(false) without calling the action', async () => {
const action = vi.fn(() => Promise.resolve())
const wrapper = mountDialog(action)
await flushPromises()
await wrapper.setProps({ open: false })
expect(action).not.toHaveBeenCalled()
expect(wrapper.emitted('close')).toEqual([[false]])
wrapper.unmount()
})
})
describe('dismissResolvesTo="confirm" makes Esc/overlay resolve as confirmed', () => {
it('emits close(true) on dismiss without calling the action', async () => {
const action = vi.fn(() => Promise.resolve())
const wrapper = mount(ConfirmDialog, {
attachTo: document.body,
props: {
title: 'Leave without saving?',
description:
'Your changes to "How we price a bathroom renovation" haven’t been saved.',
confirmLabel: 'Keep editing',
cancelLabel: 'Discard changes',
pendingLabel: 'Keep editing',
tone: 'neutral',
action,
dismissResolvesTo: 'confirm',
},
global: {
plugins: [uiPlugin],
},
})
await flushPromises()
await wrapper.setProps({ open: false })
expect(action).not.toHaveBeenCalled()
expect(wrapper.emitted('close')).toEqual([[true]])
wrapper.unmount()
})
})
describe('Cancel failed — the dialog stays open and shows the failure inline', () => {
it('re-enables both buttons and shows the rejection message inline', async () => {
const action = vi.fn(() => Promise.reject(new Error("Couldn't cancel registration.")))
const action = vi.fn(() =>
Promise.reject(new Error("Couldn't cancel registration.")),
)
const wrapper = mountDialog(action)
await flushPromises()
await clickButton('Cancel registration')
expect(wrapper.emitted('close')).toBeFalsy()
expect(document.body.textContent).toContain("Couldn't cancel registration.")
expect((findButtonElement('Keep my account') as HTMLButtonElement).disabled).toBe(false)
expect((findButtonElement('Cancel registration') as HTMLButtonElement).disabled).toBe(false)
expect(document.body.textContent).toContain(
"Couldn't cancel registration.",
)
expect(
(findButtonElement('Keep my account') as HTMLButtonElement)
.disabled,
).toBe(false)
expect(
(findButtonElement('Cancel registration') as HTMLButtonElement)
.disabled,
).toBe(false)
wrapper.unmount()
})
})
@@ -56,7 +56,9 @@ describe('DashboardShell renders the three navigation destinations', () => {
describe('DashboardShell shows the offline banner when the browser reports offline', () => {
it('shows the banner while offline and hides it once back online', async () => {
const onLineSpy = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
const onLineSpy = vi
.spyOn(navigator, 'onLine', 'get')
.mockReturnValue(false)
const wrapper = await mountShell('/article')
@@ -77,12 +79,16 @@ describe('The sidebar header renders AppLogo and the "RenewCMS" wordmark', () =>
const wrapper = await mountShell('/home')
const header = wrapper.find('[data-slot="header"]')
expect(header.find('svg[role="img"][aria-label="RenewCMS"]').exists()).toBe(true)
expect(
header.find('svg[role="img"][aria-label="RenewCMS"]').exists(),
).toBe(true)
expect(header.text()).toContain('RenewCMS')
expect(header.find('a').exists()).toBe(false)
const headerHtml = header.html()
expect(headerHtml.indexOf('<svg')).toBeLessThan(headerHtml.indexOf('RenewCMS</span>'))
expect(headerHtml.indexOf('<svg')).toBeLessThan(
headerHtml.indexOf('RenewCMS</span>'),
)
})
})
@@ -91,7 +97,9 @@ describe('The sidebar collapse trigger toggles the rail and exposes aria-expande
await mountShell('/home')
const collapseButton = () =>
document.body.querySelector('[data-testid="sidebar-collapse"]') as HTMLButtonElement
document.body.querySelector(
'[data-testid="sidebar-collapse"]',
) as HTMLButtonElement
expect(collapseButton().getAttribute('aria-expanded')).toBe('true')
@@ -112,11 +120,13 @@ describe('A successful logout clears the session and navigates to /login', () =>
await mountShell('/home')
const logOutItem = () =>
Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
(el) => el.textContent?.trim() === 'Log out',
) as HTMLElement
Array.from(
document.body.querySelectorAll('[role="menuitem"]'),
).find((el) => el.textContent?.trim() === 'Log out') as HTMLElement
const trigger = document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement
const trigger = document.body.querySelector(
'button[aria-haspopup]',
) as HTMLButtonElement
trigger.click()
await flushPromises()
@@ -141,16 +151,28 @@ describe('Collapsed sidebar hides the wordmark and username text but keeps an ac
collapseButton.click()
await flushPromises()
const header = document.body.querySelector('[data-slot="header"]') as HTMLElement
expect(header.querySelector('svg[role="img"][aria-label="RenewCMS"]')).toBeTruthy()
const header = document.body.querySelector(
'[data-slot="header"]',
) as HTMLElement
expect(
header.querySelector('svg[role="img"][aria-label="RenewCMS"]'),
).toBeTruthy()
expect(header.textContent).not.toContain('RenewCMS')
expect(header.querySelector('span[aria-hidden="true"]')).toBeNull()
const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
const footer = document.body.querySelector(
'[data-slot="footer"]',
) as HTMLElement
expect(footer.textContent).not.toContain('marta')
const userMenuTrigger = footer.querySelector('button[aria-haspopup]') as HTMLButtonElement
expect(userMenuTrigger.getAttribute('aria-label')).toBe('marta — account menu')
expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
const userMenuTrigger = footer.querySelector(
'button[aria-haspopup]',
) as HTMLButtonElement
expect(userMenuTrigger.getAttribute('aria-label')).toBe(
'marta — account menu',
)
expect(
footer.querySelector('[data-slot="fallback"]')?.textContent,
).toBe('MA')
})
})
+18 -6
View File
@@ -18,7 +18,9 @@ async function mountNotFound(breadcrumb?: string) {
const router = makeRouter()
await router.push('/')
return mount(ErrorView, {
props: breadcrumb ? { kind: 'not-found', breadcrumb } : { kind: 'not-found' },
props: breadcrumb
? { kind: 'not-found', breadcrumb }
: { kind: 'not-found' },
global: {
plugins: [router],
},
@@ -29,20 +31,30 @@ describe('ErrorView not-found kind', () => {
it('renders the not-found copy with a breadcrumb, primary and secondary actions', async () => {
const wrapper = await mountNotFound('Not found')
expect(wrapper.find('nav[aria-label="Breadcrumb"]').text()).toBe('Not found')
expect(wrapper.find('nav[aria-label="Breadcrumb"]').text()).toBe(
'Not found',
)
expect(wrapper.find('h1').text()).toBe('Page not found')
expect(wrapper.text()).toContain('The link may be mistyped, or the page may have moved.')
expect(wrapper.text()).toContain(
'The link may be mistyped, or the page may have moved.',
)
const buttons = wrapper.findAll('button')
expect(buttons.some((button) => button.text() === 'Go to Home')).toBe(true)
expect(buttons.some((button) => button.text() === 'All articles')).toBe(true)
expect(buttons.some((button) => button.text() === 'Go to Home')).toBe(
true,
)
expect(buttons.some((button) => button.text() === 'All articles')).toBe(
true,
)
})
it('routes the primary action to /home and the secondary action to /article', async () => {
const wrapper = await mountNotFound()
const router = wrapper.vm.$router
const primary = wrapper.findAll('button').find((button) => button.text() === 'Go to Home')
const primary = wrapper
.findAll('button')
.find((button) => button.text() === 'Go to Home')
await primary?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/home')
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import PublishControl from '../PublishControl.vue'
function mountControl(props: {
online: boolean
dirty: boolean
disabled?: boolean
}) {
return mount(PublishControl, {
props,
global: { plugins: [uiPlugin] },
})
}
describe('Draft, clean', () => {
it('reads "Publish"', () => {
const wrapper = mountControl({ online: false, dirty: false })
expect(wrapper.text()).toBe('Publish')
})
})
describe('Draft, dirty', () => {
it('reads "Save and publish"', () => {
const wrapper = mountControl({ online: false, dirty: true })
expect(wrapper.text()).toBe('Save and publish')
})
})
describe('Online', () => {
it('reads "Unpublish" regardless of dirty state', () => {
const wrapper = mountControl({ online: true, dirty: true })
expect(wrapper.text()).toBe('Unpublish')
})
})
describe('Disabled', () => {
it('disables the button', () => {
const wrapper = mountControl({
online: false,
dirty: false,
disabled: true,
})
expect(
(wrapper.find('button').element as HTMLButtonElement).disabled,
).toBe(true)
})
})
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import SaveStatus from '../SaveStatus.vue'
afterEach(() => {
vi.useRealTimers()
})
describe('Saved within the hour', () => {
it('reads "Saved at HH:MM"', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-26T11:10:00Z'))
const wrapper = mount(SaveStatus, {
props: {
state: 'saved',
savedAt: new Date('2026-09-26T11:02:00Z'),
},
global: { plugins: [uiPlugin] },
})
expect(wrapper.text()).toContain('Saved at 11:02')
})
})
describe('Saved over an hour ago', () => {
it('reads "Saved n min ago"', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-26T12:32:00Z'))
const wrapper = mount(SaveStatus, {
props: {
state: 'saved',
savedAt: new Date('2026-09-26T11:02:00Z'),
},
global: { plugins: [uiPlugin] },
})
expect(wrapper.text()).toContain('Saved 90 min ago')
})
})
describe('Dirty', () => {
it('reads "Unsaved changes"', () => {
const wrapper = mount(SaveStatus, {
props: { state: 'dirty' },
global: { plugins: [uiPlugin] },
})
expect(wrapper.text()).toContain('Unsaved changes')
})
})
describe('Saving', () => {
it('reads "Saving…"', () => {
const wrapper = mount(SaveStatus, {
props: { state: 'saving' },
global: { plugins: [uiPlugin] },
})
expect(wrapper.text()).toContain('Saving…')
})
})
describe('Error', () => {
it('reads "Save failed" and emits retry from the Retry button', async () => {
const wrapper = mount(SaveStatus, {
props: { state: 'error' },
global: { plugins: [uiPlugin] },
})
expect(wrapper.text()).toContain('Save failed')
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('retry')).toHaveLength(1)
})
})
describe('Offline', () => {
it('reads "Waiting for connection"', () => {
const wrapper = mount(SaveStatus, {
props: { state: 'offline' },
global: { plugins: [uiPlugin] },
})
expect(wrapper.text()).toContain('Waiting for connection')
})
})
+35 -15
View File
@@ -28,7 +28,9 @@ function findMenuItemsByRole(role: string) {
}
function findCheckboxItem(label: string) {
return findMenuItemsByRole('menuitemcheckbox').find((el) => el.textContent?.trim() === label)
return findMenuItemsByRole('menuitemcheckbox').find(
(el) => el.textContent?.trim() === label,
)
}
beforeEach(() => {
@@ -56,8 +58,8 @@ describe("UserMenu's dropdown lists the account label, color-mode checkboxes, th
expect(groups[0].textContent).toContain('marta')
const checkboxLabels = findMenuItemsByRole('menuitemcheckbox').map((el) =>
el.textContent?.trim(),
const checkboxLabels = findMenuItemsByRole('menuitemcheckbox').map(
(el) => el.textContent?.trim(),
)
expect(checkboxLabels).toEqual(['System', 'Light', 'Dark'])
@@ -82,9 +84,15 @@ describe("UserMenu's dropdown lists the account label, color-mode checkboxes, th
const wrapper = mountMenu({ username: 'marta', collapsed: false })
await openMenu()
expect(findCheckboxItem('Dark')?.getAttribute('aria-checked')).toBe('true')
expect(findCheckboxItem('Light')?.getAttribute('aria-checked')).toBe('false')
expect(findCheckboxItem('System')?.getAttribute('aria-checked')).toBe('false')
expect(findCheckboxItem('Dark')?.getAttribute('aria-checked')).toBe(
'true',
)
expect(findCheckboxItem('Light')?.getAttribute('aria-checked')).toBe(
'false',
)
expect(findCheckboxItem('System')?.getAttribute('aria-checked')).toBe(
'false',
)
wrapper.unmount()
})
@@ -105,15 +113,23 @@ describe('Selecting a color-mode item updates the preference and its checked sta
const label = to.charAt(0).toUpperCase() + to.slice(1)
const fromLabel = from.charAt(0).toUpperCase() + from.slice(1)
findCheckboxItem(label)?.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
findCheckboxItem(label)?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
findCheckboxItem(label)?.dispatchEvent(
new MouseEvent('pointerup', { bubbles: true }),
)
findCheckboxItem(label)?.dispatchEvent(
new MouseEvent('click', { bubbles: true }),
)
await flushPromises()
expect(colorMode.preference).toBe(to)
await openMenu()
expect(findCheckboxItem(label)?.getAttribute('aria-checked')).toBe('true')
expect(findCheckboxItem(fromLabel)?.getAttribute('aria-checked')).toBe('false')
expect(findCheckboxItem(label)?.getAttribute('aria-checked')).toBe(
'true',
)
expect(findCheckboxItem(fromLabel)?.getAttribute('aria-checked')).toBe(
'false',
)
wrapper.unmount()
})
@@ -124,9 +140,9 @@ describe('A successful logout clears the session and navigates to /login', () =>
const wrapper = mountMenu({ username: 'marta', collapsed: false })
await openMenu()
const logOutItem = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
(el) => el.textContent?.trim() === 'Log out',
)
const logOutItem = Array.from(
document.body.querySelectorAll('[role="menuitem"]'),
).find((el) => el.textContent?.trim() === 'Log out')
;(logOutItem as HTMLElement)?.click()
await flushPromises()
@@ -141,8 +157,12 @@ describe('Collapsed sidebar hides the wordmark and username text but keeps an ac
const wrapper = mountMenu({ username: 'marta', collapsed: true })
expect(wrapper.text()).not.toContain('marta')
expect(findTriggerButton()?.getAttribute('aria-label')).toBe('marta — account menu')
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
expect(findTriggerButton()?.getAttribute('aria-label')).toBe(
'marta — account menu',
)
expect(
document.body.querySelector('[data-slot="fallback"]')?.textContent,
).toBe('MA')
await openMenu()
@@ -33,7 +33,8 @@ let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountLogin() {
setActivePinia(createPinia())
global.fetch = (() => mockResponse(200, { open: false })) as unknown as typeof fetch
global.fetch = (() =>
mockResponse(200, { open: false })) as unknown as typeof fetch
await router.push('/login')
const wrapper = mount(LoginView, {
attachTo: document.body,
@@ -69,7 +70,9 @@ describe('AppLogo renders the brand mark as inline SVG, and theme-switches', ()
expect(card.element.contains(svg.element)).toBe(true)
const html = wrapper.html()
expect(html.indexOf('<svg')).toBeGreaterThan(html.indexOf('data-testid="auth-card"'))
expect(html.indexOf('<svg')).toBeGreaterThan(
html.indexOf('data-testid="auth-card"'),
)
expect(html.indexOf('<svg')).toBeLessThan(html.indexOf('<h1'))
let centeringAncestor = svg.element.parentElement
@@ -110,7 +113,9 @@ describe("UPageCard's title is the page h1, and its description uses UPageCard's
expect(heading.text()).toBe('Log in')
expect(heading.attributes('tabindex')).toBe('-1')
expect(wrapper.text()).toContain('Welcome back. Log in to manage your articles.')
expect(wrapper.text()).toContain(
'Welcome back. Log in to manage your articles.',
)
expect(wrapper.findAll('p').length).toBe(0)
})
})
@@ -175,8 +180,12 @@ describe('The login form is a UForm', () => {
const form = wrapper.find('form')
expect(form.exists()).toBe(true)
expect(form.attributes('id')).toMatch(/^v-/)
expect(form.find('input[placeholder="Your username"]').exists()).toBe(true)
expect(form.find('input[autocomplete="current-password"]').exists()).toBe(true)
expect(form.find('input[placeholder="Your username"]').exists()).toBe(
true,
)
expect(
form.find('input[autocomplete="current-password"]').exists(),
).toBe(true)
expect(form.find('button[type="submit"]').exists()).toBe(true)
})
})
@@ -185,14 +194,20 @@ describe('"Forgot password?" renders in the Password field\'s hint slot', () =>
it('renders the link through the hint slot, and the label reads only Password', async () => {
const wrapper = await mountLogin()
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
const fieldRoot = passwordInput.element.closest('[data-slot="root"][data-orientation]')
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
const fieldRoot = passwordInput.element.closest(
'[data-slot="root"][data-orientation]',
)
expect(fieldRoot).toBeTruthy()
const hint = fieldRoot!.querySelector('[data-slot="hint"]')
expect(hint).toBeTruthy()
expect(hint!.textContent?.trim()).toBe('Forgot password?')
expect(hint!.querySelector('a[href="/register/reset/request"]')).toBeTruthy()
expect(
hint!.querySelector('a[href="/register/reset/request"]'),
).toBeTruthy()
const label = fieldRoot!.querySelector('[data-slot="label"]')
expect(label!.textContent?.trim()).toBe('Password')
@@ -204,7 +219,9 @@ describe('Inputs are neutral by default and turn to the error edge only when the
const wrapper = await mountLogin()
const usernameInput = wrapper.find('input[placeholder="Your username"]')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
expect(usernameInput.classes()).toContain('ring-accented')
expect(usernameInput.classes()).not.toContain('ring-error')
expect(passwordInput.classes()).toContain('ring-accented')
@@ -215,7 +232,9 @@ describe('Inputs are neutral by default and turn to the error edge only when the
await flushPromises()
const usernameAfter = wrapper.find('input[placeholder="Your username"]')
const passwordAfter = wrapper.find('input[autocomplete="current-password"]')
const passwordAfter = wrapper.find(
'input[autocomplete="current-password"]',
)
expect(usernameAfter.classes()).toContain('ring-error')
expect(usernameAfter.classes()).not.toContain('ring-accented')
expect(passwordAfter.classes()).toContain('ring-error')
@@ -33,7 +33,8 @@ let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountLogin(path = '/login') {
setActivePinia(createPinia())
global.fetch = (() => mockResponse(200, { open: false })) as unknown as typeof fetch
global.fetch = (() =>
mockResponse(200, { open: false })) as unknown as typeof fetch
await router.push(path)
const wrapper = mount(LoginView, {
attachTo: document.body,
@@ -77,7 +78,9 @@ describe('A field error shows the circle-alert icon with no extra focus halo', (
await wrapper.find('form').trigger('submit')
await flushPromises()
const fieldRoots = wrapper.findAll('[data-slot="root"][data-orientation]')
const fieldRoots = wrapper.findAll(
'[data-slot="root"][data-orientation]',
)
const usernameError = fieldRoots[0]!.find('[data-slot="error"]')
const passwordError = fieldRoots[1]!.find('[data-slot="error"]')
@@ -94,7 +97,9 @@ describe('A field error shows the circle-alert icon with no extra focus halo', (
expect(usernameInput.classes()).toContain('ring-error')
expect(usernameInput.classes()).toContain('focus-visible:ring-error')
expect(usernameInput.classes()).not.toContain('ring-primary')
expect(usernameInput.classes()).not.toContain('focus-visible:ring-primary')
expect(usernameInput.classes()).not.toContain(
'focus-visible:ring-primary',
)
})
})
@@ -141,19 +146,31 @@ describe('The alert renders inside the card, under the description', () => {
expect(card.element.contains(alert.element)).toBe(true)
expect(alert.text()).toContain(text)
const rootsInCard = card.findAll('[data-slot="root"][data-orientation]')
expect(rootsInCard.some((root) => root.classes().includes('bg-primary'))).toBe(false)
const rootsInCard = card.findAll(
'[data-slot="root"][data-orientation]',
)
expect(
rootsInCard.some((root) =>
root.classes().includes('bg-primary'),
),
).toBe(false)
const usernameInput = wrapper.find('input[placeholder="Your username"]')
const usernameInput = wrapper.find(
'input[placeholder="Your username"]',
)
const html = card.html()
expect(html.indexOf('Welcome back')).toBeGreaterThan(-1)
expect(html.indexOf('Welcome back')).toBeLessThan(html.indexOf(text))
expect(html.indexOf('Welcome back')).toBeLessThan(
html.indexOf(text),
)
expect(html.indexOf(text)).toBeLessThan(html.indexOf('Username'))
expect(card.element.contains(usernameInput.element)).toBe(true)
expect(alert.classes().some((c) => /^bg-\(--rc-(info|success)-soft\)$/.test(c))).toBe(
true,
)
expect(
alert
.classes()
.some((c) => /^bg-\(--rc-(info|success)-soft\)$/.test(c)),
).toBe(true)
},
)
})
+18 -5
View File
@@ -5,7 +5,11 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import { ApiError, NetworkError, isOffline, useApi } from '@/composables/useApi'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
return {
ok: status >= 200 && status < 300,
status,
@@ -18,7 +22,10 @@ function setOnline(value: boolean) {
Object.defineProperty(navigator, 'onLine', { configurable: true, value })
}
async function expectApiError(call: Promise<unknown>, matcher: Record<string, unknown>) {
async function expectApiError(
call: Promise<unknown>,
matcher: Record<string, unknown>,
) {
await expect(call).rejects.toBeInstanceOf(ApiError)
await expect(call).rejects.toMatchObject(matcher)
}
@@ -102,7 +109,9 @@ describe('useApi global 401/403/429/offline handling', () => {
await expectApiError(call, { status: 403 })
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register/pending'),
)
expect(session.session).toEqual({ verified: false })
})
@@ -150,7 +159,9 @@ describe('useApi global 401/403/429/offline handling', () => {
})
it('A 429 on a rate-limited endpoint exposes the Retry-After seconds', async () => {
fetchMock.mockResolvedValue(mockResponse(429, null, { 'Retry-After': '30' }))
fetchMock.mockResolvedValue(
mockResponse(429, null, { 'Retry-After': '30' }),
)
const call = useApi().request('/v1/auth/login', { method: 'POST' })
@@ -160,7 +171,9 @@ describe('useApi global 401/403/429/offline handling', () => {
it('A 429 with no Retry-After header exposes an undefined retryAfter', async () => {
fetchMock.mockResolvedValue(mockResponse(429))
const call = useApi().request('/v1/auth/password-reset', { method: 'POST' })
const call = useApi().request('/v1/auth/password-reset', {
method: 'POST',
})
await expectApiError(call, { status: 429, retryAfter: undefined })
})
@@ -0,0 +1,127 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import {
useArticleDraft,
readMirroredDraft,
} from '@/composables/useArticleDraft'
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
afterEach(() => {
sessionStorage.clear()
vi.restoreAllMocks()
})
describe('Loading an article without a mirrored draft starts clean', () => {
it('sets the body and clean state from the given article', () => {
setActivePinia(createPinia())
const draft = useArticleDraft(12)
draft.load('<p>Original</p>', new Date('2026-09-26T11:02:00Z'))
expect(draft.body.value).toBe('<p>Original</p>')
expect(draft.state.value).toBe('clean')
expect(draft.savedAt.value).toEqual(new Date('2026-09-26T11:02:00Z'))
})
})
describe('Loading an article with a mirrored draft restores it as dirty', () => {
it('prefers the sessionStorage body over the loaded one', () => {
setActivePinia(createPinia())
sessionStorage.setItem('renewcms:article-draft:12', '<p>Restored</p>')
const draft = useArticleDraft(12)
draft.load('<p>Original</p>', new Date('2026-09-26T11:02:00Z'))
expect(draft.body.value).toBe('<p>Restored</p>')
expect(draft.state.value).toBe('dirty')
})
})
describe('Editing moves the state to dirty', () => {
it('updates the body and sets state to dirty', () => {
setActivePinia(createPinia())
const draft = useArticleDraft(12)
draft.load('<p>Original</p>', new Date())
draft.edit('<p>Changed</p>')
expect(draft.body.value).toBe('<p>Changed</p>')
expect(draft.state.value).toBe('dirty')
})
})
describe('A successful save sets state saved and records the time', () => {
it('resolves "saved" and clears any mirrored draft', async () => {
setActivePinia(createPinia())
global.fetch = vi
.fn()
.mockResolvedValue(
mockResponse(200, { id: 12, body: '<p>Changed</p>' }),
)
const draft = useArticleDraft(12)
draft.load('<p>Original</p>', new Date())
draft.edit('<p>Changed</p>')
const outcome = await draft.save()
expect(outcome).toBe('saved')
expect(draft.state.value).toBe('saved')
expect(draft.savedAt.value).toBeInstanceOf(Date)
})
})
describe('A 500 response sets state error and keeps the body', () => {
it('resolves "error" without touching the body', async () => {
setActivePinia(createPinia())
global.fetch = vi.fn().mockResolvedValue(mockResponse(500))
const draft = useArticleDraft(12)
draft.load('<p>Original</p>', new Date())
draft.edit('<p>Changed</p>')
const outcome = await draft.save()
expect(outcome).toBe('error')
expect(draft.state.value).toBe('error')
expect(draft.body.value).toBe('<p>Changed</p>')
})
})
describe('A 404 response keeps the body and does not enter the error state', () => {
it('resolves "not-found" and stays dirty', async () => {
setActivePinia(createPinia())
global.fetch = vi.fn().mockResolvedValue(mockResponse(404))
const draft = useArticleDraft(12)
draft.load('<p>Original</p>', new Date())
draft.edit('<p>Changed</p>')
const outcome = await draft.save()
expect(outcome).toBe('not-found')
expect(draft.state.value).toBe('dirty')
})
})
describe('A 401 response mirrors the body to sessionStorage', () => {
it('resolves "unauthorized" and stores the body keyed by the article id', async () => {
setActivePinia(createPinia())
global.fetch = vi.fn().mockResolvedValue(mockResponse(401))
const draft = useArticleDraft(12)
draft.load('<p>Original</p>', new Date())
draft.edit('<p>Paragraph added just before the session expired.</p>')
const outcome = await draft.save()
expect(outcome).toBe('unauthorized')
expect(readMirroredDraft(12)).toBe(
'<p>Paragraph added just before the session expired.</p>',
)
})
})
+29 -7
View File
@@ -33,6 +33,7 @@ export interface RequestOptions extends RequestInit {
}
export const isOffline = ref(false)
export const authRedirectPending = ref(false)
function isNavigatorOnLine() {
return typeof navigator === 'undefined' || navigator.onLine !== false
@@ -60,8 +61,15 @@ if (typeof window !== 'undefined') {
}
export function useApi() {
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { onUnauthorized = 'redirect', onForbidden = 'redirect', ...init } = options
async function request<T>(
path: string,
options: RequestOptions = {},
): Promise<T> {
const {
onUnauthorized = 'redirect',
onForbidden = 'redirect',
...init
} = options
let response: Response
try {
@@ -77,13 +85,24 @@ export function useApi() {
if (response.status === 401 && onUnauthorized === 'redirect') {
const currentPath = router.currentRoute.value.fullPath
useSessionStore().clear()
router.push({ path: '/login', query: { redirect: currentPath, reason: 'expired' } })
authRedirectPending.value = true
router
.push({
path: '/login',
query: { redirect: currentPath, reason: 'expired' },
})
.finally(() => {
authRedirectPending.value = false
})
throw new ApiError(401)
}
if (response.status === 403) {
const session = useSessionStore()
if (onForbidden !== 'passthrough' && session.session?.verified === false) {
if (
onForbidden !== 'passthrough' &&
session.session?.verified === false
) {
router.push('/register/pending')
}
throw new ApiError(403)
@@ -100,9 +119,12 @@ export function useApi() {
}
async function getMe(): Promise<MeResult> {
const body = await request<{ is_verified: boolean; username?: string }>('/v1/auth/me', {
onUnauthorized: 'passthrough',
})
const body = await request<{ is_verified: boolean; username?: string }>(
'/v1/auth/me',
{
onUnauthorized: 'passthrough',
},
)
return { verified: Boolean(body.is_verified), username: body.username }
}
+107
View File
@@ -0,0 +1,107 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ApiError, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
export type ArticleDraftBusinessState =
| 'clean'
| 'dirty'
| 'saving'
| 'saved'
| 'error'
export type ArticleDraftState = ArticleDraftBusinessState | 'offline'
export type SaveOutcome = 'saved' | 'error' | 'not-found' | 'unauthorized'
function draftStorageKey(articleId: number) {
return `renewcms:article-draft:${articleId}`
}
export function readMirroredDraft(articleId: number): string | null {
return sessionStorage.getItem(draftStorageKey(articleId))
}
export function useArticleDraft(articleId: number) {
const { request } = useApi()
const body = ref('')
const businessState = ref<ArticleDraftBusinessState>('clean')
const savedAt = ref<Date | null>(null)
const offline = ref(
typeof navigator === 'undefined' ? false : !navigator.onLine,
)
function handleOffline() {
offline.value = true
}
function handleOnline() {
offline.value = false
}
onMounted(() => {
window.addEventListener('offline', handleOffline)
window.addEventListener('online', handleOnline)
})
onUnmounted(() => {
window.removeEventListener('offline', handleOffline)
window.removeEventListener('online', handleOnline)
})
const state = computed<ArticleDraftState>(() =>
offline.value ? 'offline' : businessState.value,
)
function mirrorToStorage() {
sessionStorage.setItem(draftStorageKey(articleId), body.value)
}
function clearStorage() {
sessionStorage.removeItem(draftStorageKey(articleId))
}
function load(initialBody: string, initialSavedAt: Date) {
const mirrored = readMirroredDraft(articleId)
savedAt.value = initialSavedAt
if (mirrored !== null) {
body.value = mirrored
businessState.value = 'dirty'
return
}
body.value = initialBody
businessState.value = 'clean'
}
function edit(newBody: string) {
body.value = newBody
businessState.value = 'dirty'
}
async function save(): Promise<SaveOutcome> {
businessState.value = 'saving'
try {
await request<Article>(`/v1/article/${articleId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: body.value }),
})
businessState.value = 'saved'
savedAt.value = new Date()
clearStorage()
return 'saved'
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
businessState.value = 'dirty'
return 'not-found'
}
if (err instanceof ApiError && err.status === 401) {
mirrorToStorage()
businessState.value = 'dirty'
return 'unauthorized'
}
businessState.value = 'error'
return 'error'
}
}
return { body, state, savedAt, load, edit, save }
}
+3 -1
View File
@@ -7,7 +7,9 @@ export function useColorMode() {
return {
get preference(): ColorModePreference {
return store.value === 'auto' ? 'system' : (store.value as ColorModePreference)
return store.value === 'auto'
? 'system'
: (store.value as ColorModePreference)
},
set preference(value: ColorModePreference) {
store.value = value === 'system' ? 'auto' : value
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { ArticleIndent } from '../articleIndent'
function createEditor(content: string) {
return new Editor({
extensions: [StarterKit, ArticleIndent],
content,
})
}
describe('Indenting a paragraph writes data-indent on the paragraph', () => {
it('increases the indent level up to the maximum', () => {
const editor = createEditor('<p>Hello</p>')
editor.commands.setTextSelection(2)
editor.commands.indent()
expect(editor.getHTML()).toContain('data-indent="1"')
editor.commands.indent()
expect(editor.getHTML()).toContain('data-indent="2"')
for (let i = 0; i < 10; i += 1) {
editor.commands.indent()
}
expect(editor.getHTML()).toContain('data-indent="4"')
expect(editor.getHTML()).not.toContain('data-indent="5"')
editor.destroy()
})
})
describe('Outdenting a paragraph decreases the indent level', () => {
it('stops at zero and drops the attribute', () => {
const editor = createEditor('<p>Hello</p>')
editor.commands.setTextSelection(2)
editor.commands.indent()
editor.commands.indent()
expect(editor.getHTML()).toContain('data-indent="2"')
editor.commands.outdent()
expect(editor.getHTML()).toContain('data-indent="1"')
editor.commands.outdent()
expect(editor.getHTML()).not.toContain('data-indent')
editor.commands.outdent()
expect(editor.getHTML()).not.toContain('data-indent')
editor.destroy()
})
})
describe('Indenting inside a list item sinks it instead of touching the paragraph', () => {
it('nests the list item under the previous one', () => {
const editor = createEditor(
'<ul><li><p>First</p></li><li><p>Second</p></li></ul>',
)
editor.commands.setTextSelection(editor.state.doc.content.size - 2)
const indented = editor.commands.indent()
expect(indented).toBe(true)
expect(editor.getHTML()).toContain('<p>First</p><ul>')
expect(editor.getHTML()).not.toContain('data-indent')
editor.destroy()
})
})
describe('Outdenting a heading decreases its indent level too', () => {
it('applies data-indent to headings', () => {
const editor = createEditor('<h2>Title</h2>')
editor.commands.setTextSelection(2)
editor.commands.indent()
expect(editor.getHTML()).toContain('data-indent="1"')
editor.commands.outdent()
expect(editor.getHTML()).not.toContain('data-indent')
editor.destroy()
})
})
+27 -9
View File
@@ -5,38 +5,56 @@ const now = new Date('2026-08-25T12:00:00Z')
describe('formatRelativeDate', () => {
it('formats under an hour in minutes', () => {
expect(formatRelativeDate('2026-08-25T11:45:00Z', now)).toBe('15 minutes ago')
expect(formatRelativeDate('2026-08-25T11:45:00Z', now)).toBe(
'15 minutes ago',
)
})
it('clamps a sub-minute difference to "1 minute ago"', () => {
expect(formatRelativeDate('2026-08-25T11:59:45Z', now)).toBe('1 minute ago')
expect(formatRelativeDate('2026-08-25T11:59:45Z', now)).toBe(
'1 minute ago',
)
})
it('uses the singular for exactly one minute ago', () => {
expect(formatRelativeDate('2026-08-25T11:59:00Z', now)).toBe('1 minute ago')
expect(formatRelativeDate('2026-08-25T11:59:00Z', now)).toBe(
'1 minute ago',
)
})
it('formats under a day in hours', () => {
expect(formatRelativeDate('2026-08-25T10:00:00Z', now)).toBe('2 hours ago')
expect(formatRelativeDate('2026-08-25T10:00:00Z', now)).toBe(
'2 hours ago',
)
})
it('uses the singular for exactly one hour ago', () => {
expect(formatRelativeDate('2026-08-25T11:00:00Z', now)).toBe('1 hour ago')
expect(formatRelativeDate('2026-08-25T11:00:00Z', now)).toBe(
'1 hour ago',
)
})
it('formats exactly one day back as Yesterday', () => {
expect(formatRelativeDate('2026-08-24T11:00:00Z', now)).toBe('Yesterday')
expect(formatRelativeDate('2026-08-24T11:00:00Z', now)).toBe(
'Yesterday',
)
})
it('formats within the week in days', () => {
expect(formatRelativeDate('2026-08-22T12:00:00Z', now)).toBe('3 days ago')
expect(formatRelativeDate('2026-08-22T12:00:00Z', now)).toBe(
'3 days ago',
)
})
it('formats exactly 7 days ago as an absolute date, not "7 days ago"', () => {
expect(formatRelativeDate('2026-08-18T12:00:00Z', now)).toBe('18 Aug 2026')
expect(formatRelativeDate('2026-08-18T12:00:00Z', now)).toBe(
'18 Aug 2026',
)
})
it('formats a week or more ago as an absolute date', () => {
expect(formatRelativeDate('2026-08-10T12:00:00Z', now)).toBe('10 Aug 2026')
expect(formatRelativeDate('2026-08-10T12:00:00Z', now)).toBe(
'10 Aug 2026',
)
})
})
+97
View File
@@ -0,0 +1,97 @@
import { Extension } from '@tiptap/core'
export const INDENT_MAX = 4
export const INDENT_TYPES = ['paragraph', 'heading']
declare module '@tiptap/core' {
interface Commands<ReturnType> {
articleIndent: {
indent: () => ReturnType
outdent: () => ReturnType
}
}
}
function currentIndentableNode(state: {
selection: { $from: { parent: unknown } }
}) {
return state.selection.$from.parent as {
type: { name: string }
attrs: { indent?: number }
}
}
export const ArticleIndent = Extension.create({
name: 'articleIndent',
addGlobalAttributes() {
return [
{
types: INDENT_TYPES,
attributes: {
indent: {
default: 0,
parseHTML: (element) =>
Number(element.getAttribute('data-indent')) || 0,
renderHTML: (attributes) => {
if (!attributes.indent) {
return {}
}
return { 'data-indent': String(attributes.indent) }
},
},
},
},
]
},
addCommands() {
return {
indent:
() =>
({ state, commands, tr, dispatch }) => {
if (commands.sinkListItem('listItem')) {
return true
}
const node = currentIndentableNode(state)
if (!INDENT_TYPES.includes(node.type.name)) {
return false
}
const level = Math.min(
INDENT_MAX,
(node.attrs.indent ?? 0) + 1,
)
if (dispatch) {
const { $from } = state.selection
tr.setNodeAttribute(
$from.before($from.depth),
'indent',
level,
)
dispatch(tr)
}
return true
},
outdent:
() =>
({ state, commands, tr, dispatch }) => {
if (commands.liftListItem('listItem')) {
return true
}
const node = currentIndentableNode(state)
if (!INDENT_TYPES.includes(node.type.name)) {
return false
}
const level = Math.max(0, (node.attrs.indent ?? 0) - 1)
if (dispatch) {
const { $from } = state.selection
tr.setNodeAttribute(
$from.before($from.depth),
'indent',
level,
)
dispatch(tr)
}
return true
},
}
},
})
+22 -7
View File
@@ -65,7 +65,10 @@ describe('Nuxt UI shell foundation and router guards', () => {
it('The first navigation to a non-public route resolves the session and shows AppBoot meanwhile', async () => {
let resolveMe: (value: { verified: boolean }) => void = () => {}
const getMe = vi.fn(
() => new Promise<{ verified: boolean }>((resolve) => (resolveMe = resolve)),
() =>
new Promise<{ verified: boolean }>(
(resolve) => (resolveMe = resolve),
),
)
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
@@ -121,7 +124,9 @@ describe('Nuxt UI shell foundation and router guards', () => {
await router.push('/article')
mountApp()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register/pending'),
)
})
it('A session-only route admits an unverified session', async () => {
@@ -196,7 +201,9 @@ describe('Nuxt UI shell foundation and router guards', () => {
)
expect(app.text()).toContain('Home')
const primaryButton = app.findAll('button').find((button) => button.text() === 'Try again')
const primaryButton = app
.findAll('button')
.find((button) => button.text() === 'Try again')
await primaryButton?.trigger('click')
await flushPromises()
@@ -212,12 +219,16 @@ describe('Nuxt UI shell foundation and router guards', () => {
const app = mountApp()
await vi.waitFor(() =>
expect(app.find('[role="alert"] h1').text()).toBe('Something went wrong'),
expect(app.find('[role="alert"] h1').text()).toBe(
'Something went wrong',
),
)
expect(router.currentRoute.value.path).toBe('/article')
const primaryButton = app.findAll('button').find((button) => button.text() === 'Try again')
const primaryButton = app
.findAll('button')
.find((button) => button.text() === 'Try again')
await primaryButton?.trigger('click')
await flushPromises()
@@ -240,10 +251,14 @@ describe('Nuxt UI shell foundation and router guards', () => {
await flushPromises()
const headings = app.findAll('h1')
expect(headings.some((heading) => heading.text() === title)).toBe(true)
expect(headings.some((heading) => heading.text() === title)).toBe(
true,
)
expect(app.find('nav[aria-label="breadcrumb"]').text()).toBe(title)
const activeLink = app.find(`a[href="${path}"][aria-current="page"]`)
const activeLink = app.find(
`a[href="${path}"][aria-current="page"]`,
)
expect(activeLink.exists()).toBe(true)
},
)
+13 -3
View File
@@ -11,7 +11,11 @@ export type AuthRequirement = 'public' | 'session' | 'verified' | 'any'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', component: () => import('@/views/RootView.vue'), meta: { auth: 'any' } },
{
path: '/',
component: () => import('@/views/RootView.vue'),
meta: { auth: 'any' },
},
{
path: '/login',
component: () => import('@/views/LoginView.vue'),
@@ -94,7 +98,10 @@ export function resolveAuthRedirect(
if (auth === 'any') {
return { path: '/login' }
}
return { path: '/login', query: { redirect: to.fullPath, reason: 'expired' } }
return {
path: '/login',
query: { redirect: to.fullPath, reason: 'expired' },
}
}
if (!account.verified) {
@@ -141,7 +148,10 @@ router.afterEach((to) => {
: Promise.resolve()
settled.then(() => {
const destination = resolveAuthRedirect(router.currentRoute.value, session)
const destination = resolveAuthRedirect(
router.currentRoute.value,
session,
)
if (destination !== true) {
router.replace(destination)
}
+32 -5
View File
@@ -153,6 +153,10 @@ export const renewUi = {
base: 'h-5.5 gap-1 px-1.75 py-0 text-xs',
leadingIcon: 'size-3',
},
lg: {
base: 'h-6.5 gap-1.25 px-2.25 py-0 text-sm',
leadingIcon: 'size-3.5',
},
},
},
},
@@ -225,7 +229,14 @@ export const renewUi = {
},
compoundVariants: [
{
color: ['primary', 'secondary', 'success', 'info', 'warning', 'neutral'],
color: [
'primary',
'secondary',
'success',
'info',
'warning',
'neutral',
],
class: { close: 'hidden' },
},
],
@@ -250,10 +261,26 @@ export const renewUi = {
},
},
compoundVariants: [
{ color: 'success', variant: 'soft', class: { root: 'bg-(--rc-success-soft)' } },
{ color: 'info', variant: 'soft', class: { root: 'bg-(--rc-info-soft)' } },
{ color: 'warning', variant: 'soft', class: { root: 'bg-(--rc-warning-soft)' } },
{ color: 'error', variant: 'soft', class: { root: 'bg-(--rc-error-soft)' } },
{
color: 'success',
variant: 'soft',
class: { root: 'bg-(--rc-success-soft)' },
},
{
color: 'info',
variant: 'soft',
class: { root: 'bg-(--rc-info-soft)' },
},
{
color: 'warning',
variant: 'soft',
class: { root: 'bg-(--rc-warning-soft)' },
},
{
color: 'error',
variant: 'soft',
class: { root: 'bg-(--rc-error-soft)' },
},
],
},
card: {
+358 -2
View File
@@ -1,9 +1,365 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { useOverlay, useToast } from '@nuxt/ui/composables'
import DashboardShell from '@/components/DashboardShell.vue'
import ErrorView from '@/components/ErrorView.vue'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import ArticleEditor from '@/components/ArticleEditor.vue'
import ArticleDetails from '@/components/ArticleDetails.vue'
import SaveStatus from '@/components/SaveStatus.vue'
import PublishControl from '@/components/PublishControl.vue'
import CharacterCounter from '@/components/CharacterCounter.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
import { useArticleDraft } from '@/composables/useArticleDraft'
const SKELETON_DELAY_MS = 150
const BODY_MAX_LENGTH = 10000
type LoadState = 'loading' | 'loaded' | 'notFound' | 'error'
const route = useRoute()
const router = useRouter()
const { request } = useApi()
const overlay = useOverlay()
const toast = useToast()
const articleId = Number(route.params.id)
const draft = useArticleDraft(articleId)
const loadState = ref<LoadState>('loading')
const showSkeleton = ref(false)
const article = ref<Article | null>(null)
const previewOn = ref(false)
const detailsOpen = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
async function loadArticle() {
loadState.value = 'loading'
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
try {
const result = await request<Article>(`/v1/article/${articleId}`)
article.value = result
draft.load(result.body, new Date(result.updated_at))
loadState.value = 'loaded'
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
loadState.value = 'notFound'
return
}
loadState.value = 'error'
} finally {
clearSkeletonTimer()
showSkeleton.value = false
}
}
const bodyLength = computed(() => draft.body.value.length)
const overLimit = computed(() => bodyLength.value > BODY_MAX_LENGTH)
const saveEnabled = computed(
() =>
draft.state.value === 'dirty' &&
!overLimit.value &&
loadState.value === 'loaded',
)
const publishDisabled = computed(
() => overLimit.value || draft.state.value === 'offline',
)
async function runSave() {
const outcome = await draft.save()
if (outcome === 'not-found') {
toast.add({
color: 'error',
icon: 'i-lucide-circle-x',
title: 'That article no longer exists.',
})
}
}
async function performSave() {
if (!saveEnabled.value || !article.value) {
return
}
await runSave()
}
function retrySave() {
if (!article.value) {
return
}
runSave()
}
function goBack() {
router.push('/article')
}
function onKeydown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault()
performSave()
}
}
onMounted(() => {
window.addEventListener('keydown', onKeydown)
loadArticle()
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown)
clearSkeletonTimer()
})
onBeforeRouteLeave(async () => {
if (authRedirectPending.value || draft.state.value !== 'dirty') {
return true
}
const confirmed = await overlay
.create(ConfirmDialog, {
props: {
title: 'Leave without saving?',
description: `Your changes to "${article.value?.title ?? ''}" haven't been saved.`,
confirmLabel: 'Keep editing',
cancelLabel: 'Discard changes',
pendingLabel: 'Keep editing',
tone: 'neutral',
action: async () => {},
dismissResolvesTo: 'confirm',
},
})
.open()
return !confirmed
})
</script>
<template>
<DashboardShell>
<p>Editor placeholder.</p>
<DashboardShell mobile-title="Edit article" full-bleed>
<template v-if="loadState === 'loaded'" #breadcrumb>
<UBreadcrumb
:items="[
{ label: 'Articles', to: '/article' },
{ label: article?.title ?? '' },
]"
color="neutral"
class="hidden lg:block"
/>
</template>
<ErrorView v-if="loadState === 'notFound'" kind="article-not-found" />
<ErrorView
v-else-if="loadState === 'error'"
kind="error"
@retry="loadArticle"
/>
<div
v-else-if="loadState === 'loading' && showSkeleton"
aria-busy="true"
class="flex min-h-0 flex-1 flex-col lg:flex-row"
>
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
<div
class="flex flex-shrink-0 items-center gap-2.5 border-b border-default bg-default px-6 py-3.5"
>
<UButton
icon="i-lucide-arrow-left"
color="neutral"
variant="ghost"
class="size-8 justify-center text-toned"
aria-label="Back to Articles"
@click="goBack"
/>
<USkeleton
class="h-5 w-80 rounded-none"
data-testid="skeleton-title"
/>
<USkeleton class="h-5.5 w-15 rounded-none" />
</div>
<div
class="min-h-0 max-w-168 flex-1 space-y-3.5 px-12 py-10"
data-testid="skeleton-content"
>
<USkeleton class="h-3.5 w-full rounded-none" />
<USkeleton class="h-3.5 w-11/12 rounded-none" />
<USkeleton class="h-3.5 w-4/5 rounded-none" />
<USkeleton class="mt-4.5 h-5.5 w-2/5 rounded-none" />
<USkeleton class="h-3.5 w-full rounded-none" />
<USkeleton class="h-3.5 w-3/4 rounded-none" />
</div>
</div>
<div
class="w-70 flex-shrink-0 space-y-2.5 border-default p-5 lg:border-l"
data-testid="skeleton-details"
>
<USkeleton class="h-3 w-full rounded-none" />
<USkeleton class="h-3 w-full rounded-none" />
<USkeleton class="h-3 w-3/5 rounded-none" />
</div>
</div>
<div
v-else-if="loadState === 'loaded' && article"
class="flex min-h-0 flex-1 flex-col"
>
<div
class="flex flex-shrink-0 flex-wrap items-center gap-3 border-b border-default bg-default px-6 py-3.5"
>
<UButton
icon="i-lucide-arrow-left"
color="neutral"
variant="ghost"
class="size-8 justify-center text-toned"
aria-label="Back to Articles"
@click="goBack"
/>
<div
class="flex min-w-50 flex-1 flex-wrap items-center gap-2.5"
>
<h1
class="text-[1.125rem]/6.5 font-semibold text-highlighted"
title="The title can't be changed after creation."
>
{{ article.title }}
</h1>
<ArticleStatusBadge :online="article.is_online" size="lg" />
</div>
<SaveStatus
:state="draft.state.value"
:saved-at="draft.savedAt.value"
@retry="retrySave"
/>
<div class="hidden items-center gap-3 lg:flex">
<USwitch
v-model="previewOn"
label="Preview"
:ui="{
root: 'items-center gap-2',
container: 'h-auto',
base: 'h-4.5 w-8 p-0.5',
thumb: 'size-3.5 bg-default data-[state=checked]:translate-x-3.5',
wrapper: 'ms-0',
label: 'text-sm font-normal text-toned',
}"
/>
<UButton
data-testid="save-button"
icon="i-lucide-save"
color="neutral"
variant="outline"
class="h-8 gap-1.5 px-3"
:disabled="!saveEnabled"
@click="performSave"
>
Save
</UButton>
<PublishControl
class="h-8 gap-1.5 px-3"
:online="article.is_online"
:dirty="draft.state.value === 'dirty'"
:disabled="publishDisabled"
/>
<UButton
icon="i-lucide-ellipsis"
color="neutral"
variant="ghost"
class="size-8 justify-center text-toned"
aria-label="More actions"
/>
</div>
</div>
<div v-if="previewOn" class="flex min-h-0 flex-1">
<div
class="min-h-0 flex-1 overflow-y-auto bg-muted px-12 py-10"
data-testid="article-preview"
>
<div class="article-content mx-auto">
<h2 class="text-2xl font-semibold text-highlighted">
{{ article.title }}
</h2>
<div v-html="draft.body.value" />
</div>
</div>
<div
class="hidden w-70 flex-shrink-0 border-l border-default bg-default p-5 lg:block"
>
<ArticleDetails
:article="article"
v-model:open="detailsOpen"
/>
</div>
</div>
<ArticleEditor
v-else
class="min-h-0 flex-1"
:model-value="draft.body.value"
:article-id="articleId"
@update:model-value="draft.edit"
>
<template #footer>
<CharacterCounter
variant="footer"
:value="bodyLength"
:max="BODY_MAX_LENGTH"
/>
</template>
<template #aside>
<div class="h-full border-l border-default bg-default p-5">
<ArticleDetails
:article="article"
v-model:open="detailsOpen"
/>
</div>
</template>
</ArticleEditor>
<div
class="flex flex-shrink-0 gap-2 border-t border-default bg-default p-3 lg:hidden"
>
<UButton
icon="i-lucide-panel-right"
color="neutral"
variant="outline"
class="size-11 justify-center"
aria-label="Details"
@click="detailsOpen = true"
/>
<UButton
data-testid="save-button-mobile"
color="neutral"
variant="outline"
class="h-11 flex-1 justify-center"
:disabled="!saveEnabled"
@click="performSave"
>
Save
</UButton>
<PublishControl
class="h-11 flex-1 justify-center"
hide-icon
:online="article.is_online"
:dirty="draft.state.value === 'dirty'"
:disabled="publishDisabled"
/>
</div>
</div>
</DashboardShell>
</template>
+60 -20
View File
@@ -60,7 +60,11 @@ const statTiles = computed(() => [
const recentArticles = computed(() =>
[...articles.value]
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
.sort(
(a, b) =>
new Date(b.updated_at).getTime() -
new Date(a.updated_at).getTime(),
)
.slice(0, RECENT_LIST_SIZE),
)
@@ -115,7 +119,10 @@ onMounted(loadArticles)
<div class="flex max-w-244 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">
<h1
data-testid="greeting"
class="text-2xl font-semibold text-highlighted"
>
{{ greeting }}
</h1>
<p class="text-base/5 text-muted">{{ subtitle }}</p>
@@ -129,7 +136,12 @@ onMounted(loadArticles)
</UButton>
</div>
<template v-if="(loadState === 'loading' && showSkeleton) || loadState === 'default'">
<template
v-if="
(loadState === 'loading' && showSkeleton) ||
loadState === 'default'
"
>
<div class="grid grid-cols-3 gap-3">
<div
v-for="tile in statTiles"
@@ -138,13 +150,18 @@ onMounted(loadArticles)
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" />
<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">{{
tile.value
}}</span>
<span
class="text-2xl font-semibold text-highlighted"
>{{ tile.value }}</span
>
<span class="text-muted">{{ tile.label }}</span>
</template>
</div>
@@ -152,8 +169,13 @@ onMounted(loadArticles)
<section class="flex flex-col gap-2.5">
<div class="flex items-baseline justify-between">
<h2 class="text-lg font-semibold text-highlighted">Recently updated</h2>
<RouterLink to="/article" class="text-toned underline underline-offset-3">
<h2 class="text-lg font-semibold text-highlighted">
Recently updated
</h2>
<RouterLink
to="/article"
class="text-toned underline underline-offset-3"
>
All articles
</RouterLink>
</div>
@@ -167,21 +189,35 @@ onMounted(loadArticles)
: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" />
<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">
<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">
<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">
<ArticleStatusBadge
:online="article.is_online"
/>
<span
class="hidden w-27.5 text-right text-muted lg:inline"
>
{{ formatRelativeDate(article.updated_at) }}
</span>
</RouterLink>
@@ -242,17 +278,21 @@ onMounted(loadArticles)
</template>
</UAlert>
<div class="flex items-start gap-3.5 border border-default bg-default p-4">
<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">Connect your website</h2>
<h2 class="text-base/5 font-semibold text-highlighted">
Connect your website
</h2>
<p class="text-pretty text-muted">
Your site reads published articles from RenewCMS. Share this guide with
whoever builds it.
Your site reads published articles from RenewCMS. Share
this guide with whoever builds it.
</p>
<RouterLink
to="/integration"
+40 -12
View File
@@ -63,7 +63,8 @@ const passwordError = ref('')
const submitting = ref(false)
const passwordShown = ref(false)
const initialReason = typeof route.query.reason === 'string' ? route.query.reason : null
const initialReason =
typeof route.query.reason === 'string' ? route.query.reason : null
const alert = ref<AlertState | null>(
initialReason === 'expired'
@@ -73,7 +74,9 @@ const alert = ref<AlertState | null>(
: null,
)
const alertRole = computed(() => (alert.value?.tone === 'error' ? 'alert' : 'status'))
const alertRole = computed(() =>
alert.value?.tone === 'error' ? 'alert' : 'status',
)
const submitLabel = computed(() => {
if (submitting.value) {
@@ -85,7 +88,9 @@ const submitLabel = computed(() => {
return 'Log in'
})
const submitDisabled = computed(() => submitting.value || retryAfter.active.value)
const submitDisabled = computed(
() => submitting.value || retryAfter.active.value,
)
async function focusField(selector: string) {
await nextTick()
@@ -112,15 +117,18 @@ function focusFirstInvalid() {
}
function validate() {
usernameError.value = username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
passwordError.value = password.value.length === 0 ? 'Enter your password.' : ''
usernameError.value =
username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
passwordError.value =
password.value.length === 0 ? 'Enter your password.' : ''
return !usernameError.value && !passwordError.value
}
async function afterLoginSuccess() {
await session.retry()
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : null
const redirect =
typeof route.query.redirect === 'string' ? route.query.redirect : null
if (redirect) {
await router.push(redirect)
return
@@ -166,7 +174,10 @@ async function onSubmit() {
await api.request('/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.value, password: password.value }),
body: JSON.stringify({
username: username.value,
password: password.value,
}),
})
await afterLoginSuccess()
} catch (error) {
@@ -182,7 +193,9 @@ onMounted(async () => {
}
try {
const status = await api.request<{ open: boolean }>('/v1/auth/registration')
const status = await api.request<{ open: boolean }>(
'/v1/auth/registration',
)
registrationOpen.value = status.open
} catch {
registrationOpen.value = false
@@ -191,7 +204,10 @@ onMounted(async () => {
</script>
<template>
<AuthShell title="Log in" description="Welcome back. Log in to manage your articles.">
<AuthShell
title="Log in"
description="Welcome back. Log in to manage your articles."
>
<template v-if="alert" #notice>
<UAlert
:color="alert.tone"
@@ -205,7 +221,11 @@ onMounted(async () => {
</template>
<template #default="{ controlSize, controlClass, inputClass }">
<UForm :state="{ username, password }" class="flex flex-col gap-4.5" @submit="onSubmit">
<UForm
:state="{ username, password }"
class="flex flex-col gap-4.5"
@submit="onSubmit"
>
<UFormField
label="Username"
name="username"
@@ -275,13 +295,21 @@ onMounted(async () => {
>
<template #trailing>
<UButton
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
:icon="
passwordShown
? 'i-lucide-eye-off'
: 'i-lucide-eye'
"
variant="ghost"
color="neutral"
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
:aria-label="
passwordShown
? 'Hide password'
: 'Show password'
"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
/>
+3 -3
View File
@@ -139,9 +139,9 @@ const actions = computed(() => [
:actions="actions"
>
<p class="text-sm text-pretty text-muted">
Opened it on another device? That works too; you'll log in there first. Can't find
it? Check your spam folder. If the link has expired, cancel your registration and
start again.
Opened it on another device? That works too; you'll log in there
first. Can't find it? Check your spam folder. If the link has
expired, cancel your registration and start again.
</p>
</ResultPanel>
</AuthShell>
+35 -10
View File
@@ -24,12 +24,16 @@ const passwordShown = ref(false)
const registrationClosed = ref(false)
const serverErrorAlert = ref(false)
const usernameValid = computed(() => username.value.length >= 3 && username.value.length <= 20)
const usernameValid = computed(
() => username.value.length >= 3 && username.value.length <= 20,
)
const emailValid = computed(() => EMAIL_PATTERN.test(email.value))
const passwordValid = computed(() => password.value.length >= 8)
const usernameError = computed(() =>
usernameTouched.value && !usernameValid.value ? 'Use 3 to 20 characters.' : '',
usernameTouched.value && !usernameValid.value
? 'Use 3 to 20 characters.'
: '',
)
const emailError = computed(() =>
@@ -38,9 +42,13 @@ const emailError = computed(() =>
: '',
)
const passwordRules = computed(() => [{ label: 'At least 8 characters', met: passwordValid.value }])
const passwordRules = computed(() => [
{ label: 'At least 8 characters', met: passwordValid.value },
])
const formValid = computed(() => usernameValid.value && emailValid.value && passwordValid.value)
const formValid = computed(
() => usernameValid.value && emailValid.value && passwordValid.value,
)
function touchAll() {
usernameTouched.value = true
@@ -85,7 +93,9 @@ async function onSubmit() {
}
}
const pageTitle = computed(() => (registrationClosed.value ? '' : 'Create the owner account'))
const pageTitle = computed(() =>
registrationClosed.value ? '' : 'Create the owner account',
)
const pageDescription = computed(() =>
registrationClosed.value
@@ -142,7 +152,8 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
</span>
</template>
<template v-else #help>
Letters and numbers, 3 to 20 characters. You'll use it to log in.
Letters and numbers, 3 to 20 characters. You'll use it
to log in.
</template>
<template #hint>
<span
@@ -184,7 +195,9 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
{{ error }}
</span>
</template>
<template v-else #help> We'll send a verification link here. </template>
<template v-else #help>
We'll send a verification link here.
</template>
<UInput
v-model="email"
type="email"
@@ -201,7 +214,11 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
/>
</UFormField>
<UFormField label="Password" name="password" :size="controlSize">
<UFormField
label="Password"
name="password"
:size="controlSize"
>
<template #help>
<PasswordStrengthHint
:rules="passwordRules"
@@ -225,13 +242,21 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
>
<template #trailing>
<UButton
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
:icon="
passwordShown
? 'i-lucide-eye-off'
: 'i-lucide-eye'
"
variant="ghost"
color="neutral"
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
:aria-label="
passwordShown
? 'Hide password'
: 'Show password'
"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
/>
+5 -1
View File
@@ -10,5 +10,9 @@ function onRetry() {
</script>
<template>
<ErrorView v-if="session.status === 'error'" kind="error" @retry="onRetry" />
<ErrorView
v-if="session.status === 'error'"
kind="error"
@retry="onRetry"
/>
</template>
+36 -10
View File
@@ -70,7 +70,9 @@ const passwordShown = ref(false)
const loginSubmitting = ref(false)
const loginAlert = ref<AlertState | null>(null)
const shellTitle = computed(() => (state.value === 'noSession' ? 'Log in to finish verifying' : ''))
const shellTitle = computed(() =>
state.value === 'noSession' ? 'Log in to finish verifying' : '',
)
const shellDescription = computed(() =>
state.value === 'noSession'
? "You opened this link somewhere you're not logged in. Log in here and we'll verify your account right after."
@@ -80,7 +82,9 @@ const shellDescription = computed(() =>
const usernameError = ref('')
const passwordError = ref('')
const loginSubmitDisabled = computed(() => loginSubmitting.value || retryAfter.active.value)
const loginSubmitDisabled = computed(
() => loginSubmitting.value || retryAfter.active.value,
)
const loginSubmitLabel = computed(() => {
if (loginSubmitting.value) {
@@ -95,7 +99,11 @@ const loginSubmitLabel = computed(() => {
async function focusPassword() {
await nextTick()
await nextTick()
document.querySelector<HTMLInputElement>('input[autocomplete="current-password"]')?.focus()
document
.querySelector<HTMLInputElement>(
'input[autocomplete="current-password"]',
)
?.focus()
}
async function performVerify(code: string) {
@@ -152,8 +160,10 @@ function handleLoginError(error: unknown) {
}
function validateLogin() {
usernameError.value = username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
passwordError.value = password.value.length === 0 ? 'Enter your password.' : ''
usernameError.value =
username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
passwordError.value =
password.value.length === 0 ? 'Enter your password.' : ''
return !usernameError.value && !passwordError.value
}
@@ -170,7 +180,10 @@ async function onLoginSubmit() {
await api.request('/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.value, password: password.value }),
body: JSON.stringify({
username: username.value,
password: password.value,
}),
})
const code = typeof route.query.c === 'string' ? route.query.c : ''
await retryVerifying(code)
@@ -281,8 +294,13 @@ onMounted(() => {
role="status"
class="flex flex-col items-center gap-4 py-10 text-center"
>
<UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-highlighted" />
<p class="text-xl/7 font-semibold text-highlighted">Verifying your account…</p>
<UIcon
name="i-lucide-loader-2"
class="size-8 animate-spin text-highlighted"
/>
<p class="text-xl/7 font-semibold text-highlighted">
Verifying your account…
</p>
<p class="text-base text-muted">This only takes a moment.</p>
</div>
@@ -384,13 +402,21 @@ onMounted(() => {
>
<template #trailing>
<UButton
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
:icon="
passwordShown
? 'i-lucide-eye-off'
: 'i-lucide-eye'
"
variant="ghost"
color="neutral"
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
:aria-label="
passwordShown
? 'Hide password'
: 'Show password'
"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
/>
+709
View File
@@ -0,0 +1,709 @@
import { afterEach, beforeEach, 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 { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import ArticleEditor from '@/components/ArticleEditor.vue'
import RootComponent from '@/App.vue'
const FIXED_NOW = '2026-09-26T11:02:00Z'
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: (name: string) => headers[name] ?? null },
json: async () => body,
} as unknown as Response
}
function routeFor(url: string, init?: RequestInit) {
const method = init?.method ?? 'GET'
return `${method} ${url}`
}
function fetchRouter(
handlers: Record<string, () => Response | Promise<Response>>,
) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
if (!handler) {
throw new Error(`Unexpected fetch call: ${key}`)
}
return handler()
})
}
function articleFixture(overrides: Partial<Article> = {}): Article {
return {
id: 12,
title: 'How we price a bathroom renovation',
body: 'a'.repeat(4318),
images: [
{ id: 1, url: '/images/1.png' },
{ id: 2, url: '/images/2.png' },
{ id: 3, url: '/images/3.png' },
],
is_online: false,
created_at: '2026-03-12T09:14:00Z',
updated_at: FIXED_NOW,
...overrides,
}
}
function createOverlayMock() {
let props: Record<string, unknown> | null = null
let resolveOpen: ((value: unknown) => void) | null = null
const create = vi.fn(
(_component: unknown, options: { props: Record<string, unknown> }) => {
props = options.props
return {
open: () =>
new Promise((resolve) => {
resolveOpen = resolve
}),
}
},
)
return {
create,
getProps: () => props,
resolveOpen: (value: unknown) => resolveOpen?.(value),
}
}
let overlayMock: ReturnType<typeof createOverlayMock>
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', () => ({
useOverlay: () => overlayMock,
useToast: () => ({ add: toastAdd }),
}))
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountEditor(
handlers: Record<string, () => Response | Promise<Response>>,
path = '/article/12/edit',
) {
setActivePinia(createPinia())
overlayMock = createOverlayMock()
toastAdd = vi.fn()
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
await router.push(path)
const wrapper = mount(RootComponent, {
attachTo: document.body,
global: {
plugins: [router, uiPlugin],
},
})
currentWrapper = wrapper
await flushPromises()
return wrapper
}
function saveButton(wrapper: Awaited<ReturnType<typeof mount>>) {
return wrapper.find('[data-testid="save-button"]')
}
function articleEditorBody(wrapper: Awaited<ReturnType<typeof mount>>) {
return wrapper.findComponent(ArticleEditor).props('modelValue')
}
async function typeBody(
wrapper: Awaited<ReturnType<typeof mount>>,
body: string,
) {
await wrapper
.findComponent(ArticleEditor)
.vm.$emit('update:modelValue', body)
await flushPromises()
}
function paragraph(inner: string) {
return `<p>${inner}</p>`
}
function paragraphOfLength(totalLength: number) {
return paragraph('a'.repeat(totalLength - 7))
}
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(FIXED_NOW))
})
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
vi.useRealTimers()
sessionStorage.clear()
const onLineSpy = vi.spyOn(navigator, 'onLine', 'get')
onLineSpy.mockReturnValue(true)
window.dispatchEvent(new Event('online'))
onLineSpy.mockRestore()
await router.push('/login')
})
describe('Skeletons appear only after 150 ms, never for a fast response', () => {
it('shows no skeleton before 150 ms, a skeleton after, then the Saved state once resolved', async () => {
let resolveGet: (() => void) | null = null
const wrapper = await mountEditor({
'GET /v1/article/12': () =>
new Promise((resolve) => {
resolveGet = () =>
resolve(mockResponse(200, articleFixture()))
}),
})
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
false,
)
await vi.advanceTimersByTimeAsync(150)
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
true,
)
expect(wrapper.find('[data-testid="skeleton-content"]').exists()).toBe(
true,
)
expect(wrapper.find('[data-testid="skeleton-details"]').exists()).toBe(
true,
)
resolveGet?.()
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
false,
)
expect(wrapper.text()).toContain('How we price a bathroom renovation')
})
it('never shows a skeleton when the response resolves in under 150 ms', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () =>
Promise.resolve(mockResponse(200, articleFixture())),
})
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
false,
)
expect(wrapper.text()).toContain('How we price a bathroom renovation')
})
})
describe('Saved — a freshly loaded article, draft or online', () => {
it.each([
{ is_online: false, badge: 'Draft', publishLabel: 'Publish' },
{ is_online: true, badge: 'Online', publishLabel: 'Unpublish' },
])(
'reads the Saved state for is_online $is_online',
async ({ is_online, badge, publishLabel }) => {
const wrapper = await mountEditor({
'GET /v1/article/12': () =>
mockResponse(200, articleFixture({ is_online })),
})
expect(wrapper.text()).toContain('Articles')
expect(wrapper.text()).toContain(
'How we price a bathroom renovation',
)
expect(wrapper.text()).toContain(badge)
expect(wrapper.text()).toContain('Saved at 11:02')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
expect(wrapper.text()).toContain(publishLabel)
expect(wrapper.text()).toContain('4 318 / 10 000')
},
)
})
describe('Unsaved changes — any edit marks the article dirty', () => {
it('shows Unsaved changes, enables Save and switches Publish to Save and publish', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
expect(wrapper.text()).toContain('Unsaved changes')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(false)
expect(wrapper.text()).toContain('Save and publish')
})
})
describe('Saving — Save sends the PATCH and disables itself while in flight', () => {
it('sends the edited body and shows Saving…', async () => {
let patchBody: string | null = null
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
global.fetch = vi.fn((url: string, init?: RequestInit) => {
if (routeFor(url, init) === 'PATCH /v1/article/12') {
patchBody = init?.body as string
return new Promise(() => {})
}
throw new Error(`Unexpected fetch call: ${routeFor(url, init)}`)
}) as unknown as typeof fetch
await saveButton(wrapper).trigger('click')
await flushPromises()
expect(JSON.parse(patchBody ?? '{}')).toEqual({
body: paragraphOfLength(4320),
})
expect(wrapper.text()).toContain('Saving…')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
})
})
describe('Ctrl/⌘ S saves the same way as clicking Save', () => {
it('sends the same PATCH on Ctrl+S', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
let patchBody: string | null = null
global.fetch = vi.fn((url: string, init?: RequestInit) => {
if (routeFor(url, init) === 'PATCH /v1/article/12') {
patchBody = init?.body as string
return new Promise(() => {})
}
throw new Error(`Unexpected fetch call: ${routeFor(url, init)}`)
}) as unknown as typeof fetch
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 's', ctrlKey: true }),
)
await flushPromises()
expect(JSON.parse(patchBody ?? '{}')).toEqual({
body: paragraphOfLength(4320),
})
expect(wrapper.text()).toContain('Saving…')
})
})
describe('A successful save returns to the Saved state with the new time', () => {
it('shows Saved at the current time and disables Save again', async () => {
vi.setSystemTime(new Date('2026-09-26T11:30:00Z'))
let resolvePatch: (() => void) | null = null
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': () =>
new Promise((resolve) => {
resolvePatch = () =>
resolve(
mockResponse(
200,
articleFixture({
body: paragraphOfLength(4320),
}),
),
)
}),
})
await typeBody(wrapper, paragraphOfLength(4320))
await saveButton(wrapper).trigger('click')
await flushPromises()
resolvePatch?.()
await flushPromises()
expect(wrapper.text()).toContain('Saved at 11:30')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
})
})
describe('SaveStatus switches to a relative time after an hour', () => {
it('reads "Saved n min ago" once more than an hour has passed', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
expect(wrapper.text()).toContain('Saved at 11:02')
vi.setSystemTime(new Date('2026-09-26T12:32:00Z'))
await vi.advanceTimersByTimeAsync(20000)
await flushPromises()
expect(wrapper.text()).toContain('Saved 90 min ago')
})
})
describe('Save failed — a 500 or a network failure keeps the changes and offers Retry', () => {
it.each([
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows Save failed with Retry for $name', async ({ handler }) => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': handler,
})
await typeBody(wrapper, paragraph('Revised opening paragraph.'))
await saveButton(wrapper).trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Save failed')
expect(
wrapper
.findAll('button')
.some((button) => button.text() === 'Retry'),
).toBe(true)
expect(articleEditorBody(wrapper)).toBe(
paragraph('Revised opening paragraph.'),
)
})
})
describe('Retry resends the same body and can recover', () => {
it('resends the same PATCH and shows Saved at the current time', async () => {
const patchCall = vi
.fn()
.mockResolvedValueOnce(mockResponse(500))
.mockResolvedValueOnce(
mockResponse(
200,
articleFixture({
body: paragraph('Revised opening paragraph.'),
}),
),
)
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': patchCall,
})
await typeBody(wrapper, paragraph('Revised opening paragraph.'))
await saveButton(wrapper).trigger('click')
await flushPromises()
const retryButton = wrapper
.findAll('button')
.find((button) => button.text() === 'Retry')
await retryButton?.trigger('click')
await flushPromises()
expect(patchCall).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toMatch(/Saved at \d{2}:\d{2}/)
})
})
describe('A save against an article deleted elsewhere shows a toast, not the error SaveStatus', () => {
it('shows the toast and never reads Save failed', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': () => mockResponse(404),
})
await typeBody(wrapper, paragraphOfLength(4320))
await saveButton(wrapper).trigger('click')
await flushPromises()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({
title: 'That article no longer exists.',
}),
)
expect(wrapper.text()).not.toContain('Save failed')
})
})
describe('Near the limit — the counter warns at 9000 characters without blocking Save', () => {
it('shows the warning icon and keeps Save enabled', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(9240))
expect(wrapper.text()).toContain('9 240 / 10 000')
expect(
wrapper
.find('[data-testid="character-counter-warning-icon"]')
.exists(),
).toBe(true)
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(false)
})
})
describe('Too long — over 10000 characters disables Save and Publish until shortened', () => {
it('shows the over-limit text and disables Save and Publish, then re-enables once shortened', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(10214))
expect(wrapper.text()).toContain('10 214 / 10 000')
expect(wrapper.text()).toContain(
'214 characters over. Shorten the text or remove an image. Formatting counts too.',
)
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
const publishButton = wrapper
.findAll('button')
.find(
(button) =>
button.text() === 'Publish' ||
button.text() === 'Save and publish',
)
expect((publishButton?.element as HTMLButtonElement).disabled).toBe(
true,
)
await typeBody(wrapper, paragraphOfLength(9000))
expect(wrapper.text()).not.toContain('characters over')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(false)
})
})
describe('Preview shows the rendered title and body without the editing surface', () => {
it('replaces the editor with a read-only rendering, then restores it unchanged', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () =>
mockResponse(200, articleFixture({ body: '<p>Some body</p>' })),
})
const previewSwitch = wrapper.find('button[role="switch"]')
await previewSwitch.trigger('click')
await flushPromises()
expect(wrapper.findComponent(ArticleEditor).exists()).toBe(false)
expect(
wrapper.find('[data-testid="article-preview"]').text(),
).toContain('How we price a bathroom renovation')
expect(
wrapper.find('[data-testid="article-preview"]').html(),
).toContain('Some body')
await previewSwitch.trigger('click')
await flushPromises()
expect(wrapper.findComponent(ArticleEditor).exists()).toBe(true)
expect(articleEditorBody(wrapper)).toBe('<p>Some body</p>')
})
})
describe('Offline blocks Save and Publish, and recovers when the connection returns', () => {
it('shows the offline SaveStatus and re-enables Save once back online', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
const onLineSpy = vi
.spyOn(navigator, 'onLine', 'get')
.mockReturnValue(false)
window.dispatchEvent(new Event('offline'))
await flushPromises()
expect(wrapper.text()).toContain('Waiting for connection')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
onLineSpy.mockReturnValue(true)
window.dispatchEvent(new Event('online'))
await flushPromises()
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(false)
expect(wrapper.text()).toContain('Unsaved changes')
onLineSpy.mockRestore()
})
})
describe('Leaving with unsaved changes prompts, and "Discard changes" proceeds', () => {
it('opens ConfirmDialog with the exact copy and navigates away on Discard changes', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
const articlesLink = wrapper
.findAll('a')
.find((a) => a.attributes('href') === '/article')
expect(articlesLink).toBeTruthy()
const navigation = router.push('/article')
await flushPromises()
const props = overlayMock.getProps() as Record<string, unknown>
expect(props.title).toBe('Leave without saving?')
expect(props.description).toBe(
'Your changes to "How we price a bathroom renovation" haven\'t been saved.',
)
expect(props.cancelLabel).toBe('Discard changes')
expect(props.confirmLabel).toBe('Keep editing')
expect(props.dismissResolvesTo).toBe('confirm')
overlayMock.resolveOpen(false)
await navigation
expect(router.currentRoute.value.path).toBe('/article')
})
})
describe('Leaving with unsaved changes, "Keep editing" stays on the page', () => {
it('keeps the user on the editor with the unsaved body intact', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
const articlesLink = wrapper
.findAll('a')
.find((a) => a.attributes('href') === '/article')
expect(articlesLink).toBeTruthy()
const navigation = router.push('/article')
await flushPromises()
overlayMock.resolveOpen(true)
await navigation
expect(router.currentRoute.value.path).toBe('/article/12/edit')
expect(articleEditorBody(wrapper)).toBe(paragraphOfLength(4320))
})
})
describe('Esc or the overlay on the leave guard behaves like "Keep editing"', () => {
it('keeps the user on /article/12/edit with the unsaved body intact', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
const navigation = router.push('/article')
await flushPromises()
const props = overlayMock.getProps() as Record<string, unknown>
expect(props.dismissResolvesTo).toBe('confirm')
overlayMock.resolveOpen(true)
await navigation
expect(router.currentRoute.value.path).toBe('/article/12/edit')
expect(articleEditorBody(wrapper)).toBe(paragraphOfLength(4320))
})
})
describe('Leaving a clean article never prompts', () => {
it('navigates directly with no ConfirmDialog', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
const articlesLink = wrapper
.findAll('a')
.find((a) => a.attributes('href') === '/article')
await articlesLink?.trigger('click')
await flushPromises()
expect(overlayMock.getProps()).toBeNull()
expect(router.currentRoute.value.path).toBe('/article')
})
})
describe('Session expired mid-save keeps the draft and restores it after logging back in', () => {
it('mirrors the body to sessionStorage and restores it on the next mount', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': () => mockResponse(401),
})
const body = paragraph(
'Paragraph added just before the session expired.',
)
await typeBody(wrapper, body)
await saveButton(wrapper).trigger('click')
await flushPromises()
expect(sessionStorage.getItem('renewcms:article-draft:12')).toBe(body)
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query.redirect).toBe(
'/article/12/edit',
)
expect(router.currentRoute.value.query.reason).toBe('expired')
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
global.fetch = fetchRouter({
'GET /v1/article/12': () =>
mockResponse(
200,
articleFixture({
body: 'Last saved body without that paragraph.',
}),
),
}) as unknown as typeof fetch
await router.push('/article/12/edit')
const secondWrapper = mount(RootComponent, {
attachTo: document.body,
global: {
plugins: [router, uiPlugin],
},
})
await flushPromises()
expect(articleEditorBody(secondWrapper)).toBe(body)
expect(secondWrapper.text()).toContain('Unsaved changes')
secondWrapper.unmount()
})
})
describe('An unknown or deleted article id replaces the editor with an error state', () => {
it('shows the not-found error state and navigates to /article', async () => {
const wrapper = await mountEditor(
{
'GET /v1/article/9999': () => mockResponse(404),
},
'/article/9999/edit',
)
expect(wrapper.text()).toContain("This article doesn't exist.")
expect(wrapper.find('[data-testid="save-button"]').exists()).toBe(false)
expect(wrapper.text()).not.toContain('Publish')
const allArticlesButton = wrapper
.findAll('button')
.find((button) => button.text() === 'All articles')
await allArticlesButton?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/article')
})
})
+25 -8
View File
@@ -7,7 +7,11 @@ import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import HomeView from '../HomeView.vue'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
return {
ok: status >= 200 && status < 300,
status,
@@ -21,7 +25,9 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}`
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
function fetchRouter(
handlers: Record<string, () => Response | Promise<Response>>,
) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
@@ -91,7 +97,9 @@ const sixArticles: Article[] = [
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountHome(handlers: Record<string, () => Response | Promise<Response>>) {
async function mountHome(
handlers: Record<string, () => Response | Promise<Response>>,
) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
@@ -149,7 +157,8 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
it('never shows a skeleton when the response resolves in under 150 ms', async () => {
vi.useFakeTimers()
const wrapper = await mountHome({
'GET /v1/article/': () => Promise.resolve(mockResponse(200, sixArticles)),
'GET /v1/article/': () =>
Promise.resolve(mockResponse(200, sixArticles)),
})
await vi.advanceTimersByTimeAsync(0)
@@ -166,7 +175,9 @@ describe('Default — the list loaded with a mix of online and draft articles',
'GET /v1/article/': () => mockResponse(200, sixArticles),
})
expect(wrapper.text()).toContain("Here's where things stand on your site.")
expect(wrapper.text()).toContain(
"Here's where things stand on your site.",
)
const rows = wrapper.find('[data-testid="recent-list"]').findAll('li')
expect(rows).toHaveLength(5)
@@ -185,7 +196,9 @@ describe('Default — the list loaded with a mix of online and draft articles',
expect(wrapper.text()).toContain('2')
expect(wrapper.text()).toContain('drafts')
const guideLink = wrapper.findAll('a').find((a) => a.text() === 'Open the guide')
const guideLink = wrapper
.findAll('a')
.find((a) => a.text() === 'Open the guide')
expect(guideLink?.attributes('href')).toBe('/integration')
await findButton(wrapper, 'New article')?.trigger('click')
@@ -226,7 +239,9 @@ describe('Error — a 500 or a network failure shows the same inline alert', ()
'GET /v1/article/': handler,
})
expect(wrapper.text()).toContain("Here's where things stand on your site.")
expect(wrapper.text()).toContain(
"Here's where things stand on your site.",
)
expect(wrapper.text()).toContain('Good')
const alert = wrapper.find('[role="alert"]')
expect(alert.exists()).toBe(true)
@@ -267,7 +282,9 @@ describe('The list is fetched exactly once per navigation', () => {
expect(listCall).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('6')
expect(wrapper.find('[data-testid="recent-list"]').findAll('li')).toHaveLength(5)
expect(
wrapper.find('[data-testid="recent-list"]').findAll('li'),
).toHaveLength(5)
})
})
+96 -37
View File
@@ -5,7 +5,11 @@ import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import LoginView from '../LoginView.vue'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
return {
ok: status >= 200 && status < 300,
status,
@@ -19,7 +23,9 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}`
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
function fetchRouter(
handlers: Record<string, () => Response | Promise<Response>>,
) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
@@ -59,7 +65,9 @@ async function fillAndSubmit(
password: string,
) {
await wrapper.find('input[placeholder="Your username"]').setValue(username)
await wrapper.find('input[autocomplete="current-password"]').setValue(password)
await wrapper
.find('input[autocomplete="current-password"]')
.setValue(password)
await wrapper.find('form').trigger('submit')
await flushPromises()
}
@@ -73,16 +81,21 @@ afterEach(async () => {
describe('Default — registration is closed', () => {
it('renders the default state with no create-account footer link', async () => {
const wrapper = await mountLogin('/login', {
'GET /v1/auth/registration': () => mockResponse(200, { open: false }),
'GET /v1/auth/registration': () =>
mockResponse(200, { open: false }),
})
expect(wrapper.find('h1').text()).toBe('Log in')
expect(wrapper.text()).toContain('Welcome back. Log in to manage your articles.')
expect(wrapper.text()).toContain(
'Welcome back. Log in to manage your articles.',
)
const usernameInput = wrapper.find('input[placeholder="Your username"]')
expect((usernameInput.element as HTMLInputElement).value).toBe('')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
expect((passwordInput.element as HTMLInputElement).value).toBe('')
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in')
@@ -93,7 +106,8 @@ describe('Default — registration is closed', () => {
describe('Fresh install — registration is open', () => {
it('renders the same fields plus the create-account footer link', async () => {
const wrapper = await mountLogin('/login', {
'GET /v1/auth/registration': () => mockResponse(200, { open: true }),
'GET /v1/auth/registration': () =>
mockResponse(200, { open: true }),
})
expect(wrapper.find('h1').text()).toBe('Log in')
@@ -129,18 +143,26 @@ describe('Submitting — fields and button are disabled while the request is in
}),
})
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper
.find('input[placeholder="Your username"]')
.setValue('marta')
await wrapper
.find('input[autocomplete="current-password"]')
.setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement)
.disabled,
(
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement
).disabled,
).toBe(true)
expect(
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
.disabled,
(
wrapper.find('input[autocomplete="current-password"]')
.element as HTMLInputElement
).disabled,
).toBe(true)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Logging in…')
@@ -159,13 +181,19 @@ describe('Wrong credentials — the password is cleared and focused', () => {
await fillAndSubmit(wrapper, 'marta', 'wrong-password')
expect(wrapper.text()).toContain("That username and password don't match")
expect(wrapper.text()).toContain('Check both and try again. Passwords are case-sensitive.')
expect(wrapper.text()).toContain(
"That username and password don't match",
)
expect(wrapper.text()).toContain(
'Check both and try again. Passwords are case-sensitive.',
)
const usernameInput = wrapper.find('input[placeholder="Your username"]')
expect((usernameInput.element as HTMLInputElement).value).toBe('marta')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
expect((passwordInput.element as HTMLInputElement).value).toBe('')
expect(passwordInput.element).toBe(document.activeElement)
@@ -179,20 +207,28 @@ describe('Too many attempts — the button is disabled with a live countdown', (
it('counts down from the Retry-After value and re-enables at zero', async () => {
vi.useFakeTimers()
const wrapper = await mountLogin('/login', {
'POST /v1/auth/login': () => mockResponse(429, null, { 'Retry-After': '42' }),
'POST /v1/auth/login': () =>
mockResponse(429, null, { 'Retry-After': '42' }),
})
await fillAndSubmit(wrapper, 'marta', 'limewash')
expect(wrapper.text()).toContain('Too many attempts')
expect(wrapper.text()).toContain('For your security, wait a moment before trying again.')
expect(wrapper.text()).toContain(
'For your security, wait a moment before trying again.',
)
expect(
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement).value,
(
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement
).value,
).toBe('marta')
expect(
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
.value,
(
wrapper.find('input[autocomplete="current-password"]')
.element as HTMLInputElement
).value,
).toBe('limewash')
const button = wrapper.find('button[type="submit"]')
@@ -200,8 +236,10 @@ describe('Too many attempts — the button is disabled with a live countdown', (
expect((button.element as HTMLButtonElement).disabled).toBe(true)
const fields = () => [
wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement,
wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement,
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement,
wrapper.find('input[autocomplete="current-password"]')
.element as HTMLInputElement,
]
expect(fields().map((field) => field.disabled)).toEqual([true, true])
@@ -209,9 +247,10 @@ describe('Too many attempts — the button is disabled with a live countdown', (
await flushPromises()
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in')
expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
false,
)
expect(
(wrapper.find('button[type="submit"]').element as HTMLButtonElement)
.disabled,
).toBe(false)
expect(fields().map((field) => field.disabled)).toEqual([false, false])
vi.useRealTimers()
})
@@ -219,14 +258,19 @@ describe('Too many attempts — the button is disabled with a live countdown', (
describe('Session expired — redirected from a 401 elsewhere', () => {
it('shows the session-expired alert and focuses the password field', async () => {
const wrapper = await mountLogin('/login?redirect=/article/12/edit&reason=expired', {})
const wrapper = await mountLogin(
'/login?redirect=/article/12/edit&reason=expired',
{},
)
expect(wrapper.text()).toContain('Your session expired')
expect(wrapper.text()).toContain(
'Log in again to pick up where you left off. Unsaved changes are kept on this device.',
)
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
expect(passwordInput.element).toBe(document.activeElement)
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in')
})
@@ -247,7 +291,11 @@ describe('Password changed — arrived from a successful reset', () => {
describe('Server or network error — the fields keep their values', () => {
it.each([
{ name: 'a 500 response', password: 'limewash2026', handler: () => mockResponse(500) },
{
name: 'a 500 response',
password: 'limewash2026',
handler: () => mockResponse(500),
},
{
name: 'a network failure',
password: 'limewash2026',
@@ -270,20 +318,28 @@ describe('Server or network error — the fields keep their values', () => {
expect(login).toHaveBeenCalled()
expect(wrapper.text()).toContain("Couldn't reach RenewCMS")
expect(wrapper.text()).toContain('Check your connection and try again.')
expect(wrapper.text()).toContain(
'Check your connection and try again.',
)
expect(
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement)
.value,
(
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement
).value,
).toBe('marta')
expect(
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
.value,
(
wrapper.find('input[autocomplete="current-password"]')
.element as HTMLInputElement
).value,
).toBe(password)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Log in')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(false)
expect((submitButton.element as HTMLButtonElement).disabled).toBe(
false,
)
},
)
})
@@ -302,12 +358,15 @@ describe('A successful log in navigates to the right destination', () => {
async ({ path, verified, destination }) => {
const wrapper = await mountLogin(path, {
'POST /v1/auth/login': () => mockResponse(200),
'GET /v1/auth/me': () => mockResponse(200, { is_verified: verified }),
'GET /v1/auth/me': () =>
mockResponse(200, { is_verified: verified }),
})
await fillAndSubmit(wrapper, 'marta', 'limewash2026')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe(destination))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe(destination),
)
},
)
})
+55 -29
View File
@@ -6,7 +6,11 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import PendingView from '../PendingView.vue'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
return {
ok: status >= 200 && status < 300,
status,
@@ -20,7 +24,9 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}`
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
function fetchRouter(
handlers: Record<string, () => Response | Promise<Response>>,
) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
@@ -34,15 +40,17 @@ function fetchRouter(handlers: Record<string, () => Response | Promise<Response>
function createOverlayMock() {
let props: Record<string, unknown> | null = null
let resolveOpen: ((value: unknown) => void) | null = null
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
props = options.props
return {
open: () =>
new Promise((resolve) => {
resolveOpen = resolve
}),
}
})
const create = vi.fn(
(_component: unknown, options: { props: Record<string, unknown> }) => {
props = options.props
return {
open: () =>
new Promise((resolve) => {
resolveOpen = resolve
}),
}
},
)
return {
create,
getProps: () => props,
@@ -140,16 +148,24 @@ describe('Logging out', () => {
await findButton(wrapper, 'Log out')?.trigger('click')
await flushPromises()
expect((findButton(wrapper, 'Log out')?.element as HTMLButtonElement).disabled).toBe(true)
expect(
(findButton(wrapper, 'Cancel registration')?.element as HTMLButtonElement).disabled,
(findButton(wrapper, 'Log out')?.element as HTMLButtonElement)
.disabled,
).toBe(true)
expect(
(
findButton(wrapper, 'Cancel registration')
?.element as HTMLButtonElement
).disabled,
).toBe(true)
resolveLogout?.()
await flushPromises()
expect(session.session).toBeNull()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/login'),
)
})
})
@@ -229,7 +245,9 @@ describe('Cancelled — 200 navigates to /register with a toast', () => {
overlayMock.resolveOpen(true)
await flushPromises()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register'))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register'),
)
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
@@ -273,9 +291,12 @@ describe('Already verified — 403 shows the verified panel and toast, then navi
})
expect(router.currentRoute.value.path).not.toBe('/home')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
timeout: 2000,
})
await vi.waitFor(
() => expect(router.currentRoute.value.path).toBe('/home'),
{
timeout: 2000,
},
)
expect(toastAdd).toHaveBeenCalledTimes(1)
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
pushSpy.mockRestore()
@@ -289,18 +310,23 @@ describe('Cancel failed — the dialog stays open and shows the failure inline',
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('rejects the action instead of navigating for $name', async ({ handler }) => {
const { wrapper } = await mountPending({
'DELETE /v1/auth/register': handler,
})
])(
'rejects the action instead of navigating for $name',
async ({ handler }) => {
const { wrapper } = await mountPending({
'DELETE /v1/auth/register': handler,
})
await findButton(wrapper, 'Cancel registration')?.trigger('click')
await flushPromises()
await findButton(wrapper, 'Cancel registration')?.trigger('click')
await flushPromises()
const props = overlayMock.getProps() as { action: () => Promise<void> }
await expect(props.action()).rejects.toThrow()
const props = overlayMock.getProps() as {
action: () => Promise<void>
}
await expect(props.action()).rejects.toThrow()
expect(router.currentRoute.value.path).toBe('/register/pending')
expect(toastAdd).not.toHaveBeenCalled()
})
expect(router.currentRoute.value.path).toBe('/register/pending')
expect(toastAdd).not.toHaveBeenCalled()
},
)
})
+113 -54
View File
@@ -6,7 +6,11 @@ import router from '@/router'
import ResultPanel from '@/components/ResultPanel.vue'
import RegisterView from '../RegisterView.vue'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
return {
ok: status >= 200 && status < 300,
status,
@@ -20,7 +24,9 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}`
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
function fetchRouter(
handlers: Record<string, () => Response | Promise<Response>>,
) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
@@ -33,7 +39,9 @@ function fetchRouter(handlers: Record<string, () => Response | Promise<Response>
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountRegister(handlers: Record<string, () => Response | Promise<Response>> = {}) {
async function mountRegister(
handlers: Record<string, () => Response | Promise<Response>> = {},
) {
setActivePinia(createPinia())
const fetchMock = fetchRouter(handlers)
global.fetch = fetchMock as unknown as typeof fetch
@@ -61,7 +69,9 @@ function passwordInput(wrapper: Awaited<ReturnType<typeof mountRegister>>) {
return wrapper.find('input[autocomplete="new-password"]')
}
async function fillValidForm(wrapper: Awaited<ReturnType<typeof mountRegister>>) {
async function fillValidForm(
wrapper: Awaited<ReturnType<typeof mountRegister>>,
) {
await usernameInput(wrapper).setValue('marta')
await emailInput(wrapper).setValue('marta@renew.example')
await passwordInput(wrapper).setValue('limewash2026')
@@ -82,15 +92,21 @@ describe('Default — a fresh, empty form', () => {
"This creates the single admin account for this site. Once it's verified, registration closes.",
)
expect((usernameInput(wrapper).element as HTMLInputElement).value).toBe('')
expect((usernameInput(wrapper).element as HTMLInputElement).value).toBe(
'',
)
expect(wrapper.text()).toContain('0 / 20')
expect((emailInput(wrapper).element as HTMLInputElement).value).toBe('')
expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe('')
expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe(
'',
)
expect(wrapper.text()).toContain('At least 8 characters')
expect(wrapper.find('button[type="submit"]').text()).toBe('Create account')
expect(wrapper.find('button[type="submit"]').text()).toBe(
'Create account',
)
expect(wrapper.text()).toContain('Already set up?')
const footerLink = wrapper.find('a[href="/login"]')
expect(footerLink.exists()).toBe(true)
@@ -116,14 +132,18 @@ describe('Validation errors — checked on blur and on submit', () => {
expect(wrapper.text()).toContain('2 / 20')
expect(wrapper.text()).toContain('Use 3 to 20 characters.')
expect(wrapper.text()).toContain('Enter a full email address, like name@example.com.')
expect(wrapper.text()).toContain(
'Enter a full email address, like name@example.com.',
)
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(register).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('Use 3 to 20 characters.')
expect(wrapper.text()).toContain('Enter a full email address, like name@example.com.')
expect(wrapper.text()).toContain(
'Enter a full email address, like name@example.com.',
)
})
})
@@ -136,9 +156,11 @@ describe('Filled, valid — every rule is met', () => {
const toggle = wrapper.find('button[aria-label="Show password"]')
await toggle.trigger('click')
expect(wrapper.find('button[aria-label="Hide password"]').attributes('aria-pressed')).toBe(
'true',
)
expect(
wrapper
.find('button[aria-label="Hide password"]')
.attributes('aria-pressed'),
).toBe('true')
expect(wrapper.text()).toContain('5 / 20')
expect(wrapper.text()).not.toContain('Use 3 to 20 characters.')
@@ -146,11 +168,17 @@ describe('Filled, valid — every rule is met', () => {
"Letters and numbers, 3 to 20 characters. You'll use it to log in.",
)
expect(wrapper.text()).not.toContain('Enter a full email address, like name@example.com.')
expect(wrapper.text()).not.toContain(
'Enter a full email address, like name@example.com.',
)
expect(wrapper.text()).toContain("We'll send a verification link here.")
expect((passwordInput(wrapper).element as HTMLInputElement).type).toBe('text')
expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe('limewash2026')
expect((passwordInput(wrapper).element as HTMLInputElement).type).toBe(
'text',
)
expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe(
'limewash2026',
)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Create account')
@@ -172,9 +200,15 @@ describe('Submitting — fields and button are disabled while the request is in
await wrapper.find('form').trigger('submit')
await flushPromises()
expect((usernameInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
expect((passwordInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
expect(
(usernameInput(wrapper).element as HTMLInputElement).disabled,
).toBe(true)
expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(
true,
)
expect(
(passwordInput(wrapper).element as HTMLInputElement).disabled,
).toBe(true)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Creating account…')
@@ -221,7 +255,9 @@ describe('Registration closed — the form is replaced by a ResultPanel', () =>
expect(action.text()).toBe('Go to log in')
await action.trigger('click')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/login'),
)
})
})
@@ -232,44 +268,61 @@ describe('Server or network error — the form is kept and re-enabled', () => {
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows the error alert and keeps the values for $name', async ({ handler }) => {
const register = vi.fn(handler)
const { wrapper, fetchMock } = await mountRegister({
'POST /v1/auth/register': register,
})
])(
'shows the error alert and keeps the values for $name',
async ({ handler }) => {
const register = vi.fn(handler)
const { wrapper, fetchMock } = await mountRegister({
'POST /v1/auth/register': register,
})
await fillValidForm(wrapper)
await wrapper.find('form').trigger('submit')
await flushPromises()
await fillValidForm(wrapper)
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/register',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
username: 'marta',
email: 'marta@renew.example',
password: 'limewash2026',
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/register',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
username: 'marta',
email: 'marta@renew.example',
password: 'limewash2026',
}),
}),
}),
)
expect(wrapper.text()).toContain("Couldn't create the account")
expect(wrapper.text()).toContain(
'Something went wrong on the server. Try again in a moment.',
)
)
expect(wrapper.text()).toContain("Couldn't create the account")
expect(wrapper.text()).toContain(
'Something went wrong on the server. Try again in a moment.',
)
expect((usernameInput(wrapper).element as HTMLInputElement).value).toBe('marta')
expect((emailInput(wrapper).element as HTMLInputElement).value).toBe('marta@renew.example')
expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe('limewash2026')
expect(
(usernameInput(wrapper).element as HTMLInputElement).value,
).toBe('marta')
expect(
(emailInput(wrapper).element as HTMLInputElement).value,
).toBe('marta@renew.example')
expect(
(passwordInput(wrapper).element as HTMLInputElement).value,
).toBe('limewash2026')
expect((usernameInput(wrapper).element as HTMLInputElement).disabled).toBe(false)
expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(false)
expect((passwordInput(wrapper).element as HTMLInputElement).disabled).toBe(false)
expect(
(usernameInput(wrapper).element as HTMLInputElement).disabled,
).toBe(false)
expect(
(emailInput(wrapper).element as HTMLInputElement).disabled,
).toBe(false)
expect(
(passwordInput(wrapper).element as HTMLInputElement).disabled,
).toBe(false)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Create account')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(false)
})
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Create account')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(
false,
)
},
)
})
describe('A successful registration goes straight to the pending page', () => {
@@ -282,7 +335,9 @@ describe('A successful registration goes straight to the pending page', () => {
await fillValidForm(wrapper)
await wrapper.find('form').trigger('submit')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register/pending'),
)
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/register',
@@ -307,8 +362,12 @@ describe('A successful registration goes straight to the pending page', () => {
await fillValidForm(wrapper)
await wrapper.find('form').trigger('submit')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register/pending'),
)
expect(sessionStorage.getItem('renewcms:pendingEmail')).toBe('marta@renew.example')
expect(sessionStorage.getItem('renewcms:pendingEmail')).toBe(
'marta@renew.example',
)
})
})
+184 -81
View File
@@ -6,7 +6,11 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import VerifyView from '../VerifyView.vue'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
return {
ok: status >= 200 && status < 300,
status,
@@ -20,7 +24,9 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}`
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
function fetchRouter(
handlers: Record<string, () => Response | Promise<Response>>,
) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
@@ -75,15 +81,23 @@ afterEach(async () => {
describe('Verifying — the code submits automatically on load', () => {
it('shows the full-card spinner and calls verify once with the code', async () => {
const verify = vi.fn(() => new Promise(() => {}))
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': verify,
})
const { wrapper, fetchMock } = await mountVerify(
'/register/validate?c=abc123',
{
'POST /v1/auth/verify': verify,
},
)
expect(wrapper.find('[role="status"]').text()).toContain('Verifying your account…')
expect(wrapper.find('[role="status"]').text()).toContain(
'Verifying your account…',
)
expect(verify).toHaveBeenCalledTimes(1)
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/verify',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ code: 'abc123' }) }),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ code: 'abc123' }),
}),
)
})
})
@@ -105,9 +119,12 @@ describe('Success — 200 refreshes the session and navigates to /home with a to
description: 'Welcome to RenewCMS.',
})
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
timeout: 2000,
})
await vi.waitFor(
() => expect(router.currentRoute.value.path).toBe('/home'),
{
timeout: 2000,
},
)
expect(toastAdd).toHaveBeenCalledTimes(1)
const session = useSessionStore()
@@ -135,7 +152,9 @@ describe('Invalid or expired — 403 offers a way to start over', () => {
expect(wrapper.text()).toContain(
'Cancel your registration and start over to get a new link.',
)
expect(findButton(wrapper, 'Cancel registration and start over')).toBeTruthy()
expect(
findButton(wrapper, 'Cancel registration and start over'),
).toBeTruthy()
expect(findButton(wrapper, 'Log out')).toBeTruthy()
expect(router.currentRoute.value.path).toBe('/register/validate')
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
@@ -146,19 +165,27 @@ describe('Invalid or expired — 403 offers a way to start over', () => {
describe('Cancel registration and start over — success', () => {
it('sends DELETE with onForbidden passthrough, navigates to /register and toasts', async () => {
const deleteRegister = vi.fn(() => mockResponse(200))
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(403),
'DELETE /v1/auth/register': deleteRegister,
})
const { wrapper, fetchMock } = await mountVerify(
'/register/validate?c=abc123',
{
'POST /v1/auth/verify': () => mockResponse(403),
'DELETE /v1/auth/register': deleteRegister,
},
)
await findButton(wrapper, 'Cancel registration and start over')?.trigger('click')
await findButton(
wrapper,
'Cancel registration and start over',
)?.trigger('click')
await flushPromises()
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/register',
expect.objectContaining({ method: 'DELETE' }),
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register'))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register'),
)
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
@@ -170,31 +197,47 @@ describe('Cancel registration and start over — success', () => {
describe('Cancel registration and start over — failure shows an alert, not a redirect', () => {
it.each([
{ name: 'a 403 (already verified elsewhere)', handler: () => mockResponse(403) },
{
name: 'a 403 (already verified elsewhere)',
handler: () => mockResponse(403),
},
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows the failure alert and keeps the panel for $name', async ({ handler }) => {
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(403),
'DELETE /v1/auth/register': handler,
})
])(
'shows the failure alert and keeps the panel for $name',
async ({ handler }) => {
const { wrapper } = await mountVerify(
'/register/validate?c=abc123',
{
'POST /v1/auth/verify': () => mockResponse(403),
'DELETE /v1/auth/register': handler,
},
)
await findButton(wrapper, 'Cancel registration and start over')?.trigger('click')
await flushPromises()
await findButton(
wrapper,
'Cancel registration and start over',
)?.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain("Couldn't cancel registration")
expect(wrapper.text()).toContain(
'Something went wrong on the server. Try again in a moment.',
)
expect(wrapper.text()).toContain("This verification link doesn't work")
expect(findButton(wrapper, 'Cancel registration and start over')).toBeTruthy()
expect(findButton(wrapper, 'Log out')).toBeTruthy()
expect(router.currentRoute.value.path).toBe('/register/validate')
expect(toastAdd).not.toHaveBeenCalled()
})
expect(wrapper.text()).toContain("Couldn't cancel registration")
expect(wrapper.text()).toContain(
'Something went wrong on the server. Try again in a moment.',
)
expect(wrapper.text()).toContain(
"This verification link doesn't work",
)
expect(
findButton(wrapper, 'Cancel registration and start over'),
).toBeTruthy()
expect(findButton(wrapper, 'Log out')).toBeTruthy()
expect(router.currentRoute.value.path).toBe('/register/validate')
expect(toastAdd).not.toHaveBeenCalled()
},
)
})
describe('Log out from the Invalid-or-expired state', () => {
@@ -211,7 +254,9 @@ describe('Log out from the Invalid-or-expired state', () => {
await flushPromises()
expect(session.session).toBeNull()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/login'),
)
})
})
@@ -225,9 +270,15 @@ describe('No session — the inline log-in form replaces the spinner', () => {
expect(wrapper.text()).toContain(
"You opened this link somewhere you're not logged in. Log in here and we'll verify your account right after.",
)
expect(wrapper.find('input[placeholder="Your username"]').exists()).toBe(true)
expect(wrapper.find('input[autocomplete="current-password"]').exists()).toBe(true)
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in and verify')
expect(
wrapper.find('input[placeholder="Your username"]').exists(),
).toBe(true)
expect(
wrapper.find('input[autocomplete="current-password"]').exists(),
).toBe(true)
expect(wrapper.find('button[type="submit"]').text()).toBe(
'Log in and verify',
)
expect(wrapper.text()).not.toContain('Forgot password?')
expect(wrapper.text()).not.toContain('First time here?')
expect(router.currentRoute.value.path).toBe('/register/validate')
@@ -259,20 +310,32 @@ describe('No session — successful login retries verification with the same cod
const verify = vi.fn()
verify.mockResolvedValueOnce(mockResponse(401))
verify.mockImplementationOnce(() => new Promise(() => {}))
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': verify,
'POST /v1/auth/login': () => mockResponse(200),
})
const { wrapper, fetchMock } = await mountVerify(
'/register/validate?c=abc123',
{
'POST /v1/auth/verify': verify,
'POST /v1/auth/login': () => mockResponse(200),
},
)
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper
.find('input[placeholder="Your username"]')
.setValue('marta')
await wrapper
.find('input[autocomplete="current-password"]')
.setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.find('[role="status"]').text()).toContain('Verifying your account…')
expect(wrapper.find('[role="status"]').text()).toContain(
'Verifying your account…',
)
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/verify',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ code: 'abc123' }) }),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ code: 'abc123' }),
}),
)
expect(verify).toHaveBeenCalledTimes(2)
})
@@ -286,14 +349,24 @@ describe('No session — wrong credentials during the inline login', () => {
'POST /v1/auth/login': () => mockResponse(403),
})
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('wrong-password')
await wrapper
.find('input[placeholder="Your username"]')
.setValue('marta')
await wrapper
.find('input[autocomplete="current-password"]')
.setValue('wrong-password')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain("That username and password don't match")
expect(wrapper.text()).toContain('Check both and try again. Passwords are case-sensitive.')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
expect(wrapper.text()).toContain(
"That username and password don't match",
)
expect(wrapper.text()).toContain(
'Check both and try again. Passwords are case-sensitive.',
)
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
expect((passwordInput.element as HTMLInputElement).value).toBe('')
expect(passwordInput.element).toBe(document.activeElement)
expect(verify).toHaveBeenCalledTimes(1)
@@ -305,16 +378,23 @@ describe('No session — too many login attempts', () => {
vi.useFakeTimers()
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(401),
'POST /v1/auth/login': () => mockResponse(429, null, { 'Retry-After': '30' }),
'POST /v1/auth/login': () =>
mockResponse(429, null, { 'Retry-After': '30' }),
})
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper
.find('input[placeholder="Your username"]')
.setValue('marta')
await wrapper
.find('input[autocomplete="current-password"]')
.setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('Too many attempts')
expect(wrapper.text()).toContain('For your security, wait a moment before trying again.')
expect(wrapper.text()).toContain(
'For your security, wait a moment before trying again.',
)
const button = wrapper.find('button[type="submit"]')
expect(button.text()).toBe('Try again in 0:30')
expect((button.element as HTMLButtonElement).disabled).toBe(true)
@@ -322,7 +402,9 @@ describe('No session — too many login attempts', () => {
await vi.advanceTimersByTimeAsync(30000)
await flushPromises()
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in and verify')
expect(wrapper.find('button[type="submit"]').text()).toBe(
'Log in and verify',
)
})
})
@@ -333,31 +415,50 @@ describe('No session — server or network error during the inline login', () =>
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows the network/server alert and keeps the fields for $name', async ({ handler }) => {
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(401),
'POST /v1/auth/login': handler,
})
])(
'shows the network/server alert and keeps the fields for $name',
async ({ handler }) => {
const { wrapper } = await mountVerify(
'/register/validate?c=abc123',
{
'POST /v1/auth/verify': () => mockResponse(401),
'POST /v1/auth/login': handler,
},
)
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
await wrapper
.find('input[placeholder="Your username"]')
.setValue('marta')
await wrapper
.find('input[autocomplete="current-password"]')
.setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain("Couldn't reach RenewCMS")
expect(wrapper.text()).toContain('Check your connection and try again.')
expect(
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement).value,
).toBe('marta')
expect(
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
.value,
).toBe('limewash2026')
expect(
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement)
.disabled,
).toBe(false)
})
expect(wrapper.text()).toContain("Couldn't reach RenewCMS")
expect(wrapper.text()).toContain(
'Check your connection and try again.',
)
expect(
(
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement
).value,
).toBe('marta')
expect(
(
wrapper.find('input[autocomplete="current-password"]')
.element as HTMLInputElement
).value,
).toBe('limewash2026')
expect(
(
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement
).disabled,
).toBe(false)
},
)
})
describe('Missing code — no request is made', () => {
@@ -374,6 +475,8 @@ describe('Missing code — no request is made', () => {
expect(action).toBeTruthy()
await action?.trigger('click')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/login'),
)
})
})