diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts index 65c8c623d76..fa309c8bd21 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts @@ -267,6 +267,8 @@ describe('PanelEditor', () => { // Just adding an extra stateless behavior to verify unlinking does not remvoe it const otherBehavior = jest.fn(); const panel = new VizPanel({ key: 'panel-1', pluginId: 'text', $behaviors: [libPanelBehavior, otherBehavior] }); + new DashboardGridItem({ body: panel }); + const editScene = buildPanelEditScene(panel); editScene.onConfirmUnlinkLibraryPanel(); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index b0073be67ed..c8de84d8d46 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -21,7 +21,7 @@ import { saveLibPanel } from 'app/features/library-panels/state/api'; import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker'; import { getPanelChanges } from '../saving/getDashboardChanges'; -import { DashboardGridItem, DashboardGridItemState } from '../scene/layout-default/DashboardGridItem'; +import { DashboardLayoutItem, isDashboardLayoutItem } from '../scene/types'; import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; import { activateSceneObjectAndParentTree, @@ -54,14 +54,22 @@ export interface PanelEditorState extends SceneObjectState { export class PanelEditor extends SceneObjectBase { static Component = PanelEditorRenderer; - private _originalLayoutElementState!: DashboardGridItemState; - private _layoutElement!: DashboardGridItem; + private _layoutItemState?: SceneObjectState; + private _layoutItem: DashboardLayoutItem; private _originalSaveModel!: Panel; private _changesHaveBeenMade = false; public constructor(state: PanelEditorState) { super(state); + const panel = this.state.panelRef.resolve(); + const layoutItem = panel.parent; + if (!layoutItem || !isDashboardLayoutItem(layoutItem)) { + throw new Error('Panel must have a parent of type DashboardLayoutItem'); + } + + this._layoutItem = layoutItem; + this.setOriginalState(this.state.panelRef); this.addActivationHandler(this._activationHandler.bind(this)); } @@ -69,14 +77,12 @@ export class PanelEditor extends SceneObjectBase { private _activationHandler() { const panel = this.state.panelRef.resolve(); const deactivateParents = activateSceneObjectAndParentTree(panel); - const layoutElement = panel.parent; this.waitForPlugin(); return () => { - if (layoutElement instanceof DashboardGridItem) { - layoutElement.editingCompleted(this.state.isDirty || this._changesHaveBeenMade); - } + this._layoutItem.editingCompleted?.(this.state.isDirty || this._changesHaveBeenMade); + if (deactivateParents) { deactivateParents(); } @@ -102,11 +108,7 @@ export class PanelEditor extends SceneObjectBase { const panel = panelRef.resolve(); this._originalSaveModel = vizPanelToPanel(panel); - - if (panel.parent instanceof DashboardGridItem) { - this._originalLayoutElementState = sceneUtils.cloneSceneObjectState(panel.parent.state); - this._layoutElement = panel.parent; - } + this._layoutItemState = sceneUtils.cloneSceneObjectState(this._layoutItem.state); } /** @@ -134,9 +136,8 @@ export class PanelEditor extends SceneObjectBase { } }; - this._subs.add(panel.subscribeToEvent(SceneObjectStateChangedEvent, handleStateChange)); - // Repeat options live on the layout element (DashboardGridItem) - this._subs.add(this._layoutElement.subscribeToEvent(SceneObjectStateChangedEvent, handleStateChange)); + // Subscribe to state changes on the parent (layout item) so we do not miss state changes on the layout item + this._subs.add(this._layoutItem.subscribeToEvent(SceneObjectStateChangedEvent, handleStateChange)); } public getPanel(): VizPanel { @@ -145,15 +146,12 @@ export class PanelEditor extends SceneObjectBase { private gotPanelPlugin(plugin: PanelPlugin) { const panel = this.getPanel(); - const layoutElement = panel.parent; // First time initialization if (this.state.isInitializing) { this.setOriginalState(this.state.panelRef); - if (layoutElement instanceof DashboardGridItem) { - layoutElement.editingStarted(); - } + this._layoutItem.editingStarted?.(); this._setupChangeDetection(); this._updateDataPane(plugin); @@ -255,7 +253,7 @@ export class PanelEditor extends SceneObjectBase { getDashboardSceneFor(this).removePanel(panel); } else { // Revert any layout element changes - this._layoutElement.setState(this._originalLayoutElementState!); + this._layoutItem!.setState(this._layoutItemState!); } locationService.partial({ editPanel: null }); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx index 3467f8c8f5e..fd373104caf 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx @@ -13,7 +13,7 @@ import { import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; import { getLibraryPanelBehavior, isLibraryPanel } from '../utils/utils'; -import { getPanelFrameCategory2 } from './getPanelFrameOptions'; +import { getPanelFrameOptions } from './getPanelFrameOptions'; interface Props { panel: VizPanel; @@ -25,13 +25,7 @@ interface Props { export const PanelOptions = React.memo(({ panel, searchQuery, listMode, data }) => { const { options, fieldConfig, _pluginInstanceState } = panel.useState(); - const layoutElement = panel.parent!; - const layoutElementState = layoutElement.useState(); - - const panelFrameOptions = useMemo( - () => getPanelFrameCategory2(panel, layoutElementState), - [panel, layoutElementState] - ); + const panelFrameOptions = useMemo(() => getPanelFrameOptions(panel), [panel]); const visualizationOptions = useMemo(() => { const plugin = panel.getPlugin(); diff --git a/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx b/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx index 629430b1ecc..a75b82a2531 100644 --- a/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx +++ b/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx @@ -1,26 +1,21 @@ -import { SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config } from '@grafana/runtime'; -import { SceneObjectState, SceneTimeRangeLike, VizPanel } from '@grafana/scenes'; -import { DataLinksInlineEditor, Input, TextArea, Switch, RadioButtonGroup, Select } from '@grafana/ui'; +import { SceneTimeRangeLike, VizPanel } from '@grafana/scenes'; +import { DataLinksInlineEditor, Input, TextArea, Switch } from '@grafana/ui'; import { GenAIPanelDescriptionButton } from 'app/features/dashboard/components/GenAI/GenAIPanelDescriptionButton'; import { GenAIPanelTitleButton } from 'app/features/dashboard/components/GenAI/GenAIPanelTitleButton'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; -import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect'; import { getPanelLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv'; import { VizPanelLinks } from '../scene/PanelLinks'; import { PanelTimeRange } from '../scene/PanelTimeRange'; -import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; +import { isDashboardLayoutItem } from '../scene/types'; import { vizPanelToPanel, transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { getDashboardSceneFor } from '../utils/utils'; -export function getPanelFrameCategory2( - panel: VizPanel, - layoutElementState: SceneObjectState -): OptionsPaneCategoryDescriptor { +export function getPanelFrameOptions(panel: VizPanel): OptionsPaneCategoryDescriptor { const descriptor = new OptionsPaneCategoryDescriptor({ title: 'Panel options', id: 'Panel options', @@ -30,7 +25,7 @@ export function getPanelFrameCategory2( const panelLinksObject = dashboardSceneGraph.getPanelLinks(panel); const links = panelLinksObject?.state.rawLinks ?? []; const dashboard = getDashboardSceneFor(panel); - const layoutElement = panel.parent; + const layoutElement = panel.parent!; descriptor .addItem( @@ -97,72 +92,8 @@ export function getPanelFrameCategory2( ) ); - if (layoutElement instanceof DashboardGridItem) { - const gridItem = layoutElement; - - const category = new OptionsPaneCategoryDescriptor({ - title: 'Repeat options', - id: 'Repeat options', - isOpenDefault: false, - }); - - category.addItem( - new OptionsPaneItemDescriptor({ - title: 'Repeat by variable', - description: - 'Repeat this panel for each value in the selected variable. This is not visible while in edit mode. You need to go back to dashboard and then update the variable or reload the dashboard.', - render: function renderRepeatOptions() { - return ( - gridItem.setRepeatByVariable(value)} - /> - ); - }, - }) - ); - - category.addItem( - new OptionsPaneItemDescriptor({ - title: 'Repeat direction', - showIf: () => Boolean(gridItem.state.variableName), - render: function renderRepeatOptions() { - const directionOptions: Array> = [ - { label: 'Horizontal', value: 'h' }, - { label: 'Vertical', value: 'v' }, - ]; - - return ( - gridItem.setState({ repeatDirection: value })} - /> - ); - }, - }) - ); - - category.addItem( - new OptionsPaneItemDescriptor({ - title: 'Max per row', - showIf: () => Boolean(gridItem.state.variableName && gridItem.state.repeatDirection === 'h'), - render: function renderOption() { - const maxPerRowOptions = [2, 3, 4, 6, 8, 12].map((value) => ({ label: value.toString(), value })); - return ( - gridItem.setState({ maxPerRow: value.value })} + /> + ); +} + +function RepeatByOption({ gridItem }: OptionComponentProps) { + const { variableName } = gridItem.useState(); + + return ( + gridItem.setRepeatByVariable(value)} + /> + ); +} diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx index 8d7f0f7ff86..3a2d73cc777 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -370,8 +370,8 @@ export class DefaultGridLayoutManager return new DefaultGridLayoutManager({ grid: new SceneGridLayout({ children: children, - isDraggable: false, - isResizable: false, + isDraggable: true, + isResizable: true, }), }); } diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx index 18cb5aaf415..d1671c3cb23 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx @@ -1,13 +1,16 @@ import { SceneObjectState, VizPanel, SceneObjectBase, SceneObject, SceneComponentProps } from '@grafana/scenes'; import { Switch } from '@grafana/ui'; +import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; +import { DashboardLayoutItem } from '../types'; + export interface ResponsiveGridItemState extends SceneObjectState { body: VizPanel; hideWhenNoData?: boolean; } -export class ResponsiveGridItem extends SceneObjectBase { +export class ResponsiveGridItem extends SceneObjectBase implements DashboardLayoutItem { public constructor(state: ResponsiveGridItemState) { super(state); this.addActivationHandler(() => this._activationHandler()); @@ -17,8 +20,6 @@ export class ResponsiveGridItem extends SceneObjectBase if (!this.state.hideWhenNoData) { return; } - - // TODO add hide when no data logic (in a behavior probably) } public toggleHideWhenNoData() { @@ -28,20 +29,28 @@ export class ResponsiveGridItem extends SceneObjectBase /** * DashboardLayoutElement interface */ - public isDashboardLayoutElement: true = true; + public isDashboardLayoutItem: true = true; - public getOptions?(): OptionsPaneItemDescriptor[] { + public getOptions?(): OptionsPaneCategoryDescriptor { const model = this; - return [ + const category = new OptionsPaneCategoryDescriptor({ + title: 'Layout options', + id: 'layout-options', + isOpenDefault: false, + }); + + category.addItem( new OptionsPaneItemDescriptor({ title: 'Hide when no data', render: function renderTransparent() { - const { hideWhenNoData } = model.state; + const { hideWhenNoData } = model.useState(); return model.toggleHideWhenNoData()} />; }, - }), - ]; + }) + ); + + return category; } public setBody(body: SceneObject): void { diff --git a/public/app/features/dashboard-scene/scene/types.ts b/public/app/features/dashboard-scene/scene/types.ts index e65a06e533a..aa80c617045 100644 --- a/public/app/features/dashboard-scene/scene/types.ts +++ b/public/app/features/dashboard-scene/scene/types.ts @@ -1,6 +1,6 @@ import { BusEventWithPayload, RegistryItem } from '@grafana/data'; import { SceneObject, VizPanel } from '@grafana/scenes'; -import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; +import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; /** * A scene object that usually wraps an underlying layout @@ -94,17 +94,21 @@ export function isLayoutParent(obj: SceneObject): obj is LayoutParent { */ export interface DashboardLayoutItem extends SceneObject { /** - * Marks this object as a layout element + * Marks this object as a layout item */ isDashboardLayoutItem: true; /** - * Return layout elements options (like repeat, repeat direction, etc for the default DashboardGridItem) + * Return layout item options (like repeat, repeat direction, etc for the default DashboardGridItem) */ - getOptions?(): OptionsPaneItemDescriptor[]; + getOptions?(): OptionsPaneCategoryDescriptor; /** - * Only implemented by elements that wrap VizPanels + * When going into panel edit + **/ + editingStarted?(): void; + /** + * When coming out of panel edit */ - getVizPanel?(): VizPanel; + editingCompleted?(withChanges: boolean): void; } export function isDashboardLayoutItem(obj: SceneObject): obj is DashboardLayoutItem { diff --git a/public/app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor.tsx b/public/app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor.tsx index 54d84347cd0..645dc5cad16 100644 --- a/public/app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor.tsx @@ -11,7 +11,7 @@ import { OptionsPaneCategoryDescriptor } from './OptionsPaneCategoryDescriptor'; import { OptionsPaneItemOverrides } from './OptionsPaneItemOverrides'; import { OptionPaneItemOverrideInfo } from './types'; -export interface OptionsPaneItemProps { +export interface OptionsPaneItemInfo { title: string; value?: any; description?: string; @@ -19,6 +19,8 @@ export interface OptionsPaneItemProps { render: () => React.ReactElement; skipField?: boolean; showIf?: () => boolean; + /** Hook for controlling visibility */ + useShowIf?: () => boolean; overrides?: OptionPaneItemOverrideInfo[]; addon?: ReactNode; } @@ -29,74 +31,87 @@ export interface OptionsPaneItemProps { export class OptionsPaneItemDescriptor { parent!: OptionsPaneCategoryDescriptor; - constructor(public props: OptionsPaneItemProps) {} - - getLabel(searchQuery?: string): ReactNode { - const { title, description, overrides, addon } = this.props; - - if (!searchQuery) { - // Do not render label for categories with only one child - if (this.parent.props.title === title && !overrides?.length) { - return null; - } - - return ; - } - - const categories: React.ReactNode[] = []; - - if (this.parent.parent) { - categories.push(this.highlightWord(this.parent.parent.props.title, searchQuery)); - } - - if (this.parent.props.title !== title) { - categories.push(this.highlightWord(this.parent.props.title, searchQuery)); - } - - return ( - - ); - } - - highlightWord(word: string, query: string) { - return ( - - ); - } - - renderOverrides() { - const { overrides } = this.props; - if (!overrides || overrides.length === 0) { - return; - } - } + constructor(public props: OptionsPaneItemInfo) {} render(searchQuery?: string) { - const { title, description, render, showIf, skipField } = this.props; - const key = `${this.parent.props.id} ${title}`; + return ; + } - if (showIf && !showIf()) { + useShowIf() { + if (this.props.useShowIf) { + return this.props.useShowIf(); + } + + if (this.props.showIf) { + return this.props.showIf(); + } + + return true; + } +} + +interface OptionsPaneItemProps { + itemDescriptor: OptionsPaneItemDescriptor; + searchQuery?: string; +} + +function OptionsPaneItem({ itemDescriptor, searchQuery }: OptionsPaneItemProps) { + const { title, description, render, skipField } = itemDescriptor.props; + const key = `${itemDescriptor.parent.props.id} ${title}`; + const showIf = itemDescriptor.useShowIf(); + + if (!showIf) { + return null; + } + + if (skipField) { + return render(); + } + + return ( + + {render()} + + ); +} + +function renderOptionLabel(itemDescriptor: OptionsPaneItemDescriptor, searchQuery?: string): ReactNode { + const { title, description, overrides, addon } = itemDescriptor.props; + + if (!searchQuery) { + // Do not render label for categories with only one child + if (itemDescriptor.parent.props.title === title && !overrides?.length) { return null; } - if (skipField) { - return render(); - } - - return ( - - {render()} - - ); + return ; } + + const categories: React.ReactNode[] = []; + + if (itemDescriptor.parent.parent) { + categories.push(highlightWord(itemDescriptor.parent.parent.props.title, searchQuery)); + } + + if (itemDescriptor.parent.props.title !== title) { + categories.push(highlightWord(itemDescriptor.parent.props.title, searchQuery)); + } + + return ( + + ); +} + +function highlightWord(word: string, query: string) { + return ; } interface OptionPanelLabelProps {