feat: article editor core
This commit is contained in:
1 parent
d493a181e1
commit
a4734b2c91
55 files changed
+4186
-496
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user