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:
Idis a text field (data typeIdText)FullNameis a read-only text field (virtual property)BillingCustomeris a reference → renders an entity selectorCompanyNameis 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