Date: 2025-11-25 Status: ✅ Phase 1 Implemented Priority: High (Core Feature for Interactive HTML)
This document outlines the design for interactive variables in HTML mode, where variable changes drive URL updates that trigger dashboard re-rendering with potentially cached query results.
URL as Single Source of Truth: All variable state lives in the URL. This enables: - Shareable, bookmarkable dashboard states - Browser back/forward navigation - Simple, stateless architecture - Server-side rendering with full variable context
include conditional logicVariables map directly to URL query parameters:
/dashboard?region=North&year=2024&tab=overview
Parameter naming conventions:
- Variable names become parameter names directly
- Multi-select values use comma separation: ?regions=North,South
- Date ranges use start/end suffixes: ?date_start=2024-01-01&date_end=2024-12-31
- Reserved prefixes: _df_ for internal use (e.g., _df_tab)
Add optional URL configuration to variables:
variables:
region:
column: orders.region
input: select
default: "North"
# URL configuration (optional)
url:
param: "region" # Custom parameter name (defaults to variable name)
persist: true # Include in URL (default: true)
encode: "base64" # Optional encoding for complex values
1. User changes variable (dropdown, input, etc.)
2. JavaScript updates URL parameters
3. Browser navigates to new URL (full page load) OR
JavaScript fetches new HTML and swaps content (SPA-like)
4. Server parses URL parameters
5. Server executes queries (with caching)
6. Server renders HTML with new variable values
7. Response sent to client
Implementation in routes.py:
@router.get("/dashboard/{dashboard_name}")
async def render_interactive_dashboard(
dashboard_name: str,
request: Request,
) -> HTMLResponse:
"""Render dashboard with URL-derived variable values."""
# Extract variables from URL parameters
variables = {}
for key, value in request.query_params.items():
if not key.startswith("_df_"): # Skip internal params
variables[key] = parse_variable_value(key, value)
# Render with variables
html_content = render_dashboard(
yaml_content=load_dashboard(dashboard_name),
format="html",
variables=variables,
adapter_registry=get_adapter_registry(),
)
return HTMLResponse(content=html_content)
Generate HTML form elements that update URL on change:
<!-- Select variable -->
<div class="variable" data-variable="region">
<label for="var-region">Region</label>
<select id="var-region" name="region" data-variable-update="url">
<option value="North" selected>North</option>
<option value="South">South</option>
<option value="East">East</option>
<option value="West">West</option>
</select>
</div>
<!-- Date range variable -->
<div class="variable" data-variable="date_range">
<label>Date Range</label>
<input type="date" name="date_start" value="2024-01-01" data-variable-update="url" />
<span>to</span>
<input type="date" name="date_end" value="2024-12-31" data-variable-update="url" />
</div>
// Interactive variable handling
class VariableController {
constructor() {
this.debounceTimer = null;
this.bindEvents();
}
bindEvents() {
// Listen for variable changes
document.querySelectorAll('[data-variable-update="url"]').forEach(el => {
el.addEventListener('change', (e) => this.onVariableChange(e));
});
}
onVariableChange(event) {
// Debounce rapid changes (e.g., typing in text input)
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
this.updateUrl();
}, 150);
}
updateUrl() {
const params = new URLSearchParams();
// Collect all variable values
document.querySelectorAll('[data-variable-update="url"]').forEach(el => {
const name = el.name;
const value = el.value;
if (value) {
params.set(name, value);
}
});
// Navigate to new URL
const newUrl = `${window.location.pathname}?${params.toString()}`;
// Option 1: Full page navigation
window.location.href = newUrl;
// Option 2: Fetch and swap (smoother, but more complex)
// this.fetchAndSwap(newUrl);
}
async fetchAndSwap(url) {
// Show loading indicator
document.body.classList.add('dft-loading');
try {
const response = await fetch(url, {
headers: { 'X-Dataface-Partial': 'true' }
});
const html = await response.text();
// Extract and swap dashboard content
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newContent = doc.querySelector('.dashboard-content');
document.querySelector('.dashboard-content').replaceWith(newContent);
// Update browser URL without full navigation
history.pushState({}, '', url);
// Re-bind events for new content
this.bindEvents();
} finally {
document.body.classList.remove('dft-loading');
}
}
}
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', () => {
new VariableController();
});
Cache queries by their resolved SQL string. No variable tracking needed - just hash the final SQL after Jinja substitution.
Why this is better: - Zero complexity - no variable dependency graphs - Cross-dashboard sharing - same SQL = same cache hit regardless of dashboard - Natural cache keys - the SQL itself defines what data it returns - Works with any query type (SQL, MetricFlow, etc.)
import hashlib
def generate_cache_key(resolved_sql: str) -> str:
"""Generate cache key from resolved SQL.
After Jinja resolution, the SQL contains actual values.
Same SQL = same data = cache hit.
"""
return f"dft:query:{hashlib.sha256(resolved_sql.encode()).hexdigest()[:16]}"
Simple cache that stores query results by SQL hash:
class QueryCache:
"""Simple query result cache keyed by resolved SQL."""
def __init__(self, ttl: int = 300): # 5 min default
self._cache: Dict[str, Dict[str, Any]] = {}
self.ttl = ttl
def get(self, resolved_sql: str) -> Optional[List[Dict]]:
"""Get cached results for this SQL."""
key = generate_cache_key(resolved_sql)
entry = self._cache.get(key)
if entry and datetime.utcnow() < entry['expires_at']:
return entry['data']
# Expired or not found
if entry:
del self._cache[key]
return None
def set(self, resolved_sql: str, data: List[Dict]) -> None:
"""Cache results for this SQL."""
key = generate_cache_key(resolved_sql)
self._cache[key] = {
'data': data,
'expires_at': datetime.utcnow() + timedelta(seconds=self.ttl),
}
class DashboardExecutor:
def __init__(self, board, adapter_registry, query_registry, cache=None):
self.board = board
self.adapter_registry = adapter_registry
self.query_registry = query_registry
self.cache = cache or QueryCache()
def execute_query(self, query_name: str, variables: Dict[str, Any]) -> List[Dict]:
"""Execute query with caching."""
query = self.query_registry[query_name]
# Resolve Jinja to get final SQL
resolved_sql = resolve_jinja_template(query.sql, variables=variables)
# Check cache
cached = self.cache.get(resolved_sql)
if cached is not None:
return cached
# Execute and cache
result = self.adapter_registry.execute(query, variables)
self.cache.set(resolved_sql, result.data)
return result.data
Dashboard A and Dashboard B both have:
sql
SELECT region, SUM(revenue) FROM sales WHERE year = 2024 GROUP BY region
→ Same cache hit! One query execution serves both.
User changes region filter from North to South:
- Old SQL: ... WHERE region = 'North'
- New SQL: ... WHERE region = 'South'
→ Different hash, different cache entry. Simple.
User changes tab (doesn't affect a query): - SQL unchanged → Cache hit! Query not re-executed.
(Far future, not for now)
If we move chart rendering to the frontend, we could: 1. Track which queries each chart uses 2. Track which variables each query references 3. On variable change, only fetch charts affected by that variable
// Future: Smart partial updates
const variableDependencies = {
q_regional: ['region', 'year'],
q_overview: ['year'], // Doesn't use region
};
function onVariableChange(varName, value) {
// Only refetch queries that use this variable
const affectedQueries = Object.entries(variableDependencies)
.filter(([_, deps]) => deps.includes(varName))
.map(([name]) => name);
// Fetch only affected charts
fetchCharts(affectedQueries);
}
But for now: Keep it simple. Full server render, SQL-based cache.
Tabs are implemented as a set of boards, each with an include condition that checks against a tab variable. Only one tab's board is rendered at a time.
Key insight: This leverages the existing include conditional logic already in Dataface.
During normalization, tab layouts get an auto-generated variable:
# User writes:
tabs:
default: overview
items:
- title: Overview
rows:
- overview_kpi
- overview_chart
- title: Details
rows:
- detail_table
# Normalizer produces:
variables:
_tab: # Auto-generated
input: tabs # New input type
options:
static: [overview, details] # From tab item slugs
default: overview
url:
param: tab # URL param name
rows:
# Tab bar rendered as variable control
- _tab_selector
# Tab content boards with include conditions
- include: _tab == 'overview'
rows:
- overview_kpi
- overview_chart
- include: _tab == 'details'
rows:
- detail_table
def normalize_tabs(board: Board, board_id: str) -> Tuple[Dict, Dict]:
"""Normalize tabs into variable-controlled boards.
Returns:
Tuple of (variables dict, rows list)
"""
if not board.tabs:
return {}, []
tabs = board.tabs
tab_var_name = f"_tab_{board_id}" if board_id else "_tab"
# Generate tab options from items
tab_options = []
for item in tabs.items:
tab_slug = slugify(item.title)
tab_options.append(tab_slug)
# Create tab variable
tab_variable = {
"input": "tabs",
"options": {"static": tab_options},
"default": tabs.default or tab_options[0],
"url": {"param": "tab"},
"_tab_titles": {slugify(item.title): item.title for item in tabs.items},
"_tab_icons": {slugify(item.title): item.icon for item in tabs.items if item.icon},
}
# Convert tab items to boards with include conditions
tab_boards = []
for item in tabs.items:
tab_slug = slugify(item.title)
# Create board from tab item
tab_board = {
"include": f"{tab_var_name} == '{tab_slug}'",
# Copy layout properties from tab item
"rows": item.rows if hasattr(item, 'rows') else None,
"cols": item.cols if hasattr(item, 'cols') else None,
"grid": item.grid if hasattr(item, 'grid') else None,
"content": item.content if hasattr(item, 'content') else None,
}
# Remove None layouts
tab_board = {k: v for k, v in tab_board.items() if v is not None}
tab_boards.append(tab_board)
return {tab_var_name: tab_variable}, tab_boards
The tabs input type renders as a horizontal tab bar:
def render_tabs_variable(
variable_name: str,
variable: Variable,
current_value: str,
) -> str:
"""Render tabs variable as horizontal tab bar."""
tabs_html = ['<div class="tab-bar" role="tablist">']
for tab_slug in variable.options.get("static", []):
tab_title = variable._tab_titles.get(tab_slug, tab_slug)
tab_icon = variable._tab_icons.get(tab_slug, "")
is_active = tab_slug == current_value
active_class = "active" if is_active else ""
aria_selected = "true" if is_active else "false"
icon_html = f'<span class="tab-icon">{tab_icon}</span>' if tab_icon else ""
tabs_html.append(f'''
<button
class="tab-button {active_class}"
role="tab"
aria-selected="{aria_selected}"
data-variable-update="url"
data-variable-name="{variable_name}"
data-variable-value="{tab_slug}"
>
{icon_html}
<span class="tab-title">{html.escape(tab_title)}</span>
</button>
''')
tabs_html.append('</div>')
return '\n'.join(tabs_html)
JavaScript handles tab clicks by updating the URL:
// Tab button handler
document.addEventListener('click', (e) => {
const tabButton = e.target.closest('[data-variable-update="url"][data-variable-name]');
if (!tabButton) return;
const varName = tabButton.dataset.variableName;
const varValue = tabButton.dataset.variableValue;
// Update URL parameter
const url = new URL(window.location);
url.searchParams.set(varName, varValue);
// Navigate (or fetch-and-swap)
window.location.href = url.toString();
});
Each nested tabs layout gets its own variable:
tabs:
items:
- title: Sales
tabs: # Nested tabs
items:
- title: By Region
...
- title: By Product
...
- title: Marketing
...
Normalizes to:
variables:
_tab: ... # Top-level tabs
_tab_sales: ... # Nested tabs under Sales
# URL: ?tab=sales&tab_sales=by_region
Tab selection persists in URL, enabling:
# Shareable tab state
/dashboard?tab=details
# Deep linking to nested tabs
/dashboard?tab=sales&tab_sales=by_product&region=North
Tabs benefit significantly from query caching:
tabs:
items:
- title: Overview
rows:
- overview_chart # Uses q_overview query
- title: Details
rows:
- detail_table # Uses q_details query
When switching tabs:
- If q_overview doesn't depend on _tab variable, it stays cached
- Only q_details executes if it references different data
Goal: Variables update URL, URL changes trigger full page reload
Tasks:
1. ✅ Add URL parameter parsing to render endpoint
2. ✅ Generate HTML form controls with data-variable attribute
3. ✅ Add JavaScript to update URL on variable change
4. ✅ Ensure all variable types render as HTML controls
5. ✅ Jinja template resolution for chart titles and other properties
Supported Input Types:
- select - Dropdown selection
- text / input - Text input field
- number - Number input with min/max/step
- slider / range - Range slider with value display
- checkbox - Boolean toggle
- date - Date picker
- datepicker - Alias for date
- daterange - Date range selection
Files modified:
- dataface/renderer/dashboard.py - Variable control rendering with CSS styling
- dataface/playground/routes.py - URL parameter parsing
- dataface/playground/static/playground.js - Message passing for iframe integration
- dataface/playground/websocket.py - WebSocket variable passing
- dataface/core/types.py - Extended VariableInputType and validation
Goal: Tabs implemented as variable-controlled boards
Tasks:
1. Add tabs input type to VariableInputType
2. Normalize tabs to variable + include conditions
3. Render tab bar as variable control
4. Handle nested tabs
Files to modify:
- dataface/core/types.py - Add tabs input type
- dataface/core/normalizer.py - Add tab normalization
- dataface/renderer/dashboard.py - Add tab bar rendering
Goal: Polish and optimize
Tasks: 1. Fetch-and-swap for smoother transitions 2. Loading indicators 3. Tab transition animations 4. Keyboard navigation for tabs 5. Deep linking support
Goal: Cache query results by resolved SQL hash (see Part 2 above)
When needed: When queries are slow/expensive, not needed for local development
include logicClient-Side State: Store variables in JavaScript, fetch data via API - Rejected: More complex, harder to share, less cache-friendly
Session-Based State: Store variables in server session - Rejected: Not shareable, statefulness adds complexity
Vega-Lite Interactive: Use Vega-Lite's built-in interactivity - Rejected: Limited to client-side data, no server queries
URL Parameter Encoding: How to handle complex values (arrays, objects)? - Proposal: JSON encode, then base64 for complex types
Default Values: Should URL omit default values for cleaner URLs? - Proposal: Yes, only include non-default values
Tab URL Parameter: Use tab or _tab or configurable?
- Proposal: Configurable, default to tab
Cache Storage: In-memory, Redis, or SQLite? - Proposal: Start with in-memory, add Redis for production
Partial Updates: Fetch-and-swap or full page reload? - Proposal: Full reload for MVP, fetch-and-swap as enhancement
include conditional logic in normalizertitle: Sales Dashboard
variables:
region:
column: orders.region
input: select
default: North
year:
input: select
options:
static: [2023, 2024]
default: 2024
queries:
sales:
sql: |
SELECT month, revenue
FROM sales
WHERE {{ filter('region', '=', region) }}
AND {{ filter('year', '=', year) }}
rows:
- sales_chart
URL: /dashboard/sales?region=South&year=2024
title: Analytics Dashboard
tabs:
default: overview
items:
- title: Overview
icon: 📊
rows:
- kpi_row
- trend_chart
- title: Details
icon: 📋
rows:
- detail_table
- title: Settings
icon: ⚙️
content: |
## Dashboard Settings
Configure your preferences here.
URL: /dashboard/analytics?tab=details
title: Regional Sales
variables:
region:
column: orders.region
input: select
default: North
tabs:
items:
- title: Charts
cols:
- revenue_chart
- orders_chart
- title: Data
rows:
- data_table
URL: /dashboard/regional?region=South&tab=data