From ccfab3a10c16aa51b1be2f8cc90eaf714efa9290 Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:59:45 -0400 Subject: [PATCH 1/2] fix(dashboards): Fetch attributes while typing in categorical bar X-axis The categorical bar chart's X-axis picker loaded span attributes from the capped `/trace-items/attributes/` page and filtered it client-side only, so attributes beyond that page (e.g. `gen_ai.tool.name`) could never be selected even though they appear in Explore. Wire the picker's search input to a debounced server-side fetch, mirroring Explore's group by, so typing narrows the query with `substringMatch` and surfaces long-tail attributes. Keep the selected value pinned and show loading/empty feedback while searching. Refs DAIN-1964 Co-Authored-By: Claude --- static/app/views/dashboards/constants.tsx | 11 +++ .../components/xAxisSelector.spec.tsx | 98 +++++++++++++++++++ .../components/xAxisSelector.tsx | 81 ++++++++++++++- 3 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 static/app/views/dashboards/widgetBuilder/components/xAxisSelector.spec.tsx diff --git a/static/app/views/dashboards/constants.tsx b/static/app/views/dashboards/constants.tsx index ecb766d26df4..68d9d6788b07 100644 --- a/static/app/views/dashboards/constants.tsx +++ b/static/app/views/dashboards/constants.tsx @@ -3,3 +3,14 @@ import {t} from 'sentry/locale'; export const DASHBOARD_SAVING_MESSAGE = t('Saving changes\u2026'); export const NUM_DESKTOP_COLS = 6; + +// Cache attribute-key results from the `/trace-items/attributes/` endpoint for +// the widget builder's field pickers. Attribute keys change rarely, so a longer +// stale time avoids re-fetching on every keystroke while searching. +export const WIDGET_BUILDER_ATTRIBUTE_STALE_TIME = 5 * 60 * 1000; + +// Debounce applied to the widget builder field pickers' search input before it +// triggers a server-side attribute fetch. +export const WIDGET_BUILDER_ATTRIBUTE_SEARCH_DEBOUNCE_MS = 200; + +export const WIDGET_BUILDER_ATTRIBUTE_LOADING_MESSAGE = t('Loading…'); diff --git a/static/app/views/dashboards/widgetBuilder/components/xAxisSelector.spec.tsx b/static/app/views/dashboards/widgetBuilder/components/xAxisSelector.spec.tsx new file mode 100644 index 000000000000..05d1ed056709 --- /dev/null +++ b/static/app/views/dashboards/widgetBuilder/components/xAxisSelector.spec.tsx @@ -0,0 +1,98 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; +import {WidgetBuilderXAxisSelector} from 'sentry/views/dashboards/widgetBuilder/components/xAxisSelector'; +import {WidgetBuilderProvider} from 'sentry/views/dashboards/widgetBuilder/contexts/widgetBuilderContext'; + +const DASHBOARD_WIDGET_BUILDER_PATHNAME = + '/organizations/org-slug/dashboards/new/widget/new/'; +const DASHBOARD_WIDGET_BUILDER_ROUTE = '/organizations/:orgId/dashboards/new/widget/new/'; + +const ATTRIBUTES_URL = '/organizations/org-slug/trace-items/attributes/'; + +const organization = OrganizationFixture({ + features: ['performance-view', 'visibility-explore-view'], +}); + +const cappedAttribute = { + key: 'span.description', + name: 'span.description', + attributeType: 'string', + attributeSource: {source_type: 'sentry'}, +}; +const genAiAttribute = { + key: 'gen_ai.tool.name', + name: 'gen_ai.tool.name', + attributeType: 'string', + attributeSource: {source_type: 'sentry'}, +}; + +describe('WidgetBuilderXAxisSelector', () => { + beforeEach(() => { + MockApiClient.addMockResponse({ + url: ATTRIBUTES_URL, + method: 'GET', + body: [cappedAttribute], + }); + }); + + function renderSelector() { + return render( + + + , + { + organization, + initialRouterConfig: { + location: { + pathname: DASHBOARD_WIDGET_BUILDER_PATHNAME, + query: { + dataset: WidgetType.SPANS, + displayType: DisplayType.CATEGORICAL_BAR, + }, + }, + route: DASHBOARD_WIDGET_BUILDER_ROUTE, + }, + } + ); + } + + it('fetches attributes from the server while typing', async () => { + const searchAttributesMock = MockApiClient.addMockResponse({ + url: ATTRIBUTES_URL, + method: 'GET', + body: [cappedAttribute, genAiAttribute], + match: [MockApiClient.matchQuery({substringMatch: 'gen_ai'})], + }); + + renderSelector(); + + expect(await screen.findByText('X-Axis')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', {name: 'None'})); + + // The attribute is not part of the initial (capped) response. + expect( + await screen.findByRole('option', {name: /span\.description/}) + ).toBeInTheDocument(); + expect( + screen.queryByRole('option', {name: /gen_ai\.tool\.name/}) + ).not.toBeInTheDocument(); + + await userEvent.type(screen.getByRole('textbox'), 'gen_ai'); + + await waitFor(() => + expect(searchAttributesMock).toHaveBeenCalledWith( + ATTRIBUTES_URL, + expect.objectContaining({ + query: expect.objectContaining({substringMatch: 'gen_ai'}), + }) + ) + ); + expect( + await screen.findByRole('option', {name: /gen_ai\.tool\.name/}) + ).toBeInTheDocument(); + }); +}); diff --git a/static/app/views/dashboards/widgetBuilder/components/xAxisSelector.tsx b/static/app/views/dashboards/widgetBuilder/components/xAxisSelector.tsx index 5a53c09fa441..f73ea65c4718 100644 --- a/static/app/views/dashboards/widgetBuilder/components/xAxisSelector.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/xAxisSelector.tsx @@ -1,4 +1,4 @@ -import {Fragment, useMemo} from 'react'; +import {Fragment, useMemo, useState} from 'react'; import styled from '@emotion/styled'; import {CompactSelect} from '@sentry/scraps/compactSelect'; @@ -7,8 +7,14 @@ import {t} from 'sentry/locale'; import {trackAnalytics} from 'sentry/utils/analytics'; import {WidgetBuilderVersion} from 'sentry/utils/analytics/dashboardsAnalyticsEvents'; import {prettifyTagKey} from 'sentry/utils/fields'; +import {useDebouncedValue} from 'sentry/utils/useDebouncedValue'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useTags} from 'sentry/utils/useTags'; +import { + WIDGET_BUILDER_ATTRIBUTE_LOADING_MESSAGE, + WIDGET_BUILDER_ATTRIBUTE_SEARCH_DEBOUNCE_MS, + WIDGET_BUILDER_ATTRIBUTE_STALE_TIME, +} from 'sentry/views/dashboards/constants'; import {getDatasetConfig} from 'sentry/views/dashboards/datasetConfig/base'; import {WidgetType} from 'sentry/views/dashboards/types'; import {SectionHeader} from 'sentry/views/dashboards/widgetBuilder/components/common/sectionHeader'; @@ -22,6 +28,7 @@ import {TypeBadge} from 'sentry/views/explore/components/typeBadge'; import {HIDDEN_PREPROD_ATTRIBUTES} from 'sentry/views/explore/constants'; import {useTraceItemDatasetAttributes} from 'sentry/views/explore/hooks/useTraceItemAttributes'; import {HiddenTraceMetricGroupByFields} from 'sentry/views/explore/metrics/constants'; +import {sortSearchedAttributes} from 'sentry/views/explore/utils/sortSearchedAttributes'; export function WidgetBuilderXAxisSelector() { const organization = useOrganization(); @@ -40,10 +47,32 @@ export function WidgetBuilderXAxisSelector() { } const {traceItemType, ...traceItemOptions} = useWidgetBuilderTraceItemConfig(); + + const [search, setSearch] = useState(''); + const debouncedSearch = useDebouncedValue( + search, + WIDGET_BUILDER_ATTRIBUTE_SEARCH_DEBOUNCE_MS + ); + + const searchedTraceItemOptions = { + ...traceItemOptions, + search: debouncedSearch, + staleTime: WIDGET_BUILDER_ATTRIBUTE_STALE_TIME, + }; const {attributes: stringSpanTags, isLoading: isLoadingStringTags} = - useTraceItemDatasetAttributes(traceItemType, traceItemOptions, 'string', hiddenKeys); + useTraceItemDatasetAttributes( + traceItemType, + searchedTraceItemOptions, + 'string', + hiddenKeys + ); const {attributes: numericSpanTags, isLoading: isLoadingNumericTags} = - useTraceItemDatasetAttributes(traceItemType, traceItemOptions, 'number', hiddenKeys); + useTraceItemDatasetAttributes( + traceItemType, + searchedTraceItemOptions, + 'number', + hiddenKeys + ); const datasetConfig = getDatasetConfig(state.dataset); @@ -63,6 +92,7 @@ export function WidgetBuilderXAxisSelector() { return Object.values(allTags).map(tag => ({ label: prettifyTagKey(tag.name), value: tag.key, + textValue: tag.key, trailingItems: () => , })); } @@ -101,6 +131,22 @@ export function WidgetBuilderXAxisSelector() { return fieldEntry?.field ?? ''; }, [state.fields]); + // Keep the selected field present in the options even when it isn't in the + // current (possibly search-filtered) attribute response, so the dropdown + // reflects the saved state instead of showing nothing. + const fieldOptionsWithSelected = useMemo(() => { + if ( + currentXAxisField && + !fieldOptions.some(option => option.value === currentXAxisField) + ) { + return [ + ...fieldOptions, + {label: prettifyTagKey(currentXAxisField), value: currentXAxisField}, + ]; + } + return fieldOptions; + }, [currentXAxisField, fieldOptions]); + const handleXAxisChange = (option: {value: string | number} | undefined) => { if (!option) { return; @@ -129,10 +175,35 @@ export function WidgetBuilderXAxisSelector() { tooltipText={t('Select the field to use for X-axis categories.')} /> + sortSearchedAttributes({ + fieldDefinitionType: traceItemType, + option, + searchText, + }), + } + : true + } + // CompactSelect clears its own search input on close but doesn't notify + // us, so reset our search state too. Otherwise the stale term keeps the + // previously fetched attributes merged into the options on reopen. + onOpenChange={isOpen => { + if (!isOpen) { + setSearch(''); + } + }} loading={isLoading} + emptyMessage={ + isLoading + ? WIDGET_BUILDER_ATTRIBUTE_LOADING_MESSAGE + : t('No matching attributes') + } value={currentXAxisField} - options={fieldOptions} + options={fieldOptionsWithSelected} onChange={handleXAxisChange} /> From 182adf0075b41931fa3f78e7379964dec484a05a Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:08:14 -0400 Subject: [PATCH 2/2] fix(dashboards): Highlight matched substring in X-axis attribute search Set `highlight: true` on the picker's search config so the matched substring of each option is visually highlighted while typing, matching every Explore attribute picker. Refs DAIN-1964 Co-Authored-By: Claude --- .../views/dashboards/widgetBuilder/components/xAxisSelector.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/static/app/views/dashboards/widgetBuilder/components/xAxisSelector.tsx b/static/app/views/dashboards/widgetBuilder/components/xAxisSelector.tsx index f73ea65c4718..9eac5c95adf5 100644 --- a/static/app/views/dashboards/widgetBuilder/components/xAxisSelector.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/xAxisSelector.tsx @@ -179,6 +179,7 @@ export function WidgetBuilderXAxisSelector() { isEAPDataset ? { onChange: setSearch, + highlight: true, filter: (option, searchText) => sortSearchedAttributes({ fieldDefinitionType: traceItemType,