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