diff --git a/packages/scenes/src/components/VizPanel/VizPanel.tsx b/packages/scenes/src/components/VizPanel/VizPanel.tsx index a6bb6449c..601aae04c 100644 --- a/packages/scenes/src/components/VizPanel/VizPanel.tsx +++ b/packages/scenes/src/components/VizPanel/VizPanel.tsx @@ -63,6 +63,10 @@ export interface VizPanelState extends SceneOb * Offset hoverHeader position on the y axis */ hoverHeaderOffset?: number; + /** + * Allows adding elements to the subheader of the panel. + */ + subHeader?: React.ReactNode | SceneObject | SceneObject[]; /** * Only shows vizPanelMenu on hover if false, otherwise the menu is always visible in the header */ diff --git a/packages/scenes/src/components/VizPanel/VizPanelRenderer.tsx b/packages/scenes/src/components/VizPanel/VizPanelRenderer.tsx index e106a8e14..a79813c12 100644 --- a/packages/scenes/src/components/VizPanel/VizPanelRenderer.tsx +++ b/packages/scenes/src/components/VizPanel/VizPanelRenderer.tsx @@ -29,6 +29,7 @@ export function VizPanelRenderer({ model }: SceneComponentProps) { hoverHeaderOffset, menu, headerActions, + subHeader, titleItems, seriesLimit, seriesLimitShowAll, @@ -129,6 +130,22 @@ export function VizPanelRenderer({ model }: SceneComponentProps) { dataObject.setContainerWidth(Math.round(width)); } + let subHeaderElement: React.ReactNode[] = []; + + if (subHeader) { + if (Array.isArray(subHeader)) { + subHeaderElement = subHeaderElement.concat( + subHeader.map((subHeaderItem) => { + return ; + }) + ); + } else if (isSceneObject(subHeader)) { + subHeaderElement.push(); + } else { + subHeaderElement.push(subHeader); + } + } + let titleItemsElement: React.ReactNode[] = []; if (titleItems) { @@ -217,7 +234,6 @@ export function VizPanelRenderer({ model }: SceneComponentProps) {
} className={absoluteWrapper} data-viz-panel-key={model.state.key}> {width > 0 && height > 0 && ( - // @ts-expect-error showMenuAlways remove when updating to @grafana/ui@12 fixed in https://github.com/grafana/grafana/pull/103553 ) { onFocus={setPanelAttention} onMouseEnter={setPanelAttention} onMouseMove={debouncedMouseMove} + // @ts-expect-error remove this on next grafana/ui update + subHeaderContent={subHeaderElement.length ? subHeaderElement : undefined} onDragStart={(e: React.PointerEvent) => { dragHooks.onDragStart?.(e, model); }} diff --git a/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx index 8368126b2..97ad54078 100644 --- a/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx +++ b/packages/scenes/src/variables/adhoc/AdHocFiltersCombobox/AdHocFiltersComboboxRenderer.tsx @@ -1,7 +1,7 @@ import { css, cx } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { Icon, useStyles2 } from '@grafana/ui'; -import React, { memo, useRef } from 'react'; +import React, { memo, useEffect, useRef } from 'react'; import { AdHocFiltersController } from '../controller/AdHocFiltersController'; import { AdHocFilterPill } from './AdHocFilterPill'; import { AdHocFiltersAlwaysWipCombobox } from './AdHocFiltersAlwaysWipCombobox'; @@ -11,13 +11,21 @@ interface Props { } export const AdHocFiltersComboboxRenderer = memo(function AdHocFiltersComboboxRenderer({ controller }: Props) { - const { originFilters, filters, readOnly } = controller.useState(); + const { originFilters, filters, readOnly, _shouldFocus } = controller.useState(); const styles = useStyles2(getStyles); // ref that focuses on the always wip filter input // defined in the combobox component via useImperativeHandle const focusOnWipInputRef = useRef<() => void>(); + // Handle focus when _shouldFocus flag is set + useEffect(() => { + if (_shouldFocus) { + focusOnWipInputRef.current?.(); + controller.resetFocusFlag?.(); + } + }, [_shouldFocus, controller]); + return (
string; @@ -573,33 +577,40 @@ export class AdHocFiltersVariable } } - public async _verifyApplicability() { - const filters = [...this.state.filters, ...(this.state.originFilters ?? [])]; - + public async getFiltersApplicabilityForQueries( + filters: AdHocFilterWithLabels[], + queries: SceneDataQuery[] + ): Promise { const ds = await this._dataSourceSrv.get(this.state.datasource, this._scopedVars); // @ts-expect-error (temporary till we update grafana/data) if (!ds || !ds.getDrilldownsApplicability) { return; } - if (!filters) { - return; - } - const timeRange = sceneGraph.getTimeRange(this).state.value; - const queries = this.state.useQueriesAsFilterForOptions ? getQueriesForVariables(this) : undefined; // @ts-expect-error (temporary till we update grafana/data) - const response: DrilldownsApplicability[] = await ds.getDrilldownsApplicability({ + return await ds.getDrilldownsApplicability({ filters, queries, timeRange, scopes: sceneGraph.getScopes(this), ...getEnrichedFiltersRequest(this), }); + } + + public async _verifyApplicability() { + const filters = [...this.state.filters, ...(this.state.originFilters ?? [])]; + const queries = this.state.useQueriesAsFilterForOptions ? getQueriesForVariables(this) : undefined; + + const response = await this.getFiltersApplicabilityForQueries(filters, queries ?? []); + + if (!response) { + return; + } const responseMap = new Map(); - response.forEach((filter) => { + response.forEach((filter: DrilldownsApplicability) => { responseMap.set(`${filter.key}${filter.origin ? `-${filter.origin}` : ''}`, filter); }); @@ -754,6 +765,30 @@ export class AdHocFiltersVariable }); } + /** + * Focus the filter input to start adding a new filter. + * Works with both standard and combobox layouts. + */ + public focusInput() { + if (this.state.readOnly) { + return; + } + + if (this.state.layout === 'combobox') { + this.setState({ _shouldFocus: true }); + } + } + + /** + * Reset the focus flag after focusing has completed + * @internal + */ + public _resetFocusFlag() { + if (this.state._shouldFocus) { + this.setState({ _shouldFocus: false }); + } + } + public _getOperators() { const { supportsMultiValueOperators, allowCustomValue = true } = this.state; diff --git a/packages/scenes/src/variables/adhoc/controller/AdHocFiltersController.ts b/packages/scenes/src/variables/adhoc/controller/AdHocFiltersController.ts index 651b25071..f39ee7c33 100644 --- a/packages/scenes/src/variables/adhoc/controller/AdHocFiltersController.ts +++ b/packages/scenes/src/variables/adhoc/controller/AdHocFiltersController.ts @@ -13,6 +13,7 @@ export interface AdHocFiltersControllerState { onAddCustomValue?: OnAddCustomValueFn; wip?: AdHocFilterWithLabels; inputPlaceholder?: string; + _shouldFocus?: boolean; } /** @@ -84,6 +85,17 @@ export interface AdHocFiltersController { */ restoreOriginalFilter(filter: AdHocFilterWithLabels): void; + /** + * Optional: Focus the filter input (for combobox layout). + * This allows external code to programmatically focus the filter input. + */ + focusInput?(): void; + + /** + * Reset the focus flag. + */ + resetFocusFlag?(): void; + /** * Optional: Start profiling an interaction (for performance tracking). * @param name - The interaction name diff --git a/packages/scenes/src/variables/adhoc/controller/AdHocFiltersVariableController.ts b/packages/scenes/src/variables/adhoc/controller/AdHocFiltersVariableController.ts index 10cefb2dd..68d167ac3 100644 --- a/packages/scenes/src/variables/adhoc/controller/AdHocFiltersVariableController.ts +++ b/packages/scenes/src/variables/adhoc/controller/AdHocFiltersVariableController.ts @@ -21,6 +21,7 @@ export class AdHocFiltersVariableController implements AdHocFiltersController { supportsMultiValueOperators: state.supportsMultiValueOperators, onAddCustomValue: state.onAddCustomValue, wip: state._wip, + _shouldFocus: state._shouldFocus, }; } @@ -64,6 +65,14 @@ export class AdHocFiltersVariableController implements AdHocFiltersController { this.model.restoreOriginalFilter(filter); } + public focusInput(): void { + this.model.focusInput(); + } + + public resetFocusFlag(): void { + this.model._resetFocusFlag(); + } + public startProfile(name: string): void { const queryController = getQueryController(this.model); queryController?.startProfile(name); diff --git a/packages/scenes/src/variables/groupby/GroupByVariable.tsx b/packages/scenes/src/variables/groupby/GroupByVariable.tsx index 4a7bcedd7..b297db4ce 100644 --- a/packages/scenes/src/variables/groupby/GroupByVariable.tsx +++ b/packages/scenes/src/variables/groupby/GroupByVariable.tsx @@ -1,5 +1,5 @@ import { t } from '@grafana/i18n'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { AdHocVariableFilter, DataSourceApi, @@ -11,7 +11,7 @@ import { } from '@grafana/data'; import { allActiveGroupByVariables } from './findActiveGroupByVariablesByUid'; import { DataSourceRef, VariableType } from '@grafana/schema'; -import { SceneComponentProps, ControlsLayout, SceneObjectUrlSyncHandler } from '../../core/types'; +import { SceneComponentProps, ControlsLayout, SceneObjectUrlSyncHandler, SceneDataQuery } from '../../core/types'; import { sceneGraph } from '../../core/sceneGraph'; import { SceneVariableValueChangedEvent, @@ -77,6 +77,11 @@ export interface GroupByVariableState extends MultiValueVariableState { * Holds the applicability for each of the selected keys */ keysApplicability?: DrilldownsApplicability[]; + /** + * @internal + * Flag to trigger focus on the input + */ + _shouldFocus?: boolean; } export type getTagKeysProvider = ( @@ -219,7 +224,10 @@ export class GroupByVariable extends MultiValueVariable { return applicableValues; } - public async _verifyApplicability() { + public async getGroupByApplicabilityForQueries( + value: VariableValue, + queries: SceneDataQuery[] + ): Promise { const ds = await getDataSource(this.state.datasource, { __sceneObject: wrapInSafeSerializableSceneObject(this), }); @@ -229,18 +237,27 @@ export class GroupByVariable extends MultiValueVariable { return; } - const queries = getQueriesForVariables(this); const timeRange = sceneGraph.getTimeRange(this).state.value; - const value = this.state.value; // @ts-expect-error (temporary till we update grafana/data) - const response = await ds.getDrilldownsApplicability({ + return await ds.getDrilldownsApplicability({ groupByKeys: Array.isArray(value) ? value.map((v) => String(v)) : value ? [String(value)] : [], queries, timeRange, scopes: sceneGraph.getScopes(this), ...getEnrichedFiltersRequest(this), }); + } + + public async _verifyApplicability() { + const queries = getQueriesForVariables(this); + const value = this.state.value; + + const response = await this.getGroupByApplicabilityForQueries(value, queries); + + if (!response) { + return; + } if (!isEqual(response, this.state.keysApplicability)) { this.setState({ keysApplicability: response ?? undefined }); @@ -330,6 +347,17 @@ export class GroupByVariable extends MultiValueVariable { public getDefaultMultiState(options: VariableValueOption[]): { value: VariableValueSingle[]; text: string[] } { return { value: [], text: [] }; } + + /** + * Focus the group by input to start selecting dimensions. + */ + public focusInput() { + if (this.state.readOnly) { + return; + } + + this.setState({ _shouldFocus: true }); + } } export function GroupByVariableRenderer({ model }: SceneComponentProps) { @@ -345,6 +373,7 @@ export function GroupByVariableRenderer({ model }: SceneComponentProps>>(() => { @@ -368,6 +397,15 @@ export function GroupByVariableRenderer({ model }: SceneComponentProps(null); + + useEffect(() => { + if (_shouldFocus) { + selectRef.current?.focus?.(); + model.setState({ _shouldFocus: false }); + } + }, [_shouldFocus, model]); + // Detect value changes outside useEffect(() => { setUncommittedValue(values); @@ -397,6 +435,8 @@ export function GroupByVariableRenderer({ model }: SceneComponentProps + // @ts-expect-error + selectRef={selectRef} aria-label={t( 'grafana-scenes.variables.group-by-variable-renderer.aria-label-group-by-selector', 'Group by selector' @@ -475,6 +515,7 @@ export function GroupByVariableRenderer({ model }: SceneComponentProps ) : (