Save User Filter Configurations with Omni Events messaging

Saving and Restoring Dashboard Filter Configurations with Omni Events Messaging for Embedded Dashboards

This guide explains how to save and restore user filter configurations for embedded dashboards, allowing your users to return to their preferred dashboard views within your embedded application without needing to be restricted_queriers.

Overview

When users interact with embedded dashboards, they often apply filters to customize their view. By capturing these filter changes, you can save their preferences and restore them in future sessions, creating a personalized experience. This implementation uses Omni’s event messaging system to capture filter states and apply them programmatically.

Prerequisites

  • Be an admin user in Omni
  • Have the ability to embed Omni dashboards
  • Enable vanity domains - This eliminates cross-origin restrictions that would block communication between your application and the embedded dashboard. Both your application and the dashboard need proper DNS configuration.

Benefits of This Approach

This event-based solution offers several advantages:

  • Flexibility - Customize the save/restore experience to match your application’s design
  • Integration - The feature lives within your application’s existing UI patterns
  • User Experience - Provides a seamless, cohesive experience for users working with embedded dashboards
  • Persistence - Filter configurations survive across browser sessions
  • Management - Users can create, update, rename, duplicate, and delete saved configurations

Video Demo

Implementation Steps

Step 1: Set Up Communication

Implement the postMessage protocol in your application to enable two-way communication with the embedded dashboard. This allows your application to:

  • Listen for events from the dashboard
  • Send stored events back to the dashboard

Step 2: Listen to Dashboard Filter Events

Configure your application to listen for filter change events by setting up an event listener for the dashboard:filters event.

Key Code Implementation:

useEffect(() => {
  const listener = (event: MessageEvent) => {
    // Validate message origin to ensure it's from Omni
    if (!event.origin.includes('omniapp.co') && !event.origin.includes('omniapp.com')) {
      console.log("Ignoring message from non-Omni origin");
      return;
    }

    // Only process messages with the 'omni' source
    if (event.data && event.data.source !== 'omni') {
      return;
    }

    // Handle filter events
    if (event.data.name === "dashboard:filters") {
      const filters = event.data.payload;
      setCurrentFilters(filters);
      console.log("âś… Filter state captured:", filters);
    }
  };

  window.addEventListener("message", listener);
  return () => window.removeEventListener("message", listener);
}, []);

What This Does:

  • Validates that messages come from Omni domains (security best practice)
  • Checks for source: 'omni' in the message data
  • Captures the complete filter payload when dashboard:filters events occur
  • Stores filter state for later saving

Event Payload Structure:

{
  source: "omni",
  name: "dashboard:filters",
  payload: {
    "filter.field.name": {
      filter: { /* filter configuration */ },
      asJsonUrlSearchParam: "f--filter.field.name=%7B..."
    }
  }
}

Step 3: Save Filter Configurations

When a filter change event occurs, allow users to save the configuration with a custom name.

Implementation:

interface FilterConfig {
  id: string;
  name: string;
  filters: any;
  createdAt: string;
  updatedAt: string;
}

const STORAGE_KEY = "omni-filter-configs";

const confirmSaveConfig = () => {
  if (!configName.trim()) {
    alert("Please enter a name for this configuration");
    return;
  }

  const newConfig: FilterConfig = {
    id: Date.now().toString(),
    name: configName.trim(),
    filters: currentFilters,
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
  };

  const updatedConfigs = [...savedConfigs, newConfig];
  saveToStorage(updatedConfigs);
  setConfigName("");
  setShowSaveDialog(false);
  setSelectedConfigId(newConfig.id);
};

const saveToStorage = (configs: FilterConfig[]) => {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(configs));
  setSavedConfigs(configs);
};

What This Does:

  • Creates a unique ID using timestamp
  • Stores the complete filter object from Omni
  • Persists to localStorage (can be adapted to use a database)
  • Automatically selects the newly saved configuration

Storage Options:

  • LocalStorage (shown above) - Simple, client-side only, suitable for single-user scenarios
  • Database - For multi-user applications, replace saveToStorage with an API call to your backend

Step 4: Restore Saved Configurations

To restore a user’s saved filter preferences, send the stored configuration back to the dashboard.

Implementation:

const handleLoadConfig = (configId: string) => {
  const config = savedConfigs.find((c) => c.id === configId);
  if (!config) {
    console.error("Config not found:", configId);
    return;
  }

  if (!iframeRef.current?.contentWindow) {
    console.error("Iframe not available");
    alert("Dashboard iframe not ready. Please wait and try again.");
    return;
  }

  setSelectedConfigId(configId);

  // Convert the filters object to filterUrlParameter string
  // Each filter has an asJsonUrlSearchParam that needs to be concatenated
  const filterParams = Object.values(config.filters)
    .map((filter: any) => filter.asJsonUrlSearchParam)
    .join("&");

  // Send the filter configuration to the dashboard
  iframeRef.current.contentWindow.postMessage(
    {
      name: "dashboard:filter-change-by-url-parameter",
      payload: {
        filterUrlParameter: filterParams
      },
    },
    "*"  // Use wildcard since iframe is on embed-omniapp.co
  );

  console.log("âś… Loaded filter configuration:", config.name);
};

What This Does:

  • Extracts asJsonUrlSearchParam from each filter in the saved configuration
  • Joins filter parameters with & to create a URL parameter string
  • Uses the dashboard:filter-change-by-url-parameter event
  • Sends the configuration to the iframe via postMessage
  • The dashboard automatically updates to match the saved configuration

Event Sent to Dashboard:

{
  name: "dashboard:filter-change-by-url-parameter",
  payload: {
    filterUrlParameter: "f--field1=...&f--field2=..."
  }
}

Complete Feature Set

A full implementation can include these management features:

1. Update Configuration

Update an existing configuration with current filter values:

const handleUpdateConfig = () => {
  if (!selectedConfigId) return;
  
  const configIndex = savedConfigs.findIndex(c => c.id === selectedConfigId);
  if (configIndex === -1) return;

  const updatedConfigs = [...savedConfigs];
  updatedConfigs[configIndex] = {
    ...updatedConfigs[configIndex],
    filters: currentFilters,
    updatedAt: new Date().toISOString(),
  };
  
  saveToStorage(updatedConfigs);
};

2. Rename Configuration

const handleRenameConfig = (configId: string, newName: string) => {
  const updatedConfigs = savedConfigs.map(config =>
    config.id === configId
      ? { ...config, name: newName, updatedAt: new Date().toISOString() }
      : config
  );
  saveToStorage(updatedConfigs);
};

3. Duplicate Configuration

const handleDuplicateConfig = (configId: string) => {
  const config = savedConfigs.find(c => c.id === configId);
  if (!config) return;

  const newConfig: FilterConfig = {
    id: Date.now().toString(),
    name: `${config.name} (Copy)`,
    filters: JSON.parse(JSON.stringify(config.filters)), // Deep clone
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
  };

  saveToStorage([...savedConfigs, newConfig]);
};

4. Delete Configuration

const handleDeleteConfig = (configId: string) => {
  const updatedConfigs = savedConfigs.filter(c => c.id !== configId);
  saveToStorage(updatedConfigs);
  
  // Clear selection if deleting the currently selected config
  if (selectedConfigId === configId) {
    setSelectedConfigId(null);
  }
};

User Interface Example

Here’s a basic UI structure for managing filter configurations:

<div className="filter-config-container">
  {/* Toolbar */}
  <div className="filter-config-toolbar">
    <div className="status-indicator">
      {currentFilters ? "✓ Filters captured" : "⏳ Waiting for filters..."}
    </div>
    
    <button onClick={() => setShowSaveDialog(true)} disabled={!currentFilters}>
      đź’ľ Save Current Filters
    </button>
    
    <button onClick={handleUpdateConfig} disabled={!selectedConfigId}>
      🔄 Update Selected
    </button>
  </div>

  {/* Configuration Cards */}
  <div className="config-grid">
    {savedConfigs.map((config) => (
      <div 
        key={config.id} 
        className={`config-card ${selectedConfigId === config.id ? 'selected' : ''}`}
      >
        <h3>{config.name}</h3>
        <div className="config-metadata">
          <small>Created: {new Date(config.createdAt).toLocaleString()}</small>
          <small>Updated: {new Date(config.updatedAt).toLocaleString()}</small>
        </div>
        <div className="config-actions">
          <button onClick={() => handleLoadConfig(config.id)}>▶️ Load</button>
          <button onClick={() => handleRenameConfig(config.id)}>✏️ Rename</button>
          <button onClick={() => handleDuplicateConfig(config.id)}>đź“‹ Duplicate</button>
          <button onClick={() => handleDeleteConfig(config.id)}>🗑️ Delete</button>
        </div>
      </div>
    ))}
  </div>

  {/* Embedded Dashboard */}
  <iframe
    ref={iframeRef}
    src={dashboardUrl}
    style={{ width: '100%', height: '800px', border: 'none' }}
  />
</div>

Security Considerations

  1. Origin Validation: Always validate that messages come from Omni domains
  2. Source Validation: Only process messages with source: 'omni'
  3. User Isolation: If using a database, ensure configurations are tied to specific users
  4. No Sensitive Data: Filter configurations contain filter values but no authentication tokens

Known Limitations

  • Clear All Filters: Omni’s embed API does not currently support programmatically clearing all filters via postMessage events. Users must manually clear filters in the dashboard. Or create a base state saved filter configuration.

Troubleshooting

Filters Not Being Captured

  • Check browser console for postMessage events
  • Verify iframe origin matches validation logic
  • Ensure source: 'omni' is present in message data
  • Confirm dashboard:filters event name is correct

Load Configuration Not Working

  • Verify asJsonUrlSearchParam exists in saved filters
  • Check console for postMessage being sent
  • Ensure iframe is fully loaded before attempting to load config
  • Verify filterUrlParameter is correctly formatted

LocalStorage Issues

  • Check browser localStorage quota
  • Verify no browser extensions blocking localStorage
  • Test in incognito mode to rule out extensions

Technical Requirements

  • Modern browsers with postMessage API support
  • localStorage support (or backend database)
  • React 18.2.0+ (or equivalent framework)
  • TypeScript 5.1.6+ (optional but recommended)
  • Omni Embed SDK 0.3.18+

Additional Resources

2 Likes

Follow-up: Gotchas We Found When Implementing Filter Persistence

Great guide! For anyone implementing this, here are some non-obvious issues we ran into:

1. Filter Normalization & Display-Only Fields

Omni re-emits dashboard:filters events with normalized JSON and sometimes includes display-only fields like appliedLabels. This means byte-equal comparison of filter strings is unreliable.

Fix: Parse and canonicalize both the saved and current filter params before comparing. Sort keys alphabetically, recursively re-serialize JSON, and filter out display-only keys:

const DISPLAY_ONLY_KEYS = new Set(['appliedLabels'])

function canonicalJson(value: unknown): string {
  if (value && typeof value === 'object') {
    const keys = Object.keys(value)
      .filter((key) => !DISPLAY_ONLY_KEYS.has(key))
      .sort()
    return `{${keys.map(k => `${JSON.stringify(k)}:${canonicalJson((value as any)[k])}`).join(',')}}`
  }
  return JSON.stringify(value)
}

This lets you reliably detect whether the user’s current filters match a saved configuration.

2. Omni Emits All Filters, Even Inactive Ones

The dashboard:filters event includes every filter on the dashboard, not just the ones the user has actively set. You can’t treat presence in the payload as “active.”

Fix: Inspect the filter values themselves:

  • For list filters: check if values array has length > 0
  • For date ranges: check if left_side or right_side are non-null
  • Only treat filters as “active” if they contain actual selections

3. Race Condition on Initial Load

If you try to apply a saved filter immediately when the dashboard loads, you might race Omni’s initialization. The dashboard can emit dashboard:filters events during boot as it’s settling into its initial state.

Fix: Defer applying saved filters until after the first dashboard:filters event. This way, Omni has already emitted its baseline state before you overlay your saved configuration. Use a flag like omniReady that’s set when you receive the first size postMessage (indicating Omni JS is warm and ready for commands).

4. Iframe Readiness vs Component Readiness

Don’t assume iframeRef.current?.contentWindow exists just because your component mounted. The iframe needs to finish loading and Omni’s JS needs to initialize before it can receive postMessage commands.

Fix: Wait for an initial size message (or equivalent lifecycle event) from Omni before sending filter-change commands. Queue pending commands and only flush them once the iframe is ready.

5. Clearing Filters Isn’t Supported

As mentioned in the guide, Omni doesn’t currently support programmatically clearing all filters via a postMessage event. Empty filterUrlParameter won’t reset the dashboard.

Fix: When the user wants to clear filters, force a fresh iframe mount. Trigger a key change or URL re-sign so the dashboard reloads in its default state. You lose your scroll position, but it’s the most reliable way to get a clean slate.

6. Cache Invalidation on Dashboard Switch

If your app caches filter configs in localStorage, make sure to clear the cache when switching dashboards. Filter params are dashboard-specific and won’t work across different dashboards.

7. User/Organization Isolation

Store saved filter configs on the backend tied to user ID, organization, and potentially region/platform. Don’t rely on client-side-only storage if you want configs to persist across devices or be managed per user.

Hope this helps anyone building on top of this pattern!