From 58b8aafa66a8b8cb440d8e57583520d78804b52e Mon Sep 17 00:00:00 2001 From: Gurinder Singh Date: Tue, 4 Aug 2026 16:17:36 -0400 Subject: [PATCH 01/10] feat(dashboards): support custom dashboard search, filtering and MFE payload Adds custom-dashboard fields to the dashboard DTO/model, wires tag-based filtering through the search store, and adds the supporting migration. --- pkg/api/dashboard.go | 1 + pkg/api/dtos/dashboard.go | 3 +++ pkg/api/search.go | 3 +++ pkg/services/dashboards/database/database.go | 4 +++ pkg/services/dashboards/models.go | 25 +++++++++++++---- .../dashboards/service/dashboard_service.go | 19 ++++++------- pkg/services/search/service.go | 3 +++ .../sqlstore/migrations/dashboard_mig.go | 13 +++++++++ pkg/services/sqlstore/searchstore/filters.go | 13 +++++++++ .../sqlstore/searchstore/filters_test.go | 9 +++++++ public/microfrontends/fn_dashboard/index.html | 27 ++++++++++++++++--- 11 files changed, 102 insertions(+), 18 deletions(-) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 3e80ac14305f9..7c9e986885505 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -175,6 +175,7 @@ func (hs *HTTPServer) GetDashboard(c *contextmodel.ReqContext) response.Response FolderTitle: "General", AnnotationsPermissions: annotationPermissions, PublicDashboardEnabled: publicDashboardEnabled, + WorkspaceID: dash.WorkspaceID, } metrics.MFolderIDsAPICount.WithLabelValues(metrics.GetDashboard).Inc() // lookup folder title diff --git a/pkg/api/dtos/dashboard.go b/pkg/api/dtos/dashboard.go index 66a8922a4f1a3..dfe2f631fb684 100644 --- a/pkg/api/dtos/dashboard.go +++ b/pkg/api/dtos/dashboard.go @@ -36,6 +36,9 @@ type DashboardMeta struct { AnnotationsPermissions *dashboardsV0.AnnotationPermission `json:"annotationsPermissions"` PublicDashboardEnabled bool `json:"publicDashboardEnabled,omitempty"` HasPublicDashboard bool `json:"hasPublicDashboard,omitempty"` + // WorkspaceID scopes a dashboard to a CodeRabbit workspace. Empty for + // dashboards provisioned outside the micro frontend flow. + WorkspaceID string `json:"workspaceId,omitempty"` } type DashboardFullWithMeta struct { diff --git a/pkg/api/search.go b/pkg/api/search.go index 13c76fabf4f27..4c238f8ede70d 100644 --- a/pkg/api/search.go +++ b/pkg/api/search.go @@ -96,6 +96,9 @@ func (hs *HTTPServer) Search(c *contextmodel.ReqContext) response.Response { FolderUIDs: folderUIDs, Permission: permission, Sort: sort, + // CodeRabbit MFE: scopes AI-generated custom dashboards to the calling + // product workspace. Ignored when absent. + WorkspaceID: c.Query("workspaceId"), } hits, err := hs.SearchService.SearchHandler(c.Req.Context(), &searchQuery) diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index 8e91674cb3543..07beab1c9da4f 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -850,6 +850,10 @@ func (d *dashboardStore) FindDashboards(ctx context.Context, query *dashboards.F filters = append(filters, searchstore.TagsFilter{Tags: query.Tags}) } + if query.WorkspaceID != "" { + filters = append(filters, searchstore.WorkspaceFilter{WorkspaceID: query.WorkspaceID}) + } + if len(query.DashboardUIDs) > 0 { filters = append(filters, searchstore.DashboardFilter{UIDs: query.DashboardUIDs}) } else if len(query.DashboardIds) > 0 { diff --git a/pkg/services/dashboards/models.go b/pkg/services/dashboards/models.go index 9428fd961e55d..261867d348c57 100644 --- a/pkg/services/dashboards/models.go +++ b/pkg/services/dashboards/models.go @@ -48,6 +48,11 @@ type Dashboard struct { Title string Data *simplejson.Json + + // WorkspaceID is the CodeRabbit product workspace that owns this dashboard. + // Only set for AI-generated custom dashboards saved through the MFE; empty + // for every dashboard provisioned the normal Grafana way. + WorkspaceID string `xorm:"workspace_id"` } func (d *Dashboard) SetID(id int64) { @@ -136,6 +141,7 @@ func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard { dash.UpdatedBy = userID dash.OrgID = cmd.OrgID + dash.WorkspaceID = cmd.WorkspaceID dash.PluginID = cmd.PluginID dash.IsFolder = cmd.IsFolder metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() @@ -207,6 +213,11 @@ type SaveDashboardCommand struct { FolderUID string `json:"folderUid" xorm:"folder_uid"` IsFolder bool `json:"isFolder"` + // WorkspaceID scopes an AI-generated custom dashboard to a CodeRabbit + // product workspace. Accepted from the save payload so the handler can + // persist it without a second write. + WorkspaceID string `json:"workspaceId" xorm:"workspace_id"` + UpdatedAt time.Time } @@ -416,11 +427,15 @@ type FindPersistedDashboardsQuery struct { FolderIds []int64 FolderUIDs []string Tags []string - Limit int64 - Page int64 - Permission dashboardaccess.PermissionType - Sort model.SortOption - IsDeleted bool + // WorkspaceID restricts results to dashboards owned by a single CodeRabbit + // product workspace. Empty means "no workspace filter" and preserves the + // stock Grafana search behaviour. + WorkspaceID string + Limit int64 + Page int64 + Permission dashboardaccess.PermissionType + Sort model.SortOption + IsDeleted bool Filters []any } diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index f1e40b2f98a89..0a57a326daff6 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -217,15 +217,16 @@ func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, d metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() cmd := &dashboards.SaveDashboardCommand{ - Dashboard: dash.Data, - Message: dto.Message, - OrgID: dto.OrgID, - Overwrite: dto.Overwrite, - UserID: userID, - FolderID: dash.FolderID, // nolint:staticcheck - FolderUID: dash.FolderUID, - IsFolder: dash.IsFolder, - PluginID: dash.PluginID, + Dashboard: dash.Data, + Message: dto.Message, + OrgID: dto.OrgID, + Overwrite: dto.Overwrite, + UserID: userID, + FolderID: dash.FolderID, // nolint:staticcheck + FolderUID: dash.FolderUID, + IsFolder: dash.IsFolder, + PluginID: dash.PluginID, + WorkspaceID: dash.WorkspaceID, } if !dto.UpdatedAt.IsZero() { diff --git a/pkg/services/search/service.go b/pkg/services/search/service.go index 74c04f525cf73..42ec1be15b4da 100644 --- a/pkg/services/search/service.go +++ b/pkg/services/search/service.go @@ -48,6 +48,8 @@ type Query struct { FolderUIDs []string Permission dashboardaccess.PermissionType Sort string + // WorkspaceID scopes results to a single CodeRabbit product workspace. + WorkspaceID string } type Service interface { @@ -101,6 +103,7 @@ func (s *SearchService) SearchHandler(ctx context.Context, query *Query) (model. Page: query.Page, Permission: query.Permission, IsDeleted: query.IsDeleted, + WorkspaceID: query.WorkspaceID, } if sortOpt, exists := s.sortOptions[query.Sort]; exists { diff --git a/pkg/services/sqlstore/migrations/dashboard_mig.go b/pkg/services/sqlstore/migrations/dashboard_mig.go index bf19b56277d01..713754d2f9ed7 100644 --- a/pkg/services/sqlstore/migrations/dashboard_mig.go +++ b/pkg/services/sqlstore/migrations/dashboard_mig.go @@ -239,6 +239,19 @@ func addDashboardMigration(mg *Migrator) { Name: "deleted", Type: DB_DateTime, Nullable: true, })) + // CodeRabbit MFE: AI-generated custom dashboards are provisioned per + // product workspace rather than per Grafana org, so the owning workspace is + // stored alongside the dashboard and used to scope list/read access. + // Nullable so every pre-existing dashboard row stays valid. + mg.AddMigration("Add workspace_id for dashboard", NewAddColumnMigration(dashboardV2, &Column{ + Name: "workspace_id", Type: DB_NVarchar, Length: 190, Nullable: true, + })) + + mg.AddMigration("Add index for dashboard workspace_id", NewAddIndexMigration(dashboardV2, &Index{ + Cols: []string{"workspace_id"}, + Type: IndexType, + })) + mg.AddMigration("Add index for deleted", NewAddIndexMigration(dashboardV2, &Index{ Cols: []string{"deleted"}, Type: IndexType, diff --git a/pkg/services/sqlstore/searchstore/filters.go b/pkg/services/sqlstore/searchstore/filters.go index 432ac9e4a4d97..2a8ed5167266f 100644 --- a/pkg/services/sqlstore/searchstore/filters.go +++ b/pkg/services/sqlstore/searchstore/filters.go @@ -219,3 +219,16 @@ func (f DeletedFilter) Where() (string, []any) { return "dashboard.deleted IS NULL", nil } + +// WorkspaceFilter restricts results to dashboards owned by a single CodeRabbit +// product workspace. Applied only when a workspace id is supplied, so stock +// Grafana search behaviour is unchanged for every other caller. +type WorkspaceFilter struct { + WorkspaceID string +} + +var _ model.FilterWhere = WorkspaceFilter{} + +func (f WorkspaceFilter) Where() (string, []any) { + return "dashboard.workspace_id = ?", []any{f.WorkspaceID} +} diff --git a/pkg/services/sqlstore/searchstore/filters_test.go b/pkg/services/sqlstore/searchstore/filters_test.go index 6a8f943b4f2da..3293c4276b149 100644 --- a/pkg/services/sqlstore/searchstore/filters_test.go +++ b/pkg/services/sqlstore/searchstore/filters_test.go @@ -57,3 +57,12 @@ func TestFolderUIDFilter(t *testing.T) { }) } } + +func TestWorkspaceFilter(t *testing.T) { + f := searchstore.WorkspaceFilter{WorkspaceID: "ws-123"} + + sql, params := f.Where() + + assert.Equal(t, "dashboard.workspace_id = ?", sql) + assert.Equal(t, []any{"ws-123"}, params) +} diff --git a/public/microfrontends/fn_dashboard/index.html b/public/microfrontends/fn_dashboard/index.html index 23eb7834dec4d..c0ecf10512264 100644 --- a/public/microfrontends/fn_dashboard/index.html +++ b/public/microfrontends/fn_dashboard/index.html @@ -1,6 +1,25 @@ -CodeRabbit Micro-frontend
\ No newline at end of file + }; + + + + + + + + + From 32184ba1bbbf3e2d5bdc68d29b653ff3a88d6f0b Mon Sep 17 00:00:00 2001 From: Gurinder Singh Date: Tue, 4 Aug 2026 16:17:41 -0400 Subject: [PATCH 02/10] style(theme): modernise panel chrome to match Carrot UI Heavier Geist-style font weights with negative heading tracking, layered low-opacity shadows, roomier panel padding/header height, white light-mode panel surfaces, and a 12px panel radius with a hover elevation transition. --- .../grafana-data/src/themes/createColors.ts | 14 ++++++---- .../src/themes/createComponents.ts | 6 ++-- .../grafana-data/src/themes/createShadows.ts | 14 ++++++---- .../src/themes/createTypography.ts | 28 +++++++++++-------- .../components/PanelChrome/PanelChrome.tsx | 27 ++++++++++++++++-- 5 files changed, 61 insertions(+), 28 deletions(-) diff --git a/packages/grafana-data/src/themes/createColors.ts b/packages/grafana-data/src/themes/createColors.ts index 8850cc069fda8..f2d97d7675dcf 100644 --- a/packages/grafana-data/src/themes/createColors.ts +++ b/packages/grafana-data/src/themes/createColors.ts @@ -192,9 +192,9 @@ class LightColors implements ThemeColorsBase> { }; border = { - weak: '#e3e0e7', - medium: '#d0ccd5', - strong: '#bdb9c2', + weak: '#ebe9ee', + medium: '#dcd9e1', + strong: '#c4c0cb', }; secondary = { @@ -228,10 +228,12 @@ class LightColors implements ThemeColorsBase> { text: '#ab6400', }; + // Panels sit on a pure-white surface floating over a soft mauve canvas so the + // card edges read cleanly, matching the Carrot UI light surfaces. background = { - primary: '#faf8fb', - canvas: '#e9e7ed', - secondary: '#fdfdfe', + primary: '#ffffff', + canvas: '#f5f3f7', + secondary: '#faf9fb', }; action = { diff --git a/packages/grafana-data/src/themes/createComponents.ts b/packages/grafana-data/src/themes/createComponents.ts index 13e7fd46f5ac6..280f2609987d6 100644 --- a/packages/grafana-data/src/themes/createComponents.ts +++ b/packages/grafana-data/src/themes/createComponents.ts @@ -56,9 +56,11 @@ export interface ThemeComponents { } export function createComponents(colors: ThemeColors, shadows: ThemeShadows): ThemeComponents { + // Roomier panel chrome (12px gutters, 40px header) so charts breathe the way + // they do in the Carrot UI / Vercel card language instead of hugging the border. const panel = { - padding: 1, - headerHeight: 4, + padding: 1.5, + headerHeight: 5, background: colors.background.primary, borderColor: colors.border.weak, boxShadow: 'none', diff --git a/packages/grafana-data/src/themes/createShadows.ts b/packages/grafana-data/src/themes/createShadows.ts index d5a2b4b3d69d9..8e380e70ddd06 100644 --- a/packages/grafana-data/src/themes/createShadows.ts +++ b/packages/grafana-data/src/themes/createShadows.ts @@ -12,17 +12,19 @@ export function createShadows(colors: ThemeColors): ThemeShadows { // Shadow base colours are derived from the canvas background of each mode so // they harmonise with the mauve-tinted Carrot palette instead of using raw black. // Dark canvas #121014 → rgb(18, 16, 20) | Light text.primary #211f24 → rgb(33, 31, 36) + // Layered, low-opacity elevation in the Vercel/Geist style: a hairline contact + // shadow stacked with a wider ambient shadow instead of one heavy blur. if (colors.mode === 'dark') { return { - z1: '0px 1px 2px rgba(18, 16, 20, 0.8)', - z2: '0px 4px 8px rgba(18, 16, 20, 0.75)', - z3: '0px 8px 24px rgba(18, 16, 20, 0.9)', + z1: '0px 1px 2px rgba(0, 0, 0, 0.45)', + z2: '0px 2px 4px rgba(0, 0, 0, 0.35), 0px 8px 16px rgba(0, 0, 0, 0.4)', + z3: '0px 4px 8px rgba(0, 0, 0, 0.4), 0px 16px 32px rgba(0, 0, 0, 0.5)', }; } return { - z1: '0px 1px 2px rgba(33, 31, 36, 0.12)', - z2: '0px 4px 8px rgba(33, 31, 36, 0.15)', - z3: '0px 13px 20px 1px rgba(33, 31, 36, 0.12)', + z1: '0px 1px 2px rgba(33, 31, 36, 0.06)', + z2: '0px 1px 2px rgba(33, 31, 36, 0.06), 0px 4px 12px rgba(33, 31, 36, 0.08)', + z3: '0px 2px 4px rgba(33, 31, 36, 0.06), 0px 12px 32px rgba(33, 31, 36, 0.12)', }; } diff --git a/packages/grafana-data/src/themes/createTypography.ts b/packages/grafana-data/src/themes/createTypography.ts index 35d87ed883448..e7bbddb75270c 100644 --- a/packages/grafana-data/src/themes/createTypography.ts +++ b/packages/grafana-data/src/themes/createTypography.ts @@ -67,10 +67,12 @@ export function createTypography(colors: ThemeColors, typographyInput: ThemeTypo fontFamilyMonospace = defaultFontFamilyMonospace, // The default font size of the Material Specification. fontSize = 14, // px - fontWeightLight = 200, - fontWeightRegular = 300, - fontWeightMedium = 400, - fontWeightBold = 500, + // Geist/Vercel-style weight ramp. The previous 200/300/400/500 ramp rendered + // body copy as Light, which looked washed out against the Carrot UI palette. + fontWeightLight = 300, + fontWeightRegular = 400, + fontWeightMedium = 500, + fontWeightBold = 600, // Tell Grafana-UI what's the font-size on the html element. // 16px is the default font-size used by browsers. htmlFontSize = 16, @@ -111,14 +113,16 @@ export function createTypography(colors: ThemeColors, typographyInput: ThemeTypo // All our fonts/line heights should be integer multiples of 2 to prevent issues with alignment const variants = { - h1: buildVariant(fontWeightRegular, 28, 32, -0.25), - h2: buildVariant(fontWeightRegular, 24, 28, 0), - h3: buildVariant(fontWeightRegular, 22, 24, 0), - h4: buildVariant(fontWeightRegular, 18, 22, 0.25), - h5: buildVariant(fontWeightRegular, 16, 22, 0), - h6: buildVariant(fontWeightMedium, 14, 22, 0.15), - body: buildVariant(fontWeightRegular, fontSize, 22, 0.15), - bodySmall: buildVariant(fontWeightRegular, 12, 18, 0.15), + // Headings use negative tracking (Vercel/Geist convention) and a heavier weight + // so panel titles and section headers read as deliberate UI chrome. + h1: buildVariant(fontWeightBold, 28, 32, -0.6), + h2: buildVariant(fontWeightBold, 24, 28, -0.5), + h3: buildVariant(fontWeightMedium, 22, 24, -0.4), + h4: buildVariant(fontWeightMedium, 18, 22, -0.3), + h5: buildVariant(fontWeightMedium, 16, 22, -0.2), + h6: buildVariant(fontWeightMedium, 14, 22, -0.1), + body: buildVariant(fontWeightRegular, fontSize, 22, 0), + bodySmall: buildVariant(fontWeightRegular, 12, 18, 0), code: { ...buildVariant(fontWeightRegular, 14, 16, 0.15), fontFamily: fontFamilyMonospace }, }; diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx index eba75145d57a5..9e54f50963a4f 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx @@ -295,7 +295,11 @@ export function PanelChrome({ )} {hasHeader && ( -
+
{statusMessage && (
@@ -387,12 +391,16 @@ const getStyles = (isFNPanel?: boolean) => (theme: GrafanaTheme2) => { label: 'panel-container', backgroundColor: background, border: `1px solid ${borderColor}`, - borderRadius: theme.shape.borderRadius(2), + // Larger corner radius + soft elevation to match the Carrot UI card language. + borderRadius: theme.shape.borderRadius(3), boxShadow: theme.shadows.z1, position: 'relative', height: '100%', display: 'flex', flexDirection: 'column', + transition: theme.transitions.create(['box-shadow', 'border-color'], { + duration: theme.transitions.duration.short, + }), '.show-on-hover': { opacity: '0', @@ -400,6 +408,7 @@ const getStyles = (isFNPanel?: boolean) => (theme: GrafanaTheme2) => { }, '&:hover': { + borderColor: theme.colors.border.medium, boxShadow: theme.shadows.z2, // only show menu icon on hover '.show-on-hover': { @@ -461,6 +470,13 @@ const getStyles = (isFNPanel?: boolean) => (theme: GrafanaTheme2) => { display: 'flex', alignItems: 'center', }), + // Hairline separator between the title row and the visualisation, the way + // Vercel-style cards split header from body. Only applied when the panel + // actually renders a title, so untitled panels stay borderless. + headerDivider: css({ + label: 'panel-header-divider', + borderBottom: `1px solid ${theme.colors.border.weak}`, + }), pointer: css({ cursor: 'pointer', }), @@ -476,8 +492,15 @@ const getStyles = (isFNPanel?: boolean) => (theme: GrafanaTheme2) => { title: css({ label: 'panel-title', display: 'flex', + alignItems: 'center', padding: theme.spacing(0, padding), minWidth: 0, + // Panel titles are secondary chrome: slightly muted, tighter tracking. + '& h2': { + color: theme.colors.text.primary, + fontWeight: theme.typography.fontWeightMedium, + letterSpacing: '-0.01em', + }, // FN-dashboard panel titles span the full chrome width so the title // and the right-aligned menu line up edge-to-edge. Titles render in // normal case — the uppercase transform that previously lived here From 98779ef8adc8cfd85cbd422f6784600e38c26a34 Mon Sep 17 00:00:00 2001 From: Gurinder Singh Date: Tue, 4 Aug 2026 16:17:45 -0400 Subject: [PATCH 03/10] feat(ui): use Heroicons for the panel description icon Adopts @heroicons/react, the icon set used by coderabbit-ui, for the panel info icon so it matches the rest of the CodeRabbit design system. --- packages/grafana-ui/package.json | 1 + .../PanelChrome/PanelDescription.tsx | 18 ++++++++++++++++-- yarn.lock | 10 ++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 35196b97c59c5..355c5bf9f3405 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -55,6 +55,7 @@ "@grafana/faro-web-sdk": "^1.3.6", "@grafana/schema": "11.3.0-pre", "@hello-pangea/dnd": "16.6.0", + "@heroicons/react": "2.2.0", "@leeoniya/ufuzzy": "1.0.14", "@monaco-editor/react": "4.6.0", "@popperjs/core": "2.11.8", diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelDescription.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelDescription.tsx index b9e74f5c39d15..121c21d60f8e6 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelDescription.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelDescription.tsx @@ -1,9 +1,9 @@ import { css, cx } from '@emotion/css'; +import { InformationCircleIcon } from '@heroicons/react/24/outline'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '../../themes'; -import { Icon } from '../Icon/Icon'; import { Tooltip } from '../Tooltip'; import { TitleItem } from './TitleItem'; @@ -30,7 +30,8 @@ export function PanelDescription({ description, className }: Props) { return description !== '' ? ( - + {/* Heroicons is the icon set used by the CodeRabbit UI (Carrot UI) design system. */} + ) : null; @@ -48,5 +49,18 @@ const getStyles = (theme: GrafanaTheme2) => { display: 'block', }, }), + icon: css({ + width: 16, + height: 16, + flexShrink: 0, + color: theme.colors.text.secondary, + transition: theme.transitions.create('color', { + duration: theme.transitions.duration.shortest, + }), + + '&:hover': { + color: theme.colors.text.primary, + }, + }), }; }; diff --git a/yarn.lock b/yarn.lock index ef6a85c6f40eb..f69c444a17775 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4068,6 +4068,7 @@ __metadata: "@grafana/schema": "npm:11.3.0-pre" "@grafana/tsconfig": "npm:^2.0.0" "@hello-pangea/dnd": "npm:16.6.0" + "@heroicons/react": "npm:2.2.0" "@leeoniya/ufuzzy": "npm:1.0.14" "@monaco-editor/react": "npm:4.6.0" "@popperjs/core": "npm:2.11.8" @@ -4228,6 +4229,15 @@ __metadata: languageName: node linkType: hard +"@heroicons/react@npm:2.2.0": + version: 2.2.0 + resolution: "@heroicons/react@npm:2.2.0" + peerDependencies: + react: ">= 16 || ^19.0.0-rc" + checksum: 10/5bf8a3faa16f1566165bfaec2448ce3c2bd3e4f49e446ccf233f6aa63626155585eaf7e89f01fb4e8e92dfca3d7113b8c517c3b3c04036b9243c818595db3908 + languageName: node + linkType: hard + "@humanwhocodes/config-array@npm:^0.11.13, @humanwhocodes/config-array@npm:^0.11.14": version: 0.11.14 resolution: "@humanwhocodes/config-array@npm:0.11.14" From 6442ad98cc72902887eb19ef0f03e7db66b144c3 Mon Sep 17 00:00:00 2001 From: Gurinder Singh Date: Tue, 4 Aug 2026 16:17:51 -0400 Subject: [PATCH 04/10] style(timepicker): give the time range picker a Carrot UI look Rounds the popover, converts quick ranges to inset pills, restyles section headers as small-caps eyebrows and recesses the footer. Also fixes the boxed outline on the active tab (caused by Tab's overflow clipping its rounded underline) and keeps select menus unclipped by dropping overflow:hidden. --- .../TimeRangePicker/TimePickerContent.tsx | 19 +++++---- .../TimeRangePicker/TimePickerFooter.tsx | 40 +++++++++++++++---- .../TimeRangePicker/TimePickerTitle.tsx | 8 +++- .../TimeRangePicker/TimeRangeList.tsx | 6 +-- .../TimeRangePicker/TimeRangeOption.tsx | 21 ++++++++-- 5 files changed, 70 insertions(+), 24 deletions(-) diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx index c0cabc1764608..cbd8d644be889 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx @@ -162,7 +162,6 @@ const NarrowScreenForm = (props: FormProps) => { weekStart={weekStart} />
-
)} @@ -198,7 +197,6 @@ const FullScreenForm = (props: FormProps) => { weekStart={weekStart} />
- ); }; @@ -254,11 +252,16 @@ const useTimeOption = (raw: RawTimeRange, quickOptions: TimeOption[]): TimeOptio const getStyles = stylesFactory((theme: GrafanaTheme2, isReversed, hideQuickRanges, isContainerTall, isFullscreen) => { return { + // Carrot UI popover surface: soft 12px radius, hairline border and layered + // elevation so the panel floats above the dashboard rather than boxing it in. + // NOTE: no `overflow: hidden` here — the time zone / fiscal year selects render + // their menus inline (menuShouldPortal={false}), so clipping the container would + // crop the open dropdown list. Corner bleed is handled by the footer's own radius. container: css({ background: theme.colors.background.primary, boxShadow: theme.shadows.z3, width: `${isFullscreen ? '546px' : '262px'}`, - borderRadius: theme.shape.borderRadius(), + borderRadius: theme.shape.borderRadius(3), border: `1px solid ${theme.colors.border.weak}`, [`${isReversed ? 'left' : 'right'}`]: 0, }), @@ -286,7 +289,8 @@ const getStyles = stylesFactory((theme: GrafanaTheme2, isReversed, hideQuickRang flexDirection: 'column', }), timeRangeFilter: css({ - padding: theme.spacing(1), + padding: theme.spacing(1.5), + borderBottom: `1px solid ${theme.colors.border.weak}`, }), spacing: css({ marginTop: '16px', @@ -325,12 +329,11 @@ const getNarrowScreenStyles = (theme: GrafanaTheme2) => ({ const getFullScreenStyles = (theme: GrafanaTheme2, hideQuickRanges?: boolean) => ({ container: css({ - paddingTop: '9px', - paddingLeft: '11px', - paddingRight: !hideQuickRanges ? '20%' : '11px', + padding: theme.spacing(1.5), + paddingRight: !hideQuickRanges ? '20%' : theme.spacing(1.5), }), title: css({ - marginBottom: '11px', + marginBottom: theme.spacing(1.5), }), recent: css({ flexGrow: 1, diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerFooter.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerFooter.tsx index 6fe7b058b7f00..bb94d26940587 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerFooter.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerFooter.tsx @@ -169,26 +169,39 @@ export const TimePickerFooter = (props: Props) => { const getStyle = stylesFactory((theme: GrafanaTheme2) => { return { + // Footer reads as a distinct utility bar: recessed surface, hairline top rule. + // `:last-child` keeps the rounding on whichever block actually ends the popover + // (this bar when collapsed, the edit panel when the settings are expanded). container: css({ borderTop: `1px solid ${theme.colors.border.weak}`, - padding: '11px', + background: theme.colors.background.secondary, + padding: theme.spacing(1.5), display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - fontSize: '14px', + fontSize: theme.typography.bodySmall.fontSize, lineHeight: '20px', + '&:last-child': { + borderBottomLeftRadius: theme.shape.borderRadius(3), + borderBottomRightRadius: theme.shape.borderRadius(3), + }, '& button': { - borderRadius: '6px', + borderRadius: theme.shape.radius.default, }, }), editContainer: css({ borderTop: `1px solid ${theme.colors.border.weak}`, - padding: '11px', + background: theme.colors.background.secondary, + padding: theme.spacing(1.5), justifyContent: 'space-between', alignItems: 'center', - fontSize: '14px', + fontSize: theme.typography.bodySmall.fontSize, lineHeight: '20px', + // Round the trailing corners to match the popover instead of relying on the + // parent clipping, which would crop the inline select menus. + borderBottomLeftRadius: theme.shape.borderRadius(3), + borderBottomRightRadius: theme.shape.borderRadius(3), }), spacer: css({ marginLeft: '7px', @@ -216,9 +229,22 @@ const getStyle = stylesFactory((theme: GrafanaTheme2) => { // orange brand gradient) with a neutral gray for the time-zone / // fiscal-year tabs inside the time-range picker only. tabsOverride: css({ - 'button[role="tab"][aria-selected="true"]::before, a[role="tab"][aria-selected="true"]::before': { + '[role="tab"][aria-selected="true"]': { + // The shared Tab sets `overflow: hidden` on the active state, which clips + // its own rounded underline into a boxed outline around the tab. Reset it + // so only the underline shows. + overflow: 'visible', + border: 'none', + boxShadow: 'none', + }, + + '[role="tab"][aria-selected="true"]::before': { backgroundImage: 'none', - backgroundColor: theme.colors.border.strong, + backgroundColor: theme.colors.text.primary, + // A slim square-cut rule reads as an underline; the inherited 4px/6px-radius + // bar looked like a stray border sitting under the label. + height: '2px', + borderRadius: 0, }, }), }; diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerTitle.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerTitle.tsx index e65201002bb50..4f39f03fba5b8 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerTitle.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerTitle.tsx @@ -7,10 +7,14 @@ import { useStyles2 } from '../../../themes'; const getStyles = (theme: GrafanaTheme2) => { return { + // Section labels read as small-caps eyebrows (Carrot UI convention) so the + // quick-range values below them stay the dominant text in the popover. text: css({ - fontSize: theme.typography.size.md, + fontSize: theme.typography.size.xs, fontWeight: theme.typography.fontWeightMedium, - color: theme.colors.text.primary, + textTransform: 'uppercase', + letterSpacing: '0.06em', + color: theme.colors.text.secondary, margin: 0, display: 'flex', }), diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeList.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeList.tsx index cfc070c51c791..06e2c56b18fce 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeList.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeList.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import { useRef, ReactNode } from 'react'; -import { TimeOption } from '@grafana/data'; +import { GrafanaTheme2, TimeOption } from '@grafana/data'; import { useStyles2 } from '../../../themes'; import { t } from '../../../utils/i18n'; @@ -82,12 +82,12 @@ function isEqual(x: TimeOption, y?: TimeOption): boolean { return y.from === x.from && y.to === x.to; } -const getStyles = () => ({ +const getStyles = (theme: GrafanaTheme2) => ({ title: css({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', - padding: '8px 16px 5px 9px', + padding: theme.spacing(1.5, 2, 0.75, 2), }), }); diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeOption.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeOption.tsx index 5f00cd4c17f31..5e61249c5b97e 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeOption.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeOption.tsx @@ -9,16 +9,23 @@ import { getFocusStyles } from '../../../themes/mixins'; const getStyles = (theme: GrafanaTheme2) => { return { + // Carrot UI list rows: inset pills with a rounded hover/selected surface + // instead of full-bleed bars butting against the popover edges. container: css({ display: 'flex', alignItems: 'center', flexDirection: 'row-reverse', justifyContent: 'space-between', + padding: theme.spacing(0, 1), }), + // Selected uses the neutral secondary surface rather than the brand orange: + // an orange fill fights the panel accents and hurts label contrast. selected: css({ - background: theme.colors.secondary.main, - color: theme.colors.secondary.text, - fontWeight: theme.typography.fontWeightMedium, + '& label, & label:hover': { + background: theme.colors.secondary.main, + color: theme.colors.text.primary, + fontWeight: theme.typography.fontWeightMedium, + }, }), radio: css({ opacity: 0, @@ -30,11 +37,17 @@ const getStyles = (theme: GrafanaTheme2) => { cursor: 'pointer', flex: 1, padding: theme.spacing(0.75, 1.25), - fontSize: '14px', + borderRadius: theme.shape.radius.default, + fontSize: theme.typography.bodySmall.fontSize, lineHeight: '20px', + color: theme.colors.text.secondary, + transition: theme.transitions.create(['background-color', 'color'], { + duration: theme.transitions.duration.shortest, + }), '&:hover': { background: theme.colors.action.hover, + color: theme.colors.text.primary, cursor: 'pointer', }, }), From 754726e9137bcf514a90abdfa83ce6c8d4465590 Mon Sep 17 00:00:00 2001 From: Gurinder Singh Date: Fri, 7 Aug 2026 02:16:22 -0400 Subject: [PATCH 05/10] feat(dashboards): add host-driven per-panel edit trigger Each panel header renders a pen icon when the embedding host opts in via `enablePanelEdit`. Clicking it reports the panel back through the existing `metadata.eventListener` channel, so the host owns the editing UI and Grafana only surfaces the trigger. Also sizes the embedded single-panel view from the host-measured portal container instead of `windowHeight * 0.85`, which overflowed the host box and cropped the panel. --- .../src/components/PanelChrome/TitleItem.tsx | 10 ++- public/app/core/reducers/fn-slice.ts | 18 +++- .../dashboard/dashgrid/DashboardGrid.tsx | 87 ++++++++++++++++++- .../PanelHeaderTitleItems.test.tsx | 23 ++++- .../PanelHeader/PanelHeaderTitleItems.tsx | 33 ++++++- .../dashboard/dashgrid/PanelStateWrapper.tsx | 6 ++ .../dashboard/utils/getPanelChromeProps.tsx | 21 +++++ 7 files changed, 190 insertions(+), 8 deletions(-) diff --git a/packages/grafana-ui/src/components/PanelChrome/TitleItem.tsx b/packages/grafana-ui/src/components/PanelChrome/TitleItem.tsx index a4922f2331b8a..9fd9989a69396 100644 --- a/packages/grafana-ui/src/components/PanelChrome/TitleItem.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/TitleItem.tsx @@ -39,7 +39,15 @@ export const TitleItem = forwardRef( ); } else if (onClick) { return ( - ); diff --git a/public/app/core/reducers/fn-slice.ts b/public/app/core/reducers/fn-slice.ts index 0c885e2c770a0..1a7732d427f67 100644 --- a/public/app/core/reducers/fn-slice.ts +++ b/public/app/core/reducers/fn-slice.ts @@ -12,6 +12,12 @@ export interface FnState { pageTitle: string; queryParams: AnyObject; hiddenVariables: string[]; + /** + * When true, each panel header renders an edit affordance that reports the + * clicked panel back to the host through `metadata.eventListener`. The host + * owns the editing UI, so Grafana only surfaces the trigger. + */ + enablePanelEdit: boolean; metadata: { teams: string[]; eventListener: ((event: { type: string; data: T }) => void) | null; @@ -25,7 +31,15 @@ export type SetFnStateAction = PayloadAction; export type FnStateProp = keyof FnState; @@ -33,6 +47,7 @@ export type FnPropsMappedFromState = Pick; export const fnStateProps: FnStateProp[] = [ 'controlsContainer', + 'enablePanelEdit', 'hiddenVariables', 'pageTitle', 'queryParams', @@ -54,6 +69,7 @@ export const INITIAL_FN_STATE: FnState = { pageTitle: '', queryParams: {}, hiddenVariables: [], + enablePanelEdit: false, metadata: { teams: [], eventListener: null, diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index 70c88a5d54182..c55bcc4af906a 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -26,9 +26,15 @@ export interface Props { viewPanel: PanelModel | null; hidePanelMenus?: boolean; isFnDashboard?: boolean; + portalContainerID?: string; } -export class Component extends PureComponent { +interface State { + /** Host-measured height for an embedded single-panel view. */ + viewPanelHeight?: number; +} + +export class Component extends PureComponent { private panelMap: { [key: string]: PanelModel } = {}; private eventSubs = new Subscription(); private windowHeight = 1200; @@ -37,18 +43,69 @@ export class Component extends PureComponent { /** Used to keep track of mobile panel layout position */ private lastPanelBottom = 0; private isLayoutInitialized = false; + private portalResizeObserver?: ResizeObserver; constructor(props: Props) { super(props); + this.state = { viewPanelHeight: undefined }; } componentDidMount() { const { dashboard } = this.props; this.eventSubs.add(dashboard.events.subscribe(DashboardPanelsChangedEvent, this.triggerForceUpdate)); + this.observePortalContainer(); + } + + componentDidUpdate(prevProps: Props) { + if (prevProps.portalContainerID !== this.props.portalContainerID || prevProps.viewPanel !== this.props.viewPanel) { + this.observePortalContainer(); + } } componentWillUnmount() { this.eventSubs.unsubscribe(); + this.portalResizeObserver?.disconnect(); + this.portalResizeObserver = undefined; + } + + /** + * Embedded (MFE) single-panel mode only. + * + * The host sizes the portal container, but on first paint it can still measure + * 0 (the portal div mounts before the host flex layout resolves). A one-shot + * read therefore falls back to `windowHeight * 0.85`, which overflows the host + * box and crops the panel. Observing the container keeps the panel height in + * sync with whatever the host actually allocates, including window resizes. + */ + observePortalContainer() { + this.portalResizeObserver?.disconnect(); + this.portalResizeObserver = undefined; + + const { isFnDashboard, viewPanel, portalContainerID } = this.props; + if (!isFnDashboard || !viewPanel || !portalContainerID) { + if (this.state.viewPanelHeight !== undefined) { + this.setState({ viewPanelHeight: undefined }); + } + return; + } + + const portalContainer = document.getElementById(portalContainerID); + if (!portalContainer) { + return; + } + + this.portalResizeObserver = new ResizeObserver(() => { + const height = portalContainer.clientHeight; + if (height > 0 && height !== this.state.viewPanelHeight) { + this.setState({ viewPanelHeight: height }); + } + }); + this.portalResizeObserver.observe(portalContainer); + + const height = portalContainer.clientHeight; + if (height > 0 && height !== this.state.viewPanelHeight) { + this.setState({ viewPanelHeight: height }); + } } buildLayout() { @@ -138,8 +195,25 @@ export class Component extends PureComponent { return { top, bottom: this.lastPanelBottom }; } + getEmbeddedViewPanelHeight(): number | undefined { + if (!this.props.isFnDashboard || !this.props.viewPanel || !this.props.portalContainerID) { + return undefined; + } + + // Prefer the observed height; fall back to a direct read for the first paint + // before the ResizeObserver has delivered its initial entry. + if (this.state.viewPanelHeight !== undefined) { + return this.state.viewPanelHeight; + } + + const portalContainer = document.getElementById(this.props.portalContainerID); + const height = portalContainer?.clientHeight ?? 0; + return height > 0 ? height : undefined; + } + renderPanels(gridWidth: number, isDashboardDraggable: boolean) { const panelElements = []; + const viewPanelHeight = this.getEmbeddedViewPanelHeight(); // Reset last panel bottom this.lastPanelBottom = 0; @@ -164,6 +238,7 @@ export class Component extends PureComponent { gridWidth={gridWidth} windowHeight={this.windowHeight} windowWidth={this.windowWidth} + viewPanelHeight={viewPanelHeight} isViewing={panel.isViewing} > {(width: number, height: number) => { @@ -279,6 +354,7 @@ interface GrafanaGridItemProps extends React.HTMLAttributes { isViewing: boolean; windowHeight: number; windowWidth: number; + viewPanelHeight?: number; children: any; } @@ -290,13 +366,15 @@ const GrafanaGridItem = React.forwardRef(( let width = 100; let height = 100; - const { gridWidth, gridPos, isViewing, windowHeight, windowWidth, ...divProps } = props; + const { gridWidth, gridPos, isViewing, windowHeight, windowWidth, viewPanelHeight, ...divProps } = props; const style: CSSProperties = props.style ?? {}; if (isViewing) { - // In fullscreen view mode a single panel take up full width & 85% height + // In fullscreen view mode a single panel take up full width & 85% height. + // Embedded FN dashboards have a host-owned viewport, so use that measured + // height instead of the browser window to avoid cropping inside the host. width = gridWidth!; - height = windowHeight * 0.85; + height = viewPanelHeight ?? windowHeight * 0.85; style.height = height; style.width = '100%'; } else if (windowWidth < theme.breakpoints.values.md) { @@ -339,6 +417,7 @@ GrafanaGridItem.displayName = 'GridItemWithDimensions'; function mapStateToProps() { return (state: StoreState) => ({ isFnDashboard: state.fnGlobalState.FNDashboard, + portalContainerID: state.fnGlobalState.portalContainerID, }); } diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderTitleItems.test.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderTitleItems.test.tsx index 044e5e54f4c4d..e5f3418d30fb7 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderTitleItems.test.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderTitleItems.test.tsx @@ -5,7 +5,7 @@ import { LoadingState, TimeRange } from '@grafana/data'; import { AngularNotice, PanelHeaderTitleItems } from './PanelHeaderTitleItems'; -function renderComponent(angularNoticeOverride?: Partial) { +function renderComponent(angularNoticeOverride?: Partial, onEditPanel?: () => void) { render( ) { timeRange: {} as TimeRange, }} panelId={1} + onEditPanel={onEditPanel} angularNotice={{ ...{ show: true, @@ -73,3 +74,23 @@ describe('PanelHeaderTitleItems angular deprecation', () => { }); }); }); + +describe('PanelHeaderTitleItems panel edit affordance', () => { + const editSelector = 'fn-edit-panel'; + + it('does not render the edit item when the host has not opted in', () => { + renderComponent(); + expect(screen.queryByTestId(editSelector)).not.toBeInTheDocument(); + }); + + it('renders the edit item and reports clicks to the host', async () => { + const onEditPanel = jest.fn(); + renderComponent(undefined, onEditPanel); + + const editItem = screen.getByTestId(editSelector); + expect(editItem).toBeInTheDocument(); + + await userEvent.click(editItem); + expect(onEditPanel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderTitleItems.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderTitleItems.tsx index 2399730beca44..d684e9d3525eb 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderTitleItems.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderTitleItems.tsx @@ -17,15 +17,38 @@ export interface Props { alertState?: string; data: PanelData; panelId: number; + /** + * Set only when the embedding host has opted into panel editing. Rendering the + * affordance is driven entirely by this callback's presence. + */ + onEditPanel?: () => void; onShowPanelLinks?: () => Array>; panelLinks?: DataLink[]; angularNotice?: AngularNotice; } export function PanelHeaderTitleItems(props: Props) { - const { alertState, data, panelId, onShowPanelLinks, panelLinks, angularNotice } = props; + const { alertState, data, panelId, onEditPanel, onShowPanelLinks, panelLinks, angularNotice } = props; const styles = useStyles2(getStyles); + const editItem = ( + + { + // The header doubles as the drag handle, so keep the click local. + e.preventDefault(); + e.stopPropagation(); + onEditPanel?.(); + }} + > + + + + ); + // panel health const alertStateItem = ( @@ -72,6 +95,7 @@ export function PanelHeaderTitleItems(props: Props) { {timeshift} {alertState && alertStateItem} {angularNotice?.show && angularNoticeTooltip} + {onEditPanel && editItem} ); } @@ -118,5 +142,12 @@ const getStyles = (theme: GrafanaTheme2) => { angularNotice: css({ color: theme.colors.warning.text, }), + editPanel: css({ + color: theme.colors.text.secondary, + cursor: 'pointer', + '&:hover': { + color: theme.colors.text.primary, + }, + }), }; }; diff --git a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx index bc9cd47326f7d..efa3c660d195c 100644 --- a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx +++ b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx @@ -644,6 +644,12 @@ export class PanelStateWrapperDisConnected extends PureComponent { function mapStateToProps() { return (state: StoreState) => ({ isFnDashboard: state.fnGlobalState.FNDashboard, + /** + * Read from the per-dashboard store so the edit affordance follows whichever + * dashboard this panel belongs to. + */ + enablePanelEdit: state.fnGlobalState.enablePanelEdit, + panelEditListener: state.fnGlobalState.metadata?.eventListener ?? undefined, }); } diff --git a/public/app/features/dashboard/utils/getPanelChromeProps.tsx b/public/app/features/dashboard/utils/getPanelChromeProps.tsx index 52a0e96ec79f0..d0aaef4098907 100644 --- a/public/app/features/dashboard/utils/getPanelChromeProps.tsx +++ b/public/app/features/dashboard/utils/getPanelChromeProps.tsx @@ -18,6 +18,10 @@ interface CommonProps { plugin: PanelPlugin; isViewing: boolean; isEditing: boolean; + /** Host opt-in for the per-panel edit affordance. */ + enablePanelEdit?: boolean; + /** Channel the host listens on for `panelEditClick`. */ + panelEditListener?: (event: { type: string; data: T }) => void; isInView: boolean; isDraggable?: boolean; width: number; @@ -84,11 +88,27 @@ export function getPanelChromeProps(props: CommonProps) { const showAngularNotice = (config.featureToggles.angularDeprecationUI ?? false) && (isAngularDatasource || isAngularPanel); + /** + * Panel editing is host-driven: the host opts in with `enablePanelEdit` and + * receives the clicked panel through the same `eventListener` channel used by + * the stat/table/timeseries click events. Grafana renders only the trigger. + */ + const panelEditListener = props.enablePanelEdit ? props.panelEditListener : undefined; + + const onEditPanel = panelEditListener + ? () => + panelEditListener({ + type: 'panelEditClick', + data: { panelId: props.panel.id, title: props.panel.title ?? '' }, + }) + : undefined; + const showTitleItems = (props.panel.links && props.panel.links.length > 0 && onShowPanelLinks) || (props.data.series.length > 0 && props.data.series.some((v) => (v.meta?.notices?.length ?? 0) > 0)) || (props.data.request && props.data.request.timeInfo) || showAngularNotice || + Boolean(onEditPanel) || alertState; const titleItems = showTitleItems && ( @@ -96,6 +116,7 @@ export function getPanelChromeProps(props: CommonProps) { alertState={alertState} data={props.data} panelId={props.panel.id} + onEditPanel={onEditPanel} panelLinks={props.panel.links} angularNotice={{ show: showAngularNotice, From 69a3e3ac5a26f8ef221d7f3d10cfa04cd1e7f52b Mon Sep 17 00:00:00 2001 From: Gurinder Singh Date: Fri, 7 Aug 2026 13:50:18 -0400 Subject: [PATCH 06/10] build --- public/microfrontends/fn_dashboard/index.html | 8 +- public/sass/_variables.dark.generated.scss | 8 +- public/sass/_variables.generated.scss | 10 +-- public/sass/_variables.light.generated.scss | 84 +++++++++---------- 4 files changed, 55 insertions(+), 55 deletions(-) diff --git a/public/microfrontends/fn_dashboard/index.html b/public/microfrontends/fn_dashboard/index.html index c0ecf10512264..fd24ee33d4ad5 100644 --- a/public/microfrontends/fn_dashboard/index.html +++ b/public/microfrontends/fn_dashboard/index.html @@ -10,16 +10,16 @@ - + - + diff --git a/public/sass/_variables.dark.generated.scss b/public/sass/_variables.dark.generated.scss index b33ea16bda41b..8f897216392ba 100644 --- a/public/sass/_variables.dark.generated.scss +++ b/public/sass/_variables.dark.generated.scss @@ -238,7 +238,7 @@ $dropdownBackground: #1a181d; $dropdownBorder: #322f37; $dropdownDividerTop: #322f37; $dropdownDividerBottom: #322f37; -$dropdownShadow: 0px 8px 24px rgba(18, 16, 20, 0.9); +$dropdownShadow: 0px 4px 8px rgba(0, 0, 0, 0.4), 0px 16px 32px rgba(0, 0, 0, 0.5); $dropdownLinkColor: $link-color; $dropdownLinkColorHover: $white; @@ -268,7 +268,7 @@ $side-menu-header-color: #efedf0; // ------------------------- $menu-dropdown-bg: #1a181d; $menu-dropdown-hover-bg: rgba(255, 255, 255, 0.06); -$menu-dropdown-shadow: 0px 8px 24px rgba(18, 16, 20, 0.9); +$menu-dropdown-shadow: 0px 4px 8px rgba(0, 0, 0, 0.4), 0px 16px 32px rgba(0, 0, 0, 0.5); // Tabs // ------------------------- @@ -296,13 +296,13 @@ $tooltipBackground: #232127; $tooltipColor: #efedf0; $tooltipArrowColor: #232127; $tooltipBackgroundError: #dc3b5d; -$tooltipShadow: 0px 4px 8px rgba(18, 16, 20, 0.75); +$tooltipShadow: 0px 2px 4px rgba(0, 0, 0, 0.35), 0px 8px 16px rgba(0, 0, 0, 0.4); $popover-bg: #1a181d; $popover-color: #efedf0; $popover-border-color: #322f37; $popover-header-bg: #232127; -$popover-shadow: 0px 8px 24px rgba(18, 16, 20, 0.9); +$popover-shadow: 0px 4px 8px rgba(0, 0, 0, 0.4), 0px 16px 32px rgba(0, 0, 0, 0.5); $popover-help-bg: $tooltipBackground; $popover-help-color: $text-color; diff --git a/public/sass/_variables.generated.scss b/public/sass/_variables.generated.scss index 1883451d572db..41f962ddd8714 100644 --- a/public/sass/_variables.generated.scss +++ b/public/sass/_variables.generated.scss @@ -114,8 +114,8 @@ $font-size-xs: 10px !default; $line-height-base: 1.5714285714285714 !default; -$font-weight-regular: 300 !default; -$font-weight-semi-bold: 400 !default; +$font-weight-regular: 400 !default; +$font-weight-semi-bold: 500 !default; $font-size-h1: 1.75rem !default; $font-size-h2: 1.5rem !default; @@ -177,7 +177,7 @@ $zindex-typeahead: 1030; $btn-padding-x: 14px !default; $btn-padding-y: 0 !default; $btn-line-height: $line-height-base; -$btn-font-weight: 400 !default; +$btn-font-weight: 500 !default; $btn-padding-x-sm: 7px !default; $btn-padding-y-sm: 4px !default; @@ -196,8 +196,8 @@ $navbar-padding: 20px; // dashboard $dashboard-padding: $space-md; -$panel-padding: 8px; -$panel-header-height: 32px; +$panel-padding: 12px; +$panel-header-height: 40px; $panel-header-z-index: 10; // tabs diff --git a/public/sass/_variables.light.generated.scss b/public/sass/_variables.light.generated.scss index b7270e671ea25..d6376b5261efa 100644 --- a/public/sass/_variables.light.generated.scss +++ b/public/sass/_variables.light.generated.scss @@ -57,13 +57,13 @@ $gray-7: #fbfbfb; $white: #ffffff; -$layer0: #e9e7ed; -$layer1: #faf8fb; -$layer2: #fdfdfe; +$layer0: #f5f3f7; +$layer1: #ffffff; +$layer2: #faf9fb; -$divider: #e3e0e7; -$border0: #e3e0e7; -$border1: #d0ccd5; +$divider: #ebe9ee; +$border0: #ebe9ee; +$border1: #dcd9e1; // Accent colors // ------------------------- @@ -93,9 +93,9 @@ $critical: #ca244d; // Scaffolding // ------------------------- -$body-bg: #e9e7ed; -$page-bg: #e9e7ed; -$dashboard-bg: #e9e7ed; +$body-bg: #f5f3f7; +$page-bg: #f5f3f7; +$dashboard-bg: #f5f3f7; $text-color: #211f24; $text-color-strong: #000000; @@ -128,30 +128,30 @@ $hr-border-color: $gray-4 !default; // Panel // ------------------------- -$panel-bg: #faf8fb; -$panel-border: 1px solid #e3e0e7; +$panel-bg: #ffffff; +$panel-border: 1px solid #ebe9ee; $panel-header-hover-bg: rgba(0, 0, 0, 0.06); $panel-box-shadow: none; $panel-corner: $panel-bg; // Page header -$page-header-bg: #e9e7ed; +$page-header-bg: #f5f3f7; $page-header-shadow: inset 0px -3px 10px $gray-6; -$page-header-border-color: #e9e7ed; +$page-header-border-color: #f5f3f7; $divider-border-color: $gray-2; // Graphite Target Editor -$tight-form-func-bg: #fdfdfe; -$tight-form-func-highlight-bg: #f6f6fb; +$tight-form-func-bg: #faf9fb; +$tight-form-func-highlight-bg: #f5f3f7; -$modal-backdrop-bg: #faf8fb; +$modal-backdrop-bg: #ffffff; $code-tag-bg: $gray-6; $code-tag-border: $gray-4; // cards -$card-background: #fdfdfe; -$card-background-hover: #fdfdfe; +$card-background: #faf9fb; +$card-background-hover: #faf9fb; $card-shadow: none; // Lists @@ -168,10 +168,10 @@ $scrollbarBorder: $gray-7; // Tables // ------------------------- -$table-bg-accent: #fdfdfe; -$table-border: #d0ccd5; -$table-bg-odd: rgb(245, 243, 245); -$table-bg-hover: rgb(237, 235, 238); +$table-bg-accent: #faf9fb; +$table-border: #dcd9e1; +$table-bg-odd: rgb(249, 249, 249); +$table-bg-hover: rgb(242, 242, 242); // Buttons // ------------------------- @@ -207,16 +207,16 @@ $btn-active-box-shadow: 0px 0px 4px rgba(234, 161, 51, 0.6); // Forms // ------------------------- -$input-bg: #faf8fb; +$input-bg: #ffffff; $input-bg-disabled: rgba(45, 51, 62, 0.04); $input-color: #211f24; -$input-border-color: #d0ccd5; +$input-border-color: #dcd9e1; $input-box-shadow: none; $input-border-focus: #5794f2; $input-box-shadow-focus: #5794f2; $input-color-placeholder: #8e8a94; -$input-label-bg: #fdfdfe; +$input-label-bg: #faf9fb; $input-color-select-arrow: #7b8087; // search @@ -229,11 +229,11 @@ $typeahead-selected-color: $yellow; // Dropdowns // ------------------------- -$dropdownBackground: #faf8fb; -$dropdownBorder: #e3e0e7; -$dropdownDividerTop: #e3e0e7; -$dropdownDividerBottom: #e3e0e7; -$dropdownShadow: 0px 13px 20px 1px rgba(33, 31, 36, 0.12); +$dropdownBackground: #ffffff; +$dropdownBorder: #ebe9ee; +$dropdownDividerTop: #ebe9ee; +$dropdownDividerBottom: #ebe9ee; +$dropdownShadow: 0px 2px 4px rgba(33, 31, 36, 0.06), 0px 12px 32px rgba(33, 31, 36, 0.12); $dropdownLinkColor: $dark-2; $dropdownLinkColorHover: $link-color; @@ -263,9 +263,9 @@ $side-menu-header-color: #e9edf2; // Menu dropdowns // ------------------------- -$menu-dropdown-bg: #faf8fb; +$menu-dropdown-bg: #ffffff; $menu-dropdown-hover-bg: rgba(0, 0, 0, 0.06); -$menu-dropdown-shadow: 0px 13px 20px 1px rgba(33, 31, 36, 0.12); +$menu-dropdown-shadow: 0px 2px 4px rgba(33, 31, 36, 0.06), 0px 12px 32px rgba(33, 31, 36, 0.12); // Tabs // ------------------------- @@ -283,17 +283,17 @@ $alert-warning-bg: #ffc53d; $alert-info-bg: #ffc53d; // Tooltips and popovers -$tooltipBackground: #fdfdfe; +$tooltipBackground: #faf9fb; $tooltipColor: #211f24; -$tooltipArrowColor: #fdfdfe; +$tooltipArrowColor: #faf9fb; $tooltipBackgroundError: #dc3b5d; -$tooltipShadow: 0px 4px 8px rgba(33, 31, 36, 0.15); +$tooltipShadow: 0px 1px 2px rgba(33, 31, 36, 0.06), 0px 4px 12px rgba(33, 31, 36, 0.08); -$popover-bg: #faf8fb; +$popover-bg: #ffffff; $popover-color: #211f24; -$popover-border-color: #e3e0e7; -$popover-header-bg: #fdfdfe; -$popover-shadow: 0px 13px 20px 1px rgba(33, 31, 36, 0.12); +$popover-border-color: #ebe9ee; +$popover-header-bg: #faf9fb; +$popover-shadow: 0px 2px 4px rgba(33, 31, 36, 0.06), 0px 12px 32px rgba(33, 31, 36, 0.12); $graph-tooltip-bg: $gray-5; @@ -305,7 +305,7 @@ $popover-error-bg: $btn-danger-bg; $popover-help-bg: $tooltipBackground; $popover-help-color: $tooltipColor; -$popover-code-bg: #faf8fb; +$popover-code-bg: #ffffff; $popover-code-boxshadow: 0 0 5px $gray60; // images @@ -338,9 +338,9 @@ $diff-label-bg: rgba(0, 0, 0, 0.06); $diff-label-fg: $gray-2; $diff-arrow-color: $dark-2; -$diff-group-bg: #fdfdfe; +$diff-group-bg: #faf9fb; -$diff-json-bg: #fdfdfe; +$diff-json-bg: #faf9fb; $diff-json-fg: #211f24; $diff-json-added: $blue-shade; From c2b84fbf07c560d3a6257e3fdba695d6f789eb4d Mon Sep 17 00:00:00 2001 From: Gurinder Singh Date: Sat, 8 Aug 2026 11:13:14 -0400 Subject: [PATCH 07/10] feat(fn-dashboard): support custom layout editing --- public/app/core/reducers/fn-slice.test.ts | 7 + public/app/core/reducers/fn-slice.ts | 10 + .../dashboard/containers/DashboardPage.tsx | 44 ++++- .../dashboard/dashgrid/DashboardGrid.test.tsx | 108 ++++++++++- .../dashboard/dashgrid/DashboardGrid.tsx | 175 +++++++++++++++--- public/microfrontends/fn_dashboard/index.html | 27 +-- 6 files changed, 317 insertions(+), 54 deletions(-) create mode 100644 public/app/core/reducers/fn-slice.test.ts diff --git a/public/app/core/reducers/fn-slice.test.ts b/public/app/core/reducers/fn-slice.test.ts new file mode 100644 index 0000000000000..ce22d930515c2 --- /dev/null +++ b/public/app/core/reducers/fn-slice.test.ts @@ -0,0 +1,7 @@ +import { fnStateProps } from './fn-slice'; + +describe('fn-slice', () => { + it('includes the host portal container id in copied microfrontend state props', () => { + expect(fnStateProps).toContain('portalContainerID'); + }); +}); diff --git a/public/app/core/reducers/fn-slice.ts b/public/app/core/reducers/fn-slice.ts index 1a7732d427f67..b796f74ef2a09 100644 --- a/public/app/core/reducers/fn-slice.ts +++ b/public/app/core/reducers/fn-slice.ts @@ -9,6 +9,7 @@ export interface FnState { slug: string; version: number; controlsContainer: string | null; + dashboardAccessMode: 'standard' | 'custom'; pageTitle: string; queryParams: AnyObject; hiddenVariables: string[]; @@ -18,6 +19,7 @@ export interface FnState { * owns the editing UI, so Grafana only surfaces the trigger. */ enablePanelEdit: boolean; + enablePanelLayoutEdit: boolean; metadata: { teams: string[]; eventListener: ((event: { type: string; data: T }) => void) | null; @@ -39,7 +41,10 @@ export type FnPropMappedFromState = Extract< | 'slug' | 'version' | 'controlsContainer' + | 'dashboardAccessMode' | 'enablePanelEdit' + | 'enablePanelLayoutEdit' + | 'portalContainerID' >; export type FnStateProp = keyof FnState; @@ -47,9 +52,12 @@ export type FnPropsMappedFromState = Pick; export const fnStateProps: FnStateProp[] = [ 'controlsContainer', + 'dashboardAccessMode', 'enablePanelEdit', + 'enablePanelLayoutEdit', 'hiddenVariables', 'pageTitle', + 'portalContainerID', 'queryParams', 'slug', 'uid', @@ -66,10 +74,12 @@ export const INITIAL_FN_STATE: FnState = { slug: '', version: 1, controlsContainer: null, + dashboardAccessMode: 'standard', pageTitle: '', queryParams: {}, hiddenVariables: [], enablePanelEdit: false, + enablePanelLayoutEdit: false, metadata: { teams: [], eventListener: null, diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 0b5e65fde62bf..83dfaf854b622 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -72,7 +72,9 @@ export type MapStateToDashboardPageProps = MapStateToProps< Pick & { dashboard: ReturnType; navIndex: StoreState['navIndex']; - } & Pick, + } & Pick & { + dashboardEventListener: FnGlobalState['metadata']['eventListener']; + }, OwnProps, StoreState >; @@ -94,6 +96,8 @@ export const mapStateToProps: MapStateToDashboardPageProps = (state) => ({ navIndex: state.navIndex, FNDashboard: state.fnGlobalState.FNDashboard, controlsContainer: state.fnGlobalState.controlsContainer, + enablePanelLayoutEdit: state.fnGlobalState.enablePanelLayoutEdit, + dashboardEventListener: state.fnGlobalState.metadata?.eventListener ?? null, }); const mapDispatchToProps: MapDispatchToDashboardPageProps = { @@ -356,6 +360,22 @@ export class UnthemedDashboardPage extends PureComponent { this.setState({ scrollElement }); }; + getFnDashboardSaveModel() { + return this.props.dashboard?.getSaveModelClone(); + } + + onFnDashboardLayoutChange = () => { + const dashboardJson = this.getFnDashboardSaveModel(); + if (!dashboardJson) { + return; + } + + this.props.dashboardEventListener?.({ + type: 'dashboardLayoutChanged', + data: dashboardJson, + }); + }; + getInspectPanel() { const { dashboard, queryParams } = this.props; @@ -376,7 +396,7 @@ export class UnthemedDashboardPage extends PureComponent { } render() { - const { dashboard, initError, queryParams, FNDashboard, controlsContainer } = this.props; + const { dashboard, initError, queryParams, FNDashboard, controlsContainer, enablePanelLayoutEdit } = this.props; const { editPanel, viewPanel, pageNav, sectionNav } = this.state; const kioskMode = getKioskMode(this.props.queryParams); @@ -390,6 +410,18 @@ export class UnthemedDashboardPage extends PureComponent { const showSubMenu = !editPanel && !kioskMode && !this.props.queryParams.editview; const showToolbar = FNDashboard || (kioskMode !== KioskMode.Full && !queryParams.editview); + const isCustomFnDashboardLayoutEditable = FNDashboard && enablePanelLayoutEdit && !viewPanel && !editPanel; + const isDashboardGridLayoutEditable = FNDashboard + ? isCustomFnDashboardLayoutEditable + : Boolean(dashboard.meta.canEdit); + const fnControlsClassName = cx( + 'flex w-full gap-y-2', + viewPanel + ? 'flex-row items-start justify-between gap-x-3' + : 'flex-col-reverse md:flex-row md:items-center md:justify-between' + ); + const fnVariablesClassName = cx('flex items-center', viewPanel ? 'min-w-0 flex-1' : 'w-full'); + const fnTimeRangeClassName = cx('flex items-center justify-end gap-2', viewPanel ? 'shrink-0' : 'w-full'); const pageClassName = cx({ 'panel-in-fullscreen': Boolean(viewPanel), @@ -455,15 +487,15 @@ export class UnthemedDashboardPage extends PureComponent { {!FNDashboard && } {initError && } {FNDashboard && ( -
-
+
+
{showSubMenu && (
)}
-
{FNTimeRange}
+
{FNTimeRange}
)} {showSubMenu && !FNDashboard && ( @@ -474,6 +506,8 @@ export class UnthemedDashboardPage extends PureComponent { diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx index fef25cc59b942..c24ceaf3e11f8 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx @@ -8,6 +8,7 @@ import { TextBoxVariableModel } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { Dashboard } from '@grafana/schema'; import appEvents from 'app/core/app_events'; +import { GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants'; import { GrafanaContext } from 'app/core/context/GrafanaContext'; import { GetVariables } from 'app/features/variables/state/selectors'; import { VariablesChanged } from 'app/features/variables/types'; @@ -17,7 +18,7 @@ import { DashboardMeta } from 'app/types'; import { DashboardModel } from '../state'; import { createDashboardModelFixture } from '../state/__fixtures__/dashboardFixtures'; -import { DashboardGrid, Props } from './DashboardGrid'; +import { Component, DashboardGrid, Props } from './DashboardGrid'; import { Props as LazyLoaderProps } from './LazyLoader'; jest.mock('@grafana/runtime', () => ({ @@ -55,6 +56,21 @@ function setup(props: Props) { ); } +function getRect(overrides: Partial): DOMRect { + return { + bottom: 0, + height: 0, + left: 0, + right: 0, + toJSON: () => ({}), + top: 0, + width: 0, + x: 0, + y: 0, + ...overrides, + }; +} + function getTestDashboard( overrides?: Partial, metaOverrides?: Partial, @@ -115,6 +131,96 @@ describe('DashboardGrid', () => { expect(await screen.findByText('My gauge')).toBeInTheDocument(); }); + it('Should render only the selected panel in embedded view-panel mode', async () => { + const dashboard = getTestDashboard(); + const viewPanel = dashboard.getPanelById(2); + dashboard.initViewPanel(viewPanel); + + const props: Props = { + editPanel: null, + viewPanel, + isEditable: false, + isFnDashboard: true, + portalContainerID: 'grafana-portal', + dashboard, + }; + + act(() => { + setup(props); + }); + + expect(await screen.findByText('My table')).toBeInTheDocument(); + expect(screen.queryByText('My graph')).toBeNull(); + expect(screen.queryByText('My table 2')).toBeNull(); + expect(screen.queryByText('My gauge')).toBeNull(); + }); + + it('Should normalize selected panel layout in embedded view-panel mode without changing the dashboard model', () => { + const dashboard = getTestDashboard(); + const viewPanel = dashboard.getPanelById(2); + const originalGridPos = { x: 12, y: 10, w: 12, h: 10 }; + viewPanel.gridPos = { ...originalGridPos }; + dashboard.initViewPanel(viewPanel); + + const props: Props = { + editPanel: null, + viewPanel, + isEditable: false, + isFnDashboard: true, + portalContainerID: 'grafana-portal', + dashboard, + }; + + const grid = new Component(props); + const layout = grid.buildLayout(); + const panelLayout = layout[0]!; + + expect(layout).toHaveLength(1); + expect(panelLayout).toEqual( + expect.objectContaining({ + x: 0, + y: 0, + w: GRID_COLUMN_COUNT, + h: originalGridPos.h, + }) + ); + + grid.onLayoutChange([{ ...panelLayout, x: 8, y: 5, w: 8, h: 8 }]); + + expect(viewPanel.gridPos).toEqual(originalGridPos); + }); + + it('Should measure embedded view-panel height below dashboard controls', () => { + const dashboard = getTestDashboard(); + const viewPanel = dashboard.getPanelById(2); + dashboard.initViewPanel(viewPanel); + const portal = document.createElement('div'); + const gridWrapper = document.createElement('div'); + portal.id = 'grafana-portal'; + portal.appendChild(gridWrapper); + document.body.appendChild(portal); + + jest.spyOn(portal, 'getBoundingClientRect').mockReturnValue(getRect({ bottom: 680, height: 575, top: 105 })); + jest.spyOn(gridWrapper, 'getBoundingClientRect').mockReturnValue(getRect({ bottom: 541, height: 372, top: 169 })); + + const props: Props = { + editPanel: null, + viewPanel, + isEditable: false, + isFnDashboard: true, + portalContainerID: portal.id, + dashboard, + }; + const grid = new Component(props); + Object.defineProperty(grid, 'gridWrapperElement', { + value: gridWrapper, + }); + + expect(grid.getEmbeddedViewPanelHeight()).toBe(680 - 169 - GRID_CELL_VMARGIN); + + portal.remove(); + }); + it('Should allow filtering panels', async () => { const props: Props = { editPanel: null, diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index c55bcc4af906a..ae3975d159304 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -22,10 +22,12 @@ import { DashboardPanel } from './DashboardPanel'; export interface Props { dashboard: DashboardModel; isEditable: boolean; + isLayoutEditable?: boolean; editPanel: PanelModel | null; viewPanel: PanelModel | null; hidePanelMenus?: boolean; isFnDashboard?: boolean; + onLayoutUpdate?: () => void; portalContainerID?: string; } @@ -34,6 +36,42 @@ interface State { viewPanelHeight?: number; } +type GridResizeHandle = NonNullable[number]; +type ResizeHandleRenderer = (resizeHandle: GridResizeHandle, ref: React.RefObject) => React.ReactNode; + +const customDashboardResizeHandles: GridResizeHandle[] = ['e', 's', 'se']; +const customDashboardResizeHandleBaseStyle: CSSProperties = { + display: 'block', + pointerEvents: 'auto', + position: 'absolute', + touchAction: 'none', + visibility: 'visible', + zIndex: 20, +}; + +const customDashboardResizeHandleStyles: Record = { + e: { ...customDashboardResizeHandleBaseStyle, cursor: 'ew-resize', height: '100%', right: -6, top: 0, width: 12 }, + n: { ...customDashboardResizeHandleBaseStyle, cursor: 'ns-resize', height: 12, left: 0, top: -6, width: '100%' }, + ne: { ...customDashboardResizeHandleBaseStyle, cursor: 'ne-resize', height: 28, right: -6, top: -6, width: 28 }, + nw: { ...customDashboardResizeHandleBaseStyle, cursor: 'nw-resize', height: 28, left: -6, top: -6, width: 28 }, + s: { ...customDashboardResizeHandleBaseStyle, bottom: -6, cursor: 'ns-resize', height: 12, left: 0, width: '100%' }, + se: { ...customDashboardResizeHandleBaseStyle, bottom: -6, cursor: 'se-resize', height: 28, right: -6, width: 28 }, + sw: { ...customDashboardResizeHandleBaseStyle, bottom: -6, cursor: 'sw-resize', height: 28, left: -6, width: 28 }, + w: { ...customDashboardResizeHandleBaseStyle, cursor: 'ew-resize', height: '100%', left: -6, top: 0, width: 12 }, +}; + +const renderCustomDashboardResizeHandle: ResizeHandleRenderer = (resizeHandle, ref) => ( +