Skip to content

Query builder filter values should be scoped by preceding filters (like AdHoc filters) #32

@samjewell

Description

@samjewell

Summary

When using AdHoc filters at the dashboard level, selecting a value for one filter scopes the available options for subsequent filters. For example, filtering last_name = "M." shows only 6 first names in the next filter's dropdown — the names that actually exist with that last name.

This is great UX because it:

  • Helps users avoid "no data" queries
  • Makes large datasets navigable through progressive refinement
  • Matches user expectations from other filter UIs

However, the query builder's filter UI in panel edit mode does not scope filter values by preceding filters — it always shows all possible values for each dimension.

Current Behavior

AdHoc Filters (Dashboard): ✅ Scoped

  • First filter: last_name = M. (selected)
  • Second filter dropdown shows: 6 first names (only those with last name "M.")

Query Builder (Panel Edit): ❌ Not scoped

  • First filter: last_name = M. (selected)
  • Second filter dropdown shows: All first names in the dataset (regardless of last name)

⚠️ New addition here ⚠️

Additionally there's this case too:
I just found this, so describe it here, but not in the rest of this issue writeup.

Both together ❌ Not scoped

  • AdHoc filters, filter: last_name = M. (selected)
  • Query Builder (Panel Edit), filter: All first names in the dataset (regardless of last name)

See these screenshots:

  • Filtered for Last names F, P, H in the AdHoc filter:
    Image
  • But all last names available in the Panel-Edit filtering UI:
    Image

**⚠️ OPEN QUESTIONS ⚠️ **

  • Should this be one task (one PR) or two separate ones?
  • Which should be done first?

Technical Analysis

Why AdHoc Filters Work

Grafana automatically passes existing filters to getTagValues() when fetching options for a new filter. Our datasource already handles this:

// src/datasource.ts
getTagValues(options: {
  key: string;
  filters?: Array<{ key: string; operator: string; value: string; values?: string[] }>;
}) {
  const scopingFilters = options.filters?.length
    ? options.filters.map((filter) => ({ /* convert to Cube format */ }))
    : undefined;

  return this.getResource('tag-values', {
    key: options.key,
    filters: scopingFilters ? JSON.stringify(scopingFilters) : undefined,
  });
}

The Go backend already parses and applies these scoping filters:

// pkg/plugin/datasource.go
filtersJSON := parsedURL.Query().Get("filters")
if filtersJSON != "" {
  var scopingFilters []map[string]interface{}
  if err := json.Unmarshal([]byte(filtersJSON), &scopingFilters); err != nil {
    backend.Logger.Warn("Failed to parse scoping filters, ignoring", "error", err)
  } else if len(scopingFilters) > 0 {
    cubeQuery["filters"] = scopingFilters
  }
}

Why Query Builder Doesn't Work

The useMemberValuesQuery hook doesn't pass any filters:

// src/queries.ts
export const useMemberValuesQuery = ({
  datasource,
  member,
}: {
  datasource: DataSource;
  member: string | null;
}) => {
  return useQuery({
    queryFn: async () => {
      return await datasource.getTagValues({ key: member }); // No filters passed!
    },
  });
};

Proposed Solution

The backend already supports filter scoping — this is purely a frontend change.

Files to Modify

  1. src/queries.ts - Add optional filters parameter to useMemberValuesQuery
  2. src/components/FilterField/FilterRow.tsx - Accept and pass preceding filters
  3. src/components/FilterField/FilterField.tsx - Compute and pass preceding filters to each row

Implementation Sketch

// queries.ts
export const useMemberValuesQuery = ({
  datasource,
  member,
  precedingFilters,  // NEW
}: {
  datasource: DataSource;
  member: string | null;
  precedingFilters?: CubeFilter[];
}) => {
  return useQuery({
    queryKey: ['memberValues', datasource.uid, member, precedingFilters],
    queryFn: async () => {
      const formattedFilters = precedingFilters?.map(f => ({
        key: f.member,
        operator: f.operator === 'equals' ? '=' : '!=',
        value: f.values[0] || '',
        values: f.values,
      }));
      return await datasource.getTagValues({ key: member, filters: formattedFilters });
    },
  });
};
// FilterField.tsx - pass preceding filters to each row
{filterStates.map((filter, index) => {
  // Get all complete filters before this one
  const precedingFilters = filterStates
    .slice(0, index)
    .filter((f): f is FilterState & { member: string } => 
      f.member !== null && f.values.length > 0
    )
    .map(f => ({ member: f.member, operator: f.operator, values: f.values }));

  return (
    <FilterRow
      key={index}
      filter={filter}
      precedingFilters={precedingFilters}  // NEW
      // ... other props
    />
  );
})}

Additional Context

Interestingly, the Cube UI itself (shown in screenshots) doesn't scope filter values by preceding filters either. This Grafana feature would actually provide better UX than Cube's native playground.

Acceptance Criteria

  • When a filter row has a preceding filter with values selected, the dropdown for the current filter only shows values that exist given the preceding filter constraints
  • The scoping updates reactively when preceding filter values change
  • Empty/incomplete preceding filters (no member or no values) are excluded from scoping
  • Query cache keys include preceding filters to prevent stale data

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions