diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 23aa5bf0655..f42107f4a77 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -179,6 +179,7 @@ Experimental features might be changed or removed without prior notice. | `alertmanagerRemotePrimary` | Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager. | | `alertmanagerRemoteOnly` | Disable the internal Alertmanager and only use the external one defined. | | `extractFieldsNameDeduplication` | Make sure extracted field names are unique in the dataframe | +| `dashboardNewLayouts` | Enables experimental new dashboard layouts | | `pluginsSkipHostEnvVars` | Disables passing host environment variable to plugin processes | | `tableSharedCrosshair` | Enables shared crosshair in table panel | | `kubernetesFeatureToggles` | Use the kubernetes API for feature toggle management in the frontend | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index cb9b60760d8..8d353c72133 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -140,6 +140,7 @@ export interface FeatureToggles { dashboardSceneForViewers?: boolean; dashboardSceneSolo?: boolean; dashboardScene?: boolean; + dashboardNewLayouts?: boolean; panelFilterVariable?: boolean; pdfTables?: boolean; ssoSettingsApi?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index e988e49f0ff..2b675fde98e 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -913,6 +913,13 @@ var ( Owner: grafanaDashboardsSquad, Expression: "true", // enabled by default }, + { + Name: "dashboardNewLayouts", + Description: "Enables experimental new dashboard layouts", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaDashboardsSquad, + }, { Name: "panelFilterVariable", Description: "Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 56a8d7cd894..35f2c3d088f 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -121,6 +121,7 @@ extractFieldsNameDeduplication,experimental,@grafana/dataviz-squad,false,false,t dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true dashboardSceneSolo,GA,@grafana/dashboards-squad,false,false,true dashboardScene,GA,@grafana/dashboards-squad,false,false,true +dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,true panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,true pdfTables,preview,@grafana/sharing-squad,false,false,false ssoSettingsApi,GA,@grafana/identity-access-team,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index bd54e39391a..9dd410ac488 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -495,6 +495,10 @@ const ( // Enables dashboard rendering using scenes for all roles FlagDashboardScene = "dashboardScene" + // FlagDashboardNewLayouts + // Enables experimental new dashboard layouts + FlagDashboardNewLayouts = "dashboardNewLayouts" + // FlagPanelFilterVariable // Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard FlagPanelFilterVariable = "panelFilterVariable" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 855327fb6f3..6cd1326ee79 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -820,6 +820,22 @@ "expression": "true" } }, + { + "metadata": { + "name": "dashboardNewLayouts", + "resourceVersion": "1729671312626", + "creationTimestamp": "2024-10-16T08:44:05Z", + "annotations": { + "grafana.app/updatedTimestamp": "2024-10-23 08:15:12.626632 +0000 UTC" + } + }, + "spec": { + "description": "Enables experimental new dashboard layouts", + "stage": "experimental", + "codeowner": "@grafana/dashboards-squad", + "frontend": true + } + }, { "metadata": { "name": "dashboardRestore", @@ -857,10 +873,10 @@ { "metadata": { "name": "dashboardScene", - "resourceVersion": "1727354524763", + "resourceVersion": "1729671397794", "creationTimestamp": "2023-11-13T08:51:21Z", "annotations": { - "grafana.app/updatedTimestamp": "2024-09-26 12:42:04.763233 +0000 UTC" + "grafana.app/updatedTimestamp": "2024-10-23 08:16:37.794144 +0000 UTC" } }, "spec": { diff --git a/public/app/features/dashboard-scene/scene/AddLibraryPanelDrawer.tsx b/public/app/features/dashboard-scene/scene/AddLibraryPanelDrawer.tsx index a06154459ff..d9fbde0f6e2 100644 --- a/public/app/features/dashboard-scene/scene/AddLibraryPanelDrawer.tsx +++ b/public/app/features/dashboard-scene/scene/AddLibraryPanelDrawer.tsx @@ -23,8 +23,7 @@ export class AddLibraryPanelDrawer extends SceneObjectBase { const dashboard = getDashboardSceneFor(this); - - const newPanel = getDefaultVizPanel(dashboard); + const newPanel = getDefaultVizPanel(); newPanel.setState({ $behaviors: [new LibraryPanelBehavior({ uid: panelInfo.uid, name: panelInfo.name })], diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 7d7fcc88e55..81ff769a340 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -55,7 +55,6 @@ import { getDashboardSceneFor, getDefaultVizPanel, getPanelIdForVizPanel, - getVizPanelKeyForPanelId, isPanelClone, } from '../utils/utils'; @@ -460,10 +459,6 @@ export class DashboardScene extends SceneObjectBase { this.onEnterEditMode(); } - const panelId = dashboardSceneGraph.getNextPanelId(this); - vizPanel.setState({ key: getVizPanelKeyForPanelId(panelId) }); - vizPanel.clearParent(); - this.state.body.addPanel(vizPanel); } @@ -508,12 +503,8 @@ export class DashboardScene extends SceneObjectBase { const jsonObj = JSON.parse(jsonData); const panelModel = new PanelModel(jsonObj); const gridItem = buildGridItemForPanel(panelModel); - const panelId = dashboardSceneGraph.getNextPanelId(this); const panel = gridItem.state.body; - panel.setState({ key: getVizPanelKeyForPanelId(panelId) }); - panel.clearParent(); - this.addPanel(panel); store.delete(LS_PANEL_COPY_KEY); @@ -580,13 +571,17 @@ export class DashboardScene extends SceneObjectBase { } public onCreateNewPanel(): VizPanel { - const vizPanel = getDefaultVizPanel(this); + const vizPanel = getDefaultVizPanel(); this.addPanel(vizPanel); return vizPanel; } + public switchLayout(layout: DashboardLayoutManager) { + this.setState({ body: layout }); + } + /** * Called by the SceneQueryRunner to privide contextural parameters (tracking) props for the request */ diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.test.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.test.tsx index 37f25cc54d0..e77ccae72ed 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.test.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.test.tsx @@ -47,14 +47,13 @@ describe('DefaultGridLayoutManager', () => { const vizPanel = new VizPanel({ title: 'Panel Title', - key: 'panel-55', pluginId: 'timeseries', $data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }), }); manager.addPanel(vizPanel); - const panel = findVizPanelByKey(manager, 'panel-55')!; + const panel = findVizPanelByKey(manager, vizPanel.state.key)!; const gridItem = panel.parent as DashboardGridItem; expect(panel).toBeDefined(); 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 6728a02e669..afac8fe5e35 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -1,3 +1,4 @@ +import { config } from '@grafana/runtime'; import { SceneObjectState, SceneGridLayout, @@ -8,19 +9,24 @@ import { sceneUtils, SceneComponentProps, } from '@grafana/scenes'; +import { Button } from '@grafana/ui'; import { GRID_COLUMN_COUNT } from 'app/core/constants'; +import { Trans } from 'app/core/internationalization'; +import { DashboardInteractions } from '../../utils/interactions'; import { forceRenderChildren, getPanelIdForVizPanel, NEW_PANEL_HEIGHT, NEW_PANEL_WIDTH, getVizPanelKeyForPanelId, + getDefaultVizPanel, } from '../../utils/utils'; import { DashboardGridItem } from '../DashboardGridItem'; import { RowRepeaterBehavior } from '../RowRepeaterBehavior'; +import { LayoutEditChrome } from '../layouts-shared/LayoutEditChrome'; import { RowActions } from '../row-actions/RowActions'; -import { DashboardLayoutManager } from '../types'; +import { DashboardLayoutManager, LayoutEditorProps, LayoutRegistryItem } from '../types'; interface DefaultGridLayoutManagerState extends SceneObjectState { grid: SceneGridLayout; @@ -38,17 +44,12 @@ export class DefaultGridLayoutManager forceRenderChildren(this.state.grid, true); } - /** - * Removes the first panel - */ - public cleanUpStateFromExplore(): void { - this.state.grid.setState({ - children: this.state.grid.state.children.slice(1), - }); - } - public addPanel(vizPanel: VizPanel): void { - const panelId = getPanelIdForVizPanel(vizPanel); + const panelId = this.getNextPanelId(); + + vizPanel.setState({ key: getVizPanelKeyForPanelId(panelId) }); + vizPanel.clearParent(); + const newGridItem = new DashboardGridItem({ height: NEW_PANEL_HEIGHT, width: NEW_PANEL_WIDTH, @@ -309,6 +310,29 @@ export class DefaultGridLayoutManager }); } + public getDescriptor(): LayoutRegistryItem { + return DefaultGridLayoutManager.getDescriptor(); + } + + public static getDescriptor(): LayoutRegistryItem { + return { + name: 'Default grid', + description: 'The default grid layout', + id: 'default-grid', + createFromLayout: DefaultGridLayoutManager.createFromLayout, + }; + } + + /** + * Handle switching to the manual grid layout from other layouts + * @param currentLayout + * @returns + */ + public static createFromLayout(currentLayout: DashboardLayoutManager): DefaultGridLayoutManager { + const panels = currentLayout.getVizPanels(); + return DefaultGridLayoutManager.fromVizPanels(panels); + } + /** * For simple test grids * @param panels @@ -321,6 +345,8 @@ export class DefaultGridLayoutManager let currentX = 0; for (let panel of panels) { + panel.clearParent(); + children.push( new DashboardGridItem({ key: `griditem-${getPanelIdForVizPanel(panel)}`, @@ -349,7 +375,48 @@ export class DefaultGridLayoutManager }); } + public renderEditor() { + return ; + } + public static Component = ({ model }: SceneComponentProps) => { - return ; + if (!config.featureToggles.dashboardNewLayouts) { + return ; + } + + return ( + + + + ); }; } + +function DefaultGridLayoutEditor({ layoutManager }: LayoutEditorProps) { + return ( + <> + + + + + ); +} 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 new file mode 100644 index 00000000000..18cb5aaf415 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx @@ -0,0 +1,62 @@ +import { SceneObjectState, VizPanel, SceneObjectBase, SceneObject, SceneComponentProps } from '@grafana/scenes'; +import { Switch } from '@grafana/ui'; +import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; + +export interface ResponsiveGridItemState extends SceneObjectState { + body: VizPanel; + hideWhenNoData?: boolean; +} + +export class ResponsiveGridItem extends SceneObjectBase { + public constructor(state: ResponsiveGridItemState) { + super(state); + this.addActivationHandler(() => this._activationHandler()); + } + + private _activationHandler() { + if (!this.state.hideWhenNoData) { + return; + } + + // TODO add hide when no data logic (in a behavior probably) + } + + public toggleHideWhenNoData() { + this.setState({ hideWhenNoData: !this.state.hideWhenNoData }); + } + + /** + * DashboardLayoutElement interface + */ + public isDashboardLayoutElement: true = true; + + public getOptions?(): OptionsPaneItemDescriptor[] { + const model = this; + + return [ + new OptionsPaneItemDescriptor({ + title: 'Hide when no data', + render: function renderTransparent() { + const { hideWhenNoData } = model.state; + return model.toggleHideWhenNoData()} />; + }, + }), + ]; + } + + public setBody(body: SceneObject): void { + if (body instanceof VizPanel) { + this.setState({ body }); + } + } + + public getVizPanel() { + return this.state.body; + } + + public static Component = ({ model }: SceneComponentProps) => { + const { body } = model.useState(); + + return ; + }; +} diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx new file mode 100644 index 00000000000..25ac031c34f --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx @@ -0,0 +1,181 @@ +import { SelectableValue } from '@grafana/data'; +import { SceneComponentProps, SceneCSSGridLayout, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes'; +import { Button, Field, Select } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; + +import { DashboardInteractions } from '../../utils/interactions'; +import { getDefaultVizPanel, getPanelIdForVizPanel, getVizPanelKeyForPanelId } from '../../utils/utils'; +import { LayoutEditChrome } from '../layouts-shared/LayoutEditChrome'; +import { DashboardLayoutManager, LayoutRegistryItem, LayoutEditorProps } from '../types'; + +import { ResponsiveGridItem } from './ResponsiveGridItem'; + +interface ResponsiveGridLayoutManagerState extends SceneObjectState { + layout: SceneCSSGridLayout; +} + +export class ResponsiveGridLayoutManager + extends SceneObjectBase + implements DashboardLayoutManager +{ + public editModeChanged(isEditing: boolean): void {} + + public addPanel(vizPanel: VizPanel): void { + const panelId = this.getNextPanelId(); + + vizPanel.setState({ key: getVizPanelKeyForPanelId(panelId) }); + vizPanel.clearParent(); + + this.state.layout.setState({ + children: [new ResponsiveGridItem({ body: vizPanel }), ...this.state.layout.state.children], + }); + } + + public addNewRow(): void { + throw new Error('Method not implemented.'); + } + + public getNextPanelId(): number { + let max = 0; + + for (const child of this.state.layout.state.children) { + if (child instanceof VizPanel) { + let panelId = getPanelIdForVizPanel(child); + + if (panelId > max) { + max = panelId; + } + } + } + + return max; + } + + public removePanel(panel: VizPanel) { + const element = panel.parent; + this.state.layout.setState({ children: this.state.layout.state.children.filter((child) => child !== element) }); + } + + public duplicatePanel(panel: VizPanel): void { + throw new Error('Method not implemented.'); + } + + public getVizPanels(): VizPanel[] { + const panels: VizPanel[] = []; + + for (const child of this.state.layout.state.children) { + if (child instanceof ResponsiveGridItem) { + panels.push(child.state.body); + } + } + + return panels; + } + + public renderEditor() { + return ; + } + + public getDescriptor(): LayoutRegistryItem { + return ResponsiveGridLayoutManager.getDescriptor(); + } + + public static getDescriptor(): LayoutRegistryItem { + return { + name: 'Responsive grid', + description: 'CSS layout that adjusts to the available space', + id: 'responsive-grid', + createFromLayout: ResponsiveGridLayoutManager.createFromLayout, + }; + } + + public static createEmpty() { + return new ResponsiveGridLayoutManager({ layout: new SceneCSSGridLayout({ children: [] }) }); + } + + public static createFromLayout(layout: DashboardLayoutManager): ResponsiveGridLayoutManager { + const panels = layout.getVizPanels(); + const children: ResponsiveGridItem[] = []; + + for (let panel of panels) { + children.push(new ResponsiveGridItem({ body: panel.clone() })); + } + + return new ResponsiveGridLayoutManager({ + layout: new SceneCSSGridLayout({ + children, + templateColumns: 'repeat(auto-fit, minmax(400px, auto))', + autoRows: 'minmax(300px, auto)', + }), + }); + } + + public static Component = ({ model }: SceneComponentProps) => { + return ( + + + + ); + }; +} + +function AutomaticGridEditor({ layoutManager }: LayoutEditorProps) { + const cssLayout = layoutManager.state.layout; + const { templateColumns, autoRows } = cssLayout.useState(); + + const rowOptions: Array> = []; + const sizes = [100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 650]; + const colOptions: Array> = [ + { label: `1 column`, value: `1fr` }, + { label: `2 columns`, value: `1fr 1fr` }, + { label: `3 columns`, value: `1fr 1fr 1fr` }, + ]; + + for (const size of sizes) { + colOptions.push({ label: `Min: ${size}px`, value: `repeat(auto-fit, minmax(${size}px, auto))` }); + } + + for (const size of sizes) { + rowOptions.push({ label: `Min: ${size}px`, value: `minmax(${size}px, auto)` }); + } + + for (const size of sizes) { + rowOptions.push({ label: `Fixed: ${size}px`, value: `${size}px` }); + } + + const onColumnsChange = (value: SelectableValue) => { + cssLayout.setState({ templateColumns: value.value }); + }; + + const onRowsChange = (value: SelectableValue) => { + cssLayout.setState({ autoRows: value.value }); + }; + + return ( + <> + + + + + + ); +} diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/LayoutEditChrome.tsx b/public/app/features/dashboard-scene/scene/layouts-shared/LayoutEditChrome.tsx new file mode 100644 index 00000000000..fb73d9b2964 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layouts-shared/LayoutEditChrome.tsx @@ -0,0 +1,106 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2, Field, Select } from '@grafana/ui'; + +import { getDashboardSceneFor } from '../../utils/utils'; +import { DashboardLayoutManager, isLayoutParent, LayoutRegistryItem } from '../types'; + +import { layoutRegistry } from './layoutRegistry'; + +interface Props { + layoutManager: DashboardLayoutManager; + children: React.ReactNode; +} + +export function LayoutEditChrome({ layoutManager, children }: Props) { + const styles = useStyles2(getStyles); + const { isEditing } = getDashboardSceneFor(layoutManager).useState(); + + const layouts = layoutRegistry.list(); + const options = layouts.map((layout) => ({ + label: layout.name, + value: layout, + })); + + const currentLayoutId = layoutManager.getDescriptor().id; + const currentLayoutOption = options.find((option) => option.value.id === currentLayoutId); + + return ( +
+ {isEditing && ( +
+ +