TX Text Control .NET for WPF

Nested Blocks and Master-Detail Reports

Topic

A report of orders with their articles or of departments with their employees needs a template with levels: an outer merge block that is repeated for each master record and, inside it, an inner block for the detail records of the current master. Such master-detail reports are built from the same merge blocks as a simple invoice. You shape the nested data, design the levels in the WPF Reporting tab or in code, check the hierarchy of a template, merge JSON, objects or a DataSet, and decide what happens to levels without data.

Merge Blocks describes blocks at one level, and Add Repeating Merge Blocks the first block of the invoice. The handlers below go into MainWindow.xaml.cs of the same WPF application, next to the TextControl textControl1; add a button to MainWindow.xaml for each, with its Click attribute set to the handler's name.

How Nested Blocks Work

The master-detail relationship is one-to-many: one article group has many articles. In this template, the whole table is the outer block for the groups, and its last row, highlighted darker, is the inner block for the articles:

A template with an outer merge block around a table for article groups and an inner merge block in its last row for the articles.

The merge creates a table for each group, with the articles of that group:

The merged document with a table for the article group Vegetables and one for Fruits, each listing its articles with description and price.

  • Each block is named like its data, the array, collection or table that holds its records.
  • Fields take the values of the record of their block. article_name in the inner block is the name of the current article; fields of the group belong outside it.
  • An inner block lies completely inside its outer block. Blocks can't overlap, but two blocks may start or end at the same position.
  • Blocks nest to any depth, because MailMerge processes the hierarchy recursively.

In a DataSet, a relation between the tables connects the levels, here the column article_group_id:

Two tables, Group and Article, connected by a relation from article_group_id in Article to article_group_id in Group.

Shape the Nested Data

In JSON, a nested array becomes a nested block. Add orders.json to the project, with Copy to Output Directory set to Copy if newer. It holds a company with two orders, each with its articles:

[
  {
    "company_name": "Text Control, LLC",
    "orders": [
      {
        "id": 123,
        "articles": [
          { "id": 1, "product": { "name": "Product A", "price": 200 }, "qty": 5 }
        ]
      },
      {
        "id": 321,
        "articles": [
          { "id": 2, "product": { "name": "Product B", "price": 50 }, "qty": 12 }
        ]
      }
    ]
  }
]

The template for this data has three levels:

LevelTemplate elementFields
RootMerge fields outside all blockscompany_name
1Merge block ordersid
2Merge block articles, inside ordersproduct.name, qty

The field product.name addresses the nested product object of an article with dot notation; Merge Fields explains the notation.

Design Nested Blocks in the Reporting Tab

In the WPF Reporting tab, users insert the levels from the outside in, with the data loaded into the tab:

  1. Insert the outer block, orders, from the Insert Merge Block drop-down, for example as a table row with a header row.
  2. Place the input position inside that block, for example in a row added to its table, and insert the inner block, articles.
  3. Open the field navigator with Field Navigation. Its Merge Blocks tab shows the blocks as a tree, the inner block below the outer one.

The field navigator needs a Sidebar connected to the tab, as Create a Template sets up. The blog article Creating Nested Repeating Blocks with Version X13 (23.0) builds a nested template step by step in TX Words.

Create Nested Blocks in Code

The blocks inside a block are listed in MergeBlockInfo.ChildBlocks. InsertMergeBlock inserts a block with all its child blocks in one call. The handler needs using System.Collections.Generic;, using System.IO; and using TXTextControl.DocumentServer.DataSources;:

private void createOrdersTemplateButton_Click(object sender, RoutedEventArgs e)
{
    string jsonData = File.ReadAllText("orders.json");

    // A DataSourceManager of its own, so the Reporting tab keeps its data
    DataSourceManager dataSourceManager = new DataSourceManager();
    dataSourceManager.LoadJson(jsonData);

    // Outer block: repeated for each order
    MergeBlockInfo ordersBlock = new MergeBlockInfo("orders")
    {
        ColumnNames = new List<string> { "id" },

        // Inner block: repeated for each article of the current order
        ChildBlocks = new List<MergeBlockInfo>
        {
            new MergeBlockInfo("articles")
            {
                ColumnNames = new List<string> { "product.name", "qty" }
            }
        }
    };

    dataSourceManager.InsertMergeBlock(textControl1, ordersBlock,
        new MergeBlockSettings(BlockTemplateType.TableRow, true));

    textControl1.Save("orders-template.tx", StreamType.InternalUnicodeFormat);
}

The code works as follows:

  • LoadJson reads the structure of the data, nested arrays included.
  • Each MergeBlockInfo is one level, with its name and its merge fields in ColumnNames.
  • InsertMergeBlock creates the tables, fields and blocks of both levels at the input position of the WPF TextControl.
  • The template is saved as orders-template.tx, which the following examples merge.

Check the Block Hierarchy

A MergeBlockInfo read from an existing block contains its child blocks. The following handler walks the tree and shows it indented; it needs using System.Text;:

private void showHierarchyButton_Click(object sender, RoutedEventArgs e)
{
    StringBuilder report = new StringBuilder();

    foreach (SubTextPart subTextPart in textControl1.SubTextParts)
    {
        // Start with the merge blocks at the top level
        if (DataSourceManager.IsMergeBlock(subTextPart) && subTextPart.NestedLevel == 1)
        {
            AppendBlock(report, new MergeBlockInfo(subTextPart), 0);
        }
    }

    MessageBox.Show(this, report.ToString(), "Block hierarchy");
}

// Adds a block and, indented, its child blocks
private static void AppendBlock(StringBuilder report, MergeBlockInfo block, int depth)
{
    report.AppendLine(new string(' ', depth * 4) + block.TableName);

    if (block.ChildBlocks == null)
    {
        return;
    }

    foreach (MergeBlockInfo child in block.ChildBlocks)
    {
        AppendBlock(report, child, depth + 1);
    }
}

SubTextPart.NestedLevel is 1 for the blocks at the top level, and TableName is the name of a block's data. With the orders template in the editor, the message box lists orders and, indented below it, articles.

Merge Nested Business Objects

With business objects, a collection property fills the block of the same name on every level. Add these classes to the project:

using System.Collections.Generic;

public class Company
{
    public string company_name { get; set; }
    public List<Order> orders { get; set; } = new List<Order>();
}

public class Order
{
    public int id { get; set; }
    public List<Article> articles { get; set; } = new List<Article>();
}

public class Article
{
    public int id { get; set; }
    public Product product { get; set; }
    public int qty { get; set; }
}

public class Product
{
    public string name { get; set; }
    public decimal price { get; set; }
}

This handler merges a company into the template in the editor; it needs using TXTextControl.DocumentServer;:

private void mergeOrdersButton_Click(object sender, RoutedEventArgs e)
{
    Company company = new Company { company_name = "Text Control, LLC" };

    Order order = new Order { id = 123 };
    order.articles.Add(new Article { id = 1, qty = 5, product = new Product { name = "Product A", price = 200 } });
    company.orders.Add(order);

    using (MailMerge mailMerge = new MailMerge { TextComponent = textControl1 })
    {
        // The template in the editor is replaced with the merged document
        mailMerge.MergeObject(company);
    }
}

MergeObject merges one object and MergeObjects a collection. The rules for business objects are in Objects and Collections.

Merge a DataSet With Relations

A DataSet defines its levels with relations: each block is named like a DataTable, and a DataRelation connects the table to the table of the outer block. Merge with the master table follows the relations. This handler merges orders-template.tx in the background and saves a PDF; it needs using System.Data;:

private void mergeDataSetButton_Click(object sender, RoutedEventArgs e)
{
    DataSet dataSet = new DataSet();

    DataTable company = dataSet.Tables.Add("company");
    company.Columns.Add("company_id", typeof(int));
    company.Columns.Add("company_name");
    company.Rows.Add(1, "Text Control, LLC");

    DataTable orders = dataSet.Tables.Add("orders");
    orders.Columns.Add("company_id", typeof(int));
    orders.Columns.Add("id", typeof(int));
    orders.Rows.Add(1, 123);
    orders.Rows.Add(1, 321);

    DataTable articles = dataSet.Tables.Add("articles");
    articles.Columns.Add("order_id", typeof(int));
    articles.Columns.Add("product_id", typeof(int));
    articles.Columns.Add("qty", typeof(int));
    articles.Rows.Add(123, 1, 5);
    articles.Rows.Add(321, 2, 12);

    DataTable product = dataSet.Tables.Add("product");
    product.Columns.Add("product_id", typeof(int));
    product.Columns.Add("name");
    product.Columns.Add("price", typeof(decimal));
    product.Rows.Add(1, "Product A", 200m);
    product.Rows.Add(2, "Product B", 50m);

    // The relations connect each block with the rows of its parent
    dataSet.Relations.Add(new DataRelation("company_orders",
        company.Columns["company_id"], orders.Columns["company_id"]));
    dataSet.Relations.Add(new DataRelation("order_articles",
        orders.Columns["id"], articles.Columns["order_id"]));

    // Fields with the prefix product. reach the product of an article
    dataSet.Relations.Add(new DataRelation("article_product",
        articles.Columns["product_id"], product.Columns["product_id"], false));

    using (ServerTextControl tx = new ServerTextControl())
    {
        tx.Create();
        tx.Load("orders-template.tx", StreamType.InternalUnicodeFormat);

        using (MailMerge mailMerge = new MailMerge { TextComponent = tx })
        {
            // Merge the master table; the related tables fill the nested blocks
            mailMerge.Merge(company, true);
        }

        tx.Save("orders.pdf", StreamType.AdobePDF);
    }
}

The code works as follows:

  • The relations company_orders and order_articles connect each block with the rows of its parent, and the tables are named like the blocks orders and articles.
  • Inside the articles block, qty has no prefix, because it is a column of the block's own table. product.name has the prefix product: a prefix inside a block starts at the table of the block and follows the relation article_product to the product row of the current article, so the field shows "Product A" in order 123 and "Product B" in order 321.
  • false, the last parameter of the DataRelation constructor, creates the relation without constraints. With constraints, the DataSet would require a unique product_id in articles, and two articles of the same product couldn't be added.

Longer prefixes follow more than one relation, as the multi-level example in Merge Fields shows. Edit Data Relations in the Data Source group of the Reporting tab lets users add or remove the relations of the loaded data source; DataSets and DataTables covers relations in depth.

Repeat Table Headers per Block

A header row repeats at the top of each page with its first content. If an inner table row block sits below a header that shows data of the outer block, such as the order number, every page shows the first order. Make the whole table, header row included, part of the outer block, and each order gets a table with a header of its own. The blog article MailMerge: Table Headers and Repeating Blocks shows both layouts.

Handle Levels Without Data

RemoveEmptyBlocks is true by default and applies to all levels: an order without articles keeps its orders block and loses its articles block, and an outer block without data is removed together with the blocks inside it.

A BlockMergingCondition on the inner block merges it only for parent records that match, for example orders above an amount. Sorting and Filtering sets one.

Next Steps