Document print forms
A document's print form is a separate Dto built from a saved document and rendered on demand —
for example the printable goods-receipt sheet behind a Print button. It uses the same
vocabulary as every other print form, so read Print forms first for the attributes
([PrintForm], [PrintSection], [PrintTable], [Totals], …); this page covers only what is
specific to documents.
The worked example is Kandra's own GoodsReceipt: GoodsReceiptPrintForm in
Forms/Documents/GoodsReceipt.cs plus GoodsReceiptBehavior.OnPrintAsync. Transfer and
Waybill are the same pattern.
Prefer the create-print-form skill over hand-rolling this. Creating a document
shows a trimmed version of the same wiring in the context of a whole document; this page is the
full treatment.
What you write
Three things, all on the server-and-shared planes. There is nothing to write on the client: no Refit method, no controller action and no Print button are hand-written (see What is generated).
| Piece | Project | Where |
|---|---|---|
| The print-form Dto | Acme.Forms | Same file as the document's Dto |
[SupportedPrintForm] + [SupportedExportFormats] | Acme.Forms | On the document's Dto |
OnPrintAsync | Acme.Application | The document's Behavior |
1. Declare the form on the document Dto
The document Dto tells the engine which print form belongs to it and which formats it offers:
[KandraDocumentForm(Name = "GoodsReceipts", ValidatorType = typeof(GoodsReceiptValidator), QueryDtoType = typeof(GoodsReceiptQueryDto))]
[SupportedExportFormats(ExportFormat.Pdf)]
[SupportedPrintForm(typeof(GoodsReceiptPrintForm))]
public class GoodsReceiptDto : DocumentDto { /* ... */ }
[SupportedPrintForm] is what makes the generated Print button appear, and
[SupportedExportFormats] is the declared set of formats. Documents are declared PDF-only by
default; the engine itself does not restrict them to it, so widening the declaration to
ExportFormat.Pdf | ExportFormat.Csv is enough to expose CSV for that document. (The generated
Print button always requests the default — PDF, default layout. A format picker is not built.)
2. Write the print-form Dto
Same file, right after the document Dto. Extend DocumentPrintHeaderData if you have adopted
the shared header base (see Shared header fields) — it brings
generated-at, generated-by, and the Submitted/Draft flag:
[PrintForm]
[PrintTitle("GoodsReceipt")]
[Page(Size = PageSize.A4, Orientation = PageOrientation.Portrait, MarginsMm = new[] { 15, 10, 15, 10 })]
[PrintStyle("Number", Align = TextAlign.Right)]
[PrintSection("Header", Columns = 2)]
public sealed class GoodsReceiptPrintForm : DocumentPrintHeaderData
{
[PrintSectionRef("Header")] [Caption("Code")]
public string Code { get; set; } = string.Empty;
[PrintSectionRef("Header")] [Caption("Date")] [Format("d")]
public DateTime Date { get; set; }
[PrintSectionRef("Header")] [Caption("Counterparty")]
public string CounterpartyName { get; set; } = string.Empty;
[Required] [PrintTable]
public required ICollection<GoodsReceiptPrintLine> Lines { get; set; }
}
Note the print form holds display names (CounterpartyName), not ids: it is a rendering
model, not a mirror of the entity. That is why the Behavior below has a lookup step.
3. Override OnPrintAsync in the Behavior
IDocumentBehavior<TEntity> has a default OnPrintAsync that returns null, meaning "this
document has no print form". Override it to build the form and render it:
public async ValueTask<PrintRenderOutput?> OnPrintAsync(GoodsReceipt entity, ExportFormat format,
string? layoutTag, ILookupRepository lookup, IPrintRendererResolver renderer,
IStringLocalizer localizer, TimeZoneInfo? timeZone, CancellationToken cancellationToken)
{
var counterparties = await lookup.GetDictionaryAsync<CounterpartyNode>([entity.CounterpartyId], cancellationToken);
var warehouses = await lookup.GetDictionaryAsync<Warehouse>([entity.ToWarehouseId], cancellationToken);
var itemIds = entity.Lines.Select(l => l.ItemId).Distinct().ToList();
var itemNames = (await lookup.GetDictionaryAsync<ItemNode>(itemIds, cancellationToken))
.ToDictionary(i => i.Id, i => i.Name);
var printForm = new GoodsReceiptPrintForm
{
GeneratedAt = DateTime.UtcNow,
GeneratedByUserName = await PrintHeaderUser.TryGetNameAsync(lookup, authorizationChecker, cancellationToken),
IsSubmitted = entity.IsActive,
Code = entity.Code,
Date = entity.Date,
CounterpartyName = counterparties.Count > 0 ? counterparties[0].Name : string.Empty,
Lines = entity.Lines.Select(line => new GoodsReceiptPrintLine
{
ItemName = itemNames.TryGetValue(line.ItemId, out var name) ? name : string.Empty,
Quantity = line.Quantity,
Price = line.Price,
Sum = line.Sum,
}).ToList(),
};
return renderer.Render(printForm, localizer, timeZone, format, layoutTag ?? "default");
}
Everything except the last line is ordinary data shaping. The parameters format, layoutTag,
lookup, renderer, localizer and timeZone are all handed to you by the service layer;
only authorizationChecker (used for the "generated by" name) is a normal constructor
dependency of the Behavior — add it if the Behavior doesn't already have it.
Resolve display names through ILookupRepository
Two rules explain the lookup block above, and both are easy to get wrong:
- Use the lookup service, not navigation properties. The CRUD service loads a document with
only its
[KandraTablePart]row navigations.entity.Counterparty,entity.ToWarehouseandline.Itemare not loaded whenOnPrintAsyncruns, so read throughlookup.GetDictionaryAsync<T>(ids, ct)instead. - A hierarchical dictionary is looked up by its root type.
CounterpartyandItemare leaves of a hierarchy, and the repository behindGetDictionaryAsyncis only DI-registered for the hierarchy's root — so it'sGetDictionaryAsync<CounterpartyNode>andGetDictionaryAsync<ItemNode>. Asking for the leaf compiles fine and then fails at request time with "No service for type …". If in doubt, check which type the generated persistence DI file registers.
GetDictionaryAsync has a second overload taking a Func<IQueryable<T>, IQueryable<T>> for
filters beyond "these ids" (active-only, a name prefix).
Set the header fields
GeneratedAt should be DateTime.UtcNow — the renderer converts it to the reader's local time
zone using the timeZone parameter. IsSubmitted is entity.IsActive: for a document,
"active" is the submitted flag; there is no separate concept.
What is generated
The Print button and everything behind it come from the document Dto's attributes:
- The endpoint —
POST print/{id}on the document's controller, with aDocumentExportRequestbody carrying an optionalFormatandLayoutTag. A missing format defaults to PDF; the service loads the document by id, resolves the caller's time zone once, and calls your hook. - The client plumbing — the Refit/service
Print(id)call and the button itself, on the document's Update and View pages. It is deliberately absent from Add: an unsaved document has no id to print. It opens the result in a new browser tab rather than downloading it. - The gating — the button is generated only when the Dto carries
[SupportedPrintForm]. A document without one getsonPrint: nulland no button at all. This is deliberate: a Print button wired to a document whoseOnPrintAsyncstill returns the defaultnullwould fail silently with a 404 and no visible error.
Verify
dotnet build, then open the generatedGoodsReceiptPrintFormPdfRendererunderAcme.Application/Generated.Net/and confirm every header field and column you expect is emitted.- Open a saved document's Update or View page and click Print: a PDF opens in a new tab (not a download). Confirm the button is absent on the Add page.
- Save, don't submit, and print again: the header should say Draft; after submitting it should say Submitted.
- Post to the endpoint directly with
{"format":"Csv"}and confirm CSV content comes back only if you widened[SupportedExportFormats]to include it; with{"format":"Excel"}(or alayoutTagthat has no registered renderer) you should get a clean 404 (PrintRendererNotFoundException), not a raw 500. - Watch the server log while doing all of the above — a wrong lookup type throws at request time.
Gotchas
- The print form is not the entity. Don't map the entity onto it with AutoMapper hoping navigations will fill in; build it by hand from lookups, as above.
OnPrintAsyncruns on any saved document, submitted or not. Print a Draft/Submitted marker if the distinction matters to the reader; don't assume submitted.- A missing
[SupportedPrintForm]means no button, not an error. If your Print button doesn't appear, that attribute is the first thing to check. - Authorization is the document's View permission. Printing needs no extra permission of its own, and a soft-deleted document returns 404.
- The lines are already loaded. The print endpoint loads the document with its child rows
(
includeChildren: true), soentity.Linesis populated — but nothing else is, which is why the lookups above exist. DateTimecolumns: see the note in Print forms.
See also
- Print forms — the attribute vocabulary, shared header fields and gotchas common to documents and reports.
- Report export — the report flavor of the same mechanism.
- Creating a document — where
OnPrintAsyncsits among the other Behavior hooks. - Reference: Services.