Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f71c8f562 | ||
|
|
d7a3d61726 | ||
|
|
347075bffe | ||
|
|
0db188e95d | ||
|
|
f38df468b5 | ||
|
|
c78c2d7231 | ||
|
|
8f4fa9ed05 | ||
|
|
0aae7e01bc |
@@ -111,3 +111,4 @@ After installing and configuring the Graphite data source you can:
|
||||
- Add [transformations](ref:transformations)
|
||||
- Add [annotations](ref:annotate-visualizations)
|
||||
- Set up [alerting](ref:alerting)
|
||||
- [Troubleshoot](troubleshooting/) common issues with the Graphite data source
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
---
|
||||
description: Troubleshoot common issues with the Graphite data source.
|
||||
keywords:
|
||||
- grafana
|
||||
- graphite
|
||||
- troubleshooting
|
||||
- guide
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
menuTitle: Troubleshooting
|
||||
title: Troubleshoot Graphite data source issues
|
||||
weight: 400
|
||||
refs:
|
||||
configure-graphite:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/graphite/configure/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/graphite/configure/
|
||||
query-editor:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/graphite/query-editor/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/graphite/query-editor/
|
||||
---
|
||||
|
||||
# Troubleshoot Graphite data source issues
|
||||
|
||||
This document provides solutions for common issues you might encounter when using the Graphite data source.
|
||||
|
||||
## Connection issues
|
||||
|
||||
Use the following troubleshooting steps to resolve connection problems between Grafana and your Graphite server.
|
||||
|
||||
**Data source test fails with "Unable to connect":**
|
||||
|
||||
If the data source test fails, verify the following:
|
||||
|
||||
- The URL in your data source configuration is correct and accessible from the Grafana server.
|
||||
- The Graphite server is running and accepting connections.
|
||||
- Any firewall rules or network policies allow traffic between Grafana and the Graphite server.
|
||||
- If using TLS, ensure your certificates are valid and properly configured.
|
||||
|
||||
To test connectivity, run the following command from the Grafana server:
|
||||
|
||||
```sh
|
||||
curl -v <GRAPHITE_URL>/render
|
||||
```
|
||||
|
||||
Replace _`<GRAPHITE_URL>`_ with your Graphite server URL. A successful connection returns a response from the Graphite server.
|
||||
|
||||
**Authentication errors:**
|
||||
|
||||
If you receive 401 or 403 errors:
|
||||
|
||||
- Verify your Basic Auth username and password are correct.
|
||||
- Ensure the **With Credentials** toggle is enabled if your Graphite server requires cookies for authentication.
|
||||
- Check that your TLS client certificates are valid and match what the server expects.
|
||||
|
||||
For detailed authentication configuration, refer to [Configure the Graphite data source](ref:configure-graphite).
|
||||
|
||||
## Query issues
|
||||
|
||||
Use the following troubleshooting steps to resolve problems with Graphite queries.
|
||||
|
||||
**No data returned:**
|
||||
|
||||
If your query returns no data:
|
||||
|
||||
- Verify the metric path exists in your Graphite server by testing directly in the Graphite web interface.
|
||||
- Check that the time range in Grafana matches when data was collected.
|
||||
- Ensure wildcards in your query match existing metrics.
|
||||
- Confirm your query syntax is correct for your Graphite version.
|
||||
|
||||
**HTTP 500 errors with HTML content:**
|
||||
|
||||
Graphite-web versions before 1.6 return HTTP 500 errors with full HTML stack traces when a query fails. If you see error messages containing HTML tags:
|
||||
|
||||
- Check the Graphite server logs for the full error details.
|
||||
- Verify your query syntax is valid.
|
||||
- Ensure the requested time range doesn't exceed your Graphite server's capabilities.
|
||||
- Check that all functions used in your query are supported by your Graphite version.
|
||||
|
||||
**Parser errors in the query editor:**
|
||||
|
||||
If the query editor displays parser errors:
|
||||
|
||||
- Check for unbalanced parentheses in function calls.
|
||||
- Verify that function arguments are in the correct format.
|
||||
- Ensure metric paths don't contain unsupported characters.
|
||||
|
||||
For query syntax help, refer to [Graphite query editor](ref:query-editor).
|
||||
|
||||
## Version and feature issues
|
||||
|
||||
Use the following troubleshooting steps to resolve problems related to Graphite versions and features.
|
||||
|
||||
**Functions missing from the query editor:**
|
||||
|
||||
If expected functions don't appear in the query editor:
|
||||
|
||||
- Verify the correct Graphite version is selected in the data source configuration.
|
||||
- The available functions depend on the configured version. For example, tag-based functions require Graphite 1.1 or later.
|
||||
- If using a custom Graphite installation with additional functions, ensure the version setting matches your server.
|
||||
|
||||
**Tag-based queries not working:**
|
||||
|
||||
If `seriesByTag()` or other tag functions fail:
|
||||
|
||||
- Confirm your Graphite server is version 1.1 or later.
|
||||
- Verify the Graphite version setting in your data source configuration matches your actual server version.
|
||||
- Check that tags are properly configured in your Graphite server.
|
||||
|
||||
## Performance issues
|
||||
|
||||
Use the following troubleshooting steps to address slow queries or timeouts.
|
||||
|
||||
**Queries timing out:**
|
||||
|
||||
If queries consistently time out:
|
||||
|
||||
- Increase the **Timeout** setting in the data source configuration.
|
||||
- Reduce the time range of your query.
|
||||
- Use more specific metric paths instead of broad wildcards.
|
||||
- Consider using `summarize()` or `consolidateBy()` functions to reduce the amount of data returned.
|
||||
- Check your Graphite server's performance and resource utilization.
|
||||
|
||||
**Slow autocomplete in the query editor:**
|
||||
|
||||
If metric path autocomplete is slow:
|
||||
|
||||
- This often indicates a large number of metrics in your Graphite server.
|
||||
- Use more specific path prefixes to narrow the search scope.
|
||||
- Check your Graphite server's index performance.
|
||||
|
||||
## MetricTank-specific issues
|
||||
|
||||
If you're using MetricTank as your Graphite backend, use the following troubleshooting steps.
|
||||
|
||||
**Rollup indicator not appearing:**
|
||||
|
||||
If the rollup indicator doesn't display when expected:
|
||||
|
||||
- Verify **Metrictank** is selected as the Graphite backend type in the data source configuration.
|
||||
- Ensure the **Rollup indicator** toggle is enabled.
|
||||
- The indicator only appears when data aggregation actually occurs.
|
||||
|
||||
**Unexpected data aggregation:**
|
||||
|
||||
If you see unexpected aggregation in your data:
|
||||
|
||||
- Check the rollup configuration in your MetricTank instance.
|
||||
- Adjust the time range or use `consolidateBy()` to control aggregation behavior.
|
||||
- Review the query processing metadata in the panel inspector for details on how data was processed.
|
||||
|
||||
## Get additional help
|
||||
|
||||
If you continue to experience issues:
|
||||
|
||||
- Check the [Grafana community forums](https://community.grafana.com/) for similar issues and solutions.
|
||||
- Review the [Graphite documentation](https://graphite.readthedocs.io/) for additional configuration options.
|
||||
- Contact [Grafana Support](https://grafana.com/support/) if you're an Enterprise, Cloud Pro, or Cloud Advanced customer.
|
||||
|
||||
When reporting issues, include the following information:
|
||||
|
||||
- Grafana version
|
||||
- Graphite version (for example, 1.1.x) and backend type (Default or MetricTank)
|
||||
- Authentication method (Basic Auth, TLS, or none)
|
||||
- Error messages (redact sensitive information)
|
||||
- Steps to reproduce the issue
|
||||
- Relevant configuration such as data source settings, timeout values, and Graphite version setting (redact passwords and other credentials)
|
||||
- Sample query (if applicable, with sensitive data redacted)
|
||||
+12
-7
@@ -38,13 +38,6 @@ Users can now view anonymous usage statistics, including the count of devices an
|
||||
|
||||
The number of anonymous devices is not limited by default. The configuration option `device_limit` allows you to enforce a limit on the number of anonymous devices. This enables you to have greater control over the usage within your Grafana instance and keep the usage within the limits of your environment. Once the limit is reached, any new devices that try to access Grafana will be denied access.
|
||||
|
||||
To display anonymous users and devices for versions 10.2, 10.3, 10.4, you need to enable the feature toggle `displayAnonymousStats`
|
||||
|
||||
```bash
|
||||
[feature_toggles]
|
||||
enable = displayAnonymousStats
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Example:
|
||||
@@ -67,3 +60,15 @@ device_limit =
|
||||
```
|
||||
|
||||
If you change your organization name in the Grafana UI this setting needs to be updated to match the new name.
|
||||
|
||||
## Licensing for anonymous access
|
||||
|
||||
Grafana Enterprise (self-managed) licenses anonymous access as active users.
|
||||
|
||||
Anonymous access lets people use Grafana without login credentials. It was an early way to share dashboards, but Public dashboards gives you a more secure way to share dashboards.
|
||||
|
||||
### How anonymous usage is counted
|
||||
|
||||
Grafana estimates anonymous active users from anonymous devices:
|
||||
|
||||
- **Counting rule**: Grafana counts 1 anonymous user for every 3 anonymous devices detected.
|
||||
|
||||
@@ -782,10 +782,6 @@ export interface FeatureToggles {
|
||||
*/
|
||||
elasticsearchCrossClusterSearch?: boolean;
|
||||
/**
|
||||
* Displays the navigation history so the user can navigate back to previous pages
|
||||
*/
|
||||
unifiedHistory?: boolean;
|
||||
/**
|
||||
* Defaults to using the Loki `/labels` API instead of `/series`
|
||||
* @default true
|
||||
*/
|
||||
|
||||
@@ -170,42 +170,56 @@ func (r *ResourcePermissionsAuthorizer) FilterList(ctx context.Context, list run
|
||||
if !ok {
|
||||
return nil, storewrapper.ErrUnauthenticated
|
||||
}
|
||||
r.logger.Debug("filtering resource permissions list with auth info",
|
||||
"namespace", authInfo.GetNamespace(),
|
||||
"identity Subject", authInfo.GetSubject(),
|
||||
"identity UID", authInfo.GetUID(),
|
||||
"identity type", authInfo.GetIdentityType(),
|
||||
)
|
||||
|
||||
switch l := list.(type) {
|
||||
case *iamv0.ResourcePermissionList:
|
||||
|
||||
r.logger.Debug("filtering list of length", "length", len(l.Items))
|
||||
var (
|
||||
filteredItems []iamv0.ResourcePermission
|
||||
err error
|
||||
canViewFuncs = map[schema.GroupResource]types.ItemChecker{}
|
||||
)
|
||||
for _, item := range l.Items {
|
||||
gr := schema.GroupResource{
|
||||
Group: item.Spec.Resource.ApiGroup,
|
||||
Resource: item.Spec.Resource.Resource,
|
||||
}
|
||||
target := item.Spec.Resource
|
||||
targetGR := schema.GroupResource{Group: target.ApiGroup, Resource: target.Resource}
|
||||
|
||||
r.logger.Debug("target resource",
|
||||
"group", target.ApiGroup,
|
||||
"resource", target.Resource,
|
||||
"name", target.Name,
|
||||
)
|
||||
|
||||
// Reuse the same canView for items with the same resource
|
||||
canView, found := canViewFuncs[gr]
|
||||
canView, found := canViewFuncs[targetGR]
|
||||
|
||||
if !found {
|
||||
listReq := types.ListRequest{
|
||||
Namespace: item.Namespace,
|
||||
Group: item.Spec.Resource.ApiGroup,
|
||||
Resource: item.Spec.Resource.Resource,
|
||||
Group: target.ApiGroup,
|
||||
Resource: target.Resource,
|
||||
Verb: utils.VerbGetPermissions,
|
||||
}
|
||||
|
||||
r.logger.Debug("compiling list request",
|
||||
"namespace", item.Namespace,
|
||||
"group", target.ApiGroup,
|
||||
"resource", target.Resource,
|
||||
"verb", utils.VerbGetPermissions,
|
||||
)
|
||||
canView, _, err = r.accessClient.Compile(ctx, authInfo, listReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
canViewFuncs[gr] = canView
|
||||
canViewFuncs[targetGR] = canView
|
||||
}
|
||||
|
||||
target := item.Spec.Resource
|
||||
targetGR := schema.GroupResource{Group: target.ApiGroup, Resource: target.Resource}
|
||||
|
||||
parent := ""
|
||||
// Fetch the parent of the resource
|
||||
// It's not efficient to do for every item in the list, but it's a good starting point.
|
||||
@@ -223,6 +237,13 @@ func (r *ResourcePermissionsAuthorizer) FilterList(ctx context.Context, list run
|
||||
)
|
||||
continue
|
||||
}
|
||||
r.logger.Debug("fetched parent",
|
||||
"parent", p,
|
||||
"namespace", item.Namespace,
|
||||
"group", target.ApiGroup,
|
||||
"resource", target.Resource,
|
||||
"name", target.Name,
|
||||
)
|
||||
parent = p
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ func ProvideZanzanaClient(cfg *setting.Cfg, db db.DB, tracer tracing.Tracer, fea
|
||||
authzv1.RegisterAuthzServiceServer(channel, srv)
|
||||
authzextv1.RegisterAuthzExtentionServiceServer(channel, srv)
|
||||
|
||||
client, err := zClient.New(channel)
|
||||
client, err := zClient.New(channel, reg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize zanzana client: %w", err)
|
||||
}
|
||||
@@ -169,7 +169,7 @@ func NewRemoteZanzanaClient(cfg ZanzanaClientConfig, reg prometheus.Registerer)
|
||||
return nil, fmt.Errorf("failed to create zanzana client to remote server: %w", err)
|
||||
}
|
||||
|
||||
client, err := zClient.New(conn)
|
||||
client, err := zClient.New(conn, reg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize zanzana client: %w", err)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
authzlib "github.com/grafana/authlib/authz"
|
||||
authzv1 "github.com/grafana/authlib/authz/proto/v1"
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||
@@ -25,15 +26,17 @@ type Client struct {
|
||||
authz authzv1.AuthzServiceClient
|
||||
authzext authzextv1.AuthzExtentionServiceClient
|
||||
authzlibclient *authzlib.ClientImpl
|
||||
metrics *clientMetrics
|
||||
}
|
||||
|
||||
func New(cc grpc.ClientConnInterface) (*Client, error) {
|
||||
func New(cc grpc.ClientConnInterface, reg prometheus.Registerer) (*Client, error) {
|
||||
authzlibclient := authzlib.NewClient(cc, authzlib.WithTracerClientOption(tracer))
|
||||
c := &Client{
|
||||
authzlibclient: authzlibclient,
|
||||
authz: authzv1.NewAuthzServiceClient(cc),
|
||||
authzext: authzextv1.NewAuthzExtentionServiceClient(cc),
|
||||
logger: log.New("zanzana.client"),
|
||||
metrics: newClientMetrics(reg),
|
||||
}
|
||||
|
||||
return c, nil
|
||||
@@ -43,6 +46,9 @@ func (c *Client) Check(ctx context.Context, id authlib.AuthInfo, req authlib.Che
|
||||
ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Check")
|
||||
defer span.End()
|
||||
|
||||
timer := prometheus.NewTimer(c.metrics.requestDurationSeconds.WithLabelValues("Check", req.Namespace))
|
||||
defer timer.ObserveDuration()
|
||||
|
||||
return c.authzlibclient.Check(ctx, id, req, folder)
|
||||
}
|
||||
|
||||
@@ -50,6 +56,9 @@ func (c *Client) Compile(ctx context.Context, id authlib.AuthInfo, req authlib.L
|
||||
ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Compile")
|
||||
defer span.End()
|
||||
|
||||
timer := prometheus.NewTimer(c.metrics.requestDurationSeconds.WithLabelValues("Compile", req.Namespace))
|
||||
defer timer.ObserveDuration()
|
||||
|
||||
return c.authzlibclient.Compile(ctx, id, req)
|
||||
}
|
||||
|
||||
@@ -64,6 +73,9 @@ func (c *Client) Write(ctx context.Context, req *authzextv1.WriteRequest) error
|
||||
ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Write")
|
||||
defer span.End()
|
||||
|
||||
timer := prometheus.NewTimer(c.metrics.requestDurationSeconds.WithLabelValues("Write", req.Namespace))
|
||||
defer timer.ObserveDuration()
|
||||
|
||||
_, err := c.authzext.Write(ctx, req)
|
||||
return err
|
||||
}
|
||||
@@ -72,6 +84,9 @@ func (c *Client) BatchCheck(ctx context.Context, req *authzextv1.BatchCheckReque
|
||||
ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Check")
|
||||
defer span.End()
|
||||
|
||||
timer := prometheus.NewTimer(c.metrics.requestDurationSeconds.WithLabelValues("BatchCheck", req.Namespace))
|
||||
defer timer.ObserveDuration()
|
||||
|
||||
return c.authzext.BatchCheck(ctx, req)
|
||||
}
|
||||
|
||||
@@ -87,6 +102,9 @@ func (c *Client) Mutate(ctx context.Context, req *authzextv1.MutateRequest) erro
|
||||
ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Mutate")
|
||||
defer span.End()
|
||||
|
||||
timer := prometheus.NewTimer(c.metrics.requestDurationSeconds.WithLabelValues("Mutate", req.Namespace))
|
||||
defer timer.ObserveDuration()
|
||||
|
||||
_, err := c.authzext.Mutate(ctx, req)
|
||||
return err
|
||||
}
|
||||
@@ -95,5 +113,8 @@ func (c *Client) Query(ctx context.Context, req *authzextv1.QueryRequest) (*auth
|
||||
ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Query")
|
||||
defer span.End()
|
||||
|
||||
timer := prometheus.NewTimer(c.metrics.requestDurationSeconds.WithLabelValues("Query", req.Namespace))
|
||||
defer timer.ObserveDuration()
|
||||
|
||||
return c.authzext.Query(ctx, req)
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
|
||||
const (
|
||||
metricsNamespace = "iam"
|
||||
metricsSubSystem = "authz_zanzana"
|
||||
metricsSubSystem = "authz_zanzana_client"
|
||||
)
|
||||
|
||||
type metrics struct {
|
||||
type shadowClientMetrics struct {
|
||||
// evaluationsSeconds is a summary for evaluating access for a specific engine (RBAC and zanzana)
|
||||
evaluationsSeconds *prometheus.HistogramVec
|
||||
// compileSeconds is a summary for compiling item checker for a specific engine (RBAC and zanzana)
|
||||
@@ -19,8 +19,13 @@ type metrics struct {
|
||||
evaluationStatusTotal *prometheus.CounterVec
|
||||
}
|
||||
|
||||
func newShadowClientMetrics(reg prometheus.Registerer) *metrics {
|
||||
return &metrics{
|
||||
type clientMetrics struct {
|
||||
// requestDurationSeconds is a summary for zanzana client request duration
|
||||
requestDurationSeconds *prometheus.HistogramVec
|
||||
}
|
||||
|
||||
func newShadowClientMetrics(reg prometheus.Registerer) *shadowClientMetrics {
|
||||
return &shadowClientMetrics{
|
||||
evaluationsSeconds: promauto.With(reg).NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "engine_evaluations_seconds",
|
||||
@@ -52,3 +57,18 @@ func newShadowClientMetrics(reg prometheus.Registerer) *metrics {
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func newClientMetrics(reg prometheus.Registerer) *clientMetrics {
|
||||
return &clientMetrics{
|
||||
requestDurationSeconds: promauto.With(reg).NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "request_duration_seconds",
|
||||
Help: "Histogram for zanzana client request duration",
|
||||
Namespace: metricsNamespace,
|
||||
Subsystem: metricsSubSystem,
|
||||
Buckets: prometheus.ExponentialBuckets(0.00001, 4, 10),
|
||||
},
|
||||
[]string{"method", "request_namespace"},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ type ShadowClient struct {
|
||||
logger log.Logger
|
||||
accessClient authlib.AccessClient
|
||||
zanzanaClient authlib.AccessClient
|
||||
metrics *metrics
|
||||
metrics *shadowClientMetrics
|
||||
}
|
||||
|
||||
// WithShadowClient returns a new access client that runs zanzana checks in the background.
|
||||
|
||||
@@ -1290,13 +1290,6 @@ var (
|
||||
Owner: grafanaPartnerPluginsSquad,
|
||||
Expression: "false",
|
||||
},
|
||||
{
|
||||
Name: "unifiedHistory",
|
||||
Description: "Displays the navigation history so the user can navigate back to previous pages",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaFrontendSearchNavOrganise,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
{
|
||||
// Remove this flag once Loki v4 is released and the min supported version is v3.0+,
|
||||
// since users on v2.9 need it to disable the feature, as it doesn't work for them.
|
||||
|
||||
Generated
-1
@@ -178,7 +178,6 @@ alertingAIAnalyzeCentralStateHistory,experimental,@grafana/alerting-squad,false,
|
||||
alertingNotificationsStepMode,GA,@grafana/alerting-squad,false,false,true
|
||||
unifiedStorageSearchUI,experimental,@grafana/search-and-storage,false,false,false
|
||||
elasticsearchCrossClusterSearch,GA,@grafana/partner-datasources,false,false,false
|
||||
unifiedHistory,experimental,@grafana/grafana-search-navigate-organise,false,false,true
|
||||
lokiLabelNamesQueryApi,GA,@grafana/observability-logs,false,false,false
|
||||
k8SFolderCounts,experimental,@grafana/search-and-storage,false,false,false
|
||||
k8SFolderMove,experimental,@grafana/search-and-storage,false,false,false
|
||||
|
||||
|
+6
-2
@@ -3584,8 +3584,12 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "unifiedHistory",
|
||||
"resourceVersion": "1764664939750",
|
||||
"creationTimestamp": "2024-12-13T10:41:18Z"
|
||||
"resourceVersion": "1762958248290",
|
||||
"creationTimestamp": "2024-12-13T10:41:18Z",
|
||||
"deletionTimestamp": "2025-11-13T16:25:53Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Displays the navigation history so the user can navigate back to previous pages",
|
||||
|
||||
@@ -10,11 +10,8 @@ import { isShallowEqual } from 'app/core/utils/isShallowEqual';
|
||||
import { KioskMode } from 'app/types/dashboard';
|
||||
|
||||
import { RouteDescriptor } from '../../navigation/types';
|
||||
import { buildBreadcrumbs } from '../Breadcrumbs/utils';
|
||||
|
||||
import { logDuplicateUnifiedHistoryEntryEvent } from './History/eventsTracking';
|
||||
import { ReturnToPreviousProps } from './ReturnToPrevious/ReturnToPrevious';
|
||||
import { HistoryEntry } from './types';
|
||||
|
||||
export interface AppChromeState {
|
||||
chromeless?: boolean;
|
||||
@@ -34,7 +31,6 @@ export interface AppChromeState {
|
||||
|
||||
export const DOCKED_LOCAL_STORAGE_KEY = 'grafana.navigation.docked';
|
||||
export const DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY = 'grafana.navigation.open';
|
||||
export const HISTORY_LOCAL_STORAGE_KEY = 'grafana.navigation.history';
|
||||
|
||||
export class AppChromeService {
|
||||
searchBarStorageKey = 'SearchBar_Hidden';
|
||||
@@ -88,8 +84,6 @@ export class AppChromeService {
|
||||
newState.chromeless = newState.kioskMode === KioskMode.Full || this.currentRoute?.chromeless;
|
||||
|
||||
if (!this.ignoreStateUpdate(newState, current)) {
|
||||
config.featureToggles.unifiedHistory &&
|
||||
store.setObject(HISTORY_LOCAL_STORAGE_KEY, this.getUpdatedHistory(newState));
|
||||
this.state.next(newState);
|
||||
}
|
||||
}
|
||||
@@ -118,40 +112,6 @@ export class AppChromeService {
|
||||
window.sessionStorage.removeItem('returnToPrevious');
|
||||
};
|
||||
|
||||
private getUpdatedHistory(newState: AppChromeState): HistoryEntry[] {
|
||||
const breadcrumbs = buildBreadcrumbs(newState.sectionNav.node, newState.pageNav, { text: 'Home', url: '/' });
|
||||
const newPageNav = newState.pageNav || newState.sectionNav.node;
|
||||
|
||||
let entries = store.getObject<HistoryEntry[]>(HISTORY_LOCAL_STORAGE_KEY, []);
|
||||
const clickedHistory = store.getObject<boolean>('CLICKING_HISTORY');
|
||||
if (clickedHistory) {
|
||||
store.setObject('CLICKING_HISTORY', false);
|
||||
return entries;
|
||||
}
|
||||
if (!newPageNav) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
const lastEntry = entries[0];
|
||||
const newEntry = { name: newPageNav.text, views: [], breadcrumbs, time: Date.now(), url: window.location.href };
|
||||
const isSamePath = lastEntry && newEntry.url.split('?')[0] === lastEntry.url.split('?')[0];
|
||||
|
||||
// To avoid adding an entry with the same path twice, we always use the latest one
|
||||
if (isSamePath) {
|
||||
entries[0] = newEntry;
|
||||
} else {
|
||||
if (lastEntry && lastEntry.name === newEntry.name) {
|
||||
logDuplicateUnifiedHistoryEntryEvent({
|
||||
entryName: newEntry.name,
|
||||
lastEntryURL: lastEntry.url,
|
||||
newEntryURL: newEntry.url,
|
||||
});
|
||||
}
|
||||
entries = [newEntry, ...entries];
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
private ignoreStateUpdate(newState: AppChromeState, current: AppChromeState) {
|
||||
if (isShallowEqual(newState, current)) {
|
||||
return true;
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useEffect } from 'react';
|
||||
import { useToggle } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2, store } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { Drawer, ToolbarButton, useStyles2 } from '@grafana/ui';
|
||||
import { appEvents } from 'app/core/app_events';
|
||||
import { RecordHistoryEntryEvent } from 'app/types/events';
|
||||
|
||||
import { HISTORY_LOCAL_STORAGE_KEY } from '../AppChromeService';
|
||||
import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator';
|
||||
import { HistoryEntry } from '../types';
|
||||
|
||||
import { HistoryWrapper } from './HistoryWrapper';
|
||||
import { logUnifiedHistoryDrawerInteractionEvent } from './eventsTracking';
|
||||
|
||||
export function HistoryContainer() {
|
||||
const [showHistoryDrawer, onToggleShowHistoryDrawer] = useToggle(false);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
useEffect(() => {
|
||||
const sub = appEvents.subscribe(RecordHistoryEntryEvent, (ev) => {
|
||||
const clickedHistory = store.getObject<boolean>('CLICKING_HISTORY');
|
||||
if (clickedHistory) {
|
||||
store.setObject('CLICKING_HISTORY', false);
|
||||
return;
|
||||
}
|
||||
const history = store.getObject<HistoryEntry[]>(HISTORY_LOCAL_STORAGE_KEY, []);
|
||||
let lastEntry = history[0];
|
||||
const newUrl = ev.payload.url;
|
||||
const lastUrl = lastEntry.views[0]?.url;
|
||||
if (lastUrl !== newUrl) {
|
||||
lastEntry.views = [
|
||||
{
|
||||
name: ev.payload.name,
|
||||
description: ev.payload.description,
|
||||
url: newUrl,
|
||||
time: Date.now(),
|
||||
},
|
||||
...lastEntry.views,
|
||||
];
|
||||
store.setObject(HISTORY_LOCAL_STORAGE_KEY, [...history]);
|
||||
}
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
onToggleShowHistoryDrawer();
|
||||
logUnifiedHistoryDrawerInteractionEvent({ type: 'open' });
|
||||
}}
|
||||
iconOnly
|
||||
icon="history"
|
||||
aria-label={t('nav.history-container.drawer-tittle', 'History')}
|
||||
/>
|
||||
<NavToolbarSeparator className={styles.separator} />
|
||||
{showHistoryDrawer && (
|
||||
<Drawer
|
||||
title={t('nav.history-container.drawer-tittle', 'History')}
|
||||
onClose={() => {
|
||||
onToggleShowHistoryDrawer();
|
||||
logUnifiedHistoryDrawerInteractionEvent({ type: 'close' });
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
<HistoryWrapper onClose={() => onToggleShowHistoryDrawer(false)} />
|
||||
</Drawer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
separator: css({
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
display: 'none',
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -1,291 +0,0 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import moment from 'moment';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { FieldType, GrafanaTheme2, store } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { Box, Button, Card, Icon, IconButton, Space, Sparkline, Stack, Text, useStyles2, useTheme2 } from '@grafana/ui';
|
||||
import { formatDate } from 'app/core/internationalization/dates';
|
||||
|
||||
import { HISTORY_LOCAL_STORAGE_KEY } from '../AppChromeService';
|
||||
import { HistoryEntry } from '../types';
|
||||
|
||||
import { logClickUnifiedHistoryEntryEvent, logUnifiedHistoryShowMoreEvent } from './eventsTracking';
|
||||
|
||||
export function HistoryWrapper({ onClose }: { onClose: () => void }) {
|
||||
const history = store.getObject<HistoryEntry[]>(HISTORY_LOCAL_STORAGE_KEY, []).filter((entry) => {
|
||||
return moment(entry.time).isAfter(moment().subtract(2, 'day').startOf('day'));
|
||||
});
|
||||
const [numItemsToShow, setNumItemsToShow] = useState(5);
|
||||
|
||||
const selectedTime = history.find((entry) => {
|
||||
return entry.url === window.location.href || entry.views.some((view) => view.url === window.location.href);
|
||||
})?.time;
|
||||
|
||||
const hist = history.slice(0, numItemsToShow).reduce((acc: { [key: string]: HistoryEntry[] }, entry) => {
|
||||
const date = moment(entry.time);
|
||||
let key = '';
|
||||
if (date.isSame(moment(), 'day')) {
|
||||
key = t('nav.history-wrapper.today', 'Today');
|
||||
} else if (date.isSame(moment().subtract(1, 'day'), 'day')) {
|
||||
key = t('nav.history-wrapper.yesterday', 'Yesterday');
|
||||
} else {
|
||||
key = date.format('YYYY-MM-DD');
|
||||
}
|
||||
acc[key] = [...(acc[key] || []), entry];
|
||||
return acc;
|
||||
}, {});
|
||||
const styles = useStyles2(getStyles);
|
||||
return (
|
||||
<Stack direction="column" alignItems="flex-start">
|
||||
<Box width="100%">
|
||||
{Object.keys(hist).map((entries, date) => {
|
||||
return (
|
||||
<Stack key={date} direction="column" gap={1}>
|
||||
<Box paddingLeft={2}>
|
||||
<Text color="secondary">{entries}</Text>
|
||||
</Box>
|
||||
<div className={styles.timeline}>
|
||||
{hist[entries].map((entry, index) => {
|
||||
return (
|
||||
<HistoryEntryAppView
|
||||
key={index}
|
||||
entry={entry}
|
||||
isSelected={entry.time === selectedTime}
|
||||
onClick={() => onClose()}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{history.length > numItemsToShow && (
|
||||
<Box paddingLeft={2}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
fill="text"
|
||||
onClick={() => {
|
||||
setNumItemsToShow(numItemsToShow + 5);
|
||||
logUnifiedHistoryShowMoreEvent();
|
||||
}}
|
||||
>
|
||||
{t('nav.history-wrapper.show-more', 'Show more')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
interface ItemProps {
|
||||
entry: HistoryEntry;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function HistoryEntryAppView({ entry, isSelected, onClick }: ItemProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const theme = useTheme2();
|
||||
const [isExpanded, setIsExpanded] = useState(isSelected && entry.views.length > 0);
|
||||
|
||||
const { breadcrumbs, views, time, url, sparklineData } = entry;
|
||||
const expandedLabel = isExpanded
|
||||
? t('nav.history-wrapper.collapse', 'Collapse')
|
||||
: t('nav.history-wrapper.expand', 'Expand');
|
||||
const entryIconLabel = isExpanded
|
||||
? t('nav.history-wrapper.icon-selected', 'Selected Entry')
|
||||
: t('nav.history-wrapper.icon-unselected', 'Normal Entry');
|
||||
const selectedViewTime =
|
||||
isSelected &&
|
||||
entry.views.find((entry) => {
|
||||
return entry.url === window.location.href;
|
||||
})?.time;
|
||||
|
||||
return (
|
||||
<Box marginBottom={1}>
|
||||
<Stack direction="column" gap={1}>
|
||||
<Stack alignItems="baseline">
|
||||
{views.length > 0 ? (
|
||||
<IconButton
|
||||
name={isExpanded ? 'angle-down' : 'angle-right'}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
aria-label={expandedLabel}
|
||||
className={styles.iconButton}
|
||||
/>
|
||||
) : (
|
||||
<Space h={2} />
|
||||
)}
|
||||
<Icon
|
||||
size="sm"
|
||||
name={isSelected ? 'circle-mono' : 'circle'}
|
||||
aria-label={entryIconLabel}
|
||||
className={isExpanded ? styles.iconButtonDot : styles.iconButtonCircle}
|
||||
/>
|
||||
<Card
|
||||
noMargin
|
||||
onClick={() => {
|
||||
store.setObject('CLICKING_HISTORY', true);
|
||||
onClick();
|
||||
logClickUnifiedHistoryEntryEvent({ entryURL: url });
|
||||
}}
|
||||
href={url}
|
||||
isCompact={true}
|
||||
className={isSelected ? styles.card : cx(styles.card, styles.cardSelected)}
|
||||
>
|
||||
<Stack direction="column">
|
||||
<div>
|
||||
{breadcrumbs.map((breadcrumb, index) => (
|
||||
<Text key={index}>
|
||||
{breadcrumb.text}{' '}
|
||||
{index !== breadcrumbs.length - 1
|
||||
? // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
'> '
|
||||
: ''}
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
<Text variant="bodySmall" color="secondary">
|
||||
{formatDate(time, { timeStyle: 'short' })}
|
||||
</Text>
|
||||
{sparklineData && (
|
||||
<Sparkline
|
||||
theme={theme}
|
||||
width={240}
|
||||
height={40}
|
||||
config={{
|
||||
custom: {
|
||||
fillColor: 'rgba(130, 181, 216, 0.1)',
|
||||
lineColor: '#82B5D8',
|
||||
},
|
||||
}}
|
||||
sparkline={{
|
||||
y: {
|
||||
type: FieldType.number,
|
||||
name: 'test',
|
||||
config: {},
|
||||
values: sparklineData.values,
|
||||
state: {
|
||||
range: {
|
||||
...sparklineData.range,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
{isExpanded && (
|
||||
<div className={styles.expanded}>
|
||||
{views.map((view, index) => {
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
noMargin
|
||||
href={view.url}
|
||||
onClick={() => {
|
||||
store.setObject('CLICKING_HISTORY', true);
|
||||
onClick();
|
||||
logClickUnifiedHistoryEntryEvent({ entryURL: view.url, subEntry: 'timeRange' });
|
||||
}}
|
||||
isCompact={true}
|
||||
className={view.time === selectedViewTime ? undefined : styles.subCard}
|
||||
>
|
||||
<Stack direction="column" gap={0}>
|
||||
<Text variant="bodySmall">{view.name}</Text>
|
||||
{view.description && (
|
||||
<Text color="secondary" variant="bodySmall">
|
||||
{view.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
card: css({
|
||||
label: 'card',
|
||||
background: 'none',
|
||||
margin: theme.spacing(0.5, 0),
|
||||
}),
|
||||
cardSelected: css({
|
||||
label: 'card-selected',
|
||||
background: 'none',
|
||||
}),
|
||||
subCard: css({
|
||||
label: 'subcard',
|
||||
background: 'none',
|
||||
margin: 0,
|
||||
}),
|
||||
iconButton: css({
|
||||
label: 'expand-button',
|
||||
margin: 0,
|
||||
}),
|
||||
iconButtonCircle: css({
|
||||
label: 'blue-circle-icon',
|
||||
margin: 0,
|
||||
background: theme.colors.background.primary,
|
||||
fill: theme.colors.primary.main,
|
||||
cursor: 'default',
|
||||
'&:hover:before': {
|
||||
background: 'none',
|
||||
},
|
||||
//Need this to place the icon on the line, otherwise the line will appear on top of the icon
|
||||
zIndex: 0,
|
||||
}),
|
||||
iconButtonDot: css({
|
||||
label: 'blue-dot-icon',
|
||||
margin: 0,
|
||||
color: theme.colors.primary.main,
|
||||
border: theme.shape.radius.circle,
|
||||
cursor: 'default',
|
||||
'&:hover:before': {
|
||||
background: 'none',
|
||||
},
|
||||
//Need this to place the icon on the line, otherwise the line will appear on top of the icon
|
||||
zIndex: 0,
|
||||
}),
|
||||
expanded: css({
|
||||
label: 'expanded',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginLeft: theme.spacing(6),
|
||||
gap: theme.spacing(1),
|
||||
position: 'relative',
|
||||
'&:before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: '100%',
|
||||
width: '1px',
|
||||
background: theme.colors.border.weak,
|
||||
},
|
||||
}),
|
||||
timeline: css({
|
||||
label: 'timeline',
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
paddingLeft: theme.spacing(2),
|
||||
'&:before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: theme.spacing(5.75),
|
||||
top: 0,
|
||||
height: '100%',
|
||||
width: '1px',
|
||||
borderLeft: `1px dashed ${theme.colors.border.strong}`,
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
|
||||
const UNIFIED_HISTORY_ENTRY_CLICKED = 'grafana_unified_history_entry_clicked';
|
||||
const UNIFIED_HISTORY_ENTRY_DUPLICATED = 'grafana_unified_history_duplicated_entry_rendered';
|
||||
const UNIFIED_HISTORY_DRAWER_INTERACTION = 'grafana_unified_history_drawer_interaction';
|
||||
const UNIFIED_HISTORY_DRAWER_SHOW_MORE = 'grafana_unified_history_show_more';
|
||||
|
||||
//Currently just 'timeRange' is supported
|
||||
//in short term, we could add 'templateVariables' for example
|
||||
type subEntryTypes = 'timeRange';
|
||||
|
||||
//Whether the user opens or closes the `HistoryDrawer`
|
||||
type UnifiedHistoryDrawerInteraction = 'open' | 'close';
|
||||
|
||||
interface UnifiedHistoryEntryClicked {
|
||||
//We will also work with the current URL but we will get this from Rudderstack data
|
||||
//URL to return to
|
||||
entryURL: string;
|
||||
//In the case we want to go back to a specific query param, currently just a specific time range
|
||||
subEntry?: subEntryTypes;
|
||||
}
|
||||
|
||||
interface UnifiedHistoryEntryDuplicated {
|
||||
// Common name of the history entries
|
||||
entryName: string;
|
||||
// URL of the last entry
|
||||
lastEntryURL: string;
|
||||
// URL of the new entry
|
||||
newEntryURL: string;
|
||||
}
|
||||
|
||||
//Event triggered when a user clicks on an entry of the `HistoryDrawer`
|
||||
export const logClickUnifiedHistoryEntryEvent = ({ entryURL, subEntry }: UnifiedHistoryEntryClicked) => {
|
||||
reportInteraction(UNIFIED_HISTORY_ENTRY_CLICKED, {
|
||||
entryURL,
|
||||
subEntry,
|
||||
});
|
||||
};
|
||||
|
||||
//Event triggered when history entry name matches the previous one
|
||||
//so we keep track of duplicated entries and be able to analyze them
|
||||
export const logDuplicateUnifiedHistoryEntryEvent = ({
|
||||
entryName,
|
||||
lastEntryURL,
|
||||
newEntryURL,
|
||||
}: UnifiedHistoryEntryDuplicated) => {
|
||||
reportInteraction(UNIFIED_HISTORY_ENTRY_DUPLICATED, {
|
||||
entryName,
|
||||
lastEntryURL,
|
||||
newEntryURL,
|
||||
});
|
||||
};
|
||||
|
||||
//We keep track of users open and closing the drawer
|
||||
export const logUnifiedHistoryDrawerInteractionEvent = ({ type }: { type: UnifiedHistoryDrawerInteraction }) => {
|
||||
reportInteraction(UNIFIED_HISTORY_DRAWER_INTERACTION, {
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
//We keep track of users clicking on the `Show more` button
|
||||
export const logUnifiedHistoryShowMoreEvent = () => {
|
||||
reportInteraction(UNIFIED_HISTORY_DRAWER_SHOW_MORE);
|
||||
};
|
||||
@@ -6,7 +6,6 @@ import { Components } from '@grafana/e2e-selectors';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { ScopesContextValue } from '@grafana/runtime';
|
||||
import { Icon, Stack, ToolbarButton, useStyles2 } from '@grafana/ui';
|
||||
import { config } from 'app/core/config';
|
||||
import { MEGA_MENU_TOGGLE_ID } from 'app/core/constants';
|
||||
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||
import { useMediaQueryMinWidth } from 'app/core/hooks/useMediaQueryMinWidth';
|
||||
@@ -19,7 +18,6 @@ import { HomeLink } from '../../Branding/Branding';
|
||||
import { Breadcrumbs } from '../../Breadcrumbs/Breadcrumbs';
|
||||
import { buildBreadcrumbs } from '../../Breadcrumbs/utils';
|
||||
import { ExtensionToolbarItem } from '../ExtensionSidebar/ExtensionToolbarItem';
|
||||
import { HistoryContainer } from '../History/HistoryContainer';
|
||||
import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator';
|
||||
import { QuickAdd } from '../QuickAdd/QuickAdd';
|
||||
|
||||
@@ -60,7 +58,6 @@ export const SingleTopBar = memo(function SingleTopBar({
|
||||
const profileNode = useSelector((state) => state.navIndex['profile']);
|
||||
const homeNav = useSelector((state) => state.navIndex)[HOME_NAV_ID];
|
||||
const breadcrumbs = buildBreadcrumbs(sectionNav, pageNav, homeNav);
|
||||
const unifiedHistoryEnabled = config.featureToggles.unifiedHistory;
|
||||
const isSmallScreen = !useMediaQueryMinWidth('sm');
|
||||
const isLargeScreen = useMediaQueryMinWidth('lg');
|
||||
const topLevelScopes = !showToolbarLevel && isLargeScreen && scopes?.state.enabled;
|
||||
@@ -96,7 +93,6 @@ export const SingleTopBar = memo(function SingleTopBar({
|
||||
>
|
||||
<TopBarExtensionPoint />
|
||||
<TopSearchBarCommandPaletteTrigger />
|
||||
{unifiedHistoryEnabled && !isSmallScreen && <HistoryContainer />}
|
||||
{!isSmallScreen && <QuickAdd />}
|
||||
<HelpTopBarButton isSmallScreen={isSmallScreen} />
|
||||
<NavToolbarSeparator />
|
||||
|
||||
@@ -4,28 +4,3 @@ export interface ToolbarUpdateProps {
|
||||
pageNav?: NavModelItem;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface HistoryEntryView {
|
||||
name: string;
|
||||
description: string;
|
||||
url: string;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface HistoryEntrySparkline {
|
||||
values: number[];
|
||||
range: {
|
||||
min: number;
|
||||
max: number;
|
||||
delta: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
name: string;
|
||||
time: number;
|
||||
breadcrumbs: NavModelItem[];
|
||||
url: string;
|
||||
views: HistoryEntryView[];
|
||||
sparklineData?: HistoryEntrySparkline;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,11 @@ import { PanelEditor } from '../panel-edit/PanelEditor';
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { buildNewDashboardSaveModel, buildNewDashboardSaveModelV2 } from '../serialization/buildNewDashboardSaveModel';
|
||||
import { transformSaveModelSchemaV2ToScene } from '../serialization/transformSaveModelSchemaV2ToScene';
|
||||
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
||||
import {
|
||||
createV2RowsLayout,
|
||||
SceneCreationOptions,
|
||||
transformSaveModelToScene,
|
||||
} from '../serialization/transformSaveModelToScene';
|
||||
import { restoreDashboardStateFromLocalStorage } from '../utils/dashboardSessionState';
|
||||
|
||||
import { processQueryParamsForDashboardLoad, updateNavModel } from './utils';
|
||||
@@ -106,6 +110,34 @@ interface DashboardScenePageStateManagerLike<T> {
|
||||
useState: () => DashboardScenePageState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates scene creation options with appropriate layout creator
|
||||
* based on feature flags and dashboard type.
|
||||
*/
|
||||
export function getSceneCreationOptions(
|
||||
loadOptions?: LoadDashboardOptions,
|
||||
meta?: { isSnapshot?: boolean }
|
||||
): SceneCreationOptions | undefined {
|
||||
const isReport = loadOptions?.route === DashboardRoutes.Report;
|
||||
const isTemplate = loadOptions?.route === DashboardRoutes.Template;
|
||||
const isSnapshot = meta?.isSnapshot ?? false;
|
||||
|
||||
// Don't use v2 layout for reports or snapshots
|
||||
if (isReport || isSnapshot || isTemplate) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Use v2 layout creator when v2 API is enabled
|
||||
if (shouldForceV2API()) {
|
||||
return {
|
||||
createLayout: createV2RowsLayout,
|
||||
targetVersion: 'v2',
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
abstract class DashboardScenePageStateManagerBase<T>
|
||||
extends StateManagerBase<DashboardScenePageState>
|
||||
implements DashboardScenePageStateManagerLike<T>
|
||||
@@ -155,7 +187,7 @@ abstract class DashboardScenePageStateManagerBase<T>
|
||||
private async loadHomeDashboard(): Promise<DashboardScene | null> {
|
||||
const rsp = await this.fetchHomeDashboard();
|
||||
if (rsp) {
|
||||
return transformSaveModelToScene(rsp);
|
||||
return transformSaveModelToScene(rsp, undefined, getSceneCreationOptions());
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -441,7 +473,8 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
|
||||
}
|
||||
|
||||
if (rsp?.dashboard) {
|
||||
const scene = transformSaveModelToScene(rsp, options);
|
||||
const sceneCreationOptions = getSceneCreationOptions(options, rsp.meta);
|
||||
const scene = transformSaveModelToScene(rsp, options, sceneCreationOptions);
|
||||
|
||||
// Special handling for Template route - set up edit mode and dirty state
|
||||
if (
|
||||
@@ -474,7 +507,8 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
|
||||
throw new DashboardVersionError('v2beta1', 'Using legacy snapshot API to get a V2 dashboard');
|
||||
}
|
||||
|
||||
const scene = transformSaveModelToScene(rsp);
|
||||
// Snapshots should use default v1 layout
|
||||
const scene = transformSaveModelToScene(rsp, undefined, getSceneCreationOptions(undefined, { isSnapshot: true }));
|
||||
return scene;
|
||||
}
|
||||
|
||||
@@ -755,7 +789,8 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
|
||||
return;
|
||||
}
|
||||
|
||||
const scene = transformSaveModelToScene(rsp);
|
||||
const sceneCreationOptions = getSceneCreationOptions(undefined, rsp.meta);
|
||||
const scene = transformSaveModelToScene(rsp, undefined, sceneCreationOptions);
|
||||
|
||||
// we need to call and restore dashboard state on every reload that pulls a new dashboard version
|
||||
if (config.featureToggles.preserveDashboardStateWhenNavigating && Boolean(uid)) {
|
||||
|
||||
@@ -1,508 +0,0 @@
|
||||
import { VizPanel } from '@grafana/scenes';
|
||||
|
||||
import { DashboardLayoutOrchestrator } from './DashboardLayoutOrchestrator';
|
||||
import { DashboardScene } from './DashboardScene';
|
||||
import { AutoGridItem } from './layout-auto-grid/AutoGridItem';
|
||||
import { AutoGridLayout } from './layout-auto-grid/AutoGridLayout';
|
||||
import { AutoGridLayoutManager } from './layout-auto-grid/AutoGridLayoutManager';
|
||||
import { DashboardGridItem } from './layout-default/DashboardGridItem';
|
||||
import { TabItem } from './layout-tabs/TabItem';
|
||||
import { TabsLayoutManager } from './layout-tabs/TabsLayoutManager';
|
||||
|
||||
describe('DashboardLayoutOrchestrator', () => {
|
||||
describe('cross-tab drag cancel', () => {
|
||||
it('should drop item into current tab when dropped on tab header after detach', () => {
|
||||
const { orchestrator, tab1Manager, tab2Manager, gridItem, tabsManager, tab1 } = setupWithTwoTabs();
|
||||
|
||||
// Simulate state after cross-tab drag started:
|
||||
// - Item was detached from source
|
||||
// - We're on Tab 2 now
|
||||
// - User releases mouse over tab header (no valid drop target under mouse)
|
||||
// Expected: Item drops into Tab 2's layout
|
||||
|
||||
orchestrator.setState({
|
||||
draggingGridItem: gridItem.getRef(),
|
||||
sourceTabKey: tab1.state.key,
|
||||
});
|
||||
|
||||
const tab2 = tabsManager.state.tabs[1];
|
||||
|
||||
// @ts-expect-error - accessing private property for testing
|
||||
orchestrator._sourceDropTarget = tab1Manager;
|
||||
// @ts-expect-error - accessing private property for testing
|
||||
// lastDropTarget is the TabItem (set when tab switches)
|
||||
orchestrator._lastDropTarget = tab2;
|
||||
// @ts-expect-error - accessing private property for testing
|
||||
orchestrator._itemDetachedFromSource = true;
|
||||
|
||||
// Simulate the item being removed from source (as happens during tab switch)
|
||||
tab1Manager.draggedGridItemOutside(gridItem);
|
||||
|
||||
// Switch to tab 2 (simulating what happens after 600ms hover)
|
||||
tabsManager.switchToTab(tab2);
|
||||
|
||||
// Verify item was removed from tab1
|
||||
expect(tab1Manager.state.layout.state.children).toHaveLength(0);
|
||||
// Verify tab2 is empty before drop
|
||||
expect(tab2Manager.state.layout.state.children).toHaveLength(0);
|
||||
|
||||
// Mock _getDropTargetUnderMouse to return null (simulating cursor over tab header)
|
||||
// @ts-expect-error - accessing private method for testing
|
||||
const originalGetDropTargetUnderMouse = orchestrator._getDropTargetUnderMouse;
|
||||
// @ts-expect-error - accessing private method for testing
|
||||
orchestrator._getDropTargetUnderMouse = jest.fn().mockReturnValue(null);
|
||||
|
||||
// Create a mock pointer event
|
||||
const mockEvent = {
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
} as PointerEvent;
|
||||
|
||||
// Call _stopDraggingSync (this is what happens on mouse release)
|
||||
// @ts-expect-error - accessing private method for testing
|
||||
orchestrator._stopDraggingSync(mockEvent);
|
||||
|
||||
// Restore original methods
|
||||
// @ts-expect-error - accessing private method for testing
|
||||
orchestrator._getDropTargetUnderMouse = originalGetDropTargetUnderMouse;
|
||||
|
||||
// Wait for setTimeout to execute
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
// Verify item was dropped into tab2
|
||||
expect(tab2Manager.state.layout.state.children).toHaveLength(1);
|
||||
expect(tab2Manager.state.layout.state.children[0]).toBe(gridItem);
|
||||
|
||||
// Tab1 should still be empty
|
||||
expect(tab1Manager.state.layout.state.children).toHaveLength(0);
|
||||
|
||||
// We should still be on tab2
|
||||
expect(tabsManager.getCurrentTab()).toBe(tab2);
|
||||
|
||||
resolve();
|
||||
}, 0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should complete normal drop when valid drop target exists', () => {
|
||||
const { orchestrator, tab1Manager, tab2Manager, gridItem, tab1 } = setupWithTwoTabs();
|
||||
|
||||
// Simulate state after cross-tab drag started
|
||||
orchestrator.setState({
|
||||
draggingGridItem: gridItem.getRef(),
|
||||
sourceTabKey: tab1.state.key,
|
||||
});
|
||||
|
||||
// @ts-expect-error - accessing private property for testing
|
||||
orchestrator._sourceDropTarget = tab1Manager;
|
||||
// @ts-expect-error - accessing private property for testing
|
||||
orchestrator._lastDropTarget = tab2Manager;
|
||||
// @ts-expect-error - accessing private property for testing
|
||||
orchestrator._itemDetachedFromSource = true;
|
||||
|
||||
// Simulate the item being removed from source
|
||||
tab1Manager.draggedGridItemOutside(gridItem);
|
||||
expect(tab1Manager.state.layout.state.children).toHaveLength(0);
|
||||
|
||||
// Mock _getDropTargetUnderMouse to return the tab2Manager (valid drop target)
|
||||
// @ts-expect-error - accessing private method for testing
|
||||
const originalGetDropTargetUnderMouse = orchestrator._getDropTargetUnderMouse;
|
||||
// @ts-expect-error - accessing private method for testing
|
||||
orchestrator._getDropTargetUnderMouse = jest.fn().mockReturnValue(tab2Manager);
|
||||
|
||||
const mockEvent = {
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
} as PointerEvent;
|
||||
|
||||
// @ts-expect-error - accessing private method for testing
|
||||
orchestrator._stopDraggingSync(mockEvent);
|
||||
|
||||
// @ts-expect-error - accessing private method for testing
|
||||
orchestrator._getDropTargetUnderMouse = originalGetDropTargetUnderMouse;
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
// Verify item was NOT returned to source (it should go to tab2)
|
||||
expect(tab1Manager.state.layout.state.children).toHaveLength(0);
|
||||
expect(tab2Manager.state.layout.state.children).toHaveLength(1);
|
||||
expect(tab2Manager.state.layout.state.children[0]).toBe(gridItem);
|
||||
|
||||
resolve();
|
||||
}, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDragging', () => {
|
||||
it('should return false when nothing is being dragged', () => {
|
||||
const { orchestrator } = setup();
|
||||
|
||||
expect(orchestrator.isDragging()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when dragging a grid item', () => {
|
||||
const { orchestrator, gridItem } = setup();
|
||||
|
||||
orchestrator.setState({ draggingGridItem: gridItem.getRef() });
|
||||
|
||||
expect(orchestrator.isDragging()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when dragging a row', () => {
|
||||
const { orchestrator } = setupWithRows();
|
||||
|
||||
// Note: draggingRow is set via startRowDrag which requires more setup
|
||||
// This test verifies the state check logic
|
||||
orchestrator.setState({ draggingRow: undefined });
|
||||
expect(orchestrator.isDragging()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDroppedElsewhere', () => {
|
||||
it('should return false when not dragging', () => {
|
||||
const { orchestrator } = setup();
|
||||
|
||||
expect(orchestrator.isDroppedElsewhere()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when source and target are the same', () => {
|
||||
const { orchestrator } = setup();
|
||||
|
||||
// Use the same object reference for both - the comparison is by reference
|
||||
const mockDropTarget = { state: { key: 'grid-1' } };
|
||||
// @ts-expect-error - accessing private property for testing
|
||||
orchestrator._sourceDropTarget = mockDropTarget;
|
||||
// @ts-expect-error - accessing private property for testing
|
||||
orchestrator._lastDropTarget = mockDropTarget;
|
||||
|
||||
// When source equals target (same reference), it's not dropped elsewhere
|
||||
expect(orchestrator.isDroppedElsewhere()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when source and target differ', () => {
|
||||
const { orchestrator } = setup();
|
||||
|
||||
// @ts-expect-error - accessing private property for testing
|
||||
orchestrator._sourceDropTarget = { state: { key: 'grid-1' } };
|
||||
// @ts-expect-error - accessing private property for testing
|
||||
orchestrator._lastDropTarget = { state: { key: 'grid-2' } };
|
||||
|
||||
expect(orchestrator.isDroppedElsewhere()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when lastDropTarget is null', () => {
|
||||
const { orchestrator } = setup();
|
||||
|
||||
// @ts-expect-error - accessing private property for testing
|
||||
orchestrator._sourceDropTarget = { state: { key: 'grid-1' } };
|
||||
// @ts-expect-error - accessing private property for testing
|
||||
orchestrator._lastDropTarget = null;
|
||||
|
||||
expect(orchestrator.isDroppedElsewhere()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getItemLabel (via state)', () => {
|
||||
it('should extract panel title from AutoGridItem', () => {
|
||||
const panel = new VizPanel({
|
||||
title: 'My Panel Title',
|
||||
key: 'panel-1',
|
||||
pluginId: 'table',
|
||||
});
|
||||
|
||||
const gridItem = new AutoGridItem({
|
||||
key: 'grid-item-1',
|
||||
body: panel,
|
||||
});
|
||||
|
||||
// The label extraction happens internally, we can verify the panel structure
|
||||
expect(gridItem.state.body.state.title).toBe('My Panel Title');
|
||||
});
|
||||
|
||||
it('should handle panel with empty title', () => {
|
||||
const panel = new VizPanel({
|
||||
title: '',
|
||||
key: 'panel-1',
|
||||
pluginId: 'table',
|
||||
});
|
||||
|
||||
const gridItem = new AutoGridItem({
|
||||
key: 'grid-item-1',
|
||||
body: panel,
|
||||
});
|
||||
|
||||
// Empty title should be falsy, which the orchestrator handles with fallback to 'Panel'
|
||||
expect(gridItem.state.body.state.title).toBe('');
|
||||
expect(gridItem.state.body.state.title || 'Panel').toBe('Panel');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AutoGridLayoutManager as DashboardDropTarget', () => {
|
||||
describe('draggedGridItemInside', () => {
|
||||
it('should add item at the end when no position specified', () => {
|
||||
const { manager } = setupAutoGrid();
|
||||
const newPanel = new VizPanel({ title: 'New Panel', key: 'panel-new', pluginId: 'table' });
|
||||
const newItem = new AutoGridItem({ key: 'new-item', body: newPanel });
|
||||
|
||||
manager.draggedGridItemInside(newItem);
|
||||
|
||||
const children = manager.state.layout.state.children;
|
||||
expect(children.length).toBe(3);
|
||||
expect(children[2]).toBe(newItem);
|
||||
});
|
||||
|
||||
it('should insert item at specified position', () => {
|
||||
const { manager } = setupAutoGrid();
|
||||
const newPanel = new VizPanel({ title: 'New Panel', key: 'panel-new', pluginId: 'table' });
|
||||
const newItem = new AutoGridItem({ key: 'new-item', body: newPanel });
|
||||
|
||||
manager.draggedGridItemInside(newItem, 1);
|
||||
|
||||
const children = manager.state.layout.state.children;
|
||||
expect(children.length).toBe(3);
|
||||
expect(children[1]).toBe(newItem);
|
||||
});
|
||||
|
||||
it('should insert at beginning when position is 0', () => {
|
||||
const { manager } = setupAutoGrid();
|
||||
const newPanel = new VizPanel({ title: 'New Panel', key: 'panel-new', pluginId: 'table' });
|
||||
const newItem = new AutoGridItem({ key: 'new-item', body: newPanel });
|
||||
|
||||
manager.draggedGridItemInside(newItem, 0);
|
||||
|
||||
const children = manager.state.layout.state.children;
|
||||
expect(children.length).toBe(3);
|
||||
expect(children[0]).toBe(newItem);
|
||||
});
|
||||
|
||||
it('should clear dropPosition and isDropTarget after insertion', () => {
|
||||
const { manager } = setupAutoGrid();
|
||||
manager.setState({ dropPosition: 1, isDropTarget: true });
|
||||
|
||||
const newPanel = new VizPanel({ title: 'New Panel', key: 'panel-new', pluginId: 'table' });
|
||||
const newItem = new AutoGridItem({ key: 'new-item', body: newPanel });
|
||||
|
||||
manager.draggedGridItemInside(newItem, 1);
|
||||
|
||||
expect(manager.state.dropPosition).toBeNull();
|
||||
expect(manager.state.isDropTarget).toBe(false);
|
||||
});
|
||||
|
||||
it('should convert DashboardGridItem to AutoGridItem', () => {
|
||||
const { manager } = setupAutoGrid();
|
||||
const panel = new VizPanel({ title: 'Dashboard Panel', key: 'panel-dgi', pluginId: 'table' });
|
||||
const dashboardGridItem = new DashboardGridItem({ key: 'dgi-1', body: panel });
|
||||
|
||||
manager.draggedGridItemInside(dashboardGridItem, 1);
|
||||
|
||||
const children = manager.state.layout.state.children;
|
||||
expect(children.length).toBe(3);
|
||||
// The inserted item should be an AutoGridItem containing the panel
|
||||
expect(children[1]).toBeInstanceOf(AutoGridItem);
|
||||
expect(children[1].state.body).toBe(panel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('draggedGridItemOutside', () => {
|
||||
it('should remove item from children', () => {
|
||||
const { manager, gridItem1 } = setupAutoGrid();
|
||||
|
||||
manager.draggedGridItemOutside(gridItem1);
|
||||
|
||||
const children = manager.state.layout.state.children;
|
||||
expect(children.length).toBe(1);
|
||||
expect(children.includes(gridItem1)).toBe(false);
|
||||
});
|
||||
|
||||
it('should clear isDropTarget state', () => {
|
||||
const { manager, gridItem1 } = setupAutoGrid();
|
||||
manager.setState({ isDropTarget: true });
|
||||
|
||||
manager.draggedGridItemOutside(gridItem1);
|
||||
|
||||
expect(manager.state.isDropTarget).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setDropPosition', () => {
|
||||
it('should set dropPosition state', () => {
|
||||
const { manager } = setupAutoGrid();
|
||||
|
||||
manager.setDropPosition(2);
|
||||
|
||||
expect(manager.state.dropPosition).toBe(2);
|
||||
});
|
||||
|
||||
it('should clear dropPosition when set to null', () => {
|
||||
const { manager } = setupAutoGrid();
|
||||
manager.setState({ dropPosition: 2 });
|
||||
|
||||
manager.setDropPosition(null);
|
||||
|
||||
expect(manager.state.dropPosition).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setIsDropTarget', () => {
|
||||
it('should set isDropTarget state', () => {
|
||||
const { manager } = setupAutoGrid();
|
||||
|
||||
manager.setIsDropTarget(true);
|
||||
|
||||
expect(manager.state.isDropTarget).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function setup() {
|
||||
const panel = new VizPanel({
|
||||
title: 'Panel A',
|
||||
key: 'panel-1',
|
||||
pluginId: 'table',
|
||||
});
|
||||
|
||||
const gridItem = new AutoGridItem({
|
||||
key: 'grid-item-1',
|
||||
body: panel,
|
||||
});
|
||||
|
||||
const manager = new AutoGridLayoutManager({
|
||||
layout: new AutoGridLayout({ children: [gridItem] }),
|
||||
});
|
||||
|
||||
const orchestrator = new DashboardLayoutOrchestrator();
|
||||
|
||||
new DashboardScene({
|
||||
body: manager,
|
||||
layoutOrchestrator: orchestrator,
|
||||
});
|
||||
|
||||
return { orchestrator, manager, gridItem, panel };
|
||||
}
|
||||
|
||||
function setupWithRows() {
|
||||
const panel = new VizPanel({
|
||||
title: 'Panel A',
|
||||
key: 'panel-1',
|
||||
pluginId: 'table',
|
||||
});
|
||||
|
||||
const gridItem = new AutoGridItem({
|
||||
key: 'grid-item-1',
|
||||
body: panel,
|
||||
});
|
||||
|
||||
const manager = new AutoGridLayoutManager({
|
||||
layout: new AutoGridLayout({ children: [gridItem] }),
|
||||
});
|
||||
|
||||
const tabsManager = new TabsLayoutManager({
|
||||
tabs: [new TabItem({ title: 'Tab 1', layout: manager })],
|
||||
});
|
||||
|
||||
const orchestrator = new DashboardLayoutOrchestrator();
|
||||
|
||||
new DashboardScene({
|
||||
body: tabsManager,
|
||||
layoutOrchestrator: orchestrator,
|
||||
});
|
||||
|
||||
return { orchestrator, manager, gridItem, panel, tabsManager };
|
||||
}
|
||||
|
||||
function setupAutoGrid() {
|
||||
const panel1 = new VizPanel({
|
||||
title: 'Panel A',
|
||||
key: 'panel-1',
|
||||
pluginId: 'table',
|
||||
});
|
||||
|
||||
const panel2 = new VizPanel({
|
||||
title: 'Panel B',
|
||||
key: 'panel-2',
|
||||
pluginId: 'table',
|
||||
});
|
||||
|
||||
const gridItem1 = new AutoGridItem({
|
||||
key: 'grid-item-1',
|
||||
body: panel1,
|
||||
});
|
||||
|
||||
const gridItem2 = new AutoGridItem({
|
||||
key: 'grid-item-2',
|
||||
body: panel2,
|
||||
});
|
||||
|
||||
const manager = new AutoGridLayoutManager({
|
||||
layout: new AutoGridLayout({ children: [gridItem1, gridItem2] }),
|
||||
});
|
||||
|
||||
new DashboardScene({ body: manager });
|
||||
|
||||
return { manager, gridItem1, gridItem2, panel1, panel2 };
|
||||
}
|
||||
|
||||
function setupWithTwoTabs() {
|
||||
// Create panel for Tab 1
|
||||
const panel1 = new VizPanel({
|
||||
title: 'Panel in Tab 1',
|
||||
key: 'panel-tab1',
|
||||
pluginId: 'table',
|
||||
});
|
||||
|
||||
const gridItem = new AutoGridItem({
|
||||
key: 'grid-item-tab1',
|
||||
body: panel1,
|
||||
});
|
||||
|
||||
const tab1Manager = new AutoGridLayoutManager({
|
||||
key: 'tab1-manager',
|
||||
layout: new AutoGridLayout({ children: [gridItem] }),
|
||||
});
|
||||
|
||||
const tab1 = new TabItem({
|
||||
key: 'tab-1',
|
||||
title: 'Tab 1',
|
||||
layout: tab1Manager,
|
||||
});
|
||||
|
||||
// Create empty Tab 2
|
||||
const tab2Manager = new AutoGridLayoutManager({
|
||||
key: 'tab2-manager',
|
||||
layout: new AutoGridLayout({ children: [] }),
|
||||
});
|
||||
|
||||
const tab2 = new TabItem({
|
||||
key: 'tab-2',
|
||||
title: 'Tab 2',
|
||||
layout: tab2Manager,
|
||||
});
|
||||
|
||||
const tabsManager = new TabsLayoutManager({
|
||||
tabs: [tab1, tab2],
|
||||
});
|
||||
|
||||
const orchestrator = new DashboardLayoutOrchestrator();
|
||||
|
||||
const dashboard = new DashboardScene({
|
||||
body: tabsManager,
|
||||
layoutOrchestrator: orchestrator,
|
||||
});
|
||||
|
||||
// Activate the scene hierarchy to set up parent relationships
|
||||
dashboard.activate();
|
||||
|
||||
return {
|
||||
orchestrator,
|
||||
tabsManager,
|
||||
tab1,
|
||||
tab2,
|
||||
tab1Manager,
|
||||
tab2Manager,
|
||||
gridItem,
|
||||
panel1,
|
||||
dashboard,
|
||||
};
|
||||
}
|
||||
@@ -1,88 +1,28 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { logWarning } from '@grafana/runtime';
|
||||
import {
|
||||
sceneGraph,
|
||||
SceneComponentProps,
|
||||
SceneObjectBase,
|
||||
SceneObjectRef,
|
||||
SceneObjectState,
|
||||
VizPanel,
|
||||
SceneGridItemLike,
|
||||
} from '@grafana/scenes';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { createPointerDistance } from '@grafana/ui';
|
||||
|
||||
import { DashboardScene } from './DashboardScene';
|
||||
import { AutoGridLayoutManager } from './layout-auto-grid/AutoGridLayoutManager';
|
||||
import { RowItem } from './layout-rows/RowItem';
|
||||
import { RowsLayoutManager } from './layout-rows/RowsLayoutManager';
|
||||
import { TabItem } from './layout-tabs/TabItem';
|
||||
import { TabsLayoutManager } from './layout-tabs/TabsLayoutManager';
|
||||
import {
|
||||
AUTO_GRID_ITEM_DROP_TARGET_ATTR,
|
||||
DASHBOARD_DROP_TARGET_KEY_ATTR,
|
||||
DashboardDropTarget,
|
||||
isDashboardDropTarget,
|
||||
} from './types/DashboardDropTarget';
|
||||
|
||||
const TAB_ACTIVATION_DELAY_MS = 600;
|
||||
import { DashboardDropTarget, isDashboardDropTarget } from './types/DashboardDropTarget';
|
||||
|
||||
interface DashboardLayoutOrchestratorState extends SceneObjectState {
|
||||
/** Grid item currently being dragged */
|
||||
draggingGridItem?: SceneObjectRef<SceneGridItemLike>;
|
||||
/** Row currently being dragged */
|
||||
draggingRow?: SceneObjectRef<RowItem>;
|
||||
/** Key of the source tab where drag started */
|
||||
sourceTabKey?: string;
|
||||
/** Key of the tab currently being hovered during drag */
|
||||
hoverTabKey?: string;
|
||||
/** Preview state for cross-tab drag */
|
||||
dragPreview?: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Offset from cursor to top-left of preview (preserves click position) */
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
label: string;
|
||||
type: 'panel' | 'row';
|
||||
};
|
||||
}
|
||||
|
||||
export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayoutOrchestratorState> {
|
||||
public static Component = DragPreviewRenderer;
|
||||
|
||||
private _sourceDropTarget: DashboardDropTarget | null = null;
|
||||
private _lastDropTarget: DashboardDropTarget | null = null;
|
||||
private _tabActivationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private _lastHoveredTabKey: string | null = null;
|
||||
/** Track if item was detached from source during cross-tab drag */
|
||||
private _itemDetachedFromSource = false;
|
||||
/** Cached label for the preview */
|
||||
private _previewLabel = '';
|
||||
/** Cached type for the preview */
|
||||
private _previewType: 'panel' | 'row' = 'panel';
|
||||
/** Cached dimensions for the preview */
|
||||
private _previewWidth = 0;
|
||||
private _previewHeight = 0;
|
||||
/** Last known cursor position */
|
||||
private _lastCursorX = 0;
|
||||
private _lastCursorY = 0;
|
||||
/** Offset from cursor to item's top-left corner (captured on drag start) */
|
||||
private _dragOffsetX = 0;
|
||||
private _dragOffsetY = 0;
|
||||
/** Source layout manager for row drag (for removal before tab switch) */
|
||||
private _sourceRowsLayout: RowsLayoutManager | null = null;
|
||||
/** Flag to track if row drag offset has been captured */
|
||||
private _rowOffsetCaptured = false;
|
||||
/** Current drop position for AutoGrid (index where item will be inserted) */
|
||||
private _currentDropPosition: number | null = null;
|
||||
/** Last hovered AutoGrid item key (to prevent flickering) */
|
||||
private _lastHoveredAutoGridItemKey: string | null = null;
|
||||
private _pointerDistance = createPointerDistance();
|
||||
private _isSelectedObject = false;
|
||||
|
||||
public constructor() {
|
||||
super({});
|
||||
@@ -96,30 +36,14 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
|
||||
private _activationHandler() {
|
||||
return () => {
|
||||
document.body.removeEventListener('pointermove', this._onPointerMove);
|
||||
document.body.removeEventListener('pointermove', this._onRowDragPointerMove);
|
||||
document.body.removeEventListener('pointerup', this._stopDraggingSync, true);
|
||||
document.body.removeEventListener('pointerup', this._onRowDragPointerUp, true);
|
||||
this._clearTabActivationTimer();
|
||||
this._clearDragPreview();
|
||||
document.body.removeEventListener('pointerup', this._stopDraggingSync);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if any drag operation is in progress (grid item or row)
|
||||
*/
|
||||
public isDragging(): boolean {
|
||||
return !!(this.state.draggingGridItem || this.state.draggingRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the current drag operation will drop the item to a different layout
|
||||
* than where it started. Used by AutoGridLayout to know whether to clear draggingKey.
|
||||
*/
|
||||
public isDroppedElsewhere(): boolean {
|
||||
return this._lastDropTarget !== null && this._lastDropTarget !== this._sourceDropTarget;
|
||||
}
|
||||
|
||||
public startDraggingSync(evt: ReactPointerEvent, gridItem: SceneGridItemLike): void {
|
||||
this._pointerDistance.set(evt);
|
||||
this._isSelectedObject = false;
|
||||
|
||||
const dropTarget = sceneGraph.findObject(gridItem, isDashboardDropTarget);
|
||||
|
||||
if (!dropTarget || !isDashboardDropTarget(dropTarget)) {
|
||||
@@ -129,469 +53,54 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
|
||||
this._sourceDropTarget = dropTarget;
|
||||
this._lastDropTarget = dropTarget;
|
||||
|
||||
// Capture the offset from cursor to item's top-left corner
|
||||
this._captureDragOffset(evt.clientX, evt.clientY, gridItem);
|
||||
|
||||
document.body.addEventListener('pointermove', this._onPointerMove);
|
||||
// Use capture phase to ensure we receive the event even if something calls stopPropagation
|
||||
// (e.g., tab headers call stopPropagation on pointerup)
|
||||
document.body.addEventListener('pointerup', this._stopDraggingSync, true);
|
||||
document.body.addEventListener('pointerup', this._stopDraggingSync);
|
||||
|
||||
const sourceTabKey = this._findParentTabKey(gridItem);
|
||||
this.setState({ draggingGridItem: gridItem.getRef(), sourceTabKey });
|
||||
this.setState({ draggingGridItem: gridItem.getRef() });
|
||||
}
|
||||
|
||||
private _stopDraggingSync(evt: PointerEvent) {
|
||||
private _stopDraggingSync(_evt: PointerEvent) {
|
||||
const gridItem = this.state.draggingGridItem?.resolve();
|
||||
const wasDetached = this._itemDetachedFromSource;
|
||||
// Capture these before cleanup since setTimeout runs after cleanup
|
||||
const sourceDropTarget = this._sourceDropTarget;
|
||||
const lastDropTarget = this._lastDropTarget;
|
||||
const dropPosition = this._currentDropPosition;
|
||||
|
||||
// Check if there's a valid drop target under the mouse
|
||||
// (tab headers and other non-drop areas return null)
|
||||
const validDropTargetUnderMouse = this._getDropTargetUnderMouse(evt);
|
||||
|
||||
// If item was detached (cross-tab drag started) but there's no valid drop target under mouse,
|
||||
// drop into the current tab if lastDropTarget is a TabItem (e.g., dropped on tab header)
|
||||
const noTargetUnderMouse = wasDetached && !validDropTargetUnderMouse && gridItem;
|
||||
const canDropIntoCurrentTab = noTargetUnderMouse && lastDropTarget instanceof TabItem;
|
||||
|
||||
if (canDropIntoCurrentTab) {
|
||||
// Drop into the current tab's layout
|
||||
if (this._sourceDropTarget !== this._lastDropTarget) {
|
||||
// Wrapped in setTimeout to ensure that any event handlers are called
|
||||
// Useful for allowing react-grid-layout to remove placeholders, etc.
|
||||
setTimeout(() => {
|
||||
lastDropTarget.draggedGridItemInside?.(gridItem);
|
||||
// Clean up source grid state
|
||||
if (sourceDropTarget instanceof AutoGridLayoutManager) {
|
||||
sourceDropTarget.state.layout.endExternalDrag();
|
||||
if (gridItem) {
|
||||
// Always use grid item dragging
|
||||
this._sourceDropTarget?.draggedGridItemOutside?.(gridItem);
|
||||
this._lastDropTarget?.draggedGridItemInside?.(gridItem);
|
||||
} else {
|
||||
const warningMessage = 'No grid item to drag';
|
||||
console.warn(warningMessage);
|
||||
logWarning(warningMessage);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const isCrossLayoutDrop = sourceDropTarget !== lastDropTarget || wasDetached;
|
||||
|
||||
// Handle cross-layout or cross-tab drop
|
||||
if (isCrossLayoutDrop) {
|
||||
// Wrapped in setTimeout to ensure that any event handlers are called
|
||||
// Useful for allowing react-grid-layout to remove placeholders, etc.
|
||||
setTimeout(() => {
|
||||
if (gridItem) {
|
||||
// Only remove from source if not already detached during tab switch
|
||||
if (!wasDetached) {
|
||||
sourceDropTarget?.draggedGridItemOutside?.(gridItem);
|
||||
}
|
||||
// Pass drop position for precise placement (AutoGrid uses this)
|
||||
// Note: draggedGridItemInside also clears isDropTarget and dropPosition
|
||||
lastDropTarget?.draggedGridItemInside?.(gridItem, dropPosition ?? undefined);
|
||||
|
||||
// Clean up source grid's drag state (CSS variables and draggingKey) after item is moved.
|
||||
// This is done here (after movement) to prevent flickering where the item
|
||||
// would momentarily appear at wrong position (CSS vars cleared but draggingKey set
|
||||
// = absolute positioning with no valid position values).
|
||||
if (sourceDropTarget instanceof AutoGridLayoutManager) {
|
||||
sourceDropTarget.state.layout.endExternalDrag();
|
||||
}
|
||||
} else {
|
||||
const warningMessage = 'No grid item to drag';
|
||||
console.warn(warningMessage);
|
||||
logWarning(warningMessage);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// For same-layout drops, clear drop position state synchronously
|
||||
this._clearDropPosition();
|
||||
this._lastDropTarget?.setIsDropTarget?.(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.body.removeEventListener('pointermove', this._onPointerMove);
|
||||
document.body.removeEventListener('pointerup', this._stopDraggingSync, true);
|
||||
document.body.removeEventListener('pointerup', this._stopDraggingSync);
|
||||
|
||||
this._clearTabActivationTimer();
|
||||
this._clearDragPreview();
|
||||
|
||||
// Clear internal tracking state (but not the visual state on the target for cross-layout drops)
|
||||
this._currentDropPosition = null;
|
||||
this._lastHoveredAutoGridItemKey = null;
|
||||
this._lastDropTarget = null;
|
||||
this._sourceDropTarget = null;
|
||||
this._itemDetachedFromSource = false;
|
||||
this.setState({ draggingGridItem: undefined, sourceTabKey: undefined, hoverTabKey: undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a row drag starts (from RowsLayoutManagerRenderer)
|
||||
*/
|
||||
public startRowDrag(row: RowItem): void {
|
||||
const sourceTabKey = this._findParentTabKey(row);
|
||||
|
||||
// Store source layout info for removal before tab switch
|
||||
const parent = row.parent;
|
||||
if (parent instanceof RowsLayoutManager) {
|
||||
this._sourceRowsLayout = parent;
|
||||
}
|
||||
|
||||
// Capture row dimensions
|
||||
this._captureRowDimensions(row);
|
||||
|
||||
// Offset will be captured on first pointermove
|
||||
this._rowOffsetCaptured = false;
|
||||
|
||||
this.setState({
|
||||
draggingRow: row.getRef(),
|
||||
sourceTabKey,
|
||||
});
|
||||
|
||||
// Add pointer move listener for tab hover detection during row drag
|
||||
document.body.addEventListener('pointermove', this._onRowDragPointerMove);
|
||||
// Add pointerup listener to handle drop after cross-tab switch
|
||||
// Use capture phase to ensure we receive the event even if something calls stopPropagation
|
||||
document.body.addEventListener('pointerup', this._onRowDragPointerUp, true);
|
||||
}
|
||||
|
||||
private _onRowDragPointerMove = (evt: PointerEvent): void => {
|
||||
// Capture row offset on first move (we don't have cursor position at drag start)
|
||||
if (!this._rowOffsetCaptured) {
|
||||
const row = this.state.draggingRow?.resolve();
|
||||
if (row) {
|
||||
this._captureRowDragOffset(evt.clientX, evt.clientY, row);
|
||||
this._rowOffsetCaptured = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Store cursor position early so it's available for immediate preview on tab switch
|
||||
this._lastCursorX = evt.clientX;
|
||||
this._lastCursorY = evt.clientY;
|
||||
|
||||
this._checkTabHover(evt.clientX, evt.clientY);
|
||||
this._updateDragPreview(evt.clientX, evt.clientY);
|
||||
};
|
||||
|
||||
private _onRowDragPointerUp = (_evt: PointerEvent): void => {
|
||||
// Always clear the tab activation timer on pointerup to prevent
|
||||
// the tab from switching after the user has released the mouse
|
||||
this._clearTabActivationTimer();
|
||||
|
||||
// Handle drop after cross-tab row drag
|
||||
if (this._itemDetachedFromSource) {
|
||||
const row = this.state.draggingRow?.resolve();
|
||||
if (row) {
|
||||
// Find the drop target under cursor and add row to it
|
||||
const dropTarget = this._lastDropTarget ?? this._getDropTargetUnderMouse(_evt);
|
||||
if (dropTarget instanceof TabItem) {
|
||||
dropTarget.acceptDroppedRow?.(row);
|
||||
}
|
||||
}
|
||||
this._finalizeRowDrag();
|
||||
}
|
||||
// If not detached, stopRowDrag from hello-pangea/dnd will handle cleanup
|
||||
};
|
||||
|
||||
/**
|
||||
* Called when a row drag ends (from RowsLayoutManagerRenderer)
|
||||
* This is called by hello-pangea/dnd when drag ends normally (within same layout)
|
||||
* For cross-tab drags, the row is already detached and _onRowDragPointerUp handles the drop
|
||||
*/
|
||||
public stopRowDrag(): void {
|
||||
// If the row was detached (cross-tab drag), don't clean up yet
|
||||
// The pointerup handler will handle cleanup after drop
|
||||
if (this._itemDetachedFromSource) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._finalizeRowDrag();
|
||||
}
|
||||
|
||||
private _finalizeRowDrag(): void {
|
||||
document.body.removeEventListener('pointermove', this._onRowDragPointerMove);
|
||||
document.body.removeEventListener('pointerup', this._onRowDragPointerUp, true);
|
||||
this._clearTabActivationTimer();
|
||||
this._clearDragPreview();
|
||||
this._lastDropTarget?.setIsDropTarget?.(false);
|
||||
this._lastDropTarget = null;
|
||||
this._sourceDropTarget = null;
|
||||
this._itemDetachedFromSource = false;
|
||||
this._sourceRowsLayout = null;
|
||||
this._rowOffsetCaptured = false;
|
||||
this.setState({
|
||||
draggingRow: undefined,
|
||||
sourceTabKey: undefined,
|
||||
hoverTabKey: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private _clearTabActivationTimer(): void {
|
||||
if (this._tabActivationTimer) {
|
||||
clearTimeout(this._tabActivationTimer);
|
||||
this._tabActivationTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private _updateDragPreview(x: number, y: number): void {
|
||||
// Store cursor position for immediate preview on tab switch
|
||||
this._lastCursorX = x;
|
||||
this._lastCursorY = y;
|
||||
|
||||
if (this._itemDetachedFromSource) {
|
||||
this.setState({
|
||||
dragPreview: {
|
||||
x,
|
||||
y,
|
||||
width: this._previewWidth,
|
||||
height: this._previewHeight,
|
||||
offsetX: this._dragOffsetX,
|
||||
offsetY: this._dragOffsetY,
|
||||
label: this._previewLabel,
|
||||
type: this._previewType,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _showDragPreview(): void {
|
||||
// Prevent text selection and set move cursor during cross-tab drag
|
||||
document.body.classList.add('dashboard-draggable-transparent-selection');
|
||||
document.body.classList.add('dragging-active');
|
||||
|
||||
this.setState({
|
||||
dragPreview: {
|
||||
x: this._lastCursorX,
|
||||
y: this._lastCursorY,
|
||||
width: this._previewWidth,
|
||||
height: this._previewHeight,
|
||||
offsetX: this._dragOffsetX,
|
||||
offsetY: this._dragOffsetY,
|
||||
label: this._previewLabel,
|
||||
type: this._previewType,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _captureDragOffset(cursorX: number, cursorY: number, gridItem: SceneGridItemLike): void {
|
||||
// Both DashboardGridItem and AutoGridItem have containerRef
|
||||
if ('containerRef' in gridItem) {
|
||||
const containerRef = gridItem.containerRef;
|
||||
if (
|
||||
containerRef &&
|
||||
typeof containerRef === 'object' &&
|
||||
'current' in containerRef &&
|
||||
containerRef.current instanceof HTMLElement
|
||||
) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
// Offset is cursor position minus item's top-left
|
||||
this._dragOffsetX = cursorX - rect.left;
|
||||
this._dragOffsetY = cursorY - rect.top;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: center the preview on cursor
|
||||
this._dragOffsetX = 0;
|
||||
this._dragOffsetY = 0;
|
||||
}
|
||||
|
||||
private _captureItemDimensions(gridItem: SceneGridItemLike): void {
|
||||
// Both DashboardGridItem and AutoGridItem have containerRef
|
||||
if ('containerRef' in gridItem) {
|
||||
const containerRef = gridItem.containerRef;
|
||||
if (
|
||||
containerRef &&
|
||||
typeof containerRef === 'object' &&
|
||||
'current' in containerRef &&
|
||||
containerRef.current instanceof HTMLElement
|
||||
) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
this._previewWidth = rect.width;
|
||||
this._previewHeight = rect.height;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to reasonable default
|
||||
this._previewWidth = 400;
|
||||
this._previewHeight = 300;
|
||||
}
|
||||
|
||||
private _captureRowDimensions(row: RowItem): void {
|
||||
// Try to find the DOM element for the row using DASHBOARD_DROP_TARGET_KEY_ATTR
|
||||
const element = document.querySelector(`[${DASHBOARD_DROP_TARGET_KEY_ATTR}="${row.state.key}"]`);
|
||||
if (element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
this._previewWidth = rect.width;
|
||||
this._previewHeight = rect.height;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to reasonable default for rows
|
||||
this._previewWidth = 800;
|
||||
this._previewHeight = 48;
|
||||
}
|
||||
|
||||
private _captureRowDragOffset(cursorX: number, cursorY: number, row: RowItem): void {
|
||||
// Try to find the DOM element for the row
|
||||
const element = document.querySelector(`[${DASHBOARD_DROP_TARGET_KEY_ATTR}="${row.state.key}"]`);
|
||||
if (element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
this._dragOffsetX = cursorX - rect.left;
|
||||
this._dragOffsetY = cursorY - rect.top;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: use small offset
|
||||
this._dragOffsetX = 20;
|
||||
this._dragOffsetY = 20;
|
||||
}
|
||||
|
||||
private _clearDragPreview(): void {
|
||||
// Re-enable text selection and reset cursor
|
||||
document.body.classList.remove('dashboard-draggable-transparent-selection');
|
||||
document.body.classList.remove('dragging-active');
|
||||
window.getSelection()?.removeAllRanges();
|
||||
|
||||
if (this.state.dragPreview) {
|
||||
this.setState({ dragPreview: undefined });
|
||||
}
|
||||
}
|
||||
|
||||
private _checkTabHover(clientX: number, clientY: number): void {
|
||||
const tabKey = this._getTabUnderMouse(clientX, clientY);
|
||||
|
||||
if (tabKey !== this._lastHoveredTabKey) {
|
||||
// Cursor moved to a different tab or left all tabs
|
||||
this._clearTabActivationTimer();
|
||||
this._lastHoveredTabKey = tabKey;
|
||||
|
||||
if (tabKey) {
|
||||
// Check if this tab is already active - no need to switch
|
||||
if (this._isTabAlreadyActive(tabKey)) {
|
||||
this.setState({ hoverTabKey: undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
// Start new timer for the new tab
|
||||
this._tabActivationTimer = setTimeout(() => {
|
||||
this._activateTab(tabKey);
|
||||
}, TAB_ACTIVATION_DELAY_MS);
|
||||
|
||||
this.setState({ hoverTabKey: tabKey });
|
||||
} else {
|
||||
this.setState({ hoverTabKey: undefined });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _isTabAlreadyActive(tabKey: string): boolean {
|
||||
const dashboard = this._getDashboard();
|
||||
const tabItem = sceneGraph.findByKey(dashboard, tabKey);
|
||||
|
||||
if (tabItem instanceof TabItem) {
|
||||
const tabsManager = tabItem.getParentLayout();
|
||||
if (tabsManager instanceof TabsLayoutManager) {
|
||||
const currentTab = tabsManager.getCurrentTab();
|
||||
return currentTab === tabItem;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private _activateTab(tabKey: string): void {
|
||||
const dashboard = this._getDashboard();
|
||||
const tabItem = sceneGraph.findByKey(dashboard, tabKey);
|
||||
|
||||
if (tabItem instanceof TabItem) {
|
||||
const tabsManager = tabItem.getParentLayout();
|
||||
if (tabsManager instanceof TabsLayoutManager) {
|
||||
// For grid items: remove from source BEFORE switching tabs
|
||||
// This prevents the item from being unmounted with the source tab
|
||||
const gridItem = this.state.draggingGridItem?.resolve();
|
||||
if (gridItem && this._sourceDropTarget && !this._itemDetachedFromSource) {
|
||||
// Get label and dimensions for preview before detaching
|
||||
this._previewLabel = this._getItemLabel(gridItem);
|
||||
this._previewType = 'panel';
|
||||
this._captureItemDimensions(gridItem);
|
||||
|
||||
this._sourceDropTarget.draggedGridItemOutside?.(gridItem);
|
||||
this._itemDetachedFromSource = true;
|
||||
|
||||
// Show preview immediately using last known cursor position
|
||||
this._showDragPreview();
|
||||
}
|
||||
|
||||
// For rows: remove from source layout and show preview
|
||||
const row = this.state.draggingRow?.resolve();
|
||||
if (row && !this._itemDetachedFromSource && this._sourceRowsLayout) {
|
||||
// Get label for preview (dimensions already captured in startRowDrag)
|
||||
this._previewLabel = row.state.title || 'Row';
|
||||
this._previewType = 'row';
|
||||
|
||||
// Remove row from source layout (skip undo as this is part of drag operation)
|
||||
this._sourceRowsLayout.removeRow(row, true);
|
||||
this._itemDetachedFromSource = true;
|
||||
|
||||
// Show preview immediately
|
||||
this._showDragPreview();
|
||||
}
|
||||
|
||||
tabsManager.switchToTab(tabItem);
|
||||
|
||||
// Update last drop target to the new tab
|
||||
// This ensures drop works even if user releases immediately after tab switch
|
||||
if (isDashboardDropTarget(tabItem)) {
|
||||
this._lastDropTarget = tabItem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _getItemLabel(gridItem: SceneGridItemLike): string {
|
||||
if ('state' in gridItem && 'body' in gridItem.state && gridItem.state.body instanceof VizPanel) {
|
||||
return gridItem.state.body.state.title || 'Panel';
|
||||
}
|
||||
return 'Panel';
|
||||
}
|
||||
|
||||
private _getTabUnderMouse(clientX: number, clientY: number): string | null {
|
||||
const elementsUnderPoint = document.elementsFromPoint(clientX, clientY);
|
||||
|
||||
const tabKey = elementsUnderPoint
|
||||
?.find((element) => element.getAttribute('data-tab-activation-key'))
|
||||
?.getAttribute('data-tab-activation-key');
|
||||
|
||||
return tabKey || null;
|
||||
}
|
||||
|
||||
private _findParentTabKey(item: RowItem | SceneGridItemLike): string | undefined {
|
||||
let parent = item.parent;
|
||||
while (parent) {
|
||||
if (parent instanceof TabItem) {
|
||||
return parent.state.key;
|
||||
}
|
||||
parent = parent.parent;
|
||||
}
|
||||
return undefined;
|
||||
this.setState({ draggingGridItem: undefined });
|
||||
}
|
||||
|
||||
private _onPointerMove(evt: PointerEvent) {
|
||||
// Store cursor position early so it's available for immediate preview on tab switch
|
||||
this._lastCursorX = evt.clientX;
|
||||
this._lastCursorY = evt.clientY;
|
||||
|
||||
// Check for tab hover to enable tab switching during drag
|
||||
this._checkTabHover(evt.clientX, evt.clientY);
|
||||
|
||||
// Update drag preview position if item is detached
|
||||
this._updateDragPreview(evt.clientX, evt.clientY);
|
||||
if (!this._isSelectedObject && this.state.draggingGridItem && this._pointerDistance.check(evt)) {
|
||||
this._isSelectedObject = true;
|
||||
const gridItem = this.state.draggingGridItem?.resolve();
|
||||
if (gridItem && 'state' in gridItem && 'body' in gridItem.state && gridItem.state.body instanceof VizPanel) {
|
||||
const panel = gridItem.state.body;
|
||||
this._getDashboard().state.editPane.selectObject(panel, panel.state.key!, { force: true, multi: false });
|
||||
}
|
||||
}
|
||||
|
||||
const dropTarget = this._getDropTargetUnderMouse(evt) ?? this._sourceDropTarget;
|
||||
|
||||
if (!dropTarget) {
|
||||
this._clearDropPosition();
|
||||
return;
|
||||
}
|
||||
|
||||
if (dropTarget !== this._lastDropTarget) {
|
||||
// Clear drop position from previous target
|
||||
this._clearDropPosition();
|
||||
this._lastDropTarget?.setIsDropTarget?.(false);
|
||||
this._lastDropTarget = dropTarget;
|
||||
|
||||
@@ -599,76 +108,6 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
|
||||
dropTarget.setIsDropTarget?.(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Update drop position for AutoGrid targets
|
||||
this._updateDropPosition(evt.clientX, evt.clientY, dropTarget);
|
||||
}
|
||||
|
||||
private _updateDropPosition(clientX: number, clientY: number, dropTarget: DashboardDropTarget): void {
|
||||
// Only update position for AutoGridLayoutManager targets
|
||||
if (!(dropTarget instanceof AutoGridLayoutManager)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't show external placeholder when dragging within the same grid
|
||||
// (AutoGrid has its own internal drag placeholder)
|
||||
if (dropTarget === this._sourceDropTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find which AutoGridItem we're hovering over
|
||||
const elementsUnderPoint = document.elementsFromPoint(clientX, clientY);
|
||||
const targetElement = elementsUnderPoint?.find((el) => el.getAttribute(AUTO_GRID_ITEM_DROP_TARGET_ATTR));
|
||||
const targetKey = targetElement?.getAttribute(AUTO_GRID_ITEM_DROP_TARGET_ATTR);
|
||||
|
||||
const children = dropTarget.state.layout.state.children;
|
||||
|
||||
// If not hovering over any item
|
||||
if (!targetKey || !targetElement) {
|
||||
// Only set initial position when first entering the grid
|
||||
if (this._currentDropPosition === null) {
|
||||
this._currentDropPosition = children.length;
|
||||
dropTarget.setDropPosition?.(children.length);
|
||||
}
|
||||
// Otherwise keep the current position (prevents flickering when over placeholder)
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if we should insert before or after the hovered item
|
||||
// by checking if cursor is in left half or right half
|
||||
const rect = targetElement.getBoundingClientRect();
|
||||
const isRightHalf = clientX > rect.left + rect.width / 2;
|
||||
|
||||
// Create a composite key that includes both item key and side
|
||||
const compositeKey = `${targetKey}-${isRightHalf ? 'after' : 'before'}`;
|
||||
|
||||
// Only update if we're hovering over a different position than before
|
||||
// This prevents flickering when the placeholder shifts items around
|
||||
if (compositeKey === this._lastHoveredAutoGridItemKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._lastHoveredAutoGridItemKey = compositeKey;
|
||||
|
||||
// Find the index of the hovered item
|
||||
const hoveredIndex = children.findIndex((child) => child.state.key === targetKey);
|
||||
if (hoveredIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert after if in right half, before if in left half
|
||||
const newPosition = isRightHalf ? hoveredIndex + 1 : hoveredIndex;
|
||||
|
||||
this._currentDropPosition = newPosition;
|
||||
dropTarget.setDropPosition?.(newPosition);
|
||||
}
|
||||
|
||||
private _clearDropPosition(): void {
|
||||
if (this._currentDropPosition !== null && this._lastDropTarget) {
|
||||
this._lastDropTarget.setDropPosition?.(null);
|
||||
this._currentDropPosition = null;
|
||||
}
|
||||
this._lastHoveredAutoGridItemKey = null;
|
||||
}
|
||||
|
||||
private _getDashboard(): DashboardScene {
|
||||
@@ -682,7 +121,7 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
|
||||
private _getDropTargetUnderMouse(evt: MouseEvent): DashboardDropTarget | null {
|
||||
const elementsUnderPoint = document.elementsFromPoint(evt.clientX, evt.clientY);
|
||||
const cursorIsInSourceTarget = elementsUnderPoint.some(
|
||||
(el) => el.getAttribute(DASHBOARD_DROP_TARGET_KEY_ATTR) === this._sourceDropTarget?.state.key
|
||||
(el) => el.getAttribute('data-dashboard-drop-target-key') === this._sourceDropTarget?.state.key
|
||||
);
|
||||
|
||||
if (cursorIsInSourceTarget) {
|
||||
@@ -690,8 +129,8 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
|
||||
}
|
||||
|
||||
const key = elementsUnderPoint
|
||||
?.find((element) => element.getAttribute(DASHBOARD_DROP_TARGET_KEY_ATTR))
|
||||
?.getAttribute(DASHBOARD_DROP_TARGET_KEY_ATTR);
|
||||
?.find((element) => element.getAttribute('data-dashboard-drop-target-key'))
|
||||
?.getAttribute('data-dashboard-drop-target-key');
|
||||
|
||||
if (!key) {
|
||||
return null;
|
||||
@@ -706,61 +145,3 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
|
||||
return sceneObject;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a floating drag preview when an item is detached during cross-tab drag
|
||||
*/
|
||||
function DragPreviewRenderer({ model }: SceneComponentProps<DashboardLayoutOrchestrator>) {
|
||||
const { dragPreview } = model.useState();
|
||||
const styles = useStyles2(getPreviewStyles);
|
||||
|
||||
if (!dragPreview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Position preview so cursor maintains same relative position as when drag started
|
||||
const previewLeft = dragPreview.x - dragPreview.offsetX;
|
||||
const previewTop = dragPreview.y - dragPreview.offsetY;
|
||||
|
||||
const preview = (
|
||||
<div
|
||||
className={styles.preview}
|
||||
style={{
|
||||
left: previewLeft,
|
||||
top: previewTop,
|
||||
width: dragPreview.width,
|
||||
height: dragPreview.height,
|
||||
}}
|
||||
>
|
||||
<span className={styles.label}>{dragPreview.label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(preview, document.body);
|
||||
}
|
||||
|
||||
const getPreviewStyles = (theme: GrafanaTheme2) => ({
|
||||
preview: css({
|
||||
position: 'fixed',
|
||||
background: theme.colors.background.primary,
|
||||
border: `1px dashed ${theme.colors.primary.main}`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
boxShadow: theme.shadows.z3,
|
||||
pointerEvents: 'none',
|
||||
zIndex: theme.zIndex.tooltip,
|
||||
overflow: 'hidden',
|
||||
opacity: 0.9,
|
||||
}),
|
||||
label: css({
|
||||
// Match panel header styling
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: theme.spacing(theme.components.panel.headerHeight),
|
||||
padding: theme.spacing(0.5, 1, 0, 1.5),
|
||||
color: theme.colors.text.primary,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
...theme.typography.h6,
|
||||
}),
|
||||
});
|
||||
|
||||
+7
-15
@@ -2,19 +2,17 @@ import { css, cx } from '@emotion/css';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { LazyLoader, sceneGraph, SceneComponentProps, VizPanel } from '@grafana/scenes';
|
||||
import { LazyLoader, SceneComponentProps, VizPanel } from '@grafana/scenes';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup';
|
||||
import { useIsConditionallyHidden } from '../../conditional-rendering/hooks/useIsConditionallyHidden';
|
||||
import { useDashboardState } from '../../utils/utils';
|
||||
import { SoloPanelContextValueWithSearchStringFilter } from '../PanelSearchLayout';
|
||||
import { useSoloPanelContext, renderMatchingSoloPanels } from '../SoloPanelContext';
|
||||
import { renderMatchingSoloPanels, useSoloPanelContext } from '../SoloPanelContext';
|
||||
import { getIsLazy } from '../layouts-shared/utils';
|
||||
import { AUTO_GRID_ITEM_DROP_TARGET_ATTR } from '../types/DashboardDropTarget';
|
||||
|
||||
import { AutoGridItem } from './AutoGridItem';
|
||||
import { AutoGridLayoutManager } from './AutoGridLayoutManager';
|
||||
import { DRAGGED_ITEM_HEIGHT, DRAGGED_ITEM_LEFT, DRAGGED_ITEM_TOP, DRAGGED_ITEM_WIDTH } from './const';
|
||||
|
||||
export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem>) {
|
||||
@@ -25,10 +23,6 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
|
||||
const soloPanelContext = useSoloPanelContext();
|
||||
const isLazy = useMemo(() => getIsLazy(preload), [preload]);
|
||||
|
||||
// Check if this grid is a drop target for external drags
|
||||
const layoutManager = sceneGraph.getAncestor(model, AutoGridLayoutManager);
|
||||
const { isDropTarget } = layoutManager.useState();
|
||||
|
||||
const Wrapper = useMemo(
|
||||
() =>
|
||||
// eslint-disable-next-line react/display-name
|
||||
@@ -38,14 +32,14 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
|
||||
conditionalRendering,
|
||||
addDndContainer,
|
||||
isDragged,
|
||||
showDropTarget,
|
||||
isDragging,
|
||||
isRepeat = false,
|
||||
}: {
|
||||
item: VizPanel;
|
||||
conditionalRendering?: ConditionalRenderingGroup;
|
||||
addDndContainer: boolean;
|
||||
isDragged: boolean;
|
||||
showDropTarget: boolean;
|
||||
isDragging: boolean;
|
||||
isRepeat?: boolean;
|
||||
}) => {
|
||||
const [isConditionallyHidden, conditionalRenderingClass, conditionalRenderingOverlay, renderHidden] =
|
||||
@@ -54,7 +48,7 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
|
||||
return isConditionallyHidden && !isEditing && !renderHidden ? null : (
|
||||
<div
|
||||
{...(addDndContainer
|
||||
? { ref: model.containerRef, [AUTO_GRID_ITEM_DROP_TARGET_ATTR]: showDropTarget ? key : undefined }
|
||||
? { ref: model.containerRef, ['data-auto-grid-item-drop-target']: isDragging ? key : undefined }
|
||||
: {})}
|
||||
className={cx(isConditionallyHidden && !isEditing && styles.hidden)}
|
||||
>
|
||||
@@ -105,8 +99,6 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
|
||||
|
||||
const isDragging = !!draggingKey;
|
||||
const isDragged = draggingKey === key;
|
||||
// Show drop target attribute for both internal drags and external drags (when this grid is a drop target)
|
||||
const showDropTarget = isDragging || !!isDropTarget;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -116,7 +108,7 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
|
||||
addDndContainer={true}
|
||||
key={body.state.key!}
|
||||
isDragged={isDragged}
|
||||
showDropTarget={showDropTarget}
|
||||
isDragging={isDragging}
|
||||
/>
|
||||
{repeatedPanels.map((item, idx) => (
|
||||
<Wrapper
|
||||
@@ -125,7 +117,7 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
|
||||
addDndContainer={false}
|
||||
key={item.state.key!}
|
||||
isDragged={isDragged}
|
||||
showDropTarget={showDropTarget}
|
||||
isDragging={isDragging}
|
||||
isRepeat={true}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { SceneLayout, SceneObjectBase, SceneObjectState, VizPanel, SceneGridItem
|
||||
|
||||
import { isRepeatCloneOrChildOf } from '../../utils/clone';
|
||||
import { getLayoutOrchestratorFor } from '../../utils/utils';
|
||||
import { AUTO_GRID_ITEM_DROP_TARGET_ATTR } from '../types/DashboardDropTarget';
|
||||
|
||||
import { AutoGridItem } from './AutoGridItem';
|
||||
import { AutoGridLayoutRenderer } from './AutoGridLayoutRenderer';
|
||||
@@ -66,8 +65,6 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
|
||||
top: number;
|
||||
left: number;
|
||||
} | null = null;
|
||||
/** Container's initial page position, used to compensate for layout shifts during drag */
|
||||
private _initialContainerRect: { top: number; left: number } | null = null;
|
||||
private _lastDropTargetGridItemKey: string | null = null;
|
||||
|
||||
public constructor(state: Partial<AutoGridLayoutState>) {
|
||||
@@ -153,12 +150,6 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
|
||||
|
||||
const { top, left, width, height } = this._draggedGridItem.getBoundingBox();
|
||||
this._initialGridItemPosition = { pageX: evt.pageX, pageY: evt.pageY, top, left: left };
|
||||
|
||||
// Capture container's initial page position to compensate for layout shifts
|
||||
// (e.g., when a grid above expands due to placeholder insertion)
|
||||
const containerRect = this.containerRef.current?.getBoundingClientRect();
|
||||
this._initialContainerRect = containerRect ? { top: containerRect.top, left: containerRect.left } : null;
|
||||
|
||||
this._updatePanelSize(width, height);
|
||||
this._updatePanelPosition(top, left);
|
||||
|
||||
@@ -177,33 +168,16 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
|
||||
|
||||
this._draggedGridItem = null;
|
||||
this._initialGridItemPosition = null;
|
||||
this._initialContainerRect = null;
|
||||
this._lastDropTargetGridItemKey = null;
|
||||
this._resetPanelPositionAndSize();
|
||||
|
||||
// Only reset position/size and clear draggingKey if not dropping to a different layout.
|
||||
// For cross-grid drops, the orchestrator will call endExternalDrag() after the item is moved
|
||||
// to prevent flickering where the item would momentarily appear at wrong position
|
||||
// (CSS vars cleared but draggingKey still set = absolute positioning with no position).
|
||||
const orchestrator = getLayoutOrchestratorFor(this);
|
||||
if (!orchestrator?.isDroppedElsewhere()) {
|
||||
this._resetPanelPositionAndSize();
|
||||
this.setState({ draggingKey: undefined });
|
||||
}
|
||||
this.setState({ draggingKey: undefined });
|
||||
|
||||
document.body.removeEventListener('pointermove', this._onDrag);
|
||||
document.body.removeEventListener('pointerup', this._onDragEnd);
|
||||
document.body.classList.remove('dashboard-draggable-transparent-selection');
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the orchestrator after a cross-layout drag ends and the item has been moved.
|
||||
* Cleans up the drag state that was preserved during the cross-layout drop.
|
||||
*/
|
||||
public endExternalDrag(): void {
|
||||
this._resetPanelPositionAndSize();
|
||||
this.setState({ draggingKey: undefined });
|
||||
}
|
||||
|
||||
// Handle inside drag moves
|
||||
private _onDrag(evt: PointerEvent) {
|
||||
if (!this._draggedGridItem || !this._initialGridItemPosition) {
|
||||
@@ -211,30 +185,19 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate how much the container has shifted since drag started
|
||||
// This can happen when a grid above expands (e.g., placeholder causes row wrap)
|
||||
let containerShiftY = 0;
|
||||
let containerShiftX = 0;
|
||||
if (this._initialContainerRect && this.containerRef.current) {
|
||||
const currentRect = this.containerRef.current.getBoundingClientRect();
|
||||
containerShiftY = currentRect.top - this._initialContainerRect.top;
|
||||
containerShiftX = currentRect.left - this._initialContainerRect.left;
|
||||
}
|
||||
|
||||
// Adjust position to compensate for container movement
|
||||
this._updatePanelPosition(
|
||||
this._initialGridItemPosition.top + (evt.pageY - this._initialGridItemPosition.pageY) - containerShiftY,
|
||||
this._initialGridItemPosition.left + (evt.pageX - this._initialGridItemPosition.pageX) - containerShiftX
|
||||
this._initialGridItemPosition.top + (evt.pageY - this._initialGridItemPosition.pageY),
|
||||
this._initialGridItemPosition.left + (evt.pageX - this._initialGridItemPosition.pageX)
|
||||
);
|
||||
|
||||
const dropTargetGridItemKey = document
|
||||
.elementsFromPoint(evt.clientX, evt.clientY)
|
||||
?.find((element) => {
|
||||
const key = element.getAttribute(AUTO_GRID_ITEM_DROP_TARGET_ATTR);
|
||||
const key = element.getAttribute('data-auto-grid-item-drop-target');
|
||||
|
||||
return !!key && key !== this._draggedGridItem!.state.key;
|
||||
})
|
||||
?.getAttribute(AUTO_GRID_ITEM_DROP_TARGET_ATTR);
|
||||
?.getAttribute('data-auto-grid-item-drop-target');
|
||||
|
||||
if (dropTargetGridItemKey && dropTargetGridItemKey !== this._lastDropTargetGridItemKey) {
|
||||
this._onDragOverItem(dropTargetGridItemKey);
|
||||
|
||||
+1
-62
@@ -23,7 +23,6 @@ import {
|
||||
} from '../../utils/utils';
|
||||
import { DashboardGridItem } from '../layout-default/DashboardGridItem';
|
||||
import { clearClipboard, getAutoGridItemFromClipboard } from '../layouts-shared/paste';
|
||||
import { DashboardDropTarget } from '../types/DashboardDropTarget';
|
||||
import { DashboardLayoutGrid } from '../types/DashboardLayoutGrid';
|
||||
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
|
||||
import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
|
||||
@@ -38,10 +37,6 @@ interface AutoGridLayoutManagerState extends SceneObjectState {
|
||||
rowHeight: AutoGridRowHeight;
|
||||
columnWidth: AutoGridColumnWidth;
|
||||
fillScreen: boolean;
|
||||
/** Whether this grid is currently a drop target */
|
||||
isDropTarget?: boolean;
|
||||
/** Position index where a placeholder should be shown for external drops */
|
||||
dropPosition?: number | null;
|
||||
}
|
||||
|
||||
export type AutoGridColumnWidth = 'narrow' | 'standard' | 'wide' | 'custom' | number;
|
||||
@@ -51,14 +46,10 @@ export const AUTO_GRID_DEFAULT_MAX_COLUMN_COUNT = 3;
|
||||
export const AUTO_GRID_DEFAULT_COLUMN_WIDTH = 'standard';
|
||||
export const AUTO_GRID_DEFAULT_ROW_HEIGHT = 'standard';
|
||||
|
||||
export class AutoGridLayoutManager
|
||||
extends SceneObjectBase<AutoGridLayoutManagerState>
|
||||
implements DashboardLayoutGrid, DashboardDropTarget
|
||||
{
|
||||
export class AutoGridLayoutManager extends SceneObjectBase<AutoGridLayoutManagerState> implements DashboardLayoutGrid {
|
||||
public static Component = AutoGridLayoutManagerRenderer;
|
||||
|
||||
public readonly isDashboardLayoutManager = true;
|
||||
public readonly isDashboardDropTarget = true as const;
|
||||
|
||||
public static readonly descriptor: LayoutRegistryItem = {
|
||||
get name() {
|
||||
@@ -368,58 +359,6 @@ export class AutoGridLayoutManager
|
||||
|
||||
this.state.layout.setState({ children: [...this.state.layout.state.children, gridItem] });
|
||||
}
|
||||
|
||||
public setIsDropTarget(isDropTarget: boolean): void {
|
||||
this.setState({ isDropTarget });
|
||||
}
|
||||
|
||||
public setDropPosition(position: number | null): void {
|
||||
this.setState({ dropPosition: position });
|
||||
}
|
||||
|
||||
public draggedGridItemOutside(gridItem: SceneGridItemLike): void {
|
||||
if (gridItem instanceof AutoGridItem) {
|
||||
this.state.layout.setState({
|
||||
children: this.state.layout.state.children.filter((child) => child !== gridItem),
|
||||
});
|
||||
}
|
||||
this.setState({ isDropTarget: false });
|
||||
}
|
||||
|
||||
public draggedGridItemInside(gridItem: SceneGridItemLike, position?: number): void {
|
||||
let newGridItem: AutoGridItem;
|
||||
|
||||
if (gridItem instanceof AutoGridItem) {
|
||||
gridItem.clearParent();
|
||||
newGridItem = gridItem;
|
||||
} else if (gridItem instanceof DashboardGridItem) {
|
||||
if (!(gridItem.state.body instanceof VizPanel)) {
|
||||
throw new Error('DashboardGridItem body is not a VizPanel');
|
||||
}
|
||||
const panel = gridItem.state.body;
|
||||
panel.clearParent();
|
||||
|
||||
newGridItem = new AutoGridItem({
|
||||
body: panel,
|
||||
variableName: gridItem.state.variableName,
|
||||
});
|
||||
} else {
|
||||
throw new Error('Grid item must be an AutoGridItem or DashboardGridItem');
|
||||
}
|
||||
|
||||
const children = [...this.state.layout.state.children];
|
||||
|
||||
if (position !== undefined && position >= 0 && position <= children.length) {
|
||||
// Insert at specific position
|
||||
children.splice(position, 0, newGridItem);
|
||||
} else {
|
||||
// Append to end
|
||||
children.push(newGridItem);
|
||||
}
|
||||
|
||||
this.state.layout.setState({ children });
|
||||
this.setState({ isDropTarget: false, dropPosition: null });
|
||||
}
|
||||
}
|
||||
|
||||
function AutoGridLayoutManagerRenderer({ model }: SceneComponentProps<AutoGridLayoutManager>) {
|
||||
|
||||
+4
-36
@@ -9,7 +9,6 @@ import { useDashboardState } from '../../utils/utils';
|
||||
import { useSoloPanelContext } from '../SoloPanelContext';
|
||||
import { CanvasGridAddActions } from '../layouts-shared/CanvasGridAddActions';
|
||||
import { dashboardCanvasAddButtonHoverStyles } from '../layouts-shared/styles';
|
||||
import { DASHBOARD_DROP_TARGET_KEY_ATTR } from '../types/DashboardDropTarget';
|
||||
|
||||
import { AutoGridLayout, AutoGridLayoutState } from './AutoGridLayout';
|
||||
import { AutoGridLayoutManager } from './AutoGridLayoutManager';
|
||||
@@ -19,7 +18,7 @@ export function AutoGridLayoutRenderer({ model }: SceneComponentProps<AutoGridLa
|
||||
const styles = useStyles2(getStyles, model.state);
|
||||
const { layoutOrchestrator, isEditing } = useDashboardState(model);
|
||||
const layoutManager = sceneGraph.getAncestor(model, AutoGridLayoutManager);
|
||||
const { fillScreen, dropPosition } = layoutManager.useState();
|
||||
const { fillScreen } = layoutManager.useState();
|
||||
const soloPanelContext = useSoloPanelContext();
|
||||
|
||||
if (isHidden || !layoutOrchestrator) {
|
||||
@@ -32,44 +31,19 @@ export function AutoGridLayoutRenderer({ model }: SceneComponentProps<AutoGridLa
|
||||
return children.map((item) => <item.Component key={item.state.key} model={item} />);
|
||||
}
|
||||
|
||||
// Build children with placeholder inserted at dropPosition
|
||||
const renderChildren = () => {
|
||||
if (dropPosition === null || dropPosition === undefined) {
|
||||
return children.map((item) => <item.Component key={item.state.key} model={item} />);
|
||||
}
|
||||
|
||||
const result: React.ReactNode[] = [];
|
||||
const insertPosition = Math.min(dropPosition, children.length);
|
||||
|
||||
for (let i = 0; i <= children.length; i++) {
|
||||
if (i === insertPosition) {
|
||||
result.push(<DropPlaceholder key="drop-placeholder" styles={styles} />);
|
||||
}
|
||||
if (i < children.length) {
|
||||
const item = children[i];
|
||||
result.push(<item.Component key={item.state.key} model={item} />);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.container, fillScreen && styles.containerFillScreen, isEditing && styles.containerEditing)}
|
||||
ref={model.containerRef}
|
||||
{...{ [DASHBOARD_DROP_TARGET_KEY_ATTR]: layoutManager.state.key }}
|
||||
>
|
||||
{renderChildren()}
|
||||
{children.map((item) => (
|
||||
<item.Component key={item.state.key} model={item} />
|
||||
))}
|
||||
{showCanvasActions && <CanvasGridAddActions layoutManager={layoutManager} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DropPlaceholder({ styles }: { styles: ReturnType<typeof getStyles> }) {
|
||||
return <div className={styles.dropPlaceholder} />;
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, state: AutoGridLayoutState) => ({
|
||||
container: css({
|
||||
display: 'grid',
|
||||
@@ -98,10 +72,4 @@ const getStyles = (theme: GrafanaTheme2, state: AutoGridLayoutState) => ({
|
||||
}),
|
||||
containerFillScreen: css({ flexGrow: 1 }),
|
||||
containerEditing: css({ paddingBottom: theme.spacing(5), position: 'relative' }),
|
||||
dropPlaceholder: css({
|
||||
border: `1px dashed ${theme.colors.primary.main}`,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
backgroundColor: theme.colors.primary.transparent,
|
||||
minHeight: '100px',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -193,23 +193,8 @@ export class RowItem
|
||||
if (gridItem instanceof DashboardGridItem || gridItem instanceof AutoGridItem) {
|
||||
const layout = gridItem.parent;
|
||||
if (gridItem instanceof DashboardGridItem && layout instanceof SceneGridLayout) {
|
||||
// Toggle isDraggable off to force react-grid-layout to exit drag mode
|
||||
// This clears react-grid-layout's internal drag state
|
||||
// This is a workaround until we upgrade to react-grid-layout 2.x.x
|
||||
const wasDraggable = layout.state.isDraggable;
|
||||
if (wasDraggable) {
|
||||
layout.setState({ isDraggable: false });
|
||||
}
|
||||
|
||||
const newChildren = layout.state.children.filter((child) => child !== gridItem);
|
||||
layout.setState({ children: newChildren });
|
||||
|
||||
// Restore isDraggable after a microtask to ensure react-grid-layout processes the change
|
||||
if (wasDraggable) {
|
||||
queueMicrotask(() => {
|
||||
layout.setState({ isDraggable: true });
|
||||
});
|
||||
}
|
||||
} else if (gridItem instanceof AutoGridItem && layout instanceof AutoGridLayout) {
|
||||
const newChildren = layout.state.children.filter((child) => child !== gridItem);
|
||||
layout.setState({ children: newChildren });
|
||||
|
||||
@@ -13,7 +13,6 @@ import { isRepeatCloneOrChildOf } from '../../utils/clone';
|
||||
import { useDashboardState, useInterpolatedTitle } from '../../utils/utils';
|
||||
import { DashboardScene } from '../DashboardScene';
|
||||
import { useSoloPanelContext } from '../SoloPanelContext';
|
||||
import { DASHBOARD_DROP_TARGET_KEY_ATTR } from '../types/DashboardDropTarget';
|
||||
import { isDashboardLayoutGrid } from '../types/DashboardLayoutGrid';
|
||||
|
||||
import { RowItem } from './RowItem';
|
||||
@@ -86,7 +85,7 @@ export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
|
||||
dragProvided.innerRef(ref);
|
||||
model.containerRef.current = ref;
|
||||
}}
|
||||
{...{ [DASHBOARD_DROP_TARGET_KEY_ATTR]: isDashboardLayoutGrid(layout) ? model.state.key : undefined }}
|
||||
data-dashboard-drop-target-key={isDashboardLayoutGrid(layout) ? model.state.key : undefined}
|
||||
className={cx(
|
||||
styles.wrapper,
|
||||
!isCollapsed && styles.wrapperNotCollapsed,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { t } from '@grafana/i18n';
|
||||
import {
|
||||
sceneGraph,
|
||||
SceneGridItemLike,
|
||||
SceneGridLayout,
|
||||
SceneGridRow,
|
||||
SceneObject,
|
||||
SceneObjectBase,
|
||||
@@ -14,7 +13,6 @@ import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboa
|
||||
import { dashboardEditActions, ObjectsReorderedOnCanvasEvent } from '../../edit-pane/shared';
|
||||
import { serializeRowsLayout } from '../../serialization/layoutSerializers/RowsLayoutSerializer';
|
||||
import { getDashboardSceneFor } from '../../utils/utils';
|
||||
import { AutoGridItem } from '../layout-auto-grid/AutoGridItem';
|
||||
import { AutoGridLayoutManager } from '../layout-auto-grid/AutoGridLayoutManager';
|
||||
import { DashboardGridItem } from '../layout-default/DashboardGridItem';
|
||||
import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
|
||||
@@ -24,7 +22,6 @@ import { findAllGridTypes } from '../layouts-shared/findAllGridTypes';
|
||||
import { getRowFromClipboard } from '../layouts-shared/paste';
|
||||
import { showConvertMixedGridsModal, showUngroupConfirmation } from '../layouts-shared/ungroupConfirmation';
|
||||
import { generateUniqueTitle, ungroupLayout, GridLayoutType, mapIdToGridLayoutType } from '../layouts-shared/utils';
|
||||
import { DashboardDropTarget } from '../types/DashboardDropTarget';
|
||||
import { isDashboardLayoutGrid } from '../types/DashboardLayoutGrid';
|
||||
import { DashboardLayoutGroup, isDashboardLayoutGroup } from '../types/DashboardLayoutGroup';
|
||||
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
|
||||
@@ -36,16 +33,11 @@ import { RowLayoutManagerRenderer } from './RowsLayoutManagerRenderer';
|
||||
|
||||
interface RowsLayoutManagerState extends SceneObjectState {
|
||||
rows: RowItem[];
|
||||
isDropTarget?: boolean;
|
||||
}
|
||||
|
||||
export class RowsLayoutManager
|
||||
extends SceneObjectBase<RowsLayoutManagerState>
|
||||
implements DashboardLayoutGroup, DashboardDropTarget
|
||||
{
|
||||
export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> implements DashboardLayoutGroup {
|
||||
public static Component = RowLayoutManagerRenderer;
|
||||
public readonly isDashboardLayoutManager = true;
|
||||
public readonly isDashboardDropTarget = true as const;
|
||||
|
||||
public static readonly descriptor: LayoutRegistryItem = {
|
||||
get name() {
|
||||
@@ -70,38 +62,6 @@ export class RowsLayoutManager
|
||||
this.state.rows[0]?.getLayout().addPanel(vizPanel);
|
||||
}
|
||||
|
||||
public setIsDropTarget(isDropTarget: boolean): void {
|
||||
this.setState({ isDropTarget });
|
||||
}
|
||||
|
||||
public draggedGridItemInside(gridItem: SceneGridItemLike): void {
|
||||
// Create a new row with a DefaultGridLayoutManager and add the grid item to it
|
||||
const newLayout = new DefaultGridLayoutManager({
|
||||
grid: new SceneGridLayout({ children: [], isDraggable: true, isResizable: true }),
|
||||
});
|
||||
const newRow = new RowItem({
|
||||
title: t('dashboard.rows-layout.new-row-title', 'New row'),
|
||||
layout: newLayout,
|
||||
collapse: false,
|
||||
});
|
||||
|
||||
// Convert AutoGridItem to DashboardGridItem if needed
|
||||
if (gridItem instanceof AutoGridItem) {
|
||||
const vizPanel = gridItem.state.body;
|
||||
const newGridItem = new DashboardGridItem({
|
||||
body: vizPanel.clone(),
|
||||
width: 12,
|
||||
height: 8,
|
||||
});
|
||||
newLayout.addGridItem(newGridItem);
|
||||
} else if (gridItem instanceof DashboardGridItem) {
|
||||
newLayout.addGridItem(gridItem);
|
||||
}
|
||||
|
||||
// Add the row to this layout
|
||||
this.setState({ rows: [...this.state.rows, newRow], isDropTarget: false });
|
||||
}
|
||||
|
||||
public getVizPanels(): VizPanel[] {
|
||||
const panels: VizPanel[] = [];
|
||||
|
||||
|
||||
+14
-44
@@ -1,6 +1,5 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { DragDropContext, Droppable, BeforeCapture, DropResult } from '@hello-pangea/dnd';
|
||||
import { useCallback } from 'react';
|
||||
import { DragDropContext, Droppable } from '@hello-pangea/dnd';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
@@ -9,10 +8,9 @@ import { MultiValueVariable, SceneComponentProps, sceneGraph, useSceneObjectStat
|
||||
import { Button, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { isRepeatCloneOrChildOf } from '../../utils/clone';
|
||||
import { useDashboardState, getLayoutOrchestratorFor } from '../../utils/utils';
|
||||
import { useDashboardState } from '../../utils/utils';
|
||||
import { useSoloPanelContext } from '../SoloPanelContext';
|
||||
import { useClipboardState } from '../layouts-shared/useClipboardState';
|
||||
import { DASHBOARD_DROP_TARGET_KEY_ATTR } from '../types/DashboardDropTarget';
|
||||
|
||||
import { RowItem } from './RowItem';
|
||||
import { RowItemRepeater } from './RowItemRepeater';
|
||||
@@ -24,38 +22,6 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayo
|
||||
const styles = useStyles2(getStyles);
|
||||
const { hasCopiedRow } = useClipboardState();
|
||||
const soloPanelContext = useSoloPanelContext();
|
||||
const orchestrator = getLayoutOrchestratorFor(model);
|
||||
|
||||
// Only act as a drop target when empty (no rows)
|
||||
const showAsDropTarget = rows.length === 0;
|
||||
|
||||
const handleBeforeCapture = useCallback(
|
||||
(before: BeforeCapture) => {
|
||||
const row = rows.find((r) => r.state.key === before.draggableId);
|
||||
if (row && orchestrator) {
|
||||
orchestrator.startRowDrag(row);
|
||||
}
|
||||
},
|
||||
[rows, orchestrator]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(result: DropResult) => {
|
||||
// Stop tracking row drag in orchestrator
|
||||
orchestrator?.stopRowDrag();
|
||||
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.destination.index === result.source.index) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.moveRow(result.draggableId, result.source.index, result.destination.index);
|
||||
},
|
||||
[model, orchestrator]
|
||||
);
|
||||
|
||||
if (soloPanelContext) {
|
||||
return rows.map((row) => <RowWrapper row={row} manager={model} key={row.state.key!} />);
|
||||
@@ -65,18 +31,22 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayo
|
||||
|
||||
return (
|
||||
<DragDropContext
|
||||
onBeforeCapture={handleBeforeCapture}
|
||||
onBeforeDragStart={(start) => model.forceSelectRow(start.draggableId)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragEnd={(result) => {
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.destination.index === result.source.index) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.moveRow(result.draggableId, result.source.index, result.destination.index);
|
||||
}}
|
||||
>
|
||||
<Droppable droppableId={key!} direction="vertical">
|
||||
{(dropProvided) => (
|
||||
<div
|
||||
className={styles.wrapper}
|
||||
ref={dropProvided.innerRef}
|
||||
{...dropProvided.droppableProps}
|
||||
{...(showAsDropTarget ? { [DASHBOARD_DROP_TARGET_KEY_ATTR]: key } : {})}
|
||||
>
|
||||
<div className={styles.wrapper} ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
|
||||
{rows.map((row) => (
|
||||
<RowWrapper row={row} manager={model} key={row.state.key!} />
|
||||
))}
|
||||
|
||||
@@ -27,8 +27,6 @@ import { AutoGridItem } from '../layout-auto-grid/AutoGridItem';
|
||||
import { AutoGridLayout } from '../layout-auto-grid/AutoGridLayout';
|
||||
import { AutoGridLayoutManager } from '../layout-auto-grid/AutoGridLayoutManager';
|
||||
import { DashboardGridItem } from '../layout-default/DashboardGridItem';
|
||||
import { RowItem } from '../layout-rows/RowItem';
|
||||
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
|
||||
import { clearClipboard } from '../layouts-shared/paste';
|
||||
import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView';
|
||||
import { BulkActionElement } from '../types/BulkActionElement';
|
||||
@@ -232,19 +230,6 @@ export class TabItem
|
||||
|
||||
if (isDashboardLayoutGrid(layout)) {
|
||||
layout.addGridItem(gridItem);
|
||||
} else if (layout instanceof RowsLayoutManager) {
|
||||
// For RowsLayoutManager, add to the first row's layout
|
||||
const firstRow = layout.state.rows[0];
|
||||
if (firstRow) {
|
||||
const rowLayout = firstRow.getLayout();
|
||||
if (isDashboardLayoutGrid(rowLayout)) {
|
||||
rowLayout.addGridItem(gridItem);
|
||||
} else {
|
||||
const warningMessage = 'First row layout does not support addGridItem';
|
||||
console.warn(warningMessage);
|
||||
logWarning(warningMessage);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const warningMessage = 'Layout manager does not support addGridItem';
|
||||
console.warn(warningMessage);
|
||||
@@ -258,48 +243,6 @@ export class TabItem
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a dropped row into this tab.
|
||||
* If the tab doesn't have a RowsLayoutManager, convert the layout first.
|
||||
*/
|
||||
public acceptDroppedRow(row: RowItem): void {
|
||||
const currentLayout = this.getLayout();
|
||||
|
||||
// Clear the parent reference from the row before adding to new layout
|
||||
row.clearParent();
|
||||
|
||||
if (currentLayout instanceof RowsLayoutManager) {
|
||||
// Already has a RowsLayoutManager, just add the row
|
||||
currentLayout.addNewRow(row);
|
||||
} else {
|
||||
// Need to convert the layout to RowsLayoutManager
|
||||
let rowsLayout: RowsLayoutManager;
|
||||
|
||||
// If the current layout is empty, just create a new RowsLayoutManager with only the dropped row
|
||||
if (currentLayout.getVizPanels().length === 0) {
|
||||
rowsLayout = new RowsLayoutManager({ rows: [row] });
|
||||
} else {
|
||||
// Convert existing layout and add the dropped row
|
||||
// Use direct state update instead of addNewRow because the rowsLayout
|
||||
// isn't connected to the scene yet, so dashboardEditActions won't work
|
||||
rowsLayout = RowsLayoutManager.createFromLayout(currentLayout);
|
||||
rowsLayout.setState({ rows: [...rowsLayout.state.rows, row] });
|
||||
}
|
||||
|
||||
// Clear the parent reference from the old layout
|
||||
currentLayout.clearParent();
|
||||
|
||||
// Switch to the new rows layout
|
||||
this.setState({ layout: rowsLayout });
|
||||
}
|
||||
|
||||
// Ensure this tab is active after the drop
|
||||
const parentLayout = this.getParentLayout();
|
||||
if (parentLayout.state.currentTabSlug !== this.getSlug()) {
|
||||
parentLayout.setState({ currentTabSlug: this.getSlug() });
|
||||
}
|
||||
}
|
||||
|
||||
public getParentLayout(): TabsLayoutManager {
|
||||
return sceneGraph.getAncestor(this, TabsLayoutManager);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useIsConditionallyHidden } from '../../conditional-rendering/hooks/useI
|
||||
import { isRepeatCloneOrChildOf } from '../../utils/clone';
|
||||
import { useDashboardState } from '../../utils/utils';
|
||||
import { useSoloPanelContext } from '../SoloPanelContext';
|
||||
import { DASHBOARD_DROP_TARGET_KEY_ATTR } from '../types/DashboardDropTarget';
|
||||
import { isDashboardLayoutGrid } from '../types/DashboardLayoutGrid';
|
||||
|
||||
import { TabItem } from './TabItem';
|
||||
|
||||
@@ -92,7 +92,7 @@ export function TabItemRenderer({ model }: SceneComponentProps<TabItem>) {
|
||||
onSelect?.(evt);
|
||||
}}
|
||||
label={titleInterpolated}
|
||||
data-tab-activation-key={key}
|
||||
data-dashboard-drop-target-key={isDashboardLayoutGrid(layout) ? model.state.key : undefined}
|
||||
{...titleCollisionProps}
|
||||
/>
|
||||
</div>
|
||||
@@ -126,7 +126,7 @@ export function TabItemLayoutRenderer({ tab, isEditing }: TabItemLayoutRendererP
|
||||
return (
|
||||
<TabContent
|
||||
className={cx(styles.tabContentContainer, isEditing && conditionalRenderingClass)}
|
||||
{...{ [DASHBOARD_DROP_TARGET_KEY_ATTR]: key }}
|
||||
data-dashboard-drop-target-key={key}
|
||||
>
|
||||
<layout.Component model={layout} />
|
||||
{isEditing && conditionalRenderingOverlay}
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import { SceneObject, SceneGridItemLike } from '@grafana/scenes';
|
||||
|
||||
/** Data attribute used to identify auto grid items as drop targets */
|
||||
export const AUTO_GRID_ITEM_DROP_TARGET_ATTR = 'data-auto-grid-item-drop-target';
|
||||
|
||||
/** Data attribute used to identify dashboard layout elements as drop targets */
|
||||
export const DASHBOARD_DROP_TARGET_KEY_ATTR = 'data-dashboard-drop-target-key';
|
||||
|
||||
export interface DashboardDropTarget extends SceneObject {
|
||||
isDashboardDropTarget: Readonly<true>;
|
||||
setIsDropTarget?(isDropTarget: boolean): void;
|
||||
draggedGridItemOutside?(gridItem: SceneGridItemLike): void;
|
||||
draggedGridItemInside?(gridItem: SceneGridItemLike, position?: number): void;
|
||||
/** Set the position where a placeholder should be shown for external drops */
|
||||
setDropPosition?(position: number | null): void;
|
||||
draggedGridItemInside?(gridItem: SceneGridItemLike): void;
|
||||
}
|
||||
|
||||
export function isDashboardDropTarget(scene: SceneObject): scene is DashboardDropTarget {
|
||||
|
||||
+17
-8
@@ -27,6 +27,7 @@ import { createPanelSaveModel } from 'app/features/dashboard/state/__fixtures__/
|
||||
import { SHARED_DASHBOARD_QUERY, DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/constants';
|
||||
import { DashboardDataDTO } from 'app/types/dashboard';
|
||||
|
||||
import { getSceneCreationOptions } from '../pages/DashboardScenePageStateManager';
|
||||
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
|
||||
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
|
||||
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
|
||||
@@ -822,10 +823,14 @@ describe('transformSaveModelToScene', () => {
|
||||
});
|
||||
|
||||
it('Should convert legacy rows to new rows', () => {
|
||||
const scene = transformSaveModelToScene({
|
||||
dashboard: repeatingRowsAndPanelsDashboardJson as DashboardDataDTO,
|
||||
meta: {},
|
||||
});
|
||||
const scene = transformSaveModelToScene(
|
||||
{
|
||||
dashboard: repeatingRowsAndPanelsDashboardJson as DashboardDataDTO,
|
||||
meta: {},
|
||||
},
|
||||
undefined,
|
||||
getSceneCreationOptions()
|
||||
);
|
||||
|
||||
const layout = scene.state.body as RowsLayoutManager;
|
||||
const row1 = layout.state.rows[0];
|
||||
@@ -857,10 +862,14 @@ describe('transformSaveModelToScene', () => {
|
||||
});
|
||||
|
||||
it('Should convert legacy rows to new rows with free panels before first row', () => {
|
||||
const scene = transformSaveModelToScene({
|
||||
dashboard: rowsAfterFreePanels as DashboardDataDTO,
|
||||
meta: {},
|
||||
});
|
||||
const scene = transformSaveModelToScene(
|
||||
{
|
||||
dashboard: rowsAfterFreePanels as DashboardDataDTO,
|
||||
meta: {},
|
||||
},
|
||||
undefined,
|
||||
getSceneCreationOptions()
|
||||
);
|
||||
|
||||
const layout = scene.state.body as RowsLayoutManager;
|
||||
const row1 = layout.state.rows[0];
|
||||
|
||||
@@ -29,11 +29,11 @@ import {
|
||||
} from 'app/features/dashboard/services/DashboardProfiler';
|
||||
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
||||
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
|
||||
import { DashboardDTO, DashboardDataDTO, DashboardRoutes } from 'app/types/dashboard';
|
||||
import { DashboardDTO, DashboardDataDTO } from 'app/types/dashboard';
|
||||
|
||||
import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior';
|
||||
import { dashboardAnalyticsInitializer } from '../behaviors/DashboardAnalyticsInitializerBehavior';
|
||||
import { LoadDashboardOptions, shouldForceV2API } from '../pages/DashboardScenePageStateManager';
|
||||
import { LoadDashboardOptions } from '../pages/DashboardScenePageStateManager';
|
||||
import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer';
|
||||
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
|
||||
import { DashboardControls } from '../scene/DashboardControls';
|
||||
@@ -76,11 +76,53 @@ export interface SaveModelToSceneOptions {
|
||||
isEmbedded?: boolean;
|
||||
}
|
||||
|
||||
export function transformSaveModelToScene(rsp: DashboardDTO, options?: LoadDashboardOptions): DashboardScene {
|
||||
type LayoutCreator = (panels: PanelModel[], preload?: boolean) => DashboardLayoutManager;
|
||||
|
||||
export interface SceneCreationOptions {
|
||||
/**
|
||||
* When provided, this function is used to create the dashboard body/layout instead of the default v1 behavior.
|
||||
* This allows callers to inject v2 layout strategy.
|
||||
*/
|
||||
createLayout?: LayoutCreator;
|
||||
/**
|
||||
* Determines how the dashboard scene is serialized.
|
||||
* @default 'v1'
|
||||
*/
|
||||
targetVersion?: 'v1' | 'v2';
|
||||
}
|
||||
|
||||
// Rows as SceneGridRow within the grid.
|
||||
const createDefaultGridLayout: LayoutCreator = (panels, preload) => {
|
||||
return new DefaultGridLayoutManager({
|
||||
grid: new SceneGridLayout({
|
||||
isLazy: getIsLazy(preload),
|
||||
children: createSceneObjectsForPanels(panels),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* V2 layout creator - uses RowsLayoutManager when dashboard has rows.
|
||||
* This creates a layout that can be properly serialized to v2 format.
|
||||
*/
|
||||
export const createV2RowsLayout: LayoutCreator = (panels, preload) => {
|
||||
const hasRows = panels.some((p) => p.type === 'row');
|
||||
if (hasRows) {
|
||||
return createRowsFromPanels(panels);
|
||||
}
|
||||
// Fall back to default grid layout when no rows
|
||||
return createDefaultGridLayout(panels, preload);
|
||||
};
|
||||
|
||||
export function transformSaveModelToScene(
|
||||
rsp: DashboardDTO,
|
||||
options?: LoadDashboardOptions,
|
||||
sceneOptions?: SceneCreationOptions
|
||||
): DashboardScene {
|
||||
// Just to have migrations run
|
||||
const oldModel = new DashboardModel(rsp.dashboard, rsp.meta);
|
||||
|
||||
const scene = createDashboardSceneFromDashboardModel(oldModel, rsp.dashboard, options);
|
||||
const scene = createDashboardSceneFromDashboardModel(oldModel, rsp.dashboard, options, sceneOptions);
|
||||
// TODO: refactor createDashboardSceneFromDashboardModel to work on Dashboard schema model
|
||||
|
||||
const apiVersion = config.featureToggles.kubernetesDashboards
|
||||
@@ -92,7 +134,7 @@ export function transformSaveModelToScene(rsp: DashboardDTO, options?: LoadDashb
|
||||
return scene;
|
||||
}
|
||||
|
||||
export function createRowsFromPanels(oldPanels: PanelModel[]): RowsLayoutManager {
|
||||
function createRowsFromPanels(oldPanels: PanelModel[]): RowsLayoutManager {
|
||||
const rowItems: RowItem[] = [];
|
||||
|
||||
let currentLegacyRow: PanelModel | null = null;
|
||||
@@ -143,7 +185,7 @@ export function createRowsFromPanels(oldPanels: PanelModel[]): RowsLayoutManager
|
||||
});
|
||||
}
|
||||
|
||||
export function createSceneObjectsForPanels(oldPanels: PanelModel[]): SceneGridItemLike[] {
|
||||
function createSceneObjectsForPanels(oldPanels: PanelModel[]): SceneGridItemLike[] {
|
||||
// collects all panels and rows
|
||||
const panels: SceneGridItemLike[] = [];
|
||||
|
||||
@@ -259,14 +301,14 @@ function createRowItemFromLegacyRow(row: PanelModel, panels: DashboardGridItem[]
|
||||
export function createDashboardSceneFromDashboardModel(
|
||||
oldModel: DashboardModel,
|
||||
dto: DashboardDataDTO,
|
||||
options?: LoadDashboardOptions
|
||||
options?: LoadDashboardOptions,
|
||||
sceneOptions?: SceneCreationOptions
|
||||
) {
|
||||
let variables: SceneVariableSet | undefined;
|
||||
let annotationLayers: SceneDataLayerProvider[] = [];
|
||||
let alertStatesLayer: AlertStatesDataLayer | undefined;
|
||||
const uid = oldModel.uid;
|
||||
const isReport = options?.route === DashboardRoutes.Report;
|
||||
const serializerVersion = shouldForceV2API() && !oldModel.meta.isSnapshot && !isReport ? 'v2' : 'v1';
|
||||
const targetVersion = sceneOptions?.targetVersion ?? 'v1';
|
||||
|
||||
if (oldModel.meta.isSnapshot) {
|
||||
variables = createVariablesForSnapshot(oldModel);
|
||||
@@ -354,9 +396,11 @@ export function createDashboardSceneFromDashboardModel(
|
||||
|
||||
let body: DashboardLayoutManager;
|
||||
|
||||
if (serializerVersion === 'v2' && oldModel.panels.some((p) => p.type === 'row')) {
|
||||
body = createRowsFromPanels(oldModel.panels);
|
||||
if (sceneOptions?.createLayout) {
|
||||
// Use injected layout creator (allows callers to specify v2 or custom layout strategy)
|
||||
body = sceneOptions.createLayout(oldModel.panels, dto.preload);
|
||||
} else {
|
||||
// Default v1 layout: DefaultGridLayoutManager
|
||||
body = new DefaultGridLayoutManager({
|
||||
grid: new SceneGridLayout({
|
||||
isLazy: getIsLazy(dto.preload),
|
||||
@@ -404,7 +448,7 @@ export function createDashboardSceneFromDashboardModel(
|
||||
hideTimeControls: oldModel.timepicker.hidden,
|
||||
}),
|
||||
},
|
||||
serializerVersion
|
||||
targetVersion
|
||||
);
|
||||
|
||||
// Enable panel profiling for this dashboard using the composed SceneRenderProfiler
|
||||
|
||||
+40
-30
@@ -1,6 +1,8 @@
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { getSceneCreationOptions } from '../pages/DashboardScenePageStateManager';
|
||||
|
||||
import { normalizeBackendOutputForFrontendComparison } from './serialization-test-utils';
|
||||
import { transformSaveModelSchemaV2ToScene } from './transformSaveModelSchemaV2ToScene';
|
||||
import { transformSaveModelToScene } from './transformSaveModelToScene';
|
||||
@@ -207,22 +209,26 @@ describe('V1 to V2 Dashboard Transformation Comparison', () => {
|
||||
delete dashboardSpec.snapshot;
|
||||
|
||||
// Wrap in DashboardDTO structure that transformSaveModelToScene expects
|
||||
const scene = transformSaveModelToScene({
|
||||
dashboard: dashboardSpec,
|
||||
meta: {
|
||||
isNew: false,
|
||||
isFolder: false,
|
||||
canSave: true,
|
||||
canEdit: true,
|
||||
canDelete: false,
|
||||
canShare: false,
|
||||
canStar: false,
|
||||
canAdmin: false,
|
||||
isSnapshot: false,
|
||||
provisioned: false,
|
||||
version: 1,
|
||||
const scene = transformSaveModelToScene(
|
||||
{
|
||||
dashboard: dashboardSpec,
|
||||
meta: {
|
||||
isNew: false,
|
||||
isFolder: false,
|
||||
canSave: true,
|
||||
canEdit: true,
|
||||
canDelete: false,
|
||||
canShare: false,
|
||||
canStar: false,
|
||||
canAdmin: false,
|
||||
isSnapshot: false,
|
||||
provisioned: false,
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
undefined,
|
||||
getSceneCreationOptions()
|
||||
);
|
||||
|
||||
const frontendOutput = transformSceneToSaveModelSchemaV2(scene, false);
|
||||
|
||||
@@ -279,22 +285,26 @@ describe('V1 to V2 Dashboard Transformation Comparison', () => {
|
||||
delete dashboardSpec.snapshot;
|
||||
|
||||
// Wrap in DashboardDTO structure that transformSaveModelToScene expects
|
||||
const scene = transformSaveModelToScene({
|
||||
dashboard: dashboardSpec,
|
||||
meta: {
|
||||
isNew: false,
|
||||
isFolder: false,
|
||||
canSave: true,
|
||||
canEdit: true,
|
||||
canDelete: false,
|
||||
canShare: false,
|
||||
canStar: false,
|
||||
canAdmin: false,
|
||||
isSnapshot: false,
|
||||
provisioned: false,
|
||||
version: 1,
|
||||
const scene = transformSaveModelToScene(
|
||||
{
|
||||
dashboard: dashboardSpec,
|
||||
meta: {
|
||||
isNew: false,
|
||||
isFolder: false,
|
||||
canSave: true,
|
||||
canEdit: true,
|
||||
canDelete: false,
|
||||
canShare: false,
|
||||
canStar: false,
|
||||
canAdmin: false,
|
||||
isSnapshot: false,
|
||||
provisioned: false,
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
undefined,
|
||||
getSceneCreationOptions()
|
||||
);
|
||||
|
||||
const frontendOutput = transformSceneToSaveModelSchemaV2(scene, false);
|
||||
|
||||
|
||||
+21
-15
@@ -6,6 +6,8 @@ import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboa
|
||||
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
|
||||
import { DashboardDataDTO } from 'app/types/dashboard';
|
||||
|
||||
import { getSceneCreationOptions } from '../pages/DashboardScenePageStateManager';
|
||||
|
||||
import { transformSaveModelSchemaV2ToScene } from './transformSaveModelSchemaV2ToScene';
|
||||
import { transformSaveModelToScene } from './transformSaveModelToScene';
|
||||
import { transformSceneToSaveModel } from './transformSceneToSaveModel';
|
||||
@@ -228,22 +230,26 @@ function removeMetadata(spec: Dashboard): Partial<Dashboard> {
|
||||
* identical processing.
|
||||
*/
|
||||
function loadAndSerializeV1SaveModel(dashboard: Dashboard): Dashboard {
|
||||
const scene = transformSaveModelToScene({
|
||||
dashboard: dashboard as DashboardDataDTO,
|
||||
meta: {
|
||||
isNew: false,
|
||||
isFolder: false,
|
||||
canSave: true,
|
||||
canEdit: true,
|
||||
canDelete: false,
|
||||
canShare: false,
|
||||
canStar: false,
|
||||
canAdmin: false,
|
||||
isSnapshot: false,
|
||||
provisioned: false,
|
||||
version: 1,
|
||||
const scene = transformSaveModelToScene(
|
||||
{
|
||||
dashboard: dashboard as DashboardDataDTO,
|
||||
meta: {
|
||||
isNew: false,
|
||||
isFolder: false,
|
||||
canSave: true,
|
||||
canEdit: true,
|
||||
canDelete: false,
|
||||
canShare: false,
|
||||
canStar: false,
|
||||
canAdmin: false,
|
||||
isSnapshot: false,
|
||||
provisioned: false,
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
undefined,
|
||||
getSceneCreationOptions()
|
||||
);
|
||||
|
||||
return transformSceneToSaveModel(scene, false);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { DashboardDataDTO } from 'app/types/dashboard';
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { makeExportableV1, makeExportableV2 } from '../scene/export/exporters';
|
||||
import { createV2RowsLayout, transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
||||
import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
|
||||
import { transformSceneToSaveModelSchemaV2 } from '../serialization/transformSceneToSaveModelSchemaV2';
|
||||
import { getVariablesCompatibility } from '../utils/getVariablesCompatibility';
|
||||
@@ -216,7 +217,27 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
|
||||
}
|
||||
|
||||
if (exportMode === ExportMode.V2Resource) {
|
||||
const spec = transformSceneToSaveModelSchemaV2(scene);
|
||||
let sceneForV2Export = scene;
|
||||
|
||||
// When exporting v1 dashboard as v2, we need to recreate the scene with v2 layout creator
|
||||
// to ensure rows are properly serialized. The v1 scene uses DefaultGridLayoutManager which
|
||||
// doesn't know about RowsLayoutManager structure needed for v2 serialization.
|
||||
if (initialSaveModelVersion === 'v1' && initialSaveModel && isV1ClassicDashboard(initialSaveModel)) {
|
||||
// Recreate scene with v2 layout creator to properly handle rows
|
||||
sceneForV2Export = transformSaveModelToScene(
|
||||
{
|
||||
dashboard: { ...initialSaveModel, title: initialSaveModel.title ?? '', uid: initialSaveModel.uid ?? '' },
|
||||
meta: scene.state.meta,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
createLayout: createV2RowsLayout,
|
||||
targetVersion: 'v2',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const spec = transformSceneToSaveModelSchemaV2(sceneForV2Export);
|
||||
const specCopy = JSON.parse(JSON.stringify(spec));
|
||||
const statelessSpec = await makeExportableV2(specCopy, isSharingExternally);
|
||||
const exportableV2 = isSharingExternally ? statelessSpec : spec;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { readdirSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
|
||||
import { getSceneCreationOptions } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager';
|
||||
import { normalizeBackendOutputForFrontendComparison } from 'app/features/dashboard-scene/serialization/serialization-test-utils';
|
||||
import { transformSaveModelSchemaV2ToScene } from 'app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene';
|
||||
import { transformSaveModelToScene } from 'app/features/dashboard-scene/serialization/transformSaveModelToScene';
|
||||
@@ -193,22 +194,26 @@ describe('V1 to V2 Dashboard Transformation Comparison (ResponseTransformers)',
|
||||
delete dashboardSpec.snapshot;
|
||||
|
||||
// Wrap in DashboardDTO structure that transformSaveModelToScene expects
|
||||
const scene = transformSaveModelToScene({
|
||||
dashboard: dashboardSpec,
|
||||
meta: {
|
||||
isNew: false,
|
||||
isFolder: false,
|
||||
canSave: true,
|
||||
canEdit: true,
|
||||
canDelete: false,
|
||||
canShare: false,
|
||||
canStar: false,
|
||||
canAdmin: false,
|
||||
isSnapshot: false,
|
||||
provisioned: false,
|
||||
version: 1,
|
||||
const scene = transformSaveModelToScene(
|
||||
{
|
||||
dashboard: dashboardSpec,
|
||||
meta: {
|
||||
isNew: false,
|
||||
isFolder: false,
|
||||
canSave: true,
|
||||
canEdit: true,
|
||||
canDelete: false,
|
||||
canShare: false,
|
||||
canStar: false,
|
||||
canAdmin: false,
|
||||
isSnapshot: false,
|
||||
provisioned: false,
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
undefined,
|
||||
getSceneCreationOptions()
|
||||
);
|
||||
|
||||
const frontendOutput = transformSceneToSaveModelSchemaV2(scene, false);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AnnotationQuery, BusEventBase, BusEventWithPayload, eventFactory } from '@grafana/data';
|
||||
import { IconName, ButtonVariant } from '@grafana/ui';
|
||||
import { HistoryEntryView } from 'app/core/components/AppChrome/types';
|
||||
|
||||
/**
|
||||
* Event Payloads
|
||||
@@ -217,7 +216,3 @@ export class PanelEditEnteredEvent extends BusEventWithPayload<number> {
|
||||
export class PanelEditExitedEvent extends BusEventWithPayload<number> {
|
||||
static type = 'panel-edit-finished';
|
||||
}
|
||||
|
||||
export class RecordHistoryEntryEvent extends BusEventWithPayload<HistoryEntryView> {
|
||||
static type = 'record-history-entry';
|
||||
}
|
||||
|
||||
@@ -5337,7 +5337,6 @@
|
||||
},
|
||||
"header-hidden-tooltip": "Row header only visible in edit mode",
|
||||
"name": "Rows",
|
||||
"new-row-title": "New row",
|
||||
"row": {
|
||||
"collapse": "Collapse row",
|
||||
"expand": "Expand row",
|
||||
@@ -10721,18 +10720,6 @@
|
||||
"help/documentation": "Documentation",
|
||||
"help/keyboard-shortcuts": "Keyboard shortcuts",
|
||||
"help/support": "Support",
|
||||
"history-container": {
|
||||
"drawer-tittle": "History"
|
||||
},
|
||||
"history-wrapper": {
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand",
|
||||
"icon-selected": "Selected Entry",
|
||||
"icon-unselected": "Normal Entry",
|
||||
"show-more": "Show more",
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday"
|
||||
},
|
||||
"home": {
|
||||
"title": "Home"
|
||||
},
|
||||
|
||||
@@ -1161,56 +1161,6 @@ div.editor-option label {
|
||||
content: '\e902';
|
||||
}
|
||||
|
||||
.bootstrap-tagsinput {
|
||||
display: inline-block;
|
||||
padding: 0 0 0 6px;
|
||||
vertical-align: middle;
|
||||
max-width: 100%;
|
||||
line-height: 22px;
|
||||
background-color: $input-bg;
|
||||
border: 1px solid $input-border-color;
|
||||
|
||||
input {
|
||||
display: inline-block;
|
||||
border: none;
|
||||
margin: 0px;
|
||||
border-radius: 0;
|
||||
padding: 8px 6px;
|
||||
height: 100%;
|
||||
width: 70px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.gf-form-input--has-help-icon {
|
||||
padding-right: $space-xl;
|
||||
}
|
||||
}
|
||||
|
||||
.tag {
|
||||
margin-right: 2px;
|
||||
color: $white;
|
||||
|
||||
[data-role='remove'] {
|
||||
margin-left: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
&::after {
|
||||
content: 'x';
|
||||
padding: 0px 2px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2),
|
||||
0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
&:active {
|
||||
box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-top: $space-md;
|
||||
|
||||
|
||||
-512
@@ -1,512 +0,0 @@
|
||||
(function ($) {
|
||||
"use strict";
|
||||
|
||||
var defaultOptions = {
|
||||
tagClass: function(item) {
|
||||
return 'label label-info';
|
||||
},
|
||||
itemValue: function(item) {
|
||||
return item ? item.toString() : item;
|
||||
},
|
||||
itemText: function(item) {
|
||||
return this.itemValue(item);
|
||||
},
|
||||
freeInput: true,
|
||||
maxTags: undefined,
|
||||
confirmKeys: [13],
|
||||
onTagExists: function(item, $tag) {
|
||||
$tag.hide().fadeIn();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Constructor function
|
||||
*/
|
||||
function TagsInput(element, options) {
|
||||
this.itemsArray = [];
|
||||
|
||||
this.$element = $(element);
|
||||
this.$element.hide();
|
||||
|
||||
this.widthClass = options.widthClass || 'width-9';
|
||||
this.isSelect = (element.tagName === 'SELECT');
|
||||
this.multiple = (this.isSelect && element.hasAttribute('multiple'));
|
||||
this.objectItems = options && options.itemValue;
|
||||
this.placeholderText = element.hasAttribute('placeholder') ? this.$element.attr('placeholder') : '';
|
||||
|
||||
this.$container = $('<div class="bootstrap-tagsinput"></div>');
|
||||
this.$input = $('<input class="gf-form-input ' + this.widthClass + '" type="text" placeholder="' + this.placeholderText + '"/>').appendTo(this.$container);
|
||||
|
||||
this.$element.after(this.$container);
|
||||
|
||||
this.build(options);
|
||||
}
|
||||
|
||||
TagsInput.prototype = {
|
||||
constructor: TagsInput,
|
||||
|
||||
/**
|
||||
* Adds the given item as a new tag. Pass true to dontPushVal to prevent
|
||||
* updating the elements val()
|
||||
*/
|
||||
add: function(item, dontPushVal) {
|
||||
var self = this;
|
||||
|
||||
if (self.options.maxTags && self.itemsArray.length >= self.options.maxTags)
|
||||
return;
|
||||
|
||||
// Ignore falsey values, except false
|
||||
if (item !== false && !item)
|
||||
return;
|
||||
|
||||
// Throw an error when trying to add an object while the itemValue option was not set
|
||||
if (typeof item === "object" && !self.objectItems)
|
||||
throw("Can't add objects when itemValue option is not set");
|
||||
|
||||
// Ignore strings only containg whitespace
|
||||
if (item.toString().match(/^\s*$/))
|
||||
return;
|
||||
|
||||
// If SELECT but not multiple, remove current tag
|
||||
if (self.isSelect && !self.multiple && self.itemsArray.length > 0)
|
||||
self.remove(self.itemsArray[0]);
|
||||
|
||||
if (typeof item === "string" && this.$element[0].tagName === 'INPUT') {
|
||||
var items = item.split(',');
|
||||
if (items.length > 1) {
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
this.add(items[i], true);
|
||||
}
|
||||
|
||||
if (!dontPushVal)
|
||||
self.pushVal();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var itemValue = self.options.itemValue(item),
|
||||
itemText = self.options.itemText(item),
|
||||
tagClass = self.options.tagClass(item);
|
||||
|
||||
// Ignore items already added
|
||||
var existing = $.grep(self.itemsArray, function(item) { return self.options.itemValue(item) === itemValue; } )[0];
|
||||
if (existing) {
|
||||
// Invoke onTagExists
|
||||
if (self.options.onTagExists) {
|
||||
var $existingTag = $(".tag", self.$container).filter(function() { return $(this).data("item") === existing; });
|
||||
self.options.onTagExists(item, $existingTag);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// register item in internal array and map
|
||||
self.itemsArray.push(item);
|
||||
|
||||
// add a tag element
|
||||
var $tag = $('<span class="tag ' + htmlEncode(tagClass) + '">' + htmlEncode(itemText) + '<span data-role="remove"></span></span>');
|
||||
$tag.data('item', item);
|
||||
self.findInputWrapper().before($tag);
|
||||
$tag.after(' ');
|
||||
|
||||
// add <option /> if item represents a value not present in one of the <select />'s options
|
||||
if (self.isSelect && !$('option[value="' + escape(itemValue) + '"]',self.$element)[0]) {
|
||||
var $option = $('<option selected>' + htmlEncode(itemText) + '</option>');
|
||||
$option.data('item', item);
|
||||
$option.attr('value', itemValue);
|
||||
self.$element.append($option);
|
||||
}
|
||||
|
||||
if (!dontPushVal)
|
||||
self.pushVal();
|
||||
|
||||
// Add class when reached maxTags
|
||||
if (self.options.maxTags === self.itemsArray.length)
|
||||
self.$container.addClass('bootstrap-tagsinput-max');
|
||||
|
||||
self.$element.trigger($.Event('itemAdded', { item: item }));
|
||||
},
|
||||
|
||||
/**
|
||||
* Removes the given item. Pass true to dontPushVal to prevent updating the
|
||||
* elements val()
|
||||
*/
|
||||
remove: function(item, dontPushVal) {
|
||||
var self = this;
|
||||
|
||||
if (self.objectItems) {
|
||||
if (typeof item === "object")
|
||||
item = $.grep(self.itemsArray, function(other) { return self.options.itemValue(other) == self.options.itemValue(item); } )[0];
|
||||
else
|
||||
item = $.grep(self.itemsArray, function(other) { return self.options.itemValue(other) == item; } )[0];
|
||||
}
|
||||
|
||||
if (item) {
|
||||
$('.tag', self.$container).filter(function() { return $(this).data('item') === item; }).remove();
|
||||
$('option', self.$element).filter(function() { return $(this).data('item') === item; }).remove();
|
||||
self.itemsArray.splice($.inArray(item, self.itemsArray), 1);
|
||||
}
|
||||
|
||||
if (!dontPushVal)
|
||||
self.pushVal();
|
||||
|
||||
// Remove class when reached maxTags
|
||||
if (self.options.maxTags > self.itemsArray.length)
|
||||
self.$container.removeClass('bootstrap-tagsinput-max');
|
||||
|
||||
self.$element.trigger($.Event('itemRemoved', { item: item }));
|
||||
},
|
||||
|
||||
/**
|
||||
* Removes all items
|
||||
*/
|
||||
removeAll: function() {
|
||||
var self = this;
|
||||
|
||||
$('.tag', self.$container).remove();
|
||||
$('option', self.$element).remove();
|
||||
|
||||
while(self.itemsArray.length > 0)
|
||||
self.itemsArray.pop();
|
||||
|
||||
self.pushVal();
|
||||
|
||||
if (self.options.maxTags && !this.isEnabled())
|
||||
this.enable();
|
||||
},
|
||||
|
||||
/**
|
||||
* Refreshes the tags so they match the text/value of their corresponding
|
||||
* item.
|
||||
*/
|
||||
refresh: function() {
|
||||
var self = this;
|
||||
$('.tag', self.$container).each(function() {
|
||||
var $tag = $(this),
|
||||
item = $tag.data('item'),
|
||||
itemValue = self.options.itemValue(item),
|
||||
itemText = self.options.itemText(item),
|
||||
tagClass = self.options.tagClass(item);
|
||||
|
||||
// Update tag's class and inner text
|
||||
$tag.attr('class', null);
|
||||
$tag.addClass('tag ' + htmlEncode(tagClass));
|
||||
$tag.contents().filter(function() {
|
||||
return this.nodeType == 3;
|
||||
})[0].nodeValue = htmlEncode(itemText);
|
||||
|
||||
if (self.isSelect) {
|
||||
var option = $('option', self.$element).filter(function() { return $(this).data('item') === item; });
|
||||
option.attr('value', itemValue);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the items added as tags
|
||||
*/
|
||||
items: function() {
|
||||
return this.itemsArray;
|
||||
},
|
||||
|
||||
/**
|
||||
* Assembly value by retrieving the value of each item, and set it on the
|
||||
* element.
|
||||
*/
|
||||
pushVal: function() {
|
||||
var self = this,
|
||||
val = $.map(self.items(), function(item) {
|
||||
return self.options.itemValue(item).toString();
|
||||
});
|
||||
|
||||
self.$element.val(val, true).trigger('change');
|
||||
},
|
||||
|
||||
/**
|
||||
* Initializes the tags input behaviour on the element
|
||||
*/
|
||||
build: function(options) {
|
||||
var self = this;
|
||||
|
||||
self.options = $.extend({}, defaultOptions, options);
|
||||
var typeahead = self.options.typeahead || {};
|
||||
|
||||
// When itemValue is set, freeInput should always be false
|
||||
if (self.objectItems)
|
||||
self.options.freeInput = false;
|
||||
|
||||
makeOptionItemFunction(self.options, 'itemValue');
|
||||
makeOptionItemFunction(self.options, 'itemText');
|
||||
makeOptionItemFunction(self.options, 'tagClass');
|
||||
|
||||
// for backwards compatibility, self.options.source is deprecated
|
||||
if (self.options.source)
|
||||
typeahead.source = self.options.source;
|
||||
|
||||
if (typeahead.source && $.fn.typeahead) {
|
||||
makeOptionFunction(typeahead, 'source');
|
||||
|
||||
self.$input.typeahead({
|
||||
source: function (query, process) {
|
||||
function processItems(items) {
|
||||
var texts = [];
|
||||
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var text = self.options.itemText(items[i]);
|
||||
map[text] = items[i];
|
||||
texts.push(text);
|
||||
}
|
||||
process(texts);
|
||||
}
|
||||
|
||||
this.map = {};
|
||||
var map = this.map,
|
||||
data = typeahead.source(query);
|
||||
|
||||
if ($.isFunction(data.success)) {
|
||||
// support for Angular promises
|
||||
data.success(processItems);
|
||||
} else {
|
||||
// support for functions and jquery promises
|
||||
$.when(data)
|
||||
.then(processItems);
|
||||
}
|
||||
},
|
||||
updater: function (text) {
|
||||
self.add(this.map[text]);
|
||||
},
|
||||
matcher: function (text) {
|
||||
return (text.toLowerCase().indexOf(this.query.trim().toLowerCase()) !== -1);
|
||||
},
|
||||
sorter: function (texts) {
|
||||
return texts.sort();
|
||||
},
|
||||
highlighter: function (text) {
|
||||
var regex = new RegExp( '(' + this.query + ')', 'gi' );
|
||||
return text.replace( regex, "<strong>$1</strong>" );
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
self.$container.on('click', $.proxy(function(event) {
|
||||
self.$input.focus();
|
||||
}, self));
|
||||
|
||||
self.$container.on('blur', 'input', $.proxy(function(event) {
|
||||
var $input = $(event.target);
|
||||
self.add($input.val());
|
||||
$input.val('');
|
||||
event.preventDefault();
|
||||
}, self));
|
||||
|
||||
self.$container.on('keydown', 'input', $.proxy(function(event) {
|
||||
var $input = $(event.target),
|
||||
$inputWrapper = self.findInputWrapper();
|
||||
|
||||
switch (event.which) {
|
||||
// BACKSPACE
|
||||
case 8:
|
||||
if (doGetCaretPosition($input[0]) === 0) {
|
||||
var prev = $inputWrapper.prev();
|
||||
if (prev) {
|
||||
self.remove(prev.data('item'));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
// DELETE
|
||||
case 46:
|
||||
if (doGetCaretPosition($input[0]) === 0) {
|
||||
var next = $inputWrapper.next();
|
||||
if (next) {
|
||||
self.remove(next.data('item'));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
// LEFT ARROW
|
||||
case 37:
|
||||
// Try to move the input before the previous tag
|
||||
var $prevTag = $inputWrapper.prev();
|
||||
if ($input.val().length === 0 && $prevTag[0]) {
|
||||
$prevTag.before($inputWrapper);
|
||||
$input.focus();
|
||||
}
|
||||
break;
|
||||
// RIGHT ARROW
|
||||
case 39:
|
||||
// Try to move the input after the next tag
|
||||
var $nextTag = $inputWrapper.next();
|
||||
if ($input.val().length === 0 && $nextTag[0]) {
|
||||
$nextTag.after($inputWrapper);
|
||||
$input.focus();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// When key corresponds one of the confirmKeys, add current input
|
||||
// as a new tag
|
||||
if (self.options.freeInput && $.inArray(event.which, self.options.confirmKeys) >= 0) {
|
||||
self.add($input.val());
|
||||
$input.val('');
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// Reset internal input's size
|
||||
$input.attr('size', Math.max(this.inputSize, $input.val().length));
|
||||
}, self));
|
||||
|
||||
// Remove icon clicked
|
||||
self.$container.on('click', '[data-role=remove]', $.proxy(function(event) {
|
||||
self.remove($(event.target).closest('.tag').data('item'));
|
||||
// Grafana mod, if tags input used in popover the click event will bubble up and hide popover
|
||||
event.stopPropagation();
|
||||
}, self));
|
||||
|
||||
// Only add existing value as tags when using strings as tags
|
||||
if (self.options.itemValue === defaultOptions.itemValue) {
|
||||
if (self.$element[0].tagName === 'INPUT') {
|
||||
self.add(self.$element.val());
|
||||
} else {
|
||||
$('option', self.$element).each(function() {
|
||||
self.add($(this).attr('value'), true);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Removes all tagsinput behaviour and unregsiter all event handlers
|
||||
*/
|
||||
destroy: function() {
|
||||
var self = this;
|
||||
|
||||
// Unbind events
|
||||
self.$container.off('keypress', 'input');
|
||||
self.$container.off('click', '[role=remove]');
|
||||
|
||||
self.$container.remove();
|
||||
self.$element.removeData('tagsinput');
|
||||
self.$element.show();
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets focus on the tagsinput
|
||||
*/
|
||||
focus: function() {
|
||||
this.$input.focus();
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the internal input element
|
||||
*/
|
||||
input: function() {
|
||||
return this.$input;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the element which is wrapped around the internal input. This
|
||||
* is normally the $container, but typeahead.js moves the $input element.
|
||||
*/
|
||||
findInputWrapper: function() {
|
||||
var elt = this.$input[0],
|
||||
container = this.$container[0];
|
||||
while(elt && elt.parentNode !== container)
|
||||
elt = elt.parentNode;
|
||||
|
||||
return $(elt);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Register JQuery plugin
|
||||
*/
|
||||
$.fn.tagsinput = function(arg1, arg2) {
|
||||
var results = [];
|
||||
|
||||
this.each(function() {
|
||||
var tagsinput = $(this).data('tagsinput');
|
||||
|
||||
// Initialize a new tags input
|
||||
if (!tagsinput) {
|
||||
tagsinput = new TagsInput(this, arg1);
|
||||
$(this).data('tagsinput', tagsinput);
|
||||
results.push(tagsinput);
|
||||
|
||||
if (this.tagName === 'SELECT') {
|
||||
$('option', $(this)).attr('selected', 'selected');
|
||||
}
|
||||
|
||||
// Init tags from $(this).val()
|
||||
$(this).val($(this).val());
|
||||
} else {
|
||||
// Invoke function on existing tags input
|
||||
var retVal = tagsinput[arg1](arg2);
|
||||
if (retVal !== undefined)
|
||||
results.push(retVal);
|
||||
}
|
||||
});
|
||||
|
||||
if ( typeof arg1 == 'string') {
|
||||
// Return the results from the invoked function calls
|
||||
return results.length > 1 ? results : results[0];
|
||||
} else {
|
||||
return results;
|
||||
}
|
||||
};
|
||||
|
||||
$.fn.tagsinput.Constructor = TagsInput;
|
||||
|
||||
/**
|
||||
* Most options support both a string or number as well as a function as
|
||||
* option value. This function makes sure that the option with the given
|
||||
* key in the given options is wrapped in a function
|
||||
*/
|
||||
function makeOptionItemFunction(options, key) {
|
||||
if (typeof options[key] !== 'function') {
|
||||
var propertyName = options[key];
|
||||
options[key] = function(item) { return item[propertyName]; };
|
||||
}
|
||||
}
|
||||
function makeOptionFunction(options, key) {
|
||||
if (typeof options[key] !== 'function') {
|
||||
var value = options[key];
|
||||
options[key] = function() { return value; };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* HtmlEncodes the given value
|
||||
*/
|
||||
var htmlEncodeContainer = $('<div />');
|
||||
function htmlEncode(value) {
|
||||
if (value) {
|
||||
return htmlEncodeContainer.text(value).html();
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the position of the caret in the given input field
|
||||
* http://flightschool.acylt.com/devnotes/caret-position-woes/
|
||||
*/
|
||||
function doGetCaretPosition(oField) {
|
||||
var iCaretPos = 0;
|
||||
if (document.selection) {
|
||||
oField.focus ();
|
||||
var oSel = document.selection.createRange();
|
||||
oSel.moveStart ('character', -oField.value.length);
|
||||
iCaretPos = oSel.text.length;
|
||||
} else if (oField.selectionStart || oField.selectionStart == '0') {
|
||||
iCaretPos = oField.selectionStart;
|
||||
}
|
||||
return (iCaretPos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize tagsinput behaviour on inputs and selects which have
|
||||
* data-role=tagsinput
|
||||
*/
|
||||
$(function() {
|
||||
$("input[data-role=tagsinput], select[multiple][data-role=tagsinput]").tagsinput();
|
||||
});
|
||||
})(window.jQuery);
|
||||
Reference in New Issue
Block a user