From 295c0afc35f868a7a441762049a99d60de6d4c81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Farkas?= Date: Mon, 3 Feb 2025 09:06:18 +0100 Subject: [PATCH 1/9] datasources: querier: adjust the query-client (#99805) --- pkg/registry/apis/query/client.go | 11 +++------- pkg/registry/apis/query/client/plugin.go | 20 ++++++++--------- pkg/registry/apis/query/client/testdata.go | 22 ------------------- .../apis/query/clientapi/clientapi.go | 18 +++++++++++++++ pkg/registry/apis/query/query.go | 12 +--------- pkg/registry/apis/query/query_test.go | 3 ++- pkg/registry/apis/query/register.go | 5 +++-- 7 files changed, 37 insertions(+), 54 deletions(-) create mode 100644 pkg/registry/apis/query/clientapi/clientapi.go diff --git a/pkg/registry/apis/query/client.go b/pkg/registry/apis/query/client.go index 7f621512c09..2317afdffc9 100644 --- a/pkg/registry/apis/query/client.go +++ b/pkg/registry/apis/query/client.go @@ -4,18 +4,13 @@ import ( "context" data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/query/clientapi" ) -// The query runner interface -type DataSourceClientSupplier interface { - // Get a client for a given datasource - GetDataSourceClient(ctx context.Context, ref data.DataSourceRef, headers map[string]string) (data.QueryDataClient, error) -} - type CommonDataSourceClientSupplier struct { - Client data.QueryDataClient + Client clientapi.QueryDataClient } -func (s *CommonDataSourceClientSupplier) GetDataSourceClient(_ context.Context, _ data.DataSourceRef, _ map[string]string) (data.QueryDataClient, error) { +func (s *CommonDataSourceClientSupplier) GetDataSourceClient(_ context.Context, _ data.DataSourceRef, _ map[string]string) (clientapi.QueryDataClient, error) { return s.Client, nil } diff --git a/pkg/registry/apis/query/client/plugin.go b/pkg/registry/apis/query/client/plugin.go index ab1f85dd5c6..2ffad9fa52b 100644 --- a/pkg/registry/apis/query/client/plugin.go +++ b/pkg/registry/apis/query/client/plugin.go @@ -3,7 +3,6 @@ package client import ( "context" "fmt" - "net/http" "sync" "time" @@ -14,6 +13,7 @@ import ( query "github.com/grafana/grafana/pkg/apis/query/v0alpha1" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/registry/apis/query/clientapi" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" @@ -36,11 +36,11 @@ type pluginRegistry struct { dataSourcesService datasources.DataSourceService } -var _ data.QueryDataClient = (*pluginClient)(nil) +var _ clientapi.QueryDataClient = (*pluginClient)(nil) var _ query.DataSourceApiServerRegistry = (*pluginRegistry)(nil) // NewQueryClientForPluginClient creates a client that delegates to the internal plugins.Client stack -func NewQueryClientForPluginClient(p plugins.Client, ctx *plugincontext.Provider) data.QueryDataClient { +func NewQueryClientForPluginClient(p plugins.Client, ctx *plugincontext.Provider) clientapi.QueryDataClient { return &pluginClient{ pluginClient: p, pCtxProvider: ctx, @@ -57,19 +57,19 @@ func NewDataSourceRegistryFromStore(pluginStore pluginstore.Store, } // ExecuteQueryData implements QueryHelper. -func (d *pluginClient) QueryData(ctx context.Context, req data.QueryDataRequest) (int, *backend.QueryDataResponse, error) { +func (d *pluginClient) QueryData(ctx context.Context, req data.QueryDataRequest) (*backend.QueryDataResponse, error) { queries, dsRef, err := data.ToDataSourceQueries(req) if err != nil { - return http.StatusBadRequest, nil, err + return nil, err } if dsRef == nil { - return http.StatusBadRequest, nil, fmt.Errorf("expected single datasource request") + return nil, fmt.Errorf("expected single datasource request") } // NOTE: this depends on uid unique across datasources settings, err := d.pCtxProvider.GetDataSourceInstanceSettings(ctx, dsRef.UID) if err != nil { - return http.StatusBadRequest, nil, err + return nil, err } qdr := &backend.QueryDataRequest{ @@ -77,14 +77,14 @@ func (d *pluginClient) QueryData(ctx context.Context, req data.QueryDataRequest) } qdr.PluginContext, err = d.pCtxProvider.PluginContextForDataSource(ctx, settings) if err != nil { - return http.StatusBadRequest, nil, err + return nil, err } rsp, err := d.pluginClient.QueryData(ctx, qdr) if err != nil { - return http.StatusInternalServerError, rsp, err + return rsp, err } - return query.GetResponseCode(rsp), rsp, err + return rsp, err } // GetDatasourceAPI implements DataSourceRegistry. diff --git a/pkg/registry/apis/query/client/testdata.go b/pkg/registry/apis/query/client/testdata.go index 16aba9469e6..e481fde1b96 100644 --- a/pkg/registry/apis/query/client/testdata.go +++ b/pkg/registry/apis/query/client/testdata.go @@ -3,45 +3,23 @@ package client import ( "context" "fmt" - "net/http" "time" - "github.com/grafana/grafana-plugin-sdk-go/backend" - data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" query "github.com/grafana/grafana/pkg/apis/query/v0alpha1" - testdata "github.com/grafana/grafana/pkg/tsdb/grafana-testdata-datasource" ) type testdataDummy struct{} -var _ data.QueryDataClient = (*testdataDummy)(nil) var _ query.DataSourceApiServerRegistry = (*testdataDummy)(nil) -// NewTestDataClient creates a runner that only works with testdata -func NewTestDataClient() data.QueryDataClient { - return &testdataDummy{} -} - // NewTestDataRegistry returns a registry that only knows about testdata func NewTestDataRegistry() query.DataSourceApiServerRegistry { return &testdataDummy{} } -// ExecuteQueryData implements QueryHelper. -func (d *testdataDummy) QueryData(ctx context.Context, req data.QueryDataRequest) (int, *backend.QueryDataResponse, error) { - queries, _, err := data.ToDataSourceQueries(req) - if err != nil { - return http.StatusBadRequest, nil, err - } - - qdr := &backend.QueryDataRequest{Queries: queries} - rsp, err := testdata.ProvideService().QueryData(ctx, qdr) - return query.GetResponseCode(rsp), rsp, err -} - // GetDatasourceAPI implements DataSourceRegistry. func (*testdataDummy) GetDatasourceGroupVersion(pluginId string) (schema.GroupVersion, error) { if pluginId == "testdata" || pluginId == "grafana-testdata-datasource" { diff --git a/pkg/registry/apis/query/clientapi/clientapi.go b/pkg/registry/apis/query/clientapi/clientapi.go new file mode 100644 index 00000000000..5dfd91267ad --- /dev/null +++ b/pkg/registry/apis/query/clientapi/clientapi.go @@ -0,0 +1,18 @@ +package clientapi + +import ( + "context" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" +) + +type QueryDataClient interface { + QueryData(ctx context.Context, req data.QueryDataRequest) (*backend.QueryDataResponse, error) +} + +// The query runner interface +type DataSourceClientSupplier interface { + // Get a client for a given datasource + GetDataSourceClient(ctx context.Context, ref data.DataSourceRef, headers map[string]string) (QueryDataClient, error) +} diff --git a/pkg/registry/apis/query/query.go b/pkg/registry/apis/query/query.go index bdd1a8f227e..f86bf0fc75f 100644 --- a/pkg/registry/apis/query/query.go +++ b/pkg/registry/apis/query/query.go @@ -242,7 +242,7 @@ func (b *QueryAPIBuilder) handleQuerySingleDatasource(ctx context.Context, req d return qdr, err } - code, rsp, err := client.QueryData(ctx, *req.Request) + rsp, err := client.QueryData(ctx, *req.Request) if err == nil && rsp != nil { for _, q := range req.Request.Queries { if q.ResultAssertions != nil { @@ -259,16 +259,6 @@ func (b *QueryAPIBuilder) handleQuerySingleDatasource(ctx context.Context, req d } } - // Create a response object with the error when missing (happens for client errors like 404) - if rsp == nil && err != nil { - rsp = &backend.QueryDataResponse{Responses: make(backend.Responses)} - for _, q := range req.Request.Queries { - rsp.Responses[q.RefID] = backend.DataResponse{ - Status: backend.Status(code), - Error: err, - } - } - } return rsp, err } diff --git a/pkg/registry/apis/query/query_test.go b/pkg/registry/apis/query/query_test.go index 711b9da8ed5..5147b51bfb9 100644 --- a/pkg/registry/apis/query/query_test.go +++ b/pkg/registry/apis/query/query_test.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/registry/apis/query/clientapi" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/stretchr/testify/require" @@ -95,7 +96,7 @@ type mockClient struct { lastCalledWithHeaders *map[string]string } -func (m mockClient) GetDataSourceClient(ctx context.Context, ref data.DataSourceRef, headers map[string]string) (data.QueryDataClient, error) { +func (m mockClient) GetDataSourceClient(ctx context.Context, ref data.DataSourceRef, headers map[string]string) (clientapi.QueryDataClient, error) { *m.lastCalledWithHeaders = headers return nil, fmt.Errorf("mock error") diff --git a/pkg/registry/apis/query/register.go b/pkg/registry/apis/query/register.go index ddf56be78fd..4f8303126fb 100644 --- a/pkg/registry/apis/query/register.go +++ b/pkg/registry/apis/query/register.go @@ -19,6 +19,7 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/registry/apis/query/client" + "github.com/grafana/grafana/pkg/registry/apis/query/clientapi" "github.com/grafana/grafana/pkg/registry/apis/query/queryschema" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver/builder" @@ -40,14 +41,14 @@ type QueryAPIBuilder struct { tracer tracing.Tracer metrics *queryMetrics parser *queryParser - client DataSourceClientSupplier + client clientapi.DataSourceClientSupplier registry query.DataSourceApiServerRegistry converter *expr.ResultConverter queryTypes *query.QueryTypeDefinitionList } func NewQueryAPIBuilder(features featuremgmt.FeatureToggles, - client DataSourceClientSupplier, + client clientapi.DataSourceClientSupplier, registry query.DataSourceApiServerRegistry, legacy service.LegacyDataSourceLookup, registerer prometheus.Registerer, From 8c0b8128749e6064e7577d18113be15405e5392e Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Mon, 3 Feb 2025 12:44:34 +0300 Subject: [PATCH 2/9] Provisioning: Expose dashboard config within grafana (#99947) --- .../provisioning/dashboards/config_reader.go | 41 ++++++++++++++++--- .../dashboards/config_reader_test.go | 14 ++++--- .../provisioning/dashboards/dashboard.go | 2 +- pkg/services/provisioning/dashboards/types.go | 7 ++++ .../provisioning/datasources/config_reader.go | 3 +- pkg/services/provisioning/utils/utils.go | 22 ++++++---- 6 files changed, 68 insertions(+), 21 deletions(-) diff --git a/pkg/services/provisioning/dashboards/config_reader.go b/pkg/services/provisioning/dashboards/config_reader.go index 8d93c4de46b..3cf08eb01c2 100644 --- a/pkg/services/provisioning/dashboards/config_reader.go +++ b/pkg/services/provisioning/dashboards/config_reader.go @@ -11,14 +11,45 @@ import ( "gopkg.in/yaml.v3" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/provisioning/utils" ) type configReader struct { - path string - log log.Logger - orgService org.Service + path string + log log.Logger + orgExists utils.OrgExists +} + +func ReadDashboardConfig(dir string) ([]*DashboardProvisioning, error) { + var cfg []*DashboardProvisioning + + cr := &configReader{ + path: dir, + } + + files, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + for _, file := range files { + if !strings.HasSuffix(file.Name(), ".yaml") && !strings.HasSuffix(file.Name(), ".yml") { + continue + } + + parsedDashboards, err := cr.parseConfigs(file) + if err != nil { + return nil, fmt.Errorf("could not parse provisioning config file: %s error: %v", file.Name(), err) + } + + for _, config := range parsedDashboards { + cfg = append(cfg, &DashboardProvisioning{ + config: *config, + }) + } + } + + return cfg, nil } func (cr *configReader) parseConfigs(file fs.DirEntry) ([]*config, error) { @@ -96,7 +127,7 @@ func (cr *configReader) readConfig(ctx context.Context) ([]*config, error) { dashboard.OrgID = 1 } - if err := utils.CheckOrgExists(ctx, cr.orgService, dashboard.OrgID); err != nil { + if err := cr.orgExists(ctx, dashboard.OrgID); err != nil { return nil, fmt.Errorf("failed to provision dashboards with %q reader: %w", dashboard.Name, err) } diff --git a/pkg/services/provisioning/dashboards/config_reader_test.go b/pkg/services/provisioning/dashboards/config_reader_test.go index 07f0ade89a1..6afe2607e68 100644 --- a/pkg/services/provisioning/dashboards/config_reader_test.go +++ b/pkg/services/provisioning/dashboards/config_reader_test.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgtest" + "github.com/grafana/grafana/pkg/services/provisioning/utils" ) var ( @@ -25,10 +26,11 @@ func TestDashboardsAsConfig(t *testing.T) { logger := log.New("test-logger") // store := db.InitTestDB(t) orgFake := orgtest.NewOrgServiceFake() + orgExists := utils.NewOrgExistsChecker(orgFake) t.Run("Should fail if orgs don't exist in the database", func(t *testing.T) { orgFake.ExpectedError = org.ErrOrgNotFound - cfgProvider := configReader{path: appliedDefaults, log: logger, orgService: orgFake} + cfgProvider := configReader{path: appliedDefaults, log: logger, orgExists: orgExists} _, err := cfgProvider.readConfig(context.Background()) require.Error(t, err) assert.True(t, errors.Is(err, org.ErrOrgNotFound)) @@ -36,7 +38,7 @@ func TestDashboardsAsConfig(t *testing.T) { }) t.Run("default values should be applied", func(t *testing.T) { - cfgProvider := configReader{path: appliedDefaults, log: logger, orgService: orgFake} + cfgProvider := configReader{path: appliedDefaults, log: logger, orgExists: orgExists} cfg, err := cfgProvider.readConfig(context.Background()) require.NoError(t, err) @@ -47,7 +49,7 @@ func TestDashboardsAsConfig(t *testing.T) { t.Run("Can read config file version 1 format", func(t *testing.T) { t.Setenv("TEST_VAR", "general") - cfgProvider := configReader{path: simpleDashboardConfig, log: logger, orgService: orgFake} + cfgProvider := configReader{path: simpleDashboardConfig, log: logger, orgExists: orgExists} cfg, err := cfgProvider.readConfig(context.Background()) require.NoError(t, err) @@ -55,7 +57,7 @@ func TestDashboardsAsConfig(t *testing.T) { }) t.Run("Can read config file in version 0 format", func(t *testing.T) { - cfgProvider := configReader{path: oldVersion, log: logger, orgService: orgFake} + cfgProvider := configReader{path: oldVersion, log: logger, orgExists: orgExists} cfg, err := cfgProvider.readConfig(context.Background()) require.NoError(t, err) @@ -63,7 +65,7 @@ func TestDashboardsAsConfig(t *testing.T) { }) t.Run("Should skip invalid path", func(t *testing.T) { - cfgProvider := configReader{path: "/invalid-directory", log: logger, orgService: orgFake} + cfgProvider := configReader{path: "/invalid-directory", log: logger, orgExists: orgExists} cfg, err := cfgProvider.readConfig(context.Background()) if err != nil { t.Fatalf("readConfig return an error %v", err) @@ -73,7 +75,7 @@ func TestDashboardsAsConfig(t *testing.T) { }) t.Run("Should skip broken config files", func(t *testing.T) { - cfgProvider := configReader{path: brokenConfigs, log: logger, orgService: orgFake} + cfgProvider := configReader{path: brokenConfigs, log: logger, orgExists: orgExists} cfg, err := cfgProvider.readConfig(context.Background()) if err != nil { t.Fatalf("readConfig return an error %v", err) diff --git a/pkg/services/provisioning/dashboards/dashboard.go b/pkg/services/provisioning/dashboards/dashboard.go index 0a689d2e295..1d1d8fd87c7 100644 --- a/pkg/services/provisioning/dashboards/dashboard.go +++ b/pkg/services/provisioning/dashboards/dashboard.go @@ -42,7 +42,7 @@ func (provider *Provisioner) HasDashboardSources() bool { // New returns a new DashboardProvisioner func New(ctx context.Context, configDirectory string, provisioner dashboards.DashboardProvisioningService, orgService org.Service, dashboardStore utils.DashboardStore, folderService folder.Service) (DashboardProvisioner, error) { logger := log.New("provisioning.dashboard") - cfgReader := &configReader{path: configDirectory, log: logger, orgService: orgService} + cfgReader := &configReader{path: configDirectory, log: logger, orgExists: utils.NewOrgExistsChecker(orgService)} configs, err := cfgReader.readConfig(ctx) if err != nil { return nil, fmt.Errorf("%v: %w", "Failed to read dashboards config", err) diff --git a/pkg/services/provisioning/dashboards/types.go b/pkg/services/provisioning/dashboards/types.go index 18e58c1ce7f..717326305ae 100644 --- a/pkg/services/provisioning/dashboards/types.go +++ b/pkg/services/provisioning/dashboards/types.go @@ -36,6 +36,13 @@ type configV0 struct { AllowUIUpdates bool `json:"allowUiUpdates" yaml:"allowUiUpdates"` } +// Access to dashboard provisioning config +// Exposes the internal config outside this package with as few changes as possible +// NOTE: these provisioning configs will eventually be replaced with: /apis/provisioning.grafana.app/ +type DashboardProvisioning struct { + config +} + type configVersion struct { APIVersion int64 `json:"apiVersion" yaml:"apiVersion"` } diff --git a/pkg/services/provisioning/datasources/config_reader.go b/pkg/services/provisioning/datasources/config_reader.go index 8b8d73a4496..1d5a992d1f7 100644 --- a/pkg/services/provisioning/datasources/config_reader.go +++ b/pkg/services/provisioning/datasources/config_reader.go @@ -131,7 +131,8 @@ func (cr *configReader) validateDefaultUniqueness(ctx context.Context, datasourc } func (cr *configReader) validateAccessAndOrgID(ctx context.Context, ds *upsertDataSourceFromConfig) error { - if err := utils.CheckOrgExists(ctx, cr.orgService, ds.OrgID); err != nil { + checker := utils.NewOrgExistsChecker(cr.orgService) + if err := checker(ctx, ds.OrgID); err != nil { return err } diff --git a/pkg/services/provisioning/utils/utils.go b/pkg/services/provisioning/utils/utils.go index 05e649aa586..80a7e838a64 100644 --- a/pkg/services/provisioning/utils/utils.go +++ b/pkg/services/provisioning/utils/utils.go @@ -13,14 +13,20 @@ type DashboardStore interface { GetDashboard(context.Context, *dashboards.GetDashboardQuery) (*dashboards.Dashboard, error) } -func CheckOrgExists(ctx context.Context, orgService org.Service, orgID int64) error { - query := org.GetOrgByIDQuery{ID: orgID} - _, err := orgService.GetByID(ctx, &query) - if err != nil { - if errors.Is(err, org.ErrOrgNotFound) { - return err +// Throw an error if the org does not exist +type OrgExists = func(ctx context.Context, orgID int64) error + +// Use the org service to check if an org exists +func NewOrgExistsChecker(orgService org.Service) OrgExists { + return func(ctx context.Context, orgID int64) error { + query := org.GetOrgByIDQuery{ID: orgID} + _, err := orgService.GetByID(ctx, &query) + if err != nil { + if errors.Is(err, org.ErrOrgNotFound) { + return err + } + return fmt.Errorf("failed to check whether org. with the given ID exists: %w", err) } - return fmt.Errorf("failed to check whether org. with the given ID exists: %w", err) + return nil } - return nil } From 61f5f215ee420b3fc8af3e52897189ee4aac8634 Mon Sep 17 00:00:00 2001 From: Bogdan Matei Date: Mon, 3 Feb 2025 11:46:47 +0200 Subject: [PATCH 3/9] Dashboards: Implement rows repeat in rows layout (#99300) --- .betterer.results | 18 -- e2e/old-arch/various-suite/solo-route.spec.ts | 12 +- e2e/various-suite/solo-route.spec.ts | 12 +- .../edit-pane/DashboardEditPane.tsx | 11 + .../scene/DashboardScene.test.tsx | 7 +- .../dashboard-scene/scene/DashboardScene.tsx | 21 +- .../scene/DashboardSceneUrlSync.test.ts | 8 +- .../scene/DashboardSceneUrlSync.ts | 9 +- .../scene/PanelMenuBehavior.tsx | 11 +- .../layout-default/DashboardGridItem.test.tsx | 6 +- .../layout-default/DashboardGridItem.tsx | 3 +- .../DefaultGridLayoutManager.test.tsx | 3 + .../DefaultGridLayoutManager.tsx | 109 ++++++++- .../RowRepeaterBehavior.test.tsx | 32 ++- .../RowRepeaterBehavior.ts | 188 +++++++-------- .../row-actions/RowActions.tsx | 39 ++- .../row-actions/RowOptionsButton.tsx | 3 +- .../row-actions/RowOptionsForm.test.tsx | 2 +- .../row-actions/RowOptionsForm.tsx | 11 +- .../row-actions/RowOptionsModal.tsx | 8 +- .../ResponsiveGridLayoutManager.tsx | 7 +- .../scene/layout-rows/RowItem.tsx | 133 +++++++++- .../RowItemRepeaterBehavior.test.tsx | 227 ++++++++++++++++++ .../layout-rows/RowItemRepeaterBehavior.ts | 165 +++++++++++++ .../scene/layout-rows/RowsLayoutManager.tsx | 59 ++++- .../features/dashboard-scene/scene/types.ts | 24 +- .../transformSaveModelSchemaV2ToScene.ts | 4 +- .../transformSaveModelToScene.test.ts | 2 +- .../transformSaveModelToScene.ts | 4 +- .../transformSceneToSaveModel.test.ts | 2 +- .../transformSceneToSaveModel.ts | 5 +- .../transformSceneToSaveModelSchemaV2.test.ts | 2 +- .../transformSceneToSaveModelSchemaV2.ts | 5 +- .../solo/useSoloPanel.test.tsx | 2 +- .../dashboard-scene/solo/useSoloPanel.ts | 5 +- .../dashboard-scene/utils/clone.test.ts | 118 +++++++++ .../features/dashboard-scene/utils/clone.ts | 73 ++++++ .../dashboard-scene/utils/test-utils.ts | 2 +- .../features/dashboard-scene/utils/utils.ts | 34 +-- public/locales/en-US/grafana.json | 38 +++ public/locales/pseudo-LOCALE/grafana.json | 38 +++ 41 files changed, 1188 insertions(+), 274 deletions(-) rename public/app/features/dashboard-scene/scene/{ => layout-default}/RowRepeaterBehavior.test.tsx (88%) rename public/app/features/dashboard-scene/scene/{ => layout-default}/RowRepeaterBehavior.ts (62%) rename public/app/features/dashboard-scene/scene/{ => layout-default}/row-actions/RowActions.tsx (72%) rename public/app/features/dashboard-scene/scene/{ => layout-default}/row-actions/RowOptionsButton.tsx (89%) rename public/app/features/dashboard-scene/scene/{ => layout-default}/row-actions/RowOptionsForm.test.tsx (96%) rename public/app/features/dashboard-scene/scene/{ => layout-default}/row-actions/RowOptionsForm.tsx (79%) rename public/app/features/dashboard-scene/scene/{ => layout-default}/row-actions/RowOptionsModal.tsx (79%) create mode 100644 public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.test.tsx create mode 100644 public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.ts create mode 100644 public/app/features/dashboard-scene/utils/clone.test.ts create mode 100644 public/app/features/dashboard-scene/utils/clone.ts diff --git a/.betterer.results b/.betterer.results index ba158ab930d..160b522a8c2 100644 --- a/.betterer.results +++ b/.betterer.results @@ -3452,24 +3452,6 @@ exports[`better eslint`] = { "public/app/features/dashboard-scene/scene/UnlinkModal.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] ], - "public/app/features/dashboard-scene/scene/row-actions/RowActions.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "3"] - ], - "public/app/features/dashboard-scene/scene/row-actions/RowOptionsButton.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] - ], - "public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "3"] - ], - "public/app/features/dashboard-scene/scene/row-actions/RowOptionsModal.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] - ], "public/app/features/dashboard-scene/scene/types.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] diff --git a/e2e/old-arch/various-suite/solo-route.spec.ts b/e2e/old-arch/various-suite/solo-route.spec.ts index cfea359cff2..9717baf41bd 100644 --- a/e2e/old-arch/various-suite/solo-route.spec.ts +++ b/e2e/old-arch/various-suite/solo-route.spec.ts @@ -5,7 +5,7 @@ describe('Solo Route', () => { e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); }); - it('Can view panels with shared queries in fullsceen', () => { + it('Can view panels with shared queries in fullscreen', () => { // open Panel Tests - Bar Gauge e2e.pages.SoloPanel.visit('ZqZnVvFZz/datasource-tests-shared-queries?orgId=1&panelId=4'); @@ -25,20 +25,20 @@ describe('Solo Route', () => { it('Can view solo repeated panel in scenes', () => { // open Panel Tests - Graph NG e2e.pages.SoloPanel.visit( - 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-1&__feature.dashboardSceneSolo=true' + 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-16-clone-0/grid-item-2/panel-2-clone-0&__feature.dashboardSceneSolo=true' ); - e2e.components.Panels.Panel.title('server=B').should('exist'); + e2e.components.Panels.Panel.title('server=A').should('exist'); cy.contains('uplot-main-div').should('not.exist'); }); - it('Can view solo in repeaterd row and panel in scenes', () => { + it('Can view solo in repeated row and panel in scenes', () => { // open Panel Tests - Graph NG e2e.pages.SoloPanel.visit( - 'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-2-clone-D-clone-2&__feature.dashboardSceneSolo=true' + 'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-16-clone-1/grid-item-2/panel-2-clone-1&__feature.dashboardSceneSolo=true' ); - e2e.components.Panels.Panel.title('server = D, pod = Sod').should('exist'); + e2e.components.Panels.Panel.title('server = A, pod = Rob').should('exist'); cy.contains('uplot-main-div').should('not.exist'); }); }); diff --git a/e2e/various-suite/solo-route.spec.ts b/e2e/various-suite/solo-route.spec.ts index cfea359cff2..9717baf41bd 100644 --- a/e2e/various-suite/solo-route.spec.ts +++ b/e2e/various-suite/solo-route.spec.ts @@ -5,7 +5,7 @@ describe('Solo Route', () => { e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); }); - it('Can view panels with shared queries in fullsceen', () => { + it('Can view panels with shared queries in fullscreen', () => { // open Panel Tests - Bar Gauge e2e.pages.SoloPanel.visit('ZqZnVvFZz/datasource-tests-shared-queries?orgId=1&panelId=4'); @@ -25,20 +25,20 @@ describe('Solo Route', () => { it('Can view solo repeated panel in scenes', () => { // open Panel Tests - Graph NG e2e.pages.SoloPanel.visit( - 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-2-clone-1&__feature.dashboardSceneSolo=true' + 'templating-repeating-panels/templating-repeating-panels?orgId=1&from=1699934989607&to=1699956589607&panelId=panel-16-clone-0/grid-item-2/panel-2-clone-0&__feature.dashboardSceneSolo=true' ); - e2e.components.Panels.Panel.title('server=B').should('exist'); + e2e.components.Panels.Panel.title('server=A').should('exist'); cy.contains('uplot-main-div').should('not.exist'); }); - it('Can view solo in repeaterd row and panel in scenes', () => { + it('Can view solo in repeated row and panel in scenes', () => { // open Panel Tests - Graph NG e2e.pages.SoloPanel.visit( - 'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-2-clone-D-clone-2&__feature.dashboardSceneSolo=true' + 'Repeating-rows-uid/repeating-rows?orgId=1&var-server=A&var-server=B&var-server=D&var-pod=1&var-pod=2&var-pod=3&panelId=panel-16-clone-1/grid-item-2/panel-2-clone-1&__feature.dashboardSceneSolo=true' ); - e2e.components.Panels.Panel.title('server = D, pod = Sod').should('exist'); + e2e.components.Panels.Panel.title('server = A, pod = Rob').should('exist'); cy.contains('uplot-main-div').should('not.exist'); }); }); diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx index b8fdd41352d..6376687d480 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx @@ -12,6 +12,7 @@ import { } from '@grafana/scenes'; import { ElementSelectionContextItem, ElementSelectionContextState, ToolbarButton, useStyles2 } from '@grafana/ui'; +import { isInCloneChain } from '../utils/clone'; import { getDashboardSceneFor } from '../utils/utils'; import { ElementEditPane } from './ElementEditPane'; @@ -46,6 +47,16 @@ export class DashboardEditPane extends SceneObjectBase { } private selectElement(element: ElementSelectionContextItem, multi?: boolean) { + // We should not select clones + if (isInCloneChain(element.id)) { + if (multi) { + return; + } + + this.clearSelection(); + return; + } + const obj = sceneGraph.findByKey(this, element.id); if (obj) { this.selectObject(obj, element.id, multi); diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx index 80d4b19fa3a..386aa1e7782 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx @@ -23,6 +23,7 @@ import { createWorker } from '../saving/createDetectChangesWorker'; import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { DecoratedRevisionModel } from '../settings/VersionsEditView'; import { historySrv } from '../settings/version-history/HistorySrv'; +import { getCloneKey } from '../utils/clone'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { djb2Hash } from '../utils/djb2Hash'; import { findVizPanelByKey, getLibraryPanelBehavior, isLibraryPanel } from '../utils/utils'; @@ -33,7 +34,7 @@ import { LibraryPanelBehavior } from './LibraryPanelBehavior'; import { PanelTimeRange } from './PanelTimeRange'; import { DashboardGridItem } from './layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; -import { RowActions } from './row-actions/RowActions'; +import { RowActions } from './layout-default/row-actions/RowActions'; jest.mock('../settings/version-history/HistorySrv'); jest.mock('../serialization/transformSaveModelToScene'); @@ -642,7 +643,7 @@ describe('DashboardScene', () => { it('Should hash the key of the cloned panels and set it as panelId', () => { const queryRunner = sceneGraph.findObject(scene, (o) => o.state.key === 'data-query-runner2')!; - const expectedPanelId = djb2Hash('panel-2-clone-1'); + const expectedPanelId = djb2Hash(getCloneKey('panel-2', 1)); expect(scene.enrichDataRequest(queryRunner).panelId).toEqual(expectedPanelId); }); }); @@ -915,7 +916,7 @@ function buildTestScene(overrides?: Partial) { new DashboardGridItem({ body: new VizPanel({ title: 'Panel B', - key: 'panel-2-clone-1', + key: getCloneKey('panel-2', 1), pluginId: 'table', $data: new SceneQueryRunner({ key: 'data-query-runner2', queries: [{ refId: 'A' }] }), }), diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index df925ab3272..02b179a003c 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -53,17 +53,12 @@ import { DecoratedRevisionModel } from '../settings/VersionsEditView'; import { DashboardEditView } from '../settings/utils'; import { historySrv } from '../settings/version-history'; import { DashboardModelCompatibilityWrapper } from '../utils/DashboardModelCompatibilityWrapper'; +import { isInCloneChain } from '../utils/clone'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { djb2Hash } from '../utils/djb2Hash'; import { getDashboardUrl } from '../utils/getDashboardUrl'; import { getViewPanelUrl } from '../utils/urlBuilders'; -import { - getClosestVizPanel, - getDashboardSceneFor, - getDefaultVizPanel, - getPanelIdForVizPanel, - isPanelClone, -} from '../utils/utils'; +import { getClosestVizPanel, getDashboardSceneFor, getDefaultVizPanel, getPanelIdForVizPanel } from '../utils/utils'; import { SchemaV2EditorDrawer } from '../v2schema/SchemaV2EditorDrawer'; import { AddLibraryPanelDrawer } from './AddLibraryPanelDrawer'; @@ -480,6 +475,10 @@ export class DashboardScene extends SceneObjectBase { return this._initialState; } + public getNextPanelId(): number { + return this.state.body.getMaxPanelId() + 1; + } + public addPanel(vizPanel: VizPanel): void { if (!this.state.isEditing) { this.onEnterEditMode(); @@ -606,10 +605,11 @@ export class DashboardScene extends SceneObjectBase { public switchLayout(layout: DashboardLayoutManager) { this.setState({ body: layout }); + layout.activateRepeaters?.(); } /** - * Called by the SceneQueryRunner to privide contextural parameters (tracking) props for the request + * Called by the SceneQueryRunner to provide contextual parameters (tracking) props for the request */ public enrichDataRequest(sceneObject: SceneObject): Partial { const dashboard = getDashboardSceneFor(sceneObject); @@ -623,9 +623,12 @@ export class DashboardScene extends SceneObjectBase { let panelId = 0; if (panel && panel.state.key) { - if (isPanelClone(panel.state.key)) { + if (isInCloneChain(panel.state.key)) { + // We check if any of the panel ancestors are clones because we can't use the original panel ID in this case panelId = djb2Hash(panel?.state.key); } else { + // Otherwise, it's the absolute original panel, and we can use the key directly + // getPanelIdForVizPanel extracts the panel ID from the key so we don't need to do it manually panelId = getPanelIdForVizPanel(panel); } } diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts index 1decb7b9fc5..6092bac64c2 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts @@ -3,6 +3,8 @@ import { SceneQueryRunner, VizPanel } from '@grafana/scenes'; import appEvents from 'app/core/app_events'; import { KioskMode } from 'app/types'; +import { getCloneKey } from '../utils/clone'; + import { DashboardScene } from './DashboardScene'; import { DashboardGridItem } from './layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; @@ -82,7 +84,7 @@ describe('DashboardSceneUrlSync', () => { let errorNotice = 0; appEvents.on(AppEvents.alertError, (evt) => errorNotice++); - scene.urlSync?.updateFromUrl({ viewPanel: 'panel-1-clone-1' }); + scene.urlSync?.updateFromUrl({ viewPanel: getCloneKey('panel-1', 1) }); expect(scene.state.viewPanelScene).toBeUndefined(); // Verify no error notice was shown @@ -98,7 +100,7 @@ describe('DashboardSceneUrlSync', () => { x: 0, body: new VizPanel({ title: 'Clone Panel A', - key: 'panel-1-clone-1', + key: getCloneKey('panel-1', 1), pluginId: 'table', }), }), @@ -107,7 +109,7 @@ describe('DashboardSceneUrlSync', () => { // Verify it subscribes to DashboardRepeatsProcessedEvent scene.publishEvent(new DashboardRepeatsProcessedEvent({ source: scene })); - expect(scene.state.viewPanelScene?.getUrlKey()).toBe('panel-1-clone-1'); + expect(scene.state.viewPanelScene?.getUrlKey()).toBe(getCloneKey('panel-1', 1)); }); }); diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts index 960789473c1..1eb85045504 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts @@ -12,7 +12,8 @@ import { buildPanelEditScene } from '../panel-edit/PanelEditor'; import { createDashboardEditViewFor } from '../settings/utils'; import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer'; import { ShareModal } from '../sharing/ShareModal'; -import { findVizPanelByKey, getLibraryPanelBehavior, isPanelClone } from '../utils/utils'; +import { containsCloneKey } from '../utils/clone'; +import { findVizPanelByKey, getLibraryPanelBehavior } from '../utils/utils'; import { DashboardScene, DashboardSceneState } from './DashboardScene'; import { LibraryPanelBehavior } from './LibraryPanelBehavior'; @@ -94,8 +95,10 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { const panel = findVizPanelByKey(this._scene, values.viewPanel); if (!panel) { - // // If we are trying to view a repeat clone that can't be found it might be that the repeats have not been processed yet - if (isPanelClone(values.viewPanel)) { + // If we are trying to view a repeat clone that can't be found it might be that the repeats have not been processed yet + // Here we check if the key contains the clone key so we force the repeat processing + // It doesn't matter if the element or the ancestors are clones or not, just that the key contains the clone key + if (containsCloneKey(values.viewPanel)) { this._handleViewRepeatClone(values.viewPanel); return; } diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx index 0080b57e228..1f8a9547c83 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx @@ -30,15 +30,10 @@ import { ShowConfirmModalEvent } from 'app/types/events'; import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer'; import { ShareModal } from '../sharing/ShareModal'; +import { isInCloneChain } from '../utils/clone'; import { DashboardInteractions } from '../utils/interactions'; import { getEditPanelUrl, getInspectUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders'; -import { - getDashboardSceneFor, - getPanelIdForVizPanel, - getQueryRunnerFor, - isLibraryPanel, - isReadOnlyClone, -} from '../utils/utils'; +import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor, isLibraryPanel } from '../utils/utils'; import { DashboardScene } from './DashboardScene'; import { VizPanelLinks, VizPanelLinksMenu } from './PanelLinks'; @@ -59,7 +54,7 @@ export function panelMenuBehavior(menu: VizPanelMenu) { const dashboard = getDashboardSceneFor(panel); const { isEmbedded } = dashboard.state.meta; const exploreMenuItem = await getExploreMenuItem(panel); - const isReadOnlyRepeat = isReadOnlyClone(panel); + const isReadOnlyRepeat = isInCloneChain(panel.state.key!); // For embedded dashboards we only have explore action for now if (isEmbedded) { diff --git a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx index c9bf5da0031..165d00b99a2 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx @@ -3,8 +3,8 @@ import { setPluginImportUtils } from '@grafana/runtime'; import { SceneGridLayout, SceneVariableSet, TestVariable, VizPanel } from '@grafana/scenes'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants'; +import { isInCloneChain } from '../../utils/clone'; import { activateFullSceneTree, buildPanelRepeaterScene } from '../../utils/test-utils'; -import { isReadOnlyClone } from '../../utils/utils'; import { DashboardScene } from '../DashboardScene'; import { DashboardGridItem, DashboardGridItemState } from './DashboardGridItem'; @@ -42,8 +42,8 @@ describe('PanelRepeaterGridItem', () => { expect(panel1.state.$variables?.state.variables[0].getValueText?.()).toBe('A'); expect(panel2.state.$variables?.state.variables[0].getValue()).toBe('2'); - expect(isReadOnlyClone(panel1)).toBe(false); - expect(isReadOnlyClone(panel2)).toBe(true); + expect(isInCloneChain(panel1.state.key!)).toBe(false); + expect(isInCloneChain(panel2.state.key!)).toBe(true); }); it('Should wait for variable to load', async () => { diff --git a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx index b9ce621a2a2..165016a3493 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx @@ -23,6 +23,7 @@ import { import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; +import { getCloneKey } from '../../utils/clone'; import { getMultiVariableValues, getQueryRunnerFor } from '../../utils/utils'; import { DashboardLayoutItem, DashboardRepeatsProcessedEvent } from '../types'; @@ -138,7 +139,7 @@ export class DashboardGridItem }), ], }), - key: `${panelToRepeat.state.key}-clone-${index}`, + key: getCloneKey(panelToRepeat.state.key!, index), }; const clone = panelToRepeat.clone(cloneState); repeatedPanels.push(clone); 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 39a11c6babd..e4bb910235e 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 @@ -1,6 +1,7 @@ import { SceneGridItemLike, SceneGridLayout, SceneGridRow, SceneQueryRunner, VizPanel } from '@grafana/scenes'; import { findVizPanelByKey } from '../../utils/utils'; +import { DashboardScene } from '../DashboardScene'; import { DashboardGridItem } from './DashboardGridItem'; import { DefaultGridLayoutManager } from './DefaultGridLayoutManager'; @@ -277,5 +278,7 @@ function setup(options?: TestOptions) { const grid = new SceneGridLayout({ children: gridItems }); const manager = new DefaultGridLayoutManager({ grid: grid }); + new DashboardScene({ body: manager }); + return { manager, grid }; } 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 f6d2156b1c6..3bae6e1c50c 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -12,18 +12,21 @@ import { } from '@grafana/scenes'; import { GRID_COLUMN_COUNT } from 'app/core/constants'; +import { isClonedKey, joinCloneKeys } from '../../utils/clone'; import { forceRenderChildren, getPanelIdForVizPanel, NEW_PANEL_HEIGHT, NEW_PANEL_WIDTH, getVizPanelKeyForPanelId, + getGridItemKeyForPanelId, + getDashboardSceneFor, } from '../../utils/utils'; -import { RowRepeaterBehavior } from '../RowRepeaterBehavior'; -import { RowActions } from '../row-actions/RowActions'; import { DashboardLayoutManager, LayoutRegistryItem } from '../types'; import { DashboardGridItem } from './DashboardGridItem'; +import { RowRepeaterBehavior } from './RowRepeaterBehavior'; +import { RowActions } from './row-actions/RowActions'; interface DefaultGridLayoutManagerState extends SceneObjectState { grid: SceneGridLayout; @@ -66,7 +69,7 @@ export class DefaultGridLayoutManager x: 0, y: 0, body: vizPanel, - key: `grid-item-${panelId}`, + key: getGridItemKeyForPanelId(panelId), }); this.state.grid.setState({ @@ -75,7 +78,7 @@ export class DefaultGridLayoutManager } /** - * Adds a new emtpy row + * Adds a new empty row */ public addNewRow(): SceneGridRow { const id = this.getNextPanelId(); @@ -127,7 +130,7 @@ export class DefaultGridLayoutManager /** * Removes a panel */ - public removePanel(panel: VizPanel) { + public removePanel(panel: VizPanel): void { const gridItem = panel.parent!; if (!(gridItem instanceof DashboardGridItem)) { @@ -231,7 +234,7 @@ export class DefaultGridLayoutManager return panels; } - public getNextPanelId(): number { + public getMaxPanelId(): number { let max = 0; for (const child of this.state.grid.state.children) { @@ -271,10 +274,14 @@ export class DefaultGridLayoutManager } } - return max + 1; + return max; } - public collapseAllRows() { + public getNextPanelId(): number { + return getDashboardSceneFor(this).getNextPanelId(); + } + + public collapseAllRows(): void { this.state.grid.state.children.forEach((child) => { if (!(child instanceof SceneGridRow)) { return; @@ -285,7 +292,7 @@ export class DefaultGridLayoutManager }); } - public expandAllRows() { + public expandAllRows(): void { this.state.grid.state.children.forEach((child) => { if (!(child instanceof SceneGridRow)) { return; @@ -296,7 +303,7 @@ export class DefaultGridLayoutManager }); } - activateRepeaters(): void { + public activateRepeaters(): void { this.state.grid.forEachChild((child) => { if (child instanceof DashboardGridItem && !child.isActive) { child.activate(); @@ -325,6 +332,76 @@ export class DefaultGridLayoutManager return DefaultGridLayoutManager.getDescriptor(); } + public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager { + return this.clone({ + grid: this.state.grid.clone({ + isResizable: isSource && this.state.grid.state.isResizable, + isDraggable: isSource && this.state.grid.state.isDraggable, + children: this.state.grid.state.children.reduce<{ panelId: number; children: SceneGridItemLike[] }>( + (childrenAcc, child) => { + if (child instanceof DashboardGridItem) { + const gridItemKey = joinCloneKeys(ancestorKey, getGridItemKeyForPanelId(childrenAcc.panelId)); + + const gridItem = child.clone({ + key: gridItemKey, + body: child.state.body.clone({ + key: joinCloneKeys(gridItemKey, getVizPanelKeyForPanelId(childrenAcc.panelId++)), + }), + isDraggable: isSource && child.state.isDraggable, + isResizable: isSource && child.state.isResizable, + }); + + childrenAcc.children.push(gridItem); + return childrenAcc; + } + + if (child instanceof SceneGridRow) { + const rowKey = joinCloneKeys(ancestorKey, getVizPanelKeyForPanelId(childrenAcc.panelId++)); + + const row = child.clone({ + key: rowKey, + children: child.state.children.reduce((rowAcc, rowChild) => { + if (isClonedKey(rowChild.state.key!)) { + return rowAcc; + } + + if (!(rowChild instanceof DashboardGridItem)) { + rowAcc.push(rowChild.clone()); + return rowAcc; + } + + const gridItemKey = joinCloneKeys(rowKey, getGridItemKeyForPanelId(childrenAcc.panelId)); + + const gridItem = rowChild.clone({ + key: gridItemKey, + isDraggable: isSource && rowChild.state.isDraggable, + isResizable: isSource && rowChild.state.isResizable, + body: rowChild.state.body.clone({ + key: joinCloneKeys(gridItemKey, getVizPanelKeyForPanelId(childrenAcc.panelId++)), + }), + }); + + rowAcc.push(gridItem); + return rowAcc; + }, []), + isDraggable: isSource && child.state.isDraggable, + isResizable: isSource && child.state.isResizable, + }); + + childrenAcc.children.push(row); + + return childrenAcc; + } + + childrenAcc.children.push(child.clone()); + return childrenAcc; + }, + { panelId: 0, children: [] } + ).children, + }), + }); + } + public static getDescriptor(): LayoutRegistryItem { return { name: 'Default grid', @@ -389,9 +466,15 @@ export class DefaultGridLayoutManager /** * Useful for preserving items positioning when switching layouts * @param gridItems + * @param isDraggable + * @param isResizable * @returns */ - public static fromGridItems(gridItems: SceneGridItemLike[]): DefaultGridLayoutManager { + public static fromGridItems( + gridItems: SceneGridItemLike[], + isDraggable?: boolean, + isResizable?: boolean + ): DefaultGridLayoutManager { const children = gridItems.reduce((acc, gridItem) => { gridItem.clearParent(); acc.push(gridItem); @@ -402,8 +485,8 @@ export class DefaultGridLayoutManager return new DefaultGridLayoutManager({ grid: new SceneGridLayout({ children, - isDraggable: true, - isResizable: true, + isDraggable, + isResizable, }), }); } diff --git a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx similarity index 88% rename from public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx rename to public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx index 116fc50781d..a16bfe7da18 100644 --- a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.test.tsx @@ -13,13 +13,13 @@ import { } from '@grafana/scenes'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants'; -import { activateFullSceneTree } from '../utils/test-utils'; -import { isReadOnlyClone } from '../utils/utils'; +import { getCloneKey, isInCloneChain, joinCloneKeys } from '../../utils/clone'; +import { activateFullSceneTree } from '../../utils/test-utils'; +import { DashboardScene } from '../DashboardScene'; -import { DashboardScene } from './DashboardScene'; +import { RepeatDirection } from './DashboardGridItem'; +import { DefaultGridLayoutManager } from './DefaultGridLayoutManager'; import { RowRepeaterBehavior } from './RowRepeaterBehavior'; -import { RepeatDirection } from './layout-default/DashboardGridItem'; -import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; import { RowActions } from './row-actions/RowActions'; jest.mock('@grafana/runtime', () => ({ @@ -54,24 +54,31 @@ describe('RowRepeaterBehavior', () => { // Verify that first row still has repeat behavior const row1 = grid.state.children[1] as SceneGridRow; + expect(row1.state.key).toBe(getCloneKey('row-1', 0)); expect(row1.state.$behaviors?.[0]).toBeInstanceOf(RowRepeaterBehavior); expect(row1.state.$variables!.state.variables[0].getValue()).toBe('A1'); expect(row1.state.actions).toBeDefined(); + const gridItemRow1 = row1.state.children[0] as SceneGridItem; + expect(gridItemRow1.state.key!).toBe(joinCloneKeys(row1.state.key!, 'griditem-1')); + expect(gridItemRow1.state.body?.state.key).toBe(joinCloneKeys(gridItemRow1.state.key!, 'canvas-1')); + const row2 = grid.state.children[2] as SceneGridRow; + expect(row2.state.key).toBe(getCloneKey('row-1', 1)); + expect(row2.state.$behaviors).toEqual([]); expect(row2.state.$variables!.state.variables[0].getValueText?.()).toBe('B'); expect(row2.state.actions).toBeUndefined(); - // Should give repeated panels unique keys - const gridItem = row2.state.children[0] as SceneGridItem; - expect(gridItem.state.body?.state.key).toBe('canvas-1-clone-B1'); + const gridItemRow2 = row2.state.children[0] as SceneGridItem; + expect(gridItemRow2.state.key!).toBe(joinCloneKeys(row2.state.key!, 'griditem-1')); + expect(gridItemRow2.state.body?.state.key).toBe(joinCloneKeys(gridItemRow2.state.key!, 'canvas-1')); }); it('Repeated rows should be read only', () => { const row1 = grid.state.children[1] as SceneGridRow; const row2 = grid.state.children[2] as SceneGridRow; - expect(isReadOnlyClone(row1)).toBe(false); - expect(isReadOnlyClone(row2)).toBe(true); + expect(isInCloneChain(row1.state.key!)).toBe(false); + expect(isInCloneChain(row2.state.key!)).toBe(true); }); it('Should update all rows when a panel is added to a clone', async () => { @@ -209,6 +216,7 @@ function buildScene( const grid = new SceneGridLayout({ children: [ new SceneGridItem({ + key: 'griditem-no-row', x: 0, y: 0, width: 24, @@ -218,6 +226,7 @@ function buildScene( }), }), new SceneGridRow({ + key: 'row-1', x: 0, y: 10, width: 24, @@ -226,6 +235,7 @@ function buildScene( $behaviors: [repeatBehavior], children: [ new SceneGridItem({ + key: 'griditem-1', x: 0, y: 11, width: 24, @@ -238,12 +248,12 @@ function buildScene( ], }), new SceneGridRow({ + key: 'row-2', x: 0, y: 16, width: 24, height: 5, title: 'Row at the bottom', - children: [ new SceneGridItem({ key: 'griditem-2', diff --git a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.ts similarity index 62% rename from public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts rename to public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.ts index 990ada92748..6ac38847198 100644 --- a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts +++ b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.ts @@ -14,10 +14,18 @@ import { VariableValueSingle, } from '@grafana/scenes'; -import { getMultiVariableValues } from '../utils/utils'; +import { + containsCloneKey, + getLastKeyFromClone, + isClonedKeyOf, + joinCloneKeys, + getCloneKey, + isClonedKey, +} from '../../utils/clone'; +import { getMultiVariableValues } from '../../utils/utils'; +import { DashboardRepeatsProcessedEvent } from '../types'; -import { DashboardGridItem } from './layout-default/DashboardGridItem'; -import { DashboardRepeatsProcessedEvent } from './types'; +import { DashboardGridItem } from './DashboardGridItem'; interface RowRepeaterBehaviorState extends SceneObjectState { variableName: string; @@ -47,11 +55,11 @@ export class RowRepeaterBehavior extends SceneObjectBase !isClonedKey(child.state.key!)); const sub = layout.subscribeToState(() => { - const repeatedRows = layout.state.children.filter( - (child) => child instanceof SceneGridRow && child.state.key?.includes(filterKey) + const repeatedRows = layout.state.children.filter((child) => + isClonedKeyOf(child.state.key!, originalRow.state.key!) ); // go through cloned rows, search for panels that are not clones @@ -60,24 +68,29 @@ export class RowRepeaterBehavior extends SceneObjectBase !isClonedKey(child.state.key!)); + // if no differences in row children compared to original, then no new panel added to clone - if (row.state.children.length === originalRow.state.children.length) { + if (rowNonClonedPanels.length === originalRowNonClonedPanels.length) { continue; } - //if there are differences, find the new panel, move it to the original and perform re peat - const gridItem = row.state.children.find((gridItem) => !gridItem.state.key?.includes('clone')); + // if there are differences, find the new panel, move it to the original and perform repeat + const gridItem = rowNonClonedPanels.find((gridItem) => !containsCloneKey(gridItem.state.key!)); + if (gridItem) { const newGridItem = gridItem.clone(); + row.setState({ children: row.state.children.filter((item) => item !== gridItem) }); // if we are moving a panel from the origin row to a clone row, we just return - // this means we are modifying the origin row, retriggering the repeat and losing that panel + // this means we are modifying the origin row, re-triggering the repeat and losing that panel if (originalRow.state.children.find((item) => item.state.key === newGridItem.state.key)) { return; } originalRow.setState({ children: [...originalRow.state.children, newGridItem] }); + this.performRepeat(true); } } @@ -135,13 +148,14 @@ export class RowRepeaterBehavior extends SceneObjectBase 0 ? `${source.state.key}-clone-${localValue}` : source.state.key; - const itemClone = source.clone({ key: itemKey, y: itemY }); + const rowCloneKey = getCloneKey(rowToRepeat.state.key!, rowIndex); - // Make sure all the child scene objects have unique keys - // and add proper menu to the repeated panel - if (index > 0) { - ensureUniqueKeys(itemClone, localValue); - - //disallow clones to be dragged around or out of the row - if (itemClone instanceof DashboardGridItem) { - itemClone.setState({ isDraggable: false }); - } - } - - children.push(itemClone); - - if (maxYOfRows < itemY + itemClone.state.height!) { - maxYOfRows = itemY + itemClone.state.height!; - } - } - - const rowClone = this.getRowClone( - rowToRepeat, - index, - localValue, - variableTexts[index], - rowContentHeight, - children, - variable - ); - this._clonedRows.push(rowClone); - } - - updateLayout(layout, this._clonedRows, maxYOfRows, rowToRepeat); - - // Used from dashboard url sync - this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true); - } - - getRowClone( - rowToRepeat: SceneGridRow, - index: number, - value: VariableValueSingle, - text: VariableValueSingle, - rowContentHeight: number, - children: SceneGridItemLike[], - variable: MultiValueVariable - ): SceneGridRow { - if (index === 0) { - rowToRepeat.setState({ - // not activated + rowClone.setState({ + key: rowCloneKey, $variables: new SceneVariableSet({ variables: [ new LocalValueVariable({ name: this.state.variableName, - value, - text: String(text), + value: variableValues[rowIndex], + text: String(variableTexts[rowIndex]), isMulti: variable.state.isMulti, includeAll: variable.state.includeAll, }), ], }), - children, + children: [], }); - return rowToRepeat; + + const children: SceneGridItemLike[] = []; + + for (const sourceItem of rowContent) { + const sourceItemY = sourceItem.state.y ?? 0; + + const cloneItemKey = joinCloneKeys(rowCloneKey, getLastKeyFromClone(sourceItem.state.key!)); + const cloneItemY = sourceItemY + (rowContentHeight + 1) * rowIndex; + + const cloneItem = sourceItem.clone({ + key: cloneItemKey, + y: cloneItemY, + isDraggable: !isSourceRow && sourceItem instanceof DashboardGridItem ? false : sourceItem.state.isDraggable, + isResizable: !isSourceRow && sourceItem instanceof DashboardGridItem ? false : sourceItem.state.isResizable, + }); + + ensureUniqueKeys(cloneItem, cloneItemKey); + + children.push(cloneItem); + + if (maxYOfRows < cloneItemY + cloneItem.state.height!) { + maxYOfRows = cloneItemY + cloneItem.state.height!; + } + } + + rowClone.setState({ children }); + + this._clonedRows.push(rowClone); } - const sourceRowY = rowToRepeat.state.y ?? 0; + updateLayout(layout, this._clonedRows, maxYOfRows, rowToRepeat.state.key!); - return rowToRepeat.clone({ - key: `${rowToRepeat.state.key}-clone-${value}`, - $variables: new SceneVariableSet({ - variables: [ - new LocalValueVariable({ - name: this.state.variableName, - value, - text: String(text), - isMulti: variable.state.isMulti, - includeAll: variable.state.includeAll, - }), - ], - }), - $behaviors: [], - children, - y: sourceRowY + rowContentHeight * index + index, - actions: undefined, - }); + // Used from dashboard url sync + this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true); } public removeBehavior() { const row = this._getRow(); const layout = this._getLayout(); - const children = getLayoutChildrenFilterOutRepeatClones(this._getLayout(), this._getRow()); + const children = getLayoutChildrenFilterOutRepeatClones(layout, row.state.key!); layout.setState({ children: children }); @@ -280,9 +259,9 @@ function getRowContentHeight(panels: SceneGridItemLike[]): number { return maxY - minY; } -function updateLayout(layout: SceneGridLayout, rows: SceneGridRow[], maxYOfRows: number, rowToRepeat: SceneGridRow) { - const allChildren = getLayoutChildrenFilterOutRepeatClones(layout, rowToRepeat); - const index = allChildren.indexOf(rowToRepeat); +function updateLayout(layout: SceneGridLayout, rows: SceneGridRow[], maxYOfRows: number, rowKey: string) { + const allChildren = getLayoutChildrenFilterOutRepeatClones(layout, rowKey); + const index = allChildren.findIndex((child) => child.state.key!.includes(rowKey)); if (index === -1) { throw new Error('RowRepeaterBehavior: Parent row not found in layout children'); @@ -310,19 +289,14 @@ function updateLayout(layout: SceneGridLayout, rows: SceneGridRow[], maxYOfRows: layout.setState({ children: newChildren }); } -function getLayoutChildrenFilterOutRepeatClones(layout: SceneGridLayout, rowToRepeat: SceneGridRow) { - return layout.state.children.filter((child) => { - if (child.state.key?.startsWith(`${rowToRepeat.state.key}-clone-`)) { - return false; - } - - return true; - }); +function getLayoutChildrenFilterOutRepeatClones(layout: SceneGridLayout, rowKey: string) { + return layout.state.children.filter((child) => !isClonedKeyOf(child.state.key!, rowKey)); } -function ensureUniqueKeys(item: SceneGridItemLike, localValue: VariableValueSingle) { +function ensureUniqueKeys(item: SceneGridItemLike, ancestors: string) { item.forEachChild((child) => { - child.setState({ key: `${child.state.key}-clone-${localValue}` }); - ensureUniqueKeys(child, localValue); + const key = joinCloneKeys(ancestors, child.state.key!); + child.setState({ key }); + ensureUniqueKeys(child, key); }); } diff --git a/public/app/features/dashboard-scene/scene/row-actions/RowActions.tsx b/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowActions.tsx similarity index 72% rename from public/app/features/dashboard-scene/scene/row-actions/RowActions.tsx rename to public/app/features/dashboard-scene/scene/layout-default/row-actions/RowActions.tsx index 5d5f342dca2..c26258176db 100644 --- a/public/app/features/dashboard-scene/scene/row-actions/RowActions.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowActions.tsx @@ -11,14 +11,16 @@ import { } from '@grafana/scenes'; import { Icon, TextLink, useStyles2 } from '@grafana/ui'; import appEvents from 'app/core/app_events'; +import { t, Trans } from 'app/core/internationalization'; import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants'; +import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; import { ShowConfirmModalEvent } from 'app/types/events'; -import { getDashboardSceneFor, getQueryRunnerFor } from '../../utils/utils'; -import { DashboardScene } from '../DashboardScene'; +import { getDashboardSceneFor, getQueryRunnerFor } from '../../../utils/utils'; +import { DashboardScene } from '../../DashboardScene'; +import { DashboardGridItem } from '../DashboardGridItem'; +import { DefaultGridLayoutManager } from '../DefaultGridLayoutManager'; import { RowRepeaterBehavior } from '../RowRepeaterBehavior'; -import { DashboardGridItem } from '../layout-default/DashboardGridItem'; -import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager'; import { RowOptionsButton } from './RowOptionsButton'; @@ -75,9 +77,12 @@ export class RowActions extends SceneObjectBase { public onDelete = () => { appEvents.publish( new ShowConfirmModalEvent({ - title: 'Delete row', - text: 'Are you sure you want to remove this row and all its panels?', - altActionText: 'Delete row only', + title: t('dashboard.default-layout.row-actions.modal.title', 'Delete row'), + text: t( + 'dashboard.default-layout.row-actions.modal.text', + 'Are you sure you want to remove this row and all its panels?' + ), + altActionText: t('dashboard.default-layout.row-actions.modal.alt-action', 'Delete row only'), icon: 'trash-alt', onConfirm: () => this.removeRow(true), onAltAction: () => this.removeRow(), @@ -97,7 +102,11 @@ export class RowActions extends SceneObjectBase { const vizPanel = gridItem.state.body; if (vizPanel instanceof VizPanel) { const runner = getQueryRunnerFor(vizPanel); - return runner?.state.datasource?.uid === SHARED_DASHBOARD_QUERY; + return ( + runner?.state.datasource?.uid === SHARED_DASHBOARD_QUERY || + (runner?.state.datasource?.uid === MIXED_DATASOURCE_NAME && + runner?.state.queries.some((query) => query.datasource?.uid === SHARED_DASHBOARD_QUERY)) + ); } return false; @@ -107,8 +116,10 @@ export class RowActions extends SceneObjectBase { return (

- Panels in this row use the {SHARED_DASHBOARD_QUERY} data source. These panels will reference the panel in - the original row, not the ones in the repeated rows. + + Panels in this row use the {{ SHARED_DASHBOARD_QUERY }} data source. These panels will reference the panel + in the original row, not the ones in the repeated rows. +

{ 'https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/create-dashboard/#configure-repeating-rows' } > - Learn more + Learn more
); @@ -146,7 +157,11 @@ export class RowActions extends SceneObjectBase { onUpdate={model.onUpdate} warning={model.getWarning()} /> - diff --git a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsButton.tsx b/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowOptionsButton.tsx similarity index 89% rename from public/app/features/dashboard-scene/scene/row-actions/RowOptionsButton.tsx rename to public/app/features/dashboard-scene/scene/layout-default/row-actions/RowOptionsButton.tsx index 546c7de8387..aca11f93bbc 100644 --- a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsButton.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowOptionsButton.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { SceneObject } from '@grafana/scenes'; import { Icon, ModalsController } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; import { OnRowOptionsUpdate } from './RowOptionsForm'; import { RowOptionsModal } from './RowOptionsModal'; @@ -27,7 +28,7 @@ export const RowOptionsButton = ({ repeat, title, parent, onUpdate, warning }: R + - ); diff --git a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsModal.tsx b/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowOptionsModal.tsx similarity index 79% rename from public/app/features/dashboard-scene/scene/row-actions/RowOptionsModal.tsx rename to public/app/features/dashboard-scene/scene/layout-default/row-actions/RowOptionsModal.tsx index bab4052178b..0976bf2e387 100644 --- a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsModal.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowOptionsModal.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { SceneObject } from '@grafana/scenes'; import { Modal, useStyles2 } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; import { OnRowOptionsUpdate, RowOptionsForm } from './RowOptionsForm'; @@ -19,7 +20,12 @@ export const RowOptionsModal = ({ repeat, title, parent, onDismiss, onUpdate, wa const styles = useStyles2(getStyles); return ( - + child !== element) }); @@ -126,6 +130,7 @@ export class ResponsiveGridLayoutManager activateRepeaters?(): void { throw new Error('Method not implemented.'); } + public static Component = ({ model }: SceneComponentProps) => { return ; }; diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx index cd95834bcad..e52fd8d46d2 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx @@ -1,17 +1,40 @@ import { css, cx } from '@emotion/css'; -import { useMemo, useRef } from 'react'; +import { ReactNode, useMemo, useRef } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { SceneObjectState, SceneObjectBase, SceneComponentProps, sceneGraph } from '@grafana/scenes'; -import { Button, Icon, Input, RadioButtonGroup, Switch, useElementSelection, useStyles2 } from '@grafana/ui'; +import { + SceneObjectState, + SceneObjectBase, + SceneComponentProps, + sceneGraph, + VariableDependencyConfig, +} from '@grafana/scenes'; +import { + Alert, + Button, + Icon, + Input, + RadioButtonGroup, + Switch, + TextLink, + useElementSelection, + useStyles2, +} from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; 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 { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants'; +import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; -import { getDashboardSceneFor, getDefaultVizPanel } from '../../utils/utils'; +import { isClonedKey } from '../../utils/clone'; +import { getDashboardSceneFor, getDefaultVizPanel, getQueryRunnerFor } from '../../utils/utils'; +import { DashboardScene } from '../DashboardScene'; import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector'; import { DashboardLayoutManager, EditableDashboardElement, LayoutParent } from '../types'; +import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior'; import { RowsLayoutManager } from './RowsLayoutManager'; export interface RowItemState extends SceneObjectState { @@ -23,6 +46,10 @@ export interface RowItemState extends SceneObjectState { } export class RowItem extends SceneObjectBase implements LayoutParent, EditableDashboardElement { + protected _variableDependency = new VariableDependencyConfig(this, { + statePaths: ['title'], + }); + public isEditableDashboardElement: true = true; public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] { @@ -54,10 +81,25 @@ export class RowItem extends SceneObjectBase implements LayoutPare ); }, [row]); + const rowRepeatOptions = useMemo(() => { + const dashboard = getDashboardSceneFor(row); + + return new OptionsPaneCategoryDescriptor({ + title: 'Repeat options', + id: 'row-repeat-options', + isOpenDefault: true, + }).addItem( + new OptionsPaneItemDescriptor({ + title: 'Variable', + render: () => , + }) + ); + }, [row]); + const { layout } = this.useState(); const layoutOptions = useLayoutCategory(layout); - return [rowOptions, layoutOptions]; + return [rowOptions, rowRepeatOptions, layoutOptions]; } public getTypeName(): string { @@ -69,7 +111,7 @@ export class RowItem extends SceneObjectBase implements LayoutPare layout.removeRow(this); }; - public renderActions(): React.ReactNode { + public renderActions(): ReactNode { return ( <> - {isEditing && ( + {!isClone && isEditing && (