Skip to content

Authoring a report

A report definition is a single XML document with a <Report> root and four blocks: <Filters>, <DataSets>, <Widgets> and <Layout>. This page is the full element and attribute reference. For the mental model and how a definition gets into the system, start with the overview. Worked examples over the real Inventory schema live in sample/Inventory/models/reports — see worked examples.

Attribute Required Meaning
Name yes Canonical identity. The upsert key on import, and the RBAC resource name.
Slug yes URL-friendly id; the page is served at /reports/{Slug}. Either resolves server-side.
Label no Page title. Falls back to a humanized Name.
Icon no Font Awesome class shown beside the title.

<ParentView Name="…" /> names a navbar item this report hangs under, which becomes its breadcrumb crumb — the same element a view uses.

Identical to a view’s PreExecuteFilters — same elements, same attributes, same panel. See pre-execute filters for the full field vocabulary.

<Filters Toggle="Active">
<Select Name="FltWarehouse" Label="Warehouse">
<DataSet Type="sql"><![CDATA[
SELECT w.Id AS Value, w.Name AS Label
FROM [Inventory].[Warehouses] w
WHERE w.IsDeleted = 0 AND w.CompanyId = @SessionCompanyId
]]></DataSet>
</Select>
<Filter Name="FltRange" Type="DateRange" Label="Movement window"
InputType="Date" MaxDays="365" Required="true" />
<Layout>
<Grid>
<Row>
<Column Span="4"><Item Name="FltWarehouse" Width="12" /></Column>
<Column Span="4"><Item Name="FltRange" Width="12" /></Column>
</Row>
</Grid>
</Layout>
</Filters>

Three rules that follow from the filters being page-level:

  1. Every declared filter is bound to every dataset, as DBNull when blank. That is why an authored query guards with (@Flt IS NULL OR col = @Flt) — and why adding a filter never breaks an existing dataset.
  2. A DateRange binds two parameters, @{Name}From and @{Name}To. Guard them separately.
  3. A blank Required filter stops the whole page. No dataset is executed and the page shows a prompt, rather than a grid of empty cards.

A dataset is a named, read-only SELECT. Its SQL is the element’s text (use CDATA).

<DataSets>
<DataSet Name="DsKpis" Mode="SingleRow">
<![CDATA[
SELECT SUM(p.UnitCost * oh.OnHand) AS StockValue,
COUNT(*) AS ProductCount,
SUM(CASE WHEN oh.OnHand <= p.ReorderLevel THEN 1 ELSE 0 END) AS LowStockCount
FROM [Inventory].[Products] p
OUTER APPLY ( … ) oh
WHERE p.CompanyId = @SessionCompanyId
]]>
</DataSet>
<DataSet Name="DsFlow" Source="InventoryReports">
<![CDATA[ SELECT CAST(sm.MovedAt AS DATE) AS MovedOn, … ORDER BY MovedOn ]]>
</DataSet>
</DataSets>
Attribute Required Meaning
Name yes How a widget references it. Unique within the report.
Mode no MultiRow (default) or SingleRow.
Source no Names a Genie:ReportingSources entry. Omit for the app’s own database.

SingleRow caps the query at one row, and each projected alias becomes an output property that any number of <Tile>s can map. This is what makes a three-tile KPI strip a single round trip:

<Tile Name="TlStockValue" Data="DsKpis" Value="StockValue" Format="Currency" />
<Tile Name="TlLowStock" Data="DsKpis" Value="LowStockCount" Format="Integer" />

A <Tile> must read a SingleRow dataset. The parser rejects a tile pointed at a MultiRow one, rather than letting the tile silently pick one row of many.

Not accepted Instead
Type A report dataset is always SELECT SQL. Type belongs on a filter’s option <DataSet Type="sql">.
MaxRows Mode already says whether one row or many is expected.
Dialect, Connection, ConnectionString Environment detail belongs in Genie:ReportingSources, not the model file.
Cache, SortBy, PageSize, PrimaryKey Sorting and top-N belong in the SQL; a dataset is not a grid.

Each throws at parse time naming the alternative, so a misremembered attribute fails at import rather than being silently ignored.

The element name is the kind. Four are accepted: Tile, Chart, DataTable, View. Every widget is a card, and shares these attributes:

Attribute Meaning
Name Required. What an <Item> in the layout references.
Title / Subtitle Card header text. Title falls back to Name.
Icon Font Awesome class shown in the header.
Color A palette key (primary, warning, danger, …) or a hex code.
Height Body height in px. Omit to let content size the card.
Data The dataset this widget reads. Not used by <View>.
Drill An href the card links to, as one header button — not per row.
Hidden An expression; when it evaluates true the card is not rendered.
RolesAllowed Comma-separated roles. A caller outside them never receives the card or its query.

A widget’s attributes are a closed set. Anything outside the shared table above plus the kind’s own attributes below fails the import, naming what was legal. The same goes for <Series>, <Detail> and <Param>. That matters more here than it looks: an unrecognised attribute is read by nothing, so without the check the card renders and simply does not do the thing the attribute was there to do — which reads as a rendering bug rather than an authoring one.

<Tile Name="TlLowStock" Title="Low Stock" Icon="fa fa-triangle-exclamation" Color="warning"
Data="DsKpis" Value="LowStockCount" Format="Integer" Suffix="products">
<Detail Label="Out of stock" Value="OutOfStockCount" Format="Integer" Color="danger" />
</Tile>
Attribute Meaning
Value Required. The dataset column to show.
Format See value formats.
Suffix Unit shown after the value (“products”, “units”).
Trend A signed column. The sign alone gives up / down / flat — there is no threshold to configure.
TrendFormat Format for the trend figure; defaults to Format.
Caption A line of explanatory text under the value.

<Detail> rows show more properties from the same SingleRow round trip: Label, Value, Format, and an optional Color that paints only that value.

<Chart Name="ChCategoryValue" Title="Stock value by category" Kind="Bar" Horizontal="true" Height="320"
Data="DsCategoryValue" Category="CategoryName" Format="Currency">
<Series Value="StockValue" Label="Stock value" Color="primary" />
<Series Value="LowStockCount" Label="Low stock" Color="warning" />
</Chart>
Attribute Meaning
Kind Required. Bar, Line, Area, Pie or Donut.
Category Required. The column on the category axis (or the slice label).
Horizontal Bar only — put long category names down the side.
Stacked Bar and Area — stack the series instead of overlaying them.
Legend false suppresses the legend.
Format Formats axis labels and tooltips.

<Series> takes Value (the column), an optional Label, and an optional Color. An omitted colour is assigned positionally from a fixed rotation (primary · warning · teal · purple · rose · success · orange · info), ordered so adjacent series contrast — primary is the theme accent, which is blue in every shipped theme, so it is not followed by another blue.

One <Series> per column — there is no split-by. A chart plots the columns you name against the one Category column, so “on-hand per category over 30 days” is not a SplitBy="Category" attribute: it is a dataset returning one row per day with one column per category, and one <Series> naming each. A query that returns the long (category, day, value) shape cannot be charted per category as it stands — pivot it first, with a CASE/countIf per group or your engine’s PIVOT. The cost is that the groups have to be known when the report is authored, which is the trade a declarative chart makes. Type, Series, SplitBy and GroupBy as attributes are each rejected by name, because they are what a chart written from library habit reaches for.

Pie and Donut take one <Series> and colour by category; per-series Color does not apply. They show parts of one whole, so only positive values can be drawn: zero and negative rows are left out and counted under the legend (“2 categories are not shown”). A measure that can go negative — a net movement, a variance, stock on hand — belongs in a Bar.

The value axis is automatic and has no attribute. It rounds to a step of 1 / 2 / 2.5 / 5 / 10 × a power of ten — picking the most detailed step that still fits in five gridlines — and rounds the top up to a whole number of steps. So labels land on round numbers ($0 · $1K · $2K · $3K · $4K, not $930 · $1.86K · $2.79K), and the tallest mark stops short of the ceiling instead of reading as clipped. An Integer format never gets a fractional step. The axis starts at zero for any dataset that is entirely positive: a bar or area truncated at the bottom exaggerates differences, which is the one thing a dashboard must not do. Data that runs below zero opens the axis downward to a round step, and bars are then drawn from the zero line — a negative value hangs beneath it rather than disappearing.

Category labels are thinned to at most seven, then shortened to the width they actually have, with the full text on hover. Horizontal="true" is the better answer for long names: each one gets its own row in the gutter.

Line and Area track the mouse. Moving over the plot snaps a crosshair to the nearest category by x-position and shows one joined tooltip listing every series’ value at that point together, so a multi-series trend is read at a glance instead of one line at a time. Bar, Horizontal="true" and Pie/Donut keep their own per-shape hover instead — a native tooltip on each bar or slice.

The whole chart is drawn at the measured size of its container, so axis and category labels are the same size in a full-width report card and in a narrow one.

<DataTable Name="TbTopMovers" Title="Top movers" Data="DsTopMovers" Height="320"
Drill="/table/stock-movements">
<Column Name="Sku" Label="SKU" StyleClasses="text-muted" />
<Column Name="Name" Label="Product" StyleClasses="fw-bold" />
<Column Name="MovedUnits" Label="Units" StyleClasses="text-end fw-bold" />
<Column Name="LastDirection" Label="Dir" Render="badge" />
</DataTable>

<Column> is the table-view <Column>, reused verbatim — Label, StyleClasses, Render, Hide, RolesAllowed and the rest all mean what they mean on a grid. Note that <Column> children sit directly under <DataTable>, with no <Columns> wrapper (matching its <Series> and <Detail> siblings); the wrapper throws with that guidance.

PageSize (default 10) trims the rows shown. Ordering belongs in the dataset’s ORDER BY — a dashboard table is a top-N list, not a pageable grid.

<View> — embed an existing view read-only

Section titled “<View> — embed an existing view read-only”

The point of <View> is not restating a view you already have. It carries its own SQL, columns, formatting, badges and RBAC:

<View Name="VwLowStock" Title="Products below reorder level" Color="danger"
Object="low-stock-report" Height="380" PageSize="10"
ShowFilters="false" ShowControls="false">
<Param Name="WarehouseId" Value="{FltWarehouse}" />
</View>
Attribute Meaning
Object Required. The target view’s Name or Slug.
PageSize Overrides the target’s own.
ShowFilters Render the target’s own filter panel. Default false — the report’s bar is the page control.
ShowControls Render the search / column-manager / export toolbar. Default false.

<Param> feeds a value into the target’s declared parameters. A {FltName} token resolves against this report’s current filter values; anything else is a literal.

Read-only is structural, not cosmetic: row actions, create, inline edit and every write path are withheld regardless of what the target declares. And the target is still gated by its own RBAC resource — embedding never launders a permission.

The same 12-column vocabulary as a form layout, with one difference: <Item Name="…"> names a widget, not a field.

<Layout>
<Grid>
<Row>
<Column Span="4"><Item Name="TlStockValue" Width="12" /></Column>
<Column Span="4"><Item Name="TlLowStock" Width="12" /></Column>
<Column Span="4"><Item Name="TlNetUnits" Width="12" /></Column>
</Row>
<Row MaxHeight="360">
<Column Span="7"><Item Name="ChCategoryValue" Width="12" /></Column>
<Column Span="5"><Item Name="ChFlow" Width="12" /></Column>
</Row>
</Grid>
<Line />
<Section Label="Replenishment" Accordian="Opened">
<Grid>
<Row MaxHeight="400">
<Column Span="6"><Item Name="TbTopMovers" Width="12" /></Column>
<Column Span="6"><Item Name="VwLowStock" Width="12" /></Column>
</Row>
</Grid>
</Section>
</Layout>

Seven controls are accepted: Grid, Row, Column, Section, Tab, Item, Line. That is the view’s eight minus <Sub>, which references a table’s sub-view config a report does not have.

  • Column Span is 1–12 of the row.
  • Row MaxHeight (pixels) caps the row’s rendered height; whichever column’s content would otherwise overflow it scrolls internally instead of stretching every card beside it. This is a ceiling on the row, not a fixed size for one widget — a widget’s own Height (above) is the latter, and the two answer different questions. Omit it and the row grows to its tallest column, as it always has.
  • Section Accordian="Opened|Closed" makes the section collapsible.
  • Tab is accepted but renders as a labelled block, not a tab strip — hiding half a dashboard behind a tab defeats the point of one.
  • Item rejects Format, Unit, ViewOnlyLabel and ShowInEdit: those describe a field, and a widget owns its own formatting.

Naming the same widget twice renders it twice, from one query.

Format on a <Tile>, <Detail> or <Chart> takes one of:

Text · Number · Integer · Decimal · Currency · Percent · Code · Date · DateTime

Currency renders in the currency named by data-genie-currency on the document root (default USD) — a deployment fact, not a property of the report, so one definition renders correctly in two tenants. Percent treats the value as already being a percentage (SQL returning 12.5 shows 12.5%).

Every cell arrives from the server as a string, numbers included. A value a numeric format cannot parse falls back to the raw text rather than showing NaN.

The parser fails loudly rather than rendering something subtly wrong. Each of these throws with a message naming the actual problem, which the import dialog shows against the file it came from:

Authoring mistake Why it throws
Two datasets or two widgets sharing a Name A reference would be ambiguous.
A widget’s Data naming no declared dataset The card would render permanently empty.
A <Tile> reading a MultiRow dataset The tile would silently pick one row of many.
An <Item> naming no declared widget The layout slot would render nothing.
A dataset that is not a plain SELECT See the SELECT-only guard.
<Sub> in the layout, or <Columns> under <DataTable> Not part of this contract; the message names what to use.