diff --git a/features/schema_list_and_content_nav.feature b/features/schema_list_and_content_nav.feature new file mode 100644 index 0000000..33a031f --- /dev/null +++ b/features/schema_list_and_content_nav.feature @@ -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 "" is signed in + When the back-office shell renders + Then the sidebar 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 + When the sidebar renders + Then the row for "recipes" shows + + 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/ + 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 + 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 + 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 + And the API answers + Then it + + 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 | diff --git a/features/schema_list_and_content_nav.qa.md b/features/schema_list_and_content_nav.qa.md new file mode 100644 index 0000000..3200571 --- /dev/null +++ b/features/schema_list_and_content_nav.qa.md @@ -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. diff --git a/web/src/assets/main.css b/web/src/assets/main.css index 6aee7c4..0253e2a 100644 --- a/web/src/assets/main.css +++ b/web/src/assets/main.css @@ -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; diff --git a/web/src/components/ContentNavGroup.vue b/web/src/components/ContentNavGroup.vue new file mode 100644 index 0000000..dd66bb2 --- /dev/null +++ b/web/src/components/ContentNavGroup.vue @@ -0,0 +1,101 @@ + + + diff --git a/web/src/components/DashboardShell.vue b/web/src/components/DashboardShell.vue index 48dbac1..c3677dc 100644 --- a/web/src/components/DashboardShell.vue +++ b/web/src/components/DashboardShell.vue @@ -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() { + +