Overview: This document describes the two primary patterns for building Blazor components that work with workspace-scoped MVVM data in LionFire. Both patterns leverage reactive persistence (IObservableReader/Writer) and workspace service scoping.
- Pattern Overview
- Pattern 1: ObservableDataView (List Views)
- Pattern 2: Manual VM Creation (Single-Item Views)
- When to Use Which Pattern
- Navigation Between List and Detail
- Common Scenarios
- Troubleshooting
You have workspace-scoped services (IObservableReader/Writer<TKey, TValue>) that need to be consumed by Blazor components to display and edit data. The services are not in the root DI container - they're in a workspace-specific service provider.
| Pattern | Use Case | Complexity | Flexibility |
|---|---|---|---|
| ObservableDataView | List of items with CRUD | Low - Component handles everything | Medium - Configure via parameters |
| Manual VM Creation | Single item detail view | Medium - Manual setup required | High - Full control |
Both patterns use:
- CascadingParameter to receive
WorkspaceServices - IObservableReader/Writer for reactive persistence
- ViewModels to wrap entities for UI binding
- MudBlazor components for UI
✅ Use ObservableDataView when:
- Displaying a list/grid of items
- Need standard CRUD operations (Create, Read, Update, Delete)
- Want built-in toolbar with Add/Edit/Delete buttons
- Data is workspace-scoped
ObservableDataView is a pre-built component that:
- Accepts workspace services via
DataServiceProviderparameter - Automatically resolves
IObservableReader/Writer<TKey, TValue> - Creates ViewModels for each item using factory
- Renders a MudDataGrid with reactive updates
- Provides toolbar with CRUD operations
Bots.razor - List of bots in workspace:
@page "/bots"
@using LionFire.Mvvm
@using LionFire.Trading.Automation
<div class="pa-6">
<ObservableDataView @ref=ItemsEditor
DataServiceProvider="WorkspaceServices"
TKey="string"
TValue="BotEntity"
TValueVM="BotVM"
AllowedEditModes="EditMode.All"
ReadOnly=false>
<Columns>
<!-- Status icon with navigation -->
<TemplateColumn T="BotVM">
<HeaderTemplate>Status</HeaderTemplate>
<CellTemplate>
<MudLink Href="@($"/bots/{context.Item.Key}")">
<MudIcon Icon="@Icons.Material.Outlined.Circle" />
</MudLink>
</CellTemplate>
</TemplateColumn>
<!-- Enabled toggle -->
<TemplateColumn T="BotVM">
<HeaderTemplate>Enabled</HeaderTemplate>
<CellTemplate>
@if (context.Item?.Value != null)
{
<MudSwitch T="bool"
@bind-Value="context.Item.Value.Enabled"
Color="Color.Primary"
Size="Size.Small" />
}
</CellTemplate>
</TemplateColumn>
<!-- Live toggle (changes color based on account type) -->
<TemplateColumn T="BotVM">
<HeaderTemplate>Live</HeaderTemplate>
<CellTemplate>
@if (context.Item?.Value != null)
{
<MudSwitch T="bool"
@bind-Value="context.Item.Value.Live"
Color="@(context.Item.IsLive ? Color.Secondary : Color.Default)"
Size="Size.Small" />
}
</CellTemplate>
</TemplateColumn>
<!-- Standard property columns -->
<PropertyColumn Property="x => x.Value.Exchange" Title="Exchange" />
<PropertyColumn Property="x => x.Value.ExchangeArea" Title="Area" />
<PropertyColumn Property="x => x.Value.Symbol" Title="Symbol" />
<PropertyColumn Property="x => x.Value.TimeFrame" Title="TimeFrame" />
<PropertyColumn Property="x => x.Value.BotTypeName" Title="Type" />
<PropertyColumn Property="x => x.Value.Name" />
<PropertyColumn Property="x => x.AD" Title="AD" />
<PropertyColumn Property="x => x.Value.Comments" />
</Columns>
<!-- Optional: Expandable row content -->
<ChildRowContent>
<MudCard>
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">@context.Item.Value.Name</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudText>Enabled: <MudSwitch T="bool" @bind-Value="context.Item.Value.Enabled" /></MudText>
<MudText>Comments: @context.Item.Value.Comments</MudText>
</MudCardContent>
</MudCard>
</ChildRowContent>
<!-- Optional: Context menu -->
<ContextMenu>
<MudMenuItem Icon="@Icons.Material.Filled.Delete">
Delete @context.Value.Name
</MudMenuItem>
</ContextMenu>
</ObservableDataView>
</div>
@code {
[CascadingParameter(Name = "WorkspaceServices")]
public IServiceProvider? WorkspaceServices { get; set; }
ObservableDataView<string, BotEntity, BotVM>? ItemsEditor { get; set; }
}// Simple ViewModel wrapping the entity
public class BotVM : KeyValueVM<string, BotEntity>
{
public BotVM(string key, BotEntity value) : base(key, value)
{
}
// UI-specific computed properties
public bool IsLive => Value.Live && AccountIsRealMoney();
public double? AD => CalculateAverageDrawdown();
// Event handlers for toolbar actions
public ValueTask OnStart() => StartBotAsync();
public ValueTask OnStop() => StopBotAsync();
private bool AccountIsRealMoney() { /* ... */ }
private double? CalculateAverageDrawdown() { /* ... */ }
}Automatic CRUD Toolbar:
- Add button (if
CreatableTypesspecified) - Edit mode toggle
- Delete toggle
- Refresh button (if
ShowRefresh = true)
Automatic VM Creation:
// Component automatically creates VMs for each entity
// using constructor: new BotVM(key, entity)Reactive Updates:
// When files change on disk or items are added/removed,
// the grid automatically updates via DynamicData observablesBuilt-in Features:
- Sorting (multi-column)
- Filtering
- Grouping
- Pagination
- Cell/Row editing
- Context menus
- Expandable rows
// Required
TKey // Key type (usually string)
TValue // Entity type
TValueVM // ViewModel type
// Recommended
DataServiceProvider // Pass WorkspaceServices here
// Optional
AllowedEditModes // EditMode.None, .Cell, .Form, .All
ReadOnly // Disable editing
CreatableTypes // Enable "Add" button
VMFactory // Custom VM creation
Columns // Custom column definitions
ChildRowContent // Expandable row template
ContextMenu // Right-click menu✅ Use Manual VM Creation when:
- Displaying/editing a single item detail view
- Need custom layout (not a grid)
- Want fine-grained control over VM lifecycle
- Implementing master-detail pattern
- Receive workspace services via
CascadingParameter - Manually resolve
IObservableReader/Writer - Create
ObservableReaderWriterItemVMwith services - Set VM's
Idproperty to load specific item - Bind UI to VM properties
- Call
VM.Write()to save changes
Bot.razor - Single bot detail page:
@page "/bots/{BotId}"
@using LionFire.Mvvm
@using LionFire.Trading.Automation
@using LionFire.Reactive.Persistence
@using Microsoft.Extensions.DependencyInjection
@using Microsoft.Extensions.Logging
@inject ILogger<Bot> Logger
@inject IServiceProvider ServiceProvider
<h3>Bot @BotId</h3>
@if (VM?.Value != null)
{
<MudTextField @bind-Value="VM.Value.Name" Label="Name" />
<MudTextField @bind-Value="VM.Value.Comments" Label="Comments" />
<MudTextField @bind-Value="VM.Value.Description" Label="Description" />
<MudButton OnClick="Save" Color="Color.Primary">Save</MudButton>
}
else if (VM != null)
{
<MudProgressCircular Indeterminate="true" />
<p>Loading bot...</p>
}
else
{
<MudAlert Severity="Severity.Error">
Unable to load bot services. Check logs for details.
</MudAlert>
}
@code {
[Parameter]
public string? BotId { get; set; }
[CascadingParameter(Name = "WorkspaceServices")]
public IServiceProvider? WorkspaceServices { get; set; }
private ObservableReaderWriterItemVM<string, BotEntity, BotVM>? VM { get; set; }
protected override async Task OnParametersSetAsync()
{
// Try workspace services first, fall back to root for debugging/testing
var effectiveServices = WorkspaceServices ?? ServiceProvider;
if (WorkspaceServices == null)
{
Logger.LogWarning(
"WorkspaceServices cascading parameter not found. " +
"Falling back to root ServiceProvider. " +
"For production, this page should be rendered within a workspace layout."
);
}
// Resolve reader/writer from workspace services
var reader = effectiveServices.GetService<IObservableReader<string, BotEntity>>();
var writer = effectiveServices.GetService<IObservableWriter<string, BotEntity>>();
if (reader == null || writer == null)
{
Logger.LogError(
"Bot persistence services not registered. " +
"Reader: {ReaderAvailable}, Writer: {WriterAvailable}, " +
"Source: {ServiceSource}",
reader != null,
writer != null,
WorkspaceServices != null ? "Workspace" : "Root"
);
Logger.LogError(
"This means either: " +
"1) Workspace layout is not being used, or " +
"2) Workspace configurators haven't run yet, or " +
"3) BotEntity not registered with AddWorkspaceChildType"
);
return;
}
Logger.LogInformation(
"Loaded Bot persistence services from {ServiceSource}",
WorkspaceServices != null ? "Workspace" : "Root"
);
// Create VM with workspace services
VM = new ObservableReaderWriterItemVM<string, BotEntity, BotVM>(reader, writer);
VM.Id = BotId;
await base.OnParametersSetAsync();
}
private async Task Save()
{
if (VM?.Value != null)
{
await VM.Write();
Logger.LogInformation("Saved bot {BotId}", BotId);
// Optional: Show success snackbar
}
}
}Service Resolution:
// Get services from workspace scope
var reader = WorkspaceServices.GetService<IObservableReader<string, BotEntity>>();
var writer = WorkspaceServices.GetService<IObservableWriter<string, BotEntity>>();VM Creation:
// Create VM manually, passing workspace-scoped services
VM = new ObservableReaderWriterItemVM<string, BotEntity, BotVM>(reader, writer);
VM.Id = BotId; // Triggers load from persistenceReactive Loading:
// When VM.Id is set, it automatically:
// 1. Calls reader.TryGetValue(BotId)
// 2. Creates BotVM if entity found
// 3. Notifies UI via PropertyChanged
// 4. UI re-renders with @if (VM?.Value != null)Saving Changes:
// Call VM.Write() to persist changes
await VM.Write();
// Internally calls: writer.Write(VM.Id, VM.Value.Value)The example above includes a fallback for development:
// Try workspace services first, fall back to root
var effectiveServices = WorkspaceServices ?? ServiceProvider;
if (WorkspaceServices == null)
{
Logger.LogWarning("Using root services - workspace layout not found");
}When to use fallback:
- ✅ During development/testing
- ✅ When testing pages in isolation
- ❌ In production (should fail fast)
Production pattern (no fallback):
if (WorkspaceServices == null)
{
throw new InvalidOperationException(
"This page must be rendered within a workspace layout"
);
}
var reader = WorkspaceServices.GetRequiredService<IObservableReader<string, BotEntity>>();Do you need to display multiple items?
├─ Yes
│ └─ Do you need custom layout/grid?
│ ├─ No → Use ObservableDataView ✅
│ └─ Yes → Consider ObservableDataView with custom columns,
│ or manual pattern if very custom
└─ No (single item)
└─ Use Manual VM Creation ✅
| Aspect | ObservableDataView | Manual VM Creation |
|---|---|---|
| Lines of Code | ~20-30 (mostly column definitions) | ~60-80 (full setup) |
| Control | Medium (parameter-driven) | High (full control) |
| Complexity | Low (component handles DI) | Medium (manual DI) |
| CRUD | Built-in toolbar | Implement yourself |
| Layout | Grid/Table only | Any layout |
| Learning Curve | Easy | Moderate |
| Best For | Lists, grids, collections | Details, forms, custom UI |
ObservableDataView:
- Bot list page ✅
- Portfolio list ✅
- Strategy list ✅
- Settings categories list ✅
- File browser ✅
Manual VM Creation:
- Bot detail/edit page ✅
- Portfolio dashboard ✅
- Strategy builder ✅
- Settings detail page ✅
- Custom wizard/form ✅
List Page (Master):
<ObservableDataView ...>
<Columns>
<TemplateColumn>
<CellTemplate>
<!-- Navigate to detail page -->
<MudButton Href="@($"/bots/{context.Item.Key}")">
Edit
</MudButton>
</CellTemplate>
</TemplateColumn>
</Columns>
</ObservableDataView>Detail Page:
@page "/bots/{BotId}"
<!-- Back button -->
<MudButton Href="/bots" StartIcon="@Icons.Material.Filled.ArrowBack">
Back to List
</MudButton>
<!-- Rest of detail view -->/bots → Bots.razor (List View - ObservableDataView)
/bots/bot-alpha → Bot.razor (Detail View - Manual VM)
/bots/bot-beta → Bot.razor (Detail View - Manual VM)
Via URL Parameter:
@page "/bots/{BotId}"
@code {
[Parameter]
public string? BotId { get; set; }
}Via Navigation State (if needed):
NavigationManager.NavigateTo("/bots/bot-alpha", new NavigationOptions {
State = new { OpenInEditMode = true }
});<ObservableDataView TKey="string"
TValue="BotEntity"
TValueVM="BotVM"
DataServiceProvider="@WorkspaceServices"
ReadOnly="true"
AllowedEditModes="EditMode.None">
<!-- Columns -->
</ObservableDataView><ObservableDataView TKey="string"
TValue="BotEntity"
TValueVM="BotVM"
DataServiceProvider="@WorkspaceServices"
CreatableTypes="@(new[] { typeof(BotEntity) })"
CanCreateValueType="true">
<!-- Columns -->
</ObservableDataView><ObservableDataView TKey="string"
TValue="BotEntity"
TValueVM="BotVM"
DataServiceProvider="@WorkspaceServices"
VMFactory="@CreateBotVM">
<!-- Columns -->
</ObservableDataView>
@code {
private BotVM CreateBotVM(string key, Optional<BotEntity> entity)
{
if (entity.HasValue)
{
var vm = new BotVM(key, entity.Value);
vm.Initialize(someService); // Custom initialization
return vm;
}
return null;
}
}@page "/bots/{BotId}"
@if (VM?.Value != null)
{
<MudTabs>
<MudTabPanel Text="General">
<MudTextField @bind-Value="VM.Value.Name" Label="Name" />
<MudTextField @bind-Value="VM.Value.Description" Label="Description" />
</MudTabPanel>
<MudTabPanel Text="Parameters">
<MudTextField @bind-Value="VM.Value.Parameters.StopLoss" Label="Stop Loss" />
<MudTextField @bind-Value="VM.Value.Parameters.TakeProfit" Label="Take Profit" />
</MudTabPanel>
<MudTabPanel Text="Advanced">
<!-- Advanced settings -->
</MudTabPanel>
</MudTabs>
<MudButton OnClick="Save">Save All</MudButton>
}
@code {
// Same setup as before
}@code {
private bool IsDirty => VM?.Value?.IsChanged ?? false;
private async Task SaveWithConfirmation()
{
if (!IsDirty)
{
Snackbar.Add("No changes to save", Severity.Info);
return;
}
await VM.Write();
Snackbar.Add("Saved successfully", Severity.Success);
}
// Warn on navigation if dirty
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
NavigationManager.LocationChanged += CheckDirtyOnNavigation;
}
}
private void CheckDirtyOnNavigation(object sender, LocationChangedEventArgs e)
{
if (IsDirty)
{
// Show confirmation dialog
}
}
}Cause: Trying to inject workspace-scoped services from root container.
Solution: Use CascadingParameter and manual resolution:
[CascadingParameter(Name = "WorkspaceServices")]
public IServiceProvider? WorkspaceServices { get; set; }
var reader = WorkspaceServices.GetService<IObservableReader<string, BotEntity>>();See: Service Scoping Deep Dive
Cause: Document type not registered with AddWorkspaceChildType.
Solution:
services
.AddWorkspaceChildType<BotEntity>() // ← Must call this!
.AddWorkspaceDocumentService<string, BotEntity>();Check:
- Is
DataServiceProviderset? (Should beWorkspaceServices) - Are there files in the workspace directory?
- Check browser console for errors
- Verify
IObservableReaderhas data:var reader = WorkspaceServices.GetService<IObservableReader<string, BotEntity>>(); Logger.LogInformation("Keys: {Keys}", string.Join(", ", reader.Keys.Items));
Cause: Not subscribed to observable changes.
Solution: For manual pattern, ensure VM subscribes to reader's observable:
// ObservableReaderWriterItemVM does this automatically
// But if rolling your own:
reader.Values.Connect()
.Subscribe(changeSet => {
// Handle updates
});Check:
- Did you call
VM.Write()? - Does the entity implement
INotifyPropertyChanged(or useReactiveObject)? - Check file permissions on workspace directory
- Look for exceptions in logs
Use ObservableDataView for:
- Lists and grids
- Standard CRUD
- Quick implementation
Use Manual VM Creation for:
- Single-item details
- Custom layouts
- Fine-grained control
- ✅ Always use
CascadingParameterfor workspace services - ✅ Check for null services and log errors
- ✅ Use
ObservableReaderWriterItemVMfor single items - ✅ Call
VM.Write()to save changes - ✅ Register types with
AddWorkspaceChildType - ✅ Implement reactive properties in entities (
ReactiveObject) - ❌ Don't try to inject workspace services via
@inject - ❌ Don't create singleton readers/writers in root container