ai_notes/considerations/INTERACTIVE_VARIABLES_HTML.md

Interactive Variables for HTML Mode

Date: 2025-11-25 Status: ✅ Phase 1 Implemented Priority: High (Core Feature for Interactive HTML)


Overview

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.

Core Philosophy

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


Design Goals

  1. URL-Driven State: Variable values stored in URL parameters
  2. Server-Side Rendering: URL changes trigger server re-render (not client-side filtering)
  3. Smart Query Caching: Only re-execute queries affected by changed variables
  4. Tabs as Variable-Controlled Views: Tabs implemented using existing include conditional logic
  5. Progressive Enhancement: Works without JavaScript (forms), enhanced with JavaScript

Part 1: URL-Based Variables

1.1 URL Parameter Mapping

Variables 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)

1.2 Variable Definition Enhancement

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.3 Server-Side Flow

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)

1.4 HTML Variable Controls

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>

1.5 JavaScript Enhancement

// 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();
});

Part 2: Query Caching (Future Enhancement)

2.1 Simple SQL-Based Caching

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]}"

2.2 Cache Implementation

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),
        }

2.3 Integration with Executor

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

2.4 Benefits

  1. 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.

  2. User changes region filter from North to South: - Old SQL: ... WHERE region = 'North' - New SQL: ... WHERE region = 'South' → Different hash, different cache entry. Simple.

  3. User changes tab (doesn't affect a query): - SQL unchanged → Cache hit! Query not re-executed.

2.5 Future: Frontend Variable Dependency Graph

(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.


Part 3: Tabs as Variable-Controlled Views

3.1 Core Concept

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.

3.2 Tab Variable Auto-Assignment

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

3.3 Normalization Changes

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

3.4 Tab Input Type Rendering

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)

3.5 Tab Click Handler

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();
});

Part 4: Enhanced Tab Features

4.1 Nested Tab Variables

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

4.2 Tab Persistence

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

4.3 Tab with Query Optimization

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


Part 5: Implementation Plan

Phase 1: URL-Based Variables (MVP) ✅ IMPLEMENTED

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

Phase 2: Tab Variables

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

Phase 3: Enhancements

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

Future: Query Caching

Goal: Cache query results by resolved SQL hash (see Part 2 above)

When needed: When queries are slow/expensive, not needed for local development


Considerations

Pros of This Approach

  1. Simplicity: URL is single source of truth, no complex state management
  2. Shareability: Any dashboard state can be shared via URL
  3. SEO-Friendly: Server-side rendering with meaningful URLs
  4. Simple Caching: Cache by resolved SQL - no variable dependency tracking needed
  5. Cross-Dashboard Cache Hits: Same SQL across dashboards = shared cache
  6. Leverages Existing Code: Tab visibility uses existing include logic
  7. Progressive Enhancement: Works without JavaScript (form submit)

Cons / Trade-offs

  1. Full Page Reloads: Initial implementation requires page reload (mitigated by fetch-and-swap in Phase 4)
  2. URL Length Limits: Many variables could hit URL length limits (~2000 chars)
  3. Server Load: Each variable change hits server (mitigated by caching)
  4. No Client-Side Filtering: All filtering is server-side (intentional for data freshness)

Alternative Approaches Considered

  1. Client-Side State: Store variables in JavaScript, fetch data via API - Rejected: More complex, harder to share, less cache-friendly

  2. Session-Based State: Store variables in server session - Rejected: Not shareable, statefulness adds complexity

  3. Vega-Lite Interactive: Use Vega-Lite's built-in interactivity - Rejected: Limited to client-side data, no server queries


Open Questions

  1. URL Parameter Encoding: How to handle complex values (arrays, objects)? - Proposal: JSON encode, then base64 for complex types

  2. Default Values: Should URL omit default values for cleaner URLs? - Proposal: Yes, only include non-default values

  3. Tab URL Parameter: Use tab or _tab or configurable? - Proposal: Configurable, default to tab

  4. Cache Storage: In-memory, Redis, or SQLite? - Proposal: Start with in-memory, add Redis for production

  5. Partial Updates: Fetch-and-swap or full page reload? - Proposal: Full reload for MVP, fetch-and-swap as enhancement


Success Criteria

  1. ✅ Variable changes update URL immediately
  2. ✅ Dashboard renders correctly from URL parameters
  3. ✅ Browser back/forward works with variable state
  4. ✅ Tabs render as clickable bar that updates URL
  5. ✅ Only one tab content visible at a time
  6. ✅ Query results cached by resolved SQL hash (Phase 2)
  7. ✅ Same SQL across dashboards shares cache (Phase 2)

References


Appendix: YAML Examples

Example 1: Basic Interactive Dashboard

title: 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

Example 2: Tabbed Dashboard

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

Example 3: Combined Variables and Tabs

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


Changelog