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,101 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
import { contentLink, selectedContentKey, visibleContentTypes } from '@/lib/contentNav'
|
||||
import { contentTypeIcon } from '@/lib/contentTypeIcon'
|
||||
|
||||
const props = defineProps<{
|
||||
collapsed: boolean
|
||||
noCurrentNav: boolean
|
||||
navUi: Record<string, string>
|
||||
}>()
|
||||
|
||||
const SIDEBAR_QUERY = '(min-width: 64rem)'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useContentTypesStore()
|
||||
const isSidebar = useMediaQuery(SIDEBAR_QUERY)
|
||||
const expanded = ref(false)
|
||||
|
||||
const selectedKey = computed(() => selectedContentKey(route.path))
|
||||
const total = computed(() => store.items.length)
|
||||
const showGroup = computed(() => store.status === 'ready' && total.value > 0)
|
||||
const rows = computed(() =>
|
||||
isSidebar.value || expanded.value
|
||||
? visibleContentTypes(store.items, selectedKey.value, expanded.value)
|
||||
: [],
|
||||
)
|
||||
const hiddenCount = computed(() => total.value - rows.value.length)
|
||||
|
||||
const items = computed(() => {
|
||||
const list: Record<string, unknown>[] = []
|
||||
if (!isSidebar.value && !expanded.value) {
|
||||
list.push({
|
||||
label: t('All content types'),
|
||||
icon: 'i-lucide-layers',
|
||||
slot: 'collapsed',
|
||||
onSelect: (event: Event) => {
|
||||
event.preventDefault()
|
||||
expanded.value = true
|
||||
},
|
||||
})
|
||||
}
|
||||
for (const type of rows.value) {
|
||||
list.push({
|
||||
label: type.name,
|
||||
to: contentLink(type.key),
|
||||
icon: contentTypeIcon(type.icon),
|
||||
slot: 'type',
|
||||
active: !props.noCurrentNav && type.key === selectedKey.value,
|
||||
})
|
||||
}
|
||||
if (hiddenCount.value > 0 && rows.value.length > 0) {
|
||||
list.push({
|
||||
label: t('Show all {0}', [total.value]),
|
||||
icon: 'i-lucide-ellipsis',
|
||||
slot: 'more',
|
||||
onSelect: (event: Event) => {
|
||||
event.preventDefault()
|
||||
expanded.value = true
|
||||
},
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const groupUi = computed(() => ({
|
||||
...props.navUi,
|
||||
link: `${props.navUi.link} [&:not([aria-current=page])]:text-toned`,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="showGroup" class="mt-2 flex flex-col gap-0.5" data-testid="content-nav">
|
||||
<span
|
||||
v-if="!collapsed"
|
||||
class="px-2.5 pt-1.5 pb-0.5 text-xs font-semibold tracking-wide text-muted uppercase"
|
||||
>{{ t('Content') }}</span
|
||||
>
|
||||
<UNavigationMenu
|
||||
orientation="vertical"
|
||||
:items="items"
|
||||
:collapsed="collapsed"
|
||||
:aria-label="t('Content')"
|
||||
:ui="groupUi"
|
||||
>
|
||||
<template #type-label="{ item }">
|
||||
<UTooltip :text="item.label" :content="{ side: 'right' }">
|
||||
<span class="block min-w-0 truncate">{{ item.label }}</span>
|
||||
</UTooltip>
|
||||
</template>
|
||||
<template #collapsed-trailing>
|
||||
<span class="ms-auto text-xs text-muted">{{ total }}</span>
|
||||
<UIcon name="i-lucide-chevron-right" class="size-3.5 shrink-0" />
|
||||
</template>
|
||||
</UNavigationMenu>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,6 +3,7 @@ import { t, translate } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { useUpdateStore } from '@/stores/update'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
@@ -10,6 +11,7 @@ import { useSidebarCollapsed } from '@/composables/useSidebarCollapsed'
|
||||
import AppBoot from '@/components/AppBoot.vue'
|
||||
import AccountMenuSheet from '@/components/AccountMenuSheet.vue'
|
||||
import AppLogo from '@/components/AppLogo.vue'
|
||||
import ContentNavGroup from '@/components/ContentNavGroup.vue'
|
||||
import ErrorView from '@/components/ErrorView.vue'
|
||||
import OfflineBanner from '@/components/OfflineBanner.vue'
|
||||
import UpdatePill from '@/components/UpdatePill.vue'
|
||||
@@ -63,6 +65,21 @@ const router = useRouter()
|
||||
const session = useSessionStore()
|
||||
const update = useUpdateStore()
|
||||
const api = useApi()
|
||||
const contentTypes = useContentTypesStore()
|
||||
|
||||
const wantsBuiltin = computed(() => session.canManageContent && /^\/schema(\/|$)/.test(route.path))
|
||||
|
||||
watch(
|
||||
[() => session.status === 'ready' && session.session?.verified === true, wantsBuiltin],
|
||||
([verified, builtin], [wasVerified]) => {
|
||||
if (verified) {
|
||||
contentTypes.load({ builtin })
|
||||
} else if (wasVerified) {
|
||||
contentTypes.reset()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const publishingChildren = computed(() => [
|
||||
{ label: t('Publishing'), type: 'label' as const },
|
||||
@@ -91,6 +108,20 @@ const settingsChildren = computed(() => [
|
||||
...(session.isOwner ? publishingChildren.value : []),
|
||||
])
|
||||
|
||||
const schemaItem = computed(() =>
|
||||
session.canManageContent
|
||||
? [
|
||||
{
|
||||
get label() {
|
||||
return t('Schema')
|
||||
},
|
||||
to: '/schema',
|
||||
icon: 'i-lucide-shapes',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
|
||||
const navItems = computed(() =>
|
||||
[
|
||||
{
|
||||
@@ -128,6 +159,7 @@ const navItems = computed(() =>
|
||||
to: '/users',
|
||||
icon: 'i-lucide-users',
|
||||
},
|
||||
...schemaItem.value,
|
||||
{
|
||||
get label() {
|
||||
return t('Activity')
|
||||
@@ -281,6 +313,12 @@ async function onLogout() {
|
||||
</template>
|
||||
</UNavigationMenu>
|
||||
|
||||
<ContentNavGroup
|
||||
:collapsed="sidebarCollapsed"
|
||||
:no-current-nav="noCurrentNav"
|
||||
:nav-ui="navUi"
|
||||
/>
|
||||
|
||||
<template #footer>
|
||||
<UserMenu
|
||||
:username="session.session?.username ?? ''"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { t } from '@/i18n'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
kind: 'default' | 'noindex' | 'linked' | 'guest'
|
||||
kind: 'default' | 'noindex' | 'linked' | 'guest' | 'builtin'
|
||||
}>()
|
||||
|
||||
const badges = {
|
||||
@@ -36,6 +36,13 @@ const badges = {
|
||||
icon: 'i-lucide-user',
|
||||
class: 'border border-accented bg-transparent text-toned',
|
||||
},
|
||||
builtin: {
|
||||
get label() {
|
||||
return t('Built-in')
|
||||
},
|
||||
icon: 'i-lucide-lock',
|
||||
class: 'border border-accented bg-transparent text-toned',
|
||||
},
|
||||
} as const
|
||||
|
||||
const badge = computed(() => badges[props.kind])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
defineProps<{ title: string }>()
|
||||
defineProps<{ title: string; description?: string }>()
|
||||
|
||||
defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
@@ -15,7 +15,7 @@ defineEmits<{ retry: [] }>()
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
:title="title"
|
||||
:description="t('Check your connection and try again.')"
|
||||
:description="description ?? t('Check your connection and try again.')"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const emit = defineEmits<{ create: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UEmpty
|
||||
variant="naked"
|
||||
icon="i-lucide-shapes"
|
||||
:title="t('No content types yet')"
|
||||
:description="
|
||||
t(
|
||||
'Create a content type to manage your own kind of content, like events or recipes. The built-in types stay as they are.',
|
||||
)
|
||||
"
|
||||
:ui="{
|
||||
root: 'gap-3 border border-default bg-default px-6 py-10 sm:px-6 sm:py-10 lg:px-6 lg:py-10',
|
||||
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
|
||||
title: 'text-md/7 font-semibold',
|
||||
description: 'max-w-90 text-muted text-pretty',
|
||||
}"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton icon="i-lucide-plus" class="h-8 px-4 max-md:h-11" @click="emit('create')">
|
||||
{{ t('New content type') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UEmpty>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const emit = defineEmits<{ back: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 items-center justify-center px-4 py-6">
|
||||
<UEmpty
|
||||
variant="naked"
|
||||
icon="i-lucide-lock"
|
||||
:title="t('You don\'t have access to Schema')"
|
||||
:description="
|
||||
t(
|
||||
'Only owners and admins can change content types. You can still add and edit entries from the Content menu.',
|
||||
)
|
||||
"
|
||||
:ui="{
|
||||
root: 'w-full max-w-105 gap-3 p-0 sm:p-0 lg:p-0',
|
||||
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
|
||||
title: 'text-title font-semibold text-wrap',
|
||||
description: 'mt-1 w-full max-w-none px-0 text-base/5 text-wrap text-muted',
|
||||
}"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton class="h-8 px-4 max-md:h-11" @click="emit('back')">
|
||||
{{ t('Go to Home') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UEmpty>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import DirectoryBadge from '@/components/DirectoryBadge.vue'
|
||||
import { fieldCountLabel, type SchemaRow } from '@/lib/schemaRows'
|
||||
|
||||
const TABLE_QUERY = '(min-width: 40rem)'
|
||||
const COLUMNS = '24px minmax(0,1.4fr) minmax(0,1fr) 90px 150px 24px'
|
||||
const SKELETON_ROWS = 6
|
||||
|
||||
defineProps<{
|
||||
custom: SchemaRow[]
|
||||
builtin: SchemaRow[]
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const isTable = useMediaQuery(TABLE_QUERY)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="loading"
|
||||
role="status"
|
||||
:aria-label="t('Loading')"
|
||||
class="border border-default bg-default"
|
||||
>
|
||||
<div
|
||||
v-for="n in SKELETON_ROWS"
|
||||
:key="n"
|
||||
data-testid="schema-skeleton-row"
|
||||
class="flex h-12 items-center gap-4 px-4 max-sm:h-17"
|
||||
:class="n > 1 ? 'border-t border-default' : ''"
|
||||
>
|
||||
<USkeleton class="size-4.5 shrink-0 rounded-none bg-accented" />
|
||||
<USkeleton class="h-3.5 rounded-none bg-accented" :class="n % 2 ? 'w-1/5' : 'w-7/25'" />
|
||||
<USkeleton class="h-3 w-1/6 rounded-none bg-accented" />
|
||||
<USkeleton class="h-3 w-10 rounded-none bg-accented max-sm:hidden" />
|
||||
<USkeleton class="h-3 w-22 rounded-none bg-accented max-sm:hidden" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
:role="isTable ? 'table' : 'list'"
|
||||
:aria-label="t('Content types')"
|
||||
class="border border-default bg-default"
|
||||
>
|
||||
<template v-if="isTable">
|
||||
<div
|
||||
role="row"
|
||||
class="grid h-9 items-center gap-3 bg-elevated px-3 font-medium text-muted"
|
||||
:style="{ gridTemplateColumns: COLUMNS }"
|
||||
>
|
||||
<span role="presentation" />
|
||||
<span role="columnheader">{{ t('Name') }}</span>
|
||||
<span role="columnheader">{{ t('Key') }}</span>
|
||||
<span role="columnheader">{{ t('Fields') }}</span>
|
||||
<span role="columnheader">{{ t('Last updated') }}</span>
|
||||
<span role="presentation" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-for="(group, groupIndex) in [custom, builtin]" :key="groupIndex">
|
||||
<div
|
||||
v-if="groupIndex === 1"
|
||||
:role="isTable ? 'row' : 'listitem'"
|
||||
class="flex items-center gap-2 border-t border-default bg-muted px-3 py-2.5 text-xs/4.5 text-toned"
|
||||
>
|
||||
<span :role="isTable ? 'cell' : undefined" class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-lock" class="size-3.5 shrink-0" />
|
||||
<span>{{
|
||||
t(
|
||||
'Built-in types are managed by RenewCMS. You can read their fields, but not change them.',
|
||||
)
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
<template v-for="row in group" :key="row.id">
|
||||
<div
|
||||
v-if="isTable"
|
||||
role="row"
|
||||
data-testid="schema-row"
|
||||
class="grid h-12 items-center gap-3 border-t border-default px-3"
|
||||
:style="{ gridTemplateColumns: COLUMNS }"
|
||||
>
|
||||
<span role="cell" class="flex">
|
||||
<UIcon
|
||||
:name="row.builtin ? 'i-lucide-lock' : 'i-lucide-file-stack'"
|
||||
class="size-4 text-muted"
|
||||
/>
|
||||
</span>
|
||||
<span role="cell" class="flex min-w-0 items-center gap-2">
|
||||
<span class="truncate font-medium text-highlighted" :title="row.name">{{
|
||||
row.name
|
||||
}}</span>
|
||||
<DirectoryBadge v-if="row.builtin" kind="builtin" />
|
||||
</span>
|
||||
<span role="cell" class="truncate font-mono text-xs text-muted">{{
|
||||
row.key
|
||||
}}</span>
|
||||
<span role="cell">{{ row.fieldCount }}</span>
|
||||
<span role="cell" class="text-muted">{{ row.updated }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
role="listitem"
|
||||
data-testid="schema-card"
|
||||
class="flex min-h-17 items-center gap-3 border-t border-default px-4 py-3"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-md/5.25 font-medium text-highlighted">{{
|
||||
row.name
|
||||
}}</span>
|
||||
<DirectoryBadge v-if="row.builtin" kind="builtin" />
|
||||
</div>
|
||||
<span class="text-xs/5 text-muted"
|
||||
><span class="font-mono">{{ row.key }}</span> ·
|
||||
{{ fieldCountLabel(row.fieldCount)
|
||||
}}<template v-if="!row.builtin">
|
||||
· {{ t('Updated {0}', [row.updated]) }}</template
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,312 @@
|
||||
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 { useApi } from '@/composables/useApi'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { contentLink, selectedContentKey } from '@/lib/contentNav'
|
||||
import { contentTypeIcon } from '@/lib/contentTypeIcon'
|
||||
import { MOBILE_WIDTH } from './viewport'
|
||||
import {
|
||||
calls,
|
||||
customTypes,
|
||||
failWith,
|
||||
manyTypes,
|
||||
mountAt,
|
||||
networkFailure,
|
||||
text,
|
||||
unmountAll,
|
||||
} from '@/views/__tests__/schemaTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
const LONG_NAME = 'Frequently asked questions and answers'
|
||||
|
||||
function mainLinks() {
|
||||
return [...document.body.querySelectorAll('nav[aria-label="Main"] a')]
|
||||
}
|
||||
|
||||
function contentLinks() {
|
||||
return [...document.body.querySelectorAll('nav[aria-label="Content"] a')]
|
||||
}
|
||||
|
||||
function showAll() {
|
||||
return [
|
||||
...document.body.querySelectorAll(
|
||||
'nav[aria-label="Content"] button, nav[aria-label="Content"] a',
|
||||
),
|
||||
].find((candidate) => /^Show all/.test(text(candidate))) as HTMLElement | undefined
|
||||
}
|
||||
|
||||
async function openDrawer() {
|
||||
document.body.querySelector<HTMLElement>('[data-slot="toggle"]')!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('The Schema entry', () => {
|
||||
it.each([
|
||||
['owner', true],
|
||||
['admin', true],
|
||||
['unknown', false],
|
||||
])('role %s: shown %s, after Users, linking to /schema', async (role, shown) => {
|
||||
await mountAt('/home', { role }, true)
|
||||
|
||||
const hrefs = mainLinks().map((link) => link.getAttribute('href'))
|
||||
expect(hrefs.includes('/schema')).toBe(shown)
|
||||
if (shown) {
|
||||
expect(hrefs.indexOf('/schema')).toBe(hrefs.indexOf('/users') + 1)
|
||||
}
|
||||
expect(hrefs.filter((href) => href !== '/schema')).toEqual([
|
||||
'/home',
|
||||
'/article',
|
||||
'/media',
|
||||
'/authors',
|
||||
'/users',
|
||||
'/activity',
|
||||
'/integration',
|
||||
'/settings',
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the shapes icon', async () => {
|
||||
await mountAt('/home', {}, true)
|
||||
|
||||
const icon = document.body.querySelector('nav[aria-label="Main"] a[href="/schema"] svg')
|
||||
expect(icon?.innerHTML).toBeTruthy()
|
||||
expect(icon?.innerHTML).not.toBe(
|
||||
document.body.querySelector('nav[aria-label="Main"] a[href="/users"] svg')?.innerHTML,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The Content group', () => {
|
||||
it('lists one link per type in API order and marks the current route', async () => {
|
||||
await mountAt('/content/recipes', { role: 'unknown' }, true)
|
||||
|
||||
expect(contentLinks().map((link) => link.getAttribute('href'))).toEqual([
|
||||
'/content/events',
|
||||
'/content/recipes',
|
||||
'/content/press-mentions',
|
||||
])
|
||||
expect(contentLinks().map(text)).toEqual(['Events', 'Recipes', 'Press mentions'])
|
||||
expect(contentLinks().map((link) => link.getAttribute('aria-current'))).toEqual([
|
||||
null,
|
||||
'page',
|
||||
null,
|
||||
])
|
||||
expect(text(document.body.querySelector('[data-testid="content-nav"]'))).toMatch(/^Content/)
|
||||
})
|
||||
|
||||
it('is hidden without any type', async () => {
|
||||
await mountAt('/home', { content: () => [] }, true)
|
||||
|
||||
expect(document.body.querySelector('[data-testid="content-nav"]')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['answers 500', failWith(500)],
|
||||
['fails (network)', networkFailure],
|
||||
])(
|
||||
'is hidden, and the rest of the sidebar renders, when GET /v1/content-type %s',
|
||||
async (_, failure) => {
|
||||
await mountAt('/home', { content: failure }, true)
|
||||
|
||||
expect(document.body.querySelector('[data-testid="content-nav"]')).toBeNull()
|
||||
expect(mainLinks().length).toBeGreaterThanOrEqual(8)
|
||||
expect(document.body.textContent).not.toContain('failed')
|
||||
},
|
||||
)
|
||||
|
||||
it('is loaded once for the whole session, shared with the Schema list', async () => {
|
||||
await mountAt('/schema')
|
||||
const { default: router } = await import('@/router')
|
||||
|
||||
await router.push('/home')
|
||||
await flushPromises()
|
||||
await router.push('/schema')
|
||||
await flushPromises()
|
||||
|
||||
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
|
||||
expect(vi.mocked(useApi)).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('truncates a long name on one line and keeps the full name as accessible name', async () => {
|
||||
await mountAt('/home', { content: () => [{ ...customTypes[0]!, name: LONG_NAME }] }, true)
|
||||
|
||||
const link = contentLinks()[0]!
|
||||
expect(link.textContent).toContain(LONG_NAME)
|
||||
expect(link.querySelector('.truncate')?.textContent).toBe(LONG_NAME)
|
||||
})
|
||||
|
||||
it('shows the type icon when bundled and the default icon otherwise', async () => {
|
||||
await mountAt(
|
||||
'/home',
|
||||
{
|
||||
content: () => [
|
||||
{ ...customTypes[0]!, key: 'bundled', name: 'Bundled', icon: 'feather' },
|
||||
{ ...customTypes[0]!, key: 'none', name: 'None', icon: null },
|
||||
{ ...customTypes[0]!, key: 'other', name: 'Other', icon: 'not-bundled' },
|
||||
],
|
||||
},
|
||||
true,
|
||||
)
|
||||
|
||||
const icons = contentLinks().map((link) => link.querySelector('svg')?.innerHTML)
|
||||
const feather = document.body.querySelector('nav[aria-label="Main"] a[href="/authors"] svg')
|
||||
expect(icons[0]).toBe(feather?.innerHTML)
|
||||
expect(icons[1]).toBeTruthy()
|
||||
expect(icons[1]).not.toBe(icons[0])
|
||||
expect(icons[2]).toBe(icons[1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Icon choice', () => {
|
||||
it.each([
|
||||
['feather', 'i-lucide-feather'],
|
||||
[null, 'i-lucide-files'],
|
||||
['not-bundled', 'i-lucide-files'],
|
||||
['constructor', 'i-lucide-files'],
|
||||
['toString', 'i-lucide-files'],
|
||||
['__proto__', 'i-lucide-files'],
|
||||
['hasOwnProperty', 'i-lucide-files'],
|
||||
])('maps %s to %s', (icon, expected) => {
|
||||
expect(contentTypeIcon(icon)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Content type loading trigger', () => {
|
||||
it('does not reset or load on an unverified mount', async () => {
|
||||
const actions: string[] = []
|
||||
await mountAt('/home', { verified: false, actions }, true)
|
||||
|
||||
expect(actions).toEqual([])
|
||||
expect(calls).not.toContain('/v1/content-type')
|
||||
})
|
||||
|
||||
it('resets once when the session is cleared', async () => {
|
||||
const actions: string[] = []
|
||||
await mountAt('/home', { actions }, true)
|
||||
expect(actions).toEqual(['load'])
|
||||
|
||||
useSessionStore().clear()
|
||||
await flushPromises()
|
||||
|
||||
expect(actions).toEqual(['load', 'reset'])
|
||||
expect(document.body.querySelector('[data-testid="content-nav"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('loads built-in types only on the schema page', async () => {
|
||||
await mountAt('/home', {}, true)
|
||||
expect(calls).not.toContain('/v1/builtin-type')
|
||||
|
||||
await unmountAll()
|
||||
await mountAt('/schema')
|
||||
expect(calls.filter((url) => url === '/v1/builtin-type')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('hides the group silently when the content types answer 403', async () => {
|
||||
await mountAt('/home', { content: failWith(403) }, true)
|
||||
|
||||
expect(document.body.querySelector('[data-testid="content-nav"]')).toBeNull()
|
||||
expect(mainLinks().length).toBeGreaterThanOrEqual(8)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Content route segment', () => {
|
||||
it.each([
|
||||
['/content/events', 'events'],
|
||||
['/content/press%20mentions', 'press mentions'],
|
||||
['/content/a%2Fb/extra', 'a/b'],
|
||||
['/content/%E0%A4%A', '%E0%A4%A'],
|
||||
['/content/', ''],
|
||||
['/home', undefined],
|
||||
])('parses %s', (path, expected) => {
|
||||
expect(selectedContentKey(path)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['events', '/content/events'],
|
||||
['a b/c', '/content/a%20b%2Fc'],
|
||||
])('links %s to %s', (key, expected) => {
|
||||
expect(contentLink(key)).toBe(expected)
|
||||
})
|
||||
|
||||
it('does not throw on a malformed percent sequence in the route', async () => {
|
||||
await mountAt('/content/%E0%A4%A', {}, true)
|
||||
|
||||
expect(contentLinks().map(text)).toEqual(['Events', 'Recipes', 'Press mentions'])
|
||||
expect(contentLinks().map((link) => link.getAttribute('aria-current'))).toEqual([
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
])
|
||||
})
|
||||
|
||||
it('encodes keys in the generated links', async () => {
|
||||
await mountAt(
|
||||
'/home',
|
||||
{ content: () => [{ ...customTypes[0]!, key: 'a b', name: 'Spaced' }] },
|
||||
true,
|
||||
)
|
||||
|
||||
expect(contentLinks()[0]!.getAttribute('href')).toBe('/content/a%20b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('More than 5 types on desktop', () => {
|
||||
it('shows the first 5 and the selected one, then expands in place', async () => {
|
||||
await mountAt('/content/type-9', { content: () => manyTypes(14) }, true)
|
||||
|
||||
expect(contentLinks().map(text)).toEqual([
|
||||
'Type 1',
|
||||
'Type 2',
|
||||
'Type 3',
|
||||
'Type 4',
|
||||
'Type 5',
|
||||
'Type 9',
|
||||
])
|
||||
expect(text(showAll())).toBe('Show all 14')
|
||||
|
||||
showAll()!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(contentLinks()).toHaveLength(14)
|
||||
expect(showAll()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows no Show all action with 5 types', async () => {
|
||||
await mountAt('/home', { content: () => manyTypes(5) }, true)
|
||||
|
||||
expect(contentLinks()).toHaveLength(5)
|
||||
expect(showAll()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('On mobile', () => {
|
||||
it('shows one collapsed row with the count, then lists the types', async () => {
|
||||
await mountAt('/home', { width: MOBILE_WIDTH, content: () => manyTypes(14) }, true)
|
||||
await openDrawer()
|
||||
|
||||
const group = document.body.querySelector('[role="dialog"] [data-testid="content-nav"]')!
|
||||
expect(contentLinks()).toHaveLength(0)
|
||||
expect(text(group)).toContain('All content types')
|
||||
expect(text(group)).toContain('14')
|
||||
|
||||
const row = [...group.querySelectorAll('button, a')].find((candidate) =>
|
||||
text(candidate).startsWith('All content types'),
|
||||
) as HTMLElement
|
||||
row.click()
|
||||
await flushPromises()
|
||||
|
||||
const links = [...group.querySelectorAll('nav[aria-label="Content"] a')]
|
||||
expect(links).toHaveLength(14)
|
||||
expect(links[0]!.className).toContain('max-lg:h-11')
|
||||
})
|
||||
})
|
||||
@@ -761,7 +761,7 @@ describe('DashboardShell mobile drawer rows', () => {
|
||||
const drawer = await openDrawer('/settings')
|
||||
|
||||
const links = Array.from(drawer.querySelectorAll('nav[aria-label="Main"] a'))
|
||||
expect(links).toHaveLength(11)
|
||||
expect(links).toHaveLength(12)
|
||||
for (const link of links) {
|
||||
expect(classesOf(link)).toContain('max-lg:h-11')
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user