CnChartWidget
ApexCharts wrapper for dashboard and detail page widgets. Supports area, line, bar, pie, donut, and radialBar chart types with Nextcloud-themed defaults. The chart library is a peer dependency — consuming apps must install apexcharts and vue-apexcharts.
Try it
Usage
<!-- Area chart with categories -->
<CnChartWidget
type="area"
:series="[{ name: 'Requests', data: [10, 41, 35, 51, 49, 62] }]"
:categories="['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']"
:height="250" />
<!-- Pie chart -->
<CnChartWidget
type="pie"
:series="[44, 55, 13, 33]"
:labels="['Active', 'Pending', 'Closed', 'Draft']" />
<!-- Bar chart with custom options -->
<CnChartWidget
type="bar"
:series="barSeries"
:options="{ plotOptions: { bar: { horizontal: true } } }" />
When ApexCharts is not available a fallback slot or unavailableLabel is shown:
<CnChartWidget type="area" :series="series">
<template #fallback>
<p>Charts require the apexcharts package.</p>
</template>
</CnChartWidget>
Manifest usage (recommended)
When CnDashboardPage resolves a widget definition with type: "chart", it mounts CnChartWidget automatically. Manifest authors do NOT mount this component themselves — declare a chart widget in pages[].config.widgets[] instead:
{
"id": "sla-trend",
"title": "myapp.sla_trend",
"type": "chart",
"props": {
"chartKind": "line",
"series": [{ "name": "SLA %", "data": [82, 88, 91, 93] }],
"categories": ["Q1", "Q2", "Q3", "Q4"],
"options": { "stroke": { "width": 3 } }
}
}
The dispatcher forwards props.chartKind as the apex type and passes through series, categories, labels, options, colors, toolbar, legend, height, width, unavailableLabel.
Height on a dashboard tile
A dashboard tile's height is fixed by its grid units, so a chart tile that authors no height gets '100%' — the chart fits the tile. The standalone default (a pinned 250px) is taller than the content box of a typical gridHeight: 4 tile, which made the tile scroll the graph instead of showing it. Author a height in the widget's props (or its in-app content) to pin one deliberately.
Percentage heights need an ancestor with a resolved height. Inside CnWidgetWrapper that is already true; in a page region sized by its content, pin a pixel height instead.
dataSource — resolving series + categories from OpenRegister
CnChartWidget accepts a dataSource block that resolves series / categories / labels from a GraphQL query against OpenRegister. Three shapes are supported:
Count shorthand
dataSource: {
schema: 'meeting',
filter: { lifecycle: 'review' },
aggregate: 'count',
}
// → { count: 4 } (use the raw `graphql:` form for chart series)
Bucket shorthand (time series)
Emits OpenRegister's groupBy argument with a time interval and returns { series, categories } ready to feed into a line/bar chart:
dataSource: {
schema: 'call_log',
filter: { status: 'error' },
bucket: {
field: 'created',
interval: 'day', // case-insensitive → DAY
fromVar: 'from', // default 'from'
toVar: 'to', // default 'to'
staticRange: { // fallback when no dashboard range
from: '2026-05-01T00:00:00.000Z',
to: '2026-05-22T00:00:00.000Z',
},
},
}
When mounted under a CnDashboardPage with dateRange.enabled, the widget injects cnDashboardDateRange and uses the dashboard's currently-selected { from, to } for the GraphQL variables — every chart on the page tracks the same range. If no dashboard range is available the widget falls back to bucket.staticRange; if neither is available no query is fired and the chart shows its fallback / unavailable state.
Non-count metrics are supported via metric ('sum' | 'avg' | 'min' | 'max', case-insensitive) + a required metricField:
bucket: {
field: 'created',
interval: 'week',
metric: 'sum',
metricField: 'amount',
staticRange: { from: '…', to: '…' },
}
Raw GraphQL
dataSource: {
graphql: {
query: 'query { meeting { groups { key value } } }',
selectors: {
series: 'meeting.groups[].value',
categories: 'meeting.groups[].key',
},
},
}
The selector path syntax supports dot-paths with optional [] flat-maps. See selectByPath for the full path grammar.
Aggregation shorthand (Wave 3, #91) — group-by over an OR collection
aggregate as an object (the string form aggregate: 'count' stays the count shorthand above) runs a categorical group-by over the schema's objects — the "requests by status" / "cases by type" / "top skills" chart in one declarative block:
dataSource: {
register: 'crm',
schema: 'request',
filter: { active: true }, // shared @-token grammar
aggregate: {
groupBy: 'status', // the categorical field
metric: 'count', // 'count' (default) | 'sum'
sumField: 'hours', // required for metric: 'sum'
topN: 10, // keep the 10 largest groups (sorted desc)
otherBucket: true, // fold the remainder into a translated "Other" slice
labelResolve: { // when groupBy holds reference uuids:
schema: 'billingCategory', // resolve each key to the referenced object…
labelField: 'name', // …'s display label (default 'name')
colorField: 'color', // optional per-category colour (feeds the colorMap path)
},
},
drilldown: { route: 'Requests', filterParam: 'status' },
}
Server-first: OpenRegister's /grouped facet endpoint does the aggregation (the same facet the groupBy shorthand reaches), so the client never pulls a collection just to count it. When that endpoint is unavailable (older OR) the widget falls back to fetching the collection (capped at 1000 objects) and grouping client-side. labelResolve resolves reference keys through the shared object store (per-id cache + in-flight dedup — the fkResolve cell pattern), degrading to the raw key when an object can't be loaded. An explicit colorMap prop wins over labelResolve.colorField colours.
Drilldown — segment/bar click → filtered route
A drilldown: { route, filterParam } block on the dataSource makes every slice / bar click navigate with the clicked category's raw key (the uuid / status value, not the resolved display label) in the query: { [filterParam]: rawKey }. A route starting with / is treated as a path, anything else as a route name. The folded "Other" bucket never navigates (it has no single category value). Works with both the aggregate and legacy groupBy forms.
endpointSource — endpoint-bound series/labels (Wave 2, #91)
Binds the chart to an arbitrary app REST endpoint through the shared useEndpointSource engine (token-resolved params, request dedup + short-TTL cache, cn:page:refresh wiring; the chart's own refresh() / cn:widget:refresh handler force-refetches it too). Exactly one of dataSource | endpointSource (validator-enforced).
The response mapping keys live INSIDE the block — the flat series / labels prop names already carry the static data:
// ARRAY-of-points payload (pipelinq /api/analytics/trends → { series: [{ date, value }] }):
endpointSource: {
url: '/apps/pipelinq/api/analytics/trends',
params: { metric: 'leads', period: '@workspace.datePreset?' },
responsePath: 'series',
labelsPath: 'date', // per-item field path
series: [{ name: 'Leads', path: 'value' }], // per-item field path
}
// OBJECT payload with parallel arrays:
endpointSource: {
url: '/apps/myapp/api/pipeline-by-stage',
labelsPath: 'labels', // → payload.labels (array)
series: [{ name: 'Open value', path: 'open' }], // → payload.open (number array)
}
Pie-family charts (pie / donut / radialBar) flatten the FIRST mapped series into the flat value array ApexCharts expects. params re-resolve + the chart refetches when the dashboard date range changes (the page publishes dateFrom / dateTo / datePreset into the workspace context).
views — in-widget display switcher (Wave 3, #91)
An optional views array renders a compact pill row above the chart that toggles which named series / value format render — pure display, no series arithmetic (the €/% and hours/% toggles from the Wave-4 evaluation):
views: [
{ key: 'eur', label: '€', series: ['Margin €'], valueFormat: 'currency' },
{ key: 'pct', label: '%', series: ['Margin %'], valueFormat: 'percent' },
]
Each entry is { key, label?, series?, valueFormat? }: series (an array of series names) filters which resolved cartesian series render (a filter that matches nothing falls back to all series, so a typo never blanks the chart; pie-family series have no names to filter on), and valueFormat overrides the widget-level valueFormat while the view is active. The first view is active by default; fewer than two views render no switcher.
Value-axis baseline
ApexCharts frames the value axis to the data range by default. For a series like [7, 6] that puts 7 at the very top of the plot and 6 at the very bottom, so a difference of one reads as a total collapse. valueAxisBaseline is the guard against that.
| Value | Behaviour |
|---|---|
'auto' (default) | Anchors bar and area at zero; keeps line off zero but widens the window when it gets too narrow to be honest. |
'zero' | Always anchors at zero. |
'fit' | Plain ApexCharts autoscaling. |
The split is not arbitrary. Bar and area encode magnitude by length and area, so a truncated baseline makes the mark misstate the ratio — a bar twice as tall must mean twice as much. Line encodes position, so it may legitimately sit off zero; there the rule is instead that the visible window must span at least a quarter of the data's magnitude, which stops a 1-in-1000 wiggle from filling the plot.
The ceiling is rounded up to a nice number (7 → 8, 62 → 80, 210 → 250) so the peak is not glued to the top of the plot and the ticks land on round values.
Four cases always fall back to autoscaling, because a zero floor would be wrong, useless, or too low: pie-family charts, any series that goes negative (clamping would crop real values), an empty series, and stacked charts (options.chart.stacked) — a stacked mark's height is the per-category sum, which exceeds the largest single datapoint, so a ceiling derived from that datapoint would clip the bars. ApexCharts already baselines stacked charts at zero. An explicit options.yaxis.min / max still wins through the deep-merge.
The baseline applies to every cartesian chart, whether its series are plain numbers alongside categories or raw datapoints in the {x, y} / [x, y] forms. On a horizontal bar chart the bounds move to the x-axis, since ApexCharts flips the axes.
Reach for 'fit' when the series genuinely lives far from zero — a percentage hovering between 95 and 99, a temperature — where a zero baseline flattens the whole signal into one line. On a dashboard it can be set per widget from the manifest:
{ "type": "chart", "props": { "chartKind": "line", "valueAxisBaseline": "fit" } }
Theming
The chart follows the Nextcloud theme in both light and dark mode, with no configuration. Everything drawn inside the SVG is themed through chart options (foreColor, grid, legend and axis label colours all read var(--color-*)), and apexcharts' HTML chrome — the hover tooltip, the crosshair axis tooltips, the toolbar menu and its icons — is restyled from the same tokens in the component's stylesheet.
That override is why options.tooltip.theme has no visible effect: both apexcharts themes are hardcoded palettes, so either one is wrong in the other mode. The tokens flip themselves, which also covers high-contrast and custom (nldesign) themes.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
type | String | 'area' | Chart type: 'area', 'line', 'bar', 'pie', 'donut', 'radialBar' |
series | Array | [] | Data series. Format: [{ name, data[] }] for cartesian charts; number[] for pie/donut |
categories | Array | [] | X-axis category labels (line, area, bar charts) |
labels | Array | [] | Segment labels (pie, donut charts) |
height | Number | String | 250 | Chart height. A number pins pixels; a percentage ('100%') fits the container; 'auto' derives it from the width |
width | Number | String | '100%' | Chart width (defaults to full container width) |
options | Object | {} | Custom ApexCharts options deep-merged with Nextcloud defaults |
colors | Array | [] | Color palette — defaults to Nextcloud CSS variable colors |
toolbar | Boolean | false | Show/hide the ApexCharts toolbar (zoom, download) |
legend | Boolean | true | Show/hide the chart legend |
unavailableLabel | String | 'Chart library not available' | Text shown when ApexCharts is not installed |
dataSource | Object | null | Optional OpenRegister GraphQL block — see dataSource above |
horizontal | Boolean | false | Render type: "bar" charts horizontally (row bars). An explicit options.plotOptions.bar.horizontal still wins. |
legendPosition | String | '' | Legend placement override: top | bottom | left | right. Empty keeps the automatic placement (bottom for pie-family, top otherwise). |
valueAxisBaseline | String | 'auto' | How the value axis picks its baseline — see Value-axis baseline below. auto anchors bar/area at zero and stops line charts over-zooming; zero always anchors at zero; fit restores plain ApexCharts autoscaling. |
valueFormat | String | Object | null | Named value formatter applied to the VALUE axis labels AND the tooltip: "currency" (Intl currency, 0 decimals), "currency-compact" (compact notation, e.g. € 1,2K), "percent". Object form { name, currency?, decimals? } overrides the ISO code (EUR default, guarded) and fraction digits. With horizontal bars the formatter moves to the x-axis (the value axis flips). |
colorMap | Object | null | Per-category colour map ({ categoryLabel: cssColor }) for pie/donut/radialBar slices and bar categories (bars switch to distributed rendering so each category gets its colour). Unmapped categories keep the default palette colour. |
emptyLabel | String | '' | Empty-state message rendered INSTEAD of the chart when the resolved series contain no data points. Empty keeps the pre-existing empty-canvas behaviour. |
endpointSource | Object | null | Endpoint-bound series/labels — see endpointSource above. Exactly one of dataSource | endpointSource. |
views | Array | [] | In-widget display switcher — see views above. Each entry { key, label?, series?, valueFormat? }. |
All display props are additive (chart widget manifest content /
props keys of the same names pass through CnDashboardPage's dispatcher).
Slots
| Slot | Description |
|---|---|
fallback | Content rendered when ApexCharts is not available |
Reference (auto-generated)
The tables below are generated from the SFC source via vue-docgen-cli. They reflect what's actually in CnChartWidget.vue and update automatically whenever the component changes.
Props
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
type | string | 'area' | Chart type: area, line, bar, pie, donut, radialBar | |
series | Array | [] | Chart data series. Format depends on chart type. For line/area/bar: [{ name: string, data: number[] }] For pie/donut: number[] | |
categories | Array<string> | [] | X-axis categories (for line, area, bar charts) | |
labels | Array<string> | [] | Labels (for pie, donut charts) | |
height | union | 250 | Chart height. A number (or '250px') pins the height in pixels. A percentage — '100%' — fits the chart to its container instead, which is what a fixed-height surface such as a dashboard tile wants: a pinned height taller than the tile turns the tile into a scroll region. 'auto' derives the height from the width (16:10 for axis charts). Container fitting needs an ancestor with a resolved height. | |
width | union | '100%' | Chart width. Defaults to '100%' (fills container). | |
options | object | \{\} | Custom ApexCharts options (deep-merged with defaults). | |
colors | Array<string> | [] | Chart color palette. Defaults to Nextcloud theme colors. | |
toolbar | boolean | false | Show or hide the toolbar (zoom, download, etc.) | |
legend | boolean | true | Show or hide the legend | |
unavailableLabel | string | () => t('nextcloud-vue', 'Chart library not available') | Label shown when ApexCharts is not available | |
horizontal | boolean | false | Render bar charts horizontally (row bars instead of columns). Only meaningful for type: "bar"; an explicit options.plotOptions.bar.horizontal still wins (deep-merge). | |
valueAxisBaseline | string | 'auto' | How the value axis picks its baseline — the guard against a chart that turns a trivial difference into a dramatic one. - "auto" (default) — anchor magnitude marks (bar / area) at zero, and stop line charts from zooming so far in that noise fills the plot. Never clamps when the data goes negative. - "zero" — always anchor at zero (ignored when data goes negative). - "fit" — the old behaviour: let ApexCharts frame the data range. Use for series that live far from zero (a percentage hovering 95–99, a temperature) where a zero baseline flattens the signal. An explicit options.yaxis.min / max still wins through the deep-merge. | |
legendPosition | string | '' | Legend placement override: top | bottom | left | right. Empty (the default) keeps the pre-existing automatic placement (bottom for pie-family charts, top otherwise). | |
valueFormat | string|{name: string, currency?: string, decimals?: number}|null | null | Named value formatter applied to the VALUE axis labels and the tooltip: "currency" (Intl currency, 0 decimals), "currency-compact" (compact notation, e.g. € 1,2K), or "percent" (appends %). The object form { name, currency?, decimals? } overrides the ISO-4217 currency code (EUR default, guarded) and the fraction digits. null (the default) keeps raw values. | |
colorMap | Record<string, string>|null | null | Per-category colour map ({ categoryLabel: cssColor }) applied to pie / donut / radialBar slices and (distributed) bar categories. Categories without an entry keep the default palette colour. null (the default) keeps the palette-based colouring. | |
emptyLabel | string | '' | Empty-state message rendered INSTEAD of the chart when the resolved series contain no data points. Empty (the default) keeps the pre-existing behaviour (an empty chart canvas). | |
dataSource | object | null | Manifest dataSource block. When set, series / categories / labels are resolved from the GraphQL response via the dataSource selectors and override the static props of the same names. Static props remain the fallback while the query is loading or when no dataSource is configured. Supported shapes (one of): - Count shorthand: { register?, schema, filter?, aggregate: 'count' } - Bucket shorthand: { register?, schema, filter?, bucket: { field, interval, metric?, metricField?, fromVar?, toVar?, staticRange? } } — emits OR's groupBy argument. When mounted under a CnDashboardPage with dateRange.enabled, from / to come from the injected cnDashboardDateRange ref; otherwise they come from bucket.staticRange. If neither is available no query is fired and the chart shows its fallback. - Raw GraphQL: { graphql: { query, variables?, selectors } }. - Aggregation shorthand (Wave 3, #91): aggregate as an OBJECT — { groupBy, metric?: 'count'|'sum', sumField?, topN?, otherBucket?, labelResolve?: { register?, schema, labelField?, colorField? } } — a categorical group-by over the schema's objects. Served by OpenRegister's /grouped facet endpoint (the server aggregates); when that endpoint is unavailable the widget falls back to fetching the collection and grouping client-side. topN keeps the N largest groups (sorted by value desc) and otherBucket: true folds the remainder into a single translated "Other" slice (sum of the rest). labelResolve swaps reference (uuid) group keys for the referenced objects' labelField labels via the shared object store (per-id cache + request dedup — the fkResolve pattern), and colorField reads a per-category colour off each referenced object (feeding the same per-category colour path as the colorMap prop, which wins on overlap). metric: 'sum' requires sumField. Drilldown (Wave 3, #91): a sibling drilldown: { route, filterParam } block makes every segment / bar click navigate to route (a route NAME, or a PATH when it starts with /) with the clicked category's RAW key in the query — { [filterParam]: rawKey } — so an index page opens pre-filtered. The folded "Other" bucket never navigates (it has no single category value). | |
endpointSource | {url: string, method?: string, params?: object, responsePath?: string, labelsPath?: string, series?: Array<{name?: string, path: string}>}|null | null | Endpoint data binding (Wave 2, #91). Reads series / categories / labels from an arbitrary app REST endpoint through the shared useEndpointSource engine (token-resolved params, per-(url+params) request dedup + short-TTL cache, cn:page:refresh subscription). Exactly one of dataSource | endpointSource (validator-enforced); endpoint data wins when both slip through. Static series / categories / labels props stay the fallback while loading. The response mapping keys live INSIDE this block (not as sibling props) because the flat series / labels prop names already carry the static data: - responsePath — dot-path pluck of the payload (default whole body). - When the payload is an ARRAY of points (e.g. pipelinq /api/analytics/trends → series: [{ date, value }] with responsePath: 'series'): labelsPath / series[].path are PER-ITEM field paths — labelsPath: 'date', series: [{ name: 'Leads', path: 'value' }]. - When the payload is an OBJECT: labelsPath / series[].path point at parallel arrays — labelsPath: 'labels', series: [{ name: 'Total', path: 'totals' }]. Pie-family charts (pie / donut / radialBar) flatten the FIRST mapped series into the flat value array ApexCharts expects. params values use the shared filter-token grammar (@workspace.dateFrom?, @workspace.datePreset?, @me, @today±Nd, …) and re-resolve + refetch automatically when the dashboard date range changes (the page publishes dateFrom / dateTo / datePreset into the workspace context). | |
views | union | [] | In-widget view switcher (the Wave-4 amendment folded into Wave 3, #91): each entry declares a named display view — { key, label?, series?, valueFormat? }. When 2+ views are configured a compact pill row renders above the chart; the active view's series (an array of series NAMES) filters which of the resolved cartesian series render, and its valueFormat overrides the widget-level valueFormat (same named-formatter grammar). This is PURE DISPLAY — no series arithmetic — covering the €/% and hours/% toggles (shillinq Margin / BillableHours) with zero client-side computation. Empty (the default) renders no switcher. | |
widgetId | string | '' | Widget id used to match cn:widget:refresh event-bus events (broadcast by CnWidgetWrapper's Refresh action). When the bus fires with a matching widgetId, the chart re-queries its dataSource. Passed by CnDashboardPage from the layout item. Empty disables bus-driven refresh (the chart still refetches reactively when its dataSource / range changes). |
Slots
| Name | Bindings | Description |
|---|---|---|
fallback | — | Rendered when the ApexCharts peer dependency is not available (defaults to the unavailableLabel text). |
Refresh wiring
| Prop | Type | Default | Description |
|---|---|---|---|
widgetId | String | '' | Matches cn:widget:refresh event-bus events (broadcast by CnWidgetWrapper's Refresh); on a matching id the chart re-queries its dataSource. |
The chart also subscribes to cn:page:refresh, the channel the page-level Refresh action broadcasts on (CnDashboardPage / CnDetailPage). That one carries no widget id — a page refresh refreshes everything on the page — so a chart placed without a widgetId still reloads.
Both channels re-query dataSource (the GraphQL, time-bucket, group-by and aggregate paths). They differ on endpointSource: the per-widget channel and the ref-callable refresh() refetch it, while the page channel leaves it to useEndpointSource, which subscribes to cn:page:refresh itself. Refetching it twice would issue two HTTP requests, not one — a forced fetch drops the in-flight dedup entry.