Building a Complete Page

This walkthrough builds a full Customer edit page step by step, covering route setup, graph definition, DataSet creation, layout with DataGraph and PropertyComponent, and conditional rendering.

Entity labels in DataGraphPage

Benevia.Core.Blazor.Components.DataGraphPage.DataGraphPage provides a reusable page shell. Its optional Label parameter overrides the model's entity label. Without an override, the page uses the server metadata label, falling back to the entity name formatted as words. The read-only EntityLabel property exposes that resolved value to page-owned headings and dialogs.

Define business names with [Display(Name = "...")] on the server model rather than repeating them on each page. Record titles still take precedence in the page title and deletion confirmation toast; the entity label is the fallback when a record has no title. See entity labels.

Saving a new record in DataGraphPage

A page opened for a new record lives at {EntityRouteBase}/new. The first Save the user asks for, with the Save button or Ctrl+S, replaces that address with {EntityRouteBase}/{guid}?drawer=bypass, so a reload or Back lands on the record instead of a fresh draft. A save made through the leave-page dialog leaves the address alone, because the navigation that raised the dialog is already under way.

A page hosted in a drawer cannot change the address, which belongs to the page under it. After every successful save, the dialog's included, it calls DrawerService.NotifyEntitySaved(drawerId, EntityRouteBase, guid), which raises DrawerService.EntitySaved. A host that keys open drawers by route listens for it and moves a drawer opened for a new record to the record's route in whatever it restores drawers from, such as an address parameter or a history entry. A host that commits a document outside the page's Save, through the workflow directly, calls NotifyEntitySaved itself.

Standard deletion in DataGraphPage

An existing entity automatically gets a delete item in the page's actions menu when the current user has CreateAndDelete access. DataGraphPage asks for confirmation, deletes through the page's workflow-aware DataSet, shows the success or failure message, notifies matching Core list views, and then follows the same back or drawer-close path as the page's back button. New entities and users without delete permission do not see the action.

The page can keep custom menu items in its Menu fragment; the standard delete item is appended to them. Use CanDelete only for record-specific business visibility, such as protecting a standard record or a finalized document:

<DataGraphPage MainGraph="CreateGraph"
               EntityGuid="@CustomerGuid"
               EntityRouteBase="/customers"
               CanDelete="CanDeleteCustomer">
    <Menu Context="dataSet">
        <MudMenuItem OnClick="@(() => CopyAsync(dataSet))">Copy</MudMenuItem>
    </Menu>
</DataGraphPage>

@code {
    private static bool CanDeleteCustomer(IDataSet dataSet) =>
        !dataSet.Get<bool>("IsDefaultEntity");
}

Use OnDeleted for exceptional page-specific cleanup that must run after deletion but before the standard list notification and navigation. Ordinary pages need neither parameter. The public DeleteAsync() method remains available for programmatic deletion; callers using it directly own confirmation, notification, and navigation.

1. Define the Route

A typical entity page has two routes — one for editing an existing entity by GUID and one for creating a new entity:

@page "/customers/{CustomerGuid:guid}"
@page "/customers/new"

When CustomerGuid is null (the /new route), DataGraph calls DataSet.CreateAsync() to create a new Customer on the server. When it has a value, DataGraph calls DataSet.LoadAsync(guid).

2. Define the Graph

The graph declares every property path the page will read or write. Define it as a static readonly field in the code-behind:

private static GraphBuilder BuildGraph() => new GraphBuilder("Customer")
    .Property("Id")
    .Property("FullName")
    .Reference("BillingCustomer")
    .Navigation("PrimaryContact", pc => pc
        .Property("CompanyName")
        .Property("PrimaryEmail")
        .Property("SecondaryEmail")
        .Property("PrimaryPhone")
        .Property("SecondaryPhone")
        .Property("Website")
        .Property("Note")
        .Property("IsMailingAddressSameAsPhysical")
        .Navigation("MailingAddress", ma => ma
            .Property("Street")
            .Property("City")
            .Property("State")
            .Property("PostalCode")
            .Reference("Country"))
        .Navigation("PhysicalAddress", pa => pa
            .Property("Street")
            .Property("City")
            .Property("State")
            .Property("PostalCode")
            .Reference("Country")));

See GRAPHS.md for details on the GraphBuilder API.

3. Create the DataSet

The DataSet connects the graph to the server via IODataClient. Create it in OnInitialized:

@inject IODataClient OData

private DataSet _dataSet = null!;

protected override void OnInitialized()
    => _dataSet = new DataSet(OData, BuildGraph().Build());

See DATASETS.md for the full DataSet API.

4. Build the Layout with DataGraph

DataGraph is the root container. It initializes metadata, loads or creates the entity, and cascades the editing context to all PropertyComponent children:

<DataGraph Builder="@BuildGraph" DataSet="@_dataSet" EntityGuid="@CustomerGuid">
    @* All PropertyComponent instances go here *@
</DataGraph>

DataGraph shows a loading spinner until the entity is initialized, then renders child content.

5. Add PropertyComponents

Place PropertyComponent instances inside DataGraph. Each one renders the right control based on the property's server metadata:

<DataGraph Builder="@BuildGraph" DataSet="@_dataSet" EntityGuid="@CustomerGuid">
    <MudStack Spacing="4">
        <MudPaper Class="pa-4 rounded-lg" Elevation="2">
            <MudText Typo="Typo.h6" Class="mb-4">Profile</MudText>
            <MudGrid Spacing="3">
                <MudItem xs="12" sm="6">
                    <PropertyComponent Path="Id" />
                </MudItem>
                <MudItem xs="12" sm="6">
                    <PropertyComponent Path="FullName" />
                </MudItem>
                <MudItem xs="12" sm="6">
                    <PropertyComponent Path="BillingCustomer" />
                </MudItem>
                <MudItem xs="12" sm="6">
                    <PropertyComponent Path="PrimaryContact.CompanyName" />
                </MudItem>
            </MudGrid>
        </MudPaper>
    </MudStack>
</DataGraph>

PropertyComponent determines automatically that:

  • Id is a text field (data type IdText)
  • FullName is a read-only text field (virtual property)
  • BillingCustomer is a reference → renders an entity selector
  • CompanyName is a proper noun text field

See PROPERTY_COMPONENTS.md for all built-in mappings and customization options.

6. Organize into Sections

Use MudBlazor layout components to group related properties into visual sections:

@* Contact Section *@
<MudPaper Class="pa-4 rounded-lg" Elevation="2">
    <MudText Typo="Typo.h6" Class="mb-4">Contact</MudText>
    <MudGrid Spacing="3">
        <MudItem xs="12" sm="6">
            <PropertyComponent Path="PrimaryContact.PrimaryEmail" />
        </MudItem>
        <MudItem xs="12" sm="6">
            <PropertyComponent Path="PrimaryContact.SecondaryEmail" />
        </MudItem>
        <MudItem xs="12" sm="6">
            <PropertyComponent Path="PrimaryContact.PrimaryPhone" />
        </MudItem>
        <MudItem xs="12" sm="6">
            <PropertyComponent Path="PrimaryContact.SecondaryPhone" />
        </MudItem>
        <MudItem xs="12">
            <PropertyComponent Path="PrimaryContact.Note" />
        </MudItem>
    </MudGrid>
</MudPaper>

The xs and sm attributes control responsive layout — 6 of 12 columns means two fields per row on small screens and above.

7. Conditional Rendering with DataSet.Changed

Some UI sections should show or hide based on property values. For example, the physical address should only appear when IsMailingAddressSameAsPhysical is false.

To react to property changes, subscribe to the DataSet.Changed event:

private void HandleDataSetChanged(object? sender, IDataSet.ChangedArgs args)
{
    if (args.AffectsPath("PrimaryContact.IsMailingAddressSameAsPhysical"))
    {
        InvokeAsync(StateHasChanged);
    }
}

In the template, subscribe to the event and conditionally render:

<MudPaper Class="pa-4 rounded-lg" Elevation="2">
    <MudStack Row="true" AlignItems="AlignItems.Center"
              Justify="Justify.SpaceBetween" Class="mb-4">
        <MudText Typo="Typo.h6">Physical Address</MudText>
        <PropertyComponent Path="PrimaryContact.IsMailingAddressSameAsPhysical" />
    </MudStack>

    @if (!_dataSet.Get<bool>("PrimaryContact.IsMailingAddressSameAsPhysical"))
    {
        <MudGrid Spacing="3">
            <MudItem xs="12">
                <PropertyComponent Path="PrimaryContact.PhysicalAddress.Street" />
            </MudItem>
            <MudItem xs="12" sm="6">
                <PropertyComponent Path="PrimaryContact.PhysicalAddress.City" />
            </MudItem>
            <MudItem xs="12" sm="3">
                <PropertyComponent Path="PrimaryContact.PhysicalAddress.State" />
            </MudItem>
            <MudItem xs="12" sm="3">
                <PropertyComponent Path="PrimaryContact.PhysicalAddress.PostalCode" />
            </MudItem>
            <MudItem xs="12" sm="6">
                <PropertyComponent Path="PrimaryContact.PhysicalAddress.Country" />
            </MudItem>
        </MudGrid>
    }
</MudPaper>

When the user toggles the checkbox, SetAsync tells the server, the Changed event fires, HandleDataSetChanged triggers StateHasChanged, and the physical address fields appear or disappear.

Complete Example

CustomerPage.razor

@page "/customers/{CustomerGuid:guid}"
@page "/customers/new"
@using Benevia.Core.Blazor
@using Benevia.Core.Blazor.DynamicComponents
@using Benevia.Core.Client.DataSets
@using Benevia.Core.Client.GraphDefinitions
@using Benevia.Core.Client.GraphDefinitions.Builders
@using Benevia.Core.Client.OData
@using MudBlazor
@inject IODataClient OData

<DataGraph Builder="@BuildGraph" DataSet="@_dataSet" EntityGuid="@CustomerGuid">
    @{
        _dataSet.Changed -= HandleDataSetChanged;
        _dataSet.Changed += HandleDataSetChanged;
    }
    <MudStack Spacing="4">
        @* Profile Section *@
        <MudPaper Class="pa-4 rounded-lg" Elevation="2">
            <MudText Typo="Typo.h6" Class="mb-4">Profile</MudText>
            <MudGrid Spacing="3">
                <MudItem xs="12" sm="6">
                    <PropertyComponent Path="Id" />
                </MudItem>
                <MudItem xs="12" sm="6">
                    <PropertyComponent Path="FullName" />
                </MudItem>
                <MudItem xs="12" sm="6">
                    <PropertyComponent Path="BillingCustomer" />
                </MudItem>
                <MudItem xs="12" sm="6">
                    <PropertyComponent Path="PrimaryContact.CompanyName" />
                </MudItem>
                <MudItem xs="12" sm="6">
                    <PropertyComponent Path="PrimaryContact.Website" />
                </MudItem>
            </MudGrid>
        </MudPaper>

        @* Contact Section *@
        <MudPaper Class="pa-4 rounded-lg" Elevation="2">
            <MudText Typo="Typo.h6" Class="mb-4">Contact</MudText>
            <MudGrid Spacing="3">
                <MudItem xs="12" sm="6">
                    <PropertyComponent Path="PrimaryContact.PrimaryEmail" />
                </MudItem>
                <MudItem xs="12" sm="6">
                    <PropertyComponent Path="PrimaryContact.SecondaryEmail" />
                </MudItem>
                <MudItem xs="12" sm="6">
                    <PropertyComponent Path="PrimaryContact.PrimaryPhone" />
                </MudItem>
                <MudItem xs="12" sm="6">
                    <PropertyComponent Path="PrimaryContact.SecondaryPhone" />
                </MudItem>
                <MudItem xs="12">
                    <PropertyComponent Path="PrimaryContact.Note" />
                </MudItem>
            </MudGrid>
        </MudPaper>

        @* Mailing Address Section *@
        <MudPaper Class="pa-4 rounded-lg" Elevation="2">
            <MudText Typo="Typo.h6" Class="mb-4">Mailing Address</MudText>
            <MudGrid Spacing="3">
                <MudItem xs="12">
                    <PropertyComponent Path="PrimaryContact.MailingAddress.Street" />
                </MudItem>
                <MudItem xs="12" sm="6">
                    <PropertyComponent Path="PrimaryContact.MailingAddress.City" />
                </MudItem>
                <MudItem xs="12" sm="3">
                    <PropertyComponent Path="PrimaryContact.MailingAddress.State" />
                </MudItem>
                <MudItem xs="12" sm="3">
                    <PropertyComponent Path="PrimaryContact.MailingAddress.PostalCode" />
                </MudItem>
                <MudItem xs="12" sm="6">
                    <PropertyComponent Path="PrimaryContact.MailingAddress.Country" />
                </MudItem>
            </MudGrid>
        </MudPaper>

        @* Physical Address Section *@
        <MudPaper Class="pa-4 rounded-lg" Elevation="2">
            <MudStack Row="true" AlignItems="AlignItems.Center"
                      Justify="Justify.SpaceBetween" Class="mb-4">
                <MudText Typo="Typo.h6">Physical Address</MudText>
                <PropertyComponent Path="PrimaryContact.IsMailingAddressSameAsPhysical" />
            </MudStack>

            @if (!_dataSet.Get<bool>("PrimaryContact.IsMailingAddressSameAsPhysical"))
            {
                <MudGrid Spacing="3">
                    <MudItem xs="12">
                        <PropertyComponent Path="PrimaryContact.PhysicalAddress.Street" />
                    </MudItem>
                    <MudItem xs="12" sm="6">
                        <PropertyComponent Path="PrimaryContact.PhysicalAddress.City" />
                    </MudItem>
                    <MudItem xs="12" sm="3">
                        <PropertyComponent Path="PrimaryContact.PhysicalAddress.State" />
                    </MudItem>
                    <MudItem xs="12" sm="3">
                        <PropertyComponent Path="PrimaryContact.PhysicalAddress.PostalCode" />
                    </MudItem>
                    <MudItem xs="12" sm="6">
                        <PropertyComponent Path="PrimaryContact.PhysicalAddress.Country" />
                    </MudItem>
                </MudGrid>
            }
        </MudPaper>
    </MudStack>
</DataGraph>

CustomerPage.razor.cs

using Benevia.Core.Client.DataSets;
using Benevia.Core.Client.GraphDefinitions;
using Benevia.Core.Client.GraphDefinitions.Builders;
using Benevia.Core.Client.OData;
using Microsoft.AspNetCore.Components;

namespace MyApp.Pages.Customers;

public partial class CustomerPage : ComponentBase
{
    [Parameter] public Guid? CustomerGuid { get; set; }

    [Inject] public required IODataClient OData { get; set; }

    private DataSet _dataSet = null!;

    private static GraphBuilder BuildGraph() => new GraphBuilder("Customer")
        .Property("Id")
        .Property("FullName")
        .Reference("BillingCustomer")
        .Navigation("PrimaryContact", pc => pc
            .Property("CompanyName")
            .Property("PrimaryEmail")
            .Property("SecondaryEmail")
            .Property("PrimaryPhone")
            .Property("SecondaryPhone")
            .Property("Website")
            .Property("Note")
            .Property("IsMailingAddressSameAsPhysical")
            .Navigation("MailingAddress", ma => ma
                .Property("Street")
                .Property("City")
                .Property("State")
                .Property("PostalCode")
                .Reference("Country"))
            .Navigation("PhysicalAddress", pa => pa
                .Property("Street")
                .Property("City")
                .Property("State")
                .Property("PostalCode")
                .Reference("Country")));

    protected override void OnInitialized()
        => _dataSet = new DataSet(OData, BuildGraph().Build());

    private void HandleDataSetChanged(object? sender, IDataSet.ChangedArgs args)
    {
        if (args.AffectsPath("PrimaryContact.IsMailingAddressSameAsPhysical"))
        {
            InvokeAsync(StateHasChanged);
        }
    }
}

Key Patterns

View vs. Edit Mode

Pass DisplayMode to DataGraph to switch between editing and read-only display:

<DataGraph Builder="@BuildGraph" DataSet="@_dataSet" EntityGuid="@guid"
           DisplayMode="DisplayMode.View">
    <PropertyComponent Path="Id" />          @* Renders as text *@
    <PropertyComponent Path="PrimaryContact.PrimaryEmail" /> @* Renders as mailto link *@
</DataGraph>

Separate Graphs for View and Edit

If your view mode shows fewer properties, define a slimmer graph to reduce server load:

private static GraphBuilder BuildViewGraph() => new GraphBuilder("Customer")
    .Property("Id")
    .Property("FullName")
    .Navigation("PrimaryContact", pc => pc
        .Property("PrimaryEmail")
        .Property("PrimaryPhone"));

private static GraphBuilder BuildEditGraph() => new GraphBuilder("Customer")
    .Property("Id")
    .Property("FullName")
    .Reference("BillingCustomer")
    .Navigation("PrimaryContact", pc => pc
        .Property("CompanyName")
        // ... all editable properties
        );

Reading Values Directly from the DataSet

For conditional logic or custom display, read values from the DataSet directly:

<DataGraph Builder="@BuildGraph" DataSet="@_dataSet" EntityGuid="@CustomerGuid">
    @{
        var email = _dataSet.Get<string>("PrimaryContact.PrimaryEmail");
        var phone = _dataSet.Get<string>("PrimaryContact.PrimaryPhone");
    }

    @if (!string.IsNullOrWhiteSpace(email))
    {
        <MudText>@email</MudText>
    }
</DataGraph>

Lifecycle Summary

Page loads
  ├─ OnInitialized: Create DataSet(OData, BuildGraph().Build())
  ├─ DataGraph.OnParametersSetAsync:
  │    ├─ Load EntityMetaData from IModelProvider
  │    ├─ Create DataGraphContext (cascading)
  │    ├─ LoadAsync(guid) or CreateAsync()
  │    └─ Subscribe to DataSet.Changed
  └─ PropertyComponent.OnParametersSet (per property):
       ├─ Resolve PropertyMetaData from path
       ├─ Resolve component type from DynamicComponentProvider
       ├─ Subscribe to DataSet.Changed for this path
       └─ Render the resolved component