Report CSV/PDF export
A report's export is the second flavor of print form: the user picks CSV or PDF on the
report page and the same data that fills the on-screen grid is rendered to a file. It uses the
vocabulary from Print forms; this page covers what is specific to reports — the
result envelope, the Behavior's Export method, and what each format includes.
The worked example is Kandra's own RestsReport: RestsReportResult and RestsReportDataDto
in Forms/Reports/Rests.cs, and RestsReportBehavior in Application/Reports/Rests.cs. For a
complete report built from scratch, including the export, see
Creating a report.
Prefer the create-report skill (which already scaffolds the export wiring) and the
create-print-form skill (to extend it) over hand-rolling this.
The result envelope is not the print form
A report's result is split in two, and only one half is a print form:
// The technical envelope: Filters + Data + CacheTag. A closed generic with no members of its own.
// It NEVER carries [PrintForm].
public sealed class RestsReportResult : ReportResult<ReportRestsDto, RestsReportDataDto>;
// The business/print data. THIS is the [PrintForm] class and what the renderer targets.
[PrintForm]
[PrintTitle("RestsReport")]
[PrintSection("Header", Columns = 2)]
public sealed class RestsReportDataDto : PrintHeaderData { /* header fields + [PrintTable] lines */ }
ReportResult<TFilters, TData> implements IReportResult<TFilters, TData>, carrying the
filters that produced the data, the data itself, and a cache tag. TData is the half that
extends the header base (if you use one) and holds the lines. Two consequences:
-
Point
[SupportedPrintForm]atTData, on the filters Dto — not at the envelope:[KandraReportForm(Name = "RestsReport", ResultDtoType = typeof(RestsReportResult))][SupportedExportFormats(ExportFormat.Csv | ExportFormat.Pdf)][SupportedPrintForm(typeof(RestsReportDataDto))]public sealed class ReportRestsDto : IReportFiltersDto { /* ... */ }Pointing it at the envelope is a mistake — the envelope has no
[PrintForm], so there is no renderer for it. -
Header fields live on
result.Data, not on the result.GeneratedAtandGeneratedByUserNameare on theTDataclass (via the header base), so fill them there.
The same TData class also feeds the on-screen grid: the [ReportViewGrid] on its [PrintTable]
property is what the UI generator walks to build the MudDataGrid. One class, two uses.
Wire the Behavior's Export method
IReportBehavior<TFilters, TResult> has one Export method — not a CSV/PDF pair. The
service hands you the resolver, the localizer and the caller's time zone; you render and name the
file:
public IFileData Export(RestsReportResult report, ExportFormat format, string? layoutTag,
IPrintRendererResolver renderer, IStringLocalizer localizer, TimeZoneInfo? timeZone)
{
// report.Data, not report: the renderer targets RestsReportDataDto, never the envelope.
var output = renderer.Render(report.Data, localizer, timeZone, format, layoutTag ?? "default");
return new FileData
{
FileName = $"RestsReport_{DateTime.Now:yyyyMMdd_HHmmss}{output.FileExtension}",
ContentType = output.ContentType,
Content = output.Content,
};
}
- The renderer never knows a filename — only bytes, a content type and an extension. You build the name yourself.
- The format is the user's choice at export time. It arrives as the
formatparameter, so the one method serves both buttons. The defaultExportreturnsnull, meaning "this report doesn't export"; the buttons that appear are decided by[SupportedExportFormats]. layoutTagis the resolver's DI key.nullmeans the generator-registered"default"renderer, which is every report in the reference configuration today. A report that needs a hand-crafted alternate layout registers a secondIPrintRenderer<TData, TTarget>under a different keyed tag and the client passes that tag; nothing forces a report to use only the default. Declare the option with[SupportedLayoutTag]only once a renderer for it exists.
Fill the header in GetDataAsync
GetDataAsync receives an ILookupRepository lookup as a parameter, so a report Behavior
never needs its own dictionary repositories just to resolve names for the print header. Enrich
the data after querying it:
public async Task<RestsReportResult> GetDataAsync(ReportRestsDto filters, ILookupRepository lookup, CancellationToken ct)
{
var lines = await repository.GetRests(filters.NonEmptyOnly, filters.WarehouseId, filters.ItemId, filters.DateAsOf, ct);
var data = new RestsReportDataDto { Lines = mapper.Map<ICollection<ReportRestLineDto>>(lines) };
var result = new RestsReportResult { Filters = filters, Data = data };
result.Data.GeneratedAt = DateTime.UtcNow;
result.Data.DateAsOf = filters.DateAsOf;
result.Data.GeneratedByUserName = await PrintHeaderUser.TryGetNameAsync(lookup, authorizationChecker, ct);
if (filters.WarehouseId?.Any() == true)
result.Data.Warehouses = await lookup.GetDictionaryAsync<Warehouse>(filters.WarehouseId, ct);
if (filters.ItemId?.Any() == true)
result.Data.Items = await lookup.GetDictionaryAsync<ItemNode>(filters.ItemId, ct);
return result;
}
A filter that holds only ids (a Guid[] of warehouses or items) must not print raw Guids.
GetDictionaryAsync resolves them to CodeNameRef records, and declaring the print property as
IReadOnlyCollection<CodeNameRef>? makes the header show a joined "Code - Name" list of exactly
what was filtered:
[PrintSectionRef("Header")] [Caption("Warehouse")]
public IReadOnlyCollection<CodeNameRef>? Warehouses { get; set; }
As on the document page, a
hierarchical dictionary (Item, Counterparty) is looked up by its root type (ItemNode).
Totals
Put [Totals(Aggregate.Sum)] on each numeric line column you want summed. The PDF renders a bold
totals row; the on-screen grid's own grouping/aggregation is separate ([Aggregate], covered in
Creating a report) and is not affected by [Totals].
CSV versus PDF
| CSV | ||
|---|---|---|
The [PrintTable] rows and columns | Yes | Yes (repeating column headers on long results) |
| Header section (generated-at/by, resolved filter names) | No | Yes |
| Footer section | No | Yes |
[Totals] row | No | Yes |
| Title | No | Yes |
By design the CSV renderer ignores every flow and fixed section, so the resolved filter names in the print header are PDF-only. If a downstream consumer needs the filters, put them in a column, not the header.
Verify
dotnet buildand inspect the generated renderers forTData.- Run the report and click both export buttons. Confirm the CSV holds only the table columns, and the PDF has the title, the header with the resolved filter names, the table, a bold totals row, and the footer.
- Export with a warehouse/item filter set and confirm the header shows names, not Guids — and check the no-filter case too, since the header fields are only filled when a filter is set.
- Watch the server log: a wrong lookup type (a hierarchical leaf instead of its root) throws at request time, not at build time.
Gotchas
[SupportedPrintForm]must nameTData. The envelope carries no[PrintForm], so it has no renderer to point at.- Pass
report.Datato the renderer, notreport. - Header fields must be declared before the
[PrintTable]property to render as a header; declared after, they become a footer. - The filename is yours. The renderer supplies only the extension.
- CSV drops the header. Don't rely on a filter summary reaching a CSV.
See also
- Print forms — the attribute vocabulary and shared header fields.
- Document print forms — the document flavor of the same mechanism.
- Creating a report — a whole report, including the export wiring above.
- Reference: Services.