Filtered Dashboard Pattern
This guide explains how to build filtered dashboard pages using the pattern established in SCM (Speaker Compliance Monitoring). The pattern provides a shared filter bar, cookie persistence, automatic Plotly inclusion, and consistent page layout.
Architecture
SCMBasePage ← App styling, menu
└── FilteredBasePage ← Filter bar, layout, Plotly
└── ProgramAnalysisPage ← Your dashboard content
Components
FilterState
Manages filter values with cookie persistence:
public class FilterState
{
// Date range
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
// Dropdown filters (empty = "All")
public string Status { get; set; }
public string Enterprise { get; set; }
// ... more filters
// Methods
void LoadFromCookies(HttpRequest request);
void LoadFromForm(HttpRequest request);
void SaveToCookies(Action<string, string, int> setCookie);
string BuildWhereClause(bool excludeStatus = false);
void AddParameters(DbQuery q, bool excludeStatus = false);
}
FilteredBasePage
Abstract base that handles common filter infrastructure:
public abstract class FilteredBasePage : SCMBasePage
{
protected FilterState Filters { get; }
// You implement this:
protected abstract void BuildPageContent(ContainerElement main);
}
What FilteredBasePage provides:
- Loads filter state from cookies
- Overrides with form POST data
- Saves filter state back to cookies
- Includes Plotly.js script
- Sets up page layout (PanelCol, flex:1)
- Renders filter bar
- Creates main content area and calls your BuildPageContent()
Creating a Filtered Dashboard
Step 1: Create the Page Class
using FlightPlan.Core.Dom;
using FlightPlan.Core.Dom.Elements;
using PixieDust.Charting;
using CoreCss = FlightPlan.Core.Dom.Styles.CoreStyles.Css;
namespace FlightPlan.SpkrComp.Pages
{
public class MyDashboardPage : FilteredBasePage
{
protected override string RequiredRole => "read";
protected override void BuildPageContent(ContainerElement main)
{
// Your content here - main is a Panel.Row().Grow()
}
}
}
Step 2: Implement BuildPageContent
The main parameter is already a horizontal row that grows to fill available space:
protected override void BuildPageContent(ContainerElement main)
{
// Left panel (60%)
var left = main.Add(Panel.Col().Percent(60));
BuildLeftPanel(left);
// Right panel (40%)
var right = main.Add(Panel.Col().Percent(40));
BuildRightPanel(right);
}
private void BuildLeftPanel(ContainerElement left)
{
// KPIs at top (fixed height)
var kpiRow = left.Add(Panel.Row().Fixed());
// ... add KPI cards
// Chart in middle (grows)
var chartArea = left.Add(Panel.Col().Grow().Fill());
// ... add chart
// Table at bottom (fixed height)
var tableArea = left.Add(Panel.Col().Fixed());
// ... add table
}
Step 3: Use the Filters Property
Access filter values when querying data:
protected override void BuildPageContent(ContainerElement main)
{
// Filters property is available from FilteredBasePage
var data = SCMAdapter.GetMonthlyTotals(Filters);
// Use in chart
var chart = new BarChart()
.FromData(data, "month_label", "cnt")
.WithFill();
}
Step 4: Register the Route
In your app's routing (e.g., SCMApp.cs):
else if (internalPath.StartsWith("/MyDashboard", StringComparison.OrdinalIgnoreCase))
{
page = new Pages.MyDashboardPage();
}
Step 5: Add Menu Item
In your base page (e.g., SCMBasePage.cs):
MenuItems.Add(new MenuItem("My Dashboard", "/SCM/MyDashboard"));
Complete Example
public class SalesDashboardPage : FilteredBasePage
{
protected override string RequiredRole => "read";
protected override void BuildPageContent(ContainerElement main)
{
// LEFT PANEL (60%)
var left = main.Add(Panel.Col().Percent(60));
// KPI Row
var kpiRow = left.Add(Panel.Row().Fixed());
kpiRow.Add(Panel.Col().Card()).Add(
new KpiCard($"{Adapter.GetTotal(Filters)}", "Total Sales"));
kpiRow.Add(Panel.Col().Card()).Add(
new KpiCard($"{Adapter.GetAverage(Filters):C}", "Average"));
// Trend Chart
var chartArea = left.Add(Panel.Col().Grow().Fill());
var chartCard = chartArea.Add(Panel.Col().Grow().Fill().Card());
var trendData = Adapter.GetMonthlyTrend(Filters);
chartCard.Add(new Div(CoreCss.ChartTitle).WithText("Sales Trend"));
chartCard.Add(new ChartElement(
new BarChart()
.FromData(trendData, "month", "sales")
.WithFill()));
// Data Table
var tableArea = left.Add(Panel.Col().Fixed());
var tableCard = tableArea.Add(Panel.Col().Card().Scroll());
var pivoted = trendData.Pivot("region", "month", "sales", 0);
tableCard.Add(new SimpleTable(pivoted.WithTotalsRow(), CoreCss.BorderTable));
// RIGHT PANEL (40%)
var right = main.Add(Panel.Col().Percent(40));
// Donut charts
var topCard = right.Add(Panel.Col().Grow().Fill().Card());
topCard.Add(new Div(CoreCss.ChartTitle).WithText("By Region"));
topCard.Add(new ChartElement(
new PieChart()
.FromData(Adapter.GetByRegion(Filters), "region", "total")
.WithHole(0.4)
.WithFill()));
var bottomCard = right.Add(Panel.Col().Grow().Fill().Card());
bottomCard.Add(new Div(CoreCss.ChartTitle).WithText("By Product"));
bottomCard.Add(new ChartElement(
new PieChart()
.FromData(Adapter.GetByProduct(Filters), "product", "total")
.WithHole(0.4)
.WithMaxSlices(5)
.WithFill()));
}
}
Using Filters in Queries
Adapter Pattern
Create query methods that accept FilterState:
public static class MyAdapter
{
public static DbResults GetMonthlyData(FilterState filters)
{
var s = new StringBuilder();
s.AppendLine("SELECT month, COUNT(*) as cnt");
s.AppendLine("FROM MyTable");
s.AppendLine(filters.BuildWhereClause());
s.AppendLine("GROUP BY month");
s.AppendLine("ORDER BY month");
var q = new DbQuery(s.ToString());
filters.AddParameters(q);
return q.ExecuteReader();
}
public static int GetCount(FilterState filters, string status)
{
var s = new StringBuilder();
s.AppendLine("SELECT COUNT(*)");
s.AppendLine("FROM MyTable");
s.AppendLine(filters.BuildWhereClause(excludeStatus: true));
s.AppendLine("AND status = @status");
var q = new DbQuery(s.ToString());
filters.AddParameters(q, excludeStatus: true);
q.AddParameter("@status", status);
return q.ExecuteCount();
}
}
BuildWhereClause
Generates SQL WHERE clause from active filters:
// With all filters: filters.BuildWhereClause() // Returns: "WHERE activity_datetime BETWEEN @StartDate AND @EndDate AND status = @Status ..." // Excluding status (useful for KPIs that show count per status): filters.BuildWhereClause(excludeStatus: true) // Returns: "WHERE activity_datetime BETWEEN @StartDate AND @EndDate AND enterprise = @Enterprise ..."
AddParameters
Adds SqlParameters for active filters:
var q = new DbQuery(sql); filters.AddParameters(q); // Adds @StartDate, @EndDate, @Status, etc.
Page Lifecycle
- Request arrives → SCMApp.HandleRequest()
- Auth check → Redirect to login if needed
- Route to page → Create page instance
- BuildContent() called (in FilteredBasePage): load filters, save to cookies, build filter bar, call BuildPageContent(main)
- Your code runs → BuildPageContent() builds the dashboard
- Render → DOM tree converted to HTML
Tips
Reusing Data
Query once, use multiple times:
protected override void BuildPageContent(ContainerElement main)
{
// Query once
var data = Adapter.GetMonthlyData(Filters);
// Use for chart
chartCard.Add(new ChartElement(
new BarChart().FromData(data, "month", "cnt").WithFill()));
// Use for table (with pivot)
var pivoted = data.Pivot("status", "month", "cnt", 0);
tableCard.Add(new SimpleTable(pivoted.WithTotalsRow(), CoreCss.BorderTable));
}
Organizing Complex Pages
Extract sections into methods:
protected override void BuildPageContent(ContainerElement main)
{
var left = main.Add(Panel.Col().Percent(60));
BuildKpiRow(left);
BuildTrendChart(left);
BuildDataTable(left);
var right = main.Add(Panel.Col().Percent(40));
BuildDonutCharts(right);
}
private void BuildKpiRow(ContainerElement parent) { ... }
private void BuildTrendChart(ContainerElement parent) { ... }
private void BuildDataTable(ContainerElement parent) { ... }
private void BuildDonutCharts(ContainerElement parent) { ... }
Filter Dropdowns from Data
Populate dropdowns dynamically:
private DropDownList BuildFilterDropdown(string name, string label, string column, string selected)
{
var dropdown = new DropDownList(name, label)
.WithSelectedValue(selected)
.WithAutoSubmit()
.WithShowAllOption(); // Adds "All" as first option
var values = Adapter.GetDistinctValues(column);
foreach (var value in values)
{
dropdown.AddItem(value, value);
}
return dropdown;
}