mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge remote-tracking branch 'origin/main' into resource-store
This commit is contained in:
commit
9f2aae1c48
@ -39,7 +39,7 @@ Grouping in Grafana Alerting allows you to batch relevant alerts together into a
|
||||
|
||||
Grouping combines similar alert instances within a specific period into a single notification, reducing alert noise.
|
||||
|
||||
In the notification policy, you can configure how to group multiple alerts into a single notification:
|
||||
In the [notification policy](ref:notification-policies), you can configure how to group multiple alerts into a single notification:
|
||||
|
||||
- The `Group by` option specifies the criteria for grouping incoming alerts within the policy. The default is by alert rule.
|
||||
- [Timing options](#timing-options) determine when and how often to send the notification.
|
||||
|
@ -4,49 +4,93 @@ aliases:
|
||||
- ../../alerting/alert-groups/filter-alerts/ # /docs/grafana/<GRAFANA_VERSION>/alerting/alert-groups/filter-alerts/
|
||||
- ../../alerting/alert-groups/view-alert-grouping/ # /docs/grafana/<GRAFANA_VERSION>/alerting/alert-groups/view-alert-grouping/
|
||||
- ../../alerting/unified-alerting/alert-groups/ # /docs/grafana/<GRAFANA_VERSION>/alerting/unified-alerting/alert-groups/
|
||||
- ../../alerting/manage-notifications/view-notification-errors/ # /docs/grafana/<GRAFANA_VERSION>/alerting/manage-notifications/view-notification-errors/
|
||||
canonical: https://grafana.com/docs/grafana/latest/alerting/manage-notifications/view-alert-groups/
|
||||
description: Alert groups
|
||||
description: The Groups view lists grouped alerts that are actively triggering notifications.
|
||||
keywords:
|
||||
- grafana
|
||||
- alerting
|
||||
- alerts
|
||||
- errors
|
||||
- notifications
|
||||
- groups
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
title: View and filter by alert groups
|
||||
title: View the status of notifications
|
||||
weight: 800
|
||||
refs:
|
||||
alertmanager:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/set-up/configure-alertmanager/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/set-up/configure-alertmanager/
|
||||
grouping:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/fundamentals/notifications/group-alert-notifications/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/notifications/group-alert-notifications/
|
||||
---
|
||||
|
||||
# View and filter by alert groups
|
||||
# View the status of notifications
|
||||
|
||||
Alert groups show grouped alerts from an Alertmanager instance. By default, alert rules are grouped by the label keys for the default policy in notification policies. Grouping common alert rules into a single alert group prevents duplicate alert rules from being fired.
|
||||
The Groups view page lists grouped alerts that are actively triggering notifications.
|
||||
|
||||
You can view alert groups and also filter for alert rules that match specific criteria.
|
||||
By default, Grafana Alerting groups similar firing alerts (or alert instances) to prevent notification overload. For details on how notification grouping works, refer to [Group alert notifications](ref:grouping).
|
||||
|
||||
## View alert groups
|
||||
In the Groups view, you can see alert groups, check the state of their notifications, and also filter for alert instances that match specific criteria. This view is useful for debugging and verifying your grouping settings of notification policies.
|
||||
|
||||
## View alert groups and notification state
|
||||
|
||||
To view alert groups, complete the following steps.
|
||||
|
||||
1. In the left-side menu, click **Alerts & IRM** and then **Alerting**.
|
||||
1. Click **Groups** to view the list of existing groups.
|
||||
1. From the **Alertmanager** dropdown, select an external Alertmanager as your data source. By default, the `Grafana` Alertmanager is selected.
|
||||
1. From **Custom group by** dropdown, select a combination of labels to view a grouping other than the default. This is useful for debugging and verifying your grouping of notification policies.
|
||||
1. Click **Alerts & IRM** -> **Alerting**.
|
||||
1. Click **Groups** to view the list of groups firing notifications.
|
||||
|
||||
By default, alert groups are grouped by the notification policies grouping.
|
||||
|
||||
Each group displays its label set, contact point, and the number of alert instances (or alerts).
|
||||
|
||||
Then, click on a group to access its alert instances. You can find alert instances by their label set and view their notification state.
|
||||
|
||||
### Notification states
|
||||
|
||||
The notification state of an alert instance can be in one of the following states:
|
||||
|
||||
- **Unprocessed**: The alert is received but its notification has not been processed yet.
|
||||
- **Suppressed**: The alert has been silenced.
|
||||
- **Active**: The alert notification has been handled. The alert is still firing and continues to be managed.
|
||||
|
||||
### Filter alerts
|
||||
|
||||
You can filter by label, state, or Alertmanager:
|
||||
|
||||
- **By label**: In **Search**, enter an existing label to view alerts matching the label. For example, `environment=production,region=~US|EU,severity!=warning`.
|
||||
|
||||
- **By state**: In **States**, select from Active, Suppressed, or Unprocessed states to view alerts matching your selected state. All other alerts are hidden.
|
||||
|
||||
- **By Alertmanager**: In the **Alertmanager** dropdown, select an [external Alertmanager](ref:alertmanager) to view only alert groups for that specific Alertmanager. By default, the `Grafana` Alertmanager is selected.
|
||||
|
||||
### Custom group
|
||||
|
||||
From **Custom group by** dropdown, select a combination of labels to view a grouping other than the default. This helps validate the [grouping settings of your notification policies](ref:grouping).
|
||||
|
||||
If an alert does not contain labels specified either in the grouping of the default policy or the custom grouping, then the alert is added to a catch all group with a header of `No grouping`.
|
||||
|
||||
## Filter alerts
|
||||
## View notification errors
|
||||
|
||||
You can filter by label or state.
|
||||
{{% admonition type="note" %}}
|
||||
|
||||
### Search by label
|
||||
Notification errors are only available with [pre-configured Grafana Alertmanagers](ref:alertmanager).
|
||||
|
||||
In **Search**, enter an existing label to view alerts matching the label.
|
||||
{{% /admonition %}}
|
||||
|
||||
For example, `environment=production,region=~US|EU,severity!=warning`.
|
||||
Notification errors provide information about why they failed to be sent or were not received.
|
||||
|
||||
### Filter by state
|
||||
To view notification errors, navigate to **Alerts & IRM** -> **Alerting** -> **Contact points**.
|
||||
|
||||
In **States**, select from Active, Suppressed, or Unprocessed states to view alerts matching your selected state. All other alerts are hidden.
|
||||
Each contact point displays a message about the status of their latest notification deliveries.
|
||||
|
||||
If a contact point is failing, a red message indicates that there are errors delivering notifications. Hover over the error message to see the notification error details.
|
||||
|
@ -1,44 +0,0 @@
|
||||
---
|
||||
canonical: https://grafana.com/docs/grafana/latest/alerting/manage-notifications/view-notification-errors/
|
||||
description: View notification errors and understand why they failed to be sent or were not received
|
||||
keywords:
|
||||
- grafana
|
||||
- alerting
|
||||
- notification
|
||||
- errors
|
||||
- contact points
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
- enterprise
|
||||
- oss
|
||||
title: View notification errors
|
||||
weight: 900
|
||||
---
|
||||
|
||||
# View notification errors
|
||||
|
||||
View notification errors and understand why they failed to be sent or were not received.
|
||||
|
||||
**Note:**
|
||||
This feature only works if you are using Grafana Alertmanager.
|
||||
|
||||
To view notification errors, complete the following steps.
|
||||
|
||||
1. Navigate to Alerting -> Contact points.
|
||||
|
||||
If any contact points are failing, a message at the right-hand corner of the screen alerts the user to the fact that there are errors and how many.
|
||||
|
||||
2. Click on the contact point to view the details of errors for each contact point.
|
||||
|
||||
Error details are displayed if you hover over the Error icon.
|
||||
|
||||
If a contact point has more than one integration, you see all errors for each of the integrations listed.
|
||||
|
||||
3. In the Health column, check the status of the notification.
|
||||
|
||||
This can be either OK, No attempts, or Error.
|
||||
|
||||
## Useful links
|
||||
|
||||
[Receivers API](https://editor.swagger.io/?url=https://raw.githubusercontent.com/grafana/grafana/main/pkg/services/ngalert/api/tooling/post.json)
|
@ -582,11 +582,6 @@
|
||||
},
|
||||
"required": ["extensionPointId", "title", "type"]
|
||||
}
|
||||
},
|
||||
"apiVersion": {
|
||||
"type": "string",
|
||||
"description": "[internal only] The API version for the plugin. Used for Datasource API servers. This metadata is temporary and will be removed in the future.",
|
||||
"pattern": "^v([\\d]+)(?:(alpha|beta)([\\d]+))?$"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -199,4 +199,5 @@ export interface FeatureToggles {
|
||||
alertingApiServer?: boolean;
|
||||
dashboardRestoreUI?: boolean;
|
||||
cloudWatchRoundUpEndTime?: boolean;
|
||||
bodyScrolling?: boolean;
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ export const AppEvents = {
|
||||
alertSuccess: eventFactory<AlertPayload>('alert-success'),
|
||||
alertWarning: eventFactory<AlertPayload>('alert-warning'),
|
||||
alertError: eventFactory<AlertErrorPayload>('alert-error'),
|
||||
alertInfo: eventFactory<AlertPayload>('alert-info'),
|
||||
};
|
||||
|
||||
export const PanelEvents = {
|
||||
|
@ -28,7 +28,6 @@ type PluginSetting struct {
|
||||
SignatureType plugins.SignatureType `json:"signatureType"`
|
||||
SignatureOrg string `json:"signatureOrg"`
|
||||
AngularDetected bool `json:"angularDetected"`
|
||||
APIVersion string `json:"apiVersion"`
|
||||
}
|
||||
|
||||
type PluginListItem struct {
|
||||
@ -50,7 +49,6 @@ type PluginListItem struct {
|
||||
AccessControl accesscontrol.Metadata `json:"accessControl,omitempty"`
|
||||
AngularDetected bool `json:"angularDetected"`
|
||||
IAM *pfs.IAM `json:"iam,omitempty"`
|
||||
APIVersion string `json:"apiVersion"`
|
||||
}
|
||||
|
||||
type PluginList []PluginListItem
|
||||
|
@ -142,7 +142,6 @@ func (hs *HTTPServer) GetPluginList(c *contextmodel.ReqContext) response.Respons
|
||||
SignatureOrg: pluginDef.SignatureOrg,
|
||||
AccessControl: pluginsMetadata[pluginDef.ID],
|
||||
AngularDetected: pluginDef.Angular.Detected,
|
||||
APIVersion: pluginDef.APIVersion,
|
||||
}
|
||||
|
||||
if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagExternalServiceAccounts) {
|
||||
@ -209,7 +208,6 @@ func (hs *HTTPServer) GetPluginSettingByID(c *contextmodel.ReqContext) response.
|
||||
SignatureOrg: plugin.SignatureOrg,
|
||||
SecureJsonFields: map[string]bool{},
|
||||
AngularDetected: plugin.Angular.Detected,
|
||||
APIVersion: plugin.APIVersion,
|
||||
}
|
||||
|
||||
if plugin.IsApp() {
|
||||
|
@ -3,8 +3,6 @@ package validation
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
@ -117,36 +115,3 @@ func (a *AngularDetector) Validate(ctx context.Context, p *plugins.Plugin) error
|
||||
p.Angular.HideDeprecation = slices.Contains(a.cfg.HideAngularDeprecation, p.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// APIVersionValidation implements a ValidateFunc for validating plugin API versions.
|
||||
type APIVersionValidation struct {
|
||||
}
|
||||
|
||||
// APIVersionValidationStep returns a new ValidateFunc for validating plugin signatures.
|
||||
func APIVersionValidationStep() ValidateFunc {
|
||||
sv := &APIVersionValidation{}
|
||||
return sv.Validate
|
||||
}
|
||||
|
||||
// Validate validates the plugin signature. If a signature error is encountered, the error is recorded with the
|
||||
// pluginerrs.ErrorTracker.
|
||||
func (v *APIVersionValidation) Validate(ctx context.Context, p *plugins.Plugin) error {
|
||||
if p.APIVersion != "" {
|
||||
if !p.Backend {
|
||||
return fmt.Errorf("plugin %s has an API version but is not a backend plugin", p.ID)
|
||||
}
|
||||
// Eventually, all backend plugins will be supported
|
||||
if p.Type != plugins.TypeDataSource {
|
||||
return fmt.Errorf("plugin %s has an API version but is not a datasource plugin", p.ID)
|
||||
}
|
||||
m, err := regexp.MatchString(`^v([\d]+)(?:(alpha|beta)([\d]+))?$`, p.APIVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to verify apiVersion %s: %v", p.APIVersion, err)
|
||||
}
|
||||
if !m {
|
||||
return fmt.Errorf("plugin %s has an invalid API version %s", p.ID, p.APIVersion)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -1,75 +0,0 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAPIVersionValidation(t *testing.T) {
|
||||
s := APIVersionValidationStep()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
plugin *plugins.Plugin
|
||||
err bool
|
||||
}{
|
||||
{
|
||||
name: "valid plugin",
|
||||
plugin: &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{
|
||||
Backend: true,
|
||||
Type: plugins.TypeDataSource,
|
||||
APIVersion: "v0alpha1",
|
||||
},
|
||||
},
|
||||
err: false,
|
||||
},
|
||||
{
|
||||
name: "invalid plugin - not backend",
|
||||
plugin: &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{
|
||||
Backend: false,
|
||||
Type: plugins.TypeDataSource,
|
||||
APIVersion: "v0alpha1",
|
||||
},
|
||||
},
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "invalid plugin - not datasource",
|
||||
plugin: &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{
|
||||
Backend: true,
|
||||
Type: plugins.TypeApp,
|
||||
APIVersion: "v0alpha1",
|
||||
},
|
||||
},
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "invalid plugin - invalid API version",
|
||||
plugin: &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{
|
||||
Backend: true,
|
||||
Type: plugins.TypeDataSource,
|
||||
APIVersion: "invalid",
|
||||
},
|
||||
},
|
||||
err: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := s(context.Background(), tt.plugin)
|
||||
if tt.err {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -128,9 +128,6 @@ type JSONData struct {
|
||||
|
||||
// App Service Auth Registration
|
||||
IAM *pfs.IAM `json:"iam,omitempty"`
|
||||
|
||||
// API Version: Temporary field while plugins don't expose a OpenAPI schema
|
||||
APIVersion string `json:"apiVersion,omitempty"`
|
||||
}
|
||||
|
||||
func ReadPluginJSON(reader io.Reader) (JSONData, error) {
|
||||
|
@ -322,10 +322,7 @@ func (s *Service) prepareInstanceSettings(ctx context.Context, settings *backend
|
||||
}
|
||||
|
||||
// When the APIVersion is set, the client must also implement AdmissionHandler
|
||||
if p.APIVersion == "" {
|
||||
if settings.APIVersion != "" {
|
||||
return nil, fmt.Errorf("invalid request apiVersion (datasource does not have one configured)")
|
||||
}
|
||||
if settings.APIVersion == "" {
|
||||
return settings, nil // NOOP
|
||||
}
|
||||
|
||||
@ -367,7 +364,7 @@ func (s *Service) prepareInstanceSettings(ctx context.Context, settings *backend
|
||||
if err != nil {
|
||||
if errors.Is(err, plugins.ErrMethodNotImplemented) {
|
||||
return nil, errutil.Internal("plugin.unimplemented").
|
||||
Errorf("plugin (%s) with apiVersion=%s must implement ValidateAdmission", p.ID, p.APIVersion)
|
||||
Errorf("plugin (%s) with apiVersion=%s must implement ValidateAdmission", p.ID, settings.APIVersion)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@ -388,7 +385,7 @@ func (s *Service) prepareInstanceSettings(ctx context.Context, settings *backend
|
||||
if err != nil {
|
||||
if errors.Is(err, plugins.ErrMethodNotImplemented) {
|
||||
return nil, errutil.Internal("plugin.unimplemented").
|
||||
Errorf("plugin (%s) with apiVersion=%s must implement MutateAdmission", p.ID, p.APIVersion)
|
||||
Errorf("plugin (%s) with apiVersion=%s must implement MutateAdmission", p.ID, settings.APIVersion)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
@ -110,10 +110,9 @@ func TestService_AddDataSource(t *testing.T) {
|
||||
dsService.pluginStore = &pluginstore.FakePluginStore{
|
||||
PluginList: []pluginstore.Plugin{{
|
||||
JSONData: plugins.JSONData{
|
||||
ID: "test",
|
||||
Type: plugins.TypeDataSource,
|
||||
Name: "test",
|
||||
APIVersion: "v0alpha1", // When a value exists in plugin.json, the callbacks will be executed
|
||||
ID: "test",
|
||||
Type: plugins.TypeDataSource,
|
||||
Name: "test",
|
||||
},
|
||||
}},
|
||||
}
|
||||
@ -150,10 +149,9 @@ func TestService_AddDataSource(t *testing.T) {
|
||||
dsService.pluginStore = &pluginstore.FakePluginStore{
|
||||
PluginList: []pluginstore.Plugin{{
|
||||
JSONData: plugins.JSONData{
|
||||
ID: "test",
|
||||
Type: plugins.TypeDataSource,
|
||||
Name: "test",
|
||||
APIVersion: "v0alpha1", // When a value exists in plugin.json, the callbacks will be executed
|
||||
ID: "test",
|
||||
Type: plugins.TypeDataSource,
|
||||
Name: "test",
|
||||
},
|
||||
}},
|
||||
}
|
||||
@ -200,10 +198,9 @@ func TestService_AddDataSource(t *testing.T) {
|
||||
dsService.pluginStore = &pluginstore.FakePluginStore{
|
||||
PluginList: []pluginstore.Plugin{{
|
||||
JSONData: plugins.JSONData{
|
||||
ID: "test",
|
||||
Type: plugins.TypeDataSource,
|
||||
Name: "test",
|
||||
APIVersion: "v0alpha1", // When a value exists in plugin.json, the callbacks will be executed
|
||||
ID: "test",
|
||||
Type: plugins.TypeDataSource,
|
||||
Name: "test",
|
||||
},
|
||||
}},
|
||||
}
|
||||
@ -491,10 +488,9 @@ func TestService_UpdateDataSource(t *testing.T) {
|
||||
dsService.pluginStore = &pluginstore.FakePluginStore{
|
||||
PluginList: []pluginstore.Plugin{{
|
||||
JSONData: plugins.JSONData{
|
||||
ID: "test",
|
||||
Type: plugins.TypeDataSource,
|
||||
Name: "test",
|
||||
APIVersion: "v0alpha1", // When a value exists in plugin.json, the callbacks will be executed
|
||||
ID: "test",
|
||||
Type: plugins.TypeDataSource,
|
||||
Name: "test",
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
@ -1360,6 +1360,17 @@ var (
|
||||
Owner: awsDatasourcesSquad,
|
||||
Expression: "true",
|
||||
},
|
||||
{
|
||||
Name: "bodyScrolling",
|
||||
Description: "Adjusts Page to make body the scrollable element",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaFrontendPlatformSquad,
|
||||
Expression: "false", // enabled by default
|
||||
FrontendOnly: true,
|
||||
AllowSelfServe: false,
|
||||
HideFromDocs: true,
|
||||
HideFromAdminPage: true,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -180,3 +180,4 @@ passScopeToDashboardApi,experimental,@grafana/dashboards-squad,false,false,false
|
||||
alertingApiServer,experimental,@grafana/alerting-squad,false,true,false
|
||||
dashboardRestoreUI,experimental,@grafana/grafana-frontend-platform,false,false,false
|
||||
cloudWatchRoundUpEndTime,GA,@grafana/aws-datasources,false,false,false
|
||||
bodyScrolling,experimental,@grafana/grafana-frontend-platform,false,false,true
|
||||
|
|
@ -730,4 +730,8 @@ const (
|
||||
// FlagCloudWatchRoundUpEndTime
|
||||
// Round up end time for metric queries to the next minute to avoid missing data
|
||||
FlagCloudWatchRoundUpEndTime = "cloudWatchRoundUpEndTime"
|
||||
|
||||
// FlagBodyScrolling
|
||||
// Adjusts Page to make body the scrollable element
|
||||
FlagBodyScrolling = "bodyScrolling"
|
||||
)
|
||||
|
@ -473,6 +473,21 @@
|
||||
"codeowner": "@grafana/partner-datasources"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "bodyScrolling",
|
||||
"resourceVersion": "1719825052257",
|
||||
"creationTimestamp": "2024-07-01T09:10:52Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Adjusts Page to make body the scrollable element",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/grafana-frontend-platform",
|
||||
"frontend": true,
|
||||
"hideFromAdminPage": true,
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "cachingOptimizeSerializationMemoryUsage",
|
||||
|
@ -856,16 +856,21 @@ func TestGetFolderUIDsForFilter(t *testing.T) {
|
||||
|
||||
result, err := loki.getFolderUIDsForFilter(context.Background(), models.HistoryQuery{OrgID: orgID, SignedInUser: usr})
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, folders, result)
|
||||
assert.ElementsMatch(t, folders, result)
|
||||
|
||||
assert.Len(t, ac.Calls, len(folders)+1)
|
||||
assert.Equal(t, "CanReadAllRules", ac.Calls[0].MethodName)
|
||||
assert.Equal(t, usr, ac.Calls[0].Arguments[1])
|
||||
for i, folderUID := range folders {
|
||||
assert.Equal(t, "HasAccessInFolder", ac.Calls[i+1].MethodName)
|
||||
assert.Equal(t, usr, ac.Calls[i+1].Arguments[1])
|
||||
assert.Equal(t, folderUID, ac.Calls[i+1].Arguments[2].(models.Namespaced).GetNamespaceUID())
|
||||
|
||||
var called []string
|
||||
for _, call := range ac.Calls[1:] {
|
||||
if !assert.Equal(t, "HasAccessInFolder", call.MethodName) {
|
||||
continue
|
||||
}
|
||||
assert.Equal(t, usr, call.Arguments[1])
|
||||
called = append(called, call.Arguments[2].(models.Namespaced).GetNamespaceUID())
|
||||
}
|
||||
assert.ElementsMatch(t, folders, called)
|
||||
|
||||
t.Run("should fail if no folders to read", func(t *testing.T) {
|
||||
loki := createLoki(ac)
|
||||
|
@ -54,7 +54,6 @@ func ProvideValidationStage(cfg *config.PluginManagementCfg, sv signature.Valida
|
||||
SignatureValidationStep(sv),
|
||||
validation.ModuleJSValidationStep(),
|
||||
validation.AngularDetectionStep(cfg, ai),
|
||||
validation.APIVersionValidationStep(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
@ -43,7 +43,6 @@ func (p *BaseProvider) GetBasePluginContext(ctx context.Context, plugin pluginst
|
||||
pCtx := backend.PluginContext{
|
||||
PluginID: plugin.ID,
|
||||
PluginVersion: plugin.Info.Version,
|
||||
APIVersion: plugin.APIVersion,
|
||||
}
|
||||
if user != nil && !user.IsNil() {
|
||||
pCtx.OrgID = user.GetOrgID()
|
||||
|
@ -26,17 +26,15 @@ import (
|
||||
|
||||
func TestGet(t *testing.T) {
|
||||
const (
|
||||
pluginID = "plugin-id"
|
||||
alias = "alias"
|
||||
apiVersion = "v0alpha1"
|
||||
pluginID = "plugin-id"
|
||||
alias = "alias"
|
||||
)
|
||||
|
||||
preg := registry.NewInMemory()
|
||||
require.NoError(t, preg.Add(context.Background(), &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{
|
||||
ID: pluginID,
|
||||
AliasIDs: []string{alias},
|
||||
APIVersion: apiVersion,
|
||||
ID: pluginID,
|
||||
AliasIDs: []string{alias},
|
||||
},
|
||||
}))
|
||||
|
||||
@ -61,7 +59,6 @@ func TestGet(t *testing.T) {
|
||||
pCtx, err := pcp.Get(context.Background(), tc.input, identity, identity.OrgID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, pluginID, pCtx.PluginID)
|
||||
require.Equal(t, apiVersion, pCtx.APIVersion)
|
||||
require.NotNil(t, pCtx.GrafanaConfig)
|
||||
})
|
||||
|
||||
@ -75,7 +72,6 @@ func TestGet(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, pluginID, pCtx.PluginID)
|
||||
require.Equal(t, apiVersion, pCtx.APIVersion)
|
||||
require.NotNil(t, pCtx.GrafanaConfig)
|
||||
})
|
||||
})
|
||||
|
@ -1671,8 +1671,7 @@
|
||||
"signature": "internal",
|
||||
"signatureType": "",
|
||||
"signatureOrg": "",
|
||||
"angularDetected": false,
|
||||
"apiVersion": "v0alpha1"
|
||||
"angularDetected": false
|
||||
},
|
||||
{
|
||||
"name": "Text",
|
||||
|
@ -214,7 +214,7 @@ export class AppChromeService {
|
||||
const { kioskMode, searchBarHidden } = this.state.getValue();
|
||||
|
||||
if (searchBarHidden || kioskMode === KioskMode.TV) {
|
||||
appEvents.emit(AppEvents.alertSuccess, [t('navigation.kiosk.tv-alert', 'Press ESC to exit kiosk mode')]);
|
||||
appEvents.emit(AppEvents.alertInfo, [t('navigation.kiosk.tv-alert', 'Press ESC to exit kiosk mode')]);
|
||||
return KioskMode.Full;
|
||||
}
|
||||
|
||||
|
@ -10,6 +10,7 @@ import { useSelector, useDispatch } from 'app/types';
|
||||
|
||||
import {
|
||||
createErrorNotification,
|
||||
createInfoNotification,
|
||||
createSuccessNotification,
|
||||
createWarningNotification,
|
||||
} from '../../copy/appNotification';
|
||||
@ -25,6 +26,7 @@ export function AppNotificationList() {
|
||||
appEvents.on(AppEvents.alertWarning, (payload) => dispatch(notifyApp(createWarningNotification(...payload))));
|
||||
appEvents.on(AppEvents.alertSuccess, (payload) => dispatch(notifyApp(createSuccessNotification(...payload))));
|
||||
appEvents.on(AppEvents.alertError, (payload) => dispatch(notifyApp(createErrorNotification(...payload))));
|
||||
appEvents.on(AppEvents.alertInfo, (payload) => dispatch(notifyApp(createInfoNotification(...payload))));
|
||||
}, [dispatch]);
|
||||
|
||||
const onClearAppNotification = (id: string) => {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, ReactElement } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { getMessageFromError } from 'app/core/utils/errors';
|
||||
@ -40,7 +40,7 @@ export const createErrorNotification = (
|
||||
title: string,
|
||||
text: string | Error = '',
|
||||
traceId?: string,
|
||||
component?: React.ReactElement
|
||||
component?: ReactElement
|
||||
): AppNotification => {
|
||||
return {
|
||||
...defaultErrorNotification,
|
||||
@ -64,12 +64,23 @@ export const createWarningNotification = (title: string, text = '', traceId?: st
|
||||
showing: true,
|
||||
});
|
||||
|
||||
/** Hook for showing toast notifications with varying severity (success, warning error).
|
||||
export const createInfoNotification = (title: string, text = '', traceId?: string): AppNotification => ({
|
||||
severity: AppNotificationSeverity.Info,
|
||||
icon: 'info-circle',
|
||||
title,
|
||||
text,
|
||||
id: uuidv4(),
|
||||
timestamp: Date.now(),
|
||||
showing: true,
|
||||
});
|
||||
|
||||
/** Hook for showing toast notifications with varying severity (success, warning, error, info).
|
||||
* @example
|
||||
* const notifyApp = useAppNotification();
|
||||
* notifyApp.success('Success!', 'Some additional text');
|
||||
* notifyApp.warning('Warning!');
|
||||
* notifyApp.error('Error!');
|
||||
* notifyApp.info('Info text');
|
||||
*/
|
||||
export function useAppNotification() {
|
||||
const dispatch = useDispatch();
|
||||
@ -84,6 +95,9 @@ export function useAppNotification() {
|
||||
error: (title: string, text = '', traceId?: string) => {
|
||||
dispatch(notifyApp(createErrorNotification(title, text, traceId)));
|
||||
},
|
||||
info: (title: string, text = '') => {
|
||||
dispatch(notifyApp(createInfoNotification(title, text)));
|
||||
},
|
||||
}),
|
||||
[dispatch]
|
||||
);
|
||||
|
@ -17,9 +17,9 @@ import { t, Trans } from 'app/core/internationalization';
|
||||
|
||||
import { ScopesInput } from './ScopesInput';
|
||||
import { ScopesScene } from './ScopesScene';
|
||||
import { ScopesTreeLevel } from './ScopesTreeLevel';
|
||||
import { ScopesTree } from './ScopesTree';
|
||||
import { fetchNodes, fetchScope, fetchSelectedScopes } from './api';
|
||||
import { NodesMap, SelectedScope, TreeScope } from './types';
|
||||
import { NodeReason, NodesMap, SelectedScope, TreeScope } from './types';
|
||||
import { getBasicScope } from './utils';
|
||||
|
||||
export interface ScopesFiltersSceneState extends SceneObjectState {
|
||||
@ -47,6 +47,7 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
|
||||
nodes: {
|
||||
'': {
|
||||
name: '',
|
||||
reason: NodeReason.Result,
|
||||
nodeType: 'container',
|
||||
title: '',
|
||||
isExpandable: true,
|
||||
@ -119,7 +120,19 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
|
||||
})
|
||||
)
|
||||
.subscribe((childNodes) => {
|
||||
currentNode.nodes = childNodes;
|
||||
const persistedNodes = this.state.treeScopes
|
||||
.map(({ path }) => path[path.length - 1])
|
||||
.filter((nodeName) => nodeName in currentNode.nodes && !(nodeName in childNodes))
|
||||
.reduce<NodesMap>((acc, nodeName) => {
|
||||
acc[nodeName] = {
|
||||
...currentNode.nodes[nodeName],
|
||||
reason: NodeReason.Persisted,
|
||||
};
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
currentNode.nodes = { ...persistedNodes, ...childNodes };
|
||||
|
||||
this.setState({ nodes });
|
||||
|
||||
@ -284,7 +297,7 @@ export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps<Scopes
|
||||
{isLoadingScopes ? (
|
||||
<Spinner data-testid="scopes-filters-loading" />
|
||||
) : (
|
||||
<ScopesTreeLevel
|
||||
<ScopesTree
|
||||
nodes={nodes}
|
||||
nodePath={['']}
|
||||
loadingNodeName={loadingNodeName}
|
||||
|
@ -1,10 +1,9 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { groupBy } from 'lodash';
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { IconButton, Input, Tooltip } from '@grafana/ui';
|
||||
import { useStyles2 } from '@grafana/ui/';
|
||||
import { IconButton, Input, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { NodesMap, SelectedScope } from './types';
|
||||
@ -28,6 +27,12 @@ export function ScopesInput({
|
||||
}: ScopesInputProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const [isTooltipVisible, setIsTooltipVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsTooltipVisible(false);
|
||||
}, [scopes]);
|
||||
|
||||
const scopesPaths = useMemo(() => {
|
||||
const pathsTitles = scopes.map(({ scope, path }) => {
|
||||
let currentLevel = nodes;
|
||||
@ -64,7 +69,7 @@ export function ScopesInput({
|
||||
|
||||
const groupedByPath = groupBy(pathsTitles, ([path]) => path);
|
||||
|
||||
return Object.entries(groupedByPath)
|
||||
const scopesPaths = Object.entries(groupedByPath)
|
||||
.map(([path, pathScopes]) => {
|
||||
const scopesTitles = pathScopes.map(([, scopeTitle]) => scopeTitle).join(', ');
|
||||
|
||||
@ -75,41 +80,44 @@ export function ScopesInput({
|
||||
{path}
|
||||
</p>
|
||||
));
|
||||
|
||||
return <>{scopesPaths}</>;
|
||||
}, [nodes, scopes, styles]);
|
||||
|
||||
const scopesTitles = useMemo(() => scopes.map(({ scope }) => scope.spec.title).join(', '), [scopes]);
|
||||
|
||||
const input = (
|
||||
<Input
|
||||
readOnly
|
||||
placeholder={t('scopes.filters.input.placeholder', 'Select scopes...')}
|
||||
loading={isLoading}
|
||||
value={scopesTitles}
|
||||
aria-label={t('scopes.filters.input.placeholder', 'Select scopes...')}
|
||||
data-testid="scopes-filters-input"
|
||||
suffix={
|
||||
scopes.length > 0 && !isDisabled ? (
|
||||
<IconButton
|
||||
aria-label={t('scopes.filters.input.removeAll', 'Remove all scopes')}
|
||||
name="times"
|
||||
onClick={() => onRemoveAllClick()}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (!isDisabled) {
|
||||
onInputClick();
|
||||
const input = useMemo(
|
||||
() => (
|
||||
<Input
|
||||
readOnly
|
||||
placeholder={t('scopes.filters.input.placeholder', 'Select scopes...')}
|
||||
loading={isLoading}
|
||||
value={scopesTitles}
|
||||
aria-label={t('scopes.filters.input.placeholder', 'Select scopes...')}
|
||||
data-testid="scopes-filters-input"
|
||||
suffix={
|
||||
scopes.length > 0 && !isDisabled ? (
|
||||
<IconButton
|
||||
aria-label={t('scopes.filters.input.removeAll', 'Remove all scopes')}
|
||||
name="times"
|
||||
onClick={() => onRemoveAllClick()}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
}}
|
||||
/>
|
||||
onMouseOver={() => setIsTooltipVisible(true)}
|
||||
onMouseOut={() => setIsTooltipVisible(false)}
|
||||
onClick={() => {
|
||||
if (!isDisabled) {
|
||||
onInputClick();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
[isDisabled, isLoading, onInputClick, onRemoveAllClick, scopes, scopesTitles]
|
||||
);
|
||||
|
||||
if (scopes.length === 0) {
|
||||
return input;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content={<>{scopesPaths}</>} interactive={true}>
|
||||
<Tooltip content={scopesPaths} show={scopes.length === 0 ? false : isTooltipVisible}>
|
||||
{input}
|
||||
</Tooltip>
|
||||
);
|
||||
|
@ -10,47 +10,54 @@ import { ScopesFiltersScene } from './ScopesFiltersScene';
|
||||
import { ScopesScene } from './ScopesScene';
|
||||
import {
|
||||
buildTestScene,
|
||||
fetchSuggestedDashboardsSpy,
|
||||
fetchNodesSpy,
|
||||
fetchScopeSpy,
|
||||
fetchSelectedScopesSpy,
|
||||
getApplicationsClustersExpand,
|
||||
getApplicationsClustersSelect,
|
||||
getApplicationsClustersSlothClusterNorthSelect,
|
||||
getApplicationsClustersSlothClusterSouthSelect,
|
||||
getApplicationsExpand,
|
||||
getApplicationsSearch,
|
||||
getApplicationsSlothPictureFactorySelect,
|
||||
getApplicationsSlothPictureFactoryTitle,
|
||||
getApplicationsSlothVoteTrackerSelect,
|
||||
getFiltersApply,
|
||||
getFiltersCancel,
|
||||
getFiltersInput,
|
||||
getClustersExpand,
|
||||
getClustersSelect,
|
||||
getClustersSlothClusterNorthRadio,
|
||||
getClustersSlothClusterSouthRadio,
|
||||
fetchSuggestedDashboardsSpy,
|
||||
getDashboard,
|
||||
getDashboardsContainer,
|
||||
getDashboardsExpand,
|
||||
getDashboardsSearch,
|
||||
getFiltersApply,
|
||||
getFiltersCancel,
|
||||
getFiltersInput,
|
||||
getMock,
|
||||
getNotFoundForFilter,
|
||||
getNotFoundForFilterClear,
|
||||
getNotFoundForScope,
|
||||
getNotFoundNoScopes,
|
||||
getPersistedApplicationsSlothPictureFactorySelect,
|
||||
getPersistedApplicationsSlothPictureFactoryTitle,
|
||||
getPersistedApplicationsSlothVoteTrackerTitle,
|
||||
getResultApplicationsClustersExpand,
|
||||
getResultApplicationsClustersSelect,
|
||||
getResultApplicationsClustersSlothClusterNorthSelect,
|
||||
getResultApplicationsClustersSlothClusterSouthSelect,
|
||||
getResultApplicationsExpand,
|
||||
getResultApplicationsSlothPictureFactorySelect,
|
||||
getResultApplicationsSlothPictureFactoryTitle,
|
||||
getResultApplicationsSlothVoteTrackerSelect,
|
||||
getResultApplicationsSlothVoteTrackerTitle,
|
||||
getResultClustersExpand,
|
||||
getResultClustersSelect,
|
||||
getResultClustersSlothClusterEastRadio,
|
||||
getResultClustersSlothClusterNorthRadio,
|
||||
getResultClustersSlothClusterSouthRadio,
|
||||
getTreeHeadline,
|
||||
getTreeSearch,
|
||||
mocksScopes,
|
||||
queryAllDashboard,
|
||||
queryFiltersApply,
|
||||
queryApplicationsClustersTitle,
|
||||
queryApplicationsSlothPictureFactoryTitle,
|
||||
queryApplicationsSlothVoteTrackerTitle,
|
||||
queryDashboard,
|
||||
queryDashboardsContainer,
|
||||
queryDashboardsExpand,
|
||||
renderDashboard,
|
||||
getNotFoundForScope,
|
||||
queryDashboardsSearch,
|
||||
getNotFoundForFilter,
|
||||
getClustersSlothClusterEastRadio,
|
||||
getNotFoundForFilterClear,
|
||||
getNotFoundNoScopes,
|
||||
queryFiltersApply,
|
||||
queryPersistedApplicationsSlothPictureFactoryTitle,
|
||||
queryPersistedApplicationsSlothVoteTrackerTitle,
|
||||
queryResultApplicationsClustersTitle,
|
||||
queryResultApplicationsSlothPictureFactoryTitle,
|
||||
queryResultApplicationsSlothVoteTrackerTitle,
|
||||
renderDashboard,
|
||||
} from './testUtils';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
@ -106,15 +113,15 @@ describe('ScopesScene', () => {
|
||||
describe('Tree', () => {
|
||||
it('Navigates through scopes nodes', async () => {
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getApplicationsExpand());
|
||||
await userEvents.click(getApplicationsClustersExpand());
|
||||
await userEvents.click(getApplicationsExpand());
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
await userEvents.click(getResultApplicationsClustersExpand());
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
});
|
||||
|
||||
it('Fetches scope details on select', async () => {
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getApplicationsExpand());
|
||||
await userEvents.click(getApplicationsSlothVoteTrackerSelect());
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
|
||||
await waitFor(() => expect(fetchScopeSpy).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
@ -126,77 +133,167 @@ describe('ScopesScene', () => {
|
||||
])
|
||||
);
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getApplicationsExpand());
|
||||
expect(getApplicationsSlothVoteTrackerSelect()).toBeChecked();
|
||||
expect(getApplicationsSlothPictureFactorySelect()).toBeChecked();
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
expect(getResultApplicationsSlothVoteTrackerSelect()).toBeChecked();
|
||||
expect(getResultApplicationsSlothPictureFactorySelect()).toBeChecked();
|
||||
});
|
||||
|
||||
it('Can select scopes from same level', async () => {
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getApplicationsExpand());
|
||||
await userEvents.click(getApplicationsSlothVoteTrackerSelect());
|
||||
await userEvents.click(getApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getApplicationsClustersSelect());
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
|
||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getResultApplicationsClustersSelect());
|
||||
await userEvents.click(getFiltersApply());
|
||||
expect(getFiltersInput().value).toBe('slothVoteTracker, slothPictureFactory, Cluster Index Helper');
|
||||
});
|
||||
|
||||
it('Can select a node from an inner level', async () => {
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getApplicationsExpand());
|
||||
await userEvents.click(getApplicationsSlothVoteTrackerSelect());
|
||||
await userEvents.click(getApplicationsClustersExpand());
|
||||
await userEvents.click(getApplicationsClustersSlothClusterNorthSelect());
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
|
||||
await userEvents.click(getResultApplicationsClustersExpand());
|
||||
await userEvents.click(getResultApplicationsClustersSlothClusterNorthSelect());
|
||||
await userEvents.click(getFiltersApply());
|
||||
expect(getFiltersInput().value).toBe('slothClusterNorth');
|
||||
});
|
||||
|
||||
it('Can select a node from an upper level', async () => {
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getApplicationsExpand());
|
||||
await userEvents.click(getApplicationsSlothVoteTrackerSelect());
|
||||
await userEvents.click(getApplicationsExpand());
|
||||
await userEvents.click(getClustersSelect());
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
await userEvents.click(getResultClustersSelect());
|
||||
await userEvents.click(getFiltersApply());
|
||||
expect(getFiltersInput().value).toBe('Cluster Index Helper');
|
||||
});
|
||||
|
||||
it('Respects only one select per container', async () => {
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getClustersExpand());
|
||||
await userEvents.click(getClustersSlothClusterNorthRadio());
|
||||
expect(getClustersSlothClusterNorthRadio().checked).toBe(true);
|
||||
expect(getClustersSlothClusterSouthRadio().checked).toBe(false);
|
||||
await userEvents.click(getClustersSlothClusterSouthRadio());
|
||||
expect(getClustersSlothClusterNorthRadio().checked).toBe(false);
|
||||
expect(getClustersSlothClusterSouthRadio().checked).toBe(true);
|
||||
await userEvents.click(getResultClustersExpand());
|
||||
await userEvents.click(getResultClustersSlothClusterNorthRadio());
|
||||
expect(getResultClustersSlothClusterNorthRadio().checked).toBe(true);
|
||||
expect(getResultClustersSlothClusterSouthRadio().checked).toBe(false);
|
||||
await userEvents.click(getResultClustersSlothClusterSouthRadio());
|
||||
expect(getResultClustersSlothClusterNorthRadio().checked).toBe(false);
|
||||
expect(getResultClustersSlothClusterSouthRadio().checked).toBe(true);
|
||||
});
|
||||
|
||||
it('Search works', async () => {
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getApplicationsExpand());
|
||||
await userEvents.type(getApplicationsSearch(), 'Clusters');
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
await userEvents.type(getTreeSearch(), 'Clusters');
|
||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
||||
expect(queryApplicationsSlothPictureFactoryTitle()).not.toBeInTheDocument();
|
||||
expect(queryApplicationsSlothVoteTrackerTitle()).not.toBeInTheDocument();
|
||||
expect(getApplicationsClustersSelect()).toBeInTheDocument();
|
||||
await userEvents.clear(getApplicationsSearch());
|
||||
await userEvents.type(getApplicationsSearch(), 'sloth');
|
||||
expect(queryResultApplicationsSlothPictureFactoryTitle()).not.toBeInTheDocument();
|
||||
expect(queryResultApplicationsSlothVoteTrackerTitle()).not.toBeInTheDocument();
|
||||
expect(getResultApplicationsClustersSelect()).toBeInTheDocument();
|
||||
await userEvents.clear(getTreeSearch());
|
||||
await userEvents.type(getTreeSearch(), 'sloth');
|
||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(4));
|
||||
expect(getApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
|
||||
expect(getApplicationsSlothVoteTrackerSelect()).toBeInTheDocument();
|
||||
expect(queryApplicationsClustersTitle()).not.toBeInTheDocument();
|
||||
expect(getResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
|
||||
expect(getResultApplicationsSlothVoteTrackerSelect()).toBeInTheDocument();
|
||||
expect(queryResultApplicationsClustersTitle()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Opens to a selected scope', async () => {
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getApplicationsExpand());
|
||||
await userEvents.click(getApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getApplicationsExpand());
|
||||
await userEvents.click(getClustersExpand());
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
await userEvents.click(getResultClustersExpand());
|
||||
await userEvents.click(getFiltersApply());
|
||||
await userEvents.click(getFiltersInput());
|
||||
expect(queryApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
|
||||
expect(queryResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Persists a scope', async () => {
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.type(getTreeSearch(), 'slothVoteTracker');
|
||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
||||
expect(getPersistedApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
|
||||
expect(queryPersistedApplicationsSlothVoteTrackerTitle()).not.toBeInTheDocument();
|
||||
expect(queryResultApplicationsSlothPictureFactoryTitle()).not.toBeInTheDocument();
|
||||
expect(getResultApplicationsSlothVoteTrackerTitle()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Does not persist a retrieved scope', async () => {
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.type(getTreeSearch(), 'slothPictureFactory');
|
||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
||||
expect(queryPersistedApplicationsSlothPictureFactoryTitle()).not.toBeInTheDocument();
|
||||
expect(getResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Removes persisted nodes', async () => {
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.type(getTreeSearch(), 'slothVoteTracker');
|
||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
||||
await userEvents.clear(getTreeSearch());
|
||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(4));
|
||||
expect(queryPersistedApplicationsSlothPictureFactoryTitle()).not.toBeInTheDocument();
|
||||
expect(queryPersistedApplicationsSlothVoteTrackerTitle()).not.toBeInTheDocument();
|
||||
expect(getResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
|
||||
expect(getResultApplicationsSlothVoteTrackerTitle()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Persists nodes from search', async () => {
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
await userEvents.type(getTreeSearch(), 'sloth');
|
||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
|
||||
await userEvents.type(getTreeSearch(), 'slothunknown');
|
||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(4));
|
||||
expect(getPersistedApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
|
||||
expect(getPersistedApplicationsSlothVoteTrackerTitle()).toBeInTheDocument();
|
||||
await userEvents.clear(getTreeSearch());
|
||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(5));
|
||||
expect(getResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument();
|
||||
expect(getResultApplicationsSlothVoteTrackerTitle()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Selects a persisted scope', async () => {
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.type(getTreeSearch(), 'slothVoteTracker');
|
||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
||||
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
|
||||
await userEvents.click(getFiltersApply());
|
||||
expect(getFiltersInput().value).toBe('slothPictureFactory, slothVoteTracker');
|
||||
});
|
||||
|
||||
it('Deselects a persisted scope', async () => {
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.type(getTreeSearch(), 'slothVoteTracker');
|
||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
||||
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
|
||||
await userEvents.click(getFiltersApply());
|
||||
expect(getFiltersInput().value).toBe('slothPictureFactory, slothVoteTracker');
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getPersistedApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getFiltersApply());
|
||||
expect(getFiltersInput().value).toBe('slothVoteTracker');
|
||||
});
|
||||
|
||||
it('Shows the proper headline', async () => {
|
||||
await userEvents.click(getFiltersInput());
|
||||
expect(getTreeHeadline()).toHaveTextContent('Recommended');
|
||||
await userEvents.type(getTreeSearch(), 'Applications');
|
||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(2));
|
||||
expect(getTreeHeadline()).toHaveTextContent('Results');
|
||||
await userEvents.type(getTreeSearch(), 'unknown');
|
||||
await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3));
|
||||
expect(getTreeHeadline()).toHaveTextContent('No results found for your query');
|
||||
});
|
||||
});
|
||||
|
||||
@ -208,7 +305,7 @@ describe('ScopesScene', () => {
|
||||
|
||||
it('Fetches scope details on save', async () => {
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getClustersSelect());
|
||||
await userEvents.click(getResultClustersSelect());
|
||||
await userEvents.click(getFiltersApply());
|
||||
await waitFor(() => expect(fetchSelectedScopesSpy).toHaveBeenCalled());
|
||||
expect(filtersScene.getSelectedScopes()).toEqual(
|
||||
@ -218,7 +315,7 @@ describe('ScopesScene', () => {
|
||||
|
||||
it("Doesn't save the scopes on close", async () => {
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getClustersSelect());
|
||||
await userEvents.click(getResultClustersSelect());
|
||||
await userEvents.click(getFiltersCancel());
|
||||
await waitFor(() => expect(fetchSelectedScopesSpy).not.toHaveBeenCalled());
|
||||
expect(filtersScene.getSelectedScopes()).toEqual([]);
|
||||
@ -226,7 +323,7 @@ describe('ScopesScene', () => {
|
||||
|
||||
it('Shows selected scopes', async () => {
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getClustersSelect());
|
||||
await userEvents.click(getResultClustersSelect());
|
||||
await userEvents.click(getFiltersApply());
|
||||
expect(getFiltersInput().value).toEqual('Cluster Index Helper');
|
||||
});
|
||||
@ -240,8 +337,8 @@ describe('ScopesScene', () => {
|
||||
|
||||
it('Does not fetch dashboards list when the list is not expanded', async () => {
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getApplicationsExpand());
|
||||
await userEvents.click(getApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getFiltersApply());
|
||||
await waitFor(() => expect(fetchSuggestedDashboardsSpy).not.toHaveBeenCalled());
|
||||
});
|
||||
@ -249,16 +346,16 @@ describe('ScopesScene', () => {
|
||||
it('Fetches dashboards list when the list is expanded', async () => {
|
||||
await userEvents.click(getDashboardsExpand());
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getApplicationsExpand());
|
||||
await userEvents.click(getApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getFiltersApply());
|
||||
await waitFor(() => expect(fetchSuggestedDashboardsSpy).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('Fetches dashboards list when the list is expanded after scope selection', async () => {
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getApplicationsExpand());
|
||||
await userEvents.click(getApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getFiltersApply());
|
||||
await userEvents.click(getDashboardsExpand());
|
||||
await waitFor(() => expect(fetchSuggestedDashboardsSpy).toHaveBeenCalled());
|
||||
@ -267,22 +364,22 @@ describe('ScopesScene', () => {
|
||||
it('Shows dashboards for multiple scopes', async () => {
|
||||
await userEvents.click(getDashboardsExpand());
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getApplicationsExpand());
|
||||
await userEvents.click(getApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getFiltersApply());
|
||||
expect(getDashboard('1')).toBeInTheDocument();
|
||||
expect(getDashboard('2')).toBeInTheDocument();
|
||||
expect(queryDashboard('3')).not.toBeInTheDocument();
|
||||
expect(queryDashboard('4')).not.toBeInTheDocument();
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getApplicationsSlothVoteTrackerSelect());
|
||||
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
|
||||
await userEvents.click(getFiltersApply());
|
||||
expect(getDashboard('1')).toBeInTheDocument();
|
||||
expect(getDashboard('2')).toBeInTheDocument();
|
||||
expect(getDashboard('3')).toBeInTheDocument();
|
||||
expect(getDashboard('4')).toBeInTheDocument();
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getFiltersApply());
|
||||
expect(queryDashboard('1')).not.toBeInTheDocument();
|
||||
expect(queryDashboard('2')).not.toBeInTheDocument();
|
||||
@ -293,8 +390,8 @@ describe('ScopesScene', () => {
|
||||
it('Filters the dashboards list', async () => {
|
||||
await userEvents.click(getDashboardsExpand());
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getApplicationsExpand());
|
||||
await userEvents.click(getApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getFiltersApply());
|
||||
expect(getDashboard('1')).toBeInTheDocument();
|
||||
expect(getDashboard('2')).toBeInTheDocument();
|
||||
@ -305,10 +402,10 @@ describe('ScopesScene', () => {
|
||||
it('Deduplicates the dashboards list', async () => {
|
||||
await userEvents.click(getDashboardsExpand());
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getApplicationsExpand());
|
||||
await userEvents.click(getApplicationsClustersExpand());
|
||||
await userEvents.click(getApplicationsClustersSlothClusterNorthSelect());
|
||||
await userEvents.click(getApplicationsClustersSlothClusterSouthSelect());
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
await userEvents.click(getResultApplicationsClustersExpand());
|
||||
await userEvents.click(getResultApplicationsClustersSlothClusterNorthSelect());
|
||||
await userEvents.click(getResultApplicationsClustersSlothClusterSouthSelect());
|
||||
await userEvents.click(getFiltersApply());
|
||||
expect(queryAllDashboard('5')).toHaveLength(1);
|
||||
expect(queryAllDashboard('6')).toHaveLength(1);
|
||||
@ -325,8 +422,8 @@ describe('ScopesScene', () => {
|
||||
it('Does not show the input when there are no dashboards found for scope', async () => {
|
||||
await userEvents.click(getDashboardsExpand());
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getClustersExpand());
|
||||
await userEvents.click(getClustersSlothClusterEastRadio());
|
||||
await userEvents.click(getResultClustersExpand());
|
||||
await userEvents.click(getResultClustersSlothClusterEastRadio());
|
||||
await userEvents.click(getFiltersApply());
|
||||
expect(getNotFoundForScope()).toBeInTheDocument();
|
||||
expect(queryDashboardsSearch()).not.toBeInTheDocument();
|
||||
@ -335,8 +432,8 @@ describe('ScopesScene', () => {
|
||||
it('Does show the input and a message when there are no dashboards found for filter', async () => {
|
||||
await userEvents.click(getDashboardsExpand());
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getApplicationsExpand());
|
||||
await userEvents.click(getApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getFiltersApply());
|
||||
await userEvents.type(getDashboardsSearch(), 'unknown');
|
||||
expect(queryDashboardsSearch()).toBeInTheDocument();
|
||||
@ -380,8 +477,8 @@ describe('ScopesScene', () => {
|
||||
describe('Enrichers', () => {
|
||||
it('Data requests', async () => {
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getApplicationsExpand());
|
||||
await userEvents.click(getApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getFiltersApply());
|
||||
await waitFor(() => {
|
||||
const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;
|
||||
@ -391,7 +488,7 @@ describe('ScopesScene', () => {
|
||||
});
|
||||
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getApplicationsSlothVoteTrackerSelect());
|
||||
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
|
||||
await userEvents.click(getFiltersApply());
|
||||
await waitFor(() => {
|
||||
const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;
|
||||
@ -403,7 +500,7 @@ describe('ScopesScene', () => {
|
||||
});
|
||||
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getFiltersApply());
|
||||
await waitFor(() => {
|
||||
const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;
|
||||
@ -415,8 +512,8 @@ describe('ScopesScene', () => {
|
||||
|
||||
it('Filters requests', async () => {
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getApplicationsExpand());
|
||||
await userEvents.click(getApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getResultApplicationsExpand());
|
||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getFiltersApply());
|
||||
await waitFor(() => {
|
||||
expect(dashboardScene.enrichFiltersRequest().scopes).toEqual(
|
||||
@ -425,7 +522,7 @@ describe('ScopesScene', () => {
|
||||
});
|
||||
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getApplicationsSlothVoteTrackerSelect());
|
||||
await userEvents.click(getResultApplicationsSlothVoteTrackerSelect());
|
||||
await userEvents.click(getFiltersApply());
|
||||
await waitFor(() => {
|
||||
expect(dashboardScene.enrichFiltersRequest().scopes).toEqual(
|
||||
@ -436,7 +533,7 @@ describe('ScopesScene', () => {
|
||||
});
|
||||
|
||||
await userEvents.click(getFiltersInput());
|
||||
await userEvents.click(getApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getResultApplicationsSlothPictureFactorySelect());
|
||||
await userEvents.click(getFiltersApply());
|
||||
await waitFor(() => {
|
||||
expect(dashboardScene.enrichFiltersRequest().scopes).toEqual(
|
||||
|
@ -0,0 +1,81 @@
|
||||
import { groupBy } from 'lodash';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { ScopesTreeHeadline } from './ScopesTreeHeadline';
|
||||
import { ScopesTreeItem } from './ScopesTreeItem';
|
||||
import { ScopesTreeLoading } from './ScopesTreeLoading';
|
||||
import { ScopesTreeSearch } from './ScopesTreeSearch';
|
||||
import { NodeReason, NodesMap, OnNodeSelectToggle, OnNodeUpdate, TreeScope } from './types';
|
||||
|
||||
export interface ScopesTreeProps {
|
||||
nodes: NodesMap;
|
||||
nodePath: string[];
|
||||
loadingNodeName: string | undefined;
|
||||
scopes: TreeScope[];
|
||||
onNodeUpdate: OnNodeUpdate;
|
||||
onNodeSelectToggle: OnNodeSelectToggle;
|
||||
}
|
||||
|
||||
export function ScopesTree({
|
||||
nodes,
|
||||
nodePath,
|
||||
loadingNodeName,
|
||||
scopes,
|
||||
onNodeUpdate,
|
||||
onNodeSelectToggle,
|
||||
}: ScopesTreeProps) {
|
||||
const nodeId = nodePath[nodePath.length - 1];
|
||||
const node = nodes[nodeId];
|
||||
const childNodes = Object.values(node.nodes);
|
||||
const isNodeLoading = loadingNodeName === nodeId;
|
||||
const scopeNames = scopes.map(({ scopeName }) => scopeName);
|
||||
const anyChildExpanded = childNodes.some(({ isExpanded }) => isExpanded);
|
||||
const groupedNodes = useMemo(() => groupBy(childNodes, 'reason'), [childNodes]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScopesTreeSearch
|
||||
anyChildExpanded={anyChildExpanded}
|
||||
nodePath={nodePath}
|
||||
query={node.query}
|
||||
onNodeUpdate={onNodeUpdate}
|
||||
/>
|
||||
|
||||
<ScopesTreeLoading isNodeLoading={isNodeLoading}>
|
||||
<ScopesTreeItem
|
||||
anyChildExpanded={anyChildExpanded}
|
||||
isNodeLoading={isNodeLoading}
|
||||
loadingNodeName={loadingNodeName}
|
||||
node={node}
|
||||
nodePath={nodePath}
|
||||
nodes={groupedNodes[NodeReason.Persisted] ?? []}
|
||||
scopes={scopes}
|
||||
scopeNames={scopeNames}
|
||||
type="persisted"
|
||||
onNodeSelectToggle={onNodeSelectToggle}
|
||||
onNodeUpdate={onNodeUpdate}
|
||||
/>
|
||||
|
||||
<ScopesTreeHeadline
|
||||
anyChildExpanded={anyChildExpanded}
|
||||
query={node.query}
|
||||
resultsNodes={groupedNodes[NodeReason.Result] ?? []}
|
||||
/>
|
||||
|
||||
<ScopesTreeItem
|
||||
anyChildExpanded={anyChildExpanded}
|
||||
isNodeLoading={isNodeLoading}
|
||||
loadingNodeName={loadingNodeName}
|
||||
node={node}
|
||||
nodePath={nodePath}
|
||||
nodes={groupedNodes[NodeReason.Result] ?? []}
|
||||
scopes={scopes}
|
||||
scopeNames={scopeNames}
|
||||
type="result"
|
||||
onNodeSelectToggle={onNodeSelectToggle}
|
||||
onNodeUpdate={onNodeUpdate}
|
||||
/>
|
||||
</ScopesTreeLoading>
|
||||
</>
|
||||
);
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
|
||||
import { Node } from './types';
|
||||
|
||||
export interface ScopesTreeHeadlineProps {
|
||||
anyChildExpanded: boolean;
|
||||
query: string;
|
||||
resultsNodes: Node[];
|
||||
}
|
||||
|
||||
export function ScopesTreeHeadline({ anyChildExpanded, query, resultsNodes }: ScopesTreeHeadlineProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
if (anyChildExpanded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<h6 className={styles.container} data-testid="scopes-tree-headline">
|
||||
{!query ? (
|
||||
<Trans i18nKey="scopes.tree.headline.recommended">Recommended</Trans>
|
||||
) : resultsNodes.length === 0 ? (
|
||||
<Trans i18nKey="scopes.tree.headline.noResults">No results found for your query</Trans>
|
||||
) : (
|
||||
<Trans i18nKey="scopes.tree.headline.results">Results</Trans>
|
||||
)}
|
||||
</h6>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
container: css({
|
||||
color: theme.colors.text.secondary,
|
||||
margin: theme.spacing(1, 0),
|
||||
}),
|
||||
};
|
||||
};
|
@ -0,0 +1,143 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Checkbox, Icon, RadioButtonDot, useStyles2 } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { ScopesTree } from './ScopesTree';
|
||||
import { Node, OnNodeSelectToggle, OnNodeUpdate, TreeScope } from './types';
|
||||
|
||||
export interface ScopesTreeItemProps {
|
||||
anyChildExpanded: boolean;
|
||||
isNodeLoading: boolean;
|
||||
loadingNodeName: string | undefined;
|
||||
node: Node;
|
||||
nodePath: string[];
|
||||
nodes: Node[];
|
||||
scopeNames: string[];
|
||||
scopes: TreeScope[];
|
||||
type: 'persisted' | 'result';
|
||||
onNodeUpdate: OnNodeUpdate;
|
||||
onNodeSelectToggle: OnNodeSelectToggle;
|
||||
}
|
||||
|
||||
export function ScopesTreeItem({
|
||||
anyChildExpanded,
|
||||
loadingNodeName,
|
||||
node,
|
||||
nodePath,
|
||||
nodes,
|
||||
scopeNames,
|
||||
scopes,
|
||||
type,
|
||||
onNodeSelectToggle,
|
||||
onNodeUpdate,
|
||||
}: ScopesTreeItemProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div role="tree">
|
||||
{nodes.map((childNode) => {
|
||||
const isSelected = childNode.isSelectable && scopeNames.includes(childNode.linkId!);
|
||||
|
||||
if (anyChildExpanded && !childNode.isExpanded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const childNodePath = [...nodePath, childNode.name];
|
||||
|
||||
const radioName = childNodePath.join('.');
|
||||
|
||||
return (
|
||||
<div key={childNode.name} role="treeitem" aria-selected={childNode.isExpanded}>
|
||||
<div className={styles.title}>
|
||||
{childNode.isSelectable && !childNode.isExpanded ? (
|
||||
node.disableMultiSelect ? (
|
||||
<RadioButtonDot
|
||||
id={radioName}
|
||||
name={radioName}
|
||||
checked={isSelected}
|
||||
label=""
|
||||
data-testid={`scopes-tree-${type}-${childNode.name}-radio`}
|
||||
onClick={() => {
|
||||
onNodeSelectToggle(childNodePath);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
data-testid={`scopes-tree-${type}-${childNode.name}-checkbox`}
|
||||
onChange={() => {
|
||||
onNodeSelectToggle(childNodePath);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{childNode.isExpandable ? (
|
||||
<button
|
||||
className={styles.expand}
|
||||
data-testid={`scopes-tree-${type}-${childNode.name}-expand`}
|
||||
aria-label={
|
||||
childNode.isExpanded ? t('scopes.tree.collapse', 'Collapse') : t('scopes.tree.expand', 'Expand')
|
||||
}
|
||||
onClick={() => {
|
||||
onNodeUpdate(childNodePath, !childNode.isExpanded, childNode.query);
|
||||
}}
|
||||
>
|
||||
<Icon name={!childNode.isExpanded ? 'angle-right' : 'angle-down'} />
|
||||
|
||||
{childNode.title}
|
||||
</button>
|
||||
) : (
|
||||
<span data-testid={`scopes-tree-${type}-${childNode.name}-title`}>{childNode.title}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.children}>
|
||||
{childNode.isExpanded && (
|
||||
<ScopesTree
|
||||
nodes={node.nodes}
|
||||
nodePath={childNodePath}
|
||||
loadingNodeName={loadingNodeName}
|
||||
scopes={scopes}
|
||||
onNodeUpdate={onNodeUpdate}
|
||||
onNodeSelectToggle={onNodeSelectToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
title: css({
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
gap: theme.spacing(1),
|
||||
fontSize: theme.typography.pxToRem(14),
|
||||
lineHeight: theme.typography.pxToRem(22),
|
||||
padding: theme.spacing(0.5, 0),
|
||||
|
||||
'& > label': css({
|
||||
gap: 0,
|
||||
}),
|
||||
}),
|
||||
expand: css({
|
||||
alignItems: 'center',
|
||||
background: 'none',
|
||||
border: 0,
|
||||
display: 'flex',
|
||||
gap: theme.spacing(1),
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}),
|
||||
children: css({
|
||||
paddingLeft: theme.spacing(4),
|
||||
}),
|
||||
};
|
||||
};
|
@ -1,185 +0,0 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { debounce } from 'lodash';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Checkbox, FilterInput, Icon, RadioButtonDot, useStyles2 } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
|
||||
import { NodesMap, TreeScope } from './types';
|
||||
|
||||
export interface ScopesTreeLevelProps {
|
||||
nodes: NodesMap;
|
||||
nodePath: string[];
|
||||
loadingNodeName: string | undefined;
|
||||
scopes: TreeScope[];
|
||||
onNodeUpdate: (path: string[], isExpanded: boolean, query: string) => void;
|
||||
onNodeSelectToggle: (path: string[]) => void;
|
||||
}
|
||||
|
||||
export function ScopesTreeLevel({
|
||||
nodes,
|
||||
nodePath,
|
||||
loadingNodeName,
|
||||
scopes,
|
||||
onNodeUpdate,
|
||||
onNodeSelectToggle,
|
||||
}: ScopesTreeLevelProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const nodeId = nodePath[nodePath.length - 1];
|
||||
const node = nodes[nodeId];
|
||||
const childNodes = node.nodes;
|
||||
const childNodesArr = Object.values(childNodes);
|
||||
const isNodeLoading = loadingNodeName === nodeId;
|
||||
|
||||
const scopeNames = scopes.map(({ scopeName }) => scopeName);
|
||||
const anyChildExpanded = childNodesArr.some(({ isExpanded }) => isExpanded);
|
||||
|
||||
const [queryValue, setQueryValue] = useState(node.query);
|
||||
useEffect(() => {
|
||||
setQueryValue(node.query);
|
||||
}, [node.query]);
|
||||
const onQueryUpdate = useMemo(() => debounce(onNodeUpdate, 500), [onNodeUpdate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!anyChildExpanded && (
|
||||
<FilterInput
|
||||
placeholder={t('scopes.tree.search', 'Search')}
|
||||
value={queryValue}
|
||||
className={styles.searchInput}
|
||||
data-testid={`scopes-tree-${nodeId}-search`}
|
||||
onChange={(value) => {
|
||||
setQueryValue(value);
|
||||
onQueryUpdate(nodePath, true, value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!anyChildExpanded && !node.query && (
|
||||
<h6 className={styles.headline}>
|
||||
<Trans i18nKey="scopes.tree.headline">Recommended</Trans>
|
||||
</h6>
|
||||
)}
|
||||
|
||||
<div role="tree">
|
||||
{isNodeLoading && <Skeleton count={5} className={styles.loader} />}
|
||||
|
||||
{!isNodeLoading &&
|
||||
childNodesArr.map((childNode) => {
|
||||
const isSelected = childNode.isSelectable && scopeNames.includes(childNode.linkId!);
|
||||
|
||||
if (anyChildExpanded && !childNode.isExpanded && !isSelected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const childNodePath = [...nodePath, childNode.name];
|
||||
|
||||
const radioName = childNodePath.join('.');
|
||||
|
||||
return (
|
||||
<div key={childNode.name} role="treeitem" aria-selected={childNode.isExpanded}>
|
||||
<div className={styles.itemTitle}>
|
||||
{childNode.isSelectable && !childNode.isExpanded ? (
|
||||
node.disableMultiSelect ? (
|
||||
<RadioButtonDot
|
||||
id={radioName}
|
||||
name={radioName}
|
||||
checked={isSelected}
|
||||
label=""
|
||||
data-testid={`scopes-tree-${childNode.name}-radio`}
|
||||
onClick={() => {
|
||||
onNodeSelectToggle(childNodePath);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
data-testid={`scopes-tree-${childNode.name}-checkbox`}
|
||||
onChange={() => {
|
||||
onNodeSelectToggle(childNodePath);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{childNode.isExpandable ? (
|
||||
<button
|
||||
className={styles.itemExpand}
|
||||
data-testid={`scopes-tree-${childNode.name}-expand`}
|
||||
aria-label={
|
||||
childNode.isExpanded ? t('scopes.tree.collapse', 'Collapse') : t('scopes.tree.expand', 'Expand')
|
||||
}
|
||||
onClick={() => {
|
||||
onNodeUpdate(childNodePath, !childNode.isExpanded, childNode.query);
|
||||
}}
|
||||
>
|
||||
<Icon name={!childNode.isExpanded ? 'angle-right' : 'angle-down'} />
|
||||
|
||||
{childNode.title}
|
||||
</button>
|
||||
) : (
|
||||
<span data-testid={`scopes-tree-${childNode.name}-title`}>{childNode.title}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.itemChildren}>
|
||||
{childNode.isExpanded && (
|
||||
<ScopesTreeLevel
|
||||
nodes={node.nodes}
|
||||
nodePath={childNodePath}
|
||||
loadingNodeName={loadingNodeName}
|
||||
scopes={scopes}
|
||||
onNodeUpdate={onNodeUpdate}
|
||||
onNodeSelectToggle={onNodeSelectToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
searchInput: css({
|
||||
margin: theme.spacing(1, 0),
|
||||
}),
|
||||
headline: css({
|
||||
color: theme.colors.text.secondary,
|
||||
margin: theme.spacing(1, 0),
|
||||
}),
|
||||
loader: css({
|
||||
margin: theme.spacing(0.5, 0),
|
||||
}),
|
||||
itemTitle: css({
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
gap: theme.spacing(1),
|
||||
fontSize: theme.typography.pxToRem(14),
|
||||
lineHeight: theme.typography.pxToRem(22),
|
||||
padding: theme.spacing(0.5, 0),
|
||||
|
||||
'& > label': css({
|
||||
gap: 0,
|
||||
}),
|
||||
}),
|
||||
itemExpand: css({
|
||||
alignItems: 'center',
|
||||
background: 'none',
|
||||
border: 0,
|
||||
display: 'flex',
|
||||
gap: theme.spacing(1),
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}),
|
||||
itemChildren: css({
|
||||
paddingLeft: theme.spacing(4),
|
||||
}),
|
||||
};
|
||||
};
|
@ -0,0 +1,29 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { ReactNode } from 'react';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
export interface ScopesTreeLoadingProps {
|
||||
children: ReactNode;
|
||||
isNodeLoading: boolean;
|
||||
}
|
||||
|
||||
export function ScopesTreeLoading({ children, isNodeLoading }: ScopesTreeLoadingProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
if (isNodeLoading) {
|
||||
return <Skeleton count={5} className={styles.loader} />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
loader: css({
|
||||
margin: theme.spacing(0.5, 0),
|
||||
}),
|
||||
};
|
||||
};
|
@ -0,0 +1,53 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { debounce } from 'lodash';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { FilterInput, useStyles2 } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { OnNodeUpdate } from './types';
|
||||
|
||||
export interface ScopesTreeSearchProps {
|
||||
anyChildExpanded: boolean;
|
||||
nodePath: string[];
|
||||
query: string;
|
||||
onNodeUpdate: OnNodeUpdate;
|
||||
}
|
||||
|
||||
export function ScopesTreeSearch({ anyChildExpanded, nodePath, query, onNodeUpdate }: ScopesTreeSearchProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const [queryValue, setQueryValue] = useState(query);
|
||||
|
||||
useEffect(() => {
|
||||
setQueryValue(query);
|
||||
}, [query]);
|
||||
|
||||
const onQueryUpdate = useMemo(() => debounce(onNodeUpdate, 500), [onNodeUpdate]);
|
||||
|
||||
if (anyChildExpanded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterInput
|
||||
placeholder={t('scopes.tree.search', 'Search')}
|
||||
value={queryValue}
|
||||
className={styles.input}
|
||||
data-testid="scopes-tree-search"
|
||||
onChange={(value) => {
|
||||
setQueryValue(value);
|
||||
onQueryUpdate(nodePath, true, value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
input: css({
|
||||
margin: theme.spacing(1, 0),
|
||||
}),
|
||||
};
|
||||
};
|
@ -1,8 +1,8 @@
|
||||
import { Scope, ScopeSpec, ScopeNode, ScopeDashboardBinding } from '@grafana/data';
|
||||
import { Scope, ScopeDashboardBinding, ScopeNode, ScopeSpec } from '@grafana/data';
|
||||
import { config, getBackendSrv } from '@grafana/runtime';
|
||||
import { ScopedResourceClient } from 'app/features/apiserver/client';
|
||||
|
||||
import { NodesMap, SelectedScope, SuggestedDashboard, TreeScope } from './types';
|
||||
import { NodeReason, NodesMap, SelectedScope, SuggestedDashboard, TreeScope } from './types';
|
||||
import { getBasicScope, mergeScopes } from './utils';
|
||||
|
||||
const group = 'scope.grafana.app';
|
||||
@ -37,6 +37,7 @@ export async function fetchNodes(parent: string, query: string): Promise<NodesMa
|
||||
isSelectable: spec.linkType === 'scope',
|
||||
isExpanded: false,
|
||||
query: '',
|
||||
reason: NodeReason.Result,
|
||||
nodes: {},
|
||||
};
|
||||
return acc;
|
||||
|
@ -314,11 +314,12 @@ export const getMock = jest
|
||||
|
||||
const selectors = {
|
||||
tree: {
|
||||
search: (nodeId: string) => `scopes-tree-${nodeId}-search`,
|
||||
select: (nodeId: string) => `scopes-tree-${nodeId}-checkbox`,
|
||||
radio: (nodeId: string) => `scopes-tree-${nodeId}-radio`,
|
||||
expand: (nodeId: string) => `scopes-tree-${nodeId}-expand`,
|
||||
title: (nodeId: string) => `scopes-tree-${nodeId}-title`,
|
||||
search: 'scopes-tree-search',
|
||||
headline: 'scopes-tree-headline',
|
||||
select: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-checkbox`,
|
||||
radio: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-radio`,
|
||||
expand: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-expand`,
|
||||
title: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-title`,
|
||||
},
|
||||
filters: {
|
||||
input: 'scopes-filters-input',
|
||||
@ -359,36 +360,50 @@ export const getNotFoundForScope = () => screen.getByTestId(selectors.dashboards
|
||||
export const getNotFoundForFilter = () => screen.getByTestId(selectors.dashboards.notFoundForFilter);
|
||||
export const getNotFoundForFilterClear = () => screen.getByTestId(selectors.dashboards.notFoundForFilterClear);
|
||||
|
||||
export const getApplicationsExpand = () => screen.getByTestId(selectors.tree.expand('applications'));
|
||||
export const getApplicationsSearch = () => screen.getByTestId<HTMLInputElement>(selectors.tree.search('applications'));
|
||||
export const queryApplicationsSlothPictureFactoryTitle = () =>
|
||||
screen.queryByTestId(selectors.tree.title('applications-slothPictureFactory'));
|
||||
export const getApplicationsSlothPictureFactoryTitle = () =>
|
||||
screen.getByTestId(selectors.tree.title('applications-slothPictureFactory'));
|
||||
export const getApplicationsSlothPictureFactorySelect = () =>
|
||||
screen.getByTestId(selectors.tree.select('applications-slothPictureFactory'));
|
||||
export const queryApplicationsSlothVoteTrackerTitle = () =>
|
||||
screen.queryByTestId(selectors.tree.title('applications-slothVoteTracker'));
|
||||
export const getApplicationsSlothVoteTrackerSelect = () =>
|
||||
screen.getByTestId(selectors.tree.select('applications-slothVoteTracker'));
|
||||
export const queryApplicationsClustersTitle = () => screen.queryByTestId(selectors.tree.title('applications.clusters'));
|
||||
export const getApplicationsClustersSelect = () => screen.getByTestId(selectors.tree.select('applications.clusters'));
|
||||
export const getApplicationsClustersExpand = () => screen.getByTestId(selectors.tree.expand('applications.clusters'));
|
||||
export const queryApplicationsClustersSlothClusterNorthTitle = () =>
|
||||
screen.queryByTestId(selectors.tree.title('applications.clusters-slothClusterNorth'));
|
||||
export const getApplicationsClustersSlothClusterNorthSelect = () =>
|
||||
screen.getByTestId(selectors.tree.select('applications.clusters-slothClusterNorth'));
|
||||
export const getApplicationsClustersSlothClusterSouthSelect = () =>
|
||||
screen.getByTestId(selectors.tree.select('applications.clusters-slothClusterSouth'));
|
||||
export const getTreeSearch = () => screen.getByTestId<HTMLInputElement>(selectors.tree.search);
|
||||
export const getTreeHeadline = () => screen.getByTestId(selectors.tree.headline);
|
||||
export const getResultApplicationsExpand = () => screen.getByTestId(selectors.tree.expand('applications', 'result'));
|
||||
export const queryResultApplicationsSlothPictureFactoryTitle = () =>
|
||||
screen.queryByTestId(selectors.tree.title('applications-slothPictureFactory', 'result'));
|
||||
export const getResultApplicationsSlothPictureFactoryTitle = () =>
|
||||
screen.getByTestId(selectors.tree.title('applications-slothPictureFactory', 'result'));
|
||||
export const getResultApplicationsSlothPictureFactorySelect = () =>
|
||||
screen.getByTestId(selectors.tree.select('applications-slothPictureFactory', 'result'));
|
||||
export const queryPersistedApplicationsSlothPictureFactoryTitle = () =>
|
||||
screen.queryByTestId(selectors.tree.title('applications-slothPictureFactory', 'persisted'));
|
||||
export const getPersistedApplicationsSlothPictureFactoryTitle = () =>
|
||||
screen.getByTestId(selectors.tree.title('applications-slothPictureFactory', 'persisted'));
|
||||
export const getPersistedApplicationsSlothPictureFactorySelect = () =>
|
||||
screen.getByTestId(selectors.tree.select('applications-slothPictureFactory', 'persisted'));
|
||||
export const queryResultApplicationsSlothVoteTrackerTitle = () =>
|
||||
screen.queryByTestId(selectors.tree.title('applications-slothVoteTracker', 'result'));
|
||||
export const getResultApplicationsSlothVoteTrackerTitle = () =>
|
||||
screen.getByTestId(selectors.tree.title('applications-slothVoteTracker', 'result'));
|
||||
export const getResultApplicationsSlothVoteTrackerSelect = () =>
|
||||
screen.getByTestId(selectors.tree.select('applications-slothVoteTracker', 'result'));
|
||||
export const queryPersistedApplicationsSlothVoteTrackerTitle = () =>
|
||||
screen.queryByTestId(selectors.tree.title('applications-slothVoteTracker', 'persisted'));
|
||||
export const getPersistedApplicationsSlothVoteTrackerTitle = () =>
|
||||
screen.getByTestId(selectors.tree.title('applications-slothVoteTracker', 'persisted'));
|
||||
export const queryResultApplicationsClustersTitle = () =>
|
||||
screen.queryByTestId(selectors.tree.title('applications.clusters', 'result'));
|
||||
export const getResultApplicationsClustersSelect = () =>
|
||||
screen.getByTestId(selectors.tree.select('applications.clusters', 'result'));
|
||||
export const getResultApplicationsClustersExpand = () =>
|
||||
screen.getByTestId(selectors.tree.expand('applications.clusters', 'result'));
|
||||
export const getResultApplicationsClustersSlothClusterNorthSelect = () =>
|
||||
screen.getByTestId(selectors.tree.select('applications.clusters-slothClusterNorth', 'result'));
|
||||
export const getResultApplicationsClustersSlothClusterSouthSelect = () =>
|
||||
screen.getByTestId(selectors.tree.select('applications.clusters-slothClusterSouth', 'result'));
|
||||
|
||||
export const getClustersSelect = () => screen.getByTestId(selectors.tree.select('clusters'));
|
||||
export const getClustersExpand = () => screen.getByTestId(selectors.tree.expand('clusters'));
|
||||
export const getClustersSlothClusterNorthRadio = () =>
|
||||
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('clusters-slothClusterNorth'));
|
||||
export const getClustersSlothClusterSouthRadio = () =>
|
||||
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('clusters-slothClusterSouth'));
|
||||
export const getClustersSlothClusterEastRadio = () =>
|
||||
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('clusters-slothClusterEast'));
|
||||
export const getResultClustersSelect = () => screen.getByTestId(selectors.tree.select('clusters', 'result'));
|
||||
export const getResultClustersExpand = () => screen.getByTestId(selectors.tree.expand('clusters', 'result'));
|
||||
export const getResultClustersSlothClusterNorthRadio = () =>
|
||||
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('clusters-slothClusterNorth', 'result'));
|
||||
export const getResultClustersSlothClusterSouthRadio = () =>
|
||||
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('clusters-slothClusterSouth', 'result'));
|
||||
export const getResultClustersSlothClusterEastRadio = () =>
|
||||
screen.getByTestId<HTMLInputElement>(selectors.tree.radio('clusters-slothClusterEast', 'result'));
|
||||
|
||||
export function buildTestScene(overrides: Partial<DashboardScene> = {}) {
|
||||
return new DashboardScene({
|
||||
|
@ -1,7 +1,13 @@
|
||||
import { Scope, ScopeDashboardBinding, ScopeNodeSpec } from '@grafana/data';
|
||||
|
||||
export enum NodeReason {
|
||||
Persisted,
|
||||
Result,
|
||||
}
|
||||
|
||||
export interface Node extends ScopeNodeSpec {
|
||||
name: string;
|
||||
reason: NodeReason;
|
||||
isExpandable: boolean;
|
||||
isSelectable: boolean;
|
||||
isExpanded: boolean;
|
||||
@ -26,3 +32,6 @@ export interface SuggestedDashboard {
|
||||
dashboardTitle: string;
|
||||
items: ScopeDashboardBinding[];
|
||||
}
|
||||
|
||||
export type OnNodeUpdate = (path: string[], isExpanded: boolean, query: string) => void;
|
||||
export type OnNodeSelectToggle = (path: string[]) => void;
|
||||
|
@ -8,7 +8,9 @@ import { contextSrv } from 'app/core/core';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { isPublicDashboardsEnabled } from '../../../dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
|
||||
import { getTrackingSource, shareDashboardType } from '../../../dashboard/components/ShareModal/utils';
|
||||
import { DashboardScene } from '../../scene/DashboardScene';
|
||||
import { DashboardInteractions } from '../../utils/interactions';
|
||||
import { ShareDrawer } from '../ShareDrawer/ShareDrawer';
|
||||
import { SceneShareDrawerState } from '../types';
|
||||
|
||||
@ -21,6 +23,7 @@ const newShareButtonSelector = e2eSelectors.pages.Dashboard.DashNav.newShareButt
|
||||
type CustomDashboardDrawer = new (...args: SceneShareDrawerState[]) => SceneObject;
|
||||
|
||||
export interface ShareDrawerMenuItem {
|
||||
shareId: string;
|
||||
testId: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
@ -52,6 +55,7 @@ export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardSc
|
||||
const menuItems: ShareDrawerMenuItem[] = [];
|
||||
|
||||
menuItems.push({
|
||||
shareId: shareDashboardType.link,
|
||||
testId: newShareButtonSelector.shareInternally,
|
||||
icon: 'building',
|
||||
label: t('share-dashboard.menu.share-internally-title', 'Share internally'),
|
||||
@ -62,6 +66,7 @@ export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardSc
|
||||
});
|
||||
|
||||
menuItems.push({
|
||||
shareId: shareDashboardType.publicDashboard,
|
||||
testId: newShareButtonSelector.shareExternally,
|
||||
icon: 'share-alt',
|
||||
label: t('share-dashboard.menu.share-externally-title', 'Share externally'),
|
||||
@ -74,6 +79,7 @@ export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardSc
|
||||
customShareDrawerItem.forEach((d) => menuItems.push(d));
|
||||
|
||||
menuItems.push({
|
||||
shareId: shareDashboardType.snapshot,
|
||||
testId: newShareButtonSelector.shareSnapshot,
|
||||
icon: 'camera',
|
||||
label: t('share-dashboard.menu.share-snapshot-title', 'Share snapshot'),
|
||||
@ -86,6 +92,15 @@ export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardSc
|
||||
return menuItems.filter((item) => item.renderCondition);
|
||||
}, [onMenuItemClick, dashboard, panel]);
|
||||
|
||||
const onClick = (item: ShareDrawerMenuItem) => {
|
||||
DashboardInteractions.sharingCategoryClicked({
|
||||
item: item.shareId,
|
||||
shareResource: getTrackingSource(panel?.getRef()),
|
||||
});
|
||||
|
||||
item.onClick(dashboard);
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu data-testid={newShareButtonSelector.container}>
|
||||
{buildMenuItems().map((item) => (
|
||||
@ -95,7 +110,7 @@ export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardSc
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
description={item.description}
|
||||
onClick={() => item.onClick(dashboard)}
|
||||
onClick={() => onClick(item)}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
|
@ -91,7 +91,10 @@ export class ShareModal extends SceneObjectBase<ShareModalState> implements Moda
|
||||
};
|
||||
|
||||
onChangeTab: ComponentProps<typeof ModalTabsHeader>['onChangeTab'] = (tab) => {
|
||||
DashboardInteractions.sharingTabChanged({ item: tab.value, shareResource: getTrackingSource(this.state.panelRef) });
|
||||
DashboardInteractions.sharingCategoryClicked({
|
||||
item: tab.value,
|
||||
shareResource: getTrackingSource(this.state.panelRef),
|
||||
});
|
||||
this.setState({ activeTab: tab.value });
|
||||
};
|
||||
}
|
||||
|
@ -44,7 +44,7 @@ export const DashboardInteractions = {
|
||||
},
|
||||
|
||||
// Sharing interactions:
|
||||
sharingTabChanged: (properties?: Record<string, unknown>) => {
|
||||
sharingCategoryClicked: (properties?: Record<string, unknown>) => {
|
||||
reportDashboardInteraction('sharing_category_clicked', properties);
|
||||
},
|
||||
shareLinkCopied: (properties?: Record<string, unknown>) => {
|
||||
|
@ -102,7 +102,7 @@ class UnthemedShareModal extends React.Component<Props, State> {
|
||||
|
||||
onSelectTab: React.ComponentProps<typeof ModalTabsHeader>['onChangeTab'] = (t) => {
|
||||
this.setState((prevState) => ({ ...prevState, activeTab: t.value }));
|
||||
DashboardInteractions.sharingTabChanged({
|
||||
DashboardInteractions.sharingCategoryClicked({
|
||||
item: t.value,
|
||||
shareResource: getTrackingSource(this.props.panel),
|
||||
});
|
||||
|
@ -353,11 +353,11 @@ describe('SharePublic - Report interactions', () => {
|
||||
});
|
||||
|
||||
it('reports interaction when public dashboard tab is clicked', async () => {
|
||||
jest.spyOn(DashboardInteractions, 'sharingCategoryClicked');
|
||||
await renderSharePublicDashboard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(DashboardInteractions.sharingTabChanged).toHaveBeenCalledTimes(1);
|
||||
expect(DashboardInteractions.sharingTabChanged).lastCalledWith({
|
||||
expect(DashboardInteractions.sharingCategoryClicked).lastCalledWith({
|
||||
item: shareDashboardType.publicDashboard,
|
||||
shareResource: 'dashboard',
|
||||
});
|
||||
@ -374,7 +374,6 @@ describe('SharePublic - Report interactions', () => {
|
||||
await userEvent.click(screen.getByTestId(selectors.EnableTimeRangeSwitch));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(reportInteraction).toHaveBeenCalledTimes(1);
|
||||
expect(reportInteraction).toHaveBeenLastCalledWith('dashboards_sharing_public_time_picker_clicked', {
|
||||
enabled: !pubdashResponse.timeSelectionEnabled,
|
||||
});
|
||||
@ -391,7 +390,6 @@ describe('SharePublic - Report interactions', () => {
|
||||
await userEvent.click(screen.getByTestId(selectors.EnableAnnotationsSwitch));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(reportInteraction).toHaveBeenCalledTimes(1);
|
||||
expect(reportInteraction).toHaveBeenLastCalledWith('dashboards_sharing_public_annotations_clicked', {
|
||||
enabled: !pubdashResponse.annotationsEnabled,
|
||||
});
|
||||
@ -405,7 +403,6 @@ describe('SharePublic - Report interactions', () => {
|
||||
await userEvent.click(screen.getByTestId(selectors.PauseSwitch));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(reportInteraction).toHaveBeenCalledTimes(1);
|
||||
expect(reportInteraction).toHaveBeenLastCalledWith('dashboards_sharing_public_pause_clicked', {
|
||||
paused: pubdashResponse.isEnabled,
|
||||
});
|
||||
|
@ -340,7 +340,7 @@ export const useGetConfigurationForUI = ({
|
||||
function getConnectDataSourceConfiguration() {
|
||||
const description = dataSourceCompatibleWithAlerting
|
||||
? 'You have connected a datasource.'
|
||||
: 'Connect at least one data source to start receiving data.';
|
||||
: 'Connect at least one data source to start receiving data';
|
||||
const actionButtonTitle = dataSourceCompatibleWithAlerting ? 'View' : 'Connect';
|
||||
return {
|
||||
id: ConfigurationStepsEnum.CONNECT_DATASOURCE,
|
||||
@ -356,8 +356,8 @@ export const useGetConfigurationForUI = ({
|
||||
id: ConfigurationStepsEnum.ESSENTIALS,
|
||||
title: 'Essentials',
|
||||
titleIcon: 'star',
|
||||
description: 'Configure the features you need to start using Grafana IRM workflows',
|
||||
actionButtonTitle: 'Start',
|
||||
description: 'Set up the necessary features to start using Grafana IRM workflows',
|
||||
actionButtonTitle: stepsDone === totalStepsToDo ? 'View' : 'Configure',
|
||||
stepsDone,
|
||||
totalStepsToDo,
|
||||
},
|
||||
|
@ -10,7 +10,6 @@
|
||||
"alerting": true,
|
||||
"annotations": true,
|
||||
"backend": true,
|
||||
"apiVersion": "v0alpha1",
|
||||
|
||||
"queryOptions": {
|
||||
"minInterval": true,
|
||||
|
@ -1698,7 +1698,11 @@
|
||||
"tree": {
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand",
|
||||
"headline": "Recommended",
|
||||
"headline": {
|
||||
"noResults": "No results found for your query",
|
||||
"recommended": "Recommended",
|
||||
"results": "Results"
|
||||
},
|
||||
"search": "Search"
|
||||
}
|
||||
},
|
||||
|
@ -1698,7 +1698,11 @@
|
||||
"tree": {
|
||||
"collapse": "Cőľľäpşę",
|
||||
"expand": "Ēχpäʼnđ",
|
||||
"headline": "Ŗęčőmmęʼnđęđ",
|
||||
"headline": {
|
||||
"noResults": "Ńő řęşūľŧş ƒőūʼnđ ƒőř yőūř qūęřy",
|
||||
"recommended": "Ŗęčőmmęʼnđęđ",
|
||||
"results": "Ŗęşūľŧş"
|
||||
},
|
||||
"search": "Ŝęäřčĥ"
|
||||
}
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user