Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .claude/skills/create-dashboard/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ filters:

**Date range presets:** `today`, `yesterday`, `last_7_days`, `last_30_days`, `last_90_days`, `this_month`, `last_month`, `this_quarter`, `this_year`, `year_to_date`, `all_time`. If `options.presets` is omitted, a default set is shown. Users can always pick "Custom range" for arbitrary dates.

**Shareable URLs:** filter values are kept in the URL query string, so you can share a filtered dashboard as a link. Each filter becomes one query parameter named after it, for example `?region=Europe&date_range=last_30_days`. When a select has `multiple: true` the values are comma separated, and a `date-range` is either a preset key or `start..end`. Anything read from the URL is checked against the filter's type and options, and ignored if it doesn't match.

---

## Named Queries
Expand Down
2 changes: 2 additions & 0 deletions cmd/skill_templates/create-dashboard/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ Select filters support `multiple: true` for multi-select. The value is a list
{% endif %}
```

Filter values are kept in the URL query string, so you can share a filtered dashboard as a link. Each filter becomes one query parameter named after it, for example `?region=Europe&date_range=last_30_days`. When a select has `multiple: true` the values are comma separated, and a `date-range` is either a preset key or `start..end`. Anything read from the URL is checked against the filter's type and options, and ignored if it doesn't match.

## Current Viewer (`bruin.user_email`)

`{{ bruin.user_email }}` is the email of the signed-in user viewing the dashboard — a Bruin Cloud runtime feature that resolves per viewer, so one dashboard can show each person only their own rows:
Expand Down
17 changes: 17 additions & 0 deletions docs/dashboards/filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,23 @@ A free-form text input.
| `year_to_date` | January 1 to today |
| `all_time` | 1970-01-01 to 2099-12-31 |

## Sharing Filter State via URL

Filter values are reflected in the page's URL query string. Each filter is one query parameter keyed by its `name`:

```
/dashboards/sales?region=Europe&date_range=2025-01-01..2025-03-31
```

- **Multi-select** values are comma-separated: `?region=Europe,APAC`.
- **Date-range** values use `start..end`: `?date_range=2025-01-01..2025-03-31`.
- Opening a link applies the URL values on load; queries run with them immediately.

A few behaviors worth noting:

- **Defaults stay out of the URL.** The URL only captures values the viewer actively changes, keeping links clean. A link with no parameter for a filter uses that filter's current `default` from the YAML.
- **Invalid values are ignored.** URL values are validated before use, so a filter falls back to its default when the value doesn't fit its type. For `select` with a static `options.values` list, values that aren't real options are discarded (per-value for multi-select; if none are valid, the default is used). `date` and `date-range` values must be `YYYY-MM-DD` dates (`date-range` written as `start..end`); anything else is rejected. `number` values must parse as a number.

## Using Filters in Queries

Filter values are injected into SQL via [Jinja templating](/dashboards/queries). Access them with `filters.<filter_name>`:
Expand Down
69 changes: 61 additions & 8 deletions frontend/src/components/DashboardView.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
import { useParams } from "react-router-dom";
import { useParams, useSearchParams } from "react-router-dom";
import { useDashboard } from "../hooks/useDashboard";
import { useWidgetQuery } from "../hooks/useWidgetQuery";
import { useTemplate } from "../themes/TemplateProvider";
import { resolvePreset } from "../themes/bruin/FilterBar";
import { fromParam, toParam, keepAllowedValues } from "../lib/filterParams";
import { YamlPanel } from "./YamlPanel";
import { ExportMenuButton, type ExportMenuItem } from "./ExportMenuButton";
import { fetchDashboardData } from "../api/client";
Expand Down Expand Up @@ -121,13 +122,34 @@ export function DashboardView() {
const onResizeStart = useCallback(() => setIsResizing(true), []);
const onResizeEnd = useCallback(() => setIsResizing(false), []);

const defaultFilters = useMemo(
() => dashboard ? buildDefaultFilters(dashboard) : null,
[dashboard],
);
const [searchParams, setSearchParams] = useSearchParams();

const baseFilters = useMemo(() => {
if (!dashboard) return null;
// Start from the dashboard defaults, then apply any values from the URL.
const values = buildDefaultFilters(dashboard);
for (const f of dashboard.filters ?? []) {
// Multi-select uses repeated params (?x=a&x=b) so values may contain commas.
if (f.type === "select" && f.multiple) {
const raws = searchParams.getAll(f.name).map((s) => s.trim()).filter(Boolean);
if (!raws.length) continue;
const kept = keepAllowedValues(f, raws);
if (kept.length) values[f.name] = kept;
continue;
}
const raw = searchParams.get(f.name);
if (raw == null) continue;
const parsed = fromParam(f, raw);
if (parsed !== undefined) values[f.name] = parsed;
}
return values;
}, [dashboard, searchParams]);

const [filters, setFilters] = useState<Record<string, unknown> | null>(null);
const activeFilters = filters ?? defaultFilters;
const activeFilters = filters ?? baseFilters;
Comment thread
TanayBensuYurtturk marked this conversation as resolved.

// Last query string, so the reseed effect can skip our own writes.
const lastSelfWrite = useRef<string | null>(null);

const {
DashboardLayout,
Expand All @@ -154,12 +176,41 @@ export function DashboardView() {

const [activeTab, setActiveTab] = useState<string | null>(null);

// Reset local state when the dashboard definition changes (e.g. file edited on disk).
// Reset local overrides when the dashboard definition changes (e.g. file edited on disk).
useEffect(() => {
setFilters(null);
setActiveTab(null);
}, [dashboard]);

// Mirror all filters to the URL in one write (order-stable, concurrency-safe).
useEffect(() => {
if (filters == null || !dashboard) return;
const next = new URLSearchParams(searchParams);
for (const f of dashboard.filters ?? []) next.delete(f.name);
for (const f of dashboard.filters ?? []) {
const v = filters[f.name];
if (f.type === "select" && f.multiple) {
if (Array.isArray(v)) {
for (const x of v) if (x != null && x !== "") next.append(f.name, String(x));
}
} else {
const param = toParam(f, v);
if (param != null) next.set(f.name, param);
}
}
// Record even when unchanged, so a later nav isn't taken for a self-write.
const s = next.toString();
lastSelfWrite.current = s;
if (s !== searchParams.toString()) setSearchParams(next, { replace: true });
// eslint-disable-next-line react-hooks/exhaustive-deps -- run only on filters change
}, [filters]);

// External URL change (e.g. a shared link) re-seeds from the URL; our own writes are skipped.
useEffect(() => {
if (searchParams.toString() === lastSelfWrite.current) return;
Comment thread
TanayBensuYurtturk marked this conversation as resolved.
setFilters(null);
}, [searchParams]);

const handleExport = useCallback(async (format: DashboardExportFormat) => {
if (!dashboard) return;
setExporting(format);
Expand Down Expand Up @@ -221,7 +272,9 @@ export function DashboardView() {
const currentTab = activeTab ?? (hasTabs ? tabNames[0] : null);

const handleFilterChange = (filterName: string, value: unknown) => {
setFilters((prev) => ({ ...prev, [filterName]: value }));
// Accumulate locally; the projection effect writes the URL. Functional update
// so batched changes in one tick don't clobber each other.
setFilters((prev) => ({ ...(prev ?? activeFilters ?? {}), [filterName]: value }));
};

const filterBar = dashboard.filters ? (
Expand Down
64 changes: 64 additions & 0 deletions frontend/src/lib/filterParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { Filter } from "../types/dashboard";
import { resolvePreset } from "../themes/bruin/FilterBar";

/**
* Convert filter values to/from URL query-string params, so each filter is one
* param keyed by its name. Multi-select uses repeated params (?x=a&x=b), read
* and written in DashboardView so option values may safely contain commas.
*/

const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;

// True only for a real calendar date in YYYY-MM-DD form. The regex alone checks
// shape, not validity, so this also rejects impossible dates like 2026-99-99 or
// 2026-02-30 (which would otherwise roll over).
function isRealDate(s: string): boolean {
if (!ISO_DATE_RE.test(s)) return false;
const d = new Date(`${s}T00:00:00Z`);
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s;
}

/** URL param string → typed filter value. Returns undefined to ignore the param. */
export function fromParam(filter: Filter, raw: string): unknown {
switch (filter.type) {
case "number": {
if (raw === "") return undefined;
const n = Number(raw);
return Number.isFinite(n) ? n : undefined;
}
case "date": {
const v = raw.trim();
return isRealDate(v) ? v : undefined;
}
case "date-range": {
const preset = resolvePreset(raw); // e.g. "last_30_days"
if (preset) return preset;
const [start = "", end = ""] = raw.split(/\.\.|,/).map((s) => s.trim()); // or "2025-01-01..2025-03-31"
return isRealDate(start) && isRealDate(end) ? { start, end } : undefined;
}
case "select": {
// Single-select only; multi-select is read from repeated params in
// DashboardView. Only accept a value the filter actually offers.
const allowed = filter.options?.values;
return !allowed || allowed.includes(raw) ? raw : undefined;
}
default:
return raw; // text
}
}

/** Typed filter value → URL param string. Returns null to drop the param. */
export function toParam(filter: Filter, value: unknown): string | null {
if (value == null || value === "") return null;
if (filter.type === "date-range") {
const v = value as { start?: string; end?: string };
return v.start && v.end ? `${v.start}..${v.end}` : null;
}
return String(value);
}

/** Keep only multi-select values the filter offers (empty option list = accept as-is). */
export function keepAllowedValues(filter: Filter, vals: string[]): string[] {
const allowed = filter.options?.values;
return allowed ? vals.filter((v) => allowed.includes(v)) : vals;
}
Loading