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.
Root — <Report>
Section titled “Root — <Report>”| 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.
<Filters>
Section titled “<Filters>”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:
- Every declared filter is bound to every dataset, as
DBNullwhen 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. - A
DateRangebinds two parameters,@{Name}Fromand@{Name}To. Guard them separately. - A blank
Requiredfilter stops the whole page. No dataset is executed and the page shows a prompt, rather than a grid of empty cards.
<DataSets>
Section titled “<DataSets>”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 and the KPI strip
Section titled “SingleRow and the KPI strip”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.
Attributes deliberately absent
Section titled “Attributes deliberately absent”| 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.
<Widgets>
Section titled “<Widgets>”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> — one scalar
Section titled “<Tile> — one scalar”<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> — inline SVG, no chart library
Section titled “<Chart> — inline SVG, no chart library”<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> — a top-N list
Section titled “<DataTable> — a top-N list”<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.
<Layout>
Section titled “<Layout>”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 Spanis 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 ownHeight(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.Tabis accepted but renders as a labelled block, not a tab strip — hiding half a dashboard behind a tab defeats the point of one.ItemrejectsFormat,Unit,ViewOnlyLabelandShowInEdit: those describe a field, and a widget owns its own formatting.
Naming the same widget twice renders it twice, from one query.
Value formats
Section titled “Value formats”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.
Traps the parser catches at import
Section titled “Traps the parser catches at import”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. |
Where to go next
Section titled “Where to go next”- Runtime & sources — how datasets execute, row caps, and row-level security.
- Report pages overview — import, versioning and permissions.