Ga naar hoofdinhoud

CnDetailPage

A generic detail/overview page component. The simpler counterpart to CnIndexPage — designed for pages that display statistics, charts, card grids, or other detail content without multi-object tables or CRUD dialogs.

Wraps: NcEmptyContent, NcLoadingIcon, NcButton (from @nextcloud/vue), CnIcon

Try it

Loading CnDetailPage playground…

Props

PropTypeDefaultDescription
titleString''Page title
descriptionString''Optional subtitle shown below the title
iconString''MDI icon name (rendered via CnIcon)
iconSizeNumber28Icon size in pixels
loadingBooleanfalseLoading state
loadingLabelString'Loading...'Message shown during loading
sidebarBoolean | ObjectfalseSidebar configuration. Accepts EITHER the legacy Boolean form (deprecated) OR the new Object form mirroring CnIndexPage.sidebar. See Sidebar config object below.
sidebarOpenBooleantrueWhether the sidebar starts open (only relevant when sidebar is active)
objectTypeString''Object type slug passed to the sidebar (e.g. 'pipelinq_lead'). Used by legacy direct mounts; manifest-driven detail pages prefer the register + schema pair below and let the page fuse them.
objectIdString|Number''Object ID passed to the sidebar and (in schema-driven mode) to objectStore.fetchObject.
registerString''Schema-driven mode — OpenRegister register slug. When paired with schema (and objectId), the page fuses them into an internal ${register}-${schema} object-type slug, registers it on the store, fetches the object + its schema via useObjectStore, and auto-renders CnObjectDataWidget + CnObjectMetadataWidget when the default slot is empty. objectType wins on collision so existing direct mounts stay untouched.
schemaString''Schema-driven mode — OpenRegister schema slug. See register.
sidebarTabsArray[]Tab definitions for the host App's CnObjectSidebar. Forwarded via the injected objectSidebarState; mirrors sidebar.tabs / sidebarProps.tabs but lives at the top level so the manifest's config.sidebarTabs flows in directly. Empty array → the consumer's CnObjectSidebar falls back to its default tab set.
sidebarPropsObject{}Extra sidebar configuration forwarded to CnObjectSidebar (register, schema, hiddenTabs, title, subtitle, tabs). Set sidebarProps.tabs to an open-enum tab array to drive the host app's mounted CnObjectSidebar from manifest.json — see CnObjectSidebar custom tabs. The array flows through the existing objectSidebarState provide/inject channel. Note: when both sidebar (Object) AND sidebarProps set the same field, the Object form wins and a console.warn lists the conflicting fields once per component instance.
errorBooleanfalseError state
errorMessageString'An error occurred'Message shown in error state
onRetryFunctionnullCallback for retry button in error state. If null, no retry button shown.
retryLabelString'Retry'Retry button text
emptyBooleanfalseEmpty state
emptyLabelString'No data available'Message shown in empty state
statsTitleString''Title above the statistics table
statsColumnsArray[]Column defs for stats table: [{ key: string, label: string, align?: 'left'|'center'|'right' }]
statsRowsArray[]Row data for stats table (objects keyed by column keys; set indent: true for sub-row styling)
maxWidthString'1200px'Maximum width of the page content

Slots

SlotScopeDescription
#iconCustom icon (replaces CnIcon)
#header-actionsAction buttons in the header (right side)
#errorCustom error state content
#error-actionsExtra buttons inside the default error state
#emptyCustom empty state content
#empty-actionsExtra buttons inside the default empty state
#stats-headerCustom header above the stats table (replaces default h3)
#stats-rowsCustom table body rows (replaces auto-generated rows)
#defaultMain content below the stats table
#sectionsAdditional content below the default slot
#footerFooter content (separated by a border)

CnDetailPage.sidebar accepts EITHER form:

  • Boolean (legacy, deprecated):sidebar="true" activates the external CnObjectSidebar via the objectSidebarState inject; false deactivates. The first time this form is observed per component instance a one-shot console.warn fires pointing at the migration path.

  • Object (preferred) — mirrors CnIndexPage.sidebar plus detail-specific fields:

    sidebar: {
    show: true, // default true; false suppresses the sidebar
    enabled: true, // default true; false bypasses the external sidebar
    register: 'leads', // forwarded via objectSidebarState
    schema: 'lead',
    hiddenTabs: ['notes'],
    title: 'Lead detail',
    subtitle: '...',
    tabs: [ // see manifest-abstract-sidebar
    { id: 'overview', label: 'lead.overview', widgets: [{ type: 'data' }] },
    ],
    }

    Use show: false to hide the sidebar declaratively without removing the rest of the config (e.g. behind a feature flag or a responsive layout watcher).

Migrating from boolean

Replace:

<CnDetailPage
:sidebar="true"
:sidebar-props="{ register: 'leads', schema: 'lead', tabs: [...] }"
object-type="lead"
:object-id="id" />

With:

<CnDetailPage
:sidebar="{ register: 'leads', schema: 'lead', tabs: [...] }"
object-type="lead"
:object-id="id" />

sidebarProps continues to work for backwards compatibility — when both sidebar (Object) and sidebarProps are set with overlapping fields, the Object form wins and a console.warn fires once per component instance listing the conflicting fields.

Manifest type:'detail' pages declare their sidebar tabs in config.sidebarTabs[] (the human-authored source of truth). Each entry has id + label (required), optional icon, order, component, _note. Widgets bound to a tab carry tabGroup: "<tab.id>" on slot:"sidebar" entries.

{
"id": "ZaakDetail",
"route": "/zaken/:id",
"type": "detail",
"title": "Case",
"config": {
"register": "zaakafhandelapp",
"schema": "zaak",
"sidebarTabs": [
{ "id": "overview", "label": "Overview", "order": 10 },
{ "id": "history", "label": "History", "order": 20, "icon": "icon-history" }
]
},
"widgets": [
{ "widgetKey": "data", "slot": "sidebar", "tabGroup": "overview", "gridX": 0, "gridY": 0, "gridWidth": 1, "gridHeight": 1 }
]
}

The validator checks two invariants:

  1. Tab shape — each sidebarTabs[] entry MUST have non-empty id + label; ids MUST be unique within the page.
  2. Cross-reference — every widgets[] entry with slot:"sidebar" and a tabGroup value MUST match a declared sidebarTabs[].id. Catches the silent-typo case where a tab-bound widget references a non-existent tab.

The CLI manifest-migrate transform lifts config.sidebarTabs[].widgets[] into top-level widgets[] with slot:"sidebar" + tabGroup at build time. Component-only tab entries (declaring only component) are carried forward in the residual sidebarTabs[] for runtime resolution against the customComponents registry.

Usage

Basic detail page with statistics table

<template>
<CnDetailPage
title="Register Overview"
description="Statistics for this register"
icon="DatabaseOutline"
:loading="loading"
:stats-title="'Register Statistics'"
:stats-columns="[
{ key: 'type', label: 'Type' },
{ key: 'total', label: 'Total' },
{ key: 'size', label: 'Size' },
]"
:stats-rows="[
{ type: 'Objects', total: 150, size: '2.4 MB' },
{ type: 'Invalid', total: 3, size: '-', indent: true },
{ type: 'Deleted', total: 7, size: '-', indent: true },
{ type: 'Files', total: 42, size: '1.1 MB' },
{ type: 'Logs', total: 230, size: '512 KB' },
]">
<div class="chart-grid">
<ChartCard title="Audit Trail"><LineChart :data="auditData" /></ChartCard>
<ChartCard title="Objects by Schema"><PieChart :data="schemaData" /></ChartCard>
</div>
<div class="card-grid">
<SchemaCard v-for="schema in schemas" :key="schema.id" :schema="schema" />
</div>
</CnDetailPage>
</template>

With error handling and retry

<template>
<CnDetailPage
title="Schema Details"
:error="hasError"
error-message="Failed to load schema details"
:on-retry="loadSchema">
<template #error-actions>
<NcButton @click="$router.push('/registers')">
Back to Registers
</NcButton>
</template>
<DetailContent :schema="schema" />
</CnDetailPage>
</template>

Custom stats rows (manual table body)

When the auto-generated rows from statsRows aren't flexible enough, use the #stats-rows slot to render your own <tr> elements:

<template>
<CnDetailPage
title="Register Stats"
:stats-columns="[
{ key: 'type', label: 'Type' },
{ key: 'total', label: 'Total' },
{ key: 'size', label: 'Size' },
]">
<template #stats-rows>
<tr>
<td>Objects</td>
<td>{{ stats.objects?.total || 0 }}</td>
<td>{{ formatBytes(stats.objects?.size || 0) }}</td>
</tr>
<tr class="cn-detail-page__stats-row--sub">
<td class="cn-detail-page__stats-cell--indented">Invalid</td>
<td>{{ stats.objects?.invalid || 0 }}</td>
<td>-</td>
</tr>
</template>
</CnDetailPage>
</template>

Public (unauthenticated) detail pages

pages[].config.mode: 'public' marks a detail route as unauthenticated — token-scoped reader pages like credential verification or shared-link views. Pair with the @route.<param> sentinel (see resolveRouteSentinels) for the token binding:

{
"id": "CredentialVerify",
"route": "/credentials/:token/verify",
"type": "detail",
"title": "Verify credential",
"config": {
"register": "scholiq",
"schema": "credential",
"mode": "public",
"filter": { "token": "@route.token" }
}
}

The schema's typed mode enum (edit | create | public) gives consumers IDE completion + sharp validator errors on typos. Today the manifest carries the intent — CnDetailPage does not yet branch on mode for auth-header bypass; the host app skips auth headers based on the route. A follow-up will wire native public-mode handling into the component so consumers don't have to coordinate auth-bypass externally.

When to use CnDetailPage vs other page components

ComponentUse when...
CnDetailPageDisplaying detail info, stats tables, charts, card overviews — no multi-object CRUD
CnIndexPageListing objects with table/cards, pagination, search, mass actions, CRUD dialogs
CnDashboardPageBuilding a widget-based dashboard with drag-and-drop grid layout

Collaborative editing defaults

CnDetailPage auto-subscribes to live updates for the current object when both objectStore and (objectType + objectId) are provided. This wires useObjectSubscription into the page lifecycle so users see remote changes without polling — including remote pessimistic locks.

When the cached @self.locked block indicates another user holds the lock, CnDetailPage mounts CnLockedBanner above the content. The banner renders only when lockedByMe === false.

Two opt-out props:

PropDefaultBehaviour
subscribetrueWhen false, skips the auto-subscribe (useful for read-only / archive views).
objectStorenullPinia store instance. When omitted, both subscribe and lock-state are skipped. Pass the result of useObjectStore() from your app.

See useObjectLock for the lock state contract; the lib does not yet auto-acquire on edit-mode toggle (planned for a follow-up cycle that wires the form dialogs).

Integration props (AD-19)

PropTypeDefaultNotes
surfaceString'detail-page'Rendering surface forwarded to integration widgets in the grid layout (widget defs with type === 'integration'). Drives the AD-19 surface fallback.
integrationContext (integration-context)Object | nullnullObject context { register, schema, objectId } forwarded to integration widgets. When omitted it is derived from sidebarProps.register / sidebarProps.schema (or objectType) and objectId.

Built-in Actions menu

The header carries the shared CnActionsMenu overflow Refresh, Documentation, and Request a feature — after any #actions slot content. Refresh and Request-a-feature are on by default; opt out per item with :show-refresh="false" / :show-request-feature="false".

  • Refresh emits @refresh and, unless the host calls event.preventDefault(), fires the cn:page:refresh event-bus channel with { widgetId, title }.
  • Documentation renders only when documentationUrl is set, opening it in a new tab.
  • Request a feature opens CnSuggestFeatureModal with surface: "detail:<id>" when mounted under CnAppRoot.

Set :page-id for a stable id/surface (it otherwise falls back to a slugified title). All the menu props are forwarded to CnActionsMenu:

PropDefaultDescription
documentationUrl''When set, renders the Documentation entry (opens in a new tab).
documentationLabelt('Documentation')Pre-translated Documentation label.
specRef''Forwarded to the feature-request modal.
refreshingfalseWhen bound, the Refresh icon spins while true.
optimisticSpinMs800Optimistic Refresh-icon spin duration when refreshing is unbound.
refreshLabelt('Refresh')Pre-translated Refresh label.
requestFeatureLabelt('Request a feature')Pre-translated Request-a-feature label.
actionsMenuLabelt('Actions')Pre-translated overflow-menu trigger label.
SlotDescription
action-itemsExtra items appended inside the overflow menu, after the built-in trio.

Reference (auto-generated)

The tables below are generated from the SFC source via vue-docgen-cli. They reflect what's actually in CnDetailPage.vue and update automatically whenever the component changes.

Props

NameTypeRequiredDefaultDescription
layout{ id: number, widgetId: string, gridX: number, gridY: number, gridWidth: number, gridHeight?: number, showTitle?: boolean }[][]Grid layout definition. Array of placement objects defining where each widget appears in the 12-column grid.
widgets{ id: string, title: string, type?: string }[][]Widget definitions. Array of widget objects with id and title.
columnsnumber12Number of grid columns.
titlestring''Page title
descriptionstring''Page description (shown below title)
iconstring''Optional MDI icon name (rendered via CnIcon)
iconSizenumber28Icon size in pixels
loadingbooleanfalseWhether the page is in a loading state
loadingLabelstring() =&gt; t('nextcloud-vue', 'Loading…')Message shown during loading
sidebarboolean &#124; objectfalseSidebar configuration. Accepts EITHER form: - Boolean (legacy, deprecated): true activates the external sidebar, false deactivates. The first time this form is observed per component instance a one-shot console.warn is logged pointing at the migration path. Continues to work in v1.x for back-compat. - Object (preferred): mirrors CnIndexPage.sidebar plus the detail-specific fields previously on sidebarProps: ts { show?: boolean, // default true; false suppresses sidebar enabled?: boolean, // default true; false bypasses external sidebar register?: string, schema?: string, hiddenTabs?: string[], title?: string, subtitle?: string, tabs?: Array&lt;TabDef&gt;, // see manifest-abstract-sidebar } When BOTH sidebar (Object) and sidebarProps are set with overlapping fields, the Object form wins and a console.warn lists the conflicting fields once per component instance.
sidebarOpenbooleantrueWhether the sidebar is open (expanded)
objectTypestring''The registered object type slug for the sidebar
objectIdstring&#124;number''The object ID to display in the sidebar
subtitlestring''Subtitle shown in the sidebar header
sidebarPropsobject\{\}Additional sidebar configuration (register, schema, hiddenTabs, title, subtitle)
surfacestring'detail-page'Rendering surface forwarded to integration widgets in the grid layout (widget defs with type === 'integration'). Drives the AD-19 surface fallback. Defaults to 'detail-page' — the surface this page represents.
integrationContextunionnullObject context forwarded to integration widgets: { register, schema, objectId }. When omitted it is derived from sidebarProps.register / sidebarProps.schema (or objectType) and objectId, so CnFilesCard etc. can fetch the right object's sub-resources without extra wiring.
errorbooleanfalseWhether the page is in an error state
errorMessagestring() =&gt; t('nextcloud-vue', 'An error occurred')Error message shown in error state
onRetryfuncnullCallback for retry button in error state. If null, no retry button is shown.
retryLabelstring() =&gt; t('nextcloud-vue', 'Retry')Label for the retry button
emptybooleanfalseWhether the page has no data to show
emptyLabelstring() =&gt; t('nextcloud-vue', 'No data available')Message shown when page is empty
statsTitlestring''Title shown above the statistics table
statsColumnsArray<{ key: string, label: string, align: string }>[]Column definitions for the statistics table. Each column: { key: string, label: string, align?: 'left'|'center'|'right' }
statsRowsArray<object>[]Row data for the statistics table. Each row is an object keyed by column keys. Set indent: true on a row for sub-row styling.
maxWidthstring'1200px'Maximum width of the page content
subscribebooleantrueWhether to auto-subscribe to live updates for this object. Defaults to true. When useObjectStore and objectType + objectId are both available, the page calls objectStore.subscribe(objectType, objectId) on mount and unsubscribes on unmount via tryOnScopeDispose. Set false for read-only / archive views.
objectStoreunionnullOptional explicit Pinia store instance to subscribe / lock against. When omitted, the page resolves useObjectStore() lazily so consumer apps that haven't activated Pinia yet (e.g. tests) don't crash.
registerstring''OpenRegister register slug. Pair with schema to opt into the schema-driven mode: the page fuses the two into an internal \${register}-\${schema} object-type slug, registers it on the store, fetches the object identified by objectId, and auto- renders CnObjectDataWidget + CnObjectMetadataWidget when no default-slot content is supplied. Compatible with the existing objectType prop — objectType wins on collision, so legacy direct mounts are unaffected.
schemastring''OpenRegister schema slug. See register for the schema-driven contract.
sidebarTabsArray<object>[]Tab definitions forwarded to the host App's CnObjectSidebar via the injected objectSidebarState. Each entry follows the CnObjectSidebar tab shape (see that component for the exact fields). When empty (default) the sidebar falls back to its own default tab set. The actual &lt;CnObjectSidebar&gt; is rendered at NcContent level by CnAppRoot (ADR-017 — external sidebar pattern); this page only publishes the tabs.

Slots

NameBindingsDescription
headertitle, description, icon, icon-size
icon
actions
error
error-actions
empty
empty-actions
widget-${item.widgetId}name, item, widget
stats-header
stats-rows
default
sections
footer