feat: PrimeVue replaced by Nuxt UI using token files from web/src/theme, with AuthShell, DashboardShell, AppBoot and the route guards from routes.md
This commit is contained in:
1 parent
24f95a8fd7
commit
eed661bb24
38 files changed
+2365
-108
No files matched your search
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4 p-6" role="status" aria-label="Loading">
|
||||
<USkeleton class="h-6 w-40" />
|
||||
<USkeleton class="h-32 w-full" />
|
||||
<USkeleton class="h-32 w-full" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { useSlots, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
}>()
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
watch(
|
||||
() => props.title,
|
||||
(title) => {
|
||||
document.title = `${title} · RenewCMS`
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="flex min-h-screen flex-col items-center bg-default px-5 pt-[12vh] sm:px-0">
|
||||
<UAlert v-if="slots.notice" class="mb-4 w-full sm:max-w-[400px]">
|
||||
<template #description>
|
||||
<slot name="notice" />
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<UPageCard variant="outline" class="w-full sm:max-w-[400px]">
|
||||
<span role="img" aria-label="RenewCMS" class="i-lucide-square inline-block h-8 w-8" />
|
||||
|
||||
<h1>{{ title }}</h1>
|
||||
<p v-if="description">{{ description }}</p>
|
||||
|
||||
<slot />
|
||||
</UPageCard>
|
||||
|
||||
<footer class="mt-4 flex w-full items-center justify-between sm:max-w-[400px]">
|
||||
<slot name="footer" />
|
||||
<UColorModeSelect size="sm" />
|
||||
</footer>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import AppBoot from '@/components/AppBoot.vue'
|
||||
import ErrorView from '@/components/ErrorView.vue'
|
||||
import OfflineBanner from '@/components/OfflineBanner.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const session = useSessionStore()
|
||||
|
||||
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 pageTitle = computed(() => (route.meta.title as string) ?? '')
|
||||
|
||||
function onRetry() {
|
||||
session.retry()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardGroup unit="rem">
|
||||
<a href="#dashboard-content" class="sr-only focus:not-sr-only">Skip to content</a>
|
||||
|
||||
<UDashboardSidebar collapsible :resizable="false" :default-size="16" :collapsed-size="4">
|
||||
<template #header>
|
||||
<span class="font-semibold">RenewCMS</span>
|
||||
</template>
|
||||
|
||||
<UNavigationMenu orientation="vertical" :items="navItems" aria-label="Main" />
|
||||
|
||||
<template #footer>
|
||||
<span>Log out</span>
|
||||
</template>
|
||||
</UDashboardSidebar>
|
||||
|
||||
<UDashboardPanel>
|
||||
<template #header>
|
||||
<UDashboardNavbar :title="pageTitle">
|
||||
<template #leading>
|
||||
<UDashboardSidebarCollapse />
|
||||
</template>
|
||||
<template #right>
|
||||
<slot name="navbar-right" />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
<UBreadcrumb :items="[{ label: pageTitle }]" />
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div id="dashboard-content">
|
||||
<OfflineBanner />
|
||||
|
||||
<AppBoot v-if="session.status === 'checking'" />
|
||||
<ErrorView
|
||||
v-else-if="session.status === 'error'"
|
||||
kind="error"
|
||||
@retry="onRetry"
|
||||
/>
|
||||
<div v-else>
|
||||
<h1>{{ pageTitle }}</h1>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
</UDashboardGroup>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
kind: 'not-found' | 'error'
|
||||
breadcrumb?: string
|
||||
}>(),
|
||||
{
|
||||
breadcrumb: 'Home',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
retry: []
|
||||
}>()
|
||||
|
||||
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',
|
||||
},
|
||||
)
|
||||
|
||||
function onPrimary() {
|
||||
if (props.kind === 'error') {
|
||||
emit('retry')
|
||||
return
|
||||
}
|
||||
router.push('/home')
|
||||
}
|
||||
|
||||
function onSecondary() {
|
||||
if (props.kind === 'error') {
|
||||
router.push('/home')
|
||||
return
|
||||
}
|
||||
router.push('/article')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div role="alert" class="space-y-4 p-6">
|
||||
<nav aria-label="Breadcrumb">
|
||||
<span>{{ breadcrumb }}</span>
|
||||
</nav>
|
||||
<h1 tabindex="-1">{{ copy.title }}</h1>
|
||||
<p>{{ copy.text }}</p>
|
||||
<div class="flex gap-2">
|
||||
<UButton @click="onPrimary">{{ copy.primary }}</UButton>
|
||||
<UButton variant="outline" color="neutral" @click="onSecondary">{{
|
||||
copy.secondary
|
||||
}}</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,33 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Button, Drawer } from 'primevue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const visible = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<Drawer v-model:visible="visible">
|
||||
<template #container="{ closeCallback }">
|
||||
<ul @click="closeCallback">
|
||||
<li><RouterLink to="/">Home</RouterLink></li>
|
||||
<li><RouterLink to="/article">Article</RouterLink></li>
|
||||
</ul>
|
||||
<div class="button-position">
|
||||
<Button
|
||||
type="button"
|
||||
@click="closeCallback"
|
||||
icon="pi pi-times"
|
||||
variant="outlined"
|
||||
></Button>
|
||||
</div>
|
||||
</template>
|
||||
</Drawer>
|
||||
</header>
|
||||
<div class="button-position">
|
||||
<Button icon="pi pi-bars" @click="visible = true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const isOffline = ref(!navigator.onLine)
|
||||
|
||||
function updateOnlineStatus() {
|
||||
isOffline.value = !navigator.onLine
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('online', updateOnlineStatus)
|
||||
window.addEventListener('offline', updateOnlineStatus)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('online', updateOnlineStatus)
|
||||
window.removeEventListener('offline', updateOnlineStatus)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UBanner
|
||||
v-if="isOffline"
|
||||
color="neutral"
|
||||
title="You're offline. You can keep writing, but saving and publishing will wait until you're back online."
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import AuthShell from '../AuthShell.vue'
|
||||
|
||||
function mountShell(props: Record<string, unknown>, slots: Record<string, unknown> = {}) {
|
||||
return mount(AuthShell, {
|
||||
props,
|
||||
slots,
|
||||
global: {
|
||||
plugins: [uiPlugin],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('AuthShell renders its title as the page heading and sets the document title', () => {
|
||||
it('renders an h1 containing the title and sets document.title', () => {
|
||||
const wrapper = mountShell({ title: 'Log in' })
|
||||
|
||||
expect(wrapper.find('h1').text()).toBe('Log in')
|
||||
expect(document.title).toBe('Log in · RenewCMS')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AuthShell renders its notice and footer slots', () => {
|
||||
it('renders the notice slot in an alert above the title and the footer slot outside the card', () => {
|
||||
const wrapper = mountShell(
|
||||
{ title: 'Log in' },
|
||||
{
|
||||
notice: '<span>Your session expired.</span>',
|
||||
footer: '<a href="/register/reset/request">Forgot password?</a>',
|
||||
},
|
||||
)
|
||||
|
||||
const alertDescription = wrapper.find('[data-slot="description"]')
|
||||
expect(alertDescription.text()).toBe('Your session expired.')
|
||||
|
||||
const html = wrapper.html()
|
||||
expect(html.indexOf('Your session expired.')).toBeLessThan(html.indexOf('<h1>'))
|
||||
|
||||
const footerLink = wrapper.find('a[href="/register/reset/request"]')
|
||||
expect(footerLink.exists()).toBe(true)
|
||||
expect(footerLink.text()).toBe('Forgot password?')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import DashboardShell from '../DashboardShell.vue'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return {
|
||||
...actual,
|
||||
useApi: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
async function mountShell(path: string) {
|
||||
setActivePinia(createPinia())
|
||||
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn().mockResolvedValue({ verified: true }) })
|
||||
await router.push(path)
|
||||
const wrapper = mount(DashboardShell, {
|
||||
global: {
|
||||
plugins: [router, uiPlugin],
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe('DashboardShell renders the three navigation destinations', () => {
|
||||
it('lists Home, Articles and Integration, with only Articles carrying aria-current', async () => {
|
||||
const wrapper = await mountShell('/article')
|
||||
|
||||
const home = wrapper.find('a[href="/home"]')
|
||||
const articles = wrapper.find('a[href="/article"]')
|
||||
const integration = wrapper.find('a[href="/integration"]')
|
||||
|
||||
expect(home.exists()).toBe(true)
|
||||
expect(articles.exists()).toBe(true)
|
||||
expect(integration.exists()).toBe(true)
|
||||
|
||||
expect(articles.attributes('aria-current')).toBe('page')
|
||||
expect(home.attributes('aria-current')).toBeUndefined()
|
||||
expect(integration.attributes('aria-current')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
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 wrapper = await mountShell('/article')
|
||||
|
||||
expect(wrapper.text()).toContain("You're offline")
|
||||
|
||||
onLineSpy.mockReturnValue(true)
|
||||
window.dispatchEvent(new Event('online'))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain("You're offline")
|
||||
|
||||
onLineSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import ErrorView from '../ErrorView.vue'
|
||||
|
||||
function makeRouter() {
|
||||
return createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: { template: '<div />' } },
|
||||
{ path: '/home', component: { template: '<div />' } },
|
||||
{ path: '/article', component: { template: '<div />' } },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
async function mountNotFound(breadcrumb?: string) {
|
||||
const router = makeRouter()
|
||||
await router.push('/')
|
||||
return mount(ErrorView, {
|
||||
props: breadcrumb ? { kind: 'not-found', breadcrumb } : { kind: 'not-found' },
|
||||
global: {
|
||||
plugins: [router],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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('h1').text()).toBe('Page not found')
|
||||
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)
|
||||
})
|
||||
|
||||
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')
|
||||
await primary?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/home')
|
||||
|
||||
const secondary = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === 'All articles')
|
||||
await secondary?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/article')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user