Skip to main content

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.

Use the shipped skill

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] at TData, 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. GeneratedAt and GeneratedByUserName are on the TData class (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 format parameter, so the one method serves both buttons. The default Export returns null, meaning "this report doesn't export"; the buttons that appear are decided by [SupportedExportFormats].
  • layoutTag is the resolver's DI key. null means 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 second IPrintRenderer<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

CSVPDF
The [PrintTable] rows and columnsYesYes (repeating column headers on long results)
Header section (generated-at/by, resolved filter names)NoYes
Footer sectionNoYes
[Totals] rowNoYes
TitleNoYes

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

  1. dotnet build and inspect the generated renderers for TData.
  2. 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.
  3. 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.
  4. 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 name TData. The envelope carries no [PrintForm], so it has no renderer to point at.
  • Pass report.Data to the renderer, not report.
  • 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