feat(SPA): schema list and the sidebar Content group

This commit is contained in:
Florian Sylvain committed 2026-10-19 19:34:45 +02:00
1 parent 73a75d5cb9
commit 3c67cd2314
34 files changed
+2181 -18

No files matched your search

@@ -0,0 +1,193 @@
Feature: Schema list and content navigation in the back-office
As an owner or admin
I want to see the custom and built-in content types on a Schema screen
And as any verified user I want a Content menu with one link per custom type
So that I can reach the content types I manage and write entries for
Background:
Given the SPA calls the API with relative URLs under /v1 and the "jwt" HttpOnly cookie
And GET /v1/content-type returns a JSON array of {id, key, name, icon, fields, created_at, updated_at}
And GET /v1/builtin-type returns a JSON array of {key, name, fields}, for owners and admins only
And API errors are plain text
And the session store exposes the role of the signed-in user, and only owner and admin exist today
And a session whose role is neither owner nor admin (absent or unknown) is a test double for the forbidden states
And the Schema screen is the route /schema with meta auth "verified", title "Schema"
And only owners and admins can use Schema; the SPA decides from the session role, the API still enforces it
And the typed API client exposes listContentTypes() and listBuiltinTypes()
And a content types store loads them once per session and exposes loading, error and items
And this slice does not add the Vitest runner, it already exists (task test:web)
Scenario Outline: The Schema entry is shown to owners and admins only
Given a verified user with role "<role>" is signed in
When the back-office shell renders
Then the sidebar <schema_entry> a "Schema" entry linking to /schema, with the "shapes" icon, placed after "Users"
And no existing nav item changed
Examples:
| role | schema_entry |
| owner | shows |
| admin | shows |
| unknown | does not show |
Scenario: The Content group lists one link per custom type
Given a verified user is signed in
And GET /v1/content-type returns the types "events" (Events), "recipes" (Recipes) and "press-mentions" (Press mentions)
When the back-office shell renders
Then the sidebar shows a "Content" group under the main items
And the group contains three links, in the API order, to /content/events, /content/recipes and /content/press-mentions
And each row shows the type name
And the row of the type matching the current route /content/:key is marked as the current page
Scenario Outline: Each type shows its icon, or the default icon
Given a custom type "recipes" with icon <icon>
When the sidebar renders
Then the row for "recipes" shows <shown>
Examples:
| icon | shown |
| "chef-hat" | the icon i-lucide-chef-hat (in bundle) |
| null | the default icon "files" |
| "not-bundled" | the default icon "files" |
Scenario: A long type name is truncated in one line and keeps its full name available
Given a custom type named "Frequently asked questions and answers"
When the sidebar renders on desktop
Then the label is cut with an ellipsis on one line and does not widen the sidebar
And the full name is available in a tooltip and as the link's accessible name
Scenario: With more than 5 types the desktop group shows 5 and the selected one
Given 14 custom types and the current route is /content/<selected>
When the sidebar renders on desktop
Then the group shows the first 5 types, plus the selected type if it is not among them
And a "Show all 14" action follows
When the user activates "Show all 14"
Then all 14 types are listed
Scenario: With 5 types or fewer there is no "Show all"
Given 5 custom types
When the sidebar renders
Then all 5 are listed and no "Show all" action appears
Scenario: On mobile the group is one collapsed row in the drawer
Given 14 custom types and a 390 px wide viewport
When the user opens the drawer
Then the Content group shows one collapsed row "All content types" with the count 14
When the user expands it
Then the 14 types are listed with rows 44 px high
Scenario: No custom type hides the group
Given GET /v1/content-type returns []
When the shell renders
Then there is no Content group and no empty placeholder in the sidebar
Scenario Outline: A failing content type request does not break the shell
Given GET /v1/content-type <failure>
When the shell renders
Then the Content group is not shown
And the rest of the sidebar and the page render normally
Examples:
| failure |
| answers 500 |
| fails (network) |
Scenario: The Content group is loaded once for the whole session
Given the user navigates between several back-office screens
Then GET /v1/content-type is called once and the list is shared by the sidebar and the Schema list
Scenario: Populated list
Given an owner or admin is signed in
And GET /v1/content-type returns three types
And GET /v1/builtin-type returns articles, authors, sites, media and users
When they open /schema
Then the page shows the title "Schema" and the text "Content types and the fields they are made of."
And a "New content type" action is enabled
And a table has the columns "Name", "Key", "Fields", "Last updated"
And each custom row shows name, key, the number of fields and the updated date as an absolute date, using the board's sample data
And after the custom rows come the five built-in rows, each with a lock icon and a "Built-in" badge
And a built-in row shows its field count from the length of fields and an em dash as Last updated
And the text "Built-in types are managed by RenewCMS. You can read their fields, but not change them." is shown
And the summary "3 content types · 5 built-in" is shown
And below 640 px the table is a list of cards with the same content
Scenario: Loading
Given GET /v1/content-type has not answered
When an owner opens /schema
Then no skeleton is shown during the first 150 ms
And after 150 ms skeleton rows are shown
And "New content type" is disabled
And the 403 page and the empty state are not shown
Scenario: Empty, no custom type yet
Given GET /v1/content-type returns []
When an owner opens /schema
Then the empty state "No content types yet" is shown with the text "Create a content type to manage your own kind of content, like events or recipes. The built-in types stay as they are."
And a "New content type" action is shown and enabled
And the five built-in types are still listed below
Scenario Outline: Error
Given GET /v1/content-type <failure>
When an owner opens /schema
Then an alert shows "Couldn't load content types"
And it shows "GET /v1/content-type failed. Check your connection and try again."
And a "Try again" action is shown
And no table is shown
Examples:
| failure |
| answers 500 |
| fails (network) |
Scenario: Retry recovers
Given the list is in the Error state
When the user activates "Try again"
Then the skeleton rows show after 150 ms and GET /v1/content-type is called again
And when it answers 200 the Populated state replaces the alert
Scenario: Forbidden for a user who is not owner or admin
Given a verified user whose role is neither owner nor admin is signed in
When they open /schema directly
Then the URL stays /schema and nothing redirects
And the page shows "You don't have access to Schema"
And it shows "Only owners and admins can change content types. You can still add and edit entries from the Content menu."
And a "Go to Home" action navigates to /home
And GET /v1/builtin-type is never called
Scenario: The API answers 403 for the list
Given the session says owner or admin but GET /v1/builtin-type answers 403
When they open /schema
Then the same "You don't have access to Schema" page is shown
Scenario: Built-in types fail to load while custom types load
Given GET /v1/content-type answers 200 and GET /v1/builtin-type answers 500
When an owner opens /schema
Then the Error state is shown
Scenario: An unverified or signed-out user keeps the existing redirects
Given the user is not verified or not signed in
When they open /schema
Then they are redirected to /register/pending or /login as for any other "verified" route
Scenario: New content type
Given the list is Populated or Empty
When an owner activates "New content type"
Then the router navigates to /schema/new (the builder is a later slice)
Scenario: Rows are not links in this slice
Given the list is Populated
Then custom and built-in rows do not navigate (the editor and the read-only view are later slices)
Scenario Outline: The typed client maps responses
When the client calls <call>
And the API answers <answer>
Then it <result>
Examples:
| call | answer | result |
| listContentTypes | 200 with icon null | returns the types, icon typed string or null |
| listContentTypes | 200 [] | returns an empty array |
| listContentTypes | 500 plain text | rejects with an error carrying status 500 |
| listContentTypes | network failure | rejects with a network error |
| listBuiltinTypes | 200 | returns {key, name, fields} items |
| listBuiltinTypes | 403 plain text | rejects with an error carrying status 403 |
| listBuiltinTypes | 500 plain text | rejects with an error carrying status 500 |
@@ -0,0 +1,48 @@
# QA: Schema list and content navigation (SPA slice 1)
Runner: Vitest + Vue Test Utils (already installed, `task test:web`). No Go test: there is no backend change.
Mock `/v1` through the existing api composable pattern used by the other view specs. Mount the shell (DashboardShell) with a test router and a resolved session whose role varies. Only owner and admin exist, so the forbidden cases use a session with an unknown role.
## Automated
Fixtures: three custom types (events, recipes, press-mentions) with the board's sample data for names, field counts and dates, at least one with icon "chef-hat" and one with icon null; five built-in types (articles, authors, sites, media, users) with fields arrays.
Schema list, one test per design state (screens/schema.md section 1):
1. Populated: both calls 200, role owner and role admin. Assert the title, subtitle, columns, custom rows before built-in rows, lock + "Built-in" badge on 5 rows, the built-in note, the summary "3 content types · 5 built-in", enabled "New content type". Assert the card layout below 640 px.
2. Loading: unresolved promise with fake timers. No skeleton at 149 ms, skeleton at 150 ms, "New content type" disabled. A fast response (under 150 ms) never shows skeletons.
3. Empty: content-type returns []. Assert the empty state copy, action, and the 5 built-in rows still listed.
4. Error: 500 and network failure. Assert the alert copy, the "Try again" action, no table. Then retry returns 200 and the Populated state replaces the alert (the second call is made).
5. Forbidden: unknown role. Assert the 403 page copy, "Go to Home" navigates to /home, URL stays /schema, `GET /v1/builtin-type` never called. Add a case where builtin-type answers 403 for an owner.
6. builtin-type 500 with content-type 200: Error state.
7. "New content type" pushes /schema/new.
Sidebar:
8. Schema entry visibility for owner, admin and unknown role; position after Users.
9. Content group: three links with the right hrefs, current row marked, hidden on empty, hidden on 500 and on network failure, no second request across route changes.
10. Icons: "chef-hat" in the bundle renders it, null and an unbundled name render the default "files" icon.
11. Truncation: label has the truncate classes, full name in tooltip and accessible name.
12. More than 5 types: 14 types and a selected type beyond the fifth. Assert 5 + the selected row + "Show all 14"; after activating it all 14 show. With 5 types, no "Show all".
13. Mobile (viewport helper in `components/__tests__/viewport.ts`): drawer shows one collapsed row "All content types" with count 14, expands to 14 rows.
Router and guard:
14. `/schema` is registered with auth "verified" and title "Schema"; unverified and signed-out users get the existing redirects (extend routerGuard.spec).
Client and store:
15. Typed client calls (the Examples table in the feature) incl. icon string|null typing, 403, 500, network failure.
16. Store loads once, shares state between the sidebar and the Schema list, retry reloads.
## Visual (task visual:check -- schema)
Run `task visual:check -- schema`, every capture within 10 % at desktop and mobile, light and dark. States to set up in `web/visual/states.ts`, reusing the board's sample data (names, keys, field counts, dates, counts "3 content types · 5 built-in" taken from SchemaFrame.dc.html):
- 1 Populated, 2 Loading, 3 Empty, 4 Error, 5 Forbidden (403).
- State 6 (built-in read-only view) is out of scope: report it as not set up in this slice, and flag it to the user if the check demands a setup for every state.
- None of these is clock-sensitive; if the Last updated cell turns out to be relative on the board, mark it `skip: 'clock-sensitive'`.
- Mock every `/v1` call the screen makes, including the sidebar's GET /v1/content-type.
- The sidebar group and drawer frames belong to the later `content` group.
## Manual
- Keyboard: tab order through sidebar, "Show all", table and "New content type"; visible focus rings.
- Tooltip on a truncated name on hover and on focus.
- Hover and motion of the drawer expand/collapse row.
- With a real backend: log in as owner and admin and confirm the sidebar and /schema; create a type with icon "chef-hat" via the API and see it appear.
+2
View File
@@ -103,6 +103,8 @@
--text-lg--line-height: 24px;
--text-xl: 1.25rem;
--text-xl--line-height: 28px;
--text-title: 1.5rem;
--text-title--line-height: 32px;
--text-2xl: 1.75rem;
--text-2xl--line-height: 36px;
--text-3xs: 0.625rem;
+101
View File
@@ -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>
+38
View File
@@ -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 ?? ''"
+8 -1
View File
@@ -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])
+2 -2
View File
@@ -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>
+30
View File
@@ -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>
+32
View File
@@ -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>
+128
View File
@@ -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')
}
@@ -0,0 +1,84 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { ApiError, NetworkError } from '@/composables/useApi'
import { useContentTypes } from '@/composables/useContentTypes'
const recipes = {
id: 2,
key: 'recipes',
name: 'Recipes',
icon: null,
fields: [{ key: 'title' }],
created_at: '2026-09-01T10:00:00Z',
updated_at: '2026-10-02T10:00:00Z',
}
const fetchMock = vi.fn()
function answer(status: number, body: unknown) {
fetchMock.mockResolvedValue(
new Response(typeof body === 'string' ? body : JSON.stringify(body), { status }),
)
}
beforeEach(() => {
setActivePinia(createPinia())
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('listContentTypes', () => {
it('returns the types, icon typed string or null', async () => {
answer(200, [recipes, { ...recipes, id: 3, key: 'events', icon: 'calendar' }])
const listed = await useContentTypes().listContentTypes()
expect(listed.map((type) => type.icon)).toEqual([null, 'calendar'])
expect(fetchMock.mock.calls[0]![0]).toBe('/v1/content-type')
})
it('returns an empty array', async () => {
answer(200, [])
await expect(useContentTypes().listContentTypes()).resolves.toEqual([])
})
it('rejects with an error carrying status 500', async () => {
answer(500, 'boom')
await expect(useContentTypes().listContentTypes()).rejects.toMatchObject({
status: 500,
})
})
it('rejects with a network error', async () => {
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
await expect(useContentTypes().listContentTypes()).rejects.toBeInstanceOf(NetworkError)
})
})
describe('listBuiltinTypes', () => {
it('returns {key, name, fields} items', async () => {
const items = [{ key: 'articles', name: 'Articles', fields: [{}, {}] }]
answer(200, items)
await expect(useContentTypes().listBuiltinTypes()).resolves.toEqual(items)
expect(fetchMock.mock.calls[0]![0]).toBe('/v1/builtin-type')
})
it.each([403, 500])('rejects with an error carrying status %s', async (status) => {
answer(status, 'nope')
const failure = await useContentTypes()
.listBuiltinTypes()
.catch((error) => error)
expect(failure).toBeInstanceOf(ApiError)
expect(failure.status).toBe(status)
})
})
+32
View File
@@ -0,0 +1,32 @@
import { useApi } from '@/composables/useApi'
export interface ContentType {
id: number
key: string
name: string
icon: string | null
fields: unknown[]
created_at: string
updated_at: string
}
export interface BuiltinType {
key: string
name: string
fields: unknown[]
}
export function useContentTypes() {
const { request } = useApi()
const options = { onForbidden: 'passthrough' } as const
function listContentTypes(): Promise<ContentType[]> {
return request<ContentType[]>('/v1/content-type', options)
}
function listBuiltinTypes(): Promise<BuiltinType[]> {
return request<BuiltinType[]>('/v1/builtin-type', options)
}
return { listContentTypes, listBuiltinTypes }
}
+28
View File
@@ -2240,6 +2240,34 @@ export const messages = {
'Certaines images à fusionner ont changé. Actualisez et réessayez.',
'List the articles, authors and sites that use an image, with the places where each one uses it.':
'Liste les articles, auteurs et sites qui utilisent une image, avec les endroits où chacun l’utilise.',
Schema: 'Schéma',
Content: 'Contenu',
'Content types': 'Types de contenu',
'Content types and the fields they are made of.':
'Les types de contenu et les champs qui les composent.',
'New content type': 'Nouveau type de contenu',
Fields: 'Champs',
'Last updated': 'Dernière mise à jour',
'Built-in': 'Intégré',
'Built-in types are managed by RenewCMS. You can read their fields, but not change them.':
'Les types intégrés sont gérés par RenewCMS. Vous pouvez lire leurs champs, mais pas les modifier.',
'1 field': '1 champ',
'{0} fields': '{0} champs',
'1 content type': '1 type de contenu',
'{0} content types': '{0} types de contenu',
'{0} · {1} built-in': '{0} · {1} intégrés',
'Updated {0}': 'Mis à jour le {0}',
'No content types yet': 'Aucun type de contenu pour l’instant',
'Create a content type to manage your own kind of content, like events or recipes. The built-in types stay as they are.':
'Créez un type de contenu pour gérer votre propre contenu, comme des événements ou des recettes. Les types intégrés restent tels quels.',
"Couldn't load content types": 'Impossible de charger les types de contenu',
'GET /v1/content-type failed. Check your connection and try again.':
'GET /v1/content-type a échoué. Vérifiez votre connexion et réessayez.',
"You don't have access to Schema": 'Vous n’avez pas accès au schéma',
'Only owners and admins can change content types. You can still add and edit entries from the Content menu.':
'Seuls les propriétaires et les administrateurs peuvent modifier les types de contenu. Vous pouvez toujours ajouter et modifier des entrées depuis le menu Contenu.',
'All content types': 'Tous les types de contenu',
'Show all {0}': 'Tout afficher ({0})',
} as const
export type MessageKey = keyof typeof messages
+34
View File
@@ -0,0 +1,34 @@
import type { ContentType } from '@/composables/useContentTypes'
export const COLLAPSED_LIMIT = 5
export function visibleContentTypes(
types: ContentType[],
selectedKey: string | undefined,
expanded: boolean,
) {
if (expanded || types.length <= COLLAPSED_LIMIT) {
return types
}
const firstTypes = types.slice(0, COLLAPSED_LIMIT)
const selected = types.find((type) => type.key === selectedKey)
return selected && !firstTypes.includes(selected) ? [...firstTypes, selected] : firstTypes
}
const CONTENT_PREFIX = '/content/'
export function selectedContentKey(path: string) {
if (!path.startsWith(CONTENT_PREFIX)) {
return undefined
}
const segment = path.slice(CONTENT_PREFIX.length).split('/')[0] ?? ''
try {
return decodeURIComponent(segment)
} catch {
return segment
}
}
export function contentLink(key: string) {
return `${CONTENT_PREFIX}${encodeURIComponent(key)}`
}
+14
View File
@@ -0,0 +1,14 @@
import lucide from 'virtual:lucide-subset'
export const DEFAULT_CONTENT_ICON = 'i-lucide-files'
function owns(record: object | undefined, key: string) {
return record !== undefined && Object.prototype.hasOwnProperty.call(record, key)
}
export function contentTypeIcon(icon: string | null | undefined) {
if (!icon || !(owns(lucide.icons, icon) || owns(lucide.aliases, icon))) {
return DEFAULT_CONTENT_ICON
}
return `i-lucide-${icon}`
}
+46
View File
@@ -0,0 +1,46 @@
import { t, formatNumber } from '@/i18n'
import type { BuiltinType, ContentType } from '@/composables/useContentTypes'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
export const EM_DASH = '—'
export interface SchemaRow {
id: string
name: string
key: string
fieldCount: number
updated: string
builtin: boolean
}
export function customRow(type: ContentType): SchemaRow {
return {
id: `custom-${type.id}`,
name: type.name,
key: type.key,
fieldCount: type.fields.length,
updated: formatAbsoluteDateFromIso(type.updated_at),
builtin: false,
}
}
export function builtinRow(type: BuiltinType): SchemaRow {
return {
id: `builtin-${type.key}`,
name: type.name,
key: type.key,
fieldCount: type.fields.length,
updated: EM_DASH,
builtin: true,
}
}
export function fieldCountLabel(count: number) {
return count === 1 ? t('1 field') : t('{0} fields', [formatNumber(count)])
}
export function schemaSummary(custom: number, builtin: number) {
const customLabel =
custom === 1 ? t('1 content type') : t('{0} content types', [formatNumber(custom)])
return t('{0} · {1} built-in', [customLabel, formatNumber(builtin)])
}
@@ -124,6 +124,37 @@ describe('Nuxt UI shell foundation and router guards', () => {
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
})
it('The schema route is registered as verified-only with the Schema title', () => {
const resolved = router.resolve('/schema')
expect(resolved.matched).toHaveLength(1)
expect(resolved.meta.auth).toBe('verified')
expect(resolved.meta.title).toBe('Schema')
})
it('The schema route redirects an unverified session to the pending page', async () => {
const getMe = vi.fn().mockResolvedValue({ verified: false })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/schema')
mountApp()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
})
it('The schema route redirects a signed-out visitor to login with the original path', async () => {
const getMe = vi.fn().mockRejectedValue(new ApiError(401))
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/schema')
mountApp()
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/login?redirect=/schema&reason=expired')
})
it('A session-only route admits an unverified session', async () => {
const getMe = vi.fn().mockResolvedValue({ verified: false })
vi.mocked(useApi).mockReturnValue({ getMe })
+5
View File
@@ -87,6 +87,11 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/UsersView.vue'),
meta: { auth: 'verified', title: 'Users' },
},
{
path: '/schema',
component: () => import('@/views/SchemaView.vue'),
meta: { auth: 'verified', title: 'Schema' },
},
{
path: '/activity',
component: () => import('@/views/ActivityView.vue'),
@@ -0,0 +1,194 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { ApiError } from '@/composables/useApi'
import { useContentTypes } from '@/composables/useContentTypes'
import { useContentTypesStore } from '@/stores/contentTypes'
vi.mock('@/composables/useContentTypes', () => ({ useContentTypes: vi.fn() }))
const listContentTypes = vi.fn()
const listBuiltinTypes = vi.fn()
function deferred() {
let resolve!: (value: unknown) => void
const promise = new Promise((done) => (resolve = done))
return { promise, resolve }
}
const events = {
id: 1,
key: 'events',
name: 'Events',
icon: null,
fields: [],
created_at: '2026-09-01T10:00:00Z',
updated_at: '2026-10-15T10:00:00Z',
}
beforeEach(() => {
setActivePinia(createPinia())
listContentTypes.mockReset()
listBuiltinTypes.mockReset()
vi.mocked(useContentTypes).mockReturnValue({
listContentTypes,
listBuiltinTypes,
})
})
describe('content types store', () => {
it('exposes loading while the request is pending, then the items', async () => {
let resolve!: (value: unknown) => void
listContentTypes.mockReturnValue(new Promise((done) => (resolve = done)))
const store = useContentTypesStore()
const loaded = store.load()
expect(store.loading).toBe(true)
resolve([events])
await loaded
expect(store.loading).toBe(false)
expect(store.items).toEqual([events])
expect(store.error).toBe(false)
})
it('loads once however many callers ask', async () => {
listContentTypes.mockResolvedValue([events])
const store = useContentTypesStore()
await Promise.all([store.load(), store.load()])
await store.load()
expect(listContentTypes).toHaveBeenCalledTimes(1)
})
it('exposes the error and no items when the request fails, and retry calls again', async () => {
listContentTypes.mockRejectedValueOnce(new Error('500')).mockResolvedValueOnce([events])
const store = useContentTypesStore()
await store.load()
expect(store.error).toBe(true)
expect(store.items).toEqual([])
await store.retry()
expect(listContentTypes).toHaveBeenCalledTimes(2)
expect(store.error).toBe(false)
expect(store.items).toEqual([events])
})
it('discards a late result after reset and lets a new load win', async () => {
const first = deferred()
listContentTypes.mockReturnValueOnce(first.promise).mockResolvedValueOnce([events])
const store = useContentTypesStore()
const stale = store.load()
store.reset()
expect(store.status).toBe('idle')
await store.load()
first.resolve([{ ...events, key: 'stale' }])
await stale
expect(store.items).toEqual([events])
expect(store.status).toBe('ready')
})
it('ignores a stale rejection after reset', async () => {
let reject!: (reason: unknown) => void
listContentTypes.mockReturnValueOnce(new Promise((_, no) => (reject = no)))
const store = useContentTypesStore()
const stale = store.load()
store.reset()
reject(new Error('500'))
await stale
expect(store.status).toBe('idle')
})
it('a superseded promise does not clear the newer pending', async () => {
const first = deferred()
const second = deferred()
listContentTypes.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
const store = useContentTypesStore()
const stale = store.load()
store.reset()
const fresh = store.load()
first.resolve([])
await stale
const shared = store.load()
second.resolve([events])
await Promise.all([fresh, shared])
expect(listContentTypes).toHaveBeenCalledTimes(2)
expect(store.items).toEqual([events])
})
it('retry during an in-flight load supersedes it', async () => {
const first = deferred()
listContentTypes.mockReturnValueOnce(first.promise).mockResolvedValueOnce([events])
const store = useContentTypesStore()
const stale = store.load()
await store.retry()
first.resolve([{ ...events, key: 'stale' }])
await stale
expect(listContentTypes).toHaveBeenCalledTimes(2)
expect(store.items).toEqual([events])
expect(store.status).toBe('ready')
})
it('loads built-in types only when asked, and shares the request', async () => {
listContentTypes.mockResolvedValue([events])
listBuiltinTypes.mockResolvedValue([{ key: 'articles', name: 'Articles', fields: [] }])
const store = useContentTypesStore()
await store.load()
expect(listBuiltinTypes).not.toHaveBeenCalled()
expect(store.builtinStatus).toBe('idle')
await Promise.all([store.load({ builtin: true }), store.load({ builtin: true })])
expect(listBuiltinTypes).toHaveBeenCalledTimes(1)
expect(store.builtinItems).toHaveLength(1)
expect(store.builtinStatus).toBe('ready')
})
it('maps a 403 from either call to forbidden and 500 to failed', async () => {
listContentTypes.mockResolvedValue([events])
listBuiltinTypes.mockRejectedValue(new ApiError(403))
const store = useContentTypesStore()
await store.load({ builtin: true })
expect(store.builtinStatus).toBe('forbidden')
expect(store.forbidden).toBe(true)
expect(store.items).toEqual([events])
store.reset()
listContentTypes.mockRejectedValue(new ApiError(403))
listBuiltinTypes.mockRejectedValue(new ApiError(500))
await store.load({ builtin: true })
expect(store.status).toBe('forbidden')
expect(store.forbidden).toBe(true)
expect(store.error).toBe(false)
expect(store.builtinStatus).toBe('failed')
})
it('retry reloads both and reset clears both', async () => {
listContentTypes.mockResolvedValue([events])
listBuiltinTypes
.mockRejectedValueOnce(new ApiError(500))
.mockResolvedValueOnce([{ key: 'articles', name: 'Articles', fields: [] }])
const store = useContentTypesStore()
await store.load({ builtin: true })
expect(store.builtinStatus).toBe('failed')
await store.retry()
expect(store.builtinStatus).toBe('ready')
store.reset()
expect(store.items).toEqual([])
expect(store.builtinItems).toEqual([])
expect(store.status).toBe('idle')
expect(store.builtinStatus).toBe('idle')
})
})
+114
View File
@@ -0,0 +1,114 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { ApiError } from '@/composables/useApi'
import { useContentTypes, type BuiltinType, type ContentType } from '@/composables/useContentTypes'
export type ContentTypesStatus = 'idle' | 'loading' | 'ready' | 'failed' | 'forbidden'
export interface LoadOptions {
builtin?: boolean
}
function createSlice<T>(fetcher: () => Promise<T[]>) {
const items = ref<T[]>([])
const status = ref<ContentTypesStatus>('idle')
let pending: Promise<void> | null = null
let generation = 0
async function run(token: number) {
try {
const listed = await fetcher()
if (token !== generation) {
return
}
items.value = Array.isArray(listed) ? (listed as never) : []
status.value = 'ready'
} catch (error) {
if (token !== generation) {
return
}
items.value = []
status.value =
error instanceof ApiError && error.status === 403 ? 'forbidden' : 'failed'
}
}
function load() {
if (status.value === 'ready') {
return Promise.resolve()
}
if (pending) {
return pending
}
status.value = 'loading'
const current: Promise<void> = run(generation).finally(() => {
if (pending === current) {
pending = null
}
})
pending = current
return current
}
function retry() {
generation++
pending = null
status.value = 'idle'
return load()
}
function reset() {
generation++
pending = null
items.value = []
status.value = 'idle'
}
return { items, status, load, retry, reset }
}
export const useContentTypesStore = defineStore('contentTypes', () => {
const custom = createSlice<ContentType>(() => useContentTypes().listContentTypes())
const builtin = createSlice<BuiltinType>(() => useContentTypes().listBuiltinTypes())
const items = custom.items
const status = custom.status
const builtinItems = builtin.items
const builtinStatus = builtin.status
const loading = computed(() => status.value === 'loading')
const error = computed(() => status.value === 'failed')
const forbidden = computed(
() => status.value === 'forbidden' || builtinStatus.value === 'forbidden',
)
function load(options: LoadOptions = {}) {
const requests = [custom.load()]
if (options.builtin) {
requests.push(builtin.load())
}
return Promise.all(requests).then(() => undefined)
}
function retry() {
return Promise.all([custom.retry(), builtin.retry()]).then(() => undefined)
}
function reset() {
custom.reset()
builtin.reset()
}
return {
items,
status,
builtinItems,
builtinStatus,
loading,
error,
forbidden,
load,
retry,
reset,
}
})
+130
View File
@@ -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>
+2 -1
View File
@@ -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')
+4 -1
View File
@@ -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',
+7 -3
View File
@@ -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 () => {
+290
View File
@@ -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 () => {
+167
View File
@@ -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,
)
}
+32 -6
View File
@@ -418,6 +418,10 @@ const settleMs = 400
const diffThreshold = 24
const maxDiffPercent = 10
const shellApi: Record<string, ApiMock> = {
'GET /v1/content-type': { status: 200, body: [] },
}
function budgetFor(cap: DiffException['maxPercent'] | undefined, viewport: Viewport) {
if (cap === undefined) {
return maxDiffPercent
@@ -595,6 +599,15 @@ function groupSectionRows(markdown: string, group: string) {
return [...section.matchAll(/^\| \d+ \| ([^|]+?) \|/gm)].map((match) => match[1]!.trim())
}
const boardGroupLabels: Record<string, Record<string, string>> = {
schema: {
'Schema list': '1 Schema list',
'Schema builder': '2 Schema builder',
'Review and delete': '3 Review and delete',
'Field types': '4 Field types',
},
}
const referenceGroups: Record<string, string> = {
'integration-api-keys': 'API keys',
}
@@ -614,7 +627,9 @@ async function readScreen(name: string): Promise<Screen> {
if (!reference) {
throw new Error(`docs/design/screens/${name}.md has no visual reference line`)
}
const referenceGroups = [...reference[2]!.matchAll(/"([^"]+)"/g)].map((match) => match[1]!)
const referenceGroups = [...reference[2]!.matchAll(/"([^"]+)"/g)].map(
(match) => boardGroupLabels[name]?.[match[1]!] ?? match[1]!,
)
const numberedRows = [...markdown.matchAll(/^\| \d+ \| ([^|]+?) \|/gm)].map((match) =>
match[1]!.trim(),
)
@@ -813,7 +828,14 @@ async function captureDesign(
const title = headers.find(
(div) => div.querySelector('span')?.textContent?.trim() === state,
)
const block = title?.parentElement
const wideBlock = section
? [...section.querySelectorAll<HTMLElement>('div')].find(
(div) =>
div.style.width === '1037px' &&
div.querySelector('span > .sc-interp')?.textContent?.trim() === state,
)
: undefined
const block = title ? title.parentElement : wideBlock
const target = block
? [...block.querySelectorAll<HTMLElement>('div')].find(
(div) => div.style.width === `${width}px`,
@@ -890,6 +912,7 @@ async function captureApp(
})
await serveIconsLocally(context)
const unmocked = new Set<string>()
const fallback = new Set<string>()
const calls = new Map<string, number>()
await context.route('**/v1/**', async (route) => {
const request = route.request()
@@ -897,7 +920,10 @@ async function captureApp(
const key = `${request.method()} ${url.pathname}`
const call = (calls.get(key) ?? 0) + 1
calls.set(key, call)
const mock = setup.api?.[key]
const mock = setup.api?.[key] ?? shellApi[key]
if (mock && !setup.api?.[key]) {
fallback.add(key)
}
if (!mock) {
unmocked.add(key)
await route.fulfill({ status: 404, body: '' })
@@ -932,7 +958,7 @@ async function captureApp(
await page.waitForTimeout(settleMs)
await page.screenshot({ path, animations: 'disabled' })
await context.close()
return [...unmocked]
return { unmocked: [...unmocked], fallback: [...fallback] }
}
async function diff(browser: Browser, designPath: string, appPath: string, diffPath: string) {
@@ -1053,7 +1079,7 @@ async function main() {
const appPath = join(directory, `app-${suffix}`)
const diffPath = join(directory, `diff-${suffix}`)
const unmocked = await captureApp(
const { unmocked, fallback } = await captureApp(
browser,
app.origin,
setup,
@@ -1080,7 +1106,7 @@ async function main() {
? ` [exception ${describeCap(setup.exception.maxPercent)} approved by ${setup.exception.approvedBy}: ${setup.exception.reason}]`
: ''
lines.push(
`${problems.length ? 'FAIL' : 'PASS'} ${state} · ${viewport} · ${theme}: ${percent.toFixed(2)}% pixels differ → ${relative(repoRoot, directory)}${problems.length ? ` (${problems.join('; ')})` : ''}${exception}`,
`${problems.length ? 'FAIL' : 'PASS'} ${state} · ${viewport} · ${theme}: ${percent.toFixed(2)}% pixels differ → ${relative(repoRoot, directory)}${problems.length ? ` (${problems.join('; ')})` : ''}${exception}${fallback.length ? ` [shellApi fallback: ${fallback.join(', ')}]` : ''}`,
)
}
await board.context().close()
+66
View File
@@ -6445,8 +6445,74 @@ const editorSeoStates: Record<string, VisualState> = {
},
}
const schemaCustomTypes = [
['events', 'Events', 7, '2026-10-15T09:30:00Z'],
['recipes', 'Recipes', 4, '2026-10-02T09:30:00Z'],
['press-mentions', 'Press mentions', 5, '2026-09-18T09:30:00Z'],
].map(([key, name, fieldCount, updatedAt], index) => ({
id: index + 1,
key,
name,
icon: 'files',
fields: Array.from({ length: fieldCount as number }, (_, field) => ({ key: `field_${field}` })),
created_at: '2026-09-01T09:30:00Z',
updated_at: updatedAt,
}))
const schemaBuiltinTypes = [
['articles', 'Articles', 5],
['authors', 'Authors', 5],
['sites', 'Sites', 8],
['media', 'Media', 6],
['users', 'Users', 6],
].map(([key, name, fieldCount]) => ({
key,
name,
fields: Array.from({ length: fieldCount as number }, (_, field) => ({ key: `field_${field}` })),
}))
function schemaSession(role: string): ApiMock {
return {
status: 200,
body: { ...(verifiedSession.body as object), display_name: 'Marta Alves', role },
}
}
function schemaApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
return {
'GET /v1/auth/me': schemaSession('owner'),
'GET /v1/content-type': { status: 200, body: schemaCustomTypes },
'GET /v1/builtin-type': { status: 200, body: schemaBuiltinTypes },
...overrides,
}
}
const schemaStates: Record<string, VisualState> = {
Populated: { path: '/schema', api: schemaApi() },
Loading: {
path: '/schema',
api: schemaApi({ 'GET /v1/content-type': { status: 200, hang: true } }),
},
'Empty: no custom type yet': {
path: '/schema',
api: schemaApi({ 'GET /v1/content-type': { status: 200, body: [] } }),
},
Error: {
path: '/schema',
api: schemaApi({ 'GET /v1/content-type': { status: 500 } }),
},
'Forbidden (403)': {
path: '/schema',
api: schemaApi({
'GET /v1/auth/me': schemaSession('user'),
}),
},
'Built-in type: read-only view': { path: '/schema', skip: 'pending' },
}
export const visualStates: Record<string, Record<string, VisualState>> = {
sites: sitesStates,
schema: schemaStates,
authors: authorsStates,
activity: activityStates,
profile: {