feat(SPA): schema list and the sidebar Content group
This commit is contained in:
1 parent
73a75d5cb9
commit
3c67cd2314
34 files changed
+2181
-18
No files matched your search
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import DirectoryAddButton from '@/components/DirectoryAddButton.vue'
|
||||
import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
|
||||
import SchemaEmpty from '@/components/SchemaEmpty.vue'
|
||||
import SchemaForbidden from '@/components/SchemaForbidden.vue'
|
||||
import SchemaTable from '@/components/SchemaTable.vue'
|
||||
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
|
||||
import { builtinRow, customRow, schemaSummary } from '@/lib/schemaRows'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const NEW_TYPE_PATH = '/schema/new'
|
||||
|
||||
const router = useRouter()
|
||||
const session = useSessionStore()
|
||||
const store = useContentTypesStore()
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const skeleton = useDelayedSkeleton()
|
||||
|
||||
const sessionReady = computed(() => session.status === 'ready')
|
||||
const forbidden = computed(
|
||||
() => (sessionReady.value && !session.canManageContent) || store.forbidden,
|
||||
)
|
||||
const loading = computed(
|
||||
() =>
|
||||
!forbidden.value &&
|
||||
(store.status === 'idle' ||
|
||||
store.status === 'loading' ||
|
||||
store.builtinStatus === 'idle' ||
|
||||
store.builtinStatus === 'loading'),
|
||||
)
|
||||
const failed = computed(
|
||||
() => !forbidden.value && !loading.value && (store.error || store.builtinStatus === 'failed'),
|
||||
)
|
||||
const empty = computed(() => !loading.value && !failed.value && store.items.length === 0)
|
||||
const populated = computed(() => !loading.value && !failed.value)
|
||||
const showSkeleton = computed(() => loading.value && skeleton.visible.value)
|
||||
|
||||
const customRows = computed(() => store.items.map(customRow))
|
||||
const builtinRows = computed(() => store.builtinItems.map(builtinRow))
|
||||
const summary = computed(() => schemaSummary(store.items.length, store.builtinItems.length))
|
||||
|
||||
function retry() {
|
||||
store.retry()
|
||||
}
|
||||
|
||||
watch(
|
||||
loading,
|
||||
(isLoading) => {
|
||||
if (isLoading) {
|
||||
skeleton.start()
|
||||
} else {
|
||||
skeleton.stop()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(skeleton.stop)
|
||||
|
||||
function createType() {
|
||||
router.push(NEW_TYPE_PATH)
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
router.push('/home')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell compact-padding :full-bleed="forbidden" :mobile-title="t('Schema')">
|
||||
<SchemaForbidden v-if="forbidden" @back="goHome" />
|
||||
|
||||
<div v-else-if="sessionReady" class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
|
||||
<h1 class="text-xl font-semibold text-highlighted max-lg:sr-only">
|
||||
{{ t('Schema') }}
|
||||
</h1>
|
||||
<p class="text-muted">
|
||||
{{ t('Content types and the fields they are made of.') }}
|
||||
</p>
|
||||
</div>
|
||||
<UButton
|
||||
icon="i-lucide-plus"
|
||||
class="h-8 px-3.5 max-md:h-11"
|
||||
:disabled="loading"
|
||||
@click="createType"
|
||||
>
|
||||
{{ t('New content type') }}
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<DirectoryLoadError
|
||||
v-if="failed"
|
||||
:title="t('Couldn\'t load content types')"
|
||||
:description="
|
||||
t('GET /v1/content-type failed. Check your connection and try again.')
|
||||
"
|
||||
@retry="retry"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<SchemaEmpty v-if="empty" @create="createType" />
|
||||
<SchemaTable
|
||||
v-if="showSkeleton || populated"
|
||||
:custom="customRows"
|
||||
:builtin="builtinRows"
|
||||
:loading="showSkeleton"
|
||||
/>
|
||||
<p v-if="populated && !empty" class="text-muted">{{ summary }}</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<DirectoryAddButton
|
||||
v-if="!isDesktop && !forbidden && populated && !empty"
|
||||
pinned
|
||||
@click="createType"
|
||||
>
|
||||
{{ t('New content type') }}
|
||||
</DirectoryAddButton>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -81,11 +81,12 @@ describe('Navigation', () => {
|
||||
'/media',
|
||||
'/authors',
|
||||
'/users',
|
||||
'/schema',
|
||||
'/activity',
|
||||
'/integration',
|
||||
'/settings',
|
||||
])
|
||||
const activity = links[5]!
|
||||
const activity = links[6]!
|
||||
expect(activity.getAttribute('aria-current')).toBe('page')
|
||||
await expectIcon(activity, 'history')
|
||||
expect(textOf(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe('Activity')
|
||||
|
||||
@@ -672,7 +672,10 @@ async function requestRemoveImage(wrapper: Awaited<ReturnType<typeof mount>>, im
|
||||
|
||||
function requestCalls(wrapper: Awaited<ReturnType<typeof mount>>) {
|
||||
void wrapper
|
||||
return vi.mocked(global.fetch).mock.calls.map(([url, init]) => routeFor(url as string, init))
|
||||
return vi
|
||||
.mocked(global.fetch)
|
||||
.mock.calls.filter(([url]) => url !== '/v1/content-type')
|
||||
.map(([url, init]) => routeFor(url as string, init))
|
||||
}
|
||||
|
||||
describe('Requesting removal of an inline image opens the neutral confirm dialog', () => {
|
||||
|
||||
@@ -144,7 +144,9 @@ function listCalls() {
|
||||
}
|
||||
|
||||
function requestedPaths() {
|
||||
return requestMock.mock.calls.map(([path]) => path)
|
||||
return requestMock.mock.calls
|
||||
.map(([path]) => path)
|
||||
.filter((path) => path !== '/v1/content-type')
|
||||
}
|
||||
|
||||
function images() {
|
||||
|
||||
@@ -191,7 +191,7 @@ describe('Default, every card collapsed', () => {
|
||||
await expand(wrapper, 'GET /v1/media/')
|
||||
|
||||
expect(globalThis.fetch).toBe(fetchSpy)
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
expect(fetchSpy.mock.calls.filter(([url]) => url !== '/v1/content-type')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1557,6 +1557,7 @@ describe('Sidebar', () => {
|
||||
'/media',
|
||||
'/authors',
|
||||
'/users',
|
||||
'/schema',
|
||||
'/activity',
|
||||
'/integration',
|
||||
'/settings',
|
||||
|
||||
@@ -230,6 +230,10 @@ afterEach(async () => {
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
function shellFreeCalls(mock: { mock: { calls: unknown[][] } }) {
|
||||
return mock.mock.calls.filter(([path]) => path !== '/v1/content-type')
|
||||
}
|
||||
|
||||
describe('Route guards for /profile', () => {
|
||||
it('renders ProfilePage for a resolved, verified session', async () => {
|
||||
await mountApp()
|
||||
@@ -287,7 +291,7 @@ describe('Default: the usual first visit with no display name', () => {
|
||||
)
|
||||
expect(resetButton().textContent?.trim()).toBe('Send reset link')
|
||||
expect(byTestId('reset-note')).toBeNull()
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
expect(shellFreeCalls(request)).toEqual([])
|
||||
})
|
||||
|
||||
it('marks none of the eight navigation items as the current page', async () => {
|
||||
@@ -364,7 +368,7 @@ describe('Editing: typing turns Save on', () => {
|
||||
expect(footer().textContent?.trim()).toBe('Unsaved changes')
|
||||
expect(footer().classList.contains('text-warning')).toBe(true)
|
||||
expect(isEnabled(saveButton())).toBe(true)
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
expect(shellFreeCalls(request)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -513,7 +517,7 @@ describe('Saved: a 200 updates the session, the chip, the footer and raises a to
|
||||
title: 'Display name saved',
|
||||
description: 'You now appear as Marta Alves across RenewCMS and in emails.',
|
||||
})
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
expect(shellFreeCalls(request)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('opens no leave confirmation when navigating away afterwards', async () => {
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import router from '@/router'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import {
|
||||
builtinTypes,
|
||||
button,
|
||||
calls,
|
||||
customTypes,
|
||||
failWith,
|
||||
mountAt,
|
||||
networkFailure,
|
||||
pending,
|
||||
text,
|
||||
unmountAll,
|
||||
} from './schemaTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const ACCESS_TITLE = "You don't have access to Schema"
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
function main() {
|
||||
return document.body.querySelector('#dashboard-content')!
|
||||
}
|
||||
|
||||
function rowTexts() {
|
||||
return [...document.body.querySelectorAll('[data-testid="schema-row"]')].map(text)
|
||||
}
|
||||
|
||||
describe('Schema list: populated', () => {
|
||||
it('shows the title, the subtitle, an enabled New content type action and the columns', async () => {
|
||||
await mountAt('/schema')
|
||||
|
||||
expect(main().querySelector('h1')?.textContent).toBe('Schema')
|
||||
expect(text(main())).toContain('Content types and the fields they are made of.')
|
||||
expect(button('New content type')?.hasAttribute('disabled')).toBe(false)
|
||||
expect([...document.body.querySelectorAll('[role="columnheader"]')].map(text)).toEqual([
|
||||
'Name',
|
||||
'Key',
|
||||
'Fields',
|
||||
'Last updated',
|
||||
])
|
||||
})
|
||||
|
||||
it('lists custom rows then the five built-in rows with the lock, badge and em dash', async () => {
|
||||
await mountAt('/schema')
|
||||
|
||||
expect(rowTexts()).toEqual([
|
||||
'Events events 7 15 Oct 2026',
|
||||
'Recipes recipes 4 2 Oct 2026',
|
||||
'Press mentions press-mentions 5 18 Sep 2026',
|
||||
'Articles Built-in articles 5 —',
|
||||
'Authors Built-in authors 5 —',
|
||||
'Sites Built-in sites 8 —',
|
||||
'Media Built-in media 6 —',
|
||||
'Users Built-in users 6 —',
|
||||
])
|
||||
const rows = [...document.body.querySelectorAll('[data-testid="schema-row"]')]
|
||||
const [rowIcon, badgeIcon] = rows[3]!.querySelectorAll('svg')
|
||||
expect(rowIcon!.innerHTML).toBeTruthy()
|
||||
expect(rowIcon!.innerHTML).toBe(badgeIcon!.innerHTML)
|
||||
expect(rows[0]!.querySelector('svg')!.innerHTML).not.toBe(rowIcon!.innerHTML)
|
||||
})
|
||||
|
||||
it('shows the built-in note and the summary', async () => {
|
||||
await mountAt('/schema')
|
||||
|
||||
expect(text(main())).toContain(
|
||||
'Built-in types are managed by RenewCMS. You can read their fields, but not change them.',
|
||||
)
|
||||
expect(text(main())).toContain('3 content types · 5 built-in')
|
||||
})
|
||||
|
||||
it('does not make rows links', async () => {
|
||||
await mountAt('/schema')
|
||||
|
||||
expect(document.body.querySelectorAll('[data-testid="schema-row"] a')).toHaveLength(0)
|
||||
await document.body.querySelector<HTMLElement>('[data-testid="schema-row"]')!.click()
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/schema')
|
||||
})
|
||||
|
||||
it('turns into cards below 640 px with the same content', async () => {
|
||||
await mountAt('/schema', { width: 390 })
|
||||
|
||||
const cards = [...document.body.querySelectorAll('[data-testid="schema-card"]')].map(text)
|
||||
expect(cards).toHaveLength(8)
|
||||
expect(cards[0]).toBe('Events events · 7 fields · Updated 15 Oct 2026')
|
||||
expect(cards[3]).toBe('Articles Built-in articles · 5 fields')
|
||||
expect(document.body.querySelector('[data-testid="schema-row"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schema list: loading', () => {
|
||||
it('shows no skeleton for 150 ms, then skeleton rows, with New content type disabled', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = pending()
|
||||
await mountAt('/schema', { content: () => gate.promise })
|
||||
|
||||
expect(document.body.querySelector('[data-testid="schema-skeleton-row"]')).toBeNull()
|
||||
expect(button('New content type')?.hasAttribute('disabled')).toBe(true)
|
||||
|
||||
vi.advanceTimersByTime(SKELETON_DELAY_MS)
|
||||
await flushPromises()
|
||||
|
||||
expect(
|
||||
document.body.querySelectorAll('[data-testid="schema-skeleton-row"]').length,
|
||||
).toBeGreaterThan(0)
|
||||
expect(button('New content type')?.hasAttribute('disabled')).toBe(true)
|
||||
expect(text(main())).not.toContain(ACCESS_TITLE)
|
||||
expect(text(main())).not.toContain('No content types yet')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schema list: empty', () => {
|
||||
it('shows the empty state, an enabled action and the built-in types below', async () => {
|
||||
await mountAt('/schema', { content: () => [] })
|
||||
|
||||
expect(text(main())).toContain('No content types yet')
|
||||
expect(text(main())).toContain(
|
||||
'Create a content type to manage your own kind of content, like events or recipes. The built-in types stay as they are.',
|
||||
)
|
||||
expect(button('New content type')?.hasAttribute('disabled')).toBe(false)
|
||||
expect(rowTexts()).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schema list: error', () => {
|
||||
it.each([
|
||||
['answers 500', failWith(500)],
|
||||
['fails (network)', networkFailure],
|
||||
])('shows the alert and no table when GET /v1/content-type %s', async (_, failure) => {
|
||||
await mountAt('/schema', { content: failure })
|
||||
|
||||
expect(text(main())).toContain("Couldn't load content types")
|
||||
expect(text(main())).toContain(
|
||||
'GET /v1/content-type failed. Check your connection and try again.',
|
||||
)
|
||||
expect(text(button('Try again'))).toBe('Try again')
|
||||
expect(document.body.querySelector('[role="table"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the Error state when only the built-in types fail', async () => {
|
||||
await mountAt('/schema', { builtin: failWith(500) })
|
||||
|
||||
expect(text(main())).toContain("Couldn't load content types")
|
||||
expect(document.body.querySelector('[role="table"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('retries: skeleton after 150 ms, a second request, then the populated state', async () => {
|
||||
let attempt = 0
|
||||
await mountAt('/schema', {
|
||||
content: () => {
|
||||
attempt++
|
||||
if (attempt === 1) {
|
||||
throw new (class extends Error {})('x')
|
||||
}
|
||||
return customTypes
|
||||
},
|
||||
})
|
||||
vi.useFakeTimers()
|
||||
const gate = pending()
|
||||
vi.mocked(useApi)().request = vi.fn(async (url: string) => {
|
||||
calls.push(url)
|
||||
return url === '/v1/builtin-type' ? builtinTypes : gate.promise
|
||||
}) as never
|
||||
|
||||
button('Try again')!.click()
|
||||
await flushPromises()
|
||||
vi.advanceTimersByTime(SKELETON_DELAY_MS)
|
||||
await flushPromises()
|
||||
expect(
|
||||
document.body.querySelectorAll('[data-testid="schema-skeleton-row"]').length,
|
||||
).toBeGreaterThan(0)
|
||||
|
||||
gate.resolve(customTypes)
|
||||
await flushPromises()
|
||||
|
||||
expect(text(main())).not.toContain("Couldn't load content types")
|
||||
expect(rowTexts()).toHaveLength(8)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schema list: forbidden', () => {
|
||||
it('explains instead of redirecting for a role that is neither owner nor admin', async () => {
|
||||
await mountAt('/schema', { role: 'unknown' })
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/schema')
|
||||
expect(text(main())).toContain(ACCESS_TITLE)
|
||||
expect(text(main())).toContain(
|
||||
'Only owners and admins can change content types. You can still add and edit entries from the Content menu.',
|
||||
)
|
||||
expect(calls).not.toContain('/v1/builtin-type')
|
||||
expect(document.body.querySelector('a[href="/schema"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('Go to Home navigates to /home', async () => {
|
||||
await mountAt('/schema', { role: 'unknown' })
|
||||
|
||||
button('Go to Home')!.click()
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
|
||||
})
|
||||
|
||||
it('shows the same page when the built-in request answers 403', async () => {
|
||||
await mountAt('/schema', { builtin: failWith(403) })
|
||||
|
||||
expect(text(main())).toContain(ACCESS_TITLE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schema list: accessibility', () => {
|
||||
it('keeps every table child a row, every row child a cell, the divider included', async () => {
|
||||
await mountAt('/schema')
|
||||
|
||||
const table = document.body.querySelector('[role="table"]')!
|
||||
for (const row of table.children) {
|
||||
expect(row.getAttribute('role')).toBe('row')
|
||||
for (const cell of row.children) {
|
||||
expect(['cell', 'columnheader', 'presentation']).toContain(
|
||||
cell.getAttribute('role'),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the same page when the content types answer 403', async () => {
|
||||
await mountAt('/schema', { content: failWith(403) })
|
||||
|
||||
expect(text(main())).toContain(ACCESS_TITLE)
|
||||
})
|
||||
|
||||
it('loads each list once', async () => {
|
||||
await mountAt('/schema')
|
||||
|
||||
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
|
||||
expect(calls.filter((url) => url === '/v1/builtin-type')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schema list: New content type', () => {
|
||||
it('navigates to /schema/new', async () => {
|
||||
await mountAt('/schema')
|
||||
|
||||
button('New content type')!.click()
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new'))
|
||||
})
|
||||
|
||||
it('is a pinned button on mobile', async () => {
|
||||
await mountAt('/schema', { width: MOBILE_WIDTH })
|
||||
|
||||
const pinned = [...document.body.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.className.includes('fixed'),
|
||||
)!
|
||||
expect(text(pinned)).toBe('New content type')
|
||||
expect(pinned.className).toContain('fixed')
|
||||
expect(pinned.className).toContain('bottom-4')
|
||||
pinned.click()
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schema route', () => {
|
||||
it('redirects an unverified user like any verified route', async () => {
|
||||
const { resolveAuthRedirect } = await import('@/router')
|
||||
const { useSessionStore } = await import('@/stores/session')
|
||||
const { createPinia, setActivePinia } = await import('pinia')
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.status = 'ready'
|
||||
const to = router.resolve('/schema')
|
||||
|
||||
expect(to.meta).toMatchObject({ auth: 'verified', title: 'Schema' })
|
||||
expect(resolveAuthRedirect(to, session)).toMatchObject({ path: '/login' })
|
||||
session.session = { verified: false, displayName: '' }
|
||||
expect(resolveAuthRedirect(to, session)).toEqual({ path: '/register/pending' })
|
||||
})
|
||||
})
|
||||
@@ -197,7 +197,7 @@ describe('Search and sharing group', () => {
|
||||
await expand(wrapper, 'GET /v1/article/{id}/seo')
|
||||
await expand(wrapper, 'GET /v1/site/{key}/sitemap')
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
expect(fetchSpy.mock.calls.filter(([url]) => url !== '/v1/content-type')).toEqual([])
|
||||
})
|
||||
|
||||
it('reads the group, the cards and the note in French', async () => {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import UApp from '@nuxt/ui/components/App.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import router from '@/router'
|
||||
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
|
||||
export type Responder = () => unknown
|
||||
|
||||
export const customTypes = [
|
||||
{
|
||||
id: 1,
|
||||
key: 'events',
|
||||
name: 'Events',
|
||||
icon: 'files',
|
||||
fields: new Array(7),
|
||||
updated_at: '2026-10-15T09:30:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
key: 'recipes',
|
||||
name: 'Recipes',
|
||||
icon: null,
|
||||
fields: new Array(4),
|
||||
updated_at: '2026-10-02T09:30:00Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
key: 'press-mentions',
|
||||
name: 'Press mentions',
|
||||
icon: null,
|
||||
fields: new Array(5),
|
||||
updated_at: '2026-09-18T09:30:00Z',
|
||||
},
|
||||
].map((type) => ({ ...type, created_at: '2026-09-01T09:30:00Z' }))
|
||||
|
||||
export const builtinTypes = [
|
||||
['articles', 'Articles', 5],
|
||||
['authors', 'Authors', 5],
|
||||
['sites', 'Sites', 8],
|
||||
['media', 'Media', 6],
|
||||
['users', 'Users', 6],
|
||||
].map(([key, name, count]) => ({ key, name, fields: new Array(count) }))
|
||||
|
||||
export function manyTypes(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
...customTypes[0]!,
|
||||
id: index + 1,
|
||||
key: `type-${index + 1}`,
|
||||
name: `Type ${index + 1}`,
|
||||
icon: null,
|
||||
}))
|
||||
}
|
||||
|
||||
export function failWith(status: number): Responder {
|
||||
return () => {
|
||||
throw new ApiError(status)
|
||||
}
|
||||
}
|
||||
|
||||
export const networkFailure: Responder = () => {
|
||||
throw new NetworkError()
|
||||
}
|
||||
|
||||
export interface SchemaMount {
|
||||
role?: string
|
||||
width?: number
|
||||
content?: Responder
|
||||
builtin?: Responder
|
||||
verified?: boolean
|
||||
actions?: string[]
|
||||
}
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
export const calls: string[] = []
|
||||
|
||||
export async function mountAt(path: string, options: SchemaMount = {}, shellOnly = false) {
|
||||
calls.length = 0
|
||||
localStorage.clear()
|
||||
setViewportWidth(options.width ?? DESKTOP_WIDTH)
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
if (options.actions) {
|
||||
const actions = options.actions
|
||||
useContentTypesStore().$onAction(({ name }) => {
|
||||
actions.push(name)
|
||||
})
|
||||
}
|
||||
const session = useSessionStore()
|
||||
session.session = {
|
||||
verified: options.verified ?? true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
email: 'marta@renew.example',
|
||||
displayName: 'Marta Alves',
|
||||
role: (options.role ?? 'owner') as never,
|
||||
}
|
||||
session.status = 'ready'
|
||||
const request = vi.fn(async (url: string) => {
|
||||
calls.push(url)
|
||||
if (url === '/v1/content-type') {
|
||||
return (options.content ?? (() => customTypes))()
|
||||
}
|
||||
if (url === '/v1/builtin-type') {
|
||||
return (options.builtin ?? (() => builtinTypes))()
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta' }),
|
||||
request,
|
||||
} as unknown as ReturnType<typeof useApi>)
|
||||
await router.push(path)
|
||||
wrapper = mount(
|
||||
defineComponent({
|
||||
render: () =>
|
||||
h(UApp, null, {
|
||||
default: () => (shellOnly ? h(DashboardShell) : h(RouterView)),
|
||||
}),
|
||||
}),
|
||||
{
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
},
|
||||
)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
export async function unmountAll() {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
vi.useRealTimers()
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
}
|
||||
|
||||
export function pending() {
|
||||
let resolve!: (value: unknown) => void
|
||||
const promise = new Promise((done) => (resolve = done))
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
export function text(element: Element | null | undefined) {
|
||||
if (!element) {
|
||||
return ''
|
||||
}
|
||||
const parts: string[] = []
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
parts.push(node.textContent ?? '')
|
||||
}
|
||||
return parts.join(' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
export function button(label: string) {
|
||||
return [...document.body.querySelectorAll('button')].find(
|
||||
(candidate) => text(candidate) === label,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user