diff --git a/.claude/skills/create-dashboard/SKILL.md b/.claude/skills/create-dashboard/SKILL.md index 78471be..76e4c38 100644 --- a/.claude/skills/create-dashboard/SKILL.md +++ b/.claude/skills/create-dashboard/SKILL.md @@ -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 diff --git a/cmd/skill_templates/create-dashboard/SKILL.md b/cmd/skill_templates/create-dashboard/SKILL.md index 4bcf394..ed08571 100644 --- a/cmd/skill_templates/create-dashboard/SKILL.md +++ b/cmd/skill_templates/create-dashboard/SKILL.md @@ -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: diff --git a/docs/dashboards/filters.md b/docs/dashboards/filters.md index 545d847..cb24184 100644 --- a/docs/dashboards/filters.md +++ b/docs/dashboards/filters.md @@ -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.`: diff --git a/frontend/src/components/DashboardView.tsx b/frontend/src/components/DashboardView.tsx index c577f0f..c0198da 100644 --- a/frontend/src/components/DashboardView.tsx +++ b/frontend/src/components/DashboardView.tsx @@ -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"; @@ -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 | null>(null); - const activeFilters = filters ?? defaultFilters; + const activeFilters = filters ?? baseFilters; + + // Last query string, so the reseed effect can skip our own writes. + const lastSelfWrite = useRef(null); const { DashboardLayout, @@ -154,12 +176,41 @@ export function DashboardView() { const [activeTab, setActiveTab] = useState(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; + setFilters(null); + }, [searchParams]); + const handleExport = useCallback(async (format: DashboardExportFormat) => { if (!dashboard) return; setExporting(format); @@ -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 ? ( diff --git a/frontend/src/lib/filterParams.ts b/frontend/src/lib/filterParams.ts new file mode 100644 index 0000000..262ff52 --- /dev/null +++ b/frontend/src/lib/filterParams.ts @@ -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; +}